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