ccx-native 0.0.1 → 0.2.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -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 +807 -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 +269 -0
  16. package/ccx/claude/host/session.py +5244 -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 +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,738 @@
1
+ """Python client for CCX-owned Codex workers.
2
+
3
+ Operations require an active owner store and exact worker UUIDs. Native
4
+ thread IDs are provider bindings, not worker targets. Construction verifies
5
+ the store without launching a provider process.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from ccx.codex.ops import configure_worker, telemetry_worker
15
+ from ccx.codex.paths import ensure_dirs
16
+ from ccx.codex.runtime import (
17
+ record,
18
+ remaining,
19
+ read_account_usage,
20
+ require_workspace,
21
+ rpc,
22
+ validate_model,
23
+ wait as wait_for_stable,
24
+ )
25
+ from ccx.codex.settle import (
26
+ DEFAULT_WAKE_GRACE_S,
27
+ wait_for_settlement,
28
+ )
29
+ from ccx.codex.types import (
30
+ CodexApprovalResult,
31
+ CodexCompactResult,
32
+ CodexContext,
33
+ CodexControlResult,
34
+ CodexReport,
35
+ CodexReconcileResult,
36
+ CodexResumeResult,
37
+ CodexSessionInfo,
38
+ CodexTelemetryPage,
39
+ CodexTurnResult,
40
+ CodexUsage,
41
+ CodexWaitResult,
42
+ )
43
+ from ccx.constants import (
44
+ COMPACT_WAIT_DEFAULT_S,
45
+ DEFAULT_IDLE_TIMEOUT_S,
46
+ )
47
+ from ccx.types import ModelCatalog
48
+ from ccx.errors import (
49
+ DeliveryRejected,
50
+ InvalidRequest,
51
+ SessionUnavailable,
52
+ SessionWaiting,
53
+ SendIndeterminate,
54
+ TurnIncomplete,
55
+ worker_not_found,
56
+ )
57
+
58
+ _SANDBOXES = {"read-only", "workspace-write", "danger-full-access"}
59
+ _APPROVAL_POLICIES = {"untrusted", "on-request", "never"}
60
+
61
+
62
+ class CodexClient:
63
+ """Codex operations over the same owner store and receipts as the CLI."""
64
+
65
+ def __init__(self) -> None:
66
+ """Require the active owner store before any operation can write paths."""
67
+ from ccx.constants import CCX_ROOT
68
+ from ccx.store.root import GenerationError, open_or_initialize
69
+ try:
70
+ self._uuid_store, _created = open_or_initialize(CCX_ROOT)
71
+ except (GenerationError, OSError) as exc:
72
+ raise SessionUnavailable(
73
+ f"CCX worker records are unavailable; check CCX_ROOT: {exc}"
74
+ ) from exc
75
+ def _owned_codex(self, target: str) -> tuple[str, dict]:
76
+ """Positive global full UUID owner, never native thread/name/short."""
77
+ from ccx.store.identity import IdentityError,UnknownWorker
78
+ from ccx.store.root import GenerationError
79
+ from ccx.errors import SessionNotFound
80
+ try:
81
+ self._uuid_store.revalidate()
82
+ owner=self._uuid_store.resolve(target)
83
+ if owner.engine!="codex":
84
+ raise InvalidRequest("CodexClient requires Codex full UUID")
85
+ return owner.worker,self._uuid_store.record(owner.worker)
86
+ except UnknownWorker as exc:
87
+ raise worker_not_found(target) from exc
88
+ except IdentityError as exc:
89
+ raise InvalidRequest(f"Invalid Codex worker handle: {exc}") from exc
90
+ except GenerationError as exc:
91
+ raise SessionUnavailable(f"CCX records changed; reopen the worker: {exc}") from exc
92
+
93
+
94
+ def create(
95
+ self, prompt: str | None = None, *, cwd: str | None = None,
96
+ name: str | None = None, worker_id: str | None = None,
97
+ model: str | None = None, effort: str | None = None,
98
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT_S,
99
+ sandbox: str = "danger-full-access", approval_policy: str = "never",
100
+ wait: bool = False, timeout: float | None = None,
101
+ delivery_id: str | None = None,
102
+ start_timeout: float | None = None,
103
+ caps: dict | None = None,
104
+ ) -> CodexSessionInfo | CodexTurnResult:
105
+ """Create one verified original Codex thread, never a speculative resume."""
106
+ from ccx.ops.creation import create
107
+ result = create(
108
+ engine="codex",worker_id=worker_id,cwd=cwd or os.getcwd(),
109
+ prompt=prompt,delivery_id=delivery_id,wait=wait,caps=caps,
110
+ name=name,model=model,effort=effort,
111
+ idle_timeout=idle_timeout,sandbox=sandbox,
112
+ approval_policy=approval_policy,created_via="python-api",
113
+ timeout=timeout,start_timeout=start_timeout,
114
+ expected_generation=self._uuid_store.generation,
115
+ )
116
+ if wait:
117
+ report = result["report"]
118
+ turn_id = report["turn_id"]
119
+ return CodexTurnResult.from_record(
120
+ result["record"], turn_id=turn_id,
121
+ report={"turnId": turn_id, "phase": "completed",
122
+ "text": report["text"],
123
+ "completedAt": report.get("settled_at_ms")},
124
+ last_turn={"turnId": turn_id, "status": "completed"},
125
+ client_user_message_id=result["delivery"].get("client_user_message_id"),
126
+ delivery_id=result["delivery_id"],
127
+ )
128
+ # This result was verified by the shared application operation.
129
+ # Avoid a second fallible store read after first-turn submission.
130
+ if prompt is None:
131
+ self._uuid_store.revalidate()
132
+ return CodexSessionInfo.from_record(result["record"])
133
+
134
+ def fork(
135
+ self, target: str, prompt: str | None = None, *,
136
+ name: str | None = None, worker_id: str | None = None,
137
+ model: str | None = None, effort: str | None = None,
138
+ wait: bool = False, timeout: float | None = None,
139
+ delivery_id: str | None = None,
140
+ caps: dict | None = None,
141
+ ) -> CodexSessionInfo | CodexTurnResult:
142
+ """Fork a Codex worker into a new worker with its history.
143
+
144
+ Native ``thread/fork`` through the source's last settled turn; the
145
+ fork keeps the source's cwd, sandbox, approval policy, model and
146
+ effort unless overridden. Returns like :meth:`create`. Retrying with
147
+ the same ``worker_id`` reuses the original fork point.
148
+ """
149
+ from math import isfinite
150
+ from ccx.ops.forking import fork
151
+ from ccx.store.root import GenerationError
152
+ from ccx.store.identity import IdentityError, UnknownWorker, parse_managed_target
153
+ from ccx.errors import SessionNotFound
154
+
155
+ if timeout is not None and (
156
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
157
+ ):
158
+ raise InvalidRequest("fork timeout must be positive finite seconds or None")
159
+ deadline = time.monotonic() + timeout if timeout is not None else None
160
+ try:
161
+ source = parse_managed_target(target).qualified("codex")
162
+ result = fork(
163
+ source, worker_id=worker_id, name=name, model=model, effort=effort,
164
+ created_via="python-api", expected_generation=self._uuid_store.generation,
165
+ deadline=deadline, prompt=prompt, wait=wait, delivery_id=delivery_id,
166
+ caps=caps,
167
+ )
168
+ if wait:
169
+ report = result["report"]
170
+ turn_id = report["turn_id"]
171
+ return CodexTurnResult.from_record(
172
+ result["record"], turn_id=turn_id,
173
+ report={"turnId": turn_id, "phase": "completed",
174
+ "text": report["text"],
175
+ "completedAt": report.get("settled_at_ms")},
176
+ last_turn={"turnId": turn_id, "status": "completed"},
177
+ client_user_message_id=result["delivery"].get("client_user_message_id"),
178
+ delivery_id=result["delivery_id"],
179
+ )
180
+ if prompt is None:
181
+ self._uuid_store.revalidate()
182
+ return CodexSessionInfo.from_record(result["record"])
183
+ except UnknownWorker as exc:
184
+ raise worker_not_found(target) from exc
185
+ except IdentityError as exc:
186
+ raise InvalidRequest(f"fork: Invalid Codex worker handle: {exc}") from exc
187
+ except GenerationError as exc:
188
+ raise SessionUnavailable(f"fork: Codex ACTIVE generation unavailable: {exc}") from exc
189
+
190
+ def resume(
191
+ self,
192
+ target: str,
193
+ *,
194
+ timeout: float | None = 90,
195
+ ) -> CodexResumeResult:
196
+ """Re-subscribe only the original full-UUID native Codex thread.
197
+
198
+ No model turn is started by explicit resume. A confirmed idle native
199
+ subscription, not read-only thread history, is the success condition.
200
+ """
201
+ from ccx.ops.send import ensure_ready
202
+ handle, _record = self._owned_codex(target)
203
+ ready = ensure_ready(
204
+ handle, timeout=timeout,
205
+ expected_generation=self._uuid_store.generation,
206
+ )
207
+ return CodexResumeResult.from_record(
208
+ self._uuid_store.record(handle), resumed=ready["resumed"],
209
+ )
210
+
211
+ def set_name(self, target: str, name: str | None) -> CodexSessionInfo:
212
+ """Rename an exact Codex full UUID with one fresh semantic record CAS."""
213
+ from ccx.store.identity import EngineMismatch, IdentityError, UnknownWorker
214
+ from ccx.store.root import GenerationError
215
+ try:
216
+ owner = self._uuid_store.resolve(target)
217
+ if owner.engine != "codex":
218
+ raise EngineMismatch("CodexClient requires a Codex full-UUID owner")
219
+ rec = self._uuid_store.rename_worker(
220
+ owner.worker, engine="codex", name=name,
221
+ )
222
+ return CodexSessionInfo.from_record(rec)
223
+ except UnknownWorker as exc:
224
+ from ccx.errors import SessionNotFound
225
+ raise worker_not_found(target) from exc
226
+ except IdentityError as exc:
227
+ raise InvalidRequest(f"invalid Codex worker full UUID: {exc}") from exc
228
+ except GenerationError as exc:
229
+ raise SessionUnavailable(f"Worker records changed; retry with the saved handle: {exc}") from exc
230
+
231
+ def set_model(
232
+ self,
233
+ target: str,
234
+ model: str | None,
235
+ *,
236
+ timeout: float = 5,
237
+ ) -> CodexSessionInfo:
238
+ """Set the model used by future turns without starting a turn."""
239
+ worker, _record = self._owned_codex(target)
240
+ value = configure_worker(
241
+ worker, model=model, timeout=timeout,
242
+ expected_generation=self._uuid_store.generation,
243
+ )
244
+ self._uuid_store.revalidate()
245
+ return CodexSessionInfo.from_record(value)
246
+
247
+ def set_effort(
248
+ self,
249
+ target: str,
250
+ effort: str | None,
251
+ *,
252
+ timeout: float = 5,
253
+ ) -> CodexSessionInfo:
254
+ """Set future-turn reasoning effort without starting a turn."""
255
+ worker, _record = self._owned_codex(target)
256
+ value = configure_worker(
257
+ worker, effort=effort, timeout=timeout,
258
+ expected_generation=self._uuid_store.generation,
259
+ )
260
+ self._uuid_store.revalidate()
261
+ return CodexSessionInfo.from_record(value)
262
+
263
+ def send(
264
+ self, target: str, text: str, *, delivery_id: str | None = None,
265
+ wait: bool = True, timeout: float | None = None, caps: dict | None = None,
266
+ ) -> CodexTurnResult:
267
+ """One original native Codex app-server turn, full-UUID only.
268
+
269
+ The original delivery_id and clientUserMessageId are immutable.
270
+ Timeout or a lost app-server reply retains the exact native effect
271
+ fence; never guess a missing turn/start was not submitted.
272
+ """
273
+ from ccx.ops.send import send_turn as _send
274
+ from ccx.ops.wait import reconcile_turn
275
+ from ccx.ops.report import report_worker
276
+ from ccx.store.root import NativeEffectIndeterminate, GenerationError
277
+ from ccx.codex.delivery import DeliveryReceiptConflict
278
+ from ccx.errors import SendIndeterminate,OperationConflict,DeliveryRejected
279
+ from uuid import uuid4
280
+ if timeout is not None and (not isinstance(timeout,(int,float))
281
+ or timeout<=0):
282
+ raise InvalidRequest("Codex send timeout must be positive or None")
283
+ worker,record=self._owned_codex(target)
284
+ token=delivery_id if delivery_id is not None else str(uuid4())
285
+ deadline=None if timeout is None else time.monotonic()+timeout
286
+ try:
287
+ accepted=_send(worker,text,delivery_id=token,mode="new_turn_only",cap=caps)
288
+ except DeliveryReceiptConflict as exc:
289
+ raise OperationConflict(
290
+ f"Codex original delivery {token!r} conflicts: {exc}",
291
+ short=worker[6:14],worker_id=worker.split(":",1)[1],
292
+ ) from exc
293
+ except NativeEffectIndeterminate as exc:
294
+ raise SendIndeterminate(
295
+ "native Codex turn effect unknown; original delivery must "
296
+ f"be reconciled without replay: {exc}",
297
+ short=worker.split(":",1)[1][:8],
298
+ delivery_id=token,delivery_status="submitted-unknown",
299
+ ) from exc
300
+ except GenerationError as exc:
301
+ raise SessionUnavailable(
302
+ f"full-UUID Codex native send unavailable: {exc}"
303
+ ) from exc
304
+ result=accepted
305
+ if wait:
306
+ while True:
307
+ self._uuid_store.revalidate()
308
+ result=reconcile_turn(
309
+ worker, delivery_id=token,
310
+ timeout=(0.5 if deadline is None else
311
+ max(0.001, min(0.5, deadline-time.monotonic()))),
312
+ )
313
+ if result["status"] in ("settled","rejected"):
314
+ break
315
+ from ccx.codex.settle import send_settle_failure
316
+ failure = send_settle_failure(worker, result)
317
+ if failure is not None:
318
+ detail = failure.get("error") or failure["status"]
319
+ if failure["status"] == "budget-stopped":
320
+ from ccx.errors import BudgetStopped
321
+ raise BudgetStopped(
322
+ f"budget stop: {detail}", short=worker.split(":", 1)[1][:8],
323
+ reason=str(detail), phase="turn",
324
+ )
325
+ if failure["status"] == "waiting":
326
+ preview = failure.get("pending_input")
327
+ raise SessionWaiting(
328
+ str(detail), short=worker.split(":", 1)[1][:8],
329
+ waiting_for=({**preview, "type": preview.get("kind"),
330
+ "id": preview.get("request_id")}
331
+ if preview is not None else None),
332
+ )
333
+ delivery = result.get("delivery") or {}
334
+ raise TurnIncomplete(
335
+ str(detail), short=worker.split(":", 1)[1][:8],
336
+ state=failure["status"], turn_id=delivery.get("turn_id"),
337
+ turn_status=(failure["status"]
338
+ if failure["status"] in {"failed", "interrupted", "cancelled"}
339
+ else result.get("native_status")),
340
+ turn_error=failure.get("error"),
341
+ )
342
+ if deadline is not None and time.monotonic()>=deadline:
343
+ raise SendIndeterminate(
344
+ "Codex turn not positively completed within caller "
345
+ f"deadline; reconcile original delivery {token!r}",
346
+ short=worker.split(":",1)[1][:8],
347
+ delivery_id=token,delivery_status=result["status"],
348
+ )
349
+ time.sleep(.1)
350
+ delivery=result.get("delivery") or {}
351
+ if result["status"]=="rejected":
352
+ raise DeliveryRejected(
353
+ delivery.get("rejection_message") or "native Codex refused",
354
+ short=worker.split(":",1)[1][:8],
355
+ refusal=delivery.get("rejection_code"),
356
+ delivery_id=token,delivery_status="rejected",
357
+ )
358
+ turn_id=delivery.get("turn_id")
359
+ client_id=delivery.get("client_user_message_id")
360
+ report=None
361
+ final_turn=None
362
+ already_completed=(bool(accepted.get("settled")) if not wait
363
+ else result["status"]=="settled")
364
+ if already_completed:
365
+ if wait:
366
+ native_report=report_worker(
367
+ worker,turn_ref=delivery.get("turn_ref"))
368
+ positive_text=native_report["text"]
369
+ else:
370
+ original=delivery.get("settlement")
371
+ if (not isinstance(original,dict)
372
+ or original.get("status")!="completed"
373
+ or original.get("turnId")!=turn_id
374
+ or not isinstance(original.get("text"),str)):
375
+ raise SessionUnavailable(
376
+ "original Codex accepted receipt lacks positive "
377
+ "completed-turn evidence"
378
+ )
379
+ positive_text=original["text"]
380
+ report={"turnId":turn_id,"phase":"completed",
381
+ "text":positive_text}
382
+ final_turn={"turnId":turn_id,"status":"completed"}
383
+ rec=self._uuid_store.record(worker)
384
+ return CodexTurnResult.from_record(
385
+ rec,turn_id=turn_id,report=report,last_turn=final_turn,
386
+ client_user_message_id=client_id,delivery_id=token,
387
+ )
388
+
389
+ def reconcile(
390
+ self, target: str, *, delivery_id: str | None = None,
391
+ turn_id: str | None = None, timeout: float = 30,
392
+ ) -> CodexReconcileResult:
393
+ """Observe original native thread/turn, no second native mutation."""
394
+ from ccx.ops.wait import reconcile_turn
395
+ from ccx.store.root import GenerationError
396
+ if timeout<=0:
397
+ raise InvalidRequest("Codex reconciliation timeout must be positive")
398
+ worker,record=self._owned_codex(target)
399
+ try:
400
+ result=reconcile_turn(
401
+ worker,delivery_id=delivery_id,turn_id=turn_id,timeout=timeout
402
+ )
403
+ except GenerationError as exc:
404
+ raise SessionUnavailable(
405
+ f"Codex delivery record unavailable; inspect the worker: {exc}"
406
+ ) from exc
407
+ return CodexReconcileResult.from_operation(
408
+ self._uuid_store.record(worker),
409
+ result.get("delivery"),
410
+ result["status"],
411
+ )
412
+
413
+ def telemetry(
414
+ self,
415
+ target: str,
416
+ *,
417
+ after_cursor: str | None = None,
418
+ limit: int = 200,
419
+ timeout: float = 120,
420
+ ) -> CodexTelemetryPage:
421
+ """Read original source-bound Codex telemetry without starting a turn."""
422
+ from ccx.store.root import GenerationError
423
+
424
+ worker, _record = self._owned_codex(target)
425
+ try:
426
+ result = telemetry_worker(
427
+ worker, after_cursor=after_cursor, limit=limit, timeout=timeout,
428
+ )
429
+ except GenerationError as exc:
430
+ raise SessionUnavailable(
431
+ f"Cannot read Codex activity; check worker status: {exc}"
432
+ ) from exc
433
+ return CodexTelemetryPage.from_operation(result)
434
+
435
+ def steer(
436
+ self, target: str, text: str, *, delivery_id: str | None = None,
437
+ caps: dict | None = None,
438
+ ) -> CodexControlResult:
439
+ """Place one input in the exact active Codex turn, never start or wait."""
440
+ from uuid import uuid4
441
+ from ccx.codex import delivery as codex_delivery
442
+ from ccx.codex.runtime import settled_daemon_status
443
+ from ccx.ops.send import send_turn
444
+ from ccx.store.root import GenerationError
445
+ from ccx.store.worker_state import RUNTIME_ABSENT
446
+
447
+ handle, record = self._owned_codex(target)
448
+ if not isinstance(text, str) or not text.strip():
449
+ raise InvalidRequest("steer: prompt text is required")
450
+ token = delivery_id if delivery_id is not None else str(uuid4())
451
+ if not codex_delivery.valid_delivery_id(token):
452
+ raise InvalidRequest("steer: invalid delivery_id")
453
+ try:
454
+ original = codex_delivery.read_receipt(handle, token)
455
+ if original is None:
456
+ # Never turn a replayed/uncertain original token into a fresh
457
+ # absent-daemon refusal: the shared sender owns every replay.
458
+ self._uuid_store.authorize_native_action(handle, "send")
459
+ status = settled_daemon_status(time.monotonic() + 1.0)
460
+ if status.get("transport") == RUNTIME_ABSENT:
461
+ raise DeliveryRejected(
462
+ "Codex worker has no running turn (daemon absent); "
463
+ "no delivery was recorded, so this token is unclaimed "
464
+ "and may be used when the worker is running",
465
+ short=handle.split(":", 1)[1][:8],
466
+ state=(record.get("lifecycle") or {}).get("state"),
467
+ refusal="worker-state", delivery_id=token,
468
+ )
469
+ except (codex_delivery.DeliveryReceiptError, GenerationError, OSError) as exc:
470
+ raise SessionUnavailable(f"Codex message record is unavailable; inspect worker status: {exc}") from exc
471
+
472
+ try:
473
+ accepted = send_turn(handle, text, delivery_id=token,
474
+ mode="steer_only", cap=caps)
475
+ except (codex_delivery.DeliveryReceiptError, GenerationError, OSError) as exc:
476
+ raise SessionUnavailable(f"Cannot add this message to the running turn; check status: {exc}") from exc
477
+ delivery = accepted["delivery"]
478
+ if accepted["status"] == "rejected":
479
+ raise DeliveryRejected(
480
+ delivery.get("rejection_message") or "Codex steer refused",
481
+ short=handle.split(":", 1)[1][:8],
482
+ refusal=delivery.get("rejection_code"),
483
+ delivery_id=token, delivery_status="rejected",
484
+ )
485
+ if (accepted["status"] != "accepted"
486
+ or delivery.get("route") != "steer"
487
+ or delivery.get("placement") != "current_turn"):
488
+ raise SendIndeterminate(
489
+ "Steering was not confirmed; "
490
+ "reconcile the delivery instead of resending",
491
+ short=handle.split(":", 1)[1][:8],
492
+ delivery_id=token, delivery_status=accepted["status"],
493
+ )
494
+ return CodexControlResult.from_record(
495
+ record, action="steer", turn_id=delivery["turn_id"],
496
+ client_user_message_id=delivery["client_user_message_id"],
497
+ delivery_id=token,
498
+ )
499
+
500
+ def interrupt(
501
+ self,
502
+ target: str,
503
+ *,
504
+ expected_turn_id: str | None = None,
505
+ ) -> CodexControlResult:
506
+ """Request an exact full-UUID native interrupt; return ACK, not completion."""
507
+ from ccx.ops.cancel import stop_worker
508
+ handle, value = self._owned_codex(target)
509
+ expected_ref = None
510
+ if expected_turn_id is not None:
511
+ if not isinstance(expected_turn_id, str) or not expected_turn_id:
512
+ raise InvalidRequest("expected_turn_id must be a nonempty native turn ID")
513
+ expected_ref = "ccx-turn:codex:v1:" + expected_turn_id
514
+ result = stop_worker(
515
+ handle, expected_turn_ref=expected_ref,
516
+ expected_generation=self._uuid_store.generation,
517
+ )
518
+ # Existing CodexControlResult is an acceptance-only type. An unknown
519
+ # native reply is raised by stop_worker, never accepted=True here.
520
+ return CodexControlResult.from_record(
521
+ value, action="interrupt", turn_id=result["turn_ref"].rsplit(":", 1)[-1],
522
+ )
523
+
524
+ def wait(
525
+ self,
526
+ target: str,
527
+ *,
528
+ timeout: float | None = None,
529
+ settle: bool = False,
530
+ wake_grace: float = DEFAULT_WAKE_GRACE_S,
531
+ ) -> CodexWaitResult:
532
+ """Wait for original Codex stability or recursive notification quiescence."""
533
+ from ccx.ops.wait import wait_worker
534
+ handle, record = self._owned_codex(target)
535
+ result = (wait_for_settlement(
536
+ handle, timeout=timeout, wake_grace_s=wake_grace,
537
+ expected_generation=self._uuid_store.generation)
538
+ if settle else wait_worker(
539
+ handle, timeout=timeout,
540
+ expected_generation=self._uuid_store.generation))
541
+ return CodexWaitResult.from_result(record, result)
542
+
543
+ def report(
544
+ self, target: str, *, timeout: float | None = 90,
545
+ ) -> CodexReport:
546
+ """Read Codex's latest turn reply or last confirmed reply while busy.
547
+
548
+ An empty final answer stays empty; note explains newer uncertain
549
+ messages, running work, failed workers or native history failures.
550
+ """
551
+ from ccx.ops.report import report_worker
552
+ from ccx.store.root import GenerationError
553
+ if timeout is not None and timeout<=0:
554
+ raise InvalidRequest("Codex report timeout must be positive")
555
+ worker,rec=self._owned_codex(target)
556
+ try:
557
+ report=report_worker(
558
+ worker,timeout=90 if timeout is None else timeout,
559
+ max_chars=None,
560
+ )
561
+ except GenerationError as exc:
562
+ raise SessionUnavailable(
563
+ f"Cannot read Codex reply; inspect worker status: {exc}"
564
+ ) from exc
565
+ return CodexReport.from_record(self._uuid_store.record(worker),{
566
+ "turnId":report.get("turn_id"),
567
+ "itemId":None,"phase":report.get("phase"),
568
+ "text":report["text"],
569
+ "completedAt":report.get("settled_at_ms"),
570
+ "note":report.get("note"),
571
+ })
572
+
573
+ def approve(
574
+ self,
575
+ target: str,
576
+ *,
577
+ request_id: str,
578
+ timeout: float | None = None,
579
+ ) -> CodexApprovalResult:
580
+ """Accept one pending command or ordinary file-change request.
581
+
582
+ ``timeout`` bounds only the decision RPC itself (the host resolves the
583
+ approval synchronously); pass it when the decision is one step of a
584
+ larger budgeted operation. ``None`` keeps the default host timeout.
585
+ """
586
+ return self._decision(target, "approve", request_id, timeout)
587
+
588
+ def reject(
589
+ self,
590
+ target: str,
591
+ *,
592
+ request_id: str,
593
+ timeout: float | None = None,
594
+ ) -> CodexApprovalResult:
595
+ """Decline one pending command or ordinary file-change request.
596
+
597
+ Codex approvals carry no denial rationale, so there is no ``reason``
598
+ here: the app-server accepts a decision, not a message.
599
+ """
600
+ return self._decision(target, "reject", request_id, timeout)
601
+
602
+ def _decision(
603
+ self,
604
+ target: str,
605
+ operation: str,
606
+ request_id: str,
607
+ timeout: float | None = None,
608
+ *, response_id: str | None = None,
609
+ expected_turn_id: str | None = None,
610
+ ) -> CodexApprovalResult:
611
+ # Native requestIds can repeat after an app-server restart. The caller
612
+ # must supply the unique CCX gateId shown by recorded status/peek.
613
+ if not isinstance(request_id, str) or not request_id or len(request_id) > 128:
614
+ raise InvalidRequest("Codex approval requires an exact bounded CCX gateId")
615
+ if timeout is not None and (type(timeout) not in (int, float) or timeout <= 0):
616
+ raise InvalidRequest("Codex approval timeout must be positive")
617
+ _owner, value = self._owned_codex(target)
618
+ worker = value["workerId"] # Canonical bare global UUID, not qualified handle.
619
+ message = {"op": operation, "gateId": request_id}
620
+ if response_id is not None:
621
+ message["responseId"] = response_id
622
+ reply = rpc(worker, message, timeout=10 if timeout is None else timeout)
623
+ # The request could have reached codexd even if its ACK was lost.
624
+ # Codex app-server server-request responses have no provider ACK.
625
+ transport = reply.get("transport")
626
+ if reply.get("ok"):
627
+ native = value.get("native") or {}
628
+ expected_decision = "accept" if operation == "approve" else "decline"
629
+ if (reply.get("gateId") != request_id
630
+ or reply.get("workerId") != worker
631
+ or reply.get("threadId") != native.get("threadId")
632
+ or not isinstance(reply.get("turnId"), str)
633
+ or not reply["turnId"]
634
+ or (expected_turn_id is not None
635
+ and reply["turnId"] != expected_turn_id)
636
+ or reply.get("decision") != expected_decision):
637
+ raise SendIndeterminate(
638
+ f"Codex {operation} ACK mismatched original worker/gate; "
639
+ f"worker={worker}, gateId={request_id}", short=worker)
640
+ return CodexApprovalResult.from_record(
641
+ value, request_id=request_id, decision=expected_decision)
642
+ if transport == "absent" or reply.get("loaded") is False:
643
+ raise SessionUnavailable(f"Codex {operation}: worker is stopped; resume it before this action",
644
+ short=worker)
645
+ if transport == "inconclusive" or not reply.get("definitive"):
646
+ raise SendIndeterminate(
647
+ f"Codex {operation}: approval response indeterminate; "
648
+ f"worker={worker}, gateId={request_id}", short=worker)
649
+ raise DeliveryRejected(
650
+ str(reply.get("error") or f"Codex {operation} refused original gate"),
651
+ short=worker, reason=reply.get("error"), state=reply.get("state"))
652
+
653
+ def compact(
654
+ self,
655
+ target: str,
656
+ *,
657
+ timeout: float | None = COMPACT_WAIT_DEFAULT_S,
658
+ ) -> CodexCompactResult:
659
+ """Wait for one original, source-proven native Codex compaction."""
660
+ from uuid import uuid4
661
+ from ccx.codex.ops import compact_worker
662
+ from ccx.errors import CompactFailed, CompactIndeterminate
663
+
664
+ from ccx.store.root import GenerationError, NativeEffectIndeterminate
665
+
666
+ handle, _record = self._owned_codex(target)
667
+ operation_id = str(uuid4()) # Internal only; public Codex has no ID option.
668
+ try:
669
+ result = compact_worker(
670
+ handle, operation_id=operation_id, wait=True, timeout=timeout,
671
+ )
672
+ except NativeEffectIndeterminate as exc:
673
+ raise CompactIndeterminate(
674
+ f"Codex compaction outcome is unknown; check status before retrying: {exc}",
675
+ short=handle.split(":", 1)[1][:8], operation_id=operation_id,
676
+ operation_status="unknown",
677
+ ) from exc
678
+ except GenerationError as exc:
679
+ raise SessionUnavailable(
680
+ f"full-UUID Codex native compact unavailable: {exc}"
681
+ ) from exc
682
+ current = self._uuid_store.record(handle)
683
+ native_state = (current.get("lifecycle") or {}).get("state")
684
+ status = result["status"]
685
+ if status == "failed":
686
+ raise CompactFailed(
687
+ result.get("reason") or "Codex native compact turn failed",
688
+ short=current["workerId"][:8], state=native_state,
689
+ operation_id=operation_id,
690
+ )
691
+ if status == "rejected":
692
+ raise DeliveryRejected(
693
+ result.get("reason") or "Codex compact refused before native admission",
694
+ short=current["workerId"][:8], state=native_state,
695
+ refusal="native-rejected", operation_id=operation_id,
696
+ )
697
+ if status != "succeeded" or result.get("compacted") is not True:
698
+ raise CompactIndeterminate(
699
+ f"Codex compaction result {status!r} is unconfirmed; inspect its operation",
700
+ short=current["workerId"][:8], state=native_state,
701
+ operation_id=operation_id, operation_status=status,
702
+ )
703
+ return CodexCompactResult.from_record(
704
+ current, compacted=True,
705
+ context_before=result.get("contextBefore"),
706
+ context_after=result.get("contextAfter"),
707
+ operation_id=operation_id, operation_status=status,
708
+ compact_turn_id=result.get("compactTurnId"),
709
+ reason=result.get("reason"),
710
+ )
711
+
712
+ def context(self, target: str) -> CodexContext:
713
+ """Return the latest cached carried-context measurement."""
714
+ _worker, rec = self._owned_codex(target)
715
+ return CodexContext.from_record(rec)
716
+
717
+ def models(self, *, refresh: bool = False) -> ModelCatalog:
718
+ """The models Codex offers, as ``ccx models --engine codex``."""
719
+ from ccx.models import read_models
720
+ return read_models(engine="codex", refresh=refresh)[0]
721
+
722
+ def usage(
723
+ self,
724
+ *,
725
+ tokens: bool = False,
726
+ timeout: float = 30,
727
+ ) -> CodexUsage:
728
+ """Return live Codex account quota utilization or token activity."""
729
+ if timeout <= 0:
730
+ raise InvalidRequest("timeout must be greater than zero")
731
+ data = read_account_usage(tokens=tokens, timeout=timeout)
732
+ return CodexUsage(
733
+ raw=data,
734
+ kind="token_activity" if tokens else "rate_limits",
735
+ )
736
+
737
+
738
+ __all__ = ["CodexClient"]