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
package/ccx/workers.py ADDED
@@ -0,0 +1,2093 @@
1
+ """Engine-neutral client for managed workers.
2
+
3
+ Both engine clients pin the same sealed active generation and resolve full
4
+ CCX worker UUIDs independently of provider-native identifiers."""
5
+
6
+ from __future__ import annotations
7
+ import ccx.cli.commands.read as commands_read
8
+
9
+ import os
10
+ import signal
11
+ import time
12
+ from contextlib import ExitStack, contextmanager
13
+ from datetime import datetime, timezone
14
+ from functools import wraps
15
+ from typing import Callable, Iterator
16
+
17
+ from ccx.codex import evidence as codex_evidence
18
+ from ccx.codex import records as codex_records
19
+ from ccx.ops import event_feed
20
+ from ccx.ops import turn_reports
21
+ from ccx.ops import terminate
22
+ from ccx.system import oslayer
23
+ from ccx.ops import conversation as _conv
24
+ from .client import CcxClient
25
+ from ccx.codex.client import CodexClient
26
+ from ccx.codex.types import rate_limit_windows, window_label
27
+ from .constants import (
28
+ DEFAULT_IDLE_TIMEOUT_S,
29
+ STEER_OBSERVE_DEFAULT_S,
30
+ S,
31
+ )
32
+ from ccx.codex.read import pending_input as _codex_pending
33
+ from ccx.claude.context import current_context, public_context, read_claude_observation
34
+ from .errors import (
35
+ CapabilityUnsupported,
36
+ CcxError,
37
+ DeliveryRejected,
38
+ InvalidRequest,
39
+ SessionNotFound,
40
+ SessionUnavailable,
41
+ SessionWaiting,
42
+ TurnIncomplete,
43
+ worker_not_found,
44
+ )
45
+ from ccx.store.liveness import pid_alive, recorded_host_loaded
46
+ from ccx.store.paths import sock_path
47
+ from ccx.store.records import merge_lifecycle
48
+ from ccx.rpc.rpc import _respawn, _respawn_failure_message, _rpc
49
+ from ccx.ops.semantics import KIND_ELICITATION, pending_input_kind
50
+ from ccx.claude.usage import fetch_usage, usage_summary
51
+ from ccx.store.worker_state import (
52
+ RUNTIME_INCONCLUSIVE,
53
+ normalize_native_state,
54
+ )
55
+
56
+ _ENGINES = ("claude", "codex")
57
+
58
+ # Failures a per-engine step may raise that must NOT take down the other engine
59
+ # in a cross-engine list/usage: typed ccx errors plus the ordinary ways a
60
+ # corrupt record or an unexpected provider payload blows up. Deliberately not
61
+ # BaseException — a KeyboardInterrupt is not a provider failure.
62
+ _PROVIDER_FAILURES = (CcxError, OSError, RuntimeError, ValueError, KeyError, TypeError,
63
+ AttributeError)
64
+
65
+
66
+ def _engine_error(error: BaseException) -> dict:
67
+ return {"error": type(error).__name__, "message": str(error),
68
+ "exit_code": getattr(error, "exit_code", 1)}
69
+
70
+ _CAP_KEYS = ("5h", "7d", "opus")
71
+
72
+ # States in which a worker is settled enough to stop without forcing.
73
+ _SETTLED = ("idle", "stopped", "failed", "unavailable")
74
+ # WaitResult background-task fields carried through ``wait`` for Claude.
75
+ _BACKGROUND_FACT_FIELDS = (
76
+ "background_tasks", "background_task_count",
77
+ "running_background_task_count", "open_background_task_count",
78
+ "active_background_task_count", "stale_background_task_count",
79
+ "background_tasks_truncated", "background_task_limit",
80
+ "background_task_hint",
81
+ )
82
+
83
+ # Normalized states in the order a recap summary counts them: lifecycle order,
84
+ # so the one-line header reads the same way every time.
85
+ _RECAP_STATES = ("starting", "idle", "busy", "waiting", "stopped", "failed",
86
+ "unavailable")
87
+
88
+ # Sentinel: "read the state yourself" vs an explicitly supplied one (which may
89
+ # legitimately be None).
90
+ _UNSET = object()
91
+
92
+
93
+ # --- handles, records, budgets ----------------------------------------------
94
+
95
+ def _handle(worker) -> str:
96
+ """Coerce a handle string or any facade result dict to a handle string."""
97
+ if isinstance(worker, dict):
98
+ worker = worker.get("worker")
99
+ if not isinstance(worker, str):
100
+ raise InvalidRequest(
101
+ "worker must be a handle string like 'claude:966a8a1b' "
102
+ "(or a result dict carrying one)"
103
+ )
104
+ return worker
105
+
106
+
107
+ def _split(worker) -> tuple[str, str, str]:
108
+ """``"claude:966a8a1b"`` -> ``("claude:966a8a1b", "claude", "966a8a1b")``."""
109
+ handle = _handle(worker)
110
+ engine, sep, short = handle.partition(":")
111
+ if not sep or engine not in _ENGINES or not short:
112
+ raise InvalidRequest(
113
+ f"invalid worker handle {handle!r}; expected "
114
+ "'claude:<short>' or 'codex:<short>'"
115
+ )
116
+ return handle, engine, short
117
+
118
+
119
+ def _state(native) -> str:
120
+ return normalize_native_state(native)
121
+
122
+
123
+ def _public_created_result(store, raw: dict, *, fork: bool = False) -> dict:
124
+ """Main's neutral create/fork shape from the child's latest recorded lifecycle."""
125
+ try:
126
+ record = store.record(raw["worker"])
127
+ except Exception as error:
128
+ # The original native effect may already have landed. Keep its full UUID
129
+ # and cause rather than returning a stale activation snapshot or retrying.
130
+ from .errors import CreatePartialFailure
131
+ raise CreatePartialFailure(
132
+ "worker created but public projection is incomplete; retain worker_id",
133
+ info=raw, phase="inspect", cause=error,
134
+ ) from error
135
+ native = (record.get("lifecycle") or {}).get("state")
136
+ report = raw.get("report")
137
+ reply = (report.get("text") if isinstance(report, dict)
138
+ and report.get("status") == "completed"
139
+ and isinstance(report.get("text"), str) else None)
140
+ public = {
141
+ "worker": raw["worker"], "worker_id": raw["worker_id"],
142
+ "engine": raw["engine"], "short": raw["short"],
143
+ "state": _state(native), "native_state": native,
144
+ "report": report, "reply": reply,
145
+ "created": raw["created"], "reconciled": raw["reconciled"],
146
+ "delivery_id": raw.get("delivery_id"),
147
+ "delivery_status": raw.get("delivery_status"),
148
+ "notification": raw.get("notification"),
149
+ }
150
+ if fork:
151
+ public["forked_from"] = raw["forked_from"]
152
+ return public
153
+
154
+
155
+ def _host_from_loaded(loaded: bool | None) -> str:
156
+ if loaded is True:
157
+ return "ok"
158
+ if loaded is False:
159
+ return "unreachable"
160
+ return "timeout"
161
+
162
+
163
+ # Budget arithmetic is shared with ccx.terminate, which owns the confirmed
164
+ # teardown these same budgets are spent on.
165
+ _deadline = terminate.deadline_for
166
+ _left = terminate.left
167
+
168
+
169
+
170
+
171
+ def _codex_record(short: str) -> dict:
172
+ rec = codex_records.read_record(short)
173
+ if rec is None:
174
+ raise SessionNotFound(f"no such Codex worker: {short}", short=short)
175
+ return rec
176
+
177
+
178
+
179
+
180
+ def _mark_recent_window(document: dict, limit: int, warning: str) -> None:
181
+ """Label a document that holds only the newest part of a history.
182
+
183
+ A bounded tail cannot claim global 1-based turn numbers, nor pretend to
184
+ describe the complete native history.
185
+ """
186
+ for turn in document["turns"]:
187
+ turn["sequence"] = None
188
+ complete = document["completeness"]
189
+ complete["sourceLimited"] = True
190
+ if complete.get("status") != "partial": # partial outranks source-limited
191
+ complete["status"] = "source-limited"
192
+ complete["warnings"] = [*(complete.get("warnings") or []), warning]
193
+ document["peekHistoryWindow"] = {"kind": "recent", "limit": limit}
194
+
195
+
196
+ def _note_history_db(document: dict, thread: dict) -> None:
197
+ """Say what a document read from Codex's local history database can't vouch for."""
198
+ from ccx.codex.history_store import VERIFIED_SCHEMA_VERSION
199
+
200
+ complete = document["completeness"]
201
+ warnings = list(complete.get("warnings") or [])
202
+ version = thread.get("historyDbVersion")
203
+ if version != VERIFIED_SCHEMA_VERSION:
204
+ warnings.append(
205
+ f"read from Codex's local history database, schema version {version} "
206
+ f"(checked against {VERIFIED_SCHEMA_VERSION})")
207
+ if any((t or {}).get("status") == "inProgress" for t in thread.get("turns") or []):
208
+ # The database is a projection of the rollout and can lag a running turn.
209
+ complete["sourceLimited"] = True
210
+ if complete.get("status") != "partial":
211
+ complete["status"] = "source-limited"
212
+ warnings.append("a turn is still running; Codex's local history database "
213
+ "may not have its latest items yet")
214
+ complete["warnings"] = warnings
215
+
216
+
217
+ def _background_task_block_for_worker(store, owner) -> dict:
218
+ """Bound the original background-task facts for facade wait and report."""
219
+ from ccx.ops.common import background_task_block, recorded_background_facts
220
+
221
+ rec = store.record(owner.worker) # mandatory owner/record evidence stays strict
222
+ recorded = rec.get("backgroundTaskSummary")
223
+ if isinstance(recorded, dict):
224
+ return background_task_block(recorded_background_facts(recorded), supported=True)
225
+ if owner.engine != "claude":
226
+ return background_task_block(None, supported=False)
227
+ try:
228
+ facts = CcxClient._uuid_background_task_facts_owned(store, owner, rec)
229
+ except (SessionUnavailable, OSError) as exc:
230
+ store.revalidate() # never hide a generation change as missing task facts
231
+ return {"background_tasks_supported": True,
232
+ "background_tasks_available": False,
233
+ "background_task_note": f"background tasks unknown: {exc}",
234
+ "background_task_summary": rec.get("backgroundTaskSummary")}
235
+ if "backgroundTaskSummary" in rec:
236
+ facts["background_task_summary"] = rec["backgroundTaskSummary"]
237
+ return background_task_block(facts, supported=owner.engine == "claude")
238
+
239
+
240
+ def _check_caps(caps, *, engine: str | None = None) -> None:
241
+ """Validate a per-turn quota cap before anything is delivered.
242
+
243
+ ``{"5h"|"7d"|"opus": 0-100}``. Booleans are rejected explicitly: ``True``
244
+ is an ``int`` in Python and would silently arm a 1% cap.
245
+ """
246
+ if not isinstance(caps, dict) or not caps:
247
+ raise InvalidRequest("caps must be a non-empty dict")
248
+ unknown = set(caps) - set(_CAP_KEYS)
249
+ if unknown:
250
+ raise InvalidRequest(
251
+ f"unknown caps keys {sorted(unknown)}; expected {_CAP_KEYS}"
252
+ )
253
+ if engine == "codex" and "opus" in caps:
254
+ raise InvalidRequest("Codex does not support cap-opus (Claude-only)")
255
+ for key, value in caps.items():
256
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
257
+ raise InvalidRequest(
258
+ f"caps[{key!r}] must be a number between 0 and 100, "
259
+ f"got {value!r}"
260
+ )
261
+ if not 0 <= value <= 100:
262
+ raise InvalidRequest(
263
+ f"caps[{key!r}]={value!r} is outside 0..100"
264
+ )
265
+
266
+
267
+ def evaluate_quota(usage: dict, limits: dict) -> dict:
268
+ """Evaluate account quota windows against exact normalized window ids.
269
+
270
+ Unknown or malformed requested windows make ``allowed`` false. The
271
+ function is deliberately policy-free: callers may inspect ``unknown`` and
272
+ choose to fail open, report a checkpoint, or retry later themselves.
273
+ """
274
+ if not isinstance(usage, dict):
275
+ raise InvalidRequest("usage must be a WorkerClient.usage result dict")
276
+ if not isinstance(limits, dict) or not limits:
277
+ raise InvalidRequest("quota limits must be a non-empty dict")
278
+ normalized_limits: dict[str, float] = {}
279
+ for window_id, limit in limits.items():
280
+ if not isinstance(window_id, str) or not window_id:
281
+ raise InvalidRequest("quota limit ids must be non-empty strings")
282
+ if isinstance(limit, bool) or not isinstance(limit, (int, float)):
283
+ raise InvalidRequest(
284
+ f"quota limit {window_id!r} must be a number between 0 and 100"
285
+ )
286
+ if not 0 <= limit <= 100:
287
+ raise InvalidRequest(
288
+ f"quota limit {window_id!r}={limit!r} is outside 0..100"
289
+ )
290
+ normalized_limits[window_id] = float(limit)
291
+
292
+ raw_windows = usage.get("windows")
293
+ if not isinstance(raw_windows, list):
294
+ raw_windows = []
295
+ by_id: dict[str, list[dict]] = {}
296
+ for item in raw_windows:
297
+ if isinstance(item, dict) and isinstance(item.get("id"), str):
298
+ by_id.setdefault(item["id"], []).append(item)
299
+
300
+ reached: list[str] = []
301
+ unknown: list[str] = []
302
+ windows: list[dict] = []
303
+ reached_resets: list[tuple[datetime, str]] = []
304
+ retry_known = True
305
+ for window_id, limit in normalized_limits.items():
306
+ candidates = by_id.get(window_id, [])
307
+ item = candidates[0] if len(candidates) == 1 else None
308
+ used = item.get("used_percent") if item is not None else None
309
+ if (
310
+ item is None
311
+ or isinstance(used, bool)
312
+ or not isinstance(used, (int, float))
313
+ or not 0 <= used <= 100
314
+ ):
315
+ unknown.append(window_id)
316
+ windows.append({
317
+ "id": window_id, "limit_percent": limit,
318
+ "used_percent": None, "resets_at": None, "state": "unknown",
319
+ })
320
+ continue
321
+ used = float(used)
322
+ is_reached = used >= limit
323
+ resets_at = item.get("resets_at")
324
+ reset_time = None
325
+ if isinstance(resets_at, str):
326
+ try:
327
+ reset_time = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
328
+ if reset_time.tzinfo is None:
329
+ reset_time = None
330
+ except ValueError:
331
+ reset_time = None
332
+ else:
333
+ resets_at = None
334
+ windows.append({
335
+ "id": window_id, "limit_percent": limit,
336
+ "used_percent": used, "resets_at": resets_at,
337
+ "state": "reached" if is_reached else "allowed",
338
+ })
339
+ if is_reached:
340
+ reached.append(window_id)
341
+ if reset_time is None:
342
+ retry_known = False
343
+ else:
344
+ reached_resets.append((reset_time, resets_at))
345
+ retry_at = (
346
+ max(reached_resets, key=lambda item: item[0])[1] if reached and retry_known
347
+ and len(reached_resets) == len(reached) else None
348
+ )
349
+ return {
350
+ "allowed": not reached and not unknown,
351
+ "reached": reached,
352
+ "unknown": unknown,
353
+ "retry_at": retry_at,
354
+ "windows": windows,
355
+ }
356
+
357
+
358
+ def _unsupported(engine: str, capability: str, detail: str) -> CapabilityUnsupported:
359
+ return CapabilityUnsupported(
360
+ f"{engine} does not support {detail}", engine=engine, capability=capability
361
+ )
362
+
363
+
364
+ def _summary_context(block: dict | None, *, has_turn: bool, codex: bool) -> dict:
365
+ """Normalize persisted context without rendering either engine transcript."""
366
+ if not has_turn:
367
+ state = "not_started"
368
+ block = None
369
+ elif not isinstance(block, dict):
370
+ state = "unavailable"
371
+ block = None
372
+ else:
373
+ native_state = block.get("state")
374
+ state = native_state if native_state in {
375
+ "known", "unknown", "pending", "unavailable"
376
+ } else ("known" if block.get("tokens_used") is not None else "unavailable")
377
+ block = block or {}
378
+ return {
379
+ "state": state,
380
+ "tokens_used": block.get("tokens_used"),
381
+ "window_size": block.get("window_size") if codex else None,
382
+ "percent_used": block.get("percent_used") if codex else None,
383
+ "as_of_turn_ref": block.get("as_of_turn_ref"),
384
+ "as_of_ms": block.get("as_of_ms"),
385
+ "reason": block.get("reason"),
386
+ }
387
+
388
+
389
+ def _claude_pending(waiting_for) -> dict | None:
390
+ if not isinstance(waiting_for, dict) or not waiting_for:
391
+ return None
392
+ tool = waiting_for.get("tool")
393
+ questions = waiting_for.get("input") or {}
394
+ questions = (questions.get("questions") or []) if isinstance(questions, dict) else []
395
+ out = {
396
+ "kind": pending_input_kind(waiting_for),
397
+ "request_id": waiting_for.get("id"),
398
+ "tool": tool,
399
+ "message": waiting_for.get("message"),
400
+ "questions": list(questions),
401
+ "native": dict(waiting_for),
402
+ }
403
+ if out["kind"] == KIND_ELICITATION:
404
+ # Only the fields a responder needs; `native` keeps the full preview.
405
+ out["elicitation"] = {
406
+ key: waiting_for.get(key) for key in ("server", "mode", "url", "schema")
407
+ }
408
+ return out
409
+
410
+
411
+ def _codex_approval_turn_id(record: dict, request_id) -> str | None:
412
+ """Return the native turn fenced by one pending approval request."""
413
+ approvals = [value for value in record.get("pendingApprovals") or []
414
+ if isinstance(value, dict)]
415
+ selected = None
416
+ if request_id is None and len(approvals) == 1:
417
+ selected = approvals[0]
418
+ elif request_id is not None:
419
+ selected = next(
420
+ (
421
+ value for value in approvals
422
+ if str(request_id) in {
423
+ str(value.get("requestId")), str(value.get("gateId")),
424
+ }
425
+ ),
426
+ None,
427
+ )
428
+ turn_id = selected.get("turnId") if selected else None
429
+ if not isinstance(turn_id, str) or not turn_id:
430
+ turn_id = (record.get("lifecycle") or {}).get("activeTurnId")
431
+ return turn_id if isinstance(turn_id, str) and turn_id else None
432
+
433
+
434
+ def _recap_row(handle: str, engine: str, short: str) -> dict:
435
+ """A recap row before anything is observed.
436
+
437
+ Every row carries the same keys whatever happened to the worker, so a script
438
+ can index them unconditionally. ``unavailable`` is the honest starting point:
439
+ a row only claims a better state once one has actually been read.
440
+ """
441
+ return {"worker": handle, "engine": engine, "short": short, "name": None,
442
+ "state": "unavailable", "native_state": None,
443
+ "requested_model": None, "observed_model": None,
444
+ "requested_effort": None, "turns": None,
445
+ "context_tokens": None, "context_as_of_turn_ref": None,
446
+ "context_as_of_ms": None, "error": None}
447
+
448
+
449
+ def _recap_counts(rows: list[dict]) -> str:
450
+ counts: dict[str, int] = {}
451
+ for row in rows:
452
+ counts[row["state"]] = counts.get(row["state"], 0) + 1
453
+ order = [s for s in _RECAP_STATES if s in counts]
454
+ order += sorted(s for s in counts if s not in _RECAP_STATES)
455
+ return ", ".join(f"{state} {counts[state]}" for state in order)
456
+
457
+
458
+ def _recap_line(row: dict, width: int) -> str:
459
+ """One worker as one line. Unobservable fields are omitted, never guessed."""
460
+ fields = [row["worker"].ljust(width)]
461
+ if row["name"]:
462
+ fields.append(str(row["name"]))
463
+ state, native = row["state"], row["native_state"]
464
+ fields.append(f"{state}({native})" if native and native != state else state)
465
+ model, observed = row["requested_model"], row["observed_model"]
466
+ if observed and observed != model:
467
+ # Requested and observed never merge, so a divergence is shown as one.
468
+ model = f"{model or '?'}->{observed}"
469
+ if model:
470
+ fields.append(f"model={model}")
471
+ if row["requested_effort"]:
472
+ fields.append(f"effort={row['requested_effort']}")
473
+ if row["turns"] is not None:
474
+ fields.append(f"turns={row['turns']}")
475
+ if row["context_tokens"] is not None:
476
+ marker = row["context_as_of_turn_ref"]
477
+ fields.append(f"context={row['context_tokens']}"
478
+ + ("" if marker is None else f"@{marker}"))
479
+ if row["error"]:
480
+ fields.append(f"error={row['error']}")
481
+ return " ".join(fields)
482
+
483
+
484
+ def _recap_text(rows: list[dict]) -> str:
485
+ """The printable recap: a count header plus one line per worker, in order.
486
+
487
+ Deterministic by construction — no timestamps, no elapsed time, no ordering
488
+ by anything but the caller's input. Task-specific facts (result, phase,
489
+ workspace, report paths, continuation commands) belong to the script, which
490
+ is the only thing that knows them.
491
+ """
492
+ if not rows:
493
+ return "0 workers"
494
+ width = max(len(row["worker"]) for row in rows)
495
+ header = (f"{len(rows)} worker{'' if len(rows) == 1 else 's'}: "
496
+ f"{_recap_counts(rows)}")
497
+ return "\n".join([header] + [_recap_line(row, width) for row in rows])
498
+
499
+
500
+ # Process termination primitives live in ccx.terminate, which both this facade
501
+ # and the two CLIs use for confirmed kills.
502
+ _signal = terminate.signal_pid
503
+ _escalate_at = terminate.escalate_at
504
+ _await_exit = terminate.await_exit
505
+ _reap = terminate.reap
506
+
507
+
508
+ def _claude_windows(summary: dict) -> list[dict]:
509
+ windows: list[dict] = []
510
+ for key, label in (("five_hour", "5h"), ("seven_day", "7d")):
511
+ block = summary.get(key)
512
+ if isinstance(block, dict):
513
+ used = block.get("utilization")
514
+ windows.append({
515
+ "id": key,
516
+ "label": label,
517
+ "used_percent": float(used) if isinstance(used, (int, float)) else None,
518
+ "resets_at": block.get("resets_at"),
519
+ })
520
+ for quota in summary.get("model_quotas") or []:
521
+ if not isinstance(quota, dict):
522
+ continue
523
+ used = quota.get("utilization")
524
+ windows.append({
525
+ "id": f"model:{quota.get('model_id') or quota.get('model')}",
526
+ "label": str(quota.get("model") or "model"),
527
+ "used_percent": float(used) if isinstance(used, (int, float)) else None,
528
+ "resets_at": quota.get("resets_at"),
529
+ })
530
+ return windows
531
+
532
+
533
+ def _codex_windows(data: dict) -> list[dict]:
534
+ """Normalize Codex windows with duration-based IDs."""
535
+ windows: list[dict] = []
536
+ for limit_id, window in rate_limit_windows(data):
537
+ used, resets = window.get("usedPercent"), window.get("resetsAt")
538
+ label = window_label(window)
539
+ windows.append({
540
+ "id": f"{limit_id}:{label}",
541
+ "label": label,
542
+ "used_percent": float(used) if type(used) in (int, float) else None,
543
+ "resets_at": (
544
+ datetime.fromtimestamp(resets, timezone.utc).strftime(
545
+ "%Y-%m-%dT%H:%M:%SZ"
546
+ ) if type(resets) in (int, float) else None
547
+ ),
548
+ })
549
+ return windows
550
+
551
+
552
+ class WorkerClient:
553
+ """One lifecycle for ccx-managed Claude workers and CCX-owned Codex workers.
554
+
555
+ Stateless and cheap to construct; it holds only the two stateless concrete
556
+ clients. There is no "current engine" anywhere, so concurrent calls against
557
+ distinct workers are safe by construction. Never shells out to the ``ccx``
558
+ CLI and never calls ``os.fork()`` in your process.
559
+ """
560
+
561
+ def _uuid_owner(self, worker):
562
+ from ccx.store.identity import UnknownWorker, IdentityError
563
+ from ccx.store.root import GenerationError
564
+ target=worker.get("worker") if isinstance(worker,dict) else worker
565
+ try:
566
+ return self._uuid_store.resolve(target)
567
+ except UnknownWorker as exc:
568
+ raise worker_not_found(target) from exc
569
+ except IdentityError as exc:
570
+ raise InvalidRequest(f"Invalid worker handle: {exc}") from exc
571
+ except GenerationError as exc:
572
+ raise SessionUnavailable(f"Worker record unavailable; check its status: {exc}") from exc
573
+
574
+ def __init__(self) -> None:
575
+ """One pinned global generation for both engine views, no ensure_dirs."""
576
+ self._claude = CcxClient()
577
+ self._codex = CodexClient()
578
+ self._uuid_store = self._claude._uuid_store
579
+ if self._codex._uuid_store.pointer != self._uuid_store.pointer:
580
+ raise SessionUnavailable("CCX worker records changed during startup; retry")
581
+ self._uuid_store.revalidate()
582
+
583
+ # -- introspection ------------------------------------------------------
584
+
585
+ def capabilities(self, engine) -> dict:
586
+ """Truthful implemented UUID capabilities, not old native promises."""
587
+ if isinstance(engine,dict):
588
+ engine=engine.get("worker")
589
+ if engine not in _ENGINES:
590
+ engine=self._uuid_owner(engine).engine
591
+ # The dashboard's busy-turn router uses steer_current_turn.
592
+ # All other published flags below describe actual engine differences.
593
+ return {
594
+ "engine": engine,
595
+ "steer_current_turn": True,
596
+ "auto_delivery": engine == "claude",
597
+ "wait_next_turn": engine == "claude",
598
+ "wait_after_turn": engine == "claude",
599
+ "wait_known_input": engine == "claude",
600
+ "answer_question": engine == "claude",
601
+ "respond_elicitation": engine == "claude",
602
+ }
603
+
604
+ def variables(self, worker, name: str | None = None) -> dict:
605
+ """Full-UUID durable variable read, no live object resurrection."""
606
+ owner=self._uuid_owner(worker)
607
+ from ccx.store.root import GenerationError
608
+ try:
609
+ return self._uuid_store.variables_info(owner.worker,name=name)
610
+ except GenerationError as exc:
611
+ raise SessionUnavailable(f"full-UUID variables unavailable: {exc}") from exc
612
+
613
+ # -- create -------------------------------------------------------------
614
+
615
+ def create(
616
+ self, *, engine: str, cwd: str, worker_id: str | None = None,
617
+ model: str | None = None, effort: str | None = None,
618
+ permissions: str = "full", name: str | None = None,
619
+ prompt: str | None = None, idle_timeout: int | None = None,
620
+ wait: bool = False, timeout: float | None = None,
621
+ start_timeout: float | None = None,
622
+ caps: dict | None = None, variables: dict | None = None,
623
+ variables_from=None,
624
+ ) -> dict:
625
+ """One engine-neutral original native-ready UUID creation operation."""
626
+ from ccx.ops.creation import create
627
+ if engine not in _ENGINES:
628
+ raise InvalidRequest(f"unknown worker engine: {engine!r}")
629
+ if variables is not None and variables_from is not None:
630
+ raise InvalidRequest("variables and variables_from are mutually exclusive")
631
+ attachment = None
632
+ if variables is not None or variables_from is not None:
633
+ from ccx.controllers import live_vars
634
+ if variables_from is not None:
635
+ source = self._uuid_owner(variables_from)
636
+ attachment = live_vars.prepare_shared_durable_attachment(
637
+ source.engine, source.worker_id,
638
+ )
639
+ else:
640
+ attachment = live_vars.prepare_attachment(variables)
641
+ raw = create(
642
+ engine=engine,cwd=cwd,worker_id=worker_id,prompt=prompt,wait=wait,
643
+ caps=caps,
644
+ name=name,model=model,effort=effort,permissions=permissions,
645
+ idle_timeout=(DEFAULT_IDLE_TIMEOUT_S
646
+ if idle_timeout is None else idle_timeout),
647
+ created_via="python-api",timeout=timeout,
648
+ start_timeout=start_timeout, attachment=attachment,
649
+ expected_generation=self._uuid_store.generation,
650
+ )
651
+ return _public_created_result(self._uuid_store, raw)
652
+
653
+ def fork(
654
+ self, worker, *, worker_id: str | None = None, name: str | None = None,
655
+ model: str | None = None, effort: str | None = None,
656
+ prompt: str | None = None, wait: bool = False,
657
+ timeout: float | None = None, caps: dict | None = None,
658
+ variables: dict | None = None, variables_from=None,
659
+ ) -> dict:
660
+ """Fork the CCX-owned ``worker`` into a new worker with its history.
661
+
662
+ The result has :meth:`create`'s keys plus ``forked_from``. Retrying
663
+ with the same ``worker_id`` reuses the original fork point.
664
+ """
665
+ from math import isfinite
666
+ from ccx.ops.forking import fork
667
+ from ccx.store.root import GenerationError
668
+ from ccx.store.identity import IdentityError, UnknownWorker
669
+
670
+ if variables is not None and variables_from is not None:
671
+ raise InvalidRequest("variables and variables_from are mutually exclusive")
672
+ attachment = None
673
+ if variables is not None or variables_from is not None:
674
+ from ccx.controllers import live_vars
675
+ if variables_from is not None:
676
+ source = self._uuid_owner(variables_from)
677
+ attachment = live_vars.prepare_shared_durable_attachment(
678
+ source.engine, source.worker_id,
679
+ )
680
+ else:
681
+ attachment = live_vars.prepare_attachment(variables)
682
+ if timeout is not None and (
683
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
684
+ ):
685
+ raise InvalidRequest("fork timeout must be positive finite seconds or None")
686
+ deadline = time.monotonic() + timeout if timeout is not None else None
687
+ try:
688
+ raw = fork(
689
+ _handle(worker), worker_id=worker_id, name=name, model=model,
690
+ effort=effort, created_via="python-api",
691
+ expected_generation=self._uuid_store.generation, deadline=deadline,
692
+ prompt=prompt, wait=wait, caps=caps, attachment=attachment,
693
+ )
694
+ except UnknownWorker as exc:
695
+ raise worker_not_found(_handle(worker)) from exc
696
+ except IdentityError as exc:
697
+ raise InvalidRequest(f"fork: invalid full-UUID worker: {exc}") from exc
698
+ except GenerationError as exc:
699
+ raise SessionUnavailable(f"fork: ACTIVE generation unavailable: {exc}") from exc
700
+ return _public_created_result(self._uuid_store, raw, fork=True)
701
+
702
+ # -- state readers ------------------------------------------------------
703
+
704
+
705
+
706
+ def status(self, worker) -> dict:
707
+ """Recorded-only worker status, with native identity distinct."""
708
+ owner=self._uuid_owner(worker)
709
+ rec=self._uuid_store.record(owner.worker)
710
+ lc=rec.get("lifecycle")
711
+ lc=lc if isinstance(lc,dict) else {}
712
+ native=rec.get("native")
713
+ native=native if isinstance(native,dict) else {}
714
+ stats=rec.get("stats")
715
+ stats=stats if isinstance(stats,dict) else {}
716
+ args=rec.get("createArgs")
717
+ args=args if isinstance(args,dict) else {}
718
+ if owner.engine == "claude":
719
+ observation = read_claude_observation(self._uuid_store, owner, rec)
720
+ observed = observation.get("observedModel") if observation else None
721
+ else:
722
+ observed = rec.get("resolvedModel")
723
+ return {
724
+ "worker": owner.worker,
725
+ "worker_id":owner.worker_id, "engine":owner.engine,
726
+ "short":owner.short, "name":rec.get("name"),
727
+ "cwd":rec.get("cwd"), "state":normalize_native_state(lc.get("state")),
728
+ "native_state": lc.get("state"),
729
+ "native_id": (native.get("sessionId") if owner.engine=="claude"
730
+ else native.get("threadId")),
731
+ "terminal_only":bool(rec.get("terminalOnly")),
732
+ "state_source":"recorded",
733
+ "host":"unobserved",
734
+ "loaded":recorded_host_loaded(owner.engine, lc),
735
+ # Recorded gate previews, like peek: the request_id is what
736
+ # approve/reject/answer require; the host stays the final fence.
737
+ "pending_input": (_claude_pending(rec.get("waitingFor"))
738
+ if owner.engine == "claude"
739
+ else _codex_pending(rec.get("pendingApprovals"))
740
+ if lc.get("state") == "waiting" else None),
741
+ "last_turn_at":rec.get("lastTurnAt"),
742
+ "managed_turns":stats.get("turns"),
743
+ "requested_model":args.get("model"),
744
+ "requested_effort":args.get("effort"),
745
+ "observed_model":observed,
746
+ "record_revision":rec.get("recordRevision"),
747
+ "context": commands_read._status_context_for_record(owner.worker, rec),
748
+ **({"background_task_summary": rec["backgroundTaskSummary"]}
749
+ if "backgroundTaskSummary" in rec else {}),
750
+ }
751
+
752
+ def list(self, *, engine: str | None = None, cwd: str | None = None,
753
+ state: str | None = None, parent: str | None = None,
754
+ limit: int | None = None) -> dict:
755
+ """Recorded owned workers, optionally exact direct children of a full UUID."""
756
+ if engine is not None and engine not in _ENGINES:
757
+ raise InvalidRequest(f"unknown engine: {engine!r}")
758
+ if cwd is not None and not isinstance(cwd,str):
759
+ raise InvalidRequest("cwd filter must be a path string")
760
+ parent_owner = self._uuid_owner(parent) if parent is not None else None
761
+ from ccx.system.cwd_paths import canonical_cwd
762
+ from ccx.store.root import GenerationError
763
+ try:
764
+ rows=self._uuid_store.list_workers(
765
+ engine=engine, cwd=canonical_cwd(cwd) if cwd else None,
766
+ state=state, parent=parent_owner.worker if parent_owner else None,
767
+ limit=limit,
768
+ )
769
+ except GenerationError as exc:
770
+ raise SessionUnavailable(f"full-UUID list unavailable: {exc}") from exc
771
+ return {"workers": rows, "errors": {}}
772
+
773
+
774
+ def summaries(self, handles) -> dict:
775
+ """Bounded recorded-only enrichment of exact owned full-UUID workers.
776
+
777
+ Preserve caller order and isolate only a genuinely absent full ID.
778
+ Neither the display prefix nor a native session/thread id is a target;
779
+ no host, daemon, transcript rendering or inferred liveness is queried.
780
+ Claude's already-rendered observation is a small owned sidecar; this
781
+ never starts a provider, renders a transcript, or guesses liveness.
782
+ """
783
+ from ccx.store.identity import IdentityError, parse_managed_target
784
+
785
+ if isinstance(handles, (str, bytes, dict)):
786
+ raise InvalidRequest("summaries expects an iterable of worker handles")
787
+ try:
788
+ items = iter(handles)
789
+ except TypeError as exc:
790
+ raise InvalidRequest("summaries expects an iterable of worker handles") from exc
791
+ self._uuid_store.revalidate()
792
+ rows: list[dict] = []
793
+ errors: dict[str, dict] = {}
794
+ seen: set[str] = set()
795
+ for value in items:
796
+ target = value.get("worker") if isinstance(value, dict) else value
797
+ try:
798
+ parsed = parse_managed_target(target)
799
+ except IdentityError as exc:
800
+ raise InvalidRequest(f"Invalid worker handle: {exc}") from exc
801
+ try:
802
+ owner = self._uuid_owner(target)
803
+ except SessionNotFound as exc:
804
+ # Missing is a per-row consumer result; invalid/contradictory
805
+ # authority, stale generations and corrupt records are NOT.
806
+ missing = (f"{parsed.qualified_engine}:{parsed.worker_id}"
807
+ if parsed.qualified_engine else parsed.worker_id)
808
+ if missing in seen:
809
+ continue
810
+ seen.add(missing)
811
+ failure = _engine_error(exc)
812
+ errors[missing] = failure
813
+ rows.append({
814
+ "worker": missing, "worker_id": parsed.worker_id,
815
+ "engine": parsed.qualified_engine, "short": parsed.worker_id[:8],
816
+ "observed_model": None,
817
+ "context": _summary_context(
818
+ None, has_turn=True, codex=parsed.qualified_engine == "codex",
819
+ ),
820
+ "error": failure["error"],
821
+ })
822
+ continue
823
+ if owner.worker in seen:
824
+ continue
825
+ seen.add(owner.worker)
826
+ rec = self._uuid_store.record(owner.worker)
827
+ if owner.engine == "claude":
828
+ observation = read_claude_observation(self._uuid_store, owner, rec)
829
+ block = observation.get("context") if observation else None
830
+ observed = observation.get("observedModel") if observation else None
831
+ else:
832
+ block = current_context(rec, engine="codex", live=rec.get("context"))
833
+ observed = rec.get("resolvedModel")
834
+ rows.append({
835
+ "worker": owner.worker, "worker_id": owner.worker_id,
836
+ "engine": owner.engine, "short": owner.short,
837
+ "observed_model": observed,
838
+ "context": _summary_context(
839
+ block, has_turn=rec.get("lastTurnAt") is not None or block is not None,
840
+ codex=owner.engine == "codex",
841
+ ),
842
+ "error": None,
843
+ })
844
+ return {"count": len(rows), "workers": rows, "errors": errors}
845
+
846
+ def report(
847
+ self, worker, *, max_chars: int | None = None,
848
+ turn_ref: str | None = None, timeout: float = 30,
849
+ ) -> dict:
850
+ """Read the latest turn's reply, or an explicitly selected completed turn.
851
+
852
+ ``has_final_message`` says whether the selected turn produced a final
853
+ assistant message, independent of ``max_chars`` clipping of ``text``.
854
+ ``note`` explains when a newer turn is running, an earlier reply was
855
+ selected, native history could not be read, or the worker failed.
856
+ """
857
+ from ccx.ops.report import report_worker
858
+ owner=self._uuid_owner(worker)
859
+ if timeout<=0:
860
+ raise InvalidRequest("native report timeout must be positive")
861
+ if max_chars is not None and (type(max_chars) is not int or max_chars<0):
862
+ raise InvalidRequest("report max_chars must be nonnegative integer")
863
+ self._uuid_store.revalidate()
864
+ result=report_worker(
865
+ owner.worker,max_chars=max_chars,turn_ref=turn_ref,
866
+ timeout=timeout,
867
+ )
868
+ background = _background_task_block_for_worker(self._uuid_store, owner)
869
+ return {
870
+ "worker":owner.worker,"worker_id":owner.worker_id,
871
+ "engine":owner.engine,"short":owner.short,
872
+ "text":result["text"],"truncated":result["truncated"],
873
+ "has_final_message":result["has_final_message"],
874
+ "turn":None,"turn_id":result.get("turn_id"),
875
+ "turn_ref":result.get("turn_ref"),
876
+ "delivery_id":result.get("delivery_id"),
877
+ "status":result["status"],
878
+ "source":result["source"],
879
+ "native_evidence_sha256":result.get("native_evidence_sha256"),
880
+ "note":result.get("note"),
881
+ **background,
882
+ }
883
+
884
+ def telemetry(self, worker, *, after_cursor: str | None = None,
885
+ limit: int = 200, timeout: float = 120) -> dict:
886
+ """Read the same source-bound telemetry page as worker.telemetry RPC."""
887
+ from ccx.ops.report import telemetry_worker
888
+ owner = self._uuid_owner(worker)
889
+ return telemetry_worker(
890
+ owner.worker, after_cursor=after_cursor, limit=limit, timeout=timeout,
891
+ )
892
+
893
+ def context(self, worker) -> dict:
894
+ """Read recorded carried-context evidence, never native liveness."""
895
+ owner = self._uuid_owner(worker)
896
+ rec = self._uuid_store.record(owner.worker)
897
+ lifecycle = rec.get("lifecycle") or {}
898
+ native = rec.get("native") or {}
899
+ if owner.engine == "claude":
900
+ observation = read_claude_observation(self._uuid_store, owner, rec)
901
+ block = observation.get("context") if observation else None
902
+ else:
903
+ block = current_context(rec, engine="codex", live=rec.get("context"))
904
+ return {
905
+ "worker": owner.worker, "worker_id": owner.worker_id,
906
+ "engine": owner.engine, "short": owner.short,
907
+ "state": lifecycle.get("state"),
908
+ "native_state": native.get("nativeState"),
909
+ **public_context(
910
+ block, engine=owner.engine,
911
+ compacting=(owner.engine == "codex"
912
+ and isinstance(rec.get("activeCompact"), dict)),
913
+ ),
914
+ **({"note": observation["note"]} if owner.engine == "claude"
915
+ and observation and observation.get("note") else {}),
916
+ }
917
+
918
+ def recap(self, handles) -> dict:
919
+ """Deterministic recorded-only recap of exact full-UUID workers.
920
+
921
+ Reuse the authoritative summaries, owner records and carried-context
922
+ reader. Only valid absent UUIDs become unavailable rows; bad identity,
923
+ generation and observation integrity fail closed. No native probes,
924
+ transcript rendering or lifecycle changes occur.
925
+ """
926
+ summary = self.summaries(handles)
927
+ rows: list[dict] = []
928
+ for item in summary["workers"]:
929
+ handle = item["worker"]
930
+ row = _recap_row(handle, item["engine"], item["short"])
931
+ row["worker_id"] = item["worker_id"]
932
+ row["error"] = item["error"]
933
+ if item["error"] is None:
934
+ rec = self._uuid_store.record(handle)
935
+ lifecycle = rec.get("lifecycle") or {}
936
+ stats = rec.get("stats") or {}
937
+ args = rec.get("createArgs")
938
+ args = args if isinstance(args, dict) else {}
939
+ context = self.context(handle)
940
+ row.update(
941
+ name=rec.get("name"),
942
+ state=_state(lifecycle.get("state")),
943
+ native_state=lifecycle.get("state"),
944
+ turns=stats.get("turns"),
945
+ requested_model=args.get("model"),
946
+ observed_model=item["observed_model"],
947
+ requested_effort=args.get("effort"),
948
+ context_tokens=context["tokens_used"],
949
+ context_as_of_turn_ref=context["as_of_turn_ref"],
950
+ context_as_of_ms=context["as_of_ms"],
951
+ )
952
+ rows.append(row)
953
+ return {
954
+ "count": summary["count"], "workers": rows,
955
+ "errors": summary["errors"], "text": _recap_text(rows),
956
+ }
957
+
958
+ # -- delivery -----------------------------------------------------------
959
+
960
+ def send(
961
+ self, worker, text: str, *, wait: bool = True,
962
+ timeout: float | None = None,
963
+ caps: dict | None = None, variables: dict | None = None,
964
+ variables_from=None, delivery_id: str | None = None,
965
+ ) -> dict:
966
+ """Full UUID facade routes to original engine native send, no alias.
967
+
968
+ Native delivery_id is caller-owned and may be retried only for the
969
+ exact same prompt. Indeterminate original effects are returned as
970
+ typed errors, never changed into a fresh token/provider request.
971
+ """
972
+ owner=self._uuid_owner(worker)
973
+ handle=owner.worker
974
+ if caps is not None:
975
+ _check_caps(caps, engine=owner.engine)
976
+ if variables is not None and variables_from is not None:
977
+ raise InvalidRequest("variables and variables_from are mutually exclusive")
978
+ if variables is not None or variables_from is not None:
979
+ from ccx.controllers import live_vars
980
+ record = self._uuid_store.record(handle)
981
+ live_vars.require_send_variable_permission(owner, record)
982
+ if variables_from is not None:
983
+ source = self._uuid_owner(variables_from)
984
+ attachment = live_vars.prepare_shared_durable_attachment(
985
+ source.engine, source.worker_id,
986
+ )
987
+ else:
988
+ attachment = live_vars.prepare_attachment(variables)
989
+ if attachment is not None and (
990
+ attachment.names or attachment.state_id is not None
991
+ ):
992
+ if live_vars.should_publish_send_attachment(
993
+ self._uuid_store, owner, delivery_id, caps=caps,
994
+ ):
995
+ live_vars.publish_attachment(
996
+ attachment, owner.engine, owner.worker_id,
997
+ )
998
+ names = self._uuid_store.variables_info(handle)["names"]
999
+ text = live_vars.with_variable_hint(text, names)
1000
+ self._uuid_store.revalidate()
1001
+ try:
1002
+ if owner.engine=="claude":
1003
+ native=self._claude.send(
1004
+ handle,text,wait=wait,timeout=timeout,
1005
+ delivery_id=delivery_id,caps=caps,
1006
+ )
1007
+ else:
1008
+ native=self._codex.send(
1009
+ handle,text,wait=wait,timeout=timeout,delivery_id=delivery_id,
1010
+ caps=caps,
1011
+ )
1012
+ except DeliveryRejected as exc:
1013
+ if exc.state=="waiting":
1014
+ # Only a positive native refusal triggers waiting. Recorded
1015
+ # status supplies the pending gate preview, never authority.
1016
+ status=self.status(handle)
1017
+ raise SessionWaiting(
1018
+ f"{owner.engine} worker {owner.short} is waiting on input",
1019
+ short=owner.short,
1020
+ waiting_for=(status.get("pending_input") or {}).get("native"),
1021
+ ) from exc
1022
+ if (owner.engine=="codex" and exc.refusal=="worker-state"
1023
+ and exc.state=="busy"):
1024
+ exc.refusal="busy"
1025
+ raise
1026
+ if owner.engine=="claude":
1027
+ token=native.delivery_id
1028
+ return {
1029
+ "worker":handle,"worker_id":owner.worker_id,
1030
+ "engine":"claude","short":owner.short,
1031
+ "delivery_id":token,
1032
+ "delivery_status":native.delivery_status,
1033
+ "waited":bool(wait),"queued":False,
1034
+ "reply":native.reply,"turn_id":None,
1035
+ "turn_ref":native.turn_ref,
1036
+ "client_user_message_id":None,
1037
+ "settled":native.settled,
1038
+ "state":native.state,
1039
+ }
1040
+ return {
1041
+ "worker":handle,"worker_id":owner.worker_id,
1042
+ "engine":"codex","short":owner.short,
1043
+ "delivery_id":native.delivery_id,
1044
+ "delivery_status":("settled" if native.reply is not None
1045
+ else "accepted"),
1046
+ "waited":bool(wait),"queued":False,
1047
+ "reply":native.reply,"turn_id":native.turn_id,
1048
+ "turn_ref":("ccx-turn:codex:v1:"+native.turn_id
1049
+ if native.turn_id else None),
1050
+ "client_user_message_id":native.client_user_message_id,
1051
+ "settled":native.reply is not None,
1052
+ "state":native.state,
1053
+ }
1054
+
1055
+ def steer(self, worker, text: str, *, wait: bool = True,
1056
+ timeout: float | None = STEER_OBSERVE_DEFAULT_S,
1057
+ caps: dict | None = None,
1058
+ delivery_id: str | None = None) -> dict:
1059
+ """Place one busy-turn instruction; Codex returns on native placement."""
1060
+ owner = self._uuid_owner(worker)
1061
+ if owner.engine == "codex":
1062
+ if caps is not None:
1063
+ _check_caps(caps, engine="codex")
1064
+ try:
1065
+ native = self._codex.steer(
1066
+ owner.worker, text, delivery_id=delivery_id, caps=caps,
1067
+ )
1068
+ except DeliveryRejected as exc:
1069
+ status = self.status(owner.worker)
1070
+ if status["state"] == "waiting":
1071
+ raise SessionWaiting(
1072
+ f"Codex worker {owner.worker} is waiting on input",
1073
+ short=owner.short,
1074
+ waiting_for=(status.get("pending_input") or {}).get("native"),
1075
+ ) from exc
1076
+ raise
1077
+ return {
1078
+ "worker": owner.worker, "worker_id": owner.worker_id,
1079
+ "engine": "codex", "short": owner.short,
1080
+ "accepted": True, "delivery_id": native.delivery_id,
1081
+ "status": "accepted-current-turn", "pending": False,
1082
+ "timed_out": False, "turn_id": native.turn_id,
1083
+ "client_user_message_id": native.client_user_message_id,
1084
+ "recommended_action": None, "state": native.state,
1085
+ }
1086
+ if caps is not None:
1087
+ _check_caps(caps, engine="claude")
1088
+ value = self._claude.steer(
1089
+ owner.worker, text, delivery_id=delivery_id,
1090
+ wait=wait, timeout=timeout, caps=caps,
1091
+ ).as_dict()
1092
+ self._uuid_store.revalidate()
1093
+ return {**value, "turn_id": None, "client_user_message_id": None}
1094
+
1095
+ def _codex_settle(
1096
+ self, owner, deadline: float | None, operation: str, *,
1097
+ gate_id: str, turn_id: str,
1098
+ ) -> str:
1099
+ """Observe the one approved original Codex turn; never decide again."""
1100
+ turn_ref = f"ccx-turn:codex:v1:{turn_id}"
1101
+
1102
+ def incomplete(
1103
+ reason: str, *, state: str | None = None,
1104
+ native_status: str | None = None, native_error=None,
1105
+ ) -> TurnIncomplete:
1106
+ failure = TurnIncomplete(
1107
+ f"{reason}; approval input accepted for gateId={gate_id!r}, "
1108
+ f"turn_ref={turn_ref!r}; never retry the decision",
1109
+ short=owner.short, state=state, turn_id=turn_id,
1110
+ turn_status=native_status, turn_error=native_error,
1111
+ )
1112
+ failure.input_accepted = True
1113
+ failure.request_id = gate_id
1114
+ failure.turn_ref = turn_ref
1115
+ return failure
1116
+
1117
+ from ccx.store.root import GenerationError
1118
+
1119
+ try:
1120
+ observed = self.wait(owner.worker, timeout=_left(deadline))
1121
+ except (CcxError, GenerationError) as exc:
1122
+ raise incomplete("original approved turn observation unavailable") from exc
1123
+ state = observed.get("native_state") or observed.get("state")
1124
+ status = observed.get("status")
1125
+ if status == "waiting" or state == "waiting":
1126
+ gate_error = SessionWaiting(
1127
+ f"Codex worker {owner.short} opened another input gate after "
1128
+ f"{operation}; original {gate_id!r} already accepted, never retry",
1129
+ short=owner.short,
1130
+ waiting_for=(observed.get("pending_input") or {}).get("native"),
1131
+ )
1132
+ gate_error.input_accepted = True
1133
+ gate_error.request_id = gate_id
1134
+ gate_error.turn_ref = turn_ref
1135
+ raise gate_error
1136
+ if observed.get("timed_out") or status == "timeout":
1137
+ raise incomplete("The approved turn did not finish in time; inspect its status", state=state)
1138
+ last = observed.get("last_settled_turn") or {}
1139
+ actual = observed.get("last_turn") or observed.get("lastTurn") or {}
1140
+ if (state != "idle" or status not in ("idle", None)
1141
+ or last.get("turn_ref") != turn_ref
1142
+ or last.get("turn_id") != turn_id
1143
+ or last.get("outcome") != "completed"
1144
+ or actual.get("turnId") != turn_id
1145
+ or actual.get("status") != "completed"):
1146
+ raise incomplete(
1147
+ "Approved turn did not complete as expected; inspect its status",
1148
+ state=state, native_status=actual.get("status"),
1149
+ native_error=actual.get("error"),
1150
+ )
1151
+ remaining = _left(deadline, 30)
1152
+ if remaining is not None and remaining <= 0:
1153
+ raise incomplete("The reply was not available before timeout; inspect the turn",
1154
+ state=state, native_status="completed")
1155
+ try:
1156
+ report = self.report(owner.worker, turn_ref=turn_ref,
1157
+ timeout=remaining if remaining is not None else 30)
1158
+ except (CcxError, GenerationError) as exc:
1159
+ raise incomplete(
1160
+ "original approved turn completed but its exact report is unavailable",
1161
+ state=state, native_status="completed",
1162
+ ) from exc
1163
+ if (report.get("turn_ref") != turn_ref
1164
+ or report.get("turn_id") != turn_id
1165
+ or report.get("status") != "completed"
1166
+ or not isinstance(report.get("text"), str)):
1167
+ raise incomplete(
1168
+ "The approved reply could not be verified; inspect the turn",
1169
+ state=state,
1170
+ )
1171
+ self._uuid_store.revalidate()
1172
+ return report["text"]
1173
+
1174
+ def wait(self, worker, *, timeout: float | None = None,
1175
+ next_turn: bool = False, after_turn: str | None = None,
1176
+ known_input: str | None = None) -> dict:
1177
+ """Read-only full-UUID stability/next-turn verdict; timeout is data.
1178
+
1179
+ Both engines share one native-evidence observer. Claude additionally
1180
+ accepts next-turn/checkpoint/input selectors; Codex rejects them.
1181
+ """
1182
+ from ccx.ops.wait import wait_worker
1183
+
1184
+ owner = self._uuid_owner(worker)
1185
+ result = wait_worker(
1186
+ owner.worker, timeout=timeout, next_turn=next_turn,
1187
+ after_turn=after_turn, known_input=known_input,
1188
+ expected_generation=self._uuid_store.generation,
1189
+ )
1190
+ background = _background_task_block_for_worker(self._uuid_store, owner)
1191
+ # Keep the engine-neutral snake_case views without changing the
1192
+ # canonical worker/ref or interpreting a terminal as a completed reply.
1193
+ state = result.get("state")
1194
+ if isinstance(state, str):
1195
+ result = {**result, "native_state": result.get("native_state", state),
1196
+ "state": normalize_native_state(state)}
1197
+ public = {key: value for key, value in result.items()
1198
+ if key not in {"apiError", "lastTurn", "pendingApprovals"}}
1199
+ return {**public,
1200
+ "api_error": result.get("apiError"),
1201
+ "last_turn": result.get("lastTurn"),
1202
+ "pending_approvals": result.get("pendingApprovals"), **background}
1203
+
1204
+ def answer(
1205
+ self, worker, text: str | None = None, *,
1206
+ request_id: str | None = None,
1207
+ options: list[tuple[int, str]] | None = None,
1208
+ text_answers: list[tuple[int, str]] | None = None,
1209
+ wait: bool = False, timeout: float | None = None,
1210
+ fallback_to_send: bool = False,
1211
+ response_id: str | None = None,
1212
+ ) -> dict:
1213
+ """Answer a Claude question using its existing exact input fence."""
1214
+ owner = self._uuid_owner(worker)
1215
+ if owner.engine != "claude":
1216
+ raise _unsupported(owner.engine, "answer_question", "questions")
1217
+ result = self._claude.answer(
1218
+ owner.worker, text, request_id=request_id, options=options,
1219
+ text_answers=text_answers, wait=wait, timeout=timeout,
1220
+ fallback_to_send=fallback_to_send, response_id=response_id,
1221
+ )
1222
+ return result.as_dict()
1223
+
1224
+ def respond_elicitation(
1225
+ self, worker, action: str, content: dict | None = None, *,
1226
+ request_id: str | None = None, response_id: str | None = None,
1227
+ wait: bool = False, timeout: float | None = None,
1228
+ ) -> dict:
1229
+ """Resolve a Claude MCP form through its existing native handler."""
1230
+ owner = self._uuid_owner(worker)
1231
+ if owner.engine != "claude":
1232
+ raise _unsupported(owner.engine, "respond_elicitation", "MCP forms")
1233
+ result = self._claude.respond_elicitation(
1234
+ owner.worker, action, content, request_id=request_id,
1235
+ response_id=response_id, wait=wait, timeout=timeout,
1236
+ )
1237
+ return result.as_dict()
1238
+
1239
+ def approve(self, worker, *, request_id: str | None = None,
1240
+ wait: bool = False, timeout: float | None = None) -> dict:
1241
+ """Allow the original current permission gate, optionally await its turn."""
1242
+ return self._decision(worker, "allow", request_id, None, wait, timeout)
1243
+
1244
+ def reject(self, worker, *, request_id: str | None = None,
1245
+ reason: str | None = None, wait: bool = False,
1246
+ timeout: float | None = None) -> dict:
1247
+ """Deny the original permission gate without guessing a successor."""
1248
+ return self._decision(worker, "deny", request_id, reason, wait, timeout)
1249
+
1250
+ def _decision(self, worker, decision, request_id, reason, wait, timeout) -> dict:
1251
+ from math import isfinite
1252
+ from ccx.store.root import GenerationError
1253
+
1254
+ if timeout is not None and (
1255
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
1256
+ ):
1257
+ raise InvalidRequest("permission timeout must be positive finite or None")
1258
+ if request_id is not None and (
1259
+ not isinstance(request_id, str) or not request_id
1260
+ or len(request_id) > 256 or "\x00" in request_id
1261
+ ):
1262
+ raise InvalidRequest("Invalid request ID; use the one shown in ccx peek")
1263
+ deadline = _deadline(timeout)
1264
+ owner = self._uuid_owner(worker)
1265
+ if owner.engine == "codex" and reason is not None:
1266
+ raise _unsupported(
1267
+ "codex", "decision_reason",
1268
+ "Codex native approval does not take a rationale",
1269
+ )
1270
+
1271
+ record = self._uuid_store.record(owner.worker)
1272
+ if owner.engine == "claude":
1273
+ waiting = record.get("waitingFor")
1274
+ if (not isinstance(waiting, dict)
1275
+ or pending_input_kind(waiting) != "permission"):
1276
+ raise SessionUnavailable(
1277
+ "no Claude permission request is pending", short=owner.short,
1278
+ )
1279
+ if not isinstance(waiting.get("id"), str) or not waiting["id"]:
1280
+ # Claude Code's own safety checks prompt only in its terminal.
1281
+ raise SessionUnavailable(
1282
+ "this prompt is a Claude Code safety check that can only be "
1283
+ f"answered in its terminal: ccx attach {owner.worker}",
1284
+ short=owner.short,
1285
+ )
1286
+ actual_id = waiting["id"]
1287
+ if request_id is None:
1288
+ request_id = actual_id
1289
+ if request_id != actual_id:
1290
+ raise DeliveryRejected(
1291
+ "permission request_id differs from the current original gate",
1292
+ short=owner.short, refusal="request-id-mismatch",
1293
+ )
1294
+ verb = self._claude.approve if decision == "allow" else self._claude.reject
1295
+ remaining = _left(deadline)
1296
+ if remaining is not None and remaining <= 0:
1297
+ raise TurnIncomplete("permission budget elapsed before response",
1298
+ short=owner.short)
1299
+ result = verb(
1300
+ owner.worker, request_id=request_id, reason=reason,
1301
+ wait=wait, timeout=remaining,
1302
+ )
1303
+ reply = result.reply
1304
+ else:
1305
+ native = record.get("native") or {}
1306
+ original_thread = native.get("threadId")
1307
+ try:
1308
+ source = self._uuid_store.codex_source(owner.worker)
1309
+ activation = self._uuid_store.native_activation(owner.worker)
1310
+ except GenerationError as exc:
1311
+ raise SessionUnavailable(
1312
+ "original Codex approval source unavailable", short=owner.short,
1313
+ ) from exc
1314
+ if (not isinstance(original_thread, str) or not original_thread
1315
+ or native.get("threadSource") != source
1316
+ or activation.get("state") != "confirmed"
1317
+ or activation.get("source") != source
1318
+ or (activation.get("nativeBinding") or {}).get("threadId")
1319
+ != original_thread):
1320
+ raise SessionUnavailable(
1321
+ "original Codex approval thread/source unconfirmed",
1322
+ short=owner.short,
1323
+ )
1324
+ gates = [
1325
+ gate for gate in record.get("pendingApprovals") or []
1326
+ if isinstance(gate, dict)
1327
+ ]
1328
+ if request_id is None:
1329
+ if len(gates) != 1:
1330
+ raise InvalidRequest(
1331
+ "Codex approval requires request_id when zero or multiple "
1332
+ "original gates are pending; inspect pending_input"
1333
+ )
1334
+ request_id = gates[0].get("gateId")
1335
+ matches = [gate for gate in gates if gate.get("gateId") == request_id]
1336
+ if len(matches) != 1:
1337
+ raise DeliveryRejected(
1338
+ "no unique original Codex permission for this CCX gateId; "
1339
+ "inspect pending_input before responding",
1340
+ short=owner.short, refusal="request-id-mismatch",
1341
+ )
1342
+ native_turn_id = matches[0].get("turnId")
1343
+ if (not isinstance(request_id, str) or not request_id
1344
+ or len(request_id) > 128 or "\x00" in request_id
1345
+ or not isinstance(native_turn_id, str) or not native_turn_id):
1346
+ raise SessionUnavailable(
1347
+ "original Codex approval gateId/turnId is untrusted",
1348
+ short=owner.short,
1349
+ )
1350
+ remaining = _left(deadline, 10)
1351
+ if remaining is not None and remaining <= 0:
1352
+ raise TurnIncomplete("approval budget elapsed before native decision",
1353
+ short=owner.short, turn_id=native_turn_id)
1354
+ op = "approve" if decision == "allow" else "reject"
1355
+ if wait:
1356
+ # Reuse the concrete client's original ACK check including the
1357
+ # same native turn; no raw daemon response or second decision.
1358
+ result = self._codex._decision(
1359
+ owner.worker, op, request_id, remaining,
1360
+ expected_turn_id=native_turn_id,
1361
+ )
1362
+ else:
1363
+ verb = self._codex.approve if decision == "allow" else self._codex.reject
1364
+ result = verb(owner.worker, request_id=request_id, timeout=remaining)
1365
+ request_id = result.request_id or request_id
1366
+ reply = (self._codex_settle(
1367
+ owner, deadline, decision, gate_id=request_id,
1368
+ turn_id=native_turn_id,
1369
+ ) if wait else None)
1370
+ self._uuid_store.revalidate()
1371
+ return {
1372
+ "worker": owner.worker, "worker_id": owner.worker_id,
1373
+ "short": owner.short, "engine": owner.engine,
1374
+ "decision": decision, "request_id": request_id, "reply": reply,
1375
+ }
1376
+
1377
+ # -- context management -------------------------------------------------
1378
+
1379
+ @staticmethod
1380
+ def _compact_result_row(owner, result, *, wait: bool = True,
1381
+ before_tokens: int | None = None) -> dict:
1382
+ """Project one original Claude compact result, never an inferred turn."""
1383
+ proven = result.operation_status == "succeeded" and bool(result.compacted)
1384
+ pre = result.pre_tokens if proven else None
1385
+ post = result.post_tokens if proven else None
1386
+ comparable = type(pre) is int and type(post) is int
1387
+ return {
1388
+ "worker": owner.worker, "worker_id": owner.worker_id,
1389
+ "engine": owner.engine, "short": owner.short,
1390
+ "state": normalize_native_state(result.state),
1391
+ "native_state": result.state,
1392
+ "compacted": bool(result.compacted) if wait else False,
1393
+ "changed": (pre != post) if wait and comparable else None,
1394
+ "tokens_before": pre if wait else before_tokens,
1395
+ "tokens_after": post if wait else None,
1396
+ "message": result.message,
1397
+ "operation_id": result.operation_id,
1398
+ "operation_status": result.operation_status,
1399
+ "evidence_id": result.evidence_uuid,
1400
+ "reason": result.reason,
1401
+ }
1402
+
1403
+ def compact(self, worker, *, wait: bool = True,
1404
+ timeout: float | None = None,
1405
+ operation_id: str | None = None) -> dict:
1406
+ """Run one native compact through the exact-owner engine core.
1407
+
1408
+ The one pre-read is recorded, not a fabricated post-compact measurement.
1409
+ """
1410
+ owner = self._uuid_owner(worker)
1411
+ if owner.engine == "codex":
1412
+ from uuid import uuid4
1413
+ from ccx.codex.ops import compact_worker
1414
+ from .errors import CompactFailed, CompactIndeterminate
1415
+ from ccx.store.root import GenerationError, NativeEffectIndeterminate
1416
+
1417
+ before = self.context(owner.worker) # One fenced recorded pre-read.
1418
+ token = str(uuid4()) if operation_id is None else operation_id
1419
+ try:
1420
+ result = compact_worker(
1421
+ owner.worker, operation_id=token, wait=wait, timeout=timeout,
1422
+ )
1423
+ except NativeEffectIndeterminate as exc:
1424
+ raise CompactIndeterminate(
1425
+ f"Codex compact effect unknown; reconcile, never resubmit: {exc}",
1426
+ short=owner.short, operation_id=token, operation_status="unknown",
1427
+ ) from exc
1428
+ except GenerationError as exc:
1429
+ raise SessionUnavailable(
1430
+ f"full-UUID Codex native compact unavailable: {exc}",
1431
+ short=owner.short,
1432
+ ) from exc
1433
+ native_state = (
1434
+ self._uuid_store.record(owner.worker).get("lifecycle") or {}
1435
+ ).get("state")
1436
+ status = result["status"]
1437
+ if wait and status == "failed":
1438
+ raise CompactFailed(
1439
+ result.get("reason") or "Codex native compact turn failed",
1440
+ short=owner.short, state=native_state, operation_id=token,
1441
+ )
1442
+ if status == "rejected":
1443
+ raise DeliveryRejected(
1444
+ result.get("reason") or "Codex compact refused before native admission",
1445
+ short=owner.short, state=native_state,
1446
+ refusal="native-rejected", operation_id=token,
1447
+ )
1448
+ proven = status == "succeeded" and result.get("compacted") is True
1449
+ if wait and not proven:
1450
+ # Main contract: a returned wait=True compact is a settled one.
1451
+ raise CompactIndeterminate(
1452
+ f"Codex compact result {status!r} has no proven native completion",
1453
+ short=owner.short, state=native_state,
1454
+ operation_id=token, operation_status=status,
1455
+ )
1456
+ native_before = result.get("contextBefore")
1457
+ native_after = result.get("contextAfter") if proven else None
1458
+ baseline = native_before if isinstance(native_before, dict) else before
1459
+ pre = baseline.get("tokens_used") if isinstance(baseline, dict) else None
1460
+ post = (native_after.get("tokens_used")
1461
+ if isinstance(native_after, dict) else None)
1462
+ comparable = (proven and type(pre) is int and type(post) is int)
1463
+ return {
1464
+ "worker": owner.worker, "worker_id": owner.worker_id,
1465
+ "engine": "codex", "short": owner.short,
1466
+ "state": normalize_native_state(native_state),
1467
+ "native_state": native_state,
1468
+ "operation_id": token, "operation_status": status,
1469
+ "compacted": proven,
1470
+ "tokens_before": pre if type(pre) is int else None,
1471
+ "tokens_after": post if type(post) is int else None,
1472
+ "changed": (pre != post) if comparable else None,
1473
+ "message": result.get("reason") or f"Codex compact {status}",
1474
+ "reason": result.get("reason"),
1475
+ "evidence_id": result.get("contextItemId") if proven else None,
1476
+ "compact_turn_id": result.get("compactTurnId"),
1477
+ }
1478
+
1479
+ before = self.context(owner.worker)
1480
+ result = self._claude.compact(
1481
+ owner.worker, wait=wait, timeout=timeout, operation_id=operation_id,
1482
+ )
1483
+ return self._compact_result_row(
1484
+ owner, result, wait=wait,
1485
+ before_tokens=before.get("tokens_used") if not wait else None,
1486
+ )
1487
+
1488
+ def compact_status(self, worker, operation_id: str) -> dict:
1489
+ """Read an existing native compaction result without submitting work."""
1490
+ owner = self._uuid_owner(worker)
1491
+ if owner.engine == "claude":
1492
+ result = self._claude.compact_status(owner.worker, operation_id)
1493
+ return self._compact_result_row(owner, result)
1494
+
1495
+ from ccx.codex.ops import compact_observation
1496
+ row = compact_observation(owner.worker, operation_id)
1497
+ self._uuid_store.revalidate()
1498
+ native_state = (self._uuid_store.record(owner.worker).get("lifecycle") or {}).get("state")
1499
+ status = row["status"]
1500
+ proven = status == "succeeded" and row.get("compacted") is True
1501
+ before = row.get("contextBefore")
1502
+ after = row.get("contextAfter") if proven else None
1503
+ pre = before.get("tokens_used") if isinstance(before, dict) else None
1504
+ post = after.get("tokens_used") if isinstance(after, dict) else None
1505
+ comparable = type(pre) is int and type(post) is int
1506
+ return {
1507
+ "worker": owner.worker, "worker_id": owner.worker_id,
1508
+ "engine": owner.engine, "short": owner.short,
1509
+ "state": normalize_native_state(native_state),
1510
+ "native_state": native_state,
1511
+ "operation_id": operation_id, "operation_status": status,
1512
+ "compacted": proven,
1513
+ "tokens_before": pre if type(pre) is int else None,
1514
+ "tokens_after": post if type(post) is int else None,
1515
+ "changed": pre != post if proven and comparable else None,
1516
+ "message": row.get("reason") or f"Codex compact {status}",
1517
+ "reason": row.get("reason"),
1518
+ "evidence_id": row.get("contextItemId") if proven else None,
1519
+ "compact_turn_id": row.get("compactTurnId"),
1520
+ }
1521
+
1522
+ # -- next-turn configuration -------------------------------------------
1523
+
1524
+ def set_model(self, worker, model: str | None, *, timeout: float = 5) -> dict:
1525
+ """Persist the model for future turns without starting a native turn.
1526
+
1527
+ Claude automatically confirms a settled-idle host stop if warm;
1528
+ Codex updates the next original turn in-place without restarting.
1529
+ """
1530
+ return self._set_next_turn_config(worker, "model", model, timeout=timeout)
1531
+
1532
+ def set_effort(self, worker, effort: str | None, *, timeout: float = 5) -> dict:
1533
+ """Persist future-turn effort without changing current model work."""
1534
+ return self._set_next_turn_config(worker, "effort", effort, timeout=timeout)
1535
+
1536
+ def set_name(self, worker, name: str | None) -> dict:
1537
+ """Rename a full-UUID worker with the same CAS used by the CLI."""
1538
+ owner=self._uuid_owner(worker)
1539
+ try:
1540
+ rec=self._uuid_store.rename_worker(
1541
+ owner.worker,engine=owner.engine,name=name,
1542
+ )
1543
+ except Exception as exc:
1544
+ from ccx.store.root import GenerationError
1545
+ if isinstance(exc, GenerationError):
1546
+ raise SessionUnavailable(f"full-UUID rename unavailable: {exc}") from exc
1547
+ raise
1548
+ return {
1549
+ "worker":owner.worker, "worker_id":owner.worker_id,
1550
+ "engine":owner.engine, "short":owner.short,
1551
+ "name":rec["name"], "action":"rename",
1552
+ "record_revision":rec["recordRevision"],
1553
+ }
1554
+
1555
+ def _set_next_turn_config(self, worker, key: str, value: str | None,
1556
+ *, timeout: float) -> dict:
1557
+ """Project one verified owner from its engine's existing config core."""
1558
+ owner = self._uuid_owner(worker)
1559
+ if owner.engine == "claude":
1560
+ configured, restarted = self._claude._set_spawn_config(
1561
+ owner.worker, {key: value}, restart=None, timeout=timeout,
1562
+ )
1563
+ else:
1564
+ configured = (
1565
+ self._codex.set_model(owner.worker, value, timeout=timeout)
1566
+ if key == "model" else
1567
+ self._codex.set_effort(owner.worker, value, timeout=timeout)
1568
+ )
1569
+ restarted = False
1570
+ self._uuid_store.revalidate()
1571
+ return {**configured.as_dict(), "restarted": restarted}
1572
+
1573
+ # -- lifecycle ----------------------------------------------------------
1574
+
1575
+ def resume(self, worker, *, timeout: float | None = 90) -> dict:
1576
+ """Explicit promptless revival of the original engine runtime.
1577
+
1578
+ Claude restarts its proven original session; Codex re-subscribes the
1579
+ original thread. No synthetic model message is created.
1580
+ """
1581
+ owner = self._uuid_owner(worker)
1582
+ from ccx.ops.send import ensure_ready
1583
+ result = ensure_ready(
1584
+ owner.worker, timeout=timeout,
1585
+ expected_generation=self._uuid_store.generation,
1586
+ )
1587
+ if owner.engine == "claude":
1588
+ return {"worker": owner.worker, "worker_id": owner.worker_id,
1589
+ "engine": "claude", "short": owner.short,
1590
+ "session_id": result["session_id"], "state": result["state"],
1591
+ "resumed": result["resumed"]}
1592
+ return {"worker": owner.worker, "worker_id": owner.worker_id,
1593
+ "engine": "codex", "short": owner.short,
1594
+ "thread_id": result["thread_id"], "state": result["state"],
1595
+ "resumed": result["resumed"]}
1596
+
1597
+ def stop(self, worker) -> dict:
1598
+ """Request one exact active turn cancellation; keep the runtime alive.
1599
+
1600
+ ``accepted`` means the native interrupt was requested, not that the
1601
+ original turn ended. ``confirmed``/``outcome`` are separate proof.
1602
+ """
1603
+ from ccx.ops.cancel import stop_worker
1604
+ owner = self._uuid_owner(worker)
1605
+ result = stop_worker(
1606
+ owner.worker, expected_generation=self._uuid_store.generation,
1607
+ )
1608
+ return {
1609
+ **result, "worker": owner.worker, "short": owner.worker_id[:8],
1610
+ "worker_id": owner.worker_id, "engine": owner.engine,
1611
+ "requested": True,
1612
+ "cancelled": result["confirmed"] and result["outcome"] == "interrupted",
1613
+ "turn_id": (result["turn_ref"].rsplit(":", 1)[-1]
1614
+ if owner.engine == "codex" else None),
1615
+ }
1616
+
1617
+ def kill(self, worker, *,
1618
+ timeout: float | None = terminate.KILL_TIMEOUT_DEFAULT_S) -> dict:
1619
+ """Confirm only the exact owned worker runtime is terminated.
1620
+
1621
+ History survives; callable full-UUID resume is not yet integrated.
1622
+ """
1623
+ from ccx.ops.wait import park_worker
1624
+ owner = self._uuid_owner(worker)
1625
+ result = park_worker(owner.worker, timeout=timeout,
1626
+ expected_generation=self._uuid_store.generation)
1627
+ return {**result, "stopped": True}
1628
+
1629
+ # -- event feed ---------------------------------------------------------
1630
+
1631
+ def events(self, worker, *, after: str | None = None,
1632
+ tail: int | None = None, limit: int = event_feed.LIMIT_DEFAULT,
1633
+ kinds=None, wait: float = 0.0) -> dict:
1634
+ """One original full-UUID lifecycle page, optionally held for an append."""
1635
+ from ccx.ops.worker_events import EventCursorInvalid, wait_full_uuid_event_page
1636
+ from ccx.store.root import GenerationError
1637
+ from .errors import CursorInvalidated
1638
+ from ccx.store.identity import UnknownWorker
1639
+
1640
+ owner = self._uuid_owner(worker)
1641
+ try:
1642
+ page = wait_full_uuid_event_page(
1643
+ self._uuid_store, owner.worker, after=after, tail=tail,
1644
+ limit=limit, kinds=list(kinds) if kinds is not None else None,
1645
+ wait=wait,
1646
+ )
1647
+ # Internal semantic bookkeeping has no public lifecycle kind.
1648
+ # Retain the native cursor so subsequent pages remain resumable.
1649
+ if owner.engine == "claude":
1650
+ page["events"] = [event for event in page["events"]
1651
+ if event.get("kind") is not None]
1652
+ return page
1653
+ except EventCursorInvalid as exc:
1654
+ raise CursorInvalidated(f"Event history changed; restart reading without --after: {exc}") from exc
1655
+ except UnknownWorker as exc:
1656
+ raise worker_not_found(owner.worker) from exc
1657
+ except GenerationError as exc:
1658
+ raise SessionUnavailable(f"Events are unavailable; check the worker: {exc}") from exc
1659
+
1660
+ def follow(self, worker, *, after: str | None = None,
1661
+ tail: int | None = None, kinds=None,
1662
+ timeout: float | None = None,
1663
+ heartbeat: float | None = event_feed.HEARTBEAT_DEFAULT_S,
1664
+ ) -> Iterator[dict]:
1665
+ """Replay then follow one owned original event source, even while stopped."""
1666
+ from ccx.ops.worker_events import EventCursorInvalid, follow_full_uuid_events
1667
+ from ccx.store.root import GenerationError
1668
+ from .errors import CursorInvalidated
1669
+ from ccx.store.identity import UnknownWorker
1670
+
1671
+ owner = self._uuid_owner(worker)
1672
+
1673
+ def observations() -> Iterator[dict]:
1674
+ try:
1675
+ for event in follow_full_uuid_events(
1676
+ self._uuid_store, owner.worker, after=after, tail=tail,
1677
+ kinds=list(kinds) if kinds is not None else None,
1678
+ timeout=timeout, heartbeat=heartbeat,
1679
+ ):
1680
+ if owner.engine != "claude" or event.get("kind") is not None:
1681
+ yield event
1682
+ except EventCursorInvalid as exc:
1683
+ raise CursorInvalidated(f"Event history changed; restart reading without --after: {exc}") from exc
1684
+ except UnknownWorker as exc:
1685
+ raise worker_not_found(owner.worker) from exc
1686
+ except GenerationError as exc:
1687
+ raise SessionUnavailable(f"Cannot follow events; check the worker: {exc}") from exc
1688
+
1689
+ return observations()
1690
+
1691
+ # -- conversation -------------------------------------------------------
1692
+
1693
+ def conversation_page(
1694
+ self,
1695
+ worker,
1696
+ *,
1697
+ cursor: str | None = None,
1698
+ source_record_target: int = 40,
1699
+ max_tool_result_bytes: int | None = 16_384,
1700
+ timeout: float = 30,
1701
+ ) -> dict:
1702
+ """Read one bounded normalized native-source conversation page.
1703
+
1704
+ The first call returns the newest source records; older_cursor pages
1705
+ backward. Page sizing is a target over provider/native conversation
1706
+ records, never over rendered messages or normalized children.
1707
+ """
1708
+ from ccx.ops import conversation_paging as paging
1709
+ from ccx.claude.transcript import open_original_claude_source
1710
+ from ccx.store.root import GenerationError
1711
+ from ccx.store.identity import UnknownWorker
1712
+ from .errors import CursorInvalidated
1713
+
1714
+ paging._validate_options(source_record_target, max_tool_result_bytes, timeout)
1715
+ owner = self._uuid_owner(worker)
1716
+ if owner.engine == "codex":
1717
+ from ccx.codex import telemetry as codex_telemetry
1718
+ from .errors import OperationTimeout
1719
+ from ccx.store.identity import UnknownWorker
1720
+
1721
+ # The original journal fold and every budget refetch share one
1722
+ # caller deadline. The transport receives only the time remaining.
1723
+ deadline = time.monotonic() + float(timeout)
1724
+ store = self._uuid_store
1725
+ try:
1726
+ rec, thread_id, _journal, foreign, evidence = (
1727
+ codex_telemetry._original_source(store, owner)
1728
+ )
1729
+ remaining = deadline - time.monotonic()
1730
+ if remaining <= 0:
1731
+ raise OperationTimeout(
1732
+ "Codex conversation page deadline expired", short=owner.short,
1733
+ )
1734
+ page = paging.read_codex_page(
1735
+ rec, owner.worker, root=store.root,
1736
+ generation=store.generation, worker_id=owner.worker_id,
1737
+ thread_id=thread_id, expected_source=store.codex_source(owner.worker),
1738
+ evidence_index=evidence,
1739
+ foreign_evidence=bool(foreign), cursor=cursor,
1740
+ source_record_target=source_record_target,
1741
+ max_tool_result_bytes=max_tool_result_bytes,
1742
+ timeout=remaining,
1743
+ )
1744
+ store.revalidate()
1745
+ return page
1746
+ except UnknownWorker as exc:
1747
+ raise worker_not_found(owner.worker) from exc
1748
+ except (GenerationError, OSError) as exc:
1749
+ raise SessionUnavailable(
1750
+ f"original Codex conversation page source unavailable: {exc}",
1751
+ short=owner.short,
1752
+ ) from exc
1753
+ store = self._uuid_store
1754
+ try:
1755
+ rec = store.record(owner.worker)
1756
+ with open_original_claude_source(store, owner, rec) as source:
1757
+ if source is None:
1758
+ if cursor is not None:
1759
+ raise CursorInvalidated(
1760
+ "original Claude transcript disappeared; restart paging",
1761
+ short=owner.short,
1762
+ )
1763
+ raise SessionNotFound(
1764
+ f"no original Claude transcript for {owner.worker}",
1765
+ short=owner.short,
1766
+ )
1767
+ pinned, source_stat, end_offset, _original_path, _directory = source
1768
+ page = paging.read_claude_page(
1769
+ pinned, owner.worker, root=store.root,
1770
+ generation=store.generation,
1771
+ session_id=rec["native"]["sessionId"],
1772
+ source_stat=source_stat, end_offset=end_offset,
1773
+ cursor=cursor, source_record_target=source_record_target,
1774
+ max_tool_result_bytes=max_tool_result_bytes, timeout=timeout,
1775
+ )
1776
+ store.revalidate()
1777
+ return page
1778
+ except UnknownWorker as exc:
1779
+ raise worker_not_found(owner.worker) from exc
1780
+ except (GenerationError, OSError) as exc:
1781
+ raise SessionUnavailable(
1782
+ f"original Claude conversation page source unavailable: {exc}",
1783
+ short=owner.short,
1784
+ ) from exc
1785
+
1786
+ @contextmanager
1787
+ def _open_full_conversation(self, worker, *, max_tool_result_bytes,
1788
+ timeout, include_native=False,
1789
+ codex_recent_turns: int | None = None,
1790
+ claude_recent_turns: int | None = None,
1791
+ codex_history_db: bool = False):
1792
+ """Build once from the original owner, retaining its native source until exit.
1793
+
1794
+ ``codex_history_db`` reads a Codex thread from Codex's local history
1795
+ database first (``ccx.codex.history_store``), falling back to
1796
+ app-server when that read declines.
1797
+ """
1798
+ from ccx.codex import runtime as codex_runtime
1799
+ from ccx.codex import telemetry as codex_telemetry
1800
+ from ccx.claude.transcript import open_original_claude_source, PinnedNativeSource
1801
+ from ccx.store.root import GenerationError
1802
+ from ccx.store.identity import EngineMismatch, UnknownWorker
1803
+ from .errors import OperationTimeout
1804
+
1805
+ _conv._requested_label(max_tool_result_bytes)
1806
+ if (timeout is not None and (isinstance(timeout, bool)
1807
+ or not isinstance(timeout, (int, float))
1808
+ or not 0 < timeout < float("inf"))):
1809
+ raise InvalidRequest("conversation timeout must be positive and finite")
1810
+ deadline = time.monotonic() + timeout if timeout is not None else None
1811
+
1812
+ def read_budget() -> float:
1813
+ remaining = 120 if deadline is None else deadline - time.monotonic()
1814
+ if remaining <= 0:
1815
+ raise OperationTimeout("conversation read deadline expired")
1816
+ return remaining
1817
+
1818
+ if deadline is not None:
1819
+ read_budget()
1820
+ owner = self._uuid_owner(worker)
1821
+ store = self._uuid_store
1822
+ with ExitStack() as held:
1823
+ # Catch only original source/read failures. The yield (and export's
1824
+ # destination writes) must remain outside this exception mapping.
1825
+ try:
1826
+ native_files = ()
1827
+ if owner.engine == "claude":
1828
+ rec = store.record(owner.worker)
1829
+ source = held.enter_context(
1830
+ open_original_claude_source(store, owner, rec)
1831
+ )
1832
+ if source is None:
1833
+ raise SessionNotFound(
1834
+ f"no original Claude transcript available for {owner.worker}",
1835
+ short=owner.short,
1836
+ )
1837
+ pinned, _stat, end_offset, original_path, _directory = source
1838
+ native = rec.get("native") or {}
1839
+ view = {**rec, "short": owner.short,
1840
+ "sessionId": native.get("sessionId"),
1841
+ "transcript": pinned}
1842
+ after_offset = 0
1843
+ if claude_recent_turns:
1844
+ from ccx.ops.conversation_paging import claude_tail_offset
1845
+ after_offset = claude_tail_offset(
1846
+ pinned, before_offset=end_offset,
1847
+ turns=claude_recent_turns)
1848
+ document = _conv.build_claude(
1849
+ view, state=normalize_native_state(
1850
+ (rec.get("lifecycle") or {}).get("state")),
1851
+ worker=owner.worker,
1852
+ max_tool_result_bytes=max_tool_result_bytes,
1853
+ before_offset=end_offset, after_offset=after_offset,
1854
+ )
1855
+ if after_offset > 0:
1856
+ _mark_recent_window(
1857
+ document, claude_recent_turns,
1858
+ f"showing only the turns since the last {claude_recent_turns} "
1859
+ "user prompts")
1860
+ # Public reference names the verified original pathname;
1861
+ # export uses the still-open native descriptor, not that path.
1862
+ document["nativeArtifacts"][0]["path"] = str(original_path)
1863
+ native_source = pinned
1864
+ else:
1865
+ rec, thread_id, journal, foreign, evidence = (
1866
+ codex_telemetry._original_source(store, owner)
1867
+ )
1868
+ if foreign:
1869
+ # Foreign events cannot prove original-thread coverage.
1870
+ evidence = {**evidence, "status": "degraded", "complete": False,
1871
+ "warnings": [*(evidence.get("warnings") or []),
1872
+ "excluded native evidence from a foreign thread"]}
1873
+ known_empty = rec.get("historyKnownEmpty") is True
1874
+ thread = None
1875
+ if codex_history_db and not known_empty and codex_recent_turns is None:
1876
+ # Codex's local history database; None means fall back.
1877
+ from ccx.codex.history_store import read_thread_db
1878
+ thread = read_thread_db(thread_id)
1879
+ if known_empty:
1880
+ thread = {"id": thread_id, "threadSource": store.codex_source(owner.worker),
1881
+ "turns": []}
1882
+ elif thread is None:
1883
+ # Only rich Codex peek requests a bounded recent window.
1884
+ # Ordinary full conversation, logs and export retain
1885
+ # their unmodified source-read argument contract.
1886
+ recent = ({"limit": codex_recent_turns, "newest_first": True}
1887
+ if codex_recent_turns is not None else {})
1888
+ thread = codex_runtime.read_thread(
1889
+ thread_id, cwd=rec["cwd"], timeout=read_budget(),
1890
+ worker_id=owner.worker_id,
1891
+ expected_source=store.codex_source(owner.worker),
1892
+ **recent,
1893
+ )
1894
+ document = _conv.build_codex(
1895
+ rec, thread, state=normalize_native_state(
1896
+ (rec.get("lifecycle") or {}).get("state")),
1897
+ worker=owner.worker,
1898
+ max_tool_result_bytes=max_tool_result_bytes, evidence=evidence,
1899
+ )
1900
+ if thread.get("historySource") == "codex-history-db":
1901
+ _note_history_db(document, thread)
1902
+ if (codex_recent_turns is not None
1903
+ and len(thread.get("turns") or []) >= codex_recent_turns):
1904
+ _mark_recent_window(
1905
+ document, codex_recent_turns,
1906
+ f"showing only the most recent {codex_recent_turns} native turns")
1907
+ # A proven empty history is a synthetic normalization view,
1908
+ # never evidence that a native thread/read actually occurred.
1909
+ native_source = None if known_empty else thread
1910
+ if include_native and journal.exists:
1911
+ try:
1912
+ fd = codex_evidence._owned_evidence_fd(
1913
+ owner.worker, os.O_RDONLY, store=store,
1914
+ )
1915
+ except FileNotFoundError:
1916
+ # Secondary evidence vanished; do not claim bytes.
1917
+ pass
1918
+ else:
1919
+ held.callback(os.close, fd)
1920
+ native_files = ((
1921
+ "codex-evidence-jsonl", "codex-evidence.jsonl",
1922
+ PinnedNativeSource(fd),
1923
+ ),)
1924
+ store.revalidate()
1925
+ if deadline is not None:
1926
+ read_budget()
1927
+ except UnknownWorker as exc:
1928
+ raise worker_not_found(owner.worker) from exc
1929
+ except (GenerationError, OSError, EngineMismatch) as exc:
1930
+ raise SessionUnavailable(
1931
+ f"original {owner.engine} conversation source unavailable: {exc}",
1932
+ short=owner.short,
1933
+ ) from exc
1934
+ yield owner, document, native_source, native_files
1935
+
1936
+ def conversation(self, worker, *, max_tool_result_bytes: int | None = None,
1937
+ timeout: float | None = None,
1938
+ _recent_codex_turns: int | None = None,
1939
+ _recent_claude_turns: int | None = None,
1940
+ _codex_history_db: bool = False) -> dict:
1941
+ """Read a normalized full history from this original full-UUID owner.
1942
+
1943
+ Claude reads a pinned native JSONL complete-line snapshot; Codex reads
1944
+ its proven native thread without loading a worker or starting a turn.
1945
+ Neither engine uses lifecycle events or a short-keyed transcript cache.
1946
+ """
1947
+ with self._open_full_conversation(
1948
+ worker, max_tool_result_bytes=max_tool_result_bytes,
1949
+ timeout=timeout, codex_recent_turns=_recent_codex_turns,
1950
+ claude_recent_turns=_recent_claude_turns,
1951
+ codex_history_db=_codex_history_db,
1952
+ ) as (_owner, document, _native, _files):
1953
+ return document
1954
+
1955
+ def export_conversation(self, worker, destination, *, include_native: bool = True,
1956
+ max_tool_result_bytes: int | None = None) -> dict:
1957
+ """Write ``conversation.json`` (+ hashed native evidence) atomically."""
1958
+ from ccx.store.root import GenerationError
1959
+
1960
+ with self._open_full_conversation(
1961
+ worker, max_tool_result_bytes=max_tool_result_bytes,
1962
+ timeout=None, include_native=include_native,
1963
+ ) as (owner, document, native, native_files):
1964
+ written = _conv.write_export(
1965
+ document, destination, engine=owner.engine, native_source=native,
1966
+ include_native=include_native, native_files=native_files,
1967
+ )
1968
+ try:
1969
+ self._uuid_store.revalidate()
1970
+ except GenerationError as exc:
1971
+ raise SessionUnavailable(
1972
+ f"original {owner.engine} export generation unavailable: {exc}",
1973
+ short=owner.short,
1974
+ ) from exc
1975
+ return {"worker": owner.worker, "engine": owner.engine,
1976
+ "short": owner.short,
1977
+ "destination": written["destination"],
1978
+ "conversation_path": written["conversation_path"],
1979
+ "native_path": written["native_path"],
1980
+ "native_paths": written["native_paths"],
1981
+ "turn_count": len(document["turns"]),
1982
+ "completeness": document["completeness"],
1983
+ "evidence": document["evidence"]}
1984
+
1985
+ # -- account usage ------------------------------------------------------
1986
+
1987
+ def models(self, engine: str | None = None, *, refresh: bool = False) -> list[dict]:
1988
+ """The models one or both engines offer, as ``ccx models --json``."""
1989
+ from ccx.models import read_models
1990
+ return [catalog.as_dict() for catalog in read_models(engine=engine, refresh=refresh)]
1991
+
1992
+ def usage(self, engine: str | None = None, *, include_tokens: bool = False,
1993
+ timeout: float = 30) -> dict:
1994
+ """Account-scoped quota utilization for one or both providers.
1995
+
1996
+ The normalized ``windows`` list is a convenience, not a claim of
1997
+ equivalence: Claude and Codex quota buckets are provider-specific, so
1998
+ ``raw`` always carries the untouched provider payload.
1999
+
2000
+ ``timeout`` is one end-to-end budget for the whole call, so querying
2001
+ both providers cannot take twice as long as querying one. Neither
2002
+ provider supports unbounded account reads, so it must be a positive
2003
+ number — there is no ``None``.
2004
+ """
2005
+ if engine is not None and engine not in _ENGINES:
2006
+ raise InvalidRequest(f"unknown engine: {engine!r}")
2007
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
2008
+ raise InvalidRequest(
2009
+ f"usage timeout must be a positive number of seconds, "
2010
+ f"got {timeout!r}"
2011
+ )
2012
+ if timeout <= 0:
2013
+ raise InvalidRequest("usage timeout must be greater than zero")
2014
+ deadline = _deadline(timeout)
2015
+ if engine is not None:
2016
+ return self._usage_one(engine, include_tokens, deadline)
2017
+ from ccx.system.engine_binary import resolve_engine_binary, account_signed_out
2018
+ from .errors import EngineCliNotFound
2019
+ out: dict[str, dict] = {}
2020
+ errors: dict[str, dict] = {}
2021
+ skipped: dict[str, dict] = {}
2022
+ for eng in _ENGINES:
2023
+ try:
2024
+ binary = resolve_engine_binary(eng)
2025
+ except EngineCliNotFound as error:
2026
+ skipped[eng] = {"reason": "not_installed", "message": str(error)}
2027
+ continue
2028
+ try:
2029
+ out[eng] = self._usage_one(eng, include_tokens and eng == "codex", deadline)
2030
+ except _PROVIDER_FAILURES as error:
2031
+ if account_signed_out(eng, binary):
2032
+ skipped[eng] = {"reason": "signed_out", "message": f"{eng} is not signed in"}
2033
+ else:
2034
+ errors[eng] = _engine_error(error)
2035
+ return {"usage": out, "errors": errors, "skipped": skipped}
2036
+
2037
+ def _usage_one(self, engine: str, include_tokens: bool,
2038
+ deadline: float | None) -> dict:
2039
+ if engine == "claude" and include_tokens:
2040
+ raise _unsupported(engine, "account_token_activity",
2041
+ "account token-activity reporting")
2042
+ left = _left(deadline)
2043
+ if left is not None and left <= 0:
2044
+ # The other provider already spent the budget. Say so as a typed
2045
+ # per-engine failure rather than borrowing more time.
2046
+ raise SessionUnavailable(
2047
+ f"usage budget exhausted before {engine} was queried"
2048
+ )
2049
+ if engine == "claude":
2050
+ from ccx.system.engine_binary import resolve_engine_binary
2051
+ resolve_engine_binary("claude")
2052
+ data = fetch_usage(timeout=left, max_age=0)
2053
+ if not data:
2054
+ raise SessionUnavailable("could not read Claude account usage")
2055
+ return {"engine": "claude", "kind": "rate_limits",
2056
+ "windows": _claude_windows(usage_summary(data)),
2057
+ "raw": data}
2058
+ result = self._codex.usage(tokens=include_tokens, timeout=left)
2059
+ return {"engine": "codex", "kind": result.kind,
2060
+ "windows": [] if include_tokens else _codex_windows(result.raw),
2061
+ "raw": dict(result.raw)}
2062
+
2063
+
2064
+ def _controller_observed(method_name, function):
2065
+ """Journal original full-UUID mutations when a controller is active."""
2066
+ @wraps(function)
2067
+ def observed(self, *args, **kwargs):
2068
+ from sys import modules
2069
+ controller = modules.get('ccx.controllers.controller')
2070
+ context = getattr(controller, "_active_runtime", None)
2071
+ if context is None or context.get() is None:
2072
+ return function(self, *args, **kwargs)
2073
+ # Static creation refusals remain before any controller ordinal.
2074
+ if method_name == "create":
2075
+ if kwargs.get("engine") not in _ENGINES:
2076
+ raise InvalidRequest(f"unknown worker engine: {kwargs.get('engine')!r}")
2077
+ from ccx.controllers.controller import observe_worker_call
2078
+ return observe_worker_call(self, method_name, function, args, kwargs)
2079
+ return observed
2080
+
2081
+
2082
+ for _controller_method in (
2083
+ "create", "fork", "send", "steer", "resume", "compact", "answer", "respond_elicitation", "approve",
2084
+ "reject", "stop", "kill",
2085
+ ):
2086
+ setattr(
2087
+ WorkerClient,
2088
+ _controller_method,
2089
+ _controller_observed(_controller_method, getattr(WorkerClient, _controller_method)),
2090
+ )
2091
+
2092
+
2093
+ __all__ = ["WorkerClient", "evaluate_quota"]