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,1412 @@
1
+ """Shared, non-presentational operations for CCX-owned Codex workers.
2
+
3
+ Both ``ccx codex`` and :class:`ccx.CodexClient` use these primitives. Nothing
4
+ here prints, parses command output, or exits the importing process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ccx.version import ccx_version
10
+
11
+ import fcntl
12
+ import json
13
+ import os
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ from contextlib import contextmanager
20
+ from pathlib import Path
21
+ from typing import Any, Iterator
22
+
23
+ from ccx.codex.paths import (
24
+ codexd_lifetime_lock_path,
25
+ codexd_log_path,
26
+ codexd_sock_path,
27
+ codexd_spawn_lock_path,
28
+ ensure_dirs,
29
+ root as codex_state_root,
30
+ )
31
+ from ccx.constants import (
32
+ CODEXD_RECOVERY_OPS,
33
+ CODEXD_RPC_GENERATION,
34
+ )
35
+ from ccx.codex.protocol import AppServerClient, ProtocolError
36
+ from ccx.codex.reports import report_from_thread
37
+ from ccx.codex.records import read_record
38
+ from ccx.errors import (
39
+ CcxError,
40
+ InvalidRequest,
41
+ SessionNotFound,
42
+ SessionUnavailable,
43
+ SpawnFailed,
44
+ )
45
+ from ccx.store.worker_state import (
46
+ RUNTIME_ABSENT,
47
+ RUNTIME_INCONCLUSIVE,
48
+ RUNTIME_SERVED,
49
+ )
50
+
51
+ STABLE_STATES = {"idle", "waiting", "idle-killed", "crashed", "dead"}
52
+ # App-server boot; a load deadline still overrides this.
53
+ CODEXD_BOOT_TIMEOUT_S = 60
54
+ # How long a caller without a deadline waits for a stopping codexd to exit. Its
55
+ # shutdown closes the app-server (up to three 3s waits) before it lets go.
56
+ CODEXD_STOP_WAIT_S = 15
57
+
58
+
59
+ @contextmanager
60
+ def _short_lived_app_server(
61
+ *,
62
+ cwd: str,
63
+ title: str,
64
+ timeout: float,
65
+ rejection_prefix: str,
66
+ ) -> Iterator[AppServerClient]:
67
+ """Open one stable app-server connection for a read-only operation."""
68
+ from ccx.system.engine_binary import resolve_engine_binary
69
+ codex_bin = resolve_engine_binary("codex")
70
+ client: AppServerClient | None = None
71
+
72
+ def reject_request(
73
+ request_id: Any,
74
+ method: str,
75
+ params: dict[str, Any],
76
+ ) -> None:
77
+ if client is not None:
78
+ client.reject_request(request_id, f"{rejection_prefix}: {method}")
79
+
80
+ try:
81
+ client = AppServerClient(
82
+ [codex_bin, "app-server", "--stdio"],
83
+ cwd=cwd,
84
+ on_notification=lambda method, params: None,
85
+ on_server_request=reject_request,
86
+ stderr=subprocess.DEVNULL,
87
+ )
88
+ client.request(
89
+ "initialize",
90
+ {
91
+ "clientInfo": {
92
+ "name": "ccx",
93
+ "title": title,
94
+ "version": ccx_version(),
95
+ },
96
+ "capabilities": {"experimentalApi": False},
97
+ },
98
+ timeout=timeout,
99
+ )
100
+ client.notify("initialized")
101
+ yield client
102
+ finally:
103
+ if client is not None:
104
+ client.close()
105
+
106
+
107
+ def list_models(*, cwd: str, timeout: float = 30) -> list[str]:
108
+ """Return every model ID advertised by Codex's stable ``model/list`` API."""
109
+ try:
110
+ with _short_lived_app_server(
111
+ cwd=cwd,
112
+ title="CCX Codex model validation",
113
+ timeout=timeout,
114
+ rejection_prefix="unsupported request during model list read",
115
+ ) as client:
116
+ models: list[str] = []
117
+ cursor: str | None = None
118
+ seen_cursors: set[str] = set()
119
+ while True:
120
+ params = {"cursor": cursor} if cursor is not None else {}
121
+ page = client.request("model/list", params, timeout=timeout)
122
+ for item in page.get("data") or []:
123
+ if not isinstance(item, dict):
124
+ continue
125
+ model_id = item.get("model") or item.get("id")
126
+ if isinstance(model_id, str) and model_id and model_id not in models:
127
+ models.append(model_id)
128
+ next_cursor = page.get("nextCursor")
129
+ if not isinstance(next_cursor, str) or not next_cursor:
130
+ break
131
+ if next_cursor in seen_cursors:
132
+ raise RuntimeError("model/list returned a repeated cursor")
133
+ seen_cursors.add(next_cursor)
134
+ cursor = next_cursor
135
+ if not models:
136
+ raise RuntimeError("model/list returned no model IDs")
137
+ return models
138
+ except (OSError, RuntimeError) as error:
139
+ raise SessionUnavailable(
140
+ f"Codex model list unavailable: {error}"
141
+ ) from error
142
+
143
+
144
+ def validate_model(model: str, *, cwd: str, timeout: float = 30) -> list[str]:
145
+ """Validate one explicit model and return the complete advertised ID list."""
146
+ if not isinstance(model, str) or not model:
147
+ raise InvalidRequest("Codex model must be a non-empty string")
148
+ valid_models = list_models(cwd=cwd, timeout=timeout)
149
+ if model in valid_models:
150
+ return valid_models
151
+ suffix_matches = [
152
+ value for value in valid_models if value.endswith(f"-{model}")
153
+ ]
154
+ suggestion = (
155
+ f" Did you mean {suffix_matches[0]!r}?"
156
+ if len(suffix_matches) == 1
157
+ else ""
158
+ )
159
+ raise InvalidRequest(
160
+ f"unsupported Codex model {model!r}.{suggestion} "
161
+ f"Valid models: {', '.join(valid_models)}"
162
+ )
163
+
164
+
165
+ def read_account_usage(
166
+ *,
167
+ tokens: bool = False,
168
+ timeout: float = 30,
169
+ ) -> dict[str, Any]:
170
+ """Read Codex account quota or token activity without creating a worker.
171
+
172
+ A running codexd answers from its already-open app-server; only when no
173
+ daemon owns the runtime slot is a short-lived stable connection used. The
174
+ read never starts the daemon.
175
+ """
176
+ from ccx.system.engine_binary import resolve_engine_binary
177
+ resolve_engine_binary("codex")
178
+ deadline = time.monotonic() + max(0.0, timeout)
179
+ if observation_daemon(deadline):
180
+ reply = daemon_call(
181
+ {"op": "account-read", "tokens": tokens},
182
+ timeout=remaining(deadline, timeout),
183
+ )
184
+ if reply.get("ok"):
185
+ data = reply.get("data")
186
+ if isinstance(data, dict):
187
+ return data
188
+ raise SessionUnavailable(
189
+ "codexd returned no Codex account usage data", short="codexd"
190
+ )
191
+ # The daemon that owns the app-server answered, and its failure is the
192
+ # answer. Starting a second app-server beside it to get a different one
193
+ # would launder a daemon-side failure into a fresh read.
194
+ raise SessionUnavailable(
195
+ _served_failure(reply, "Codex usage unavailable"), short="codexd"
196
+ )
197
+ try:
198
+ with _short_lived_app_server(
199
+ cwd=os.getcwd(),
200
+ title="CCX Codex usage",
201
+ timeout=timeout,
202
+ rejection_prefix="unsupported account request during usage read",
203
+ ) as client:
204
+ method = "account/usage/read" if tokens else "account/rateLimits/read"
205
+ return client.request(method, timeout=timeout)
206
+ except (OSError, RuntimeError) as error:
207
+ raise SessionUnavailable(
208
+ f"Codex usage unavailable: {error}"
209
+ ) from error
210
+
211
+
212
+ def observation_daemon(
213
+ deadline: float | None,
214
+ *,
215
+ short: str | None = None,
216
+ ) -> bool:
217
+ """Does the shared daemon own the runtime slot for this observation?
218
+
219
+ There is one app-server per CCX root, and codexd owns it. So if codexd is
220
+ answering at all, an observation goes through it: starting a second
221
+ app-server beside a daemon that is merely still booting would double the
222
+ runtime and route the read around the compatibility boundary.
223
+
224
+ Returns ``True`` when the daemon should serve the read and ``False`` only
225
+ when absence is positively established and a short-lived app-server is safe. A daemon that is
226
+ booting is waited for strictly inside the caller's own deadline — never
227
+ past it — and one of another generation is refused rather than bypassed.
228
+ """
229
+ while True:
230
+ probe = settled_daemon_status(deadline)
231
+ transport = probe.get("transport")
232
+ if transport == RUNTIME_ABSENT:
233
+ return False
234
+ if transport != RUNTIME_SERVED:
235
+ raise SessionUnavailable(
236
+ "codexd presence is inconclusive; no second app-server was started",
237
+ short=short or "codexd",
238
+ )
239
+ require_compatible_daemon(probe, short=short)
240
+ if probe.get("state") == "ready":
241
+ return True
242
+ if remaining(deadline, 1) <= 0:
243
+ raise SessionUnavailable(
244
+ "codexd is still booting and the read budget expired; it owns "
245
+ "the shared app-server, so no second one was started",
246
+ short=short or "codexd",
247
+ )
248
+ time.sleep(min(0.05, remaining(deadline, 0.05)))
249
+
250
+
251
+ def remaining(deadline: float | None, maximum: float) -> float:
252
+ if deadline is None:
253
+ return maximum
254
+ return max(0.0, min(maximum, deadline - time.monotonic()))
255
+
256
+
257
+ def _lifetime_lock_held() -> bool | None:
258
+ """Read-only positive singleton lease check, no implicit lock creation."""
259
+ from ccx import constants
260
+ from ccx.store.root import require_ccx_active
261
+ import stat
262
+ try:
263
+ store = require_ccx_active(constants.CCX_ROOT)
264
+ with store._gen("runtime-metadata", "pid") as directory:
265
+ try:
266
+ fd = os.open(
267
+ "codexd.lease.lock",
268
+ os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
269
+ dir_fd=directory,
270
+ )
271
+ except FileNotFoundError:
272
+ return False
273
+ try:
274
+ info = os.fstat(fd)
275
+ if (not stat.S_ISREG(info.st_mode)
276
+ or info.st_uid != os.getuid()
277
+ or info.st_mode & 0o077):
278
+ return None
279
+ try:
280
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
281
+ except BlockingIOError:
282
+ return True
283
+ fcntl.flock(fd, fcntl.LOCK_UN)
284
+ return False
285
+ finally:
286
+ os.close(fd)
287
+ except FileNotFoundError:
288
+ return False
289
+ except (OSError, ValueError):
290
+ return None
291
+
292
+
293
+ def _transport_failure(error: BaseException) -> dict[str, Any]:
294
+ """Classify a failed socket observation without confusing timeout with absence."""
295
+ if isinstance(error, (TimeoutError, socket.timeout)):
296
+ presence = RUNTIME_INCONCLUSIVE
297
+ elif isinstance(error, OSError) and getattr(error, "errno", None) in {
298
+ 2, # ENOENT: no socket file
299
+ 32, # EPIPE: the listener closed under the request
300
+ 54, # ECONNRESET (BSD/macOS)
301
+ 61, # ECONNREFUSED (BSD/macOS)
302
+ 104, # ECONNRESET: a backlog connection dropped as the listener closed
303
+ 111, # ECONNREFUSED
304
+ }:
305
+ held = _lifetime_lock_held()
306
+ presence = RUNTIME_ABSENT if held is False else RUNTIME_INCONCLUSIVE
307
+ if held is True:
308
+ # Nothing listens, yet a codexd still owns the runtime: one that is
309
+ # binding its socket or shutting down. Both resolve in seconds.
310
+ return {
311
+ "ok": False,
312
+ "error": f"codexd unavailable: {error.__class__.__name__}",
313
+ "transport": presence,
314
+ "transitional": True,
315
+ }
316
+ else:
317
+ presence = RUNTIME_INCONCLUSIVE
318
+ return {
319
+ "ok": False,
320
+ "error": f"codexd unavailable: {error.__class__.__name__}",
321
+ "transport": presence,
322
+ }
323
+
324
+
325
+ HISTORY_REPLY_OPS = frozenset({"thread-read", "thread-history", "thread-items-list", "thread-turns-list"})
326
+
327
+
328
+ def daemon_rpc(message: dict[str, Any], timeout: float = 10, *,
329
+ keep_open: bool = False) -> dict[str, Any]:
330
+ """Real daemon RPC: verify singleton lease and SO_PEERCRED on same socket.
331
+
332
+ Codex provider native IDs never identify the managed CCX worker, and a
333
+ socket pathname or prior daemon status is not evidence of this daemon's
334
+ PID, generation or still-held runtime role lock.
335
+ """
336
+ from ccx import constants
337
+ from ccx.store.root import require_ccx_active
338
+ from ccx.rpc.transport import (
339
+ handshake_payload, plan_transport, verify_handshake,
340
+ )
341
+ if timeout <= 0:
342
+ return {
343
+ "ok": False, "error": "codexd unavailable: TimeoutError",
344
+ "transport": RUNTIME_INCONCLUSIVE,
345
+ }
346
+ try:
347
+ store = require_ccx_active(constants.CCX_ROOT)
348
+ plan = plan_transport(store, role="codexd")
349
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
350
+ client.settimeout(timeout)
351
+ client.connect(str(plan.final))
352
+ client.sendall((json.dumps({
353
+ "op": "handshake",
354
+ "identity": {"protocolVersion": 2, **plan.evidence()},
355
+ }) + "\n").encode())
356
+ # The native daemon must prove its own ACTIVE lease nonce in its
357
+ # reply. The request supplies only the expected root/role fields:
358
+ # a client cannot know a daemon nonce until the daemon presents
359
+ # it, and knowledge of the nonce alone never authorizes a peer.
360
+ data = bytearray()
361
+ # thread/read and thread/items/list responses are caller-requested
362
+ # native history, not bounded control envelopes. A large rollout or
363
+ # one page of big tool outputs can exceed the normal 1 MiB RPC reply
364
+ # guard; rejecting it here masqueraded as "codexd unavailable:
365
+ # ValueError".
366
+ def read_one(*, bounded: bool = True) -> dict:
367
+ while b"\n" not in data:
368
+ chunk = client.recv(65536)
369
+ if not chunk:
370
+ raise EOFError("Codex daemon closed socket mid-RPC")
371
+ data.extend(chunk)
372
+ if bounded and len(data) > 1024 * 1024:
373
+ raise ValueError("Codex daemon reply exceeds budget")
374
+ line, rest = data.split(b"\n", 1)
375
+ data[:] = rest
376
+ value = json.loads(line)
377
+ if not isinstance(value, dict):
378
+ raise ValueError("Codex daemon replied with non-object")
379
+ return value
380
+ hello = read_one()
381
+ if not hello.get("ok"):
382
+ raise ValueError(
383
+ "Codex daemon handshake refused: "
384
+ + str(hello.get("error"))
385
+ )
386
+ verify_handshake(
387
+ store, plan, received=hello.get("identity"),
388
+ connected_socket=client,
389
+ )
390
+ store.revalidate()
391
+ client.sendall((json.dumps(message) + "\n").encode())
392
+ result = read_one(bounded=message.get("op") not in HISTORY_REPLY_OPS)
393
+ if keep_open and result.get("ok") is True:
394
+ return {**result, "transport": RUNTIME_SERVED,
395
+ "_held_fd": client.detach()}
396
+ return {**result, "transport": RUNTIME_SERVED}
397
+ except (OSError, ValueError, EOFError) as error:
398
+ return _transport_failure(error)
399
+
400
+
401
+ @contextmanager
402
+ def attached_worker(target: str, *, timeout: float = 90) -> Iterator[dict[str, Any]]:
403
+ """Hold one authenticated codexd connection while the native TUI runs."""
404
+ from ccx import constants
405
+ from ccx.store.root import require_ccx_active
406
+ from ccx.store.identity import IdentityError
407
+
408
+ store = require_ccx_active(constants.CCX_ROOT)
409
+ try:
410
+ owner = store.resolve(target)
411
+ if owner.engine != "codex":
412
+ raise InvalidRequest("attach requires a Codex worker")
413
+ store.authorize_native_action(owner.worker, "resume")
414
+ record = store.record(owner.worker)
415
+ require_workspace(record)
416
+ except IdentityError as error:
417
+ raise InvalidRequest(f"Codex attach worker unavailable: {error}") from error
418
+
419
+ deadline = time.monotonic() + timeout
420
+ ensure_daemon(deadline=deadline)
421
+ reply = daemon_rpc({
422
+ "op": "attach", "short": owner.worker_id,
423
+ "rpcGeneration": CODEXD_RPC_GENERATION,
424
+ }, timeout=max(.1, deadline - time.monotonic()), keep_open=True)
425
+ fd = reply.pop("_held_fd", None)
426
+ if fd is None:
427
+ if reply.get("transport") == RUNTIME_SERVED:
428
+ require_served_generation(reply, short=owner.short)
429
+ raise SessionUnavailable(
430
+ f"Codex attach unavailable: {reply.get('error') or reply.get('state') or reply.get('transport')}",
431
+ short=owner.short)
432
+ with socket.socket(fileno=fd):
433
+ require_served_generation(reply, short=owner.short)
434
+ store.revalidate()
435
+ args = record.get("createArgs") or {}
436
+ yield {**reply, "workerId": owner.worker_id,
437
+ "model": args.get("model"), "effort": args.get("effort"),
438
+ "hasTurn": bool(record.get("lastTurn"))}
439
+
440
+
441
+ # Inspection and teardown stay available on an incompatible daemon: they are
442
+ # how an operator sees it and gets rid of it. Everything else is refused.
443
+ RECOVERY_OPS = CODEXD_RECOVERY_OPS
444
+
445
+
446
+ def daemon_call(
447
+ message: dict[str, Any],
448
+ timeout: float = 10,
449
+ *,
450
+ short: str | None = None,
451
+ ) -> dict[str, Any]:
452
+ """Send a non-recovery request that declares this client's RPC generation.
453
+
454
+ The declaration is what lets a current daemon refuse an old client before
455
+ running the handler, so it belongs on every request that can change
456
+ anything — never on the recovery operations that must keep answering an
457
+ incompatible peer.
458
+
459
+ Whatever comes back is held to the same standard in reverse. Once a request
460
+ has actually been written, a served envelope must declare this generation
461
+ whether it reports success or failure: a daemon replaced between the
462
+ preflight and the write could otherwise return an old ``ok: false`` and
463
+ have its application error read as a current one.
464
+ """
465
+ reply = daemon_rpc(
466
+ {**message, "rpcGeneration": CODEXD_RPC_GENERATION}, timeout
467
+ )
468
+ if reply.get("transport") != RUNTIME_SERVED:
469
+ return reply
470
+ return require_served_generation(reply, short=short)
471
+
472
+
473
+ def daemon_generation(reply: dict[str, Any]) -> int | None:
474
+ """The RPC generation an answering daemon declared, if it declared one."""
475
+ generation = reply.get("rpcGeneration")
476
+ return generation if isinstance(generation, int) else None
477
+
478
+
479
+ def require_served_generation(
480
+ reply: dict[str, Any],
481
+ *,
482
+ short: str | None = None,
483
+ ) -> dict[str, Any]:
484
+ """Refuse an envelope a codexd of another RPC generation served us.
485
+
486
+ A daemon that predates the generation field declares nothing and is treated
487
+ as incompatible: its replies are missing members current callers rely on,
488
+ and using them anyway is how a successful turn gets reported as a failure —
489
+ or an old rejection as a current one. Success is irrelevant here; only who
490
+ answered is.
491
+ """
492
+ generation = daemon_generation(reply)
493
+ if generation != CODEXD_RPC_GENERATION:
494
+ raise SessionUnavailable(
495
+ "the running codexd speaks RPC generation "
496
+ f"{generation if generation is not None else 'unknown (legacy)'}, "
497
+ f"but this CCX speaks {CODEXD_RPC_GENERATION}. Finish or kill its "
498
+ "workers and run `ccx shutdown --engine codex` to replace it.",
499
+ short=short or "codexd",
500
+ )
501
+ return reply
502
+
503
+
504
+ def require_compatible_daemon(
505
+ reply: dict[str, Any],
506
+ *,
507
+ short: str | None = None,
508
+ ) -> dict[str, Any]:
509
+ """Hold a raw availability probe to this RPC generation.
510
+
511
+ Only a reply that reports success proves a daemon is there to be
512
+ incompatible with: a not-ok probe mutated nothing and may simply mean no
513
+ daemon, so those paths stay free to fall through to :func:`ensure_daemon`,
514
+ which knows how to replace an idle one. Requests that were actually written
515
+ are stricter — see :func:`require_served_generation`.
516
+ """
517
+ if reply.get("transport") != RUNTIME_SERVED:
518
+ return reply
519
+ return require_served_generation(reply, short=short)
520
+
521
+
522
+ def preflight_daemon(
523
+ deadline: float | None,
524
+ *,
525
+ short: str | None = None,
526
+ ) -> dict[str, Any] | None:
527
+ """Establish daemon compatibility before a mutating operation is written.
528
+
529
+ Returns ``None`` once the answering daemon has positively declared this
530
+ RPC generation and the positive-absence reply when no daemon owns the
531
+ runtime. An inconclusive probe raises: it must never authorize mutation or
532
+ daemon replacement.
533
+ """
534
+ probe = settled_daemon_status(deadline)
535
+ if probe.get("transport") == RUNTIME_ABSENT:
536
+ return probe
537
+ if probe.get("transport") != RUNTIME_SERVED:
538
+ raise SessionUnavailable(
539
+ "codexd presence is inconclusive", short=short or "codexd"
540
+ )
541
+ require_compatible_daemon(probe, short=short)
542
+ return None
543
+
544
+
545
+ def rpc(
546
+ short: str,
547
+ message: dict[str, Any],
548
+ timeout: float = 10,
549
+ ) -> dict[str, Any]:
550
+ """Send a worker-addressed operation to the shared codexd daemon.
551
+
552
+ Every operation that is not a recovery operation is fenced on the daemon's
553
+ RPC generation before it is written, so an incompatible daemon never
554
+ processes a steer, an approval, or a turn and leaves the caller to notice
555
+ afterwards. ``timeout`` is the end-to-end budget for the preflight and the
556
+ operation together; the reply-side check stays as a guard against a daemon
557
+ that is replaced between the two.
558
+ """
559
+ if message.get("op") in RECOVERY_OPS:
560
+ return daemon_rpc({**message, "short": short}, timeout)
561
+ deadline = time.monotonic() + max(0.0, timeout)
562
+ unreachable = preflight_daemon(deadline, short=short)
563
+ if unreachable is not None:
564
+ return unreachable
565
+ return daemon_call(
566
+ {**message, "short": short},
567
+ timeout=remaining(deadline, timeout),
568
+ short=short,
569
+ )
570
+
571
+
572
+ def daemon_status(timeout: float = 2) -> dict[str, Any]:
573
+ """Probe the daemon. Deliberately unchecked: status is a recovery tool."""
574
+ return daemon_rpc({"op": "daemon-status"}, timeout=timeout)
575
+
576
+
577
+ def _stopping(probe: dict[str, Any]) -> bool:
578
+ """A codexd is between owning the runtime and having let go of it."""
579
+ if probe.get("transport") == RUNTIME_SERVED:
580
+ return probe.get("state") == "stopping"
581
+ return probe.get("transport") == RUNTIME_INCONCLUSIVE and bool(
582
+ probe.get("transitional")
583
+ )
584
+
585
+
586
+ def settled_daemon_status(deadline: float | None) -> dict[str, Any]:
587
+ """Probe the daemon, waiting out one that is binding or shutting down.
588
+
589
+ A stopping codexd refuses new loads and is about to release the runtime,
590
+ so neither "use it" nor "start another" is right yet. Waiting is bounded by
591
+ the caller's deadline (or ``CODEXD_STOP_WAIT_S``); what is still
592
+ transitional then is returned as the inconclusive probe it is.
593
+ """
594
+ limit = (
595
+ deadline if deadline is not None
596
+ else time.monotonic() + CODEXD_STOP_WAIT_S
597
+ )
598
+ while True:
599
+ probe = daemon_status(timeout=max(0.25, remaining(deadline, 1)))
600
+ if not _stopping(probe) or time.monotonic() >= limit:
601
+ return probe
602
+ time.sleep(min(0.05, max(0.0, limit - time.monotonic())))
603
+
604
+
605
+ def observe_worker(
606
+ short: str,
607
+ *,
608
+ timeout: float = 1.0,
609
+ ) -> dict[str, Any]:
610
+ """Canonical live observation for one Codex worker."""
611
+ reply = daemon_rpc({"op": "status", "short": short}, timeout=max(0.0, timeout))
612
+ transport = reply.get("transport")
613
+ if transport == RUNTIME_ABSENT:
614
+ return {
615
+ "presence": RUNTIME_ABSENT,
616
+ "daemon_served": False,
617
+ "reply": reply,
618
+ }
619
+ if transport != RUNTIME_SERVED:
620
+ return {
621
+ "presence": RUNTIME_INCONCLUSIVE,
622
+ "daemon_served": False,
623
+ "reply": reply,
624
+ }
625
+ if daemon_generation(reply) != CODEXD_RPC_GENERATION:
626
+ return {
627
+ "presence": RUNTIME_INCONCLUSIVE,
628
+ "daemon_served": True,
629
+ "reply": reply,
630
+ }
631
+ if reply.get("ok"):
632
+ return {
633
+ "presence": RUNTIME_SERVED,
634
+ "daemon_served": True,
635
+ "reply": reply,
636
+ }
637
+ if reply.get("loaded") is False:
638
+ return {
639
+ "presence": RUNTIME_ABSENT,
640
+ "daemon_served": True,
641
+ "reply": reply,
642
+ }
643
+ return {
644
+ "presence": RUNTIME_INCONCLUSIVE,
645
+ "daemon_served": True,
646
+ "reply": reply,
647
+ }
648
+
649
+
650
+ def observe_registry(*, timeout: float = 1.0) -> dict[str, Any]:
651
+ """Observe the shared daemon registry once for list-style projection."""
652
+ reply = daemon_status(timeout=timeout)
653
+ transport = reply.get("transport")
654
+ if transport == RUNTIME_ABSENT:
655
+ return {"presence": RUNTIME_ABSENT, "workers": {}, "reply": reply}
656
+ if (
657
+ transport == RUNTIME_SERVED
658
+ and daemon_generation(reply) == CODEXD_RPC_GENERATION
659
+ and reply.get("ok")
660
+ ):
661
+ workers = reply.get("workers")
662
+ return {
663
+ "presence": RUNTIME_SERVED,
664
+ "workers": workers if isinstance(workers, dict) else {},
665
+ "reply": reply,
666
+ }
667
+ return {"presence": RUNTIME_INCONCLUSIVE, "workers": {}, "reply": reply}
668
+
669
+
670
+ def explain_native_failure(message: str) -> str:
671
+ """Put a plain-language cause in front of known native Codex failures."""
672
+ if "missing source rollout" in message:
673
+ return ("the Codex thread this worker was forked or imported from was "
674
+ f"deleted, so its history can't be read ({message})")
675
+ return message
676
+
677
+
678
+ def _served_failure(reply: dict[str, Any], fallback: str) -> str:
679
+ """The most specific message a failed daemon reply carries.
680
+
681
+ A current daemon attaches the native ``ProtocolError`` as ``protocolError``;
682
+ an envelope without one only carries the flattened string.
683
+ """
684
+ structured = reply.get("protocolError")
685
+ if isinstance(structured, dict) and isinstance(structured.get("message"), str):
686
+ return explain_native_failure(structured["message"])
687
+ return explain_native_failure(str(reply.get("error") or fallback))
688
+
689
+
690
+ def _history_failure(error: BaseException, thread_id: str) -> CcxError:
691
+ message = explain_native_failure(getattr(error, "message", str(error)))
692
+ if _is_missing_history(message):
693
+ return SessionNotFound(message, short=thread_id)
694
+ return SessionUnavailable(message, short=thread_id)
695
+
696
+
697
+ def _is_missing_history(error: Any) -> bool:
698
+ """Recognize native missing-thread errors by message, never by code.
699
+
700
+ Codex 0.150.1 answers both a missing thread ("thread not loaded: <id>")
701
+ and a malformed one with the same broad ``-32600``, so the code proves
702
+ nothing. These phrases are specific enough that a model, config, or
703
+ resource failure cannot match them; anything else stays unavailable.
704
+ """
705
+ message = getattr(error, "message", str(error)).lower()
706
+ return (
707
+ "thread not loaded:" in message
708
+ or "unknown thread" in message
709
+ or "no rollout found" in message
710
+ )
711
+
712
+
713
+ def observation_cwd(cwd: Any) -> str:
714
+ """The directory a read-only app-server may be launched in.
715
+
716
+ ``thread/read`` is addressed by native thread id and never uses the launch
717
+ directory, so a deleted worktree must not also destroy access to persisted
718
+ history. Only observation falls back, and only to CCX's own state root; the
719
+ fallback is never written back to the worker record, the native thread's
720
+ own cwd, or conversation metadata. Starting project work is the opposite
721
+ policy and lives in :func:`require_workspace`.
722
+ """
723
+ if isinstance(cwd, str) and cwd and os.path.isdir(cwd):
724
+ return cwd
725
+ neutral = codex_state_root()
726
+ if not neutral.is_dir():
727
+ raise SessionUnavailable(
728
+ "cannot read Codex history: the worker workspace is gone and the "
729
+ f"CCX state directory {neutral} does not exist"
730
+ )
731
+ return str(neutral)
732
+
733
+
734
+ def require_workspace(value: dict[str, Any]) -> str:
735
+ """The worker's recorded project directory, which must still exist.
736
+
737
+ Native ``thread/resume`` will happily reattach a persisted thread whose
738
+ ``cwd`` is gone, but CCX will not start or restart project work in a
739
+ directory that no longer exists: the turn would run somewhere the record
740
+ does not describe, and its shell commands would land in whatever the
741
+ app-server falls back to. This is a deliberate CCX policy, stricter than
742
+ the native one, and it gates every operation that starts native work on
743
+ the thread — ``resume``, ``send``, and ``compact``. Creation is
744
+ independently strict about its own ``--cwd``.
745
+
746
+ Read-only observation keeps working without the workspace (see
747
+ :func:`observation_cwd`): report, conversation, export, ``thread/read``,
748
+ and status. So does controlling a turn that is already running — steer,
749
+ approve, reject, interrupt, stop, kill — because a workspace that
750
+ disappears mid-turn must not trap the turn.
751
+ """
752
+ cwd = value.get("cwd")
753
+ if isinstance(cwd, str) and cwd and os.path.isdir(cwd):
754
+ return cwd
755
+ raise SessionUnavailable(
756
+ f"the Codex worker workspace is gone: {cwd!r}. History stays readable "
757
+ f"(`ccx report`, `conversation`, `export`), but resuming or "
758
+ f"sending needs the recorded directory to exist.",
759
+ short=value.get("short"),
760
+ )
761
+
762
+
763
+ def read_thread(
764
+ thread_id: str,
765
+ *,
766
+ cwd: str,
767
+ include_turns: bool = True,
768
+ timeout: float = 30,
769
+ worker_id: str | None = None,
770
+ expected_source: str | None = None,
771
+ limit: int | None = None,
772
+ newest_first: bool = False,
773
+ exact_turn_id: str | None = None,
774
+ ) -> dict[str, Any]:
775
+ """Read source-verified Codex metadata, optionally assembling paged turns."""
776
+ if not isinstance(thread_id, str) or not thread_id:
777
+ raise SessionNotFound("Codex conversation is unavailable; check worker status")
778
+ if timeout <= 0:
779
+ raise InvalidRequest("history read timeout must be greater than zero")
780
+ if worker_id is not None and (not isinstance(expected_source, str) or not expected_source):
781
+ raise InvalidRequest("Codex conversation source is not verified; inspect the worker")
782
+ deadline = time.monotonic() + timeout
783
+ message = {"threadId": thread_id, "includeTurns": False}
784
+ from ccx.codex.history import fill_thread
785
+ # Never start a second app-server beside a daemon that owns the runtime
786
+ # slot — including one still booting — nor to skip a compatibility problem
787
+ # the operator needs to see.
788
+ if observation_daemon(deadline, short=thread_id):
789
+ budget = remaining(deadline, timeout)
790
+ reply = daemon_call({
791
+ "op": "thread-history" if include_turns else "thread-read",
792
+ **message,
793
+ **({"workerId": worker_id} if worker_id is not None else {}),
794
+ **({"limit": limit} if limit is not None else {}),
795
+ **({"newestFirst": True} if newest_first else {}),
796
+ **({"turnId": exact_turn_id} if exact_turn_id else {}),
797
+ "observeSeconds": budget,
798
+ }, timeout=budget, short=thread_id)
799
+ if not reply.get("ok"):
800
+ native = _served_failure(reply, "Codex history read failed")
801
+ if _is_missing_history(native):
802
+ raise SessionNotFound(native, short=thread_id)
803
+ raise SessionUnavailable(native, short=thread_id)
804
+ thread = reply.get("thread")
805
+ if not isinstance(thread, dict):
806
+ raise SessionNotFound("no Codex thread available", short=thread_id)
807
+ if worker_id is not None and (
808
+ reply.get("workerId") != worker_id
809
+ or reply.get("threadId") != thread_id
810
+ or thread.get("id") != thread_id
811
+ or thread.get("threadSource") != expected_source
812
+ ):
813
+ raise SessionUnavailable(
814
+ "Codex history belongs to another worker; inspect the handle", short=thread_id,
815
+ )
816
+ return thread
817
+
818
+ try:
819
+ with _short_lived_app_server(
820
+ cwd=observation_cwd(cwd),
821
+ title="CCX Codex history read",
822
+ timeout=remaining(deadline, timeout),
823
+ rejection_prefix="unsupported request during history read",
824
+ ) as client:
825
+ result = client.request(
826
+ "thread/read", message, timeout=remaining(deadline, timeout)
827
+ )
828
+ thread = result.get("thread")
829
+ if not isinstance(thread, dict):
830
+ raise SessionNotFound("no Codex thread available", short=thread_id)
831
+ if worker_id is not None and (
832
+ thread.get("id") != thread_id
833
+ or thread.get("threadSource") != expected_source
834
+ ):
835
+ raise SessionUnavailable(
836
+ "Codex history belongs to another worker; inspect the handle", short=thread_id,
837
+ )
838
+ if not include_turns:
839
+ return thread
840
+ return fill_thread(thread, client.request, deadline=deadline,
841
+ limit=limit, newest_first=newest_first,
842
+ exact_turn_id=exact_turn_id)
843
+ except (ProtocolError, OSError) as error:
844
+ raise _history_failure(error, thread_id) from error
845
+
846
+
847
+ def read_import_source(thread_id: str, *, timeout: float = 20.) -> dict:
848
+ """Read one external native thread's last completed-point candidate.
849
+
850
+ No stored owner is required, and this operation never resumes the source.
851
+ A running CCX app-server must serve it; an absent one permits one short
852
+ lived observer without access to the machine control socket.
853
+ """
854
+ import os
855
+ from ccx.codex.history import read_fork_source_point
856
+ if not isinstance(thread_id, str):
857
+ raise InvalidRequest("Codex import requires a full native thread UUID")
858
+ deadline = time.monotonic() + timeout
859
+ if observation_daemon(deadline, short=thread_id):
860
+ reply = daemon_call({
861
+ "op": "import-source-point", "threadId": thread_id,
862
+ "observeSeconds": remaining(deadline, timeout),
863
+ }, timeout=remaining(deadline, timeout), short=thread_id)
864
+ if not reply.get("ok"):
865
+ error = _served_failure(reply, "Codex import source unavailable")
866
+ if _is_missing_history(error):
867
+ raise SessionNotFound(error, short=thread_id)
868
+ raise SessionUnavailable(error, short=thread_id)
869
+ return {"thread": reply["thread"], "lastTurn": reply["lastTurn"]}
870
+ try:
871
+ with _short_lived_app_server(
872
+ cwd=os.getcwd(), title="CCX Codex native import source read",
873
+ timeout=remaining(deadline, timeout),
874
+ rejection_prefix="unsupported request during Codex native import",
875
+ ) as client:
876
+ return read_fork_source_point(
877
+ client.request, thread_id, deadline=deadline,
878
+ )
879
+ except ProtocolError as error:
880
+ raise _history_failure(error, thread_id) from error
881
+
882
+
883
+ def list_thread_turns(
884
+ thread_id: str,
885
+ *,
886
+ cwd: str,
887
+ cursor: str | None = None,
888
+ limit: int = 100,
889
+ sort_direction: str = "asc",
890
+ items_view: str = "full",
891
+ timeout: float = 30,
892
+ worker_id: str | None = None,
893
+ expected_source: str | None = None,
894
+ ) -> dict[str, Any]:
895
+ """One owner-fenced native turn page, including original statuses."""
896
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
897
+
898
+ try:
899
+ canonical_worker_id(worker_id)
900
+ except InvalidTarget as exc:
901
+ raise InvalidRequest("Codex history needs a complete worker handle") from exc
902
+ if not isinstance(expected_source, str) or not expected_source:
903
+ raise InvalidRequest("Codex history source is not verified; restart paging")
904
+ if not isinstance(thread_id, str) or not thread_id:
905
+ raise SessionNotFound("Codex conversation is unavailable; check worker status")
906
+ if type(limit) is not int or not 1 <= limit <= 100:
907
+ raise InvalidRequest("thread turn limit must be between 1 and 100")
908
+ if (not isinstance(sort_direction, str) or sort_direction not in {"asc", "desc"}
909
+ or not isinstance(items_view, str) or items_view not in {"notLoaded", "summary", "full"}):
910
+ raise InvalidRequest("invalid Codex turn page direction or items view")
911
+ if cursor is not None and (not isinstance(cursor, str) or not cursor):
912
+ raise InvalidRequest("native turn cursor must be non-empty")
913
+ params: dict[str, Any] = {
914
+ "threadId": thread_id, "limit": limit,
915
+ "sortDirection": sort_direction, "itemsView": items_view,
916
+ }
917
+ if cursor is not None:
918
+ params["cursor"] = cursor
919
+ return _paged_history_request(
920
+ method="thread/turns/list", daemon_op="thread-turns-list", params=params,
921
+ cwd=cwd, timeout=timeout, short=thread_id, worker_id=worker_id,
922
+ expected_source=expected_source,
923
+ )
924
+
925
+
926
+ def list_thread_items(
927
+ thread_id: str,
928
+ *,
929
+ cwd: str,
930
+ cursor: str | None = None,
931
+ limit: int = 25,
932
+ sort_direction: str = "desc",
933
+ timeout: float = 30,
934
+ turn_id: str | None = None,
935
+ worker_id: str | None = None,
936
+ expected_source: str | None = None,
937
+ ) -> dict[str, Any]:
938
+ """Read original full-worker Codex items without loading or resuming.
939
+
940
+ A native thread ID alone cannot authorize a page. The running daemon
941
+ independently proves the CCX owner; a cold read proves threadSource first
942
+ on the same observer connection, without materializing native turns.
943
+ """
944
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
945
+
946
+ try:
947
+ canonical_worker_id(worker_id)
948
+ except InvalidTarget as exc:
949
+ raise InvalidRequest("Codex history needs a complete worker handle") from exc
950
+ if not isinstance(expected_source, str) or not expected_source:
951
+ raise InvalidRequest("Codex history source is not verified; restart paging")
952
+ if not isinstance(thread_id, str) or not thread_id:
953
+ raise SessionNotFound("Codex conversation is unavailable; check worker status")
954
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
955
+ raise InvalidRequest("history read timeout must be greater than zero")
956
+ if isinstance(limit, bool) or not isinstance(limit, int) or not 1 <= limit <= 100:
957
+ raise InvalidRequest("thread item limit must be between 1 and 100")
958
+ if not isinstance(sort_direction, str) or sort_direction not in {"asc", "desc"}:
959
+ raise InvalidRequest("thread item sort direction must be asc or desc")
960
+ if cursor is not None and (not isinstance(cursor, str) or not cursor):
961
+ raise InvalidRequest("native item cursor must be a non-empty string")
962
+
963
+ params: dict[str, Any] = {
964
+ "threadId": thread_id,
965
+ "limit": limit,
966
+ "sortDirection": sort_direction,
967
+ }
968
+ if cursor is not None:
969
+ params["cursor"] = cursor
970
+ if turn_id is not None:
971
+ if not isinstance(turn_id, str) or not turn_id:
972
+ raise InvalidRequest("native item turn id must be non-empty")
973
+ params["turnId"] = turn_id
974
+ return _paged_history_request(
975
+ method="thread/items/list", daemon_op="thread-items-list", params=params,
976
+ cwd=cwd, timeout=timeout, short=thread_id, worker_id=worker_id,
977
+ expected_source=expected_source,
978
+ )
979
+
980
+
981
+ def _paged_history_request(
982
+ *,
983
+ method: str,
984
+ daemon_op: str,
985
+ params: dict[str, Any],
986
+ cwd: str,
987
+ timeout: float,
988
+ short: str | None,
989
+ worker_id: str,
990
+ expected_source: str,
991
+ ) -> dict[str, Any]:
992
+ """Read one original source-attested page within the caller's deadline."""
993
+ from ccx.errors import OperationTimeout
994
+
995
+ if (method, daemon_op) not in {
996
+ ("thread/items/list", "thread-items-list"),
997
+ ("thread/turns/list", "thread-turns-list"),
998
+ }:
999
+ raise InvalidRequest("Only CCX-managed Codex workers can be read; use a worker handle")
1000
+ deadline = time.monotonic() + timeout
1001
+ thread_id = params["threadId"]
1002
+ source = expected_source
1003
+ if observation_daemon(deadline, short=short):
1004
+ remaining_time = remaining(deadline, timeout)
1005
+ if remaining_time <= 0:
1006
+ raise OperationTimeout("Codex item-page deadline expired", short=short)
1007
+ reply = daemon_call(
1008
+ {"op": daemon_op, "workerId": worker_id, "params": params},
1009
+ timeout=remaining_time, short=short,
1010
+ )
1011
+ if not reply.get("ok"):
1012
+ native = _served_failure(reply, f"{method} failed")
1013
+ if reply.get("reason") == "worker-not-found" or _is_missing_history(native):
1014
+ raise SessionNotFound(native, short=short)
1015
+ raise SessionUnavailable(native, short=short)
1016
+ if (reply.get("workerId") != worker_id
1017
+ or reply.get("threadId") != thread_id
1018
+ or reply.get("threadSource") != source):
1019
+ raise SessionUnavailable(
1020
+ "Codex history page belongs to another worker; restart paging", short=short,
1021
+ )
1022
+ page = reply.get("data")
1023
+ else:
1024
+ try:
1025
+ remaining_time = remaining(deadline, timeout)
1026
+ if remaining_time <= 0:
1027
+ raise OperationTimeout("Codex item-page deadline expired", short=short)
1028
+ with _short_lived_app_server(
1029
+ cwd=observation_cwd(cwd),
1030
+ title="CCX Codex paginated history read",
1031
+ timeout=remaining_time,
1032
+ rejection_prefix="unsupported request during paginated history read",
1033
+ ) as client:
1034
+ remaining_time = remaining(deadline, timeout)
1035
+ if remaining_time <= 0:
1036
+ raise OperationTimeout("Codex item-page deadline expired", short=short)
1037
+ observed = client.request(
1038
+ "thread/read", {"threadId": thread_id, "includeTurns": False},
1039
+ timeout=remaining_time,
1040
+ )
1041
+ thread = observed.get("thread") if isinstance(observed, dict) else None
1042
+ if not isinstance(thread, dict):
1043
+ raise SessionNotFound("no Codex thread available", short=short)
1044
+ if thread.get("id") != thread_id or thread.get("threadSource") != source:
1045
+ raise SessionUnavailable(
1046
+ "Codex history page belongs to another worker; restart paging", short=short,
1047
+ )
1048
+ remaining_time = remaining(deadline, timeout)
1049
+ if remaining_time <= 0:
1050
+ raise OperationTimeout("Codex item-page deadline expired", short=short)
1051
+ page = client.request(method, params, timeout=remaining_time)
1052
+ except (ProtocolError, OSError) as error:
1053
+ raise _history_failure(error, thread_id) from error
1054
+ if (not isinstance(page, dict)
1055
+ or not isinstance(page.get("data"), list)
1056
+ or (page.get("nextCursor") is not None
1057
+ and not isinstance(page.get("nextCursor"), str))):
1058
+ raise SessionUnavailable(f"{method} returned malformed native page", short=short)
1059
+ return page
1060
+
1061
+
1062
+ def daemon_stop(*, force: bool = False, timeout: float = 10) -> dict[str, Any]:
1063
+ return daemon_rpc({"op": "daemon-stop", "force": force}, timeout=timeout)
1064
+
1065
+
1066
+ CALLER_PYTHONPATH_ENV = "CCX_CALLER_PYTHONPATH"
1067
+
1068
+
1069
+ def package_env() -> dict[str, str]:
1070
+ """Make the currently-running CCX package importable to a child process."""
1071
+ env = os.environ.copy()
1072
+ package_parent = str(Path(__file__).resolve().parents[2])
1073
+ inherited = env.get("PYTHONPATH")
1074
+ # Remember the caller's own value so grandchildren that run user commands
1075
+ # (codex app-server) get it back instead of CCX's install path.
1076
+ env.setdefault(CALLER_PYTHONPATH_ENV, inherited or "")
1077
+ env["PYTHONPATH"] = (
1078
+ package_parent
1079
+ if not inherited
1080
+ else package_parent + os.pathsep + inherited
1081
+ )
1082
+ return env
1083
+
1084
+
1085
+ def daemon_env() -> dict[str, str]:
1086
+ """Environment for the shared daemon: importable CCX, no caller identity.
1087
+
1088
+ The singleton cannot inherit the initiating worker's identity, but its
1089
+ user environment (auth, proxies, PATH and tool config) must survive.
1090
+ Each native thread gets its own attested full-UUID stamp separately.
1091
+ """
1092
+ from ccx.codex.worker_environment import scrub_shared_codex_identity
1093
+ return scrub_shared_codex_identity(package_env())
1094
+
1095
+
1096
+ def _acquire_spawn_lock(handle, deadline: float | None) -> None:
1097
+ while True:
1098
+ try:
1099
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
1100
+ return
1101
+ except BlockingIOError:
1102
+ if deadline is not None and time.monotonic() >= deadline:
1103
+ raise SessionUnavailable("timed out waiting for Codex spawn lock")
1104
+ time.sleep(0.02)
1105
+
1106
+
1107
+ @contextmanager
1108
+ def daemon_spawn_lock(deadline: float | None) -> Iterator[None]:
1109
+ """Existing singleton spawn fence on stable owner-private generation inode.
1110
+
1111
+ Create its lock only after ACTIVE, then hold that inode across daemon boot,
1112
+ without holding the activation lock during subprocess/provider startup.
1113
+ """
1114
+ from ccx import constants
1115
+ from ccx.store.root import GenerationBusy, require_ccx_active, _flock_at
1116
+ store = require_ccx_active(constants.CCX_ROOT)
1117
+ with store.lease():
1118
+ with store._gen("runtime-metadata", "pid", create=True):
1119
+ pass
1120
+ # Storage lock acquisitions are individually bounded to three seconds, but
1121
+ # a cold app-server boot may take longer. Wait for that same daemon through
1122
+ # the caller's deadline, then probe again while holding the existing fence.
1123
+ until = deadline if deadline is not None else time.monotonic() + CODEXD_BOOT_TIMEOUT_S
1124
+ while True:
1125
+ left = until - time.monotonic()
1126
+ if left <= 0:
1127
+ raise SessionUnavailable("timed out waiting for Codex spawn lock")
1128
+ acquired = False
1129
+ try:
1130
+ with store._gen("runtime-metadata", "pid") as fd:
1131
+ with _flock_at(fd, "codexd.spawn.lock", exclusive=True,
1132
+ create=True, timeout=min(3.0, left)):
1133
+ acquired = True
1134
+ store.revalidate()
1135
+ yield
1136
+ return
1137
+ except GenerationBusy:
1138
+ # A failure in the protected body must propagate; never re-enter
1139
+ # it and run a native operation a second time.
1140
+ if acquired:
1141
+ raise
1142
+ if time.monotonic() >= until:
1143
+ raise SessionUnavailable("timed out waiting for Codex spawn lock")
1144
+
1145
+
1146
+ def _open_daemon_log():
1147
+ """Descriptor-pinned, nofollow, uid-private daemon stdout/stderr sidecar."""
1148
+ from ccx import constants
1149
+ from ccx.store.root import require_ccx_active
1150
+ import stat
1151
+ store = require_ccx_active(constants.CCX_ROOT)
1152
+ with store.lease():
1153
+ with store._gen("runtime-metadata", create=True) as fd:
1154
+ for attempt in range(2):
1155
+ log_fd = os.open(
1156
+ "codexd.log", os.O_WRONLY | os.O_CREAT | os.O_APPEND |
1157
+ os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=fd,
1158
+ )
1159
+ info = os.fstat(log_fd)
1160
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
1161
+ or info.st_mode & 0o077):
1162
+ os.close(log_fd)
1163
+ raise PermissionError("untrusted Codex daemon log inode")
1164
+ if attempt == 0 and info.st_size > 10 * 1024 * 1024:
1165
+ os.close(log_fd)
1166
+ os.replace("codexd.log", "codexd.log.1", src_dir_fd=fd,
1167
+ dst_dir_fd=fd)
1168
+ continue
1169
+ break
1170
+ return os.fdopen(log_fd, "a", encoding="utf-8", buffering=1)
1171
+
1172
+
1173
+
1174
+
1175
+ def _codexd_log_tail() -> str:
1176
+ try:
1177
+ lines = codexd_log_path().read_text().splitlines()
1178
+ except OSError:
1179
+ return ""
1180
+ return " | ".join(line.strip() for line in lines[-3:] if line.strip())
1181
+
1182
+
1183
+ def ensure_daemon(deadline: float | None = None) -> dict[str, Any]:
1184
+ """Start the shared codexd daemon if it is not already serving.
1185
+
1186
+ The daemon gets its own process session and a reaper thread waits for it,
1187
+ so it survives the importing process while never becoming a zombie in
1188
+ long-lived callers. The spawn lock is held for the whole boot; a daemon
1189
+ that is already booting is waited for rather than duplicated.
1190
+ """
1191
+ ensure_dirs()
1192
+ probe = settled_daemon_status(deadline)
1193
+ if probe.get("transport") == RUNTIME_SERVED:
1194
+ require_compatible_daemon(probe)
1195
+ if probe.get("state") == "ready":
1196
+ return probe
1197
+ elif probe.get("transport") != RUNTIME_ABSENT and not (
1198
+ probe.get("transport") == RUNTIME_INCONCLUSIVE and probe.get("transitional")
1199
+ ):
1200
+ raise SessionUnavailable(
1201
+ "codexd presence is inconclusive; refusing to start a second daemon",
1202
+ short="codexd",
1203
+ )
1204
+ with daemon_spawn_lock(deadline):
1205
+ probe = settled_daemon_status(deadline)
1206
+ if probe.get("transport") == RUNTIME_SERVED:
1207
+ require_compatible_daemon(probe)
1208
+ if probe.get("state") == "ready":
1209
+ return probe
1210
+ elif probe.get("transport") == RUNTIME_INCONCLUSIVE and not probe.get(
1211
+ "transitional"
1212
+ ):
1213
+ raise SessionUnavailable(
1214
+ "codexd presence is inconclusive; refusing to start a second daemon",
1215
+ short="codexd",
1216
+ )
1217
+ process = None
1218
+ if probe.get("transport") == RUNTIME_ABSENT:
1219
+ if deadline is not None and time.monotonic() >= deadline:
1220
+ raise SessionUnavailable(
1221
+ "timed out before codexd could be started", short="codexd"
1222
+ )
1223
+ from ccx.system.engine_binary import resolve_engine_binary
1224
+ resolve_engine_binary("codex") # No daemon start for absent CLI.
1225
+ try:
1226
+ from ccx import constants
1227
+ from ccx.store.root import require_ccx_active
1228
+ from ccx.rpc.transport import plan_transport, ensure_transport_parent
1229
+ ctx = require_ccx_active(constants.CCX_ROOT)
1230
+ plan = plan_transport(ctx, role="codexd")
1231
+ ensure_transport_parent(ctx, plan)
1232
+ log = _open_daemon_log()
1233
+ except OSError as error:
1234
+ raise SpawnFailed(
1235
+ f"cannot prepare codexd: {error}",
1236
+ short="codexd",
1237
+ phase="settings-io",
1238
+ detail=str(error),
1239
+ ) from error
1240
+ try:
1241
+ process = subprocess.Popen(
1242
+ [sys.executable, "-m", 'ccx.codex.daemon'],
1243
+ cwd=str(codexd_sock_path().parent),
1244
+ stdin=subprocess.DEVNULL,
1245
+ stdout=log,
1246
+ stderr=log,
1247
+ start_new_session=True,
1248
+ close_fds=True,
1249
+ env=daemon_env(),
1250
+ )
1251
+ except OSError as error:
1252
+ raise SpawnFailed(
1253
+ f"could not start codexd: {error}",
1254
+ short="codexd",
1255
+ phase="launcher-failed",
1256
+ detail=str(error),
1257
+ ) from error
1258
+ finally:
1259
+ if not log.closed:
1260
+ log.close()
1261
+ threading.Thread(
1262
+ target=process.wait,
1263
+ name="ccx-codexd-reaper",
1264
+ daemon=True,
1265
+ ).start()
1266
+
1267
+ # The caller's deadline governs the boot wait.
1268
+ boot_deadline = (
1269
+ deadline if deadline is not None
1270
+ else time.monotonic() + CODEXD_BOOT_TIMEOUT_S
1271
+ )
1272
+ while time.monotonic() < boot_deadline:
1273
+ probe = daemon_status(timeout=1)
1274
+ if probe.get("transport") == RUNTIME_SERVED:
1275
+ require_compatible_daemon(probe)
1276
+ if probe.get("state") == "ready":
1277
+ return probe
1278
+ if process is not None and process.poll() is not None:
1279
+ # The daemon we started died before serving (e.g. no codex
1280
+ # binary); waiting out the boot budget would only hide that.
1281
+ detail = _codexd_log_tail()
1282
+ raise SpawnFailed(
1283
+ "codexd exited during startup"
1284
+ + (f": {detail}" if detail else "")
1285
+ + f" (set CCX_CODEX_BIN or put codex on PATH; log: {codexd_log_path()})",
1286
+ short="codexd",
1287
+ phase="launcher-failed",
1288
+ detail=detail or None,
1289
+ )
1290
+ time.sleep(0.05)
1291
+ detail = _codexd_log_tail()
1292
+ raise SessionUnavailable(
1293
+ "codexd did not become ready" + (f": {detail}" if detail else "")
1294
+ + f" (log: {codexd_log_path()})",
1295
+ short="codexd",
1296
+ )
1297
+
1298
+
1299
+ def record(target: str) -> dict[str, Any]:
1300
+ """Read only an exact owned Codex full UUID, bare or Codex-qualified."""
1301
+ if isinstance(target, str):
1302
+ engine, sep, suffix = target.partition(":")
1303
+ if sep and engine == "claude" and suffix:
1304
+ raise InvalidRequest(
1305
+ f"this command is scoped to codex; got {target!r} (a Claude handle)"
1306
+ )
1307
+ # Only error labels use a bare UUID; owner lookup receives the original target.
1308
+ label = (target[6:] if isinstance(target, str) and target.startswith("codex:")
1309
+ and target[6:] else target)
1310
+ try:
1311
+ value = read_record(target)
1312
+ except OSError as error:
1313
+ raise SessionUnavailable(
1314
+ f"cannot read Codex worker records: {error}",
1315
+ short=label,
1316
+ ) from error
1317
+ if not value:
1318
+ raise SessionNotFound(f"no such Codex worker: {label}", short=label)
1319
+ return value
1320
+
1321
+
1322
+
1323
+
1324
+ def identity(value: dict[str, Any]) -> dict[str, Any]:
1325
+ lifecycle = value.get("lifecycle") or {}
1326
+ return {
1327
+ "engine": "codex",
1328
+ "short": value["short"],
1329
+ "worker_id": value.get("workerId"),
1330
+ "thread_id": value.get("threadId"),
1331
+ "name": value.get("name"),
1332
+ "cwd": value.get("cwd"),
1333
+ "state": lifecycle.get("state"),
1334
+ "active_turn_id": lifecycle.get("activeTurnId"),
1335
+ }
1336
+
1337
+
1338
+ def wait(short: str, timeout: float | None) -> dict[str, Any]:
1339
+ deadline = time.monotonic() + timeout if timeout is not None else None
1340
+ polled = False
1341
+ while not polled or deadline is None or time.monotonic() < deadline:
1342
+ polled = True
1343
+ observation = observe_worker(short, timeout=remaining(deadline, 2))
1344
+ reply = observation["reply"]
1345
+ if (
1346
+ observation["presence"] == RUNTIME_SERVED
1347
+ and reply.get("state") in STABLE_STATES
1348
+ ):
1349
+ return reply
1350
+ current = read_record(short) or {}
1351
+ if observation["presence"] == RUNTIME_ABSENT:
1352
+ recorded_state = (current.get("lifecycle") or {}).get("state")
1353
+ if recorded_state in {"busy", "waiting", "resuming"}:
1354
+ return {
1355
+ **identity(current),
1356
+ "lastTurn": current.get("lastTurn"),
1357
+ "ok": False,
1358
+ "loaded": False,
1359
+ "normalized_state": "failed",
1360
+ "error": "Codex worker is not loaded; recorded active state is orphaned",
1361
+ }
1362
+ return {
1363
+ **identity(current),
1364
+ "lastTurn": current.get("lastTurn"),
1365
+ "ok": True,
1366
+ "loaded": False,
1367
+ "normalized_state": (
1368
+ "stopped" if recorded_state == "idle" else None
1369
+ ),
1370
+ }
1371
+ if deadline is not None and time.monotonic() >= deadline:
1372
+ break
1373
+ time.sleep(min(0.1, remaining(deadline, 0.1)))
1374
+ current = read_record(short) or {"short": short, "lifecycle": {}}
1375
+ return {
1376
+ **identity(current),
1377
+ "lastTurn": current.get("lastTurn"),
1378
+ "ok": False,
1379
+ "error": "wait timed out",
1380
+ }
1381
+
1382
+
1383
+ __all__ = [
1384
+ "STABLE_STATES",
1385
+ "daemon_call",
1386
+ "daemon_env",
1387
+ "daemon_rpc",
1388
+ "daemon_status",
1389
+ "daemon_stop",
1390
+ "ensure_daemon",
1391
+ "identity",
1392
+ "list_models",
1393
+ "daemon_generation",
1394
+ "daemon_spawn_lock",
1395
+ "observe_registry",
1396
+ "observe_worker",
1397
+ "package_env",
1398
+ "preflight_daemon",
1399
+ "read_account_usage",
1400
+ "record",
1401
+ "require_compatible_daemon",
1402
+ "require_served_generation",
1403
+ "require_workspace",
1404
+ "remaining",
1405
+ "observation_cwd",
1406
+ "observation_daemon",
1407
+ "read_thread",
1408
+ "report_from_thread",
1409
+ "rpc",
1410
+ "validate_model",
1411
+ "wait",
1412
+ ]