ccx-native 0.0.1 → 0.1.0

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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,805 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import json
5
+ import os
6
+ import select
7
+ import queue
8
+ import random
9
+ import signal
10
+ import socket
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ import time
15
+ from collections.abc import Callable
16
+ from typing import Any
17
+
18
+ from ccx.codex.control import _UnixWebSocket, _masked_frame
19
+
20
+
21
+ class ProtocolError(RuntimeError):
22
+ """A JSON-RPC or local protocol failure with machine-readable context."""
23
+
24
+ def __init__(
25
+ self,
26
+ message: str = "protocol error",
27
+ *,
28
+ method: str | None = None,
29
+ code: int | None = None,
30
+ data: Any = None,
31
+ request_id: Any = None,
32
+ ):
33
+ self.method = method
34
+ self.code = code
35
+ self.data = data
36
+ self.request_id = request_id
37
+ self.message = str(message)
38
+ # A message that already names its method (a local timeout reads
39
+ # "<method> timed out") keeps its own wording; only an unqualified
40
+ # server message gets the "<method> failed:" prefix.
41
+ detail = (
42
+ f"{method} failed: {self.message}"
43
+ if method and not self.message.startswith(f"{method} ")
44
+ else self.message
45
+ )
46
+ super().__init__(detail)
47
+
48
+
49
+ class ProtocolTransportError(ProtocolError):
50
+ """The local app-server protocol channel became unusable."""
51
+
52
+
53
+ class ProtocolNotSent(ProtocolTransportError):
54
+ """This request is proven to have emitted zero bytes to app-server stdin.
55
+
56
+ It can mean a previously failed channel or a local write/lock deadline
57
+ expiring while the shared channel is healthy. The app-server cannot have
58
+ seen this request, but that is not permission to clear a separately
59
+ fsynced CCX submitted-unknown claim or automatically retry native creation.
60
+ """
61
+
62
+
63
+ class ServerOverloaded(ProtocolError):
64
+ """The server explicitly rejected a request as temporarily overloaded."""
65
+
66
+
67
+ def _error_from_response(
68
+ method: str,
69
+ error: Any,
70
+ request_id: Any,
71
+ ) -> ProtocolError:
72
+ if isinstance(error, dict):
73
+ code = error.get("code")
74
+ if isinstance(code, bool) or not isinstance(code, int):
75
+ code = None
76
+ message = error.get("message")
77
+ if not isinstance(message, str):
78
+ message = str(message if message is not None else error)
79
+ data = error.get("data")
80
+ else:
81
+ code = None
82
+ message = str(error)
83
+ data = None
84
+ error_type = ServerOverloaded if code == -32001 else ProtocolError
85
+ return error_type(
86
+ message,
87
+ method=method,
88
+ code=code,
89
+ data=data,
90
+ request_id=request_id,
91
+ )
92
+
93
+
94
+ def _result_from_response(
95
+ method: str, response: Any, request_id: Any
96
+ ) -> dict[str, Any]:
97
+ if isinstance(response, BaseException):
98
+ raise response
99
+ if "error" in response:
100
+ raise _error_from_response(method, response["error"], request_id)
101
+ result = response.get("result")
102
+ return result if isinstance(result, dict) else {"value": result}
103
+
104
+
105
+ class PendingRequest:
106
+ """One sent request whose answer stays observable after callers stop waiting.
107
+
108
+ The client's reader delivers the response, or the transport failure that
109
+ ends the connection, through the same `put` interface as a result queue,
110
+ so exactly one definitive outcome always arrives.
111
+ """
112
+
113
+ def __init__(self, method: str):
114
+ self.method = method
115
+ self.request_id: Any = None
116
+ self._lock = threading.Lock()
117
+ self._done = threading.Event()
118
+ self._response: Any = None
119
+ self._callbacks: list[Callable[["PendingRequest"], None]] = []
120
+
121
+ def put(self, response: Any, block: bool = True, timeout: Any = None) -> None:
122
+ with self._lock:
123
+ if self._done.is_set():
124
+ return
125
+ self._response = response
126
+ self._done.set()
127
+ callbacks, self._callbacks = self._callbacks, []
128
+ for callback in callbacks:
129
+ self._run(callback)
130
+
131
+ put_nowait = put
132
+
133
+ def done(self) -> bool:
134
+ return self._done.is_set()
135
+
136
+ def wait(self, timeout: float) -> bool:
137
+ return self._done.wait(max(0.0, timeout))
138
+
139
+ def add_done_callback(self, callback: Callable[["PendingRequest"], None]) -> None:
140
+ """Run `callback` once answered: now if it already is, else on the reader."""
141
+ with self._lock:
142
+ if not self._done.is_set():
143
+ self._callbacks.append(callback)
144
+ return
145
+ self._run(callback)
146
+
147
+ def result(self) -> dict[str, Any]:
148
+ if not self._done.is_set():
149
+ raise ProtocolError(
150
+ f"{self.method} has not been answered yet", method=self.method
151
+ )
152
+ return _result_from_response(self.method, self._response, self.request_id)
153
+
154
+ def original_provider_rejection(self) -> dict[str, Any] | None:
155
+ """Exact coded JSON-RPC error *reply* for this original request only.
156
+
157
+ A locally raised ProtocolError may itself have an integer code; a
158
+ transport exception, timeout, or mismatched response ID is never
159
+ positive evidence that the provider refused the native operation.
160
+ Large/unstructured provider errors also remain uncertain rather than
161
+ silently truncating the original rejection on durable replay.
162
+ """
163
+ import hashlib
164
+ with self._lock:
165
+ if not self._done.is_set():
166
+ return None
167
+ response = self._response
168
+ request_id = self.request_id
169
+ if (not isinstance(response, dict)
170
+ or response.get("jsonrpc") != "2.0"
171
+ or type(response.get("id")) is not type(request_id)
172
+ or response.get("id") != request_id
173
+ or not isinstance(response.get("error"), dict)):
174
+ return None
175
+ error = response["error"]
176
+ code, message = error.get("code"), error.get("message")
177
+ if (type(code) is not int or not isinstance(message, str)
178
+ or len(message.encode("utf-8")) > 8192):
179
+ return None
180
+ try:
181
+ raw = json.dumps(response, sort_keys=True, separators=(",", ":"),
182
+ ensure_ascii=False, allow_nan=False).encode("utf-8")
183
+ except (TypeError, ValueError, UnicodeError):
184
+ return None
185
+ if len(raw) > 64 * 1024:
186
+ return None
187
+ return {
188
+ "code": code, "message": message,
189
+ "requestId": request_id,
190
+ "nativeReplySha256": hashlib.sha256(raw).hexdigest(),
191
+ }
192
+
193
+ def _run(self, callback: Callable[["PendingRequest"], None]) -> None:
194
+ # Callbacks usually run on the client's reader thread, where an
195
+ # escaping exception would stop every later message from being read.
196
+ try:
197
+ callback(self)
198
+ except Exception as error: # noqa: BLE001
199
+ print(
200
+ f"ccx: {self.method} completion callback failed: {error!r}",
201
+ file=sys.stderr, flush=True,
202
+ )
203
+
204
+
205
+ def error_payload(error: ProtocolError) -> dict[str, Any]:
206
+ """Project one ``ProtocolError`` into a JSON-compatible RPC member."""
207
+ return {
208
+ "method": error.method,
209
+ "code": error.code,
210
+ "message": error.message,
211
+ "data": error.data,
212
+ "requestId": error.request_id,
213
+ "transport": isinstance(error, ProtocolTransportError),
214
+ }
215
+
216
+
217
+ def _user_command_env() -> dict[str, str]:
218
+ """Environment for app-server, whose shell commands are the user's.
219
+
220
+ codexd itself runs with CCX's package on PYTHONPATH (``package_env``);
221
+ restore the caller's original value so it never shadows project packages.
222
+ """
223
+ from ccx.codex.worker_environment import scrub_shared_codex_identity
224
+ env = scrub_shared_codex_identity(os.environ.copy())
225
+ # The app-server serves many threads. A caller's BASH_ENV belongs only
226
+ # inside that worker's generated, full-UUID thread policy, not globally.
227
+ env.pop("BASH_ENV", None)
228
+ if "CCX_CALLER_PYTHONPATH" in env:
229
+ original = env.pop("CCX_CALLER_PYTHONPATH")
230
+ if original:
231
+ env["PYTHONPATH"] = original
232
+ else:
233
+ env.pop("PYTHONPATH", None)
234
+ return env
235
+
236
+
237
+ # One ephemeral watcher per socket-mode app-server, not a daemon/service.
238
+ # Its stdin writer belongs only to codexd. If codexd is SIGKILLed, EOF
239
+ # terminates the watcher + launcher + native child group, even though the
240
+ # WebSocket listener would otherwise keep running without its CCX owner.
241
+ _PARENT_LIFETIME_WATCHER = (
242
+ "import os, signal, sys, time\n"
243
+ "signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
244
+ "sys.stdin.buffer.read(1)\n"
245
+ "os.killpg(os.getpgrp(), signal.SIGTERM)\n"
246
+ "time.sleep(0.7)\n"
247
+ "os.killpg(os.getpgrp(), signal.SIGKILL)\n"
248
+ )
249
+
250
+
251
+ class AppServerClient:
252
+ """Small stable-API JSONL client for one dedicated Codex app-server."""
253
+
254
+ _MAX_OVERLOAD_RETRIES = 5
255
+ _OVERLOAD_DELAY_S = 0.1
256
+ _OVERLOAD_DELAY_CAP_S = 1.0
257
+ _WRITE_TIMEOUT_S = 2.0 # bounded even for notifications/server responses
258
+ _MAX_JSONL_FRAME_BYTES = 4 * 1024 * 1024
259
+
260
+ def __init__(
261
+ self,
262
+ command: list[str],
263
+ *,
264
+ cwd: str,
265
+ on_notification: Callable[[str, dict[str, Any]], None],
266
+ on_server_request: Callable[[Any, str, dict[str, Any]], None],
267
+ stderr=None,
268
+ socket_path: str | None = None,
269
+ ):
270
+ self.on_notification = on_notification
271
+ self.on_server_request = on_server_request
272
+ self._ws: _UnixWebSocket | None = None
273
+ self._socket_shutdown = False
274
+ self._parent_watcher: subprocess.Popen | None = None
275
+ self._owned_pgid: int | None = None
276
+ if socket_path:
277
+ # process_group=0 makes this watcher the sole leader of a new
278
+ # process group within codexd's session. The native launcher
279
+ # joins it, but .process remains the actual app-server PID.
280
+ # The watcher has no user env/credentials; only a stdin pipe
281
+ # held by codexd. The app-server inherits neither pipe endpoint.
282
+ watcher = subprocess.Popen(
283
+ [sys.executable, "-I", "-c", _PARENT_LIFETIME_WATCHER],
284
+ cwd=cwd, env={}, stdin=subprocess.PIPE,
285
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
286
+ close_fds=True, process_group=0,
287
+ )
288
+ self._parent_watcher = watcher
289
+ self._owned_pgid = watcher.pid
290
+ try:
291
+ self.process = subprocess.Popen(
292
+ command,
293
+ cwd=cwd,
294
+ env=_user_command_env(),
295
+ stdin=subprocess.DEVNULL if socket_path else subprocess.PIPE,
296
+ stdout=subprocess.DEVNULL if socket_path else subprocess.PIPE,
297
+ stderr=stderr,
298
+ text=True,
299
+ bufsize=1,
300
+ **({"umask": 0o077, "process_group": self._owned_pgid}
301
+ if socket_path else {}),
302
+ )
303
+ except BaseException:
304
+ if self._parent_watcher is not None:
305
+ # Only the group we just created; the native launch failed.
306
+ try:
307
+ os.killpg(self._parent_watcher.pid, signal.SIGKILL)
308
+ except ProcessLookupError:
309
+ pass
310
+ self._parent_watcher.wait()
311
+ self._parent_watcher.stdin.close()
312
+ raise
313
+ if socket_path:
314
+ try:
315
+ deadline = time.monotonic() + 20
316
+ while True:
317
+ if self.process.poll() is not None:
318
+ raise ProtocolTransportError("Codex socket app-server exited during startup")
319
+ try:
320
+ self._ws = _UnixWebSocket(
321
+ socket_path, handshake_timeout=min(1., max(.1, deadline - time.monotonic())))
322
+ self._ws.sock.setblocking(False)
323
+ break
324
+ except OSError as error:
325
+ if time.monotonic() >= deadline:
326
+ raise ProtocolTransportError(
327
+ f"Codex app-server WebSocket not ready: {error}") from error
328
+ time.sleep(.05)
329
+ except BaseException:
330
+ self.close()
331
+ raise
332
+ self._next_id = 1
333
+ self._pending: dict[Any, queue.Queue] = {}
334
+ # Registry/reader/failure state is separate from the original client
335
+ # writer lock. A blocked writer never delays reader-side _fail.
336
+ self._state_lock = threading.Lock()
337
+ self._lock = threading.Lock()
338
+ if self.process.stdin is not None:
339
+ # Exclusively use raw UTF-8 os.write from this point onward. Do not
340
+ # use TextIOWrapper.write/flush alongside this nonblocking fd.
341
+ os.set_blocking(self.process.stdin.fileno(), False)
342
+ self._closed = threading.Event()
343
+ self._failure: ProtocolTransportError | None = None
344
+ self._reader = threading.Thread(
345
+ target=self._read_loop, name="ccx-codex-appserver", daemon=True
346
+ )
347
+ self._reader.start()
348
+
349
+ @property
350
+ def failure(self) -> ProtocolTransportError | None:
351
+ """The latched transport failure, if this client has stopped reading.
352
+
353
+ The reader is the only consumer of app-server notifications, so a
354
+ latched failure means the connection is unusable even while the
355
+ subprocess is still alive. Supervisors must treat it as fatal.
356
+ """
357
+ with self._state_lock:
358
+ return self._failure
359
+
360
+ @classmethod
361
+ def _encode_frame(cls, message: dict[str, Any]) -> bytes:
362
+ # Keep the JSON ASCII escape convention of the original text writer:
363
+ # it also encodes surrogateescape filenames/argv (e.g. \udcff) as
364
+ # valid JSON without attempting to UTF-8 encode an unpaired surrogate.
365
+ # Real Unicode still round-trips through json.loads on the provider.
366
+ frame = (json.dumps(message, separators=(",", ":"),
367
+ ensure_ascii=True, allow_nan=False) + "\n").encode("utf-8")
368
+ if len(frame) > cls._MAX_JSONL_FRAME_BYTES:
369
+ raise ProtocolError("app-server JSONL frame exceeds bounded write size")
370
+ return frame
371
+
372
+ def _wire_frame(self, message: dict[str, Any]) -> bytes:
373
+ raw = self._encode_frame(message)
374
+ return _masked_frame(0x1, raw[:-1]) if self._ws is not None else raw
375
+
376
+ def _write_frame_locked(self, frame: bytes, deadline: float, *,
377
+ method: str | None = None) -> None:
378
+ """Deadline covers every byte, including a full or stalled stdin pipe.
379
+
380
+ The caller owns `_lock`. No text buffer or arbitrary worker wait
381
+ is involved; the reader can fail pending requests through `_state_lock` even
382
+ during a stalled write. Once any byte might have escaped, a timeout or
383
+ partial frame is transport-unknown, never a safe retry permission.
384
+ """
385
+ sent = 0
386
+ pipe_blocked = False
387
+ full_write_window = False
388
+ while sent < len(frame):
389
+ with self._state_lock:
390
+ failed = self._failure
391
+ if failed is not None:
392
+ if sent == 0:
393
+ raise ProtocolNotSent(failed.message, method=method) from failed
394
+ raise ProtocolTransportError(
395
+ "app-server channel failed after partial JSONL frame",
396
+ method=method,
397
+ ) from failed
398
+ remaining = deadline - time.monotonic()
399
+ if remaining <= 0:
400
+ if sent > 0:
401
+ raise ProtocolTransportError(
402
+ "app-server write deadline exceeded after partial frame",
403
+ method=method,
404
+ )
405
+ if full_write_window:
406
+ # An unresponsive pipe over the entire normal write
407
+ # window is a shared transport failure, even at zero bytes.
408
+ raise ProtocolTransportError(
409
+ "app-server pipe failed to make progress for full write window",
410
+ method=method,
411
+ )
412
+ # This request's shorter admission budget expired before any
413
+ # native bytes escaped. No shared pipe corruption or proof of
414
+ # provider rejection: siblings may still use the live client.
415
+ raise ProtocolNotSent(
416
+ "app-server write deadline exceeded before first byte",
417
+ method=method,
418
+ )
419
+ fd = (self._ws.sock.fileno() if self._ws is not None
420
+ else self.process.stdin.fileno() if self.process.stdin is not None
421
+ else None)
422
+ if fd is None:
423
+ raise ProtocolTransportError(
424
+ "app-server write channel unavailable", method=method,
425
+ )
426
+ try:
427
+ written = os.write(fd, frame[sent:])
428
+ if written <= 0:
429
+ raise OSError(errno.EPIPE, "app-server pipe made no write progress")
430
+ sent += written
431
+ continue
432
+ except InterruptedError:
433
+ continue
434
+ except BlockingIOError:
435
+ if not pipe_blocked:
436
+ pipe_blocked = True
437
+ # Tolerate only scheduling/serialization jitter when the
438
+ # full normal window was allotted to this pipe write.
439
+ # A caller whose original <=2s admission was consumed by
440
+ # claim/marker fsync gets a local zero-byte timeout instead.
441
+ slack = min(.01, self._WRITE_TIMEOUT_S / 10)
442
+ full_write_window = (
443
+ deadline - time.monotonic() >= self._WRITE_TIMEOUT_S - slack
444
+ )
445
+ except (OSError, ValueError) as error:
446
+ # EPIPE / a genuinely broken fd is fatal regardless of byte
447
+ # count. Only a proven budget expiry has local semantics.
448
+ raise ProtocolTransportError(
449
+ f"app-server pipe write failed: {error}", method=method,
450
+ ) from error
451
+ try:
452
+ # Check for reader failure at least once per tenth-second,
453
+ # without resetting the one absolute end-to-end deadline.
454
+ select.select([], [fd], [], min(.1, max(0., deadline - time.monotonic())))
455
+ except InterruptedError:
456
+ continue
457
+ except (OSError, ValueError) as error:
458
+ # A broken poll descriptor is a shared transport fault, not a
459
+ # caller-local timeout even if this frame sent no bytes.
460
+ raise ProtocolTransportError(
461
+ f"app-server pipe wait failed: {error}", method=method,
462
+ ) from error
463
+
464
+ def _write(self, message: dict[str, Any]) -> None:
465
+ """Bounded notification/response write; never share text buffering."""
466
+ self._write_bytes(self._wire_frame(message))
467
+
468
+ def _write_bytes(self, frame: bytes) -> None:
469
+ """Bounded serialized write for JSON messages and WS pong frames."""
470
+ deadline = time.monotonic() + self._WRITE_TIMEOUT_S
471
+ if not self._lock.acquire(timeout=max(0., deadline - time.monotonic())):
472
+ # No bytes from this notification escaped; an unrelated writer
473
+ # owning the lock is not evidence that its channel is corrupt.
474
+ raise ProtocolNotSent("app-server writer lock deadline exceeded")
475
+ error = None
476
+ pending = []
477
+ failure = None
478
+ try:
479
+ try:
480
+ self._write_frame_locked(frame, deadline)
481
+ except ProtocolNotSent as exc:
482
+ # A short local write deadline cannot poison unrelated sends.
483
+ error = exc
484
+ except ProtocolTransportError as exc:
485
+ error = exc
486
+ failure = ProtocolTransportError(exc.message)
487
+ pending = self._latch_failure(failure)
488
+ finally:
489
+ self._lock.release()
490
+ if error is not None:
491
+ # Fatal state (if any) was latched before releasing the writer
492
+ # lock. A caller-local zero-byte timeout has no sibling callbacks.
493
+ # All actual failure callbacks remain outside both client locks.
494
+ if failure is not None:
495
+ self._deliver_failure(pending, failure)
496
+ raise error
497
+
498
+ def _latch_failure(self, failure: ProtocolTransportError) -> list[Any]:
499
+ """Publish fatal channel state without acquiring the writer lock."""
500
+ with self._state_lock:
501
+ if self._failure is not None:
502
+ return []
503
+ self._failure = failure
504
+ self._closed.set()
505
+ pending = list(self._pending.values())
506
+ self._pending.clear()
507
+ return pending
508
+
509
+ @staticmethod
510
+ def _deliver_failure(pending: list[Any], failure: ProtocolTransportError) -> None:
511
+ """Deliver outside both client locks, including registered callbacks."""
512
+ for result_queue in pending:
513
+ try:
514
+ result_queue.put_nowait(failure)
515
+ except queue.Full:
516
+ pass
517
+
518
+ def _fail(self, failure: ProtocolTransportError) -> None:
519
+ self._deliver_failure(self._latch_failure(failure), failure)
520
+
521
+ def _send(
522
+ self, method: str, params: dict[str, Any] | None, sink: Any,
523
+ *, write_deadline: float | None = None,
524
+ ) -> int:
525
+ """One JSONL request with bounded lock and pipe admission.
526
+
527
+ An unexpired, locally shortened write deadline can fail before any
528
+ byte without damaging the shared transport or siblings. Lock deadline
529
+ expiry has the same request-only semantics. A partial frame, EPIPE or
530
+ independently failed channel remains fatal and wakes all siblings.
531
+ None of these outcomes undo a CCX submitted-unknown activation claim.
532
+ """
533
+ deadline = (time.monotonic() + self._WRITE_TIMEOUT_S
534
+ if write_deadline is None else write_deadline)
535
+ if not self._lock.acquire(timeout=max(0., deadline - time.monotonic())):
536
+ # Writer-lock contention proves zero bytes from this request.
537
+ # The existing pending siblings and physical JSONL stream survive.
538
+ raise ProtocolNotSent(
539
+ "app-server writer lock deadline exceeded", method=method,
540
+ )
541
+ error = None
542
+ pending = []
543
+ failure = None
544
+ try:
545
+ with self._state_lock:
546
+ if self._failure is not None:
547
+ raise ProtocolNotSent(
548
+ self._failure.message, method=method,
549
+ ) from self._failure
550
+ request_id = self._next_id
551
+ self._next_id += 1
552
+ message = {"jsonrpc": "2.0", "id": request_id, "method": method}
553
+ if params is not None:
554
+ message["params"] = params
555
+ frame = self._wire_frame(message)
556
+ with self._state_lock:
557
+ if self._failure is not None:
558
+ raise ProtocolNotSent(
559
+ self._failure.message, method=method,
560
+ ) from self._failure
561
+ self._pending[request_id] = sink
562
+ try:
563
+ self._write_frame_locked(frame, deadline, method=method)
564
+ except ProtocolNotSent as exc:
565
+ # The frame was not emitted: forget this request sink only.
566
+ # Another request's still-pending reply remains observable.
567
+ with self._state_lock:
568
+ if self._pending.get(request_id) is sink:
569
+ del self._pending[request_id]
570
+ error = exc
571
+ except ProtocolTransportError as exc:
572
+ error = exc
573
+ failure = ProtocolTransportError(exc.message, method=method)
574
+ pending = self._latch_failure(failure)
575
+ finally:
576
+ self._lock.release()
577
+ if error is not None:
578
+ # Shared transport failures were latched before unlocking, and
579
+ # callbacks may safely re-enter client methods. A zero-byte local
580
+ # deadline has no shared failure or pending-sibling deliveries.
581
+ if failure is not None:
582
+ self._deliver_failure(pending, failure)
583
+ raise error
584
+ return request_id
585
+
586
+ def begin_request(
587
+ self, method: str, params: dict[str, Any] | None = None,
588
+ *, write_deadline: float | None = None,
589
+ ) -> "PendingRequest":
590
+ """Send one request without waiting for it.
591
+
592
+ Unlike `request`, no caller timeout ever forgets it: the answer, or
593
+ the transport failure that ends the connection, always reaches the
594
+ returned `PendingRequest`. Use it for a mutation whose completion
595
+ something else must be able to wait for later.
596
+ """
597
+ pending = PendingRequest(method)
598
+ pending.request_id = self._send(
599
+ method, params, pending, write_deadline=write_deadline,
600
+ )
601
+ return pending
602
+
603
+ def _request_once(
604
+ self,
605
+ method: str,
606
+ params: dict[str, Any] | None,
607
+ timeout: float,
608
+ ) -> dict[str, Any]:
609
+ result_queue: queue.Queue = queue.Queue(maxsize=1)
610
+ # Preserve the response-read timeout as a separate observation wait
611
+ # after frame submission. Write admission has its own bounded budget,
612
+ # and a failed write is never a provider rejection or read timeout.
613
+ request_id = self._send(
614
+ method, params, result_queue,
615
+ write_deadline=time.monotonic() + self._WRITE_TIMEOUT_S,
616
+ )
617
+
618
+ try:
619
+ response = result_queue.get(timeout=max(0., timeout))
620
+ except queue.Empty as error:
621
+ with self._state_lock:
622
+ self._pending.pop(request_id, None)
623
+ raise ProtocolError(f"{method} timed out", method=method) from error
624
+ return _result_from_response(method, response, request_id)
625
+
626
+ def request(
627
+ self, method: str, params: dict[str, Any] | None = None, timeout: float = 30
628
+ ) -> dict[str, Any]:
629
+ deadline = time.monotonic() + max(0.0, timeout)
630
+ delay = self._OVERLOAD_DELAY_S
631
+ last_overload: ServerOverloaded | None = None
632
+ for attempt in range(self._MAX_OVERLOAD_RETRIES + 1):
633
+ remaining = max(0.0, deadline - time.monotonic())
634
+ if attempt > 0 and remaining <= 0:
635
+ # Never write an automatic retry after its shared observation
636
+ # budget has expired: the overload rejection only proved that
637
+ # the previous request was not processed.
638
+ assert last_overload is not None
639
+ raise last_overload
640
+ try:
641
+ return self._request_once(method, params, remaining)
642
+ except ServerOverloaded as error:
643
+ last_overload = error
644
+ if attempt >= self._MAX_OVERLOAD_RETRIES:
645
+ raise
646
+ remaining = max(0.0, deadline - time.monotonic())
647
+ sleep_for = min(
648
+ remaining,
649
+ delay + random.uniform(0.0, delay * 0.25),
650
+ )
651
+ if sleep_for <= 0:
652
+ raise error
653
+ time.sleep(sleep_for)
654
+ delay = min(self._OVERLOAD_DELAY_CAP_S, delay * 2)
655
+
656
+ def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
657
+ message = {"jsonrpc": "2.0", "method": method}
658
+ if params is not None:
659
+ message["params"] = params
660
+ self._write(message)
661
+
662
+ def respond(self, request_id: Any, result: dict[str, Any]) -> None:
663
+ self._write({"jsonrpc": "2.0", "id": request_id, "result": result})
664
+
665
+ def reject_request(self, request_id: Any, message: str) -> None:
666
+ self._write({
667
+ "jsonrpc": "2.0",
668
+ "id": request_id,
669
+ "error": {"code": -32601, "message": message},
670
+ })
671
+
672
+ def _messages(self):
673
+ if self._ws is None:
674
+ assert self.process.stdout is not None
675
+ yield from self.process.stdout
676
+ else:
677
+ while not self._closed.is_set():
678
+ yield self._ws.recv_text(
679
+ on_ping=lambda payload: self._write_bytes(_masked_frame(0xA, payload)))
680
+
681
+ def _read_loop(self) -> None:
682
+ try:
683
+ for raw in self._messages():
684
+ try:
685
+ message = json.loads(raw)
686
+ except ValueError as error:
687
+ excerpt = repr(raw[:200])
688
+ raise ProtocolTransportError(
689
+ f"invalid JSON from app-server stdout: {excerpt}"
690
+ ) from error
691
+ if not isinstance(message, dict):
692
+ raise ProtocolTransportError(
693
+ "invalid app-server protocol message: expected object"
694
+ )
695
+ request_id = message.get("id")
696
+ if request_id is not None and (
697
+ "result" in message or "error" in message
698
+ ):
699
+ with self._state_lock:
700
+ pending = self._pending.pop(request_id, None)
701
+ if pending:
702
+ pending.put(message)
703
+ continue
704
+ method = message.get("method")
705
+ if not method:
706
+ if request_id is not None:
707
+ raise ProtocolTransportError(
708
+ "invalid app-server response: missing result or error"
709
+ )
710
+ continue
711
+ params = message.get("params")
712
+ if not isinstance(params, dict):
713
+ params = {}
714
+ try:
715
+ if request_id is not None:
716
+ self.on_server_request(request_id, method, params)
717
+ else:
718
+ self.on_notification(method, params)
719
+ except Exception as error:
720
+ # A message whose processing raised cannot be treated as
721
+ # consumed, and skipping it would leave the daemon's view
722
+ # of a worker permanently behind the runtime. Fail the
723
+ # shared client with the real cause instead.
724
+ raise ProtocolTransportError(
725
+ f"app-server {method} handler failed: "
726
+ f"{error!r}"[:400]
727
+ ) from error
728
+ except ProtocolTransportError as error:
729
+ self._fail(error)
730
+ except (OSError, EOFError, ValueError) as error:
731
+ self._fail(ProtocolTransportError(f"Codex app-server channel closed: {error}"))
732
+ finally:
733
+ self._fail(
734
+ ProtocolTransportError("Codex app-server exited")
735
+ )
736
+
737
+ def close(self) -> None:
738
+ if self._ws is not None or self.process.stdin is None:
739
+ if self._socket_shutdown:
740
+ return
741
+ self._socket_shutdown = True
742
+ if hasattr(self, "_closed"):
743
+ self._closed.set()
744
+ # The socket-mode launcher spawns the vendor binary. Terminating
745
+ # only the launcher can orphan it; the child has its own process
746
+ # group, separate from codexd and every unrelated Codex process.
747
+ ws = self._ws
748
+ if ws is not None:
749
+ try:
750
+ ws.sock.shutdown(socket.SHUT_RDWR)
751
+ except OSError:
752
+ pass
753
+ ws.sock.close()
754
+ watcher = getattr(self, "_parent_watcher", None)
755
+ if watcher is not None and watcher.stdin is not None and not watcher.stdin.closed:
756
+ watcher.stdin.close()
757
+ pgid = getattr(self, "_owned_pgid", None) or self.process.pid
758
+ try:
759
+ os.killpg(pgid, signal.SIGTERM)
760
+ except ProcessLookupError:
761
+ pass
762
+ try:
763
+ self.process.wait(timeout=2)
764
+ except subprocess.TimeoutExpired:
765
+ pass
766
+ # A vendor child can survive its launcher. The watcher anchors
767
+ # this group until it is terminated/reaped even if the leader
768
+ # app-server exited first.
769
+ try:
770
+ os.killpg(pgid, signal.SIGKILL)
771
+ except ProcessLookupError:
772
+ pass
773
+ try:
774
+ self.process.wait(timeout=2)
775
+ except subprocess.TimeoutExpired:
776
+ pass
777
+ if watcher is not None:
778
+ try:
779
+ watcher.wait(timeout=2)
780
+ except subprocess.TimeoutExpired:
781
+ watcher.kill()
782
+ watcher.wait()
783
+ return
784
+ if self.process.poll() is not None:
785
+ return
786
+ # stdin EOF is the app-server's clean shutdown signal and the only one
787
+ # the `codex` launcher reliably propagates: SIGTERM kills the launcher
788
+ # while orphaning the vendor-binary grandchild, which then keeps
789
+ # polling its backing service from under init.
790
+ try:
791
+ if self.process.stdin is not None:
792
+ self.process.stdin.close()
793
+ except OSError:
794
+ pass
795
+ try:
796
+ self.process.wait(timeout=3)
797
+ return
798
+ except subprocess.TimeoutExpired:
799
+ pass
800
+ self.process.terminate()
801
+ try:
802
+ self.process.wait(timeout=3)
803
+ except subprocess.TimeoutExpired:
804
+ self.process.kill()
805
+ self.process.wait(timeout=3)