@agentsdock/server 1.0.7-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,3030 @@
1
+ """Persistent, multiplexed JSON-RPC client for ``codex app-server``.
2
+
3
+ The app-server protocol is bidirectional JSONL over stdio. One client owns
4
+ one lazily started process and can carry multiple Codex threads concurrently.
5
+ Transport failures are deliberately surfaced to callers; this module never
6
+ replays a request whose delivery may have succeeded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ import json
14
+ import os
15
+ import signal
16
+ import time
17
+ from collections import deque
18
+ from contextlib import suppress
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Awaitable, Callable, Sequence
21
+
22
+
23
+ class CodexAppServerError(RuntimeError):
24
+ """Base error raised by the app-server transport."""
25
+
26
+ def __init__(
27
+ self,
28
+ message: str,
29
+ *,
30
+ request_sent: bool = False,
31
+ safe_to_retry: bool = False,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.request_sent = request_sent
35
+ self.safe_to_retry = safe_to_retry
36
+ # ``start_turn`` attaches its provisional routed handle here when the
37
+ # request outcome is ambiguous. Callers can keep observing the thread
38
+ # instead of replaying the user message.
39
+ self.pending_turn: CodexAppServerTurn | None = None
40
+
41
+
42
+ class CodexAppServerDisconnected(CodexAppServerError):
43
+ """The shared app-server transport closed unexpectedly."""
44
+
45
+ def __init__(
46
+ self,
47
+ message: str,
48
+ *,
49
+ request_sent: bool = False,
50
+ safe_to_retry: bool = False,
51
+ planned: bool = False,
52
+ ) -> None:
53
+ super().__init__(
54
+ message,
55
+ request_sent=request_sent,
56
+ safe_to_retry=safe_to_retry,
57
+ )
58
+ self.planned = planned
59
+
60
+
61
+ class CodexAppServerTimeout(CodexAppServerError):
62
+ """A request exceeded its deadline before or after transport delivery."""
63
+
64
+ def __init__(
65
+ self,
66
+ method: str,
67
+ timeout: float,
68
+ *,
69
+ request_sent: bool = True,
70
+ ) -> None:
71
+ self.method = method
72
+ self.timeout = timeout
73
+ super().__init__(
74
+ f"{method} timed out after {timeout:g}s",
75
+ request_sent=request_sent,
76
+ safe_to_retry=not request_sent,
77
+ )
78
+
79
+
80
+ class CodexAppServerRequestError(CodexAppServerError):
81
+ """A JSON-RPC request was explicitly rejected by app-server."""
82
+
83
+ def __init__(self, method: str, error: Any) -> None:
84
+ self.method = method
85
+ self.error = error
86
+ self.code = error.get("code") if isinstance(error, dict) else None
87
+ self.data = error.get("data") if isinstance(error, dict) else None
88
+ if isinstance(error, dict):
89
+ message = str(error.get("message") or error)
90
+ else:
91
+ message = str(error)
92
+ super().__init__(
93
+ f"{method} failed: {message}",
94
+ request_sent=True,
95
+ safe_to_retry=True,
96
+ )
97
+
98
+
99
+ class CodexAppServerProtocolError(CodexAppServerError):
100
+ """App-server returned a response that violated the expected contract."""
101
+
102
+
103
+ class CodexAppServerSubscriptionClosed(CodexAppServerError):
104
+ """A local notification subscription is no longer active."""
105
+
106
+
107
+ ProcessFactory = Callable[..., Awaitable[asyncio.subprocess.Process]]
108
+ NotificationHandler = Callable[[dict[str, Any]], Awaitable[None] | None]
109
+ ServerRequestHandler = Callable[
110
+ [Any, str, dict[str, Any]],
111
+ Awaitable[dict[str, Any]] | dict[str, Any],
112
+ ]
113
+ # ``(pid, process_group_id)``; the group id is None when it is not provably
114
+ # owned by the client (injected factories, non-POSIX hosts).
115
+ ProcessLifecycleHook = Callable[[int, int | None], None]
116
+
117
+ _THREAD_GOAL_STATUSES = frozenset(
118
+ {
119
+ "active",
120
+ "paused",
121
+ "blocked",
122
+ "usageLimited",
123
+ "budgetLimited",
124
+ "complete",
125
+ }
126
+ )
127
+ _TURN_STATUSES = frozenset({"completed", "interrupted", "failed", "inProgress"})
128
+ _REVIEW_DELIVERIES = frozenset({"inline", "detached"})
129
+ _REVIEW_TARGET_TYPES = frozenset(
130
+ {"uncommittedChanges", "baseBranch", "commit", "custom"}
131
+ )
132
+
133
+
134
+ class _OmittedType:
135
+ """Sentinel that distinguishes an omitted optional field from JSON null."""
136
+
137
+
138
+ _OMITTED = _OmittedType()
139
+
140
+
141
+ def _require_nonempty_string(value: Any, field: str) -> str:
142
+ if not isinstance(value, str) or not value.strip():
143
+ raise ValueError(f"{field} must be a non-empty string")
144
+ return value
145
+
146
+
147
+ def _protocol_object(method: str, result: Any) -> dict[str, Any]:
148
+ if not isinstance(result, dict):
149
+ raise CodexAppServerProtocolError(
150
+ f"{method} did not return an object",
151
+ request_sent=True,
152
+ safe_to_retry=False,
153
+ )
154
+ return result
155
+
156
+
157
+ def _protocol_empty_object(method: str, result: Any) -> None:
158
+ _protocol_object(method, result)
159
+
160
+
161
+ def _protocol_cursor(method: str, result: dict[str, Any]) -> str | None:
162
+ cursor = result.get("nextCursor")
163
+ if cursor is not None and not isinstance(cursor, str):
164
+ raise CodexAppServerProtocolError(
165
+ f"{method} returned an invalid next cursor",
166
+ request_sent=True,
167
+ safe_to_retry=False,
168
+ )
169
+ return cursor
170
+
171
+
172
+ def _protocol_thread_goal(
173
+ method: str,
174
+ goal: Any,
175
+ *,
176
+ thread_id: str,
177
+ ) -> dict[str, Any]:
178
+ if not isinstance(goal, dict):
179
+ raise CodexAppServerProtocolError(
180
+ f"{method} did not return a goal object",
181
+ request_sent=True,
182
+ safe_to_retry=False,
183
+ )
184
+ if goal.get("threadId") != thread_id:
185
+ raise CodexAppServerProtocolError(
186
+ f"{method} returned a goal for a different thread",
187
+ request_sent=True,
188
+ safe_to_retry=False,
189
+ )
190
+ if not isinstance(goal.get("objective"), str) or not goal["objective"]:
191
+ raise CodexAppServerProtocolError(
192
+ f"{method} returned an invalid goal objective",
193
+ request_sent=True,
194
+ safe_to_retry=False,
195
+ )
196
+ if goal.get("status") not in _THREAD_GOAL_STATUSES:
197
+ raise CodexAppServerProtocolError(
198
+ f"{method} returned an invalid goal status",
199
+ request_sent=True,
200
+ safe_to_retry=False,
201
+ )
202
+ for field in (
203
+ "createdAt",
204
+ "updatedAt",
205
+ "tokensUsed",
206
+ "timeUsedSeconds",
207
+ ):
208
+ value = goal.get(field)
209
+ if isinstance(value, bool) or not isinstance(value, int):
210
+ raise CodexAppServerProtocolError(
211
+ f"{method} returned an invalid goal {field}",
212
+ request_sent=True,
213
+ safe_to_retry=False,
214
+ )
215
+ token_budget = goal.get("tokenBudget")
216
+ if (
217
+ token_budget is not None
218
+ and (isinstance(token_budget, bool) or not isinstance(token_budget, int))
219
+ ):
220
+ raise CodexAppServerProtocolError(
221
+ f"{method} returned an invalid goal tokenBudget",
222
+ request_sent=True,
223
+ safe_to_retry=False,
224
+ )
225
+ return dict(goal)
226
+
227
+
228
+ def _protocol_turn(method: str, turn: Any) -> dict[str, Any]:
229
+ if not isinstance(turn, dict) or not isinstance(turn.get("id"), str) or not turn["id"]:
230
+ raise CodexAppServerProtocolError(
231
+ f"{method} did not return a valid turn",
232
+ request_sent=True,
233
+ safe_to_retry=False,
234
+ )
235
+ if turn.get("status") not in _TURN_STATUSES or not isinstance(
236
+ turn.get("items"), list
237
+ ):
238
+ raise CodexAppServerProtocolError(
239
+ f"{method} returned an invalid turn payload",
240
+ request_sent=True,
241
+ safe_to_retry=False,
242
+ )
243
+ return dict(turn)
244
+
245
+
246
+ def _notification_scope(notification: dict[str, Any]) -> tuple[str, str]:
247
+ params = notification.get("params")
248
+ if not isinstance(params, dict):
249
+ return "", ""
250
+
251
+ thread_id = str(params.get("threadId") or "")
252
+ turn_id = str(params.get("turnId") or "")
253
+
254
+ thread = params.get("thread")
255
+ if not thread_id and isinstance(thread, dict):
256
+ thread_id = str(thread.get("id") or "")
257
+
258
+ turn = params.get("turn")
259
+ if not turn_id and isinstance(turn, dict):
260
+ turn_id = str(turn.get("id") or "")
261
+
262
+ return thread_id, turn_id
263
+
264
+
265
+ @dataclass(eq=False, slots=True)
266
+ class CodexAppServerSubscription:
267
+ """A local filtered view of app-server notifications."""
268
+
269
+ client: "CodexAppServerClient"
270
+ thread_id: str | None
271
+ turn_id: str | None
272
+ _queue: asyncio.Queue[Any]
273
+ _closed: bool = False
274
+ _last_enqueued_sequence: int = 0
275
+
276
+ def _matches(self, notification: dict[str, Any]) -> bool:
277
+ notification_thread_id, notification_turn_id = _notification_scope(notification)
278
+ if self.thread_id is not None and notification_thread_id != self.thread_id:
279
+ return False
280
+ if self.turn_id is not None and notification_turn_id != self.turn_id:
281
+ return False
282
+ return True
283
+
284
+ def _put(self, value: Any) -> None:
285
+ if self._closed:
286
+ return
287
+ try:
288
+ self._last_enqueued_sequence += 1
289
+ sequence = self._last_enqueued_sequence
290
+ self._queue.put_nowait((sequence, value))
291
+ except asyncio.QueueFull:
292
+ self._finish(
293
+ CodexAppServerDisconnected(
294
+ "app-server notification backlog exceeded its safety limit",
295
+ request_sent=True,
296
+ safe_to_retry=False,
297
+ )
298
+ )
299
+
300
+ def _finish(self, error: BaseException | None = None) -> None:
301
+ if self._closed:
302
+ return
303
+ if error is not None:
304
+ while not self._queue.empty():
305
+ with suppress(asyncio.QueueEmpty):
306
+ self._queue.get_nowait()
307
+ self._queue.put_nowait(error)
308
+ self._closed = True
309
+ self.client._release_subscription(self)
310
+
311
+ async def next_notification_with_sequence(
312
+ self,
313
+ timeout: float | None = None,
314
+ ) -> tuple[int, dict[str, Any]]:
315
+ if self._closed and self._queue.empty():
316
+ raise CodexAppServerSubscriptionClosed("app-server notification subscription closed")
317
+ waiter = self._queue.get()
318
+ value = await (asyncio.wait_for(waiter, timeout) if timeout is not None else waiter)
319
+ if isinstance(value, BaseException):
320
+ raise value
321
+ if (
322
+ not isinstance(value, tuple)
323
+ or len(value) != 2
324
+ or not isinstance(value[0], int)
325
+ or not isinstance(value[1], dict)
326
+ ):
327
+ raise CodexAppServerProtocolError("app-server notification was not an object")
328
+ return value
329
+
330
+ async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
331
+ _sequence, notification = await self.next_notification_with_sequence(timeout)
332
+ return notification
333
+
334
+ def close(self) -> None:
335
+ self._finish()
336
+
337
+
338
+ @dataclass(eq=False, slots=True)
339
+ class CodexAppServerTurn:
340
+ """An accepted app-server turn and its routed notification stream."""
341
+
342
+ client: "CodexAppServerClient"
343
+ thread_id: str
344
+ turn_id: str
345
+ _subscription: CodexAppServerSubscription
346
+ # Set at the exact stdin write boundary. A caller cancelled after delivery
347
+ # must retire this generation, not the one observed before lazy start/init.
348
+ transport_generation: int = 0
349
+ _closed: bool = False
350
+ _completed: bool = False
351
+ # Goal owners can keep the routed stream after this initial turn ends.
352
+ # The caller still closes the handle when it releases the thread owner.
353
+ _retain_thread_stream: bool = False
354
+ # Native turn identities are retained only for this owner's lifetime.
355
+ # Delayed duplicate starts must not resurrect one of its finished turns.
356
+ _completed_turn_ids: set[str] = field(default_factory=set)
357
+
358
+ async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
359
+ return await self._subscription.next_notification(timeout)
360
+
361
+ async def next_notification_with_sequence(
362
+ self,
363
+ timeout: float | None = None,
364
+ ) -> tuple[int, dict[str, Any]]:
365
+ return await self._subscription.next_notification_with_sequence(timeout)
366
+
367
+ async def steer(
368
+ self,
369
+ input_items: list[dict[str, Any]],
370
+ *,
371
+ client_user_message_id: str | None = None,
372
+ ) -> str:
373
+ return await self.client.steer_turn(
374
+ self.thread_id,
375
+ self.turn_id,
376
+ input_items,
377
+ client_user_message_id=client_user_message_id,
378
+ )
379
+
380
+ async def steer_with_notification_watermark(
381
+ self,
382
+ input_items: list[dict[str, Any]],
383
+ *,
384
+ client_user_message_id: str | None = None,
385
+ ) -> tuple[str, int]:
386
+ return await self.client.steer_turn_with_notification_watermark(
387
+ self.thread_id,
388
+ self.turn_id,
389
+ input_items,
390
+ client_user_message_id=client_user_message_id,
391
+ notification_subscription=self._subscription,
392
+ )
393
+
394
+ async def interrupt(self) -> None:
395
+ await self.client.interrupt_turn(self.thread_id, self.turn_id)
396
+
397
+ async def continue_after_subagents(
398
+ self,
399
+ *,
400
+ before_send: Callable[[], bool],
401
+ client_user_message_id: str | None = None,
402
+ responsesapi_client_metadata: dict[str, str] | None = None,
403
+ ) -> str | None:
404
+ """Collect native child notifications without inserting a user message.
405
+
406
+ The runner establishes child ownership and attempts this once per
407
+ completed native turn. A spontaneous continuation or Stop wins at the
408
+ stdin boundary and returns None without sending anything. An ambiguous
409
+ delivery raises without closing this stream or replaying the request.
410
+ """
411
+ completed_id = self.turn_id
412
+ generation = self.transport_generation
413
+ guard_lost = False
414
+ delivery_attempted = False
415
+
416
+ def owns_completed_turn() -> bool:
417
+ nonlocal guard_lost
418
+ allowed = (
419
+ self._retain_thread_stream
420
+ and not self._closed
421
+ and self._completed
422
+ and bool(completed_id)
423
+ and self.turn_id == completed_id
424
+ and completed_id in self._completed_turn_ids
425
+ and generation == self.client.generation
426
+ and self.client._turns_by_thread.get(self.thread_id) is self
427
+ and before_send() is True
428
+ )
429
+ guard_lost = not allowed
430
+ return allowed
431
+
432
+ if not owns_completed_turn():
433
+ return None
434
+ params: dict[str, Any] = {"threadId": self.thread_id, "input": []}
435
+ if client_user_message_id is not None:
436
+ params["clientUserMessageId"] = _require_nonempty_string(
437
+ client_user_message_id, "client_user_message_id"
438
+ )
439
+ if responsesapi_client_metadata is not None:
440
+ params["responsesapiClientMetadata"] = dict(responsesapi_client_metadata)
441
+
442
+ def write_guard() -> bool:
443
+ nonlocal delivery_attempted
444
+ # _send performs stdin.write immediately after this guard, with no
445
+ # intervening await. Cancellation before the lock sends no bytes;
446
+ # cancellation after this point must reconcile native acceptance.
447
+ delivery_attempted = owns_completed_turn()
448
+ return delivery_attempted
449
+
450
+ try:
451
+ result = await self.client._request_connected(
452
+ "turn/start", params, before_send=write_guard,
453
+ transport_turn=self,
454
+ )
455
+ except asyncio.CancelledError as exc:
456
+ exc.request_sent = delivery_attempted
457
+ exc.pending_turn = self if delivery_attempted else None
458
+ raise
459
+ except CodexAppServerProtocolError as exc:
460
+ if guard_lost and not exc.request_sent:
461
+ return None
462
+ raise
463
+ turn = result.get("turn") if isinstance(result, dict) else None
464
+ accepted_id = turn.get("id") if isinstance(turn, dict) else None
465
+ if not isinstance(accepted_id, str) or not accepted_id or accepted_id == completed_id:
466
+ raise CodexAppServerProtocolError(
467
+ "child continuation did not return a new turn id",
468
+ request_sent=True, safe_to_retry=False,
469
+ )
470
+ if (
471
+ self._closed
472
+ or generation != self.client.generation
473
+ or self.client._turns_by_thread.get(self.thread_id) is not self
474
+ ):
475
+ raise CodexAppServerProtocolError(
476
+ "child continuation owner changed after delivery",
477
+ request_sent=True, safe_to_retry=False,
478
+ )
479
+ if self.turn_id == completed_id:
480
+ self.turn_id = accepted_id
481
+ self._completed = False
482
+ elif self.turn_id != accepted_id and accepted_id not in self._completed_turn_ids:
483
+ raise CodexAppServerProtocolError(
484
+ "child continuation response did not match its early notifications",
485
+ request_sent=True, safe_to_retry=False,
486
+ )
487
+ # If B completed (and possibly C started) before acknowledgement B,
488
+ # retain the receive-order state rather than reviving B here.
489
+ return accepted_id
490
+
491
+ def adopt_turn_id(self, turn_id: str) -> None:
492
+ """Bind a provisional turn after an ambiguous ``turn/start`` response."""
493
+ resolved = str(turn_id or "")
494
+ if not resolved:
495
+ raise CodexAppServerProtocolError("cannot bind an empty app-server turn id")
496
+ if self.turn_id and self.turn_id != resolved:
497
+ raise CodexAppServerProtocolError(
498
+ f"provisional turn already bound to {self.turn_id}, not {resolved}"
499
+ )
500
+ self.turn_id = resolved
501
+ if not self._retain_thread_stream:
502
+ self._subscription.turn_id = resolved
503
+
504
+ async def close(self) -> None:
505
+ if self._closed:
506
+ return
507
+ self._closed = True
508
+ self.client.release_turn(self)
509
+
510
+
511
+ async def decline_server_request(
512
+ _request_id: Any,
513
+ method: str,
514
+ params: dict[str, Any],
515
+ ) -> dict[str, Any]:
516
+ """Return the least-privileged valid response for interactive requests.
517
+
518
+ AgentsServer launches Codex with non-interactive approval settings. If a
519
+ request nevertheless reaches this connection, silently approving it would
520
+ be unsafe and waiting forever would wedge the turn.
521
+ """
522
+
523
+ if method in {
524
+ "item/commandExecution/requestApproval",
525
+ "item/fileChange/requestApproval",
526
+ }:
527
+ return {"decision": "decline"}
528
+ if method in {"applyPatchApproval", "execCommandApproval"}:
529
+ return {"decision": "denied"}
530
+ if method == "item/tool/requestUserInput":
531
+ return {"answers": {}}
532
+ if method == "mcpServer/elicitation/request":
533
+ return {"action": "decline"}
534
+ if method == "item/permissions/requestApproval":
535
+ return {"permissions": {}, "scope": "turn", "strictAutoReview": False}
536
+ raise CodexAppServerRequestError(
537
+ method,
538
+ {
539
+ "code": -32601,
540
+ "message": f"AgentsServer does not handle server request {method}",
541
+ },
542
+ )
543
+
544
+
545
+ class CodexAppServerClient:
546
+ """Supervise one local app-server process and multiplex active threads."""
547
+
548
+ def __init__(
549
+ self,
550
+ codex_bin: str,
551
+ *,
552
+ cwd: str,
553
+ env_factory: Callable[[], dict[str, str]],
554
+ app_server_args: Sequence[str] = (),
555
+ request_timeout: float = 30.0,
556
+ lifecycle_timeout: float = 300.0,
557
+ process_exit_timeout: float = 1.0,
558
+ process_stream_limit: int = 16 * 1024 * 1024,
559
+ notification_queue_limit: int = 8192,
560
+ json_parse_thread_threshold: int = 1024 * 1024,
561
+ fork_cleanup_grace: float = 30.0,
562
+ process_factory: ProcessFactory | None = None,
563
+ before_start: Callable[[], Awaitable[Any]] | None = None,
564
+ server_request_handler: ServerRequestHandler | None = None,
565
+ initialize_params: dict[str, Any] | None = None,
566
+ on_process_started: ProcessLifecycleHook | None = None,
567
+ on_process_exited: ProcessLifecycleHook | None = None,
568
+ sensitive_values: Sequence[str] = (),
569
+ protected_env_keys: Sequence[str] = (),
570
+ ) -> None:
571
+ self.codex_bin = codex_bin
572
+ self.cwd = cwd
573
+ self.env_factory = env_factory
574
+ self._before_start = before_start
575
+ self._sensitive_values = tuple(value for value in sensitive_values if isinstance(value, str) and value)
576
+ self._protected_env_keys = tuple(protected_env_keys)
577
+ # Lifecycle hooks receive ``(pid, process_group_id)``. The group id is
578
+ # None whenever start() could not prove it owns the child's session,
579
+ # so a hook must never derive a signal target from the pid alone.
580
+ self._on_process_started = on_process_started
581
+ self._on_process_exited = on_process_exited
582
+ self.app_server_args = tuple(str(value) for value in app_server_args)
583
+ self.request_timeout = request_timeout
584
+ self.lifecycle_timeout = lifecycle_timeout
585
+ self.process_exit_timeout = max(0.0, float(process_exit_timeout))
586
+ self.process_stream_limit = process_stream_limit
587
+ self.notification_queue_limit = max(1, int(notification_queue_limit))
588
+ self.json_parse_thread_threshold = max(
589
+ 0,
590
+ int(json_parse_thread_threshold),
591
+ )
592
+ # Fork creation itself may consume the full lifecycle timeout. Allow a
593
+ # substantial post-timeout window for the adjacent thread/started
594
+ # notification instead of relying on sub-second scheduling luck.
595
+ self.fork_cleanup_grace = max(0.0, float(fork_cleanup_grace))
596
+ self._process_factory = process_factory or asyncio.create_subprocess_exec
597
+ # Only the default launcher proves that ``start_new_session=True`` was
598
+ # honored by the process we own. Tests and injected factories may use
599
+ # arbitrary/recycled PIDs, so never derive a signal target from them.
600
+ self._owns_spawned_process_groups = process_factory is None and os.name == "posix"
601
+ self._server_request_handler = server_request_handler or decline_server_request
602
+ self._initialize_params = initialize_params or {
603
+ "clientInfo": {
604
+ "name": "agents_server",
605
+ "title": "AgentsServer",
606
+ "version": "1",
607
+ },
608
+ "capabilities": {"experimentalApi": True},
609
+ }
610
+ self._proc: asyncio.subprocess.Process | None = None
611
+ self._process_group_id: int | None = None
612
+ self._reader_task: asyncio.Task[None] | None = None
613
+ self._stderr_task: asyncio.Task[None] | None = None
614
+ self._start_lock = asyncio.Lock()
615
+ self._write_lock = asyncio.Lock()
616
+ # A timed-out/cancelled thread/fork may still finish in app-server.
617
+ # Keep each source serialized through its late-notification cleanup so
618
+ # one watcher can never claim a later fork from the same source.
619
+ self._fork_source_locks: dict[str, asyncio.Lock] = {}
620
+ self._next_id = 0
621
+ self._pending: dict[
622
+ int,
623
+ tuple[
624
+ str,
625
+ asyncio.Future[Any],
626
+ CodexAppServerSubscription | None,
627
+ ],
628
+ ] = {}
629
+ self._turns_by_thread: dict[str, CodexAppServerTurn] = {}
630
+ self._loaded_threads: set[str] = set()
631
+ # Provider identity outlives subscription/load state for one app-server
632
+ # process. Fork cleanup uses this to distinguish a newly-created child
633
+ # from an existing child that another operation merely resumed.
634
+ self._known_thread_ids: set[str] = set()
635
+ self._thread_names: dict[str, str] = {}
636
+ self._subscriptions: set[CodexAppServerSubscription] = set()
637
+ self._notification_handlers: set[NotificationHandler] = set()
638
+ self._callback_tasks: set[asyncio.Task[Any]] = set()
639
+ self._callback_tails: dict[
640
+ tuple[NotificationHandler, str],
641
+ asyncio.Task[Any],
642
+ ] = {}
643
+ self._server_request_tasks: dict[Any, asyncio.Task[None]] = {}
644
+ self._stderr_tail: deque[str] = deque(maxlen=40)
645
+ # Native authentication can emit delayed diagnostics. Once credentials
646
+ # have crossed this transport, never retain unstructured stderr from it.
647
+ self._authentication_submitted = bool(self._sensitive_values)
648
+ # Diagnostic-only record of notifications _route_notification could
649
+ # not match to any live subscription (see its docstring) - these are
650
+ # otherwise dropped with no trace, which is exactly what makes a
651
+ # stalled turn look like it hung for no reason.
652
+ self._unmatched_notifications: deque[dict[str, Any]] = deque(maxlen=40)
653
+ self._closing = False
654
+ self._initialized = False
655
+ self._initialize_result: dict[str, Any] | None = None
656
+ self._generation = 0
657
+
658
+ @property
659
+ def ready(self) -> bool:
660
+ return bool(
661
+ self._initialized
662
+ and self._proc is not None
663
+ and self._proc.returncode is None
664
+ and self._reader_task is not None
665
+ and not self._reader_task.done()
666
+ )
667
+
668
+ @property
669
+ def stderr_tail(self) -> list[str]:
670
+ return list(self._stderr_tail)
671
+
672
+ @property
673
+ def unmatched_notifications(self) -> list[dict[str, Any]]:
674
+ return list(self._unmatched_notifications)
675
+
676
+ @property
677
+ def process(self) -> asyncio.subprocess.Process | None:
678
+ """The supervised process, exposed read-only for diagnostics."""
679
+
680
+ return self._proc
681
+
682
+ @property
683
+ def generation(self) -> int:
684
+ """Incremented after every successful initialize handshake."""
685
+
686
+ return self._generation
687
+
688
+ @property
689
+ def initialize_result(self) -> dict[str, Any] | None:
690
+ return dict(self._initialize_result) if self._initialize_result is not None else None
691
+
692
+ def is_thread_loaded(self, thread_id: str) -> bool:
693
+ return thread_id in self._loaded_threads
694
+
695
+ def cached_thread_name(self, thread_id: str) -> str | None:
696
+ """Metadata already received from Codex; never starts a request."""
697
+ return self._thread_names.get(thread_id)
698
+
699
+ def _cache_thread_name(self, thread: Any) -> None:
700
+ if not isinstance(thread, dict):
701
+ return
702
+ thread_id, name = thread.get("id"), thread.get("name")
703
+ if not isinstance(thread_id, str) or not thread_id or len(thread_id) > 512:
704
+ return
705
+ if not isinstance(name, str) or not name.strip() or len(name) > 4096:
706
+ return
707
+ self._thread_names.pop(thread_id, None)
708
+ self._thread_names[thread_id] = name
709
+ if len(self._thread_names) > 1024:
710
+ self._thread_names.pop(next(iter(self._thread_names)))
711
+
712
+ async def start(self) -> None:
713
+ """Start and initialize the process once, or reuse the live process."""
714
+
715
+ if self.ready:
716
+ return
717
+ async with self._start_lock:
718
+ if self.ready:
719
+ return
720
+ # A caller may subscribe before the first lazy start. Preserve
721
+ # those local subscriptions when there is no old transport to
722
+ # discard.
723
+ if (
724
+ self._proc is not None
725
+ or self._reader_task is not None
726
+ or self._stderr_task is not None
727
+ or self._pending
728
+ ):
729
+ await self._discard_process()
730
+ self._closing = False
731
+ try:
732
+ if self._before_start is not None:
733
+ await self._before_start()
734
+ proc = await self._process_factory(
735
+ self.codex_bin,
736
+ "app-server",
737
+ *self.app_server_args,
738
+ "--listen",
739
+ "stdio://",
740
+ stdin=asyncio.subprocess.PIPE,
741
+ stdout=asyncio.subprocess.PIPE,
742
+ stderr=asyncio.subprocess.PIPE,
743
+ cwd=self.cwd,
744
+ env=self.env_factory(),
745
+ limit=self.process_stream_limit,
746
+ start_new_session=True,
747
+ )
748
+ except Exception as exc:
749
+ raise CodexAppServerDisconnected(
750
+ f"failed to start codex app-server: {exc}",
751
+ request_sent=False,
752
+ safe_to_retry=True,
753
+ ) from exc
754
+
755
+ raw_pid = getattr(proc, "pid", None)
756
+ self._process_group_id = (
757
+ raw_pid
758
+ if (
759
+ self._owns_spawned_process_groups
760
+ and isinstance(raw_pid, int)
761
+ and not isinstance(raw_pid, bool)
762
+ and raw_pid > 0
763
+ )
764
+ else None
765
+ )
766
+ self._proc = proc
767
+ if (
768
+ self._on_process_started is not None
769
+ and isinstance(raw_pid, int)
770
+ and not isinstance(raw_pid, bool)
771
+ and raw_pid > 0
772
+ ):
773
+ # Registry/bookkeeping hooks must never break a provider
774
+ # start; they exist so an orphaned child can be reaped later.
775
+ with suppress(Exception):
776
+ self._on_process_started(raw_pid, self._process_group_id)
777
+ self._reader_task = asyncio.create_task(
778
+ self._reader_loop(proc),
779
+ name="codex-app-server-reader",
780
+ )
781
+ self._stderr_task = asyncio.create_task(
782
+ self._stderr_loop(proc),
783
+ name="codex-app-server-stderr",
784
+ )
785
+ try:
786
+ result = await self._request_connected(
787
+ "initialize",
788
+ dict(self._initialize_params),
789
+ discard_on_send_timeout=False,
790
+ )
791
+ self._initialize_result = result if isinstance(result, dict) else {}
792
+ await self._send(
793
+ {"method": "initialized"},
794
+ discard_on_send_timeout=False,
795
+ )
796
+ self._initialized = True
797
+ self._generation += 1
798
+ except Exception:
799
+ await self._discard_process()
800
+ raise
801
+
802
+ async def close(self) -> None:
803
+ self._closing = True
804
+ await self._discard_process()
805
+
806
+ async def retire_generation(self, expected_generation: int) -> bool:
807
+ """Stop only the transport generation that may own ambiguous work.
808
+
809
+ A cancelled request can have crossed the stdin write boundary before
810
+ its acknowledgement arrived. Callers that cannot recover the native
811
+ turn id must be able to retire that exact process without racing a
812
+ replacement generation started by another task.
813
+ """
814
+
815
+ async with self._start_lock:
816
+ if self._generation != expected_generation:
817
+ return False
818
+ await self._discard_process()
819
+ return True
820
+
821
+ async def __aenter__(self) -> "CodexAppServerClient":
822
+ await self.start()
823
+ return self
824
+
825
+ async def __aexit__(self, *_exc: Any) -> None:
826
+ await self.close()
827
+
828
+ def subscribe(
829
+ self,
830
+ *,
831
+ thread_id: str | None = None,
832
+ turn_id: str | None = None,
833
+ ) -> CodexAppServerSubscription:
834
+ if turn_id is not None and thread_id is None:
835
+ raise ValueError("turn subscriptions require a thread_id")
836
+ subscription = CodexAppServerSubscription(
837
+ self,
838
+ thread_id,
839
+ turn_id,
840
+ asyncio.Queue(maxsize=self.notification_queue_limit),
841
+ )
842
+ self._subscriptions.add(subscription)
843
+ return subscription
844
+
845
+ def subscribe_thread(self, thread_id: str) -> CodexAppServerSubscription:
846
+ return self.subscribe(thread_id=thread_id)
847
+
848
+ def subscribe_turn(self, thread_id: str, turn_id: str) -> CodexAppServerSubscription:
849
+ return self.subscribe(thread_id=thread_id, turn_id=turn_id)
850
+
851
+ def _release_subscription(self, subscription: CodexAppServerSubscription) -> None:
852
+ self._subscriptions.discard(subscription)
853
+
854
+ def _finish_scoped_subscriptions(
855
+ self,
856
+ thread_id: str,
857
+ *,
858
+ turn_id: str | None = None,
859
+ include_thread_subscription: bool = False,
860
+ ) -> None:
861
+ if include_thread_subscription:
862
+ retained = self._turns_by_thread.pop(thread_id, None)
863
+ if retained is not None:
864
+ retained._completed = True
865
+ retained._closed = True
866
+ for subscription in tuple(self._subscriptions):
867
+ if subscription.thread_id != thread_id:
868
+ continue
869
+ if include_thread_subscription or subscription.turn_id == turn_id:
870
+ subscription._finish()
871
+
872
+ def add_notification_handler(self, handler: NotificationHandler) -> None:
873
+ self._notification_handlers.add(handler)
874
+
875
+ def remove_notification_handler(self, handler: NotificationHandler) -> None:
876
+ self._notification_handlers.discard(handler)
877
+
878
+ async def wait_for_notification_handler(
879
+ self,
880
+ handler: NotificationHandler,
881
+ thread_id: str,
882
+ ) -> None:
883
+ """Wait through the latest callback received for one provider thread."""
884
+ tail = self._callback_tails.get((handler, thread_id))
885
+ if tail is None:
886
+ return
887
+ try:
888
+ await asyncio.shield(tail)
889
+ except Exception:
890
+ # Callback failures are deliberately isolated from transport and
891
+ # subscription consumers, matching normal callback dispatch.
892
+ pass
893
+
894
+ async def _discard_process(
895
+ self,
896
+ *,
897
+ preserve_task: asyncio.Task[Any] | None = None,
898
+ ) -> None:
899
+ self._initialized = False
900
+ self._initialize_result = None
901
+ proc = self._proc
902
+ self._proc = None
903
+ process_group_id = self._process_group_id
904
+ self._process_group_id = None
905
+ current = asyncio.current_task()
906
+ reader_task = self._reader_task
907
+ stderr_task = self._stderr_task
908
+ had_transport_state = bool(
909
+ proc
910
+ or reader_task
911
+ or stderr_task
912
+ or self._pending
913
+ or self._turns_by_thread
914
+ or self._known_thread_ids
915
+ or self._subscriptions
916
+ or self._server_request_tasks
917
+ )
918
+ self._reader_task = None
919
+ self._stderr_task = None
920
+
921
+ if proc:
922
+ loop = asyncio.get_running_loop()
923
+ deadline = loop.time() + 2.0
924
+ if process_group_id is not None:
925
+ # start() created this exact new session. The native child can
926
+ # outlive the Node leader, so signal and verify the owned group
927
+ # even when the leader's returncode has already been observed.
928
+ group_alive = True
929
+ try:
930
+ os.killpg(process_group_id, signal.SIGTERM)
931
+ except ProcessLookupError:
932
+ group_alive = False
933
+ except PermissionError:
934
+ # Keep the group target: signal 0/KILL below remain scoped
935
+ # to the same exact group and cannot hit a sibling service.
936
+ pass
937
+ while group_alive and loop.time() < deadline:
938
+ try:
939
+ os.killpg(process_group_id, 0)
940
+ except ProcessLookupError:
941
+ group_alive = False
942
+ break
943
+ except PermissionError:
944
+ pass
945
+ await asyncio.sleep(0.05)
946
+ if group_alive:
947
+ with suppress(ProcessLookupError, PermissionError):
948
+ os.killpg(process_group_id, signal.SIGKILL)
949
+ if proc.returncode is None:
950
+ with suppress(Exception):
951
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
952
+ elif proc.returncode is None:
953
+ with suppress(ProcessLookupError):
954
+ proc.terminate()
955
+ with suppress(Exception):
956
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
957
+ if proc.returncode is None:
958
+ with suppress(ProcessLookupError):
959
+ proc.kill()
960
+ with suppress(Exception):
961
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
962
+ raw_pid = getattr(proc, "pid", None)
963
+ if (
964
+ self._on_process_exited is not None
965
+ and isinstance(raw_pid, int)
966
+ and not isinstance(raw_pid, bool)
967
+ and raw_pid > 0
968
+ ):
969
+ with suppress(Exception):
970
+ self._on_process_exited(raw_pid, process_group_id)
971
+
972
+ for task in (reader_task, stderr_task):
973
+ if task and task is not current:
974
+ task.cancel()
975
+ with suppress(asyncio.CancelledError):
976
+ await task
977
+
978
+ background_tasks = [
979
+ task
980
+ for task in (
981
+ *self._server_request_tasks.values(),
982
+ *self._callback_tasks,
983
+ )
984
+ if task is not current and task is not preserve_task
985
+ ]
986
+ for task in background_tasks:
987
+ task.cancel()
988
+ self._server_request_tasks.clear()
989
+ self._callback_tasks.clear()
990
+ self._callback_tails.clear()
991
+ if background_tasks:
992
+ await asyncio.gather(*background_tasks, return_exceptions=True)
993
+
994
+ if had_transport_state:
995
+ self._fail_all(
996
+ CodexAppServerDisconnected(
997
+ (
998
+ "codex app-server stopped with AgentsServer"
999
+ if self._closing
1000
+ else "codex app-server transport closed"
1001
+ ),
1002
+ request_sent=bool(self._pending),
1003
+ safe_to_retry=not bool(self._pending),
1004
+ planned=self._closing,
1005
+ )
1006
+ )
1007
+
1008
+ def _fail_all(self, error: BaseException) -> None:
1009
+ for _, future, _notification_boundary_subscription in list(
1010
+ self._pending.values()
1011
+ ):
1012
+ if not future.done():
1013
+ future.set_exception(error)
1014
+ self._pending.clear()
1015
+
1016
+ self._turns_by_thread.clear()
1017
+ self._loaded_threads.clear()
1018
+ self._known_thread_ids.clear()
1019
+ self._thread_names.clear()
1020
+ for subscription in tuple(self._subscriptions):
1021
+ subscription._finish(error)
1022
+
1023
+ async def request(
1024
+ self,
1025
+ method: str,
1026
+ params: dict[str, Any],
1027
+ *,
1028
+ timeout: float | None = None,
1029
+ ) -> Any:
1030
+ if method == "account/login/start":
1031
+ self._authentication_submitted = True
1032
+ self._stderr_tail.clear()
1033
+ await self.start()
1034
+ return await self._request_connected(
1035
+ method, params, timeout=timeout,
1036
+ # A status refresh must not retire an active shared provider if its
1037
+ # small control write stalls. Auth mutations are never replayed.
1038
+ discard_on_send_timeout=not method.startswith("account/"),
1039
+ )
1040
+
1041
+ async def _request_connected(
1042
+ self,
1043
+ method: str,
1044
+ params: dict[str, Any],
1045
+ *,
1046
+ timeout: float | None = None,
1047
+ notification_boundary_subscription: CodexAppServerSubscription | None = None,
1048
+ discard_on_send_timeout: bool = True,
1049
+ transport_turn: CodexAppServerTurn | None = None,
1050
+ before_send: Callable[[], bool] | None = None,
1051
+ ) -> Any:
1052
+ if self._protected_env_keys and method in {"thread/start", "thread/resume", "thread/fork"}:
1053
+ effective = await self._request_connected("config/read", {
1054
+ "includeLayers": False, "cwd": params.get("cwd") or self.cwd,
1055
+ }, discard_on_send_timeout=False)
1056
+ config = effective.get("config") if isinstance(effective, dict) else None
1057
+ policy = config.get("shell_environment_policy") if isinstance(config, dict) else None
1058
+ if not isinstance(config, dict) or policy is not None and not isinstance(policy, dict):
1059
+ raise CodexAppServerProtocolError("Native shell environment policy could not be verified", request_sent=False, safe_to_retry=False)
1060
+ exclusions = (policy or {}).get("exclude") or []
1061
+ overrides = dict(params.get("config") or {})
1062
+ requested = overrides.get("shell_environment_policy.exclude") or []
1063
+ if not all(isinstance(values, list) and all(isinstance(value, str) for value in values) for values in (exclusions, requested)):
1064
+ raise CodexAppServerProtocolError("Native shell environment exclusions could not be verified", request_sent=False, safe_to_retry=False)
1065
+ overrides["shell_environment_policy.exclude"] = list(dict.fromkeys([*exclusions, *requested, *self._protected_env_keys]))
1066
+ params = {**params, "config": overrides}
1067
+ proc = self._proc
1068
+ if not proc or proc.returncode is not None or not proc.stdin:
1069
+ raise CodexAppServerDisconnected(
1070
+ "codex app-server is not connected",
1071
+ request_sent=False,
1072
+ safe_to_retry=True,
1073
+ )
1074
+
1075
+ loop = asyncio.get_running_loop()
1076
+ self._next_id += 1
1077
+ request_id = self._next_id
1078
+ future: asyncio.Future[Any] = loop.create_future()
1079
+ self._pending[request_id] = (
1080
+ method,
1081
+ future,
1082
+ notification_boundary_subscription,
1083
+ )
1084
+ effective_timeout = self.request_timeout if timeout is None else timeout
1085
+ deadline = loop.time() + max(0.0, float(effective_timeout))
1086
+ try:
1087
+ await self._send(
1088
+ {"id": request_id, "method": method, "params": params},
1089
+ deadline=deadline,
1090
+ timeout=effective_timeout,
1091
+ expected_process=proc,
1092
+ discard_on_send_timeout=discard_on_send_timeout,
1093
+ transport_turn=transport_turn,
1094
+ before_send=before_send,
1095
+ )
1096
+ try:
1097
+ return await asyncio.wait_for(
1098
+ asyncio.shield(future),
1099
+ timeout=max(0.0, deadline - loop.time()),
1100
+ )
1101
+ except asyncio.TimeoutError as exc:
1102
+ raise CodexAppServerTimeout(method, effective_timeout) from exc
1103
+ finally:
1104
+ self._pending.pop(request_id, None)
1105
+ if not future.done():
1106
+ future.cancel()
1107
+ elif not future.cancelled():
1108
+ # Transport retirement can fail every pending future while
1109
+ # this request is unwinding from its write timeout. Consume
1110
+ # that superseded exception so asyncio does not report it as
1111
+ # an unretrieved future error.
1112
+ with suppress(BaseException):
1113
+ future.exception()
1114
+
1115
+ async def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
1116
+ payload: dict[str, Any] = {"method": method}
1117
+ if params is not None:
1118
+ payload["params"] = params
1119
+ await self._send(payload)
1120
+
1121
+ async def _discard_process_after_write_timeout(
1122
+ self,
1123
+ proc: asyncio.subprocess.Process,
1124
+ ) -> None:
1125
+ """Retire exactly the process whose stdin missed its write deadline."""
1126
+ async with self._start_lock:
1127
+ if proc is not self._proc:
1128
+ return
1129
+ cleanup_task = asyncio.create_task(
1130
+ self._discard_process(preserve_task=asyncio.current_task())
1131
+ )
1132
+ cancelled = False
1133
+ while not cleanup_task.done():
1134
+ try:
1135
+ await asyncio.shield(cleanup_task)
1136
+ except asyncio.CancelledError:
1137
+ # _discard_process clears the owned process reference
1138
+ # before its first await. Every subsequent cancellation
1139
+ # must remain outside the cleanup task; a plain join here
1140
+ # lets a second cancel abandon the now-unreachable child.
1141
+ cancelled = True
1142
+ # Propagate cleanup failure before restoring caller cancellation.
1143
+ # result() also retrieves any exception when the task completed in
1144
+ # the same loop turn as a repeated cancel.
1145
+ cleanup_task.result()
1146
+ if cancelled:
1147
+ raise asyncio.CancelledError
1148
+
1149
+ async def _send(
1150
+ self,
1151
+ payload: dict[str, Any],
1152
+ *,
1153
+ timeout: float | None = None,
1154
+ deadline: float | None = None,
1155
+ expected_process: asyncio.subprocess.Process | None = None,
1156
+ discard_on_send_timeout: bool = True,
1157
+ transport_turn: CodexAppServerTurn | None = None,
1158
+ before_send: Callable[[], bool] | None = None,
1159
+ ) -> None:
1160
+ loop = asyncio.get_running_loop()
1161
+ effective_timeout = self.request_timeout if timeout is None else timeout
1162
+ timeout_seconds = max(0.0, float(effective_timeout))
1163
+ if deadline is None:
1164
+ deadline = loop.time() + timeout_seconds
1165
+ method = str(payload.get("method") or "app-server write")
1166
+ encoded = (json.dumps(payload, separators=(",", ":")) + "\n").encode()
1167
+ proc = expected_process if expected_process is not None else self._proc
1168
+ if not proc or proc.returncode is not None or not proc.stdin:
1169
+ raise CodexAppServerDisconnected(
1170
+ "codex app-server stdin is unavailable",
1171
+ request_sent=False,
1172
+ safe_to_retry=True,
1173
+ )
1174
+
1175
+ lock_acquired = False
1176
+ request_sent = False
1177
+ send_timeout: asyncio.TimeoutError | None = None
1178
+ try:
1179
+ try:
1180
+ await asyncio.wait_for(
1181
+ self._write_lock.acquire(),
1182
+ timeout=max(0.0, deadline - loop.time()),
1183
+ )
1184
+ except asyncio.TimeoutError as exc:
1185
+ send_timeout = exc
1186
+ else:
1187
+ lock_acquired = True
1188
+ # A process may be retired while this sender waits behind an
1189
+ # older write. Never put a request on that stale pipe.
1190
+ if (
1191
+ proc is not self._proc
1192
+ or proc.returncode is not None
1193
+ or not proc.stdin
1194
+ ):
1195
+ raise CodexAppServerDisconnected(
1196
+ "codex app-server stdin is unavailable",
1197
+ request_sent=False,
1198
+ safe_to_retry=True,
1199
+ )
1200
+ if loop.time() >= deadline:
1201
+ send_timeout = asyncio.TimeoutError()
1202
+ else:
1203
+ # A control request can wait behind another writer after
1204
+ # its caller checked ownership. Recheck synchronously at
1205
+ # the actual write boundary; there is no await between
1206
+ # this decision and stdin.write. Rejection sends no bytes.
1207
+ if before_send is not None:
1208
+ try:
1209
+ allowed = before_send() is True
1210
+ except Exception as exc:
1211
+ raise CodexAppServerProtocolError(
1212
+ "app-server send guard failed before delivery",
1213
+ request_sent=False, safe_to_retry=True,
1214
+ ) from exc
1215
+ if not allowed:
1216
+ raise CodexAppServerProtocolError(
1217
+ "app-server control owner changed before delivery",
1218
+ request_sent=False, safe_to_retry=True,
1219
+ )
1220
+ try:
1221
+ if transport_turn is not None:
1222
+ transport_turn.transport_generation = self._generation
1223
+ proc.stdin.write(encoded)
1224
+ request_sent = True
1225
+ await asyncio.wait_for(
1226
+ proc.stdin.drain(),
1227
+ timeout=max(0.0, deadline - loop.time()),
1228
+ )
1229
+ except asyncio.TimeoutError as exc:
1230
+ send_timeout = exc
1231
+ except Exception as exc:
1232
+ raise CodexAppServerDisconnected(
1233
+ f"codex app-server write failed: {exc}",
1234
+ request_sent=True,
1235
+ safe_to_retry=False,
1236
+ ) from exc
1237
+ except CodexAppServerError:
1238
+ raise
1239
+ finally:
1240
+ if lock_acquired:
1241
+ self._write_lock.release()
1242
+
1243
+ if send_timeout is None:
1244
+ return
1245
+ if request_sent and discard_on_send_timeout:
1246
+ # Cleanup runs after releasing the shared writer lock. It is
1247
+ # serialized with start() so a fresh process cannot be spawned and
1248
+ # then accidentally cleared by retirement of the stale transport.
1249
+ await self._discard_process_after_write_timeout(proc)
1250
+ raise CodexAppServerTimeout(
1251
+ method,
1252
+ effective_timeout,
1253
+ request_sent=request_sent,
1254
+ ) from send_timeout
1255
+
1256
+ def _redact_sensitive(self, value):
1257
+ if isinstance(value, str):
1258
+ for secret in self._sensitive_values:
1259
+ value = value.replace(secret, "[REDACTED]")
1260
+ return value
1261
+ if isinstance(value, list):
1262
+ return [self._redact_sensitive(item) for item in value]
1263
+ if isinstance(value, dict):
1264
+ return {self._redact_sensitive(key): self._redact_sensitive(item) for key, item in value.items()}
1265
+ return value
1266
+
1267
+ async def _reader_loop(self, proc: asyncio.subprocess.Process) -> None:
1268
+ error: BaseException | None = None
1269
+ try:
1270
+ if not proc.stdout:
1271
+ raise CodexAppServerDisconnected(
1272
+ "codex app-server stdout is unavailable",
1273
+ request_sent=False,
1274
+ safe_to_retry=True,
1275
+ )
1276
+ while True:
1277
+ raw = await proc.stdout.readline()
1278
+ if not raw:
1279
+ break
1280
+ try:
1281
+ if (
1282
+ self.json_parse_thread_threshold
1283
+ and len(raw) >= self.json_parse_thread_threshold
1284
+ ):
1285
+ message = await asyncio.to_thread(json.loads, raw)
1286
+ else:
1287
+ message = json.loads(raw)
1288
+ except (TypeError, ValueError):
1289
+ continue
1290
+ if not isinstance(message, dict):
1291
+ continue
1292
+
1293
+ if self._sensitive_values:
1294
+ message = self._redact_sensitive(message)
1295
+ request_id = message.get("id")
1296
+ if request_id is not None and ("result" in message or "error" in message):
1297
+ pending = self._pending.get(request_id)
1298
+ if pending:
1299
+ (
1300
+ request_method,
1301
+ future,
1302
+ notification_boundary_subscription,
1303
+ ) = pending
1304
+ if not future.done():
1305
+ if "error" in message:
1306
+ future.set_exception(
1307
+ CodexAppServerRequestError(
1308
+ request_method,
1309
+ {"message": "Native Codex authentication request failed"}
1310
+ if request_method.startswith("account/")
1311
+ else message.get("error"),
1312
+ )
1313
+ )
1314
+ else:
1315
+ result = message.get("result")
1316
+ future.set_result(
1317
+ (
1318
+ result,
1319
+ notification_boundary_subscription._last_enqueued_sequence,
1320
+ )
1321
+ if notification_boundary_subscription is not None
1322
+ else result
1323
+ )
1324
+ continue
1325
+
1326
+ if request_id is not None and message.get("method"):
1327
+ params = message.get("params")
1328
+ self._start_server_request(
1329
+ request_id,
1330
+ str(message.get("method") or ""),
1331
+ params if isinstance(params, dict) else {},
1332
+ )
1333
+ continue
1334
+
1335
+ method = str(message.get("method") or "")
1336
+ params = message.get("params")
1337
+ if method and isinstance(params, dict):
1338
+ self._route_notification({"method": method, "params": params})
1339
+ except asyncio.CancelledError:
1340
+ raise
1341
+ except Exception as exc:
1342
+ error = exc
1343
+ finally:
1344
+ if not self._closing and proc is self._proc:
1345
+ returncode = proc.returncode
1346
+ if returncode is None:
1347
+ try:
1348
+ returncode = await asyncio.wait_for(
1349
+ proc.wait(),
1350
+ timeout=self.process_exit_timeout,
1351
+ )
1352
+ except asyncio.TimeoutError:
1353
+ returncode = proc.returncode
1354
+ except Exception as exc:
1355
+ error = error or exc
1356
+ # ``close()`` may have started while the reader was waiting
1357
+ # for the child status. Let the planned shutdown path own
1358
+ # subscription cleanup instead of reporting a false crash.
1359
+ if not self._closing and proc is self._proc:
1360
+ self._initialized = False
1361
+ error = error or CodexAppServerDisconnected(
1362
+ (
1363
+ f"codex app-server exited with code {returncode}"
1364
+ if returncode is not None
1365
+ else (
1366
+ "codex app-server closed stdout before its "
1367
+ "process exit status became available"
1368
+ )
1369
+ ),
1370
+ request_sent=bool(self._pending),
1371
+ safe_to_retry=not bool(self._pending),
1372
+ )
1373
+ self._fail_all(error)
1374
+ self._cancel_server_request_tasks()
1375
+ # An unplanned exit must release the child's registry entry
1376
+ # now; ``_discard_process`` only runs on the next start or
1377
+ # on close, and a stale {pid, pgid} could be reused.
1378
+ raw_pid = getattr(proc, "pid", None)
1379
+ if (
1380
+ self._on_process_exited is not None
1381
+ and isinstance(raw_pid, int)
1382
+ and not isinstance(raw_pid, bool)
1383
+ and raw_pid > 0
1384
+ ):
1385
+ with suppress(Exception):
1386
+ self._on_process_exited(raw_pid, self._process_group_id)
1387
+
1388
+ async def _stderr_loop(self, proc: asyncio.subprocess.Process) -> None:
1389
+ if not proc.stderr:
1390
+ return
1391
+ while True:
1392
+ line = await proc.stderr.readline()
1393
+ if not line:
1394
+ return
1395
+ text = line.decode("utf-8", "replace").strip()
1396
+ if text and not self._authentication_submitted:
1397
+ self._stderr_tail.append(text)
1398
+
1399
+ def _route_notification(self, notification: dict[str, Any]) -> None:
1400
+ method = str(notification.get("method") or "")
1401
+ # Authentication notifications are not chat events. Do not expose
1402
+ # account metadata or upstream login errors to event subscribers.
1403
+ if method in {"account/login/completed", "account/updated"}:
1404
+ return
1405
+ params = notification.get("params")
1406
+ if not isinstance(params, dict):
1407
+ return
1408
+
1409
+ if method == "serverRequest/resolved":
1410
+ request_id = params.get("requestId")
1411
+ task = self._server_request_tasks.pop(request_id, None)
1412
+ if task:
1413
+ task.cancel()
1414
+
1415
+ thread_id, turn_id = _notification_scope(notification)
1416
+ if method == "thread/name/updated":
1417
+ self._thread_names.pop(thread_id, None)
1418
+ self._cache_thread_name({"id": thread_id, "name": params.get("threadName")})
1419
+ elif method == "thread/started":
1420
+ self._cache_thread_name(params.get("thread"))
1421
+ if method == "thread/started" and thread_id:
1422
+ self._loaded_threads.add(thread_id)
1423
+ elif method == "thread/closed" and thread_id:
1424
+ self._loaded_threads.discard(thread_id)
1425
+
1426
+ active_turn = self._turns_by_thread.get(thread_id)
1427
+ if (
1428
+ active_turn is not None
1429
+ and method == "turn/started"
1430
+ and turn_id in active_turn._completed_turn_ids
1431
+ ):
1432
+ return
1433
+ if active_turn and turn_id:
1434
+ # A provisional turn may receive an item notification before the
1435
+ # turn/start response binds its id. Once bound, only an explicit
1436
+ # turn/started notification may move the handle to another turn.
1437
+ # Native goals and subagent results can start follow-up turns, so
1438
+ # keeping this id current is required for both event routing and
1439
+ # turn/interrupt to target the work that is actually running.
1440
+ if not active_turn.turn_id or method == "turn/started":
1441
+ active_turn.turn_id = turn_id
1442
+ if method == "turn/started":
1443
+ active_turn._completed = False
1444
+ if not active_turn._retain_thread_stream:
1445
+ active_turn._subscription.turn_id = turn_id
1446
+
1447
+ matched = False
1448
+ for subscription in tuple(self._subscriptions):
1449
+ if subscription._matches(notification):
1450
+ subscription._put(notification)
1451
+ matched = True
1452
+ if not matched:
1453
+ self._unmatched_notifications.append({
1454
+ "method": method,
1455
+ "thread_id": thread_id,
1456
+ "turn_id": turn_id,
1457
+ "at": time.time(),
1458
+ })
1459
+
1460
+ for handler in tuple(self._notification_handlers):
1461
+ owner = (handler, thread_id or "")
1462
+ previous = self._callback_tails.get(owner)
1463
+ if previous is None:
1464
+ try:
1465
+ result = handler(notification)
1466
+ except Exception:
1467
+ continue
1468
+ if not inspect.isawaitable(result):
1469
+ # Preserve the original inline contract for synchronous
1470
+ # caches consumed by immediately following server requests.
1471
+ continue
1472
+ task = asyncio.ensure_future(result)
1473
+ else:
1474
+ task = asyncio.create_task(
1475
+ self._invoke_notification_handler(
1476
+ handler,
1477
+ notification,
1478
+ previous,
1479
+ )
1480
+ )
1481
+ self._callback_tails[owner] = task
1482
+ self._callback_tasks.add(task)
1483
+ task.add_done_callback(
1484
+ lambda completed, owner=owner: self._finish_callback_task(
1485
+ owner,
1486
+ completed,
1487
+ )
1488
+ )
1489
+
1490
+ # Synchronous handlers (including the scoped fork watcher) must see
1491
+ # whether this identity was known *before* this notification. Record a
1492
+ # genuinely new notification only after they have made that decision.
1493
+ if method == "thread/started" and thread_id:
1494
+ self._known_thread_ids.add(thread_id)
1495
+
1496
+ if method == "turn/completed" and active_turn:
1497
+ if not turn_id or not active_turn.turn_id or turn_id == active_turn.turn_id:
1498
+ active_turn._completed = True
1499
+ if turn_id:
1500
+ active_turn._completed_turn_ids.add(turn_id)
1501
+ # A retained handle owns the thread stream until close(), not
1502
+ # just its first native turn. Keep it available for the next
1503
+ # turn/started so Stop/steer cannot target the finished turn.
1504
+ if (
1505
+ not active_turn._retain_thread_stream
1506
+ and self._turns_by_thread.get(thread_id) is active_turn
1507
+ ):
1508
+ self._turns_by_thread.pop(thread_id, None)
1509
+ if not active_turn._retain_thread_stream:
1510
+ active_turn._subscription._finish()
1511
+ if method == "turn/completed" and thread_id and turn_id:
1512
+ self._finish_scoped_subscriptions(thread_id, turn_id=turn_id)
1513
+ elif method == "thread/closed" and thread_id:
1514
+ self._finish_scoped_subscriptions(
1515
+ thread_id,
1516
+ include_thread_subscription=True,
1517
+ )
1518
+
1519
+ async def _invoke_notification_handler(
1520
+ self,
1521
+ handler: NotificationHandler,
1522
+ notification: dict[str, Any],
1523
+ previous: asyncio.Task[Any] | None,
1524
+ ) -> None:
1525
+ if previous is not None:
1526
+ try:
1527
+ await previous
1528
+ except Exception:
1529
+ # One failed notification must not discard every later
1530
+ # notification already received for the same handler.
1531
+ pass
1532
+ try:
1533
+ result = handler(notification)
1534
+ except Exception:
1535
+ return
1536
+ if inspect.isawaitable(result):
1537
+ await result
1538
+
1539
+ def _finish_callback_task(
1540
+ self,
1541
+ owner: tuple[NotificationHandler, str],
1542
+ task: asyncio.Task[Any],
1543
+ ) -> None:
1544
+ self._callback_tasks.discard(task)
1545
+ if self._callback_tails.get(owner) is task:
1546
+ self._callback_tails.pop(owner, None)
1547
+ if not task.cancelled():
1548
+ with suppress(Exception):
1549
+ task.result()
1550
+
1551
+ def _cancel_server_request_tasks(self) -> None:
1552
+ tasks = list(self._server_request_tasks.values())
1553
+ self._server_request_tasks.clear()
1554
+ for task in tasks:
1555
+ task.cancel()
1556
+
1557
+ def _start_server_request(
1558
+ self,
1559
+ request_id: Any,
1560
+ method: str,
1561
+ params: dict[str, Any],
1562
+ ) -> None:
1563
+ previous = self._server_request_tasks.pop(request_id, None)
1564
+ if previous:
1565
+ previous.cancel()
1566
+ task = asyncio.create_task(
1567
+ self._handle_server_request(request_id, method, params),
1568
+ name=f"codex-app-server-request-{request_id}",
1569
+ )
1570
+ self._server_request_tasks[request_id] = task
1571
+ task.add_done_callback(
1572
+ lambda done, rid=request_id: self._finish_server_request(rid, done)
1573
+ )
1574
+
1575
+ def _finish_server_request(self, request_id: Any, task: asyncio.Task[None]) -> None:
1576
+ if self._server_request_tasks.get(request_id) is task:
1577
+ self._server_request_tasks.pop(request_id, None)
1578
+ if not task.cancelled():
1579
+ with suppress(Exception):
1580
+ task.result()
1581
+
1582
+ async def _handle_server_request(
1583
+ self,
1584
+ request_id: Any,
1585
+ method: str,
1586
+ params: dict[str, Any],
1587
+ ) -> None:
1588
+ try:
1589
+ result = self._server_request_handler(request_id, method, params)
1590
+ if inspect.isawaitable(result):
1591
+ result = await result
1592
+ if not isinstance(result, dict):
1593
+ raise CodexAppServerProtocolError(
1594
+ f"server request handler returned non-object for {method}"
1595
+ )
1596
+ await self._send({"id": request_id, "result": result})
1597
+ except asyncio.CancelledError:
1598
+ # Another app-server client or a turn transition resolved it.
1599
+ return
1600
+ except CodexAppServerRequestError as exc:
1601
+ with suppress(CodexAppServerError):
1602
+ await self._send(
1603
+ {
1604
+ "id": request_id,
1605
+ "error": (
1606
+ exc.error
1607
+ if isinstance(exc.error, dict)
1608
+ else {"code": -32000, "message": str(exc)}
1609
+ ),
1610
+ }
1611
+ )
1612
+ except Exception as exc:
1613
+ with suppress(CodexAppServerError):
1614
+ await self._send(
1615
+ {
1616
+ "id": request_id,
1617
+ "error": {"code": -32000, "message": str(exc)},
1618
+ }
1619
+ )
1620
+
1621
+ async def start_thread(self, params: dict[str, Any]) -> str:
1622
+ result = await self.request(
1623
+ "thread/start",
1624
+ dict(params),
1625
+ timeout=self.lifecycle_timeout,
1626
+ )
1627
+ thread_id = self._thread_id_from_result("thread/start", result)
1628
+ self._cache_thread_name(result.get("thread"))
1629
+ self._known_thread_ids.add(thread_id)
1630
+ self._loaded_threads.add(thread_id)
1631
+ return thread_id
1632
+
1633
+ async def resume_thread(
1634
+ self,
1635
+ thread_id: str,
1636
+ params: dict[str, Any] | None = None,
1637
+ ) -> str:
1638
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
1639
+ # Register before the first await: thread/started can be routed before
1640
+ # the resume response and must not look like this process's late fork.
1641
+ self._known_thread_ids.add(thread_id)
1642
+ payload = dict(params or {})
1643
+ payload["threadId"] = thread_id
1644
+ payload.setdefault("excludeTurns", True)
1645
+ result = await self.request(
1646
+ "thread/resume",
1647
+ payload,
1648
+ timeout=self.lifecycle_timeout,
1649
+ )
1650
+ resolved = self._thread_id_from_result("thread/resume", result)
1651
+ self._known_thread_ids.add(resolved)
1652
+ if resolved != thread_id:
1653
+ raise CodexAppServerProtocolError(
1654
+ "thread/resume returned a different thread id",
1655
+ request_sent=True,
1656
+ safe_to_retry=False,
1657
+ )
1658
+ self._cache_thread_name(result.get("thread"))
1659
+ self._loaded_threads.add(resolved)
1660
+ return resolved
1661
+
1662
+ async def fork_thread(
1663
+ self,
1664
+ thread_id: str,
1665
+ params: dict[str, Any] | None = None,
1666
+ *,
1667
+ last_turn_id: str | None = None,
1668
+ ) -> str:
1669
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
1670
+ # The source itself is provider-owned even if it is currently unloaded.
1671
+ self._known_thread_ids.add(thread_id)
1672
+ source_lock = self._fork_source_locks.setdefault(
1673
+ thread_id,
1674
+ asyncio.Lock(),
1675
+ )
1676
+ async with source_lock:
1677
+ return await self._fork_thread_serialized(
1678
+ thread_id,
1679
+ params,
1680
+ last_turn_id=last_turn_id,
1681
+ )
1682
+
1683
+ async def _fork_thread_serialized(
1684
+ self,
1685
+ thread_id: str,
1686
+ params: dict[str, Any] | None,
1687
+ *,
1688
+ last_turn_id: str | None,
1689
+ ) -> str:
1690
+ payload = dict(params or {})
1691
+ payload["threadId"] = thread_id
1692
+ payload.setdefault("excludeTurns", True)
1693
+ if last_turn_id is not None:
1694
+ payload["lastTurnId"] = last_turn_id
1695
+ expected_cwd = str(payload.get("cwd") or "").strip()
1696
+ fork_started_at = int(time.time())
1697
+ late_children: asyncio.Queue[str] = asyncio.Queue()
1698
+ queued_late_children: set[str] = set()
1699
+
1700
+ def watch_started_fork(notification: dict[str, Any]) -> None:
1701
+ if notification.get("method") != "thread/started":
1702
+ return
1703
+ notification_params = notification.get("params")
1704
+ thread = (
1705
+ notification_params.get("thread")
1706
+ if isinstance(notification_params, dict)
1707
+ else None
1708
+ )
1709
+ if not isinstance(thread, dict):
1710
+ return
1711
+ child_id = str(thread.get("id") or "").strip()
1712
+ forked_from_id = str(thread.get("forkedFromId") or "").strip()
1713
+ child_cwd = str(thread.get("cwd") or "").strip()
1714
+ created_at = thread.get("createdAt")
1715
+ if (
1716
+ child_id
1717
+ and child_id != thread_id
1718
+ and forked_from_id == thread_id
1719
+ and child_id not in self._known_thread_ids
1720
+ and child_id not in queued_late_children
1721
+ # Destructive cleanup requires request-local evidence. An old
1722
+ # fork resumed after an app-server reconnect can share the same
1723
+ # ancestry but cannot share this creation window.
1724
+ and expected_cwd
1725
+ and child_cwd
1726
+ and os.path.realpath(child_cwd) == os.path.realpath(expected_cwd)
1727
+ and isinstance(created_at, int)
1728
+ and not isinstance(created_at, bool)
1729
+ and fork_started_at - 1 <= created_at <= int(time.time()) + 1
1730
+ ):
1731
+ queued_late_children.add(child_id)
1732
+ late_children.put_nowait(child_id)
1733
+
1734
+ # This must be installed before the request is written. A fast provider
1735
+ # can announce the child before returning the JSON-RPC response.
1736
+ self.add_notification_handler(watch_started_fork)
1737
+ try:
1738
+ try:
1739
+ result = await self.request(
1740
+ "thread/fork",
1741
+ payload,
1742
+ timeout=self.lifecycle_timeout,
1743
+ )
1744
+ except BaseException as exc:
1745
+ ambiguous = isinstance(
1746
+ exc,
1747
+ (asyncio.CancelledError, CodexAppServerTimeout),
1748
+ ) or (
1749
+ isinstance(exc, CodexAppServerDisconnected)
1750
+ and exc.request_sent
1751
+ )
1752
+ if ambiguous:
1753
+ cleanup = asyncio.create_task(
1754
+ self._delete_late_fork_child(late_children)
1755
+ )
1756
+ try:
1757
+ unretired_thread_ids = await self._join_shielded_task(
1758
+ cleanup
1759
+ )
1760
+ except BaseException:
1761
+ # The timeout/cancellation is the actionable transport
1762
+ # result. A cleanup failure before a child identity is
1763
+ # known must not hide it.
1764
+ pass
1765
+ else:
1766
+ if unretired_thread_ids:
1767
+ # Preserve the original timeout/cancellation while
1768
+ # handing the known provider identity to the owning
1769
+ # layer for durable cleanup. A tuple prevents later
1770
+ # mutation while the exception crosses task layers.
1771
+ exc.unretired_fork_thread_ids = tuple(
1772
+ unretired_thread_ids
1773
+ )
1774
+ raise
1775
+ finally:
1776
+ self.remove_notification_handler(watch_started_fork)
1777
+ resolved = self._thread_id_from_result("thread/fork", result)
1778
+ self._known_thread_ids.add(resolved)
1779
+ if resolved == thread_id:
1780
+ raise CodexAppServerProtocolError(
1781
+ "thread/fork returned the source thread id",
1782
+ request_sent=True,
1783
+ safe_to_retry=False,
1784
+ )
1785
+ thread = result.get("thread") if isinstance(result, dict) else None
1786
+ forked_from_id = (
1787
+ thread.get("forkedFromId") if isinstance(thread, dict) else None
1788
+ )
1789
+ if forked_from_id is not None and forked_from_id != thread_id:
1790
+ error = CodexAppServerProtocolError(
1791
+ "thread/fork returned a thread with a different source id",
1792
+ request_sent=True,
1793
+ safe_to_retry=False,
1794
+ )
1795
+ cleanup = asyncio.create_task(self.delete_thread(resolved))
1796
+ try:
1797
+ await self._join_shielded_task(cleanup)
1798
+ except BaseException:
1799
+ # Preserve the ancestry violation even when an older provider
1800
+ # cannot delete the untrusted child.
1801
+ pass
1802
+ raise error
1803
+ self._loaded_threads.add(resolved)
1804
+ return resolved
1805
+
1806
+ async def _delete_late_fork_child(
1807
+ self,
1808
+ late_children: asyncio.Queue[str],
1809
+ ) -> tuple[str, ...]:
1810
+ """Delete an announced child or return identities needing retirement."""
1811
+
1812
+ try:
1813
+ child_id = await asyncio.wait_for(
1814
+ late_children.get(),
1815
+ timeout=self.fork_cleanup_grace,
1816
+ )
1817
+ except asyncio.TimeoutError:
1818
+ return ()
1819
+ # Drain notifications already routed in this event-loop turn. If more
1820
+ # than one request-correlated identity exists, ownership is ambiguous;
1821
+ # leak an unexposed provider fork rather than deleting user history.
1822
+ await asyncio.sleep(0)
1823
+ candidates = {child_id}
1824
+ while not late_children.empty():
1825
+ candidates.add(late_children.get_nowait())
1826
+ if len(candidates) != 1:
1827
+ return ()
1828
+ try:
1829
+ await self.delete_thread(child_id)
1830
+ except BaseException:
1831
+ return (child_id,)
1832
+ return ()
1833
+
1834
+ @staticmethod
1835
+ async def _join_shielded_task(task: asyncio.Task[Any]) -> Any:
1836
+ """Join cleanup even if the caller receives repeated cancellation."""
1837
+
1838
+ while True:
1839
+ try:
1840
+ return await asyncio.shield(task)
1841
+ except asyncio.CancelledError:
1842
+ if task.done():
1843
+ return task.result()
1844
+
1845
+ @staticmethod
1846
+ def _thread_id_from_result(method: str, result: Any) -> str:
1847
+ thread = result.get("thread") if isinstance(result, dict) else None
1848
+ resolved = str(thread.get("id") or "") if isinstance(thread, dict) else ""
1849
+ if not resolved.strip():
1850
+ raise CodexAppServerProtocolError(
1851
+ f"{method} did not return a thread id",
1852
+ request_sent=True,
1853
+ safe_to_retry=False,
1854
+ )
1855
+ return resolved
1856
+
1857
+ async def start_or_resume_thread(
1858
+ self,
1859
+ thread_id: str | None,
1860
+ start_params: dict[str, Any],
1861
+ resume_params: dict[str, Any] | None = None,
1862
+ ) -> str:
1863
+ if thread_id:
1864
+ return await self.resume_thread(
1865
+ thread_id,
1866
+ resume_params if resume_params is not None else start_params,
1867
+ )
1868
+ return await self.start_thread(start_params)
1869
+
1870
+ async def inject_items(
1871
+ self,
1872
+ thread_id: str,
1873
+ items: list[dict[str, Any]],
1874
+ ) -> None:
1875
+ await self.request(
1876
+ "thread/inject_items",
1877
+ {"threadId": thread_id, "items": items},
1878
+ )
1879
+
1880
+ async def inject_items_guarded(
1881
+ self,
1882
+ thread_id: str,
1883
+ items: list[dict[str, Any]],
1884
+ *,
1885
+ expected_generation: int,
1886
+ before_send: Callable[[], bool],
1887
+ timeout: float | None = None,
1888
+ ) -> None:
1889
+ """Append one runtime notice to an already-loaded, exact-owned thread.
1890
+
1891
+ Never starts/reconnects a provider or changes its turn. The caller owns
1892
+ notice coalescing; a sent request with no definite acknowledgement (or
1893
+ cancellation during delivery) must not be blindly replayed. A notice
1894
+ write timeout must not retire the busy provider shared by other work.
1895
+ """
1896
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
1897
+ if type(expected_generation) is not int or expected_generation < 1 or not callable(before_send):
1898
+ raise ValueError("exact generation and synchronous ownership guard are required")
1899
+ if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
1900
+ raise ValueError("one generated developer notice is required")
1901
+ item = items[0]
1902
+ content = item.get("content")
1903
+ if (item.get("type") != "message" or item.get("role") != "developer"
1904
+ or not isinstance(content, list) or len(content) != 1 or not isinstance(content[0], dict)
1905
+ or content[0].get("type") != "input_text" or not isinstance(content[0].get("text"), str)
1906
+ or not content[0]["text"].strip()
1907
+ or len(content[0]["text"].encode("utf-8", "surrogatepass")) > 2048):
1908
+ raise ValueError("runtime notices require bounded generated developer input_text")
1909
+ # Copy the only allowed fields before waiting for the shared writer.
1910
+ notice = {"type": "message", "role": "developer", "content": [
1911
+ {"type": "input_text", "text": content[0]["text"]},
1912
+ ]}
1913
+
1914
+ def owns_loaded_thread() -> bool:
1915
+ return (self.ready and self._generation == expected_generation
1916
+ and thread_id in self._loaded_threads)
1917
+
1918
+ if not owns_loaded_thread():
1919
+ raise CodexAppServerProtocolError(
1920
+ "runtime notice owner is unavailable", request_sent=False, safe_to_retry=True,
1921
+ )
1922
+ result = await self._request_connected(
1923
+ "thread/inject_items", {"threadId": thread_id, "items": [notice]},
1924
+ timeout=timeout, discard_on_send_timeout=False,
1925
+ before_send=lambda: owns_loaded_thread() and before_send() is True,
1926
+ )
1927
+ if not isinstance(result, dict):
1928
+ raise CodexAppServerProtocolError(
1929
+ "runtime notice acknowledgement is invalid", request_sent=True, safe_to_retry=False,
1930
+ )
1931
+
1932
+ async def read_thread(
1933
+ self,
1934
+ thread_id: str,
1935
+ *,
1936
+ include_turns: bool = False,
1937
+ ) -> dict[str, Any]:
1938
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
1939
+ # A read is sufficient proof that the provider identity predates any
1940
+ # concurrent fork watcher, regardless of current load state.
1941
+ self._known_thread_ids.add(thread_id)
1942
+ result = await self.request(
1943
+ "thread/read",
1944
+ {"threadId": thread_id, "includeTurns": include_turns},
1945
+ )
1946
+ thread = result.get("thread") if isinstance(result, dict) else None
1947
+ returned_thread_id = (
1948
+ str(thread.get("id") or "").strip()
1949
+ if isinstance(thread, dict)
1950
+ else ""
1951
+ )
1952
+ if returned_thread_id:
1953
+ self._known_thread_ids.add(returned_thread_id)
1954
+ if not isinstance(thread, dict) or returned_thread_id != thread_id:
1955
+ raise CodexAppServerProtocolError(
1956
+ "thread/read did not return the requested thread",
1957
+ request_sent=True,
1958
+ safe_to_retry=False,
1959
+ )
1960
+ self._cache_thread_name(thread)
1961
+ return thread
1962
+
1963
+ async def delete_thread(self, thread_id: str) -> None:
1964
+ """Permanently remove a newly-created, unexposed provider thread."""
1965
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
1966
+ method = "thread/delete"
1967
+ _protocol_empty_object(
1968
+ method,
1969
+ await self.request(
1970
+ method,
1971
+ {"threadId": thread_id},
1972
+ timeout=self.lifecycle_timeout,
1973
+ ),
1974
+ )
1975
+ self._loaded_threads.discard(thread_id)
1976
+ self._known_thread_ids.discard(thread_id)
1977
+
1978
+ async def list_turns(
1979
+ self,
1980
+ thread_id: str,
1981
+ *,
1982
+ limit: int = 4,
1983
+ items_view: str = "full",
1984
+ sort_direction: str = "desc",
1985
+ ) -> list[dict[str, Any]]:
1986
+ result = await self.request(
1987
+ "thread/turns/list",
1988
+ {
1989
+ "threadId": thread_id,
1990
+ "limit": max(1, int(limit)),
1991
+ "itemsView": items_view,
1992
+ "sortDirection": sort_direction,
1993
+ },
1994
+ )
1995
+ data = result.get("data") if isinstance(result, dict) else None
1996
+ if not isinstance(data, list):
1997
+ raise CodexAppServerProtocolError(
1998
+ "thread/turns/list did not return a turn list",
1999
+ request_sent=True,
2000
+ safe_to_retry=False,
2001
+ )
2002
+ return [turn for turn in data if isinstance(turn, dict)]
2003
+
2004
+ async def list_descendant_threads(
2005
+ self,
2006
+ thread_id: str,
2007
+ *,
2008
+ page_size: int = 100,
2009
+ ) -> list[dict[str, Any]]:
2010
+ """Return every persisted subagent descendant of ``thread_id``.
2011
+
2012
+ Relationship filters are part of app-server's experimental API. The
2013
+ client enables that capability during initialization, so using the
2014
+ ancestor filter is both cheaper and more accurate than reconstructing
2015
+ the spawn tree from transcript items.
2016
+ """
2017
+
2018
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2019
+ if isinstance(page_size, bool) or not isinstance(page_size, int) or page_size < 1:
2020
+ raise ValueError("page_size must be a positive integer")
2021
+
2022
+ method = "thread/list"
2023
+ descendants: list[dict[str, Any]] = []
2024
+ cursor: str | None = None
2025
+ seen_cursors: set[str] = set()
2026
+ while True:
2027
+ params: dict[str, Any] = {
2028
+ "ancestorThreadId": thread_id,
2029
+ "limit": page_size,
2030
+ "useStateDbOnly": True,
2031
+ # Relationship-filtered requests must opt into subagent source
2032
+ # kinds. The ordinary omitted/default source filter is limited
2033
+ # to interactive threads on older app-server releases.
2034
+ "sourceKinds": [
2035
+ "subAgent",
2036
+ "subAgentReview",
2037
+ "subAgentCompact",
2038
+ "subAgentThreadSpawn",
2039
+ "subAgentOther",
2040
+ ],
2041
+ }
2042
+ if cursor is not None:
2043
+ params["cursor"] = cursor
2044
+ result = _protocol_object(method, await self.request(method, params))
2045
+ data = result.get("data")
2046
+ if not isinstance(data, list):
2047
+ raise CodexAppServerProtocolError(
2048
+ f"{method} did not return a thread list",
2049
+ request_sent=True,
2050
+ safe_to_retry=False,
2051
+ )
2052
+ for thread in data:
2053
+ if not isinstance(thread, dict) or not str(thread.get("id") or "").strip():
2054
+ raise CodexAppServerProtocolError(
2055
+ f"{method} returned an invalid thread",
2056
+ request_sent=True,
2057
+ safe_to_retry=False,
2058
+ )
2059
+ self._known_thread_ids.add(str(thread["id"]).strip())
2060
+ descendants.append(dict(thread))
2061
+
2062
+ cursor = _protocol_cursor(method, result)
2063
+ if cursor is None:
2064
+ return descendants
2065
+ if cursor in seen_cursors:
2066
+ raise CodexAppServerProtocolError(
2067
+ f"{method} repeated a pagination cursor",
2068
+ request_sent=True,
2069
+ safe_to_retry=False,
2070
+ )
2071
+ seen_cursors.add(cursor)
2072
+
2073
+ async def list_permission_profiles(
2074
+ self,
2075
+ *,
2076
+ cwd: str | None = None,
2077
+ page_size: int | None = None,
2078
+ ) -> list[dict[str, Any]]:
2079
+ """Return every selectable permission profile across cursor pages."""
2080
+
2081
+ if cwd is not None:
2082
+ _require_nonempty_string(cwd, "cwd")
2083
+ if page_size is not None and (
2084
+ isinstance(page_size, bool)
2085
+ or not isinstance(page_size, int)
2086
+ or page_size < 1
2087
+ ):
2088
+ raise ValueError("page_size must be a positive integer")
2089
+
2090
+ profiles: list[dict[str, Any]] = []
2091
+ cursor: str | None = None
2092
+ seen_cursors: set[str] = set()
2093
+ while True:
2094
+ params: dict[str, Any] = {}
2095
+ if cwd is not None:
2096
+ params["cwd"] = cwd
2097
+ if page_size is not None:
2098
+ params["limit"] = page_size
2099
+ if cursor is not None:
2100
+ params["cursor"] = cursor
2101
+
2102
+ method = "permissionProfile/list"
2103
+ result = _protocol_object(method, await self.request(method, params))
2104
+ data = result.get("data")
2105
+ if not isinstance(data, list):
2106
+ raise CodexAppServerProtocolError(
2107
+ f"{method} did not return a profile list",
2108
+ request_sent=True,
2109
+ safe_to_retry=False,
2110
+ )
2111
+ for profile in data:
2112
+ if not isinstance(profile, dict):
2113
+ raise CodexAppServerProtocolError(
2114
+ f"{method} returned a non-object profile",
2115
+ request_sent=True,
2116
+ safe_to_retry=False,
2117
+ )
2118
+ profile_id = profile.get("id")
2119
+ allowed = profile.get("allowed")
2120
+ description = profile.get("description")
2121
+ if not isinstance(profile_id, str) or not profile_id:
2122
+ raise CodexAppServerProtocolError(
2123
+ f"{method} returned a profile without an id",
2124
+ request_sent=True,
2125
+ safe_to_retry=False,
2126
+ )
2127
+ if not isinstance(allowed, bool) or (
2128
+ description is not None and not isinstance(description, str)
2129
+ ):
2130
+ raise CodexAppServerProtocolError(
2131
+ f"{method} returned an invalid profile",
2132
+ request_sent=True,
2133
+ safe_to_retry=False,
2134
+ )
2135
+ profiles.append(dict(profile))
2136
+
2137
+ cursor = _protocol_cursor(method, result)
2138
+ if cursor is None:
2139
+ return profiles
2140
+ if cursor in seen_cursors:
2141
+ raise CodexAppServerProtocolError(
2142
+ f"{method} repeated a pagination cursor",
2143
+ request_sent=True,
2144
+ safe_to_retry=False,
2145
+ )
2146
+ seen_cursors.add(cursor)
2147
+
2148
+ async def set_thread_goal(
2149
+ self,
2150
+ thread_id: str,
2151
+ *,
2152
+ objective: str | None | _OmittedType = _OMITTED,
2153
+ status: str | None | _OmittedType = _OMITTED,
2154
+ token_budget: int | None | _OmittedType = _OMITTED,
2155
+ ) -> dict[str, Any]:
2156
+ """Set or update the native persisted goal for a thread."""
2157
+
2158
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2159
+ params: dict[str, Any] = {"threadId": thread_id}
2160
+ if objective is not _OMITTED:
2161
+ if objective is not None:
2162
+ if not isinstance(objective, str) or not objective.strip():
2163
+ raise ValueError("objective must be a non-empty string")
2164
+ if len(objective) > 4000:
2165
+ raise ValueError("objective must be at most 4000 characters")
2166
+ params["objective"] = objective
2167
+ if status is not _OMITTED:
2168
+ if status is not None and status not in _THREAD_GOAL_STATUSES:
2169
+ raise ValueError(f"invalid thread goal status: {status}")
2170
+ params["status"] = status
2171
+ if token_budget is not _OMITTED:
2172
+ if token_budget is not None and (
2173
+ isinstance(token_budget, bool) or not isinstance(token_budget, int)
2174
+ ):
2175
+ raise ValueError("token_budget must be an integer or null")
2176
+ params["tokenBudget"] = token_budget
2177
+
2178
+ method = "thread/goal/set"
2179
+ result = _protocol_object(method, await self.request(method, params))
2180
+ return _protocol_thread_goal(
2181
+ method,
2182
+ result.get("goal"),
2183
+ thread_id=thread_id,
2184
+ )
2185
+
2186
+ async def get_thread_goal(self, thread_id: str) -> dict[str, Any] | None:
2187
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2188
+ method = "thread/goal/get"
2189
+ result = _protocol_object(
2190
+ method,
2191
+ await self.request(method, {"threadId": thread_id}),
2192
+ )
2193
+ goal = result.get("goal")
2194
+ if goal is None:
2195
+ return None
2196
+ return _protocol_thread_goal(method, goal, thread_id=thread_id)
2197
+
2198
+ async def clear_thread_goal(self, thread_id: str) -> bool:
2199
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2200
+ method = "thread/goal/clear"
2201
+ result = _protocol_object(
2202
+ method,
2203
+ await self.request(method, {"threadId": thread_id}),
2204
+ )
2205
+ cleared = result.get("cleared")
2206
+ if not isinstance(cleared, bool):
2207
+ raise CodexAppServerProtocolError(
2208
+ f"{method} did not return a cleared flag",
2209
+ request_sent=True,
2210
+ safe_to_retry=False,
2211
+ )
2212
+ return cleared
2213
+
2214
+ async def compact_thread(self, thread_id: str) -> None:
2215
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2216
+ method = "thread/compact/start"
2217
+ _protocol_empty_object(
2218
+ method,
2219
+ await self.request(method, {"threadId": thread_id}),
2220
+ )
2221
+
2222
+ async def rollback_thread(
2223
+ self,
2224
+ thread_id: str,
2225
+ *,
2226
+ num_turns: int,
2227
+ ) -> dict[str, Any]:
2228
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2229
+ if (
2230
+ isinstance(num_turns, bool)
2231
+ or not isinstance(num_turns, int)
2232
+ or num_turns < 1
2233
+ ):
2234
+ raise ValueError("num_turns must be a positive integer")
2235
+ method = "thread/rollback"
2236
+ result = _protocol_object(
2237
+ method,
2238
+ await self.request(
2239
+ method,
2240
+ {"threadId": thread_id, "numTurns": num_turns},
2241
+ timeout=self.lifecycle_timeout,
2242
+ ),
2243
+ )
2244
+ thread = result.get("thread")
2245
+ if not isinstance(thread, dict) or thread.get("id") != thread_id:
2246
+ raise CodexAppServerProtocolError(
2247
+ f"{method} did not return the requested thread",
2248
+ request_sent=True,
2249
+ safe_to_retry=False,
2250
+ )
2251
+ return dict(thread)
2252
+
2253
+ async def start_review(
2254
+ self,
2255
+ thread_id: str,
2256
+ target: dict[str, Any],
2257
+ *,
2258
+ delivery: str = "inline",
2259
+ ) -> dict[str, Any]:
2260
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2261
+ if delivery not in _REVIEW_DELIVERIES:
2262
+ raise ValueError(f"invalid review delivery: {delivery}")
2263
+ if not isinstance(target, dict):
2264
+ raise ValueError("review target must be an object")
2265
+ review_target = dict(target)
2266
+ target_type = review_target.get("type")
2267
+ if target_type not in _REVIEW_TARGET_TYPES:
2268
+ raise ValueError(f"invalid review target type: {target_type}")
2269
+ if target_type == "baseBranch":
2270
+ _require_nonempty_string(review_target.get("branch"), "target.branch")
2271
+ elif target_type == "commit":
2272
+ _require_nonempty_string(review_target.get("sha"), "target.sha")
2273
+ title = review_target.get("title")
2274
+ if title is not None and not isinstance(title, str):
2275
+ raise ValueError("target.title must be a string or null")
2276
+ elif target_type == "custom":
2277
+ _require_nonempty_string(
2278
+ review_target.get("instructions"),
2279
+ "target.instructions",
2280
+ )
2281
+
2282
+ method = "review/start"
2283
+ result = _protocol_object(
2284
+ method,
2285
+ await self.request(
2286
+ method,
2287
+ {
2288
+ "threadId": thread_id,
2289
+ "target": review_target,
2290
+ "delivery": delivery,
2291
+ },
2292
+ timeout=self.lifecycle_timeout,
2293
+ ),
2294
+ )
2295
+ review_thread_id = result.get("reviewThreadId")
2296
+ if not isinstance(review_thread_id, str) or not review_thread_id:
2297
+ raise CodexAppServerProtocolError(
2298
+ f"{method} did not return a review thread id",
2299
+ request_sent=True,
2300
+ safe_to_retry=False,
2301
+ )
2302
+ if delivery == "inline" and review_thread_id != thread_id:
2303
+ raise CodexAppServerProtocolError(
2304
+ f"{method} returned a detached thread for an inline review",
2305
+ request_sent=True,
2306
+ safe_to_retry=False,
2307
+ )
2308
+ validated = dict(result)
2309
+ validated["turn"] = _protocol_turn(method, result.get("turn"))
2310
+ return validated
2311
+
2312
+ async def run_thread_shell_command(self, thread_id: str, command: str) -> None:
2313
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2314
+ command = _require_nonempty_string(command, "command")
2315
+ method = "thread/shellCommand"
2316
+ _protocol_empty_object(
2317
+ method,
2318
+ await self.request(
2319
+ method,
2320
+ {"threadId": thread_id, "command": command},
2321
+ ),
2322
+ )
2323
+
2324
+ async def list_background_terminals(
2325
+ self,
2326
+ thread_id: str,
2327
+ *,
2328
+ page_size: int | None = None,
2329
+ ) -> list[dict[str, Any]]:
2330
+ """Return every live background terminal across cursor pages."""
2331
+
2332
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2333
+ if page_size is not None and (
2334
+ isinstance(page_size, bool)
2335
+ or not isinstance(page_size, int)
2336
+ or page_size < 1
2337
+ ):
2338
+ raise ValueError("page_size must be a positive integer")
2339
+
2340
+ method = "thread/backgroundTerminals/list"
2341
+ terminals: list[dict[str, Any]] = []
2342
+ cursor: str | None = None
2343
+ seen_cursors: set[str] = set()
2344
+ while True:
2345
+ params: dict[str, Any] = {"threadId": thread_id}
2346
+ if page_size is not None:
2347
+ params["limit"] = page_size
2348
+ if cursor is not None:
2349
+ params["cursor"] = cursor
2350
+ result = _protocol_object(method, await self.request(method, params))
2351
+ data = result.get("data")
2352
+ if not isinstance(data, list):
2353
+ raise CodexAppServerProtocolError(
2354
+ f"{method} did not return a terminal list",
2355
+ request_sent=True,
2356
+ safe_to_retry=False,
2357
+ )
2358
+ for terminal in data:
2359
+ if not isinstance(terminal, dict):
2360
+ raise CodexAppServerProtocolError(
2361
+ f"{method} returned a non-object terminal",
2362
+ request_sent=True,
2363
+ safe_to_retry=False,
2364
+ )
2365
+ for field in ("itemId", "processId", "command", "cwd"):
2366
+ if not isinstance(terminal.get(field), str):
2367
+ raise CodexAppServerProtocolError(
2368
+ f"{method} returned a terminal with invalid {field}",
2369
+ request_sent=True,
2370
+ safe_to_retry=False,
2371
+ )
2372
+ for field in ("osPid", "rssKb"):
2373
+ value = terminal.get(field)
2374
+ if value is not None and (
2375
+ isinstance(value, bool) or not isinstance(value, int)
2376
+ ):
2377
+ raise CodexAppServerProtocolError(
2378
+ f"{method} returned a terminal with invalid {field}",
2379
+ request_sent=True,
2380
+ safe_to_retry=False,
2381
+ )
2382
+ cpu_percent = terminal.get("cpuPercent")
2383
+ if cpu_percent is not None and (
2384
+ isinstance(cpu_percent, bool)
2385
+ or not isinstance(cpu_percent, (int, float))
2386
+ ):
2387
+ raise CodexAppServerProtocolError(
2388
+ f"{method} returned a terminal with invalid cpuPercent",
2389
+ request_sent=True,
2390
+ safe_to_retry=False,
2391
+ )
2392
+ terminals.append(dict(terminal))
2393
+
2394
+ cursor = _protocol_cursor(method, result)
2395
+ if cursor is None:
2396
+ return terminals
2397
+ if cursor in seen_cursors:
2398
+ raise CodexAppServerProtocolError(
2399
+ f"{method} repeated a pagination cursor",
2400
+ request_sent=True,
2401
+ safe_to_retry=False,
2402
+ )
2403
+ seen_cursors.add(cursor)
2404
+
2405
+ async def terminate_background_terminal(
2406
+ self,
2407
+ thread_id: str,
2408
+ process_id: str,
2409
+ ) -> bool:
2410
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2411
+ process_id = _require_nonempty_string(process_id, "process_id")
2412
+ method = "thread/backgroundTerminals/terminate"
2413
+ result = _protocol_object(
2414
+ method,
2415
+ await self.request(
2416
+ method,
2417
+ {"threadId": thread_id, "processId": process_id},
2418
+ ),
2419
+ )
2420
+ terminated = result.get("terminated")
2421
+ if not isinstance(terminated, bool):
2422
+ raise CodexAppServerProtocolError(
2423
+ f"{method} did not return a terminated flag",
2424
+ request_sent=True,
2425
+ safe_to_retry=False,
2426
+ )
2427
+ return terminated
2428
+
2429
+ async def clean_background_terminals(self, thread_id: str) -> None:
2430
+ thread_id = _require_nonempty_string(thread_id, "thread_id")
2431
+ method = "thread/backgroundTerminals/clean"
2432
+ _protocol_empty_object(
2433
+ method,
2434
+ await self.request(method, {"threadId": thread_id}),
2435
+ )
2436
+
2437
+ async def unsubscribe_thread(self, thread_id: str) -> str:
2438
+ await self.start()
2439
+ generation = self._generation
2440
+ owner = self._turns_by_thread.get(thread_id)
2441
+ owner_turn_id = owner.turn_id if owner is not None else None
2442
+ subscriptions = {
2443
+ subscription for subscription in self._subscriptions
2444
+ if subscription.thread_id == thread_id
2445
+ }
2446
+
2447
+ def same_scope() -> bool:
2448
+ return (
2449
+ self._generation == generation
2450
+ and self._turns_by_thread.get(thread_id) is owner
2451
+ and (owner is None or owner.turn_id == owner_turn_id)
2452
+ and not any(
2453
+ subscription.thread_id == thread_id
2454
+ and subscription not in subscriptions
2455
+ for subscription in self._subscriptions
2456
+ )
2457
+ )
2458
+
2459
+ result = await self._request_connected(
2460
+ "thread/unsubscribe", {"threadId": thread_id},
2461
+ before_send=same_scope,
2462
+ )
2463
+ status = str(result.get("status") or "") if isinstance(result, dict) else ""
2464
+ if status not in {"notLoaded", "notSubscribed", "unsubscribed"}:
2465
+ raise CodexAppServerProtocolError(
2466
+ "thread/unsubscribe returned an invalid status",
2467
+ request_sent=True,
2468
+ safe_to_retry=False,
2469
+ )
2470
+ # An old unsubscribe response must not retire a successor installed
2471
+ # while the request was in flight, or one on a reconnected transport.
2472
+ if self._generation == generation and not (
2473
+ owner is not None
2474
+ and self._turns_by_thread.get(thread_id) is owner
2475
+ and owner.turn_id != owner_turn_id
2476
+ ):
2477
+ if same_scope():
2478
+ self._loaded_threads.discard(thread_id)
2479
+ if owner is not None and self._turns_by_thread.get(thread_id) is owner:
2480
+ self._turns_by_thread.pop(thread_id, None)
2481
+ owner._completed = True
2482
+ owner._closed = True
2483
+ for subscription in subscriptions:
2484
+ subscription._finish()
2485
+ return status
2486
+
2487
+ async def steer_turn(
2488
+ self,
2489
+ thread_id: str,
2490
+ turn_id: str,
2491
+ input_items: list[dict[str, Any]],
2492
+ *,
2493
+ client_user_message_id: str | None = None,
2494
+ ) -> str:
2495
+ params: dict[str, Any] = {
2496
+ "threadId": thread_id,
2497
+ "expectedTurnId": turn_id,
2498
+ "input": input_items,
2499
+ }
2500
+ if client_user_message_id:
2501
+ params["clientUserMessageId"] = client_user_message_id
2502
+ result = await self.request("turn/steer", params)
2503
+ resolved = str(result.get("turnId") or "") if isinstance(result, dict) else ""
2504
+ if resolved != turn_id:
2505
+ raise CodexAppServerProtocolError(
2506
+ f"turn/steer returned unexpected turn id {resolved or '<missing>'}",
2507
+ request_sent=True,
2508
+ safe_to_retry=False,
2509
+ )
2510
+ return resolved
2511
+
2512
+ async def steer_turn_with_notification_watermark(
2513
+ self,
2514
+ thread_id: str,
2515
+ turn_id: str,
2516
+ input_items: list[dict[str, Any]],
2517
+ *,
2518
+ client_user_message_id: str | None = None,
2519
+ notification_subscription: CodexAppServerSubscription | None = None,
2520
+ before_send: Callable[[], bool] | None = None,
2521
+ ) -> tuple[str, int]:
2522
+ """Return the exact receive-order boundary of the steer response.
2523
+
2524
+ Notifications at or below the watermark were read before app-server's
2525
+ steer acknowledgement and therefore belong to the preceding logical
2526
+ AgentsDock run. Later notifications belong to the steered run.
2527
+ """
2528
+ params: dict[str, Any] = {
2529
+ "threadId": thread_id,
2530
+ "expectedTurnId": turn_id,
2531
+ "input": input_items,
2532
+ }
2533
+ if client_user_message_id:
2534
+ params["clientUserMessageId"] = client_user_message_id
2535
+ if notification_subscription is None:
2536
+ active_turn = self._turns_by_thread.get(thread_id)
2537
+ if active_turn is not None and active_turn.turn_id == turn_id:
2538
+ notification_subscription = active_turn._subscription
2539
+ if notification_subscription is None:
2540
+ raise CodexAppServerProtocolError(
2541
+ "turn/steer notification stream is unavailable",
2542
+ request_sent=False,
2543
+ safe_to_retry=True,
2544
+ )
2545
+ await self.start()
2546
+ response = await self._request_connected(
2547
+ "turn/steer",
2548
+ params,
2549
+ notification_boundary_subscription=notification_subscription,
2550
+ before_send=before_send,
2551
+ )
2552
+ if (
2553
+ not isinstance(response, tuple)
2554
+ or len(response) != 2
2555
+ or not isinstance(response[1], int)
2556
+ ):
2557
+ raise CodexAppServerProtocolError(
2558
+ "turn/steer did not return a notification watermark",
2559
+ request_sent=True,
2560
+ safe_to_retry=False,
2561
+ )
2562
+ result, watermark = response
2563
+ resolved = str(result.get("turnId") or "") if isinstance(result, dict) else ""
2564
+ if resolved != turn_id:
2565
+ raise CodexAppServerProtocolError(
2566
+ f"turn/steer returned unexpected turn id {resolved or '<missing>'}",
2567
+ request_sent=True,
2568
+ safe_to_retry=False,
2569
+ )
2570
+ return resolved, watermark
2571
+
2572
+ async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
2573
+ await self.request(
2574
+ "turn/interrupt",
2575
+ {"threadId": thread_id, "turnId": turn_id},
2576
+ )
2577
+
2578
+ async def start_turn(
2579
+ self,
2580
+ thread_id: str,
2581
+ input_items: list[dict[str, Any]],
2582
+ *,
2583
+ overrides: dict[str, Any] | None = None,
2584
+ retain_thread_stream: bool = False,
2585
+ ) -> CodexAppServerTurn:
2586
+ # Initialize before installing the provisional turn subscription.
2587
+ # Otherwise the first lazy start would correctly discard it as stale
2588
+ # transport state.
2589
+ await self.start()
2590
+ if thread_id in self._turns_by_thread:
2591
+ raise CodexAppServerError(
2592
+ f"thread {thread_id} already has an active app-server turn"
2593
+ )
2594
+
2595
+ subscription = self.subscribe(thread_id=thread_id)
2596
+ provisional = CodexAppServerTurn(
2597
+ self, thread_id, "", subscription,
2598
+ _retain_thread_stream=retain_thread_stream,
2599
+ )
2600
+ self._turns_by_thread[thread_id] = provisional
2601
+
2602
+ params = dict(overrides or {})
2603
+ params["threadId"] = thread_id
2604
+ params["input"] = input_items
2605
+ try:
2606
+ result = await self._request_connected(
2607
+ "turn/start",
2608
+ params,
2609
+ transport_turn=provisional,
2610
+ )
2611
+ turn = result.get("turn") if isinstance(result, dict) else None
2612
+ turn_id = str(turn.get("id") or "") if isinstance(turn, dict) else ""
2613
+ if not turn_id:
2614
+ raise CodexAppServerProtocolError(
2615
+ "turn/start did not return a turn id",
2616
+ request_sent=True,
2617
+ safe_to_retry=False,
2618
+ )
2619
+ if provisional.turn_id and provisional.turn_id != turn_id:
2620
+ if (
2621
+ retain_thread_stream
2622
+ and turn_id in provisional._completed_turn_ids
2623
+ and self._turns_by_thread.get(thread_id) is provisional
2624
+ and provisional.transport_generation == self._generation
2625
+ and not provisional._closed
2626
+ ):
2627
+ # The initial accepted turn can finish and natively
2628
+ # continue before its delayed start response is read.
2629
+ # Acceptance of that exact completed ID is proven; leave
2630
+ # the handle bound to the newer live turn, not the reply.
2631
+ return provisional
2632
+ raise CodexAppServerProtocolError(
2633
+ "turn/start response did not match its early notifications",
2634
+ request_sent=True,
2635
+ safe_to_retry=False,
2636
+ )
2637
+ provisional.turn_id = turn_id
2638
+ if not retain_thread_stream:
2639
+ provisional._subscription.turn_id = turn_id
2640
+ return provisional
2641
+ except asyncio.CancelledError as exc:
2642
+ # turn/start may already have reached app-server. Transfer the
2643
+ # provisional handle to the owning runner so its cancellation
2644
+ # cleanup can interrupt/release it instead of leaving this thread
2645
+ # permanently occupied in _turns_by_thread.
2646
+ if self._turns_by_thread.get(thread_id) is provisional:
2647
+ exc.pending_turn = provisional
2648
+ else:
2649
+ provisional._subscription._finish()
2650
+ raise
2651
+ except CodexAppServerError as exc:
2652
+ if not exc.safe_to_retry:
2653
+ exc.pending_turn = provisional
2654
+ # A timeout can race the acceptance response. Keep the
2655
+ # provisional subscription alive so later notifications can
2656
+ # bind the real turn id and prove acceptance.
2657
+ if self._turns_by_thread.get(thread_id) is provisional:
2658
+ raise
2659
+ if self._turns_by_thread.get(thread_id) is provisional:
2660
+ self._turns_by_thread.pop(thread_id, None)
2661
+ provisional._subscription._finish()
2662
+ raise
2663
+ except Exception:
2664
+ if self._turns_by_thread.get(thread_id) is provisional:
2665
+ self._turns_by_thread.pop(thread_id, None)
2666
+ provisional._subscription._finish()
2667
+ raise
2668
+
2669
+ def release_turn(self, turn: CodexAppServerTurn) -> None:
2670
+ if self._turns_by_thread.get(turn.thread_id) is turn:
2671
+ self._turns_by_thread.pop(turn.thread_id, None)
2672
+ turn._subscription._finish()
2673
+
2674
+ def active_turn(self, thread_id: str) -> CodexAppServerTurn | None:
2675
+ turn = self._turns_by_thread.get(thread_id)
2676
+ # Retaining stream ownership does not mean the provider is executing
2677
+ # between turns. A new native turn/started makes this handle active.
2678
+ return turn if turn is not None and not turn._completed else None
2679
+
2680
+
2681
+ class CodexAppServerManager:
2682
+ """Lifecycle façade owning exactly one lazy shared app-server client."""
2683
+
2684
+ def __init__(
2685
+ self,
2686
+ codex_bin: str,
2687
+ *,
2688
+ cwd: str,
2689
+ env_factory: Callable[[], dict[str, str]],
2690
+ app_server_args: Sequence[str] = (),
2691
+ request_timeout: float = 30.0,
2692
+ lifecycle_timeout: float = 300.0,
2693
+ process_stream_limit: int = 16 * 1024 * 1024,
2694
+ notification_queue_limit: int = 8192,
2695
+ json_parse_thread_threshold: int = 1024 * 1024,
2696
+ process_factory: ProcessFactory | None = None,
2697
+ before_start: Callable[[], Awaitable[Any]] | None = None,
2698
+ server_request_handler: ServerRequestHandler | None = None,
2699
+ initialize_params: dict[str, Any] | None = None,
2700
+ on_process_started: ProcessLifecycleHook | None = None,
2701
+ on_process_exited: ProcessLifecycleHook | None = None,
2702
+ sensitive_values: Sequence[str] = (),
2703
+ protected_env_keys: Sequence[str] = (),
2704
+ ) -> None:
2705
+ self.client = CodexAppServerClient(
2706
+ codex_bin,
2707
+ cwd=cwd,
2708
+ env_factory=env_factory,
2709
+ app_server_args=app_server_args,
2710
+ request_timeout=request_timeout,
2711
+ lifecycle_timeout=lifecycle_timeout,
2712
+ process_stream_limit=process_stream_limit,
2713
+ notification_queue_limit=notification_queue_limit,
2714
+ json_parse_thread_threshold=json_parse_thread_threshold,
2715
+ process_factory=process_factory,
2716
+ before_start=before_start,
2717
+ server_request_handler=server_request_handler,
2718
+ initialize_params=initialize_params,
2719
+ on_process_started=on_process_started,
2720
+ on_process_exited=on_process_exited,
2721
+ sensitive_values=sensitive_values,
2722
+ protected_env_keys=protected_env_keys,
2723
+ )
2724
+
2725
+ @property
2726
+ def ready(self) -> bool:
2727
+ return self.client.ready
2728
+
2729
+ @property
2730
+ def process(self) -> asyncio.subprocess.Process | None:
2731
+ return self.client.process
2732
+
2733
+ @property
2734
+ def generation(self) -> int:
2735
+ return self.client.generation
2736
+
2737
+ def is_thread_loaded(self, thread_id: str) -> bool:
2738
+ return self.client.is_thread_loaded(thread_id)
2739
+
2740
+ def cached_thread_name(self, thread_id: str) -> str | None:
2741
+ return self.client.cached_thread_name(thread_id)
2742
+
2743
+ async def start(self) -> None:
2744
+ await self.client.start()
2745
+
2746
+ async def close(self) -> None:
2747
+ await self.client.close()
2748
+
2749
+ async def retire_generation(self, expected_generation: int) -> bool:
2750
+ return await self.client.retire_generation(expected_generation)
2751
+
2752
+ async def __aenter__(self) -> "CodexAppServerManager":
2753
+ await self.start()
2754
+ return self
2755
+
2756
+ async def __aexit__(self, *_exc: Any) -> None:
2757
+ await self.close()
2758
+
2759
+ def subscribe_thread(self, thread_id: str) -> CodexAppServerSubscription:
2760
+ return self.client.subscribe_thread(thread_id)
2761
+
2762
+ def subscribe_turn(
2763
+ self,
2764
+ thread_id: str,
2765
+ turn_id: str,
2766
+ ) -> CodexAppServerSubscription:
2767
+ return self.client.subscribe_turn(thread_id, turn_id)
2768
+
2769
+ def add_notification_handler(self, handler: NotificationHandler) -> None:
2770
+ self.client.add_notification_handler(handler)
2771
+
2772
+ def remove_notification_handler(self, handler: NotificationHandler) -> None:
2773
+ self.client.remove_notification_handler(handler)
2774
+
2775
+ async def wait_for_notification_handler(
2776
+ self,
2777
+ handler: NotificationHandler,
2778
+ thread_id: str,
2779
+ ) -> None:
2780
+ await self.client.wait_for_notification_handler(handler, thread_id)
2781
+
2782
+ async def request(
2783
+ self,
2784
+ method: str,
2785
+ params: dict[str, Any],
2786
+ *,
2787
+ timeout: float | None = None,
2788
+ ) -> Any:
2789
+ return await self.client.request(method, params, timeout=timeout)
2790
+
2791
+ async def notify(
2792
+ self,
2793
+ method: str,
2794
+ params: dict[str, Any] | None = None,
2795
+ ) -> None:
2796
+ await self.client.notify(method, params)
2797
+
2798
+ async def start_thread(self, params: dict[str, Any]) -> str:
2799
+ return await self.client.start_thread(params)
2800
+
2801
+ async def resume_thread(
2802
+ self,
2803
+ thread_id: str,
2804
+ params: dict[str, Any] | None = None,
2805
+ ) -> str:
2806
+ return await self.client.resume_thread(thread_id, params)
2807
+
2808
+ async def start_or_resume_thread(
2809
+ self,
2810
+ thread_id: str | None,
2811
+ start_params: dict[str, Any],
2812
+ resume_params: dict[str, Any] | None = None,
2813
+ ) -> str:
2814
+ return await self.client.start_or_resume_thread(
2815
+ thread_id,
2816
+ start_params,
2817
+ resume_params,
2818
+ )
2819
+
2820
+ async def fork_thread(
2821
+ self,
2822
+ thread_id: str,
2823
+ params: dict[str, Any] | None = None,
2824
+ *,
2825
+ last_turn_id: str | None = None,
2826
+ ) -> str:
2827
+ return await self.client.fork_thread(
2828
+ thread_id,
2829
+ params,
2830
+ last_turn_id=last_turn_id,
2831
+ )
2832
+
2833
+ async def inject_items(
2834
+ self,
2835
+ thread_id: str,
2836
+ items: list[dict[str, Any]],
2837
+ ) -> None:
2838
+ await self.client.inject_items(thread_id, items)
2839
+
2840
+ async def inject_items_guarded(
2841
+ self,
2842
+ thread_id: str,
2843
+ items: list[dict[str, Any]],
2844
+ *,
2845
+ expected_generation: int,
2846
+ before_send: Callable[[], bool],
2847
+ timeout: float | None = None,
2848
+ ) -> None:
2849
+ await self.client.inject_items_guarded(
2850
+ thread_id, items, expected_generation=expected_generation,
2851
+ before_send=before_send, timeout=timeout,
2852
+ )
2853
+
2854
+ async def read_thread(
2855
+ self,
2856
+ thread_id: str,
2857
+ *,
2858
+ include_turns: bool = False,
2859
+ ) -> dict[str, Any]:
2860
+ return await self.client.read_thread(
2861
+ thread_id,
2862
+ include_turns=include_turns,
2863
+ )
2864
+
2865
+ async def delete_thread(self, thread_id: str) -> None:
2866
+ await self.client.delete_thread(thread_id)
2867
+
2868
+ async def list_turns(
2869
+ self,
2870
+ thread_id: str,
2871
+ *,
2872
+ limit: int = 4,
2873
+ items_view: str = "full",
2874
+ sort_direction: str = "desc",
2875
+ ) -> list[dict[str, Any]]:
2876
+ return await self.client.list_turns(
2877
+ thread_id,
2878
+ limit=limit,
2879
+ items_view=items_view,
2880
+ sort_direction=sort_direction,
2881
+ )
2882
+
2883
+ async def list_descendant_threads(
2884
+ self,
2885
+ thread_id: str,
2886
+ *,
2887
+ page_size: int = 100,
2888
+ ) -> list[dict[str, Any]]:
2889
+ return await self.client.list_descendant_threads(
2890
+ thread_id,
2891
+ page_size=page_size,
2892
+ )
2893
+
2894
+ async def list_permission_profiles(
2895
+ self,
2896
+ *,
2897
+ cwd: str | None = None,
2898
+ page_size: int | None = None,
2899
+ ) -> list[dict[str, Any]]:
2900
+ return await self.client.list_permission_profiles(
2901
+ cwd=cwd,
2902
+ page_size=page_size,
2903
+ )
2904
+
2905
+ async def set_thread_goal(
2906
+ self,
2907
+ thread_id: str,
2908
+ *,
2909
+ objective: str | None | _OmittedType = _OMITTED,
2910
+ status: str | None | _OmittedType = _OMITTED,
2911
+ token_budget: int | None | _OmittedType = _OMITTED,
2912
+ ) -> dict[str, Any]:
2913
+ return await self.client.set_thread_goal(
2914
+ thread_id,
2915
+ objective=objective,
2916
+ status=status,
2917
+ token_budget=token_budget,
2918
+ )
2919
+
2920
+ async def get_thread_goal(self, thread_id: str) -> dict[str, Any] | None:
2921
+ return await self.client.get_thread_goal(thread_id)
2922
+
2923
+ async def clear_thread_goal(self, thread_id: str) -> bool:
2924
+ return await self.client.clear_thread_goal(thread_id)
2925
+
2926
+ async def compact_thread(self, thread_id: str) -> None:
2927
+ await self.client.compact_thread(thread_id)
2928
+
2929
+ async def rollback_thread(
2930
+ self,
2931
+ thread_id: str,
2932
+ *,
2933
+ num_turns: int,
2934
+ ) -> dict[str, Any]:
2935
+ return await self.client.rollback_thread(thread_id, num_turns=num_turns)
2936
+
2937
+ async def start_review(
2938
+ self,
2939
+ thread_id: str,
2940
+ target: dict[str, Any],
2941
+ *,
2942
+ delivery: str = "inline",
2943
+ ) -> dict[str, Any]:
2944
+ return await self.client.start_review(
2945
+ thread_id,
2946
+ target,
2947
+ delivery=delivery,
2948
+ )
2949
+
2950
+ async def run_thread_shell_command(self, thread_id: str, command: str) -> None:
2951
+ await self.client.run_thread_shell_command(thread_id, command)
2952
+
2953
+ async def list_background_terminals(
2954
+ self,
2955
+ thread_id: str,
2956
+ *,
2957
+ page_size: int | None = None,
2958
+ ) -> list[dict[str, Any]]:
2959
+ return await self.client.list_background_terminals(
2960
+ thread_id,
2961
+ page_size=page_size,
2962
+ )
2963
+
2964
+ async def terminate_background_terminal(
2965
+ self,
2966
+ thread_id: str,
2967
+ process_id: str,
2968
+ ) -> bool:
2969
+ return await self.client.terminate_background_terminal(thread_id, process_id)
2970
+
2971
+ async def clean_background_terminals(self, thread_id: str) -> None:
2972
+ await self.client.clean_background_terminals(thread_id)
2973
+
2974
+ async def unsubscribe_thread(self, thread_id: str) -> str:
2975
+ return await self.client.unsubscribe_thread(thread_id)
2976
+
2977
+ async def start_turn(
2978
+ self,
2979
+ thread_id: str,
2980
+ input_items: list[dict[str, Any]],
2981
+ *,
2982
+ overrides: dict[str, Any] | None = None,
2983
+ retain_thread_stream: bool = False,
2984
+ ) -> CodexAppServerTurn:
2985
+ return await self.client.start_turn(
2986
+ thread_id,
2987
+ input_items,
2988
+ overrides=overrides,
2989
+ retain_thread_stream=retain_thread_stream,
2990
+ )
2991
+
2992
+ async def steer_turn(
2993
+ self,
2994
+ thread_id: str,
2995
+ turn_id: str,
2996
+ input_items: list[dict[str, Any]],
2997
+ *,
2998
+ client_user_message_id: str | None = None,
2999
+ ) -> str:
3000
+ return await self.client.steer_turn(
3001
+ thread_id,
3002
+ turn_id,
3003
+ input_items,
3004
+ client_user_message_id=client_user_message_id,
3005
+ )
3006
+
3007
+ async def steer_turn_with_notification_watermark(
3008
+ self,
3009
+ thread_id: str,
3010
+ turn_id: str,
3011
+ input_items: list[dict[str, Any]],
3012
+ *,
3013
+ client_user_message_id: str | None = None,
3014
+ notification_subscription: CodexAppServerSubscription | None = None,
3015
+ before_send: Callable[[], bool] | None = None,
3016
+ ) -> tuple[str, int]:
3017
+ return await self.client.steer_turn_with_notification_watermark(
3018
+ thread_id,
3019
+ turn_id,
3020
+ input_items,
3021
+ client_user_message_id=client_user_message_id,
3022
+ notification_subscription=notification_subscription,
3023
+ before_send=before_send,
3024
+ )
3025
+
3026
+ async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
3027
+ await self.client.interrupt_turn(thread_id, turn_id)
3028
+
3029
+ def active_turn(self, thread_id: str) -> CodexAppServerTurn | None:
3030
+ return self.client.active_turn(thread_id)