codex-autorunner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. codex_autorunner/__init__.py +3 -0
  2. codex_autorunner/bootstrap.py +151 -0
  3. codex_autorunner/cli.py +886 -0
  4. codex_autorunner/codex_cli.py +79 -0
  5. codex_autorunner/codex_runner.py +17 -0
  6. codex_autorunner/core/__init__.py +1 -0
  7. codex_autorunner/core/about_car.py +125 -0
  8. codex_autorunner/core/codex_runner.py +100 -0
  9. codex_autorunner/core/config.py +1465 -0
  10. codex_autorunner/core/doc_chat.py +547 -0
  11. codex_autorunner/core/docs.py +37 -0
  12. codex_autorunner/core/engine.py +720 -0
  13. codex_autorunner/core/git_utils.py +206 -0
  14. codex_autorunner/core/hub.py +756 -0
  15. codex_autorunner/core/injected_context.py +9 -0
  16. codex_autorunner/core/locks.py +57 -0
  17. codex_autorunner/core/logging_utils.py +158 -0
  18. codex_autorunner/core/notifications.py +465 -0
  19. codex_autorunner/core/optional_dependencies.py +41 -0
  20. codex_autorunner/core/prompt.py +107 -0
  21. codex_autorunner/core/prompts.py +275 -0
  22. codex_autorunner/core/request_context.py +21 -0
  23. codex_autorunner/core/runner_controller.py +116 -0
  24. codex_autorunner/core/runner_process.py +29 -0
  25. codex_autorunner/core/snapshot.py +576 -0
  26. codex_autorunner/core/state.py +156 -0
  27. codex_autorunner/core/update.py +567 -0
  28. codex_autorunner/core/update_runner.py +44 -0
  29. codex_autorunner/core/usage.py +1221 -0
  30. codex_autorunner/core/utils.py +108 -0
  31. codex_autorunner/discovery.py +102 -0
  32. codex_autorunner/housekeeping.py +423 -0
  33. codex_autorunner/integrations/__init__.py +1 -0
  34. codex_autorunner/integrations/app_server/__init__.py +6 -0
  35. codex_autorunner/integrations/app_server/client.py +1386 -0
  36. codex_autorunner/integrations/app_server/supervisor.py +206 -0
  37. codex_autorunner/integrations/github/__init__.py +10 -0
  38. codex_autorunner/integrations/github/service.py +889 -0
  39. codex_autorunner/integrations/telegram/__init__.py +1 -0
  40. codex_autorunner/integrations/telegram/adapter.py +1401 -0
  41. codex_autorunner/integrations/telegram/commands_registry.py +104 -0
  42. codex_autorunner/integrations/telegram/config.py +450 -0
  43. codex_autorunner/integrations/telegram/constants.py +154 -0
  44. codex_autorunner/integrations/telegram/dispatch.py +162 -0
  45. codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
  46. codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
  47. codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
  48. codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
  49. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
  50. codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
  51. codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
  52. codex_autorunner/integrations/telegram/helpers.py +2084 -0
  53. codex_autorunner/integrations/telegram/notifications.py +164 -0
  54. codex_autorunner/integrations/telegram/outbox.py +174 -0
  55. codex_autorunner/integrations/telegram/rendering.py +102 -0
  56. codex_autorunner/integrations/telegram/retry.py +37 -0
  57. codex_autorunner/integrations/telegram/runtime.py +270 -0
  58. codex_autorunner/integrations/telegram/service.py +921 -0
  59. codex_autorunner/integrations/telegram/state.py +1223 -0
  60. codex_autorunner/integrations/telegram/transport.py +318 -0
  61. codex_autorunner/integrations/telegram/types.py +57 -0
  62. codex_autorunner/integrations/telegram/voice.py +413 -0
  63. codex_autorunner/manifest.py +150 -0
  64. codex_autorunner/routes/__init__.py +53 -0
  65. codex_autorunner/routes/base.py +470 -0
  66. codex_autorunner/routes/docs.py +275 -0
  67. codex_autorunner/routes/github.py +197 -0
  68. codex_autorunner/routes/repos.py +121 -0
  69. codex_autorunner/routes/sessions.py +137 -0
  70. codex_autorunner/routes/shared.py +137 -0
  71. codex_autorunner/routes/system.py +175 -0
  72. codex_autorunner/routes/terminal_images.py +107 -0
  73. codex_autorunner/routes/voice.py +128 -0
  74. codex_autorunner/server.py +23 -0
  75. codex_autorunner/spec_ingest.py +113 -0
  76. codex_autorunner/static/app.js +95 -0
  77. codex_autorunner/static/autoRefresh.js +209 -0
  78. codex_autorunner/static/bootstrap.js +105 -0
  79. codex_autorunner/static/bus.js +23 -0
  80. codex_autorunner/static/cache.js +52 -0
  81. codex_autorunner/static/constants.js +48 -0
  82. codex_autorunner/static/dashboard.js +795 -0
  83. codex_autorunner/static/docs.js +1514 -0
  84. codex_autorunner/static/env.js +99 -0
  85. codex_autorunner/static/github.js +168 -0
  86. codex_autorunner/static/hub.js +1511 -0
  87. codex_autorunner/static/index.html +622 -0
  88. codex_autorunner/static/loader.js +28 -0
  89. codex_autorunner/static/logs.js +690 -0
  90. codex_autorunner/static/mobileCompact.js +300 -0
  91. codex_autorunner/static/snapshot.js +116 -0
  92. codex_autorunner/static/state.js +87 -0
  93. codex_autorunner/static/styles.css +4966 -0
  94. codex_autorunner/static/tabs.js +50 -0
  95. codex_autorunner/static/terminal.js +21 -0
  96. codex_autorunner/static/terminalManager.js +3535 -0
  97. codex_autorunner/static/todoPreview.js +25 -0
  98. codex_autorunner/static/types.d.ts +8 -0
  99. codex_autorunner/static/utils.js +597 -0
  100. codex_autorunner/static/vendor/LICENSE.xterm +24 -0
  101. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
  102. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
  103. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
  104. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
  105. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
  106. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
  107. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
  108. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
  109. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
  110. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
  111. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
  112. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
  113. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
  114. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
  115. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
  116. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
  117. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
  118. codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
  119. codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
  120. codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
  121. codex_autorunner/static/vendor/xterm.css +209 -0
  122. codex_autorunner/static/vendor/xterm.js +2 -0
  123. codex_autorunner/static/voice.js +591 -0
  124. codex_autorunner/voice/__init__.py +39 -0
  125. codex_autorunner/voice/capture.py +349 -0
  126. codex_autorunner/voice/config.py +167 -0
  127. codex_autorunner/voice/provider.py +66 -0
  128. codex_autorunner/voice/providers/__init__.py +7 -0
  129. codex_autorunner/voice/providers/openai_whisper.py +345 -0
  130. codex_autorunner/voice/resolver.py +36 -0
  131. codex_autorunner/voice/service.py +210 -0
  132. codex_autorunner/web/__init__.py +1 -0
  133. codex_autorunner/web/app.py +1037 -0
  134. codex_autorunner/web/hub_jobs.py +181 -0
  135. codex_autorunner/web/middleware.py +552 -0
  136. codex_autorunner/web/pty_session.py +357 -0
  137. codex_autorunner/web/runner_manager.py +25 -0
  138. codex_autorunner/web/schemas.py +253 -0
  139. codex_autorunner/web/static_assets.py +430 -0
  140. codex_autorunner/web/terminal_sessions.py +78 -0
  141. codex_autorunner/workspace.py +16 -0
  142. codex_autorunner-0.1.0.dist-info/METADATA +240 -0
  143. codex_autorunner-0.1.0.dist-info/RECORD +147 -0
  144. codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
  145. codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
  146. codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
  147. codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1386 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import random
