ccx-native 0.0.1 → 0.1.0

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