5
+ import re
6
+ from collections import deque
7
+ from dataclasses import dataclass
8
+ from importlib import metadata as importlib_metadata
9
+ from pathlib import Path
10
+ from typing import Any, Awaitable, Callable, Dict, Optional, Sequence, Union, cast
11
+
12
+ from ...core.logging_utils import log_event, sanitize_log_value
13
+
14
+ ApprovalDecision = Union[str, Dict[str, Any]]
15
+ ApprovalHandler = Callable[[Dict[str, Any]], Awaitable[ApprovalDecision]]
16
+ NotificationHandler = Callable[[Dict[str, Any]], Awaitable[None]]
17
+ TurnKey = tuple[str, str]
18
+
19
+ APPROVAL_METHODS = {
20
+ "item/commandExecution/requestApproval",
21
+ "item/fileChange/requestApproval",
22
+ }
23
+ _READ_CHUNK_SIZE = 64 * 1024
24
+ _MAX_MESSAGE_BYTES = 50 * 1024 * 1024
25
+ _OVERSIZE_PREVIEW_BYTES = 4096
26
+ _MAX_OVERSIZE_DRAIN_BYTES = 100 * 1024 * 1024
27
+
28
+ _RESTART_BACKOFF_INITIAL_SECONDS = 0.5
29
+ _RESTART_BACKOFF_MAX_SECONDS = 30.0
30
+ _RESTART_BACKOFF_JITTER_RATIO = 0.1
31
+
32
+
33
+ class CodexAppServerError(Exception):
34
+ """Base error for app-server client failures."""
35
+
36
+
37
+ class CodexAppServerResponseError(CodexAppServerError):
38
+ """Raised when the app-server responds with an error payload."""
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ method: Optional[str],
44
+ code: Optional[int],
45
+ message: str,
46
+ data: Optional[Dict[str, Any]] = None,
47
+ ) -> None:
48
+ super().__init__(message)
49
+ self.method = method
50
+ self.code = code
51
+ self.data = data
52
+
53
+
54
+ class CodexAppServerDisconnected(CodexAppServerError):
55
+ """Raised when the app-server disconnects mid-flight."""
56
+
57
+
58
+ class CodexAppServerProtocolError(CodexAppServerError):
59
+ """Raised when the app-server returns malformed responses."""
60
+
61
+
62
+ @dataclass
63
+ class TurnResult:
64
+ turn_id: str
65
+ status: Optional[str]
66
+ agent_messages: list[str]
67
+ errors: list[str]
68
+ raw_events: list[Dict[str, Any]]
69
+
70
+
71
+ class TurnHandle:
72
+ def __init__(
73
+ self, client: "CodexAppServerClient", turn_id: str, thread_id: str
74
+ ) -> None:
75
+ self._client = client
76
+ self.turn_id = turn_id
77
+ self.thread_id = thread_id
78
+
79
+ async def wait(self, *, timeout: Optional[float] = None) -> TurnResult:
80
+ return await self._client.wait_for_turn(
81
+ self.turn_id, thread_id=self.thread_id, timeout=timeout
82
+ )
83
+
84
+
85
+ @dataclass
86
+ class _TurnState:
87
+ turn_id: str
88
+ thread_id: Optional[str]
89
+ future: asyncio.Future["TurnResult"]
90
+ agent_messages: list[str]
91
+ errors: list[str]
92
+ raw_events: list[Dict[str, Any]]
93
+ status: Optional[str] = None
94
+
95
+
96
+ class CodexAppServerClient:
97
+ def __init__(
98
+ self,
99
+ command: Sequence[str],
100
+ *,
101
+ cwd: Optional[Path] = None,
102
+ env: Optional[Dict[str, str]] = None,
103
+ approval_handler: Optional[ApprovalHandler] = None,
104
+ default_approval_decision: str = "cancel",
105
+ auto_restart: bool = True,
106
+ request_timeout: Optional[float] = None,
107
+ notification_handler: Optional[NotificationHandler] = None,
108
+ logger: Optional[logging.Logger] = None,
109
+ ) -> None:
110
+ self._command = [str(arg) for arg in command]
111
+ self._cwd = str(cwd) if cwd is not None else None
112
+ self._env = env
113
+ self._approval_handler = approval_handler
114
+ self._default_approval_decision = default_approval_decision
115
+ self._auto_restart = auto_restart
116
+ self._request_timeout = request_timeout
117
+ self._notification_handler = notification_handler
118
+ self._logger = logger or logging.getLogger(__name__)
119
+
120
+ self._process: Optional[asyncio.subprocess.Process] = None
121
+ self._reader_task: Optional[asyncio.Task] = None
122
+ self._stderr_task: Optional[asyncio.Task] = None
123
+ self._start_lock: Optional[asyncio.Lock] = None
124
+ self._write_lock: Optional[asyncio.Lock] = None
125
+ self._pending: Dict[int, asyncio.Future[Any]] = {}
126
+ self._pending_methods: Dict[int, str] = {}
127
+ self._turns: Dict[TurnKey, _TurnState] = {}
128
+ self._pending_turns: Dict[str, _TurnState] = {}
129
+ self._next_id = 1
130
+ self._initialized = False
131
+ self._initializing = False
132
+ self._closed = False
133
+ self._disconnected: Optional[asyncio.Event] = None
134
+ self._disconnected_set = True
135
+ self._client_version = _client_version()
136
+ self._include_client_version = True
137
+ self._restart_task: Optional[asyncio.Task] = None
138
+ self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
139
+ self._stderr_tail: deque[str] = deque(maxlen=5)
140
+
141
+ async def start(self) -> None:
142
+ await self._ensure_process()
143
+
144
+ async def close(self) -> None:
145
+ self._closed = True
146
+ if self._restart_task is not None:
147
+ self._restart_task.cancel()
148
+ try:
149
+ await self._restart_task
150
+ except asyncio.CancelledError:
151
+ pass
152
+ self._restart_task = None
153
+ await self._terminate_process()
154
+ self._fail_pending(CodexAppServerDisconnected("Client closed"))
155
+
156
+ async def wait_for_disconnect(self, *, timeout: Optional[float] = None) -> None:
157
+ disconnected = self._ensure_disconnect_event()
158
+ if timeout is None:
159
+ await disconnected.wait()
160
+ return
161
+ await asyncio.wait_for(disconnected.wait(), timeout)
162
+
163
+ async def request(
164
+ self,
165
+ method: str,
166
+ params: Optional[Dict[str, Any]] = None,
167
+ *,
168
+ timeout: Optional[float] = None,
169
+ ) -> Any:
170
+ await self._ensure_process()
171
+ return await self._request_raw(method, params=params, timeout=timeout)
172
+
173
+ async def notify(
174
+ self, method: str, params: Optional[Dict[str, Any]] = None
175
+ ) -> None:
176
+ await self._ensure_process()
177
+ log_event(
178
+ self._logger,
179
+ logging.INFO,
180
+ "app_server.notify",
181
+ method=method,
182
+ **_summarize_params(method, params),
183
+ )
184
+ await self._send_message(self._build_message(method, params=params))
185
+
186
+ async def thread_start(self, cwd: str, **kwargs: Any) -> Dict[str, Any]:
187
+ params = {"cwd": cwd}
188
+ params.update(kwargs)
189
+ result = await self.request("thread/start", params)
190
+ if not isinstance(result, dict):
191
+ raise CodexAppServerProtocolError("thread/start returned non-object result")
192
+ thread_id = _extract_thread_id(result)
193
+ if thread_id and "id" not in result:
194
+ result = dict(result)
195
+ result["id"] = thread_id
196
+ return result
197
+
198
+ async def thread_resume(self, thread_id: str, **kwargs: Any) -> Dict[str, Any]:
199
+ params = {"threadId": thread_id}
200
+ params.update(kwargs)
201
+ result = await self.request("thread/resume", params)
202
+ if not isinstance(result, dict):
203
+ raise CodexAppServerProtocolError(
204
+ "thread/resume returned non-object result"
205
+ )
206
+ resumed_id = _extract_thread_id(result)
207
+ if resumed_id and "id" not in result:
208
+ result = dict(result)
209
+ result["id"] = resumed_id
210
+ return result
211
+
212
+ async def thread_list(self, **kwargs: Any) -> Any:
213
+ params = kwargs if kwargs else {}
214
+ result = await self.request("thread/list", params)
215
+ if isinstance(result, dict) and "threads" not in result:
216
+ for key in ("data", "items", "results"):
217
+ value = result.get(key)
218
+ if isinstance(value, list):
219
+ result = dict(result)
220
+ result["threads"] = value
221
+ break
222
+ return result
223
+
224
+ async def turn_start(
225
+ self,
226
+ thread_id: str,
227
+ text: str,
228
+ *,
229
+ input_items: Optional[list[Dict[str, Any]]] = None,
230
+ approval_policy: Optional[str] = None,
231
+ sandbox_policy: Optional[str] = None,
232
+ **kwargs: Any,
233
+ ) -> TurnHandle:
234
+ params: Dict[str, Any] = {"threadId": thread_id}
235
+ if input_items is None:
236
+ params["input"] = [{"type": "text", "text": text}]
237
+ else:
238
+ params["input"] = input_items
239
+ if approval_policy:
240
+ params["approvalPolicy"] = approval_policy
241
+ if sandbox_policy:
242
+ params["sandboxPolicy"] = _normalize_sandbox_policy(sandbox_policy)
243
+ params.update(kwargs)
244
+ result = await self.request("turn/start", params)
245
+ if not isinstance(result, dict):
246
+ raise CodexAppServerProtocolError("turn/start returned non-object result")
247
+ turn_id = _extract_turn_id(result)
248
+ if not turn_id:
249
+ raise CodexAppServerProtocolError("turn/start response missing turn id")
250
+ self._register_turn_state(turn_id, thread_id)
251
+ return TurnHandle(self, turn_id, thread_id)
252
+
253
+ async def review_start(
254
+ self,
255
+ thread_id: str,
256
+ *,
257
+ target: Dict[str, Any],
258
+ delivery: str = "inline",
259
+ approval_policy: Optional[str] = None,
260
+ sandbox_policy: Optional[Any] = None,
261
+ **kwargs: Any,
262
+ ) -> TurnHandle:
263
+ params: Dict[str, Any] = {
264
+ "threadId": thread_id,
265
+ "target": target,
266
+ "delivery": delivery,
267
+ }
268
+ if approval_policy:
269
+ params["approvalPolicy"] = approval_policy
270
+ if sandbox_policy:
271
+ params["sandboxPolicy"] = _normalize_sandbox_policy(sandbox_policy)
272
+ params.update(kwargs)
273
+ result = await self.request("review/start", params)
274
+ if not isinstance(result, dict):
275
+ raise CodexAppServerProtocolError("review/start returned non-object result")
276
+ turn_id = _extract_turn_id(result)
277
+ if not turn_id:
278
+ raise CodexAppServerProtocolError("review/start response missing turn id")
279
+ self._register_turn_state(turn_id, thread_id)
280
+ return TurnHandle(self, turn_id, thread_id)
281
+
282
+ async def turn_interrupt(
283
+ self, turn_id: str, *, thread_id: Optional[str] = None
284
+ ) -> Any:
285
+ if thread_id is None:
286
+ _key, state = self._find_turn_state(turn_id, thread_id=None)
287
+ if state is None or not state.thread_id:
288
+ raise CodexAppServerProtocolError(
289
+ f"Unknown thread id for turn {turn_id}"
290
+ )
291
+ thread_id = state.thread_id
292
+ params = {"turnId": turn_id, "threadId": thread_id}
293
+ return await self.request("turn/interrupt", params)
294
+
295
+ async def wait_for_turn(
296
+ self,
297
+ turn_id: str,
298
+ *,
299
+ thread_id: Optional[str] = None,
300
+ timeout: Optional[float] = None,
301
+ ) -> TurnResult:
302
+ key, state = self._find_turn_state(turn_id, thread_id=thread_id)
303
+ if state is None:
304
+ raise CodexAppServerProtocolError(
305
+ f"Unknown turn id {turn_id} (thread {thread_id})"
306
+ )
307
+ if state.future.done():
308
+ result = state.future.result()
309
+ if key is not None:
310
+ self._turns.pop(key, None)
311
+ return result
312
+ timeout = timeout if timeout is not None else self._request_timeout
313
+ if timeout is None:
314
+ result = await state.future
315
+ if key is not None:
316
+ self._turns.pop(key, None)
317
+ return result
318
+ result = await asyncio.wait_for(state.future, timeout)
319
+ if key is not None:
320
+ self._turns.pop(key, None)
321
+ return result
322
+
323
+ async def _ensure_process(self) -> None:
324
+ self._ensure_locks()
325
+ start_lock = self._start_lock
326
+ if start_lock is None:
327
+ raise CodexAppServerProtocolError("start lock unavailable")
328
+ async with start_lock:
329
+ if self._closed:
330
+ raise CodexAppServerDisconnected("Client closed")
331
+ if (
332
+ self._process is not None
333
+ and self._process.returncode is None
334
+ and self._initialized
335
+ ):
336
+ return
337
+ await self._spawn_process()
338
+ await self._initialize_handshake()
339
+
340
+ async def _spawn_process(self) -> None:
341
+ await self._terminate_process()
342
+ self._process = await asyncio.create_subprocess_exec(
343
+ *self._command,
344
+ cwd=self._cwd,
345
+ env=self._env,
346
+ stdin=asyncio.subprocess.PIPE,
347
+ stdout=asyncio.subprocess.PIPE,
348
+ stderr=asyncio.subprocess.PIPE,
349
+ )
350
+ log_event(
351
+ self._logger,
352
+ logging.INFO,
353
+ "app_server.spawned",
354
+ command=list(self._command),
355
+ cwd=self._cwd,
356
+ )
357
+ disconnected = self._ensure_disconnect_event()
358
+ disconnected.clear()
359
+ self._disconnected_set = False
360
+ self._reader_task = asyncio.create_task(self._read_loop())
361
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
362
+ self._initialized = False
363
+
364
+ async def _initialize_handshake(self) -> None:
365
+ client_info: Dict[str, Any] = {"name": "codex-autorunner"}
366
+ if self._include_client_version:
367
+ client_info["version"] = self._client_version
368
+ params = {"clientInfo": client_info}
369
+ self._initializing = True
370
+ try:
371
+ await self._request_raw("initialize", params=params)
372
+ except CodexAppServerResponseError as exc:
373
+ if self._include_client_version:
374
+ self._include_client_version = False
375
+ log_event(
376
+ self._logger,
377
+ logging.WARNING,
378
+ "app_server.initialize.retry",
379
+ reason="response_error",
380
+ error_code=exc.code,
381
+ )
382
+ raise
383
+ except CodexAppServerDisconnected:
384
+ if self._include_client_version:
385
+ self._include_client_version = False
386
+ log_event(
387
+ self._logger,
388
+ logging.WARNING,
389
+ "app_server.initialize.retry",
390
+ reason="disconnect",
391
+ )
392
+ raise
393
+ finally:
394
+ self._initializing = False
395
+ await self._send_message(self._build_message("initialized", params=None))
396
+ self._initialized = True
397
+ self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
398
+ log_event(self._logger, logging.INFO, "app_server.initialized")
399
+
400
+ async def _request_raw(
401
+ self,
402
+ method: str,
403
+ params: Optional[Dict[str, Any]],
404
+ *,
405
+ timeout: Optional[float] = None,
406
+ ) -> Any:
407
+ request_id = self._next_request_id()
408
+ loop = asyncio.get_running_loop()
409
+ future: asyncio.Future[Any] = loop.create_future()
410
+ self._pending[request_id] = future
411
+ self._pending_methods[request_id] = method
412
+ log_event(
413
+ self._logger,
414
+ logging.INFO,
415
+ "app_server.request",
416
+ request_id=request_id,
417
+ method=method,
418
+ **_summarize_params(method, params),
419
+ )
420
+ await self._send_message(
421
+ self._build_message(method, params=params, req_id=request_id)
422
+ )
423
+ timeout = timeout if timeout is not None else self._request_timeout
424
+ try:
425
+ if timeout is None:
426
+ return await future
427
+ return await asyncio.wait_for(future, timeout)
428
+ except asyncio.TimeoutError:
429
+ if not future.done():
430
+ future.cancel()
431
+ raise
432
+ finally:
433
+ self._pending.pop(request_id, None)
434
+ self._pending_methods.pop(request_id, None)
435
+
436
+ async def _send_message(self, message: Dict[str, Any]) -> None:
437
+ if not self._process or not self._process.stdin:
438
+ raise CodexAppServerDisconnected("App-server process is not running")
439
+ self._ensure_locks()
440
+ write_lock = self._write_lock
441
+ if write_lock is None:
442
+ raise CodexAppServerProtocolError("write lock unavailable")
443
+ payload = json.dumps(message, separators=(",", ":"))
444
+ async with write_lock:
445
+ self._process.stdin.write((payload + "\n").encode("utf-8"))
446
+ await self._process.stdin.drain()
447
+
448
+ def _build_message(
449
+ self,
450
+ method: Optional[str] = None,
451
+ *,
452
+ params: Optional[Dict[str, Any]] = None,
453
+ req_id: Optional[int] = None,
454
+ result: Optional[Any] = None,
455
+ error: Optional[Dict[str, Any]] = None,
456
+ ) -> Dict[str, Any]:
457
+ message: Dict[str, Any] = {}
458
+ if req_id is not None:
459
+ message["id"] = req_id
460
+ if method is not None:
461
+ message["method"] = method
462
+ if params is not None:
463
+ message["params"] = params
464
+ if result is not None:
465
+ message["result"] = result
466
+ if error is not None:
467
+ message["error"] = error
468
+ return message
469
+
470
+ def _next_request_id(self) -> int:
471
+ request_id = self._next_id
472
+ self._next_id += 1
473
+ return request_id
474
+
475
+ def _ensure_locks(self) -> None:
476
+ if self._start_lock is None:
477
+ self._start_lock = asyncio.Lock()
478
+ if self._write_lock is None:
479
+ self._write_lock = asyncio.Lock()
480
+
481
+ def _ensure_disconnect_event(self) -> asyncio.Event:
482
+ if self._disconnected is None:
483
+ self._disconnected = asyncio.Event()
484
+ if self._disconnected_set:
485
+ self._disconnected.set()
486
+ return self._disconnected
487
+
488
+ async def _read_loop(self) -> None:
489
+ assert self._process is not None
490
+ assert self._process.stdout is not None
491
+ buffer = bytearray()
492
+ dropping_oversize = False
493
+ oversize_preview = bytearray()
494
+ oversize_bytes_dropped = 0
495
+ try:
496
+ while True:
497
+ chunk = await self._process.stdout.read(_READ_CHUNK_SIZE)
498
+ if not chunk:
499
+ break
500
+ if dropping_oversize:
501
+ newline_index = chunk.find(b"\n")
502
+ if newline_index == -1:
503
+ if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
504
+ remaining = _OVERSIZE_PREVIEW_BYTES - len(oversize_preview)
505
+ oversize_preview.extend(chunk[:remaining])
506
+ oversize_bytes_dropped += len(chunk)
507
+ if oversize_bytes_dropped >= _MAX_OVERSIZE_DRAIN_BYTES:
508
+ await self._emit_oversize_warning(
509
+ bytes_dropped=oversize_bytes_dropped,
510
+ preview=oversize_preview,
511
+ aborted=True,
512
+ drain_limit=_MAX_OVERSIZE_DRAIN_BYTES,
513
+ )
514
+ raise ValueError(
515
+ "App-server message exceeded oversize drain limit "
516
+ f"({_MAX_OVERSIZE_DRAIN_BYTES} bytes)"
517
+ )
518
+ continue
519
+ before = chunk[: newline_index + 1]
520
+ after = chunk[newline_index + 1 :]
521
+ if len(oversize_preview) < _OVERSIZE_PREVIEW_BYTES:
522
+ remaining = _OVERSIZE_PREVIEW_BYTES - len(oversize_preview)
523
+ oversize_preview.extend(before[:remaining])
524
+ oversize_bytes_dropped += len(before)
525
+ await self._emit_oversize_warning(
526
+ bytes_dropped=oversize_bytes_dropped,
527
+ preview=oversize_preview,
528
+ )
529
+ dropping_oversize = False
530
+ oversize_preview = bytearray()
531
+ oversize_bytes_dropped = 0
532
+ if not after:
533
+ continue
534
+ buffer.extend(after)
535
+ else:
536
+ buffer.extend(chunk)
537
+ while True:
538
+ newline_index = buffer.find(b"\n")
539
+ if newline_index == -1:
540
+ break
541
+ line = buffer[:newline_index]
542
+ del buffer[: newline_index + 1]
543
+ await self._handle_payload_line(line)
544
+ if not dropping_oversize and len(buffer) > _MAX_MESSAGE_BYTES:
545
+ oversize_preview = bytearray(buffer[:_OVERSIZE_PREVIEW_BYTES])
546
+ oversize_bytes_dropped = len(buffer)
547
+ buffer.clear()
548
+ dropping_oversize = True
549
+ if dropping_oversize:
550
+ if oversize_bytes_dropped:
551
+ await self._emit_oversize_warning(
552
+ bytes_dropped=oversize_bytes_dropped,
553
+ preview=oversize_preview,
554
+ truncated=True,
555
+ )
556
+ elif buffer:
557
+ if len(buffer) > _MAX_MESSAGE_BYTES:
558
+ await self._emit_oversize_warning(
559
+ bytes_dropped=len(buffer),
560
+ preview=buffer[:_OVERSIZE_PREVIEW_BYTES],
561
+ truncated=True,
562
+ )
563
+ else:
564
+ await self._handle_payload_line(buffer)
565
+ except Exception as exc:
566
+ log_event(self._logger, logging.WARNING, "app_server.read.failed", exc=exc)
567
+ finally:
568
+ await self._handle_disconnect()
569
+
570
+ async def _handle_payload_line(self, line: bytes) -> None:
571
+ if not line:
572
+ return
573
+ payload = line.decode("utf-8", errors="ignore").strip()
574
+ if not payload:
575
+ return
576
+ try:
577
+ message = json.loads(payload)
578
+ except json.JSONDecodeError:
579
+ return
580
+ if not isinstance(message, dict):
581
+ return
582
+ await self._handle_message(message)
583
+
584
+ async def _emit_oversize_warning(
585
+ self,
586
+ *,
587
+ bytes_dropped: int,
588
+ preview: bytes,
589
+ truncated: bool = False,
590
+ aborted: bool = False,
591
+ drain_limit: Optional[int] = None,
592
+ ) -> None:
593
+ metadata = _infer_metadata_from_preview(preview)
594
+ log_event(
595
+ self._logger,
596
+ logging.WARNING,
597
+ "app_server.read.oversize_dropped",
598
+ bytes_dropped=bytes_dropped,
599
+ preview_bytes=len(preview),
600
+ preview_excerpt=_preview_excerpt(metadata.get("preview") or ""),
601
+ inferred_method=metadata.get("method"),
602
+ inferred_thread_id=metadata.get("thread_id"),
603
+ inferred_turn_id=metadata.get("turn_id"),
604
+ truncated=truncated,
605
+ aborted=aborted,
606
+ drain_limit=drain_limit,
607
+ )
608
+ if self._notification_handler is None:
609
+ return
610
+ params: Dict[str, Any] = {
611
+ "byteLimit": _MAX_MESSAGE_BYTES,
612
+ "bytesDropped": bytes_dropped,
613
+ }
614
+ inferred_method = metadata.get("method")
615
+ inferred_thread_id = metadata.get("thread_id")
616
+ inferred_turn_id = metadata.get("turn_id")
617
+ if inferred_method:
618
+ params["inferredMethod"] = inferred_method
619
+ if inferred_thread_id:
620
+ params["threadId"] = inferred_thread_id
621
+ if inferred_turn_id:
622
+ params["turnId"] = inferred_turn_id
623
+ if truncated:
624
+ params["truncated"] = True
625
+ if aborted:
626
+ params["aborted"] = True
627
+ if drain_limit is not None:
628
+ params["drainLimit"] = drain_limit
629
+ try:
630
+ await _maybe_await(
631
+ self._notification_handler(
632
+ {
633
+ "method": "car/app_server/oversizedMessageDropped",
634
+ "params": params,
635
+ }
636
+ )
637
+ )
638
+ except Exception as exc:
639
+ log_event(
640
+ self._logger,
641
+ logging.WARNING,
642
+ "app_server.notification_handler.failed",
643
+ method="car/app_server/oversizedMessageDropped",
644
+ handled=False,
645
+ exc=exc,
646
+ )
647
+
648
+ async def _drain_stderr(self) -> None:
649
+ if not self._process or not self._process.stderr:
650
+ return
651
+ try:
652
+ while True:
653
+ line = await self._process.stderr.readline()
654
+ if not line:
655
+ break
656
+ text = line.decode("utf-8", errors="ignore").strip()
657
+ if text:
658
+ sanitized = sanitize_log_value(text)
659
+ if isinstance(sanitized, str):
660
+ self._stderr_tail.append(sanitized)
661
+ else:
662
+ self._stderr_tail.append(str(sanitized))
663
+ log_event(
664
+ self._logger,
665
+ logging.DEBUG,
666
+ "app_server.stderr",
667
+ line_len=len(text),
668
+ tail_size=len(self._stderr_tail),
669
+ )
670
+ except Exception:
671
+ return
672
+
673
+ async def _handle_message(self, message: Dict[str, Any]) -> None:
674
+ if "id" in message and "method" not in message:
675
+ await self._handle_response(message)
676
+ return
677
+ if "id" in message and "method" in message:
678
+ await self._handle_server_request(message)
679
+ return
680
+ if "method" in message:
681
+ await self._handle_notification(message)
682
+
683
+ async def _handle_response(self, message: Dict[str, Any]) -> None:
684
+ req_id = message.get("id")
685
+ if not isinstance(req_id, int):
686
+ return
687
+ future = self._pending.pop(req_id, None)
688
+ method = self._pending_methods.pop(req_id, None)
689
+ if future is None:
690
+ return
691
+ if future.cancelled():
692
+ return
693
+ if "error" in message and message["error"] is not None:
694
+ err = message.get("error") or {}
695
+ log_event(
696
+ self._logger,
697
+ logging.WARNING,
698
+ "app_server.response.error",
699
+ request_id=req_id,
700
+ method=method,
701
+ error_code=err.get("code"),
702
+ error_message=err.get("message"),
703
+ )
704
+ future.set_exception(
705
+ CodexAppServerResponseError(
706
+ method=method,
707
+ code=err.get("code"),
708
+ message=err.get("message") or "app-server error",
709
+ data=err.get("data"),
710
+ )
711
+ )
712
+ return
713
+ log_event(
714
+ self._logger,
715
+ logging.INFO,
716
+ "app_server.response",
717
+ request_id=req_id,
718
+ method=method,
719
+ )
720
+ future.set_result(message.get("result"))
721
+
722
+ async def _handle_server_request(self, message: Dict[str, Any]) -> None:
723
+ method = message.get("method")
724
+ req_id = message.get("id")
725
+ if isinstance(method, str) and method in APPROVAL_METHODS:
726
+ params_raw = message.get("params")
727
+ params: Dict[str, Any] = params_raw if isinstance(params_raw, dict) else {}
728
+ log_event(
729
+ self._logger,
730
+ logging.INFO,
731
+ "app_server.approval.requested",
732
+ request_id=req_id,
733
+ method=method,
734
+ turn_id=params.get("turnId"),
735
+ )
736
+ decision: ApprovalDecision = self._default_approval_decision
737
+ if self._approval_handler is not None:
738
+ try:
739
+ decision = await _maybe_await(self._approval_handler(message))
740
+ except Exception as exc:
741
+ log_event(
742
+ self._logger,
743
+ logging.WARNING,
744
+ "app_server.approval.failed",
745
+ request_id=req_id,
746
+ method=method,
747
+ exc=exc,
748
+ )
749
+ await self._send_message(
750
+ self._build_message(
751
+ req_id=req_id,
752
+ error={
753
+ "code": -32001,
754
+ "message": "approval handler failed",
755
+ },
756
+ )
757
+ )
758
+ return
759
+ result = decision if isinstance(decision, dict) else {"decision": decision}
760
+ log_event(
761
+ self._logger,
762
+ logging.INFO,
763
+ "app_server.approval.responded",
764
+ request_id=req_id,
765
+ method=method,
766
+ decision=result.get("decision") if isinstance(result, dict) else None,
767
+ )
768
+ await self._send_message(self._build_message(req_id=req_id, result=result))
769
+ return
770
+ await self._send_message(
771
+ self._build_message(
772
+ req_id=req_id,
773
+ error={"code": -32601, "message": f"Unsupported method: {method}"},
774
+ )
775
+ )
776
+
777
+ async def _handle_notification(self, message: Dict[str, Any]) -> None:
778
+ method = message.get("method")
779
+ params = message.get("params") or {}
780
+ handled = False
781
+ if method == "item/completed":
782
+ turn_id = _extract_turn_id(params) or _extract_turn_id(
783
+ params.get("item") if isinstance(params, dict) else None
784
+ )
785
+ if not turn_id:
786
+ handled = True
787
+ return
788
+ thread_id = _extract_thread_id_for_turn(params)
789
+ _key, state = self._find_turn_state(turn_id, thread_id=thread_id)
790
+ if state is None:
791
+ if thread_id:
792
+ state = self._ensure_turn_state(turn_id, thread_id)
793
+ else:
794
+ state = self._ensure_pending_turn_state(turn_id)
795
+ self._apply_item_completed(state, message, params)
796
+ handled = True
797
+ elif method == "turn/completed":
798
+ turn_id = _extract_turn_id(params)
799
+ if not turn_id:
800
+ handled = True
801
+ return
802
+ thread_id = _extract_thread_id_for_turn(params)
803
+ _key, state = self._find_turn_state(turn_id, thread_id=thread_id)
804
+ if state is None:
805
+ if thread_id:
806
+ state = self._ensure_turn_state(turn_id, thread_id)
807
+ else:
808
+ state = self._ensure_pending_turn_state(turn_id)
809
+ self._apply_turn_completed(state, message, params)
810
+ handled = True
811
+ elif method == "error":
812
+ turn_id = _extract_turn_id(params)
813
+ if not turn_id:
814
+ handled = True
815
+ return
816
+ thread_id = _extract_thread_id_for_turn(params)
817
+ _key, state = self._find_turn_state(turn_id, thread_id=thread_id)
818
+ if state is None:
819
+ if thread_id:
820
+ state = self._ensure_turn_state(turn_id, thread_id)
821
+ else:
822
+ state = self._ensure_pending_turn_state(turn_id)
823
+ self._apply_error(state, message, params)
824
+ handled = True
825
+ if self._notification_handler is not None:
826
+ try:
827
+ await _maybe_await(self._notification_handler(message))
828
+ except Exception as exc:
829
+ log_event(
830
+ self._logger,
831
+ logging.WARNING,
832
+ "app_server.notification_handler.failed",
833
+ method=method,
834
+ handled=handled,
835
+ exc=exc,
836
+ )
837
+
838
+ def _find_turn_state(
839
+ self, turn_id: str, *, thread_id: Optional[str]
840
+ ) -> tuple[Optional[TurnKey], Optional[_TurnState]]:
841
+ key = _turn_key(thread_id, turn_id)
842
+ if key is not None:
843
+ state = self._turns.get(key)
844
+ if state is not None:
845
+ return key, state
846
+ matches = [
847
+ (candidate_key, state)
848
+ for candidate_key, state in self._turns.items()
849
+ if candidate_key[1] == turn_id
850
+ ]
851
+ if len(matches) == 1:
852
+ candidate_key, state = matches[0]
853
+ if key is not None and candidate_key != key:
854
+ log_event(
855
+ self._logger,
856
+ logging.WARNING,
857
+ "app_server.turn.thread_mismatch",
858
+ turn_id=turn_id,
859
+ requested_thread_id=thread_id,
860
+ actual_thread_id=candidate_key[0],
861
+ )
862
+ return candidate_key, state
863
+ if len(matches) > 1:
864
+ log_event(
865
+ self._logger,
866
+ logging.WARNING,
867
+ "app_server.turn.ambiguous",
868
+ turn_id=turn_id,
869
+ matches=len(matches),
870
+ )
871
+ return None, None
872
+
873
+ def _ensure_turn_state(self, turn_id: str, thread_id: str) -> _TurnState:
874
+ key = _turn_key(thread_id, turn_id)
875
+ if key is None:
876
+ raise CodexAppServerProtocolError("turn state missing thread id")
877
+ state = self._turns.get(key)
878
+ if state is not None:
879
+ return state
880
+ loop = asyncio.get_running_loop()
881
+ future = cast(asyncio.Future[TurnResult], loop.create_future())
882
+ state = _TurnState(
883
+ turn_id=turn_id,
884
+ thread_id=thread_id,
885
+ future=future,
886
+ agent_messages=[],
887
+ errors=[],
888
+ raw_events=[],
889
+ )
890
+ self._turns[key] = state
891
+ return state
892
+
893
+ def _ensure_pending_turn_state(self, turn_id: str) -> _TurnState:
894
+ state = self._pending_turns.get(turn_id)
895
+ if state is not None:
896
+ return state
897
+ loop = asyncio.get_running_loop()
898
+ future = cast(asyncio.Future[TurnResult], loop.create_future())
899
+ state = _TurnState(
900
+ turn_id=turn_id,
901
+ thread_id=None,
902
+ future=future,
903
+ agent_messages=[],
904
+ errors=[],
905
+ raw_events=[],
906
+ )
907
+ self._pending_turns[turn_id] = state
908
+ return state
909
+
910
+ def _merge_turn_state(self, target: _TurnState, source: _TurnState) -> None:
911
+ if not target.agent_messages:
912
+ target.agent_messages = list(source.agent_messages)
913
+ else:
914
+ target.agent_messages.extend(source.agent_messages)
915
+ if not target.raw_events:
916
+ target.raw_events = list(source.raw_events)
917
+ else:
918
+ target.raw_events.extend(source.raw_events)
919
+ if not target.errors:
920
+ target.errors = list(source.errors)
921
+ else:
922
+ target.errors.extend(source.errors)
923
+ if target.status is None and source.status is not None:
924
+ target.status = source.status
925
+ if source.future.done() and not target.future.done():
926
+ target.future.set_result(
927
+ TurnResult(
928
+ turn_id=target.turn_id,
929
+ status=target.status,
930
+ agent_messages=list(target.agent_messages),
931
+ errors=list(target.errors),
932
+ raw_events=list(target.raw_events),
933
+ )
934
+ )
935
+
936
+ def _register_turn_state(self, turn_id: str, thread_id: str) -> _TurnState:
937
+ key = _turn_key(thread_id, turn_id)
938
+ if key is None:
939
+ raise CodexAppServerProtocolError("turn/start missing thread id")
940
+ pending = self._pending_turns.pop(turn_id, None)
941
+ state = self._turns.get(key)
942
+ if pending is not None:
943
+ if state is None:
944
+ pending.thread_id = thread_id
945
+ self._turns[key] = pending
946
+ return pending
947
+ self._merge_turn_state(state, pending)
948
+ return state
949
+ if state is None:
950
+ return self._ensure_turn_state(turn_id, thread_id)
951
+ return state
952
+
953
+ def _apply_item_completed(
954
+ self, state: _TurnState, message: Dict[str, Any], params: Any
955
+ ) -> None:
956
+ item = params.get("item") if isinstance(params, dict) else None
957
+ text = None
958
+
959
+ def append_message(candidate: Optional[str]) -> None:
960
+ if not candidate:
961
+ return
962
+ if state.agent_messages and state.agent_messages[-1] == candidate:
963
+ return
964
+ state.agent_messages.append(candidate)
965
+
966
+ if isinstance(item, dict) and item.get("type") == "agentMessage":
967
+ text = item.get("text")
968
+ if isinstance(text, str):
969
+ append_message(text)
970
+ review_text = _extract_review_text(item)
971
+ if review_text and review_text != text:
972
+ append_message(review_text)
973
+ item_type = item.get("type") if isinstance(item, dict) else None
974
+ log_event(
975
+ self._logger,
976
+ logging.INFO,
977
+ "app_server.item.completed",
978
+ turn_id=state.turn_id,
979
+ item_type=item_type,
980
+ )
981
+ state.raw_events.append(message)
982
+
983
+ def _apply_error(
984
+ self, state: _TurnState, message: Dict[str, Any], params: Any
985
+ ) -> None:
986
+ error_message = _extract_error_message(params)
987
+ if error_message:
988
+ state.errors.append(error_message)
989
+ error_payload = params.get("error") if isinstance(params, dict) else None
990
+ error_code = (
991
+ error_payload.get("code") if isinstance(error_payload, dict) else None
992
+ )
993
+ will_retry = params.get("willRetry") if isinstance(params, dict) else None
994
+ log_event(
995
+ self._logger,
996
+ logging.WARNING,
997
+ "app_server.turn_error",
998
+ turn_id=state.turn_id,
999
+ thread_id=state.thread_id,
1000
+ message=error_message,
1001
+ code=error_code,
1002
+ will_retry=will_retry,
1003
+ )
1004
+ state.raw_events.append(message)
1005
+
1006
+ def _apply_turn_completed(
1007
+ self, state: _TurnState, message: Dict[str, Any], params: Any
1008
+ ) -> None:
1009
+ state.raw_events.append(message)
1010
+ status = None
1011
+ if isinstance(params, dict):
1012
+ status = params.get("status")
1013
+ state.status = status
1014
+ log_event(
1015
+ self._logger,
1016
+ logging.INFO,
1017
+ "app_server.turn.completed",
1018
+ turn_id=state.turn_id,
1019
+ status=status,
1020
+ )
1021
+ if not state.future.done():
1022
+ state.future.set_result(
1023
+ TurnResult(
1024
+ turn_id=state.turn_id,
1025
+ status=state.status,
1026
+ agent_messages=list(state.agent_messages),
1027
+ errors=list(state.errors),
1028
+ raw_events=list(state.raw_events),
1029
+ )
1030
+ )
1031
+
1032
+ async def _handle_disconnect(self) -> None:
1033
+ self._initialized = False
1034
+ self._initializing = False
1035
+ disconnected = self._ensure_disconnect_event()
1036
+ disconnected.set()
1037
+ self._disconnected_set = True
1038
+ process = self._process
1039
+ returncode = process.returncode if process is not None else None
1040
+ pid = process.pid if process is not None else None
1041
+ log_event(
1042
+ self._logger,
1043
+ logging.WARNING,
1044
+ "app_server.disconnected",
1045
+ auto_restart=self._auto_restart,
1046
+ returncode=returncode,
1047
+ pid=pid,
1048
+ pending_requests=len(self._pending),
1049
+ pending_turns=len(self._pending_turns),
1050
+ active_turns=len(self._turns),
1051
+ initializing=self._initializing,
1052
+ initialized=self._initialized,
1053
+ closed=self._closed,
1054
+ stderr_tail=list(self._stderr_tail),
1055
+ )
1056
+ if not self._closed:
1057
+ self._fail_pending(CodexAppServerDisconnected("App-server disconnected"))
1058
+ if self._auto_restart and not self._closed:
1059
+ self._schedule_restart()
1060
+
1061
+ def _fail_pending(self, error: Exception) -> None:
1062
+ for future in list(self._pending.values()):
1063
+ if not future.done():
1064
+ future.set_exception(error)
1065
+ self._pending.clear()
1066
+ for state in list(self._turns.values()):
1067
+ if not state.future.done():
1068
+ state.future.set_exception(error)
1069
+ self._turns.clear()
1070
+ for state in list(self._pending_turns.values()):
1071
+ if not state.future.done():
1072
+ state.future.set_exception(error)
1073
+ self._pending_turns.clear()
1074
+
1075
+ def _schedule_restart(self) -> None:
1076
+ if self._restart_task is not None and not self._restart_task.done():
1077
+ return
1078
+ self._restart_task = asyncio.create_task(self._restart_after_disconnect())
1079
+
1080
+ async def _restart_after_disconnect(self) -> None:
1081
+ delay = max(self._restart_backoff_seconds, _RESTART_BACKOFF_INITIAL_SECONDS)
1082
+ jitter = delay * _RESTART_BACKOFF_JITTER_RATIO
1083
+ if jitter:
1084
+ delay += random.uniform(0, jitter)
1085
+ await asyncio.sleep(delay)
1086
+ if self._closed:
1087
+ return
1088
+ try:
1089
+ await self._ensure_process()
1090
+ self._restart_backoff_seconds = _RESTART_BACKOFF_INITIAL_SECONDS
1091
+ log_event(
1092
+ self._logger,
1093
+ logging.INFO,
1094
+ "app_server.restarted",
1095
+ delay_seconds=round(delay, 2),
1096
+ )
1097
+ except Exception as exc:
1098
+ next_delay = min(
1099
+ max(
1100
+ self._restart_backoff_seconds * 2, _RESTART_BACKOFF_INITIAL_SECONDS
1101
+ ),
1102
+ _RESTART_BACKOFF_MAX_SECONDS,
1103
+ )
1104
+ log_event(
1105
+ self._logger,
1106
+ logging.WARNING,
1107
+ "app_server.restart.failed",
1108
+ delay_seconds=round(delay, 2),
1109
+ next_delay_seconds=round(next_delay, 2),
1110
+ exc=exc,
1111
+ )
1112
+ self._restart_backoff_seconds = next_delay
1113
+ if not self._closed:
1114
+ self._schedule_restart()
1115
+
1116
+ async def _terminate_process(self) -> None:
1117
+ if self._reader_task is not None:
1118
+ self._reader_task.cancel()
1119
+ try:
1120
+ await self._reader_task
1121
+ except asyncio.CancelledError:
1122
+ pass
1123
+ if self._stderr_task is not None:
1124
+ self._stderr_task.cancel()
1125
+ try:
1126
+ await self._stderr_task
1127
+ except asyncio.CancelledError:
1128
+ pass
1129
+ if self._process is None:
1130
+ return
1131
+ if self._process.returncode is None:
1132
+ self._process.terminate()
1133
+ try:
1134
+ await asyncio.wait_for(self._process.wait(), timeout=1)
1135
+ except asyncio.TimeoutError:
1136
+ self._process.kill()
1137
+ await self._process.wait()
1138
+ self._process = None
1139
+
1140
+
1141
+ def _summarize_params(method: str, params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
1142
+ if not isinstance(params, dict):
1143
+ return {}
1144
+ if method == "turn/start":
1145
+ input_items = params.get("input")
1146
+ input_chars = 0
1147
+ if isinstance(input_items, list):
1148
+ for item in input_items:
1149
+ if isinstance(item, dict) and item.get("type") == "text":
1150
+ text = item.get("text")
1151
+ if isinstance(text, str):
1152
+ input_chars += len(text)
1153
+ summary: Dict[str, Any] = {
1154
+ "thread_id": params.get("threadId"),
1155
+ "input_chars": input_chars,
1156
+ }
1157
+ if "approvalPolicy" in params:
1158
+ summary["approval_policy"] = params.get("approvalPolicy")
1159
+ if "sandboxPolicy" in params:
1160
+ summary["sandbox_policy"] = params.get("sandboxPolicy")
1161
+ return summary
1162
+ if method == "turn/interrupt":
1163
+ return {"turn_id": params.get("turnId"), "thread_id": params.get("threadId")}
1164
+ if method == "thread/start":
1165
+ return {"cwd": params.get("cwd")}
1166
+ if method == "thread/resume":
1167
+ return {"thread_id": params.get("threadId")}
1168
+ if method == "thread/list":
1169
+ return {}
1170
+ if method == "review/start":
1171
+ return {"thread_id": params.get("threadId")}
1172
+ return {"param_keys": list(params.keys())[:10]}
1173
+
1174
+
1175
+ def _client_version() -> str:
1176
+ try:
1177
+ return importlib_metadata.version("codex-autorunner")
1178
+ except Exception:
1179
+ return "unknown"
1180
+
1181
+
1182
+ async def _maybe_await(value: Any) -> Any:
1183
+ if asyncio.iscoroutine(value):
1184
+ return await value
1185
+ return value
1186
+
1187
+
1188
+ def _first_regex_group(text: str, pattern: str) -> Optional[str]:
1189
+ try:
1190
+ match = re.search(pattern, text)
1191
+ except re.error:
1192
+ return None
1193
+ if not match:
1194
+ return None
1195
+ value = match.group(1)
1196
+ return value.strip() if isinstance(value, str) and value.strip() else None
1197
+
1198
+
1199
+ def _infer_metadata_from_preview(preview: bytes) -> Dict[str, Optional[str]]:
1200
+ try:
1201
+ text = preview.decode("utf-8", errors="ignore")
1202
+ except Exception:
1203
+ return {"preview": "", "method": None, "thread_id": None, "turn_id": None}
1204
+ method = _first_regex_group(text, r'"method"\s*:\s*"([^"]+)"')
1205
+ thread_id = _first_regex_group(text, r'"threadId"\s*:\s*"([^"]+)"')
1206
+ if not thread_id:
1207
+ thread_id = _first_regex_group(text, r'"thread_id"\s*:\s*"([^"]+)"')
1208
+ turn_id = _first_regex_group(text, r'"turnId"\s*:\s*"([^"]+)"')
1209
+ if not turn_id:
1210
+ turn_id = _first_regex_group(text, r'"turn_id"\s*:\s*"([^"]+)"')
1211
+ return {
1212
+ "preview": text,
1213
+ "method": method,
1214
+ "thread_id": thread_id,
1215
+ "turn_id": turn_id,
1216
+ }
1217
+
1218
+
1219
+ def _preview_excerpt(text: str, limit: int = 256) -> str:
1220
+ normalized = " ".join(text.split()).strip()
1221
+ if not normalized:
1222
+ return ""
1223
+ if len(normalized) <= limit:
1224
+ return normalized
1225
+ return f"{normalized[:limit].rstrip()}..."
1226
+
1227
+
1228
+ def _extract_turn_id(payload: Any) -> Optional[str]:
1229
+ if not isinstance(payload, dict):
1230
+ return None
1231
+ for key in ("turnId", "turn_id", "id"):
1232
+ value = payload.get(key)
1233
+ if isinstance(value, str):
1234
+ return value
1235
+ turn = payload.get("turn")
1236
+ if isinstance(turn, dict):
1237
+ for key in ("id", "turnId", "turn_id"):
1238
+ value = turn.get(key)
1239
+ if isinstance(value, str):
1240
+ return value
1241
+ return None
1242
+
1243
+
1244
+ def _turn_key(thread_id: Optional[str], turn_id: Optional[str]) -> Optional[TurnKey]:
1245
+ if not thread_id or not turn_id:
1246
+ return None
1247
+ return (thread_id, turn_id)
1248
+
1249
+
1250
+ def _extract_thread_id_for_turn(payload: Any) -> Optional[str]:
1251
+ if not isinstance(payload, dict):
1252
+ return None
1253
+ for candidate in (payload, payload.get("turn"), payload.get("item")):
1254
+ thread_id = _extract_thread_id_from_container(candidate)
1255
+ if thread_id:
1256
+ return thread_id
1257
+ return None
1258
+
1259
+
1260
+ def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
1261
+ if not isinstance(payload, dict):
1262
+ return None
1263
+ for key in ("threadId", "thread_id"):
1264
+ value = payload.get(key)
1265
+ if isinstance(value, str):
1266
+ return value
1267
+ thread = payload.get("thread")
1268
+ if isinstance(thread, dict):
1269
+ for key in ("id", "threadId", "thread_id"):
1270
+ value = thread.get(key)
1271
+ if isinstance(value, str):
1272
+ return value
1273
+ return None
1274
+
1275
+
1276
+ def _extract_review_text(item: Any) -> Optional[str]:
1277
+ if not isinstance(item, dict):
1278
+ return None
1279
+ exited = item.get("exitedReviewMode")
1280
+ if isinstance(exited, dict):
1281
+ review = exited.get("review")
1282
+ if isinstance(review, str) and review.strip():
1283
+ return review
1284
+ if item.get("type") == "review":
1285
+ text = item.get("text")
1286
+ if isinstance(text, str) and text.strip():
1287
+ return text
1288
+ review = item.get("review")
1289
+ if isinstance(review, str) and review.strip():
1290
+ return review
1291
+ return None
1292
+
1293
+
1294
+ def _extract_error_message(payload: Any) -> Optional[str]:
1295
+ if not isinstance(payload, dict):
1296
+ return None
1297
+ error = payload.get("error")
1298
+ message: Optional[str] = None
1299
+ details: Optional[str] = None
1300
+ if isinstance(error, dict):
1301
+ raw_message = error.get("message")
1302
+ if isinstance(raw_message, str):
1303
+ message = raw_message.strip() or None
1304
+ raw_details = error.get("additionalDetails") or error.get("details")
1305
+ if isinstance(raw_details, str):
1306
+ details = raw_details.strip() or None
1307
+ elif isinstance(error, str):
1308
+ message = error.strip() or None
1309
+ if message is None:
1310
+ fallback = payload.get("message")
1311
+ if isinstance(fallback, str):
1312
+ message = fallback.strip() or None
1313
+ if details and details != message:
1314
+ if message:
1315
+ return f"{message} ({details})"
1316
+ return details
1317
+ return message
1318
+
1319
+
1320
+ def _extract_thread_id(payload: Any) -> Optional[str]:
1321
+ if not isinstance(payload, dict):
1322
+ return None
1323
+ for key in ("threadId", "thread_id", "id"):
1324
+ value = payload.get(key)
1325
+ if isinstance(value, str):
1326
+ return value
1327
+ thread = payload.get("thread")
1328
+ if isinstance(thread, dict):
1329
+ for key in ("id", "threadId", "thread_id"):
1330
+ value = thread.get(key)
1331
+ if isinstance(value, str):
1332
+ return value
1333
+ return None
1334
+
1335
+
1336
+ _SANDBOX_POLICY_CANONICAL = {
1337
+ "dangerfullaccess": "dangerFullAccess",
1338
+ "readonly": "readOnly",
1339
+ "workspacewrite": "workspaceWrite",
1340
+ "externalsandbox": "externalSandbox",
1341
+ }
1342
+
1343
+
1344
+ def _normalize_sandbox_policy(value: Any) -> Any:
1345
+ if value is None:
1346
+ return None
1347
+ if isinstance(value, dict):
1348
+ type_value = value.get("type")
1349
+ if isinstance(type_value, str):
1350
+ canonical = _normalize_sandbox_policy_type(type_value)
1351
+ if canonical != type_value:
1352
+ updated = dict(value)
1353
+ updated["type"] = canonical
1354
+ return updated
1355
+ return value
1356
+ if isinstance(value, str):
1357
+ raw = value.strip()
1358
+ if not raw:
1359
+ return None
1360
+ canonical = _normalize_sandbox_policy_type(raw)
1361
+ return {"type": canonical}
1362
+ return value
1363
+
1364
+
1365
+ def _normalize_sandbox_policy_type(raw: str) -> str:
1366
+ cleaned = re.sub(r"[^a-zA-Z0-9]+", "", raw.strip())
1367
+ if not cleaned:
1368
+ return raw.strip()
1369
+ canonical = _SANDBOX_POLICY_CANONICAL.get(cleaned.lower())
1370
+ return canonical or raw.strip()
1371
+
1372
+
1373
+ __all__ = [
1374
+ "APPROVAL_METHODS",
1375
+ "ApprovalDecision",
1376
+ "ApprovalHandler",
1377
+ "CodexAppServerClient",
1378
+ "CodexAppServerDisconnected",
1379
+ "CodexAppServerError",
1380
+ "CodexAppServerProtocolError",
1381
+ "CodexAppServerResponseError",
1382
+ "NotificationHandler",
1383
+ "TurnHandle",
1384
+ "TurnResult",
1385
+ "_normalize_sandbox_policy",
1386
+ ]