ccx-native 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
package/ccx/client.py ADDED
@@ -0,0 +1,1900 @@
1
+ """Direct Python Claude client bound to a sealed active generation.
2
+
3
+ Managed operations use full CCX worker UUIDs and durable delivery identities."""
4
+
5
+ from __future__ import annotations
6
+ import ccx.cli.commands.read as commands_read
7
+ import ccx.cli.commands.turns as commands_turns
8
+
9
+ import os
10
+ import socket
11
+ import time
12
+ from types import SimpleNamespace
13
+ from typing import Iterator
14
+
15
+ from ccx.rpc import rpc as host_rpc
16
+ from ccx.ops import event_feed
17
+ from ccx.claude.delivery import (
18
+ public_receipt as public_semantic_receipt,
19
+ read_receipt as read_semantic_receipt,
20
+ valid_delivery_id as valid_semantic_delivery_id,
21
+ )
22
+ from ccx.ops.compact_ops import (
23
+ get_operation as get_compact_operation,
24
+ new_operation_id as new_compact_operation_id,
25
+ observe_operation as observe_compact_operation,
26
+ )
27
+ from .constants import (
28
+ COMPACT_POLL_S,
29
+ COMPACT_WAIT_DEFAULT_S,
30
+ DEFAULT_IDLE_TIMEOUT_S,
31
+ S,
32
+ STEER_OBSERVE_DEFAULT_S,
33
+ STEER_OBSERVE_POLL_S,
34
+ )
35
+ from .errors import (
36
+ BudgetStopped,
37
+ CompactFailed,
38
+ CompactIndeterminate,
39
+ DeliveryRejected,
40
+ InvalidRequest,
41
+ PayloadTooLarge,
42
+ SessionNotFound,
43
+ SessionUnavailable,
44
+ SessionWaiting,
45
+ SendIndeterminate,
46
+ SteerIndeterminate,
47
+ TurnIncomplete,
48
+ WorkerApiError,
49
+ WorkerRateLimited,
50
+ api_error_from_lifecycle,
51
+ build_worker_api_error,
52
+ worker_not_found,
53
+ )
54
+ from ccx.store.paths import ensure_dirs, events_ndjson_path, sock_path
55
+ from ccx.ops.steer_ops import (
56
+ PUBLIC_ACCEPTED_PENDING as STEER_PENDING,
57
+ PUBLIC_CONSUMED as STEER_CONSUMED,
58
+ PUBLIC_STARTED as STEER_STARTED,
59
+ PUBLIC_UNKNOWN as STEER_UNKNOWN,
60
+ get_delivery as get_steer_delivery,
61
+ new_delivery_id as new_steer_delivery_id,
62
+ observe_delivery as observe_steer_delivery,
63
+ public_delivery as public_steer_delivery,
64
+ valid_delivery_id as valid_steer_delivery_id,
65
+ )
66
+ from ccx.store.records import read_record
67
+ from ccx.ops.terminate import KILL_TIMEOUT_DEFAULT_S
68
+ from ccx.claude.managed_turns import latest_settled_turn, settlement_for_turn
69
+ from ccx.rpc.rpc import (
70
+ _rpc,
71
+ )
72
+ from ccx.ops.semantics import (
73
+ payload_guidance,
74
+ KIND_PERMISSION,
75
+ answer_illegality,
76
+ answer_request_fence,
77
+ elicitation_illegality,
78
+ elicitation_response_problem,
79
+ filter_events_after,
80
+ pending_input_kind,
81
+ wrong_verb_hint,
82
+ question_answer_map,
83
+ question_answer_message,
84
+ )
85
+ from ccx.claude.transcript import (
86
+ render_transcript_json,
87
+ )
88
+ from .types import (
89
+ ClaudeContext,
90
+ ClaudeTask,
91
+ CompactOperationResult,
92
+ Event,
93
+ FiveHourReset,
94
+ Report,
95
+ PendingInput,
96
+ SemanticDeliveryResult,
97
+ SendResult,
98
+ SessionInfo,
99
+ SessionStatus,
100
+ SteerResult,
101
+ Usage,
102
+ WaitResult,
103
+ )
104
+ from ccx.claude.usage import fetch_usage, reset_five_hour_limit, session_reset_status, usage_summary
105
+
106
+
107
+ def _compact_failed(
108
+ operation: dict | None, *, short: str, state: str | None,
109
+ operation_id: str | None,
110
+ ) -> CompactFailed:
111
+ """Build the typed error for a proven compaction failure.
112
+
113
+ The journal already carries the classification, so this only re-types it.
114
+ ``api_error`` stays ``None`` when the conversation itself was the problem —
115
+ a caller retrying on an API blip must not also retry a compaction that can
116
+ never succeed.
117
+ """
118
+ result = (operation or {}).get("result") or {}
119
+ payload = result.get("api_error")
120
+ api_error = None
121
+ if isinstance(payload, dict):
122
+ api_error = build_worker_api_error(
123
+ (operation or {}).get("reason") or "",
124
+ short=short,
125
+ kind=payload.get("error"),
126
+ status=payload.get("status"),
127
+ transient=bool(payload.get("transient")),
128
+ rate_limited=bool(payload.get("rate_limited")),
129
+ request_id=payload.get("request_id"),
130
+ detail=payload.get("text"),
131
+ reset_text=payload.get("reset_text"),
132
+ )
133
+ return CompactFailed(
134
+ "compact failed after acceptance: "
135
+ f"{(operation or {}).get('reason') or 'unknown reason'}",
136
+ short=short, state=state, operation_id=operation_id,
137
+ api_error=api_error,
138
+ )
139
+
140
+
141
+ def _managed_completed_turns(short: str) -> int:
142
+ # Historical private name retained; this is now the managed lifecycle ordinal.
143
+ record = read_record(short) or {}
144
+ return latest_settled_turn(
145
+ short,
146
+ fallback=int((record.get("stats") or {}).get("turns") or 0),
147
+ record_last_settled_at_ms=record.get("lastSettledAt") or record.get("lastTurnAt"),
148
+ )
149
+
150
+
151
+ def _original_input_ack(reply: dict, *, op: str, worker_id: str,
152
+ request_id: str, response_id: str | None) -> None:
153
+ """Classify the existing verified host RPC; a lost reply is not a refusal."""
154
+ if reply.get("ok") is True:
155
+ if reply.get("request_id") == request_id and (
156
+ response_id is None or reply.get("response_id") == response_id):
157
+ return
158
+ raise SendIndeterminate(
159
+ f"{op}: original input ACK has wrong request identity "
160
+ f"(request_id={request_id!r}, response_id={response_id!r})",
161
+ short=worker_id,
162
+ )
163
+ error = str(reply.get("error") or "unknown host refusal")
164
+ if error.startswith(("unreachable:", "unverified host:")):
165
+ raise SessionUnavailable(f"{op}: original host unavailable: {error}",
166
+ short=worker_id)
167
+ if error in ("timeout", "no reply", "bad reply", "reply too large",
168
+ "non-object reply"):
169
+ raise SendIndeterminate(
170
+ f"{op}: native decision reply indeterminate ({error}); "
171
+ f"request_id={request_id!r}, response_id={response_id!r}",
172
+ short=worker_id,
173
+ )
174
+ if error.startswith(("no pending ", "pending input is not ",
175
+ "AskUserQuestion requires ", "MCP elicitation requires ")):
176
+ raise SessionUnavailable(f"{op}: {error}", short=worker_id)
177
+ raise DeliveryRejected(f"{op} rejected: {error}", short=worker_id,
178
+ reason=error, state=reply.get("state"))
179
+
180
+
181
+ class CcxClient:
182
+ """In-process client for ccx-managed Claude Code sessions.
183
+
184
+ Stateless and cheap to construct; all session state lives on disk under
185
+ ``CCX_ROOT`` exactly as the CLI leaves it, so a ``CcxClient`` and the ``ccx``
186
+ command interoperate freely on the same sessions.
187
+ """
188
+
189
+ def _uuid_session_info(self, owner, rec: dict, *,
190
+ inventory_row: dict | None = None) -> SessionInfo:
191
+ """Typed managed-only public fields, never an external/native alias."""
192
+ native = rec.get("native")
193
+ native = native if isinstance(native, dict) else {}
194
+ args = rec.get("createArgs")
195
+ args = args if isinstance(args, dict) else {}
196
+ stats = rec.get("stats")
197
+ stats = stats if isinstance(stats, dict) else {}
198
+ lifecycle = rec.get("lifecycle")
199
+ lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
200
+ return SessionInfo(
201
+ short=owner.short, worker_id=owner.worker_id,
202
+ session_id=native.get("sessionId"), name=rec.get("name"),
203
+ cwd=rec.get("cwd"), state=lifecycle.get("state"),
204
+ model=args.get("model"), effort=args.get("effort"),
205
+ last_turn_at=(inventory_row["last_turn_at"] if inventory_row is not None
206
+ else rec.get("lastTurnAt")),
207
+ created_at=(inventory_row["created_at"] if inventory_row is not None
208
+ else rec.get("createdAt")),
209
+ turns=stats.get("turns"),
210
+ warning=inventory_row.get("warning") if inventory_row is not None else None,
211
+ )
212
+
213
+ def __init__(self) -> None:
214
+ """One fail-closed full-UUID bootstrap; never legacy ensure_dirs."""
215
+ from .constants import CCX_ROOT
216
+ from ccx.store.root import GenerationError, open_or_initialize
217
+ try:
218
+ self._uuid_store, _created = open_or_initialize(CCX_ROOT)
219
+ except (GenerationError, OSError) as exc:
220
+ raise SessionUnavailable(
221
+ f"CCX worker records are unavailable; check CCX_ROOT: {exc}"
222
+ ) from exc
223
+
224
+ # -- internal helpers ---------------------------------------------------
225
+
226
+ def _resolve_record(self, identifier: str, *, live: bool = False) -> dict:
227
+ """Exact full-UUID CCX-owned record; unmanaged Claude inaccessible."""
228
+ if live:
229
+ raise SessionUnavailable(
230
+ "Only CCX-managed Claude workers are supported; use a worker handle"
231
+ )
232
+ return self._resolve_managed(identifier,"inspect")
233
+
234
+ def _resolve_managed(self, identifier: str, op: str) -> dict:
235
+ """Current global owned full UUID only, no native/name/short fallback."""
236
+ from ccx.store.root import GenerationError
237
+ from ccx.store.identity import IdentityError,UnknownWorker
238
+ try:
239
+ self._uuid_store.revalidate()
240
+ owner=self._uuid_store.resolve(identifier)
241
+ if owner.engine!="claude":
242
+ raise InvalidRequest(f"{op}: Claude full-UUID owner required")
243
+ return self._uuid_store.record(owner.worker)
244
+ except UnknownWorker as exc:
245
+ raise worker_not_found(identifier) from exc
246
+ except IdentityError as exc:
247
+ raise InvalidRequest(f"{op}: invalid Claude full UUID: {exc}") from exc
248
+ except GenerationError as exc:
249
+ raise SessionUnavailable(f"{op}: ACTIVE generation changed: {exc}") from exc
250
+
251
+ # -- write paths --------------------------------------------------------
252
+
253
+ def new(
254
+ self, prompt: str | None = None, *, cwd: str | None = None,
255
+ name: str | None = None, worker_id: str | None = None,
256
+ model: str | None = None, effort: str | None = None,
257
+ agent: str | None = None, append_system_prompt: str | None = None,
258
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT_S,
259
+ allowed_tools: str | None = None, disallowed_tools: str | None = None,
260
+ add_dir: list[str] | None = None, permission_mode: str | None = None,
261
+ skip_permissions: bool = True, caps: dict | None = None,
262
+ wait: bool = False, timeout: float | None = None,
263
+ start_timeout: float | None = None,
264
+ ) -> SessionInfo | SendResult:
265
+ """Create one verified native Claude session under its original UUID."""
266
+ from ccx.ops.creation import create
267
+ result = create(
268
+ engine="claude",worker_id=worker_id,cwd=cwd or os.getcwd(),
269
+ prompt=prompt,wait=wait,caps=caps,
270
+ name=name,model=model,effort=effort,agent=agent,
271
+ append_system_prompt=append_system_prompt,
272
+ idle_timeout=idle_timeout,allowed_tools=allowed_tools,
273
+ disallowed_tools=disallowed_tools,add_dir=add_dir,
274
+ permission_mode=permission_mode,
275
+ permissions="full" if skip_permissions else "restricted",
276
+ created_via="python-api",timeout=timeout,
277
+ start_timeout=start_timeout,
278
+ expected_generation=self._uuid_store.generation,
279
+ )
280
+ if wait:
281
+ report = result["report"]
282
+ return SendResult(
283
+ short=result["short"], worker_id=result["worker_id"],
284
+ engine="claude", reply=report["text"], state="idle",
285
+ queued=False, delivery_id=result["delivery_id"],
286
+ delivery_status="settled", settled=True,
287
+ turn_ref=result["turn_ref"],
288
+ )
289
+ if prompt is not None:
290
+ # After a first send, reconstruct the typed public identity from
291
+ # the already-verified create result; a second mutable store read
292
+ # could otherwise lose a freshly minted original worker UUID.
293
+ from ccx.store.identity import WorkerOwner
294
+ return self._uuid_session_info(
295
+ WorkerOwner(result["worker_id"], "claude"), result["record"],
296
+ )
297
+ self._uuid_store.revalidate()
298
+ return self._uuid_session_info(
299
+ self._uuid_store.resolve(result["worker"]), result["record"],
300
+ )
301
+
302
+ def fork(
303
+ self, target: str, prompt: str | None = None, *,
304
+ name: str | None = None, worker_id: str | None = None,
305
+ model: str | None = None, effort: str | None = None,
306
+ caps: dict | None = None, wait: bool = False,
307
+ timeout: float | None = None,
308
+ ) -> SessionInfo | SendResult:
309
+ """Fork one owned Claude history with an optional original first turn."""
310
+ from math import isfinite
311
+ from ccx.ops.forking import fork
312
+ from ccx.store.root import GenerationError
313
+ from ccx.store.identity import IdentityError, UnknownWorker, parse_managed_target
314
+
315
+ if timeout is not None and (
316
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
317
+ ):
318
+ raise InvalidRequest("fork timeout must be positive finite seconds or None")
319
+ deadline = time.monotonic() + timeout if timeout is not None else None
320
+ try:
321
+ source = parse_managed_target(target).qualified("claude")
322
+ result = fork(
323
+ source, worker_id=worker_id, name=name, model=model, effort=effort,
324
+ created_via="python-api", expected_generation=self._uuid_store.generation,
325
+ deadline=deadline, prompt=prompt, caps=caps, wait=wait,
326
+ )
327
+ if wait:
328
+ report = result["report"]
329
+ return SendResult(
330
+ short=result["short"], worker_id=result["worker_id"],
331
+ engine="claude", reply=report["text"], state="idle",
332
+ queued=False, delivery_id=result["delivery_id"],
333
+ delivery_status="settled", settled=True,
334
+ turn_ref=result["turn_ref"],
335
+ )
336
+ if prompt is not None:
337
+ from ccx.store.identity import WorkerOwner
338
+ return self._uuid_session_info(
339
+ WorkerOwner(result["worker_id"], "claude"), result["record"],
340
+ )
341
+ self._uuid_store.revalidate()
342
+ return self._uuid_session_info(
343
+ self._uuid_store.resolve(result["worker"]), result["record"],
344
+ )
345
+ except UnknownWorker as exc:
346
+ raise worker_not_found(target) from exc
347
+ except IdentityError as exc:
348
+ raise InvalidRequest(f"fork: invalid Claude full UUID: {exc}") from exc
349
+ except GenerationError as exc:
350
+ raise SessionUnavailable(f"fork: Claude ACTIVE generation unavailable: {exc}") from exc
351
+
352
+ def send(
353
+ self, short: str, text: str, *, wait: bool | None = None,
354
+ timeout: float | None = None, caps: dict | None = None,
355
+ delivery_id: str | None = None,
356
+ ) -> SendResult:
357
+ """One original full-UUID Claude native delivery, never short-routed.
358
+
359
+ Caller-known delivery_id is immutable. Unknown native effect and
360
+ waited completion timeouts retain this original token; they never
361
+ authorize a second provider effect.
362
+ """
363
+ from uuid import uuid4
364
+ from ccx.ops.send import send_turn
365
+ from ccx.ops.wait import wait_delivery
366
+ from ccx.ops.report import report_worker
367
+ from ccx.store.root import NativeEffectIndeterminate, GenerationError
368
+ from ccx.claude.delivery import ClaudeDeliveryConflict
369
+ from .errors import SendIndeterminate,OperationConflict
370
+ if not isinstance(text,str) or not text.strip():
371
+ raise InvalidRequest("send requires nonempty prompt")
372
+ if timeout is not None and (not isinstance(timeout,(int,float))
373
+ or timeout <= 0):
374
+ raise InvalidRequest("send timeout must be positive or None")
375
+ rec=self._resolve_managed(short,"send")
376
+ worker_id=rec["workerId"]
377
+ handle=f"claude:{worker_id}"
378
+ token=delivery_id if delivery_id is not None else str(uuid4())
379
+ deadline=time.monotonic()+timeout if timeout is not None else None
380
+ try:
381
+ first=send_turn(handle,text,delivery_id=token,mode="new_turn_only",
382
+ cap=caps,expected_generation=self._uuid_store.generation)
383
+ except ClaudeDeliveryConflict as exc:
384
+ raise OperationConflict(
385
+ f"original delivery {token!r} content conflict: {exc}",
386
+ short=worker_id[:8],worker_id=worker_id,
387
+ ) from exc
388
+ except NativeEffectIndeterminate as exc:
389
+ raise SendIndeterminate(
390
+ "native Claude effect unknown; reconcile original token, never "
391
+ f"resend: {exc}",short=worker_id[:8],delivery_id=token,
392
+ delivery_status="submitted-unknown",
393
+ ) from exc
394
+ except GenerationError as exc:
395
+ raise SessionUnavailable(f"Claude native send unavailable: {exc}") from exc
396
+ observed=None
397
+ if wait is not False:
398
+ completed = wait_delivery(
399
+ handle, delivery_id=token,
400
+ timeout=None if deadline is None else max(0., deadline-time.monotonic()),
401
+ expected_generation=self._uuid_store.generation,
402
+ )
403
+ observed = {"status": "settled", "delivery": completed["delivery"]}
404
+ result=observed or first
405
+ delivery=result.get("delivery") or {}
406
+ status=result.get("status") or delivery.get("status")
407
+ if status=="rejected":
408
+ raise DeliveryRejected(
409
+ delivery.get("rejection_message") or "native Claude refused",
410
+ short=worker_id[:8],refusal=delivery.get("rejection_code"),
411
+ delivery_id=token,delivery_status="rejected",
412
+ )
413
+ settled=(bool(first.get("settled")) if observed is None
414
+ else status=="settled")
415
+ reply=None
416
+ if settled:
417
+ reply=(completed["report"]["text"] if observed is not None else
418
+ report_worker(handle,turn_ref=delivery.get("turn_ref"))["text"])
419
+ return SendResult(
420
+ short=worker_id[:8],worker_id=worker_id,engine="claude",
421
+ reply=reply,state=("idle" if settled else "busy"),queued=False,
422
+ delivery_id=token,
423
+ delivery_status=("settled" if settled else status),
424
+ settled=settled,
425
+ turn_ref=delivery.get("turn_ref"),
426
+ )
427
+
428
+ def _semantic_result(
429
+ self, short: str, delivery_id: str, delivery: dict | None, *,
430
+ state: str | None = None,
431
+ ) -> SemanticDeliveryResult:
432
+ """Typed original semantic Claude receipt, full UUID globally owned."""
433
+ owner=self._uuid_store.resolve(short)
434
+ value=delivery or {}
435
+ status=value.get("status") or "pending"
436
+ refusal=value.get("last_refusal") or {}
437
+ return SemanticDeliveryResult(
438
+ short=owner.short,worker_id=owner.worker_id,engine="claude",
439
+ delivery_id=value.get("delivery_id") or delivery_id,
440
+ status=status,accepted=status in ("accepted","settled"),
441
+ owned=bool(value.get("owned")),
442
+ settled=bool(value.get("settlement")),
443
+ recommended_action=value.get("recommended_action"),
444
+ route=value.get("route"),placement=value.get("placement"),
445
+ target_turn_ref=value.get("target_turn_ref"),
446
+ turn_ref=value.get("turn_ref"),
447
+ managed_turn_ordinal=value.get("managed_turn_ordinal"),
448
+ state=state,attempts=int(value.get("attempts") or 0),
449
+ maybe_written=bool(value.get("maybe_written")),
450
+ evidence=value.get("evidence"),
451
+ evidence_uuid=value.get("evidence_uuid"),
452
+ reason=(value.get("rejection_message") or refusal.get("message")
453
+ or value.get("evidence_status")),
454
+ )
455
+
456
+ def deliver(
457
+ self,
458
+ short: str,
459
+ text: str,
460
+ *,
461
+ delivery_id: str,
462
+ mode: str = "auto",
463
+ timeout: float | None = None,
464
+ caps: dict | None = None,
465
+ ) -> SemanticDeliveryResult:
466
+ """Atomically deliver one caller-identified semantic message."""
467
+ from ccx.ops.send import send_turn
468
+ from ccx.claude.delivery import ClaudeDeliveryConflict
469
+ from ccx.store.root import NativeEffectIndeterminate
470
+ from .errors import OperationConflict
471
+ if mode not in {"auto", "new_turn_only"}:
472
+ raise InvalidRequest(f"deliver: unsupported mode {mode!r}")
473
+ if not isinstance(text, str) or not text.strip():
474
+ raise InvalidRequest("deliver requires nonempty prompt")
475
+ violation = commands_turns._send_payload_violation(text)
476
+ if violation is not None:
477
+ size, limit = violation
478
+ raise PayloadTooLarge(
479
+ payload_guidance(short, size, limit),
480
+ short=short, size=size, limit=limit,
481
+ )
482
+ rec = self._resolve_managed(short, "deliver")
483
+ worker_id = rec["workerId"]
484
+ try:
485
+ result = send_turn(
486
+ f"claude:{worker_id}", text, delivery_id=delivery_id,
487
+ mode=mode, cap=caps, timeout=timeout,
488
+ expected_generation=self._uuid_store.generation,
489
+ )
490
+ except ClaudeDeliveryConflict as exc:
491
+ raise OperationConflict(str(exc), short=worker_id[:8]) from exc
492
+ except NativeEffectIndeterminate as exc:
493
+ raise SendIndeterminate(
494
+ f"Claude delivery uncertain: {exc}",
495
+ short=worker_id[:8], delivery_id=delivery_id,
496
+ delivery_status="submitted-unknown",
497
+ ) from exc
498
+ current = self._uuid_store.record(worker_id)
499
+ return self._semantic_result(
500
+ worker_id, delivery_id, result.get("delivery"),
501
+ state=(current.get("lifecycle") or {}).get("state"),
502
+ )
503
+
504
+ def delivery_status(
505
+ self, short: str, delivery_id: str,
506
+ ) -> SemanticDeliveryResult:
507
+ """Read original Claude receipt without re-entering the provider."""
508
+ from ccx.ops.wait import reconcile_turn
509
+ from ccx.store.root import GenerationError
510
+ rec=self._resolve_managed(short,"delivery_status")
511
+ wid=rec["workerId"]
512
+ self._uuid_store.revalidate()
513
+ try:
514
+ verdict=reconcile_turn(f"claude:{wid}",delivery_id=delivery_id)
515
+ except GenerationError as exc:
516
+ raise SessionUnavailable(
517
+ f"Claude delivery unavailable: {exc}"
518
+ ) from exc
519
+ original=verdict.get("delivery")
520
+ if original is None:
521
+ raise SessionNotFound(
522
+ f"no original delivery {delivery_id!r} for Claude {wid}"
523
+ )
524
+ latest=self._uuid_store.record(wid)
525
+ return self._semantic_result(
526
+ wid,delivery_id,original,
527
+ state=(latest.get("lifecycle") or {}).get("state"),
528
+ )
529
+
530
+ @staticmethod
531
+ def _input_deadline(timeout: float | None) -> float | None:
532
+ from math import isfinite
533
+ if timeout is not None and (
534
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
535
+ ):
536
+ raise InvalidRequest("input timeout must be positive and finite or None")
537
+ return None if timeout is None else time.monotonic() + timeout
538
+
539
+ @staticmethod
540
+ def _input_remaining(deadline: float | None, cap: float | None = None) -> float | None:
541
+ if deadline is None:
542
+ return cap
543
+ remaining = deadline - time.monotonic()
544
+ if remaining <= 0:
545
+ raise TurnIncomplete("input deadline exhausted")
546
+ return remaining if cap is None else min(cap, remaining)
547
+
548
+ def _input_turn_fence(
549
+ self, worker_id: str, record: dict, request_id: str,
550
+ kind: str, deadline: float | None,
551
+ ) -> tuple[str, str | None]:
552
+ """Bind the already-running original turn while its native gate is open."""
553
+ from ccx.store.root import GenerationError
554
+
555
+ sid = (record.get("native") or {}).get("sessionId")
556
+ try:
557
+ activation = self._uuid_store.native_activation(worker_id)
558
+ except GenerationError as exc:
559
+ raise SessionUnavailable("input original native claim unavailable") from exc
560
+ if (not isinstance(sid, str) or not sid
561
+ or activation.get("state") != "confirmed"
562
+ or activation.get("source") != f"claude-session:{sid}"
563
+ or (activation.get("nativeBinding") or {}).get("sessionId") != sid):
564
+ raise SessionUnavailable("Claude input source could not be verified; check the worker",
565
+ short=worker_id[:8])
566
+ status = _rpc(worker_id, {"op": "status"},
567
+ timeout=self._input_remaining(deadline, 5.0))
568
+ waiting = status.get("waitingFor")
569
+ ref = status.get("activeTurnRef")
570
+ if (status.get("ok") is not True or status.get("state") != "waiting"
571
+ or not isinstance(waiting, dict)
572
+ or waiting.get("id") != request_id
573
+ or pending_input_kind(waiting) != kind):
574
+ raise SessionUnavailable("Claude is not accepting input; check its pending request",
575
+ short=worker_id[:8])
576
+ if (not isinstance(ref, str)
577
+ or not ref.startswith("ccx-turn:claude:v1:")
578
+ or len(ref) <= len("ccx-turn:claude:v1:")):
579
+ raise SessionUnavailable("Claude turn cannot be identified; inspect the worker",
580
+ short=worker_id[:8])
581
+ prior = self._uuid_store.record(worker_id).get("lastSettledTurn") or {}
582
+ prior_ref = prior.get("turnRef")
583
+ if not (isinstance(prior_ref, str)
584
+ and prior_ref.startswith("ccx-turn:claude:v1:")):
585
+ prior_ref = None
586
+ self._uuid_store.revalidate()
587
+ return ref, prior_ref
588
+
589
+ def _input_wait_reply(
590
+ self, worker_id: str, request_id: str, response_id: str | None,
591
+ turn_ref: str, prior_ref: str | None, deadline: float | None,
592
+ ) -> SendResult:
593
+ """Observe only the original native R; never send another input."""
594
+ from ccx.ops.wait import wait_worker
595
+ from ccx.ops.report import report_worker
596
+ from .errors import CcxError
597
+ from ccx.store.root import GenerationError
598
+
599
+ def incomplete(why: str, *, state: str | None = None) -> TurnIncomplete:
600
+ err = TurnIncomplete(
601
+ f"{why}; input accepted, never retry: request_id={request_id!r}, "
602
+ f"response_id={response_id!r}, turn_ref={turn_ref!r}",
603
+ short=worker_id[:8], state=state, turn_id=turn_ref,
604
+ )
605
+ err.input_accepted = True
606
+ err.request_id = request_id
607
+ err.response_id = response_id
608
+ err.turn_ref = turn_ref
609
+ return err
610
+
611
+ current = self._uuid_store.record(worker_id)
612
+ settled = current.get("lastSettledTurn") or {}
613
+ if settled.get("turnRef") != turn_ref:
614
+ budget = None if deadline is None else max(0.0, deadline - time.monotonic())
615
+ try:
616
+ verdict = wait_worker(
617
+ f"claude:{worker_id}", next_turn=True, after_turn=prior_ref,
618
+ timeout=budget, expected_generation=self._uuid_store.generation,
619
+ )
620
+ except (CcxError, GenerationError) as exc:
621
+ raise incomplete("original resumed turn observation unavailable") from exc
622
+ latest = verdict.get("last_settled_turn") or {}
623
+ if latest.get("turn_ref") != turn_ref:
624
+ if verdict.get("status") == "waiting":
625
+ raise SessionWaiting(
626
+ "new input gate opened before the original turn settled",
627
+ short=worker_id[:8], waiting_for=verdict.get("waiting_for"),
628
+ )
629
+ if verdict.get("status") == "budget-stopped":
630
+ raise BudgetStopped("original resumed turn stopped on budget",
631
+ short=worker_id[:8], phase="turn")
632
+ if verdict.get("status") in ("dead", "crashed", "host-dead"):
633
+ error = api_error_from_lifecycle(
634
+ (self._uuid_store.record(worker_id).get("lifecycle") or {}),
635
+ short=worker_id[:8],
636
+ )
637
+ if error is not None:
638
+ raise error
639
+ raise incomplete(
640
+ "original resumed turn not proven settled",
641
+ state=verdict.get("state"),
642
+ )
643
+ settled = {"turnRef": latest["turn_ref"], "outcome": latest.get("outcome")}
644
+ if settled.get("outcome") != "completed":
645
+ raise incomplete(
646
+ "The resumed turn did not finish "
647
+ f"({settled.get('outcome') or 'unknown'})"
648
+ )
649
+ try:
650
+ report = report_worker(f"claude:{worker_id}", turn_ref=turn_ref)
651
+ except (CcxError, GenerationError) as exc:
652
+ raise incomplete(
653
+ "original input turn completed but its exact reply report is unavailable"
654
+ ) from exc
655
+ if (report.get("turn_ref") != turn_ref
656
+ or report.get("status") != "completed"
657
+ or not isinstance(report.get("text"), str)):
658
+ raise incomplete("The reply is not confirmed complete; retry or inspect the turn")
659
+ return SendResult(
660
+ short=worker_id[:8], worker_id=worker_id, engine="claude",
661
+ reply=report["text"], transcript=None, state=S.IDLE,
662
+ queued=False, settled=True, turn_ref=turn_ref,
663
+ )
664
+
665
+ def _require_dead_original_question_host(
666
+ self, real: str, rec: dict, *, response_id: str | None,
667
+ ) -> None:
668
+ """Prove original QUESTION host died before any native answer effect."""
669
+ from ccx.store.liveness import owned_process
670
+ from ccx.store.root import GenerationError
671
+ lc = rec.get("lifecycle") or {}
672
+ if (not lc.get("hostPid") or owned_process(
673
+ lc.get("hostPid"), lc.get("hostStart")) != "dead"):
674
+ raise SessionUnavailable(
675
+ "Cannot confirm Claude stopped; check its status before sending another answer",
676
+ short=real,
677
+ )
678
+ native = rec.get("native") or {}
679
+ try:
680
+ activation = self._uuid_store.native_activation(real)
681
+ except GenerationError as exc:
682
+ raise SessionUnavailable("Cannot verify this question belongs to the worker; check its status") from exc
683
+ sid = native.get("sessionId")
684
+ if (not isinstance(sid, str) or not sid
685
+ or activation.get("state") != "confirmed"
686
+ or activation.get("source") != f"claude-session:{sid}"
687
+ or (activation.get("nativeBinding") or {}).get("sessionId") != sid):
688
+ raise SessionUnavailable("Cannot verify this question belongs to the worker; check its status", short=real)
689
+ if response_id is not None and self._uuid_store.read_receipt(
690
+ real, "answer", response_id,
691
+ ) is not None:
692
+ raise SessionUnavailable(
693
+ "This response ID was already used; "
694
+ "reconcile it, never convert to another message", short=real,
695
+ )
696
+ self._uuid_store.revalidate()
697
+
698
+ def answer(
699
+ self,
700
+ short: str,
701
+ text: str | None = None,
702
+ *,
703
+ request_id: str | None = None,
704
+ options: list[tuple[int, str]] | None = None,
705
+ text_answers: list[tuple[int, str]] | None = None,
706
+ wait: bool = False,
707
+ timeout: float | None = None,
708
+ fallback_to_send: bool = False,
709
+ response_id: str | None = None,
710
+ ) -> SendResult:
711
+ """Answer the exact current original Claude question, optionally wait.
712
+
713
+ A missing request_id binds the currently brokered question once.
714
+ A dead original host may become a NEW strict message only with
715
+ fallback_to_send=True and positive pre-response no-effect evidence.
716
+ """
717
+ deadline = self._input_deadline(timeout)
718
+ if request_id is not None and (
719
+ not isinstance(request_id, str) or not request_id or len(request_id) > 256
720
+ ):
721
+ raise InvalidRequest("answer: request_id must be a nonempty bounded string")
722
+ rec = self._resolve_managed(short, "answer")
723
+ real = rec["workerId"]
724
+ question = rec.get("waitingFor")
725
+ illegality = answer_illegality(question, real)
726
+ if illegality:
727
+ error_type = DeliveryRejected if pending_input_kind(question) is None else SessionUnavailable
728
+ raise error_type(illegality, short=real)
729
+ if not isinstance(question, dict) or not question.get("id"):
730
+ raise SessionUnavailable("answer: no brokered question request ID", short=real)
731
+ if request_id is None:
732
+ request_id = question["id"]
733
+ if not isinstance(request_id, str) or len(request_id) > 256:
734
+ raise InvalidRequest("answer: original request_id must be bounded")
735
+ fence = answer_request_fence(question, request_id)
736
+ if fence:
737
+ raise DeliveryRejected(
738
+ f"answer rejected: {fence} (expected {request_id!r}, "
739
+ f"pending {question.get('id')!r})",
740
+ short=real, reason=fence,
741
+ state=(rec.get("lifecycle") or {}).get("state"),
742
+ )
743
+
744
+ answers, err = question_answer_map(
745
+ (question.get("input") or {}).get("questions") or [],
746
+ text=text, options=options, text_answers=text_answers,
747
+ )
748
+ if err:
749
+ raise InvalidRequest(f"answer: {err}", short=real)
750
+ if not answers:
751
+ raise InvalidRequest("answer: answer text is empty", short=real)
752
+
753
+ if not host_rpc.host_socket_live(real, rec):
754
+ if not fallback_to_send:
755
+ raise SessionUnavailable(
756
+ "answer: original host unavailable; fallback_to_send=True "
757
+ "only when its owned process is confirmed dead", short=real,
758
+ )
759
+ self._require_dead_original_question_host(
760
+ real, rec, response_id=response_id,
761
+ )
762
+ return self.send(
763
+ real, question_answer_message(answers), wait=wait,
764
+ timeout=self._input_remaining(deadline), caps=None,
765
+ )
766
+ turn_fence = self._input_turn_fence(
767
+ real, rec, request_id, "question", deadline,
768
+ ) if wait else None
769
+ msg = {"op": "answer-permission", "answers": answers, "request_id": request_id}
770
+ if response_id is not None:
771
+ msg["response_id"] = response_id
772
+ reply = _rpc(real, msg, timeout=self._input_remaining(deadline, 5.0))
773
+ _original_input_ack(reply, op="answer", worker_id=real,
774
+ request_id=request_id, response_id=response_id)
775
+ if turn_fence is not None:
776
+ return self._input_wait_reply(real, request_id, response_id,
777
+ *turn_fence, deadline)
778
+ return SendResult(short=real[:8], worker_id=real, engine="claude",
779
+ queued=True, state=S.BUSY)
780
+
781
+ def respond_elicitation(
782
+ self,
783
+ short: str,
784
+ action: str,
785
+ content: dict | None = None,
786
+ *,
787
+ request_id: str | None = None,
788
+ response_id: str | None = None,
789
+ wait: bool = False,
790
+ timeout: float | None = None,
791
+ ) -> SendResult:
792
+ """Resolve the exact original MCP elicitation, optionally wait for R.
793
+
794
+ Omitted request_id binds the current brokered elicitation once.
795
+ The response is schema-fenced; there is no new-message fallback.
796
+ """
797
+ deadline = self._input_deadline(timeout)
798
+ if request_id is not None and (
799
+ not isinstance(request_id, str) or not request_id or len(request_id) > 256
800
+ ):
801
+ raise InvalidRequest("respond: request_id must be a nonempty bounded string")
802
+ rec = self._resolve_managed(short, "respond")
803
+ real = rec["workerId"]
804
+ pending = rec.get("waitingFor")
805
+ illegality = elicitation_illegality(pending, real)
806
+ if illegality:
807
+ error_type = DeliveryRejected if pending_input_kind(pending) is None else SessionUnavailable
808
+ raise error_type(illegality, short=real)
809
+ if not isinstance(pending, dict) or not pending.get("id"):
810
+ raise SessionUnavailable("respond: no brokered elicitation request ID", short=real)
811
+ if request_id is None:
812
+ request_id = pending["id"]
813
+ if not isinstance(request_id, str) or len(request_id) > 256:
814
+ raise InvalidRequest("respond: original request_id must be bounded")
815
+ fence = answer_request_fence(pending, request_id)
816
+ if fence:
817
+ raise DeliveryRejected(
818
+ f"respond rejected: {fence} (expected {request_id!r}, "
819
+ f"pending {pending.get('id')!r})",
820
+ short=real, reason=fence,
821
+ state=(rec.get("lifecycle") or {}).get("state"),
822
+ )
823
+ problem = elicitation_response_problem(pending, action, content)
824
+ if problem:
825
+ raise InvalidRequest(f"respond: {problem}", short=real)
826
+ if not host_rpc.host_socket_live(real, rec):
827
+ raise SessionUnavailable(
828
+ f"respond: session host not alive "
829
+ f"(state={(rec.get('lifecycle') or {}).get('state')}); the MCP "
830
+ "request died with it and cannot be answered",
831
+ short=real,
832
+ )
833
+
834
+ turn_fence = self._input_turn_fence(
835
+ real, rec, request_id, "elicitation", deadline,
836
+ ) if wait else None
837
+ msg: dict = {"op": "elicitation-response", "action": action,
838
+ "request_id": request_id}
839
+ if content is not None:
840
+ msg["content"] = content
841
+ if response_id is not None:
842
+ msg["response_id"] = response_id
843
+ reply = _rpc(real, msg, timeout=self._input_remaining(deadline, 5.0))
844
+ _original_input_ack(reply, op="respond", worker_id=real,
845
+ request_id=request_id, response_id=response_id)
846
+ if turn_fence is not None:
847
+ return self._input_wait_reply(real, request_id, response_id,
848
+ *turn_fence, deadline)
849
+ return SendResult(short=real[:8], worker_id=real, engine="claude",
850
+ queued=True, state=S.BUSY)
851
+
852
+ def approve(
853
+ self,
854
+ short: str,
855
+ *,
856
+ request_id: str | None = None,
857
+ reason: str | None = None,
858
+ wait: bool = False,
859
+ timeout: float | None = None,
860
+ response_id: str | None = None,
861
+ ) -> SendResult:
862
+ """Allow one original Claude permission, optionally await its turn.
863
+
864
+ The current brokered gate binds an omitted request_id once.
865
+ """
866
+ return self._permission_decision(
867
+ short, "allow", reason=reason, request_id=request_id,
868
+ wait=wait, timeout=timeout, response_id=response_id,
869
+ )
870
+
871
+ def reject(
872
+ self,
873
+ short: str,
874
+ *,
875
+ request_id: str | None = None,
876
+ reason: str | None = None,
877
+ wait: bool = False,
878
+ timeout: float | None = None,
879
+ response_id: str | None = None,
880
+ ) -> SendResult:
881
+ """Deny one original Claude permission, optionally await its turn.
882
+
883
+ A reason is passed to the original host gate unchanged.
884
+ """
885
+ return self._permission_decision(
886
+ short, "deny", reason=reason, request_id=request_id,
887
+ wait=wait, timeout=timeout, response_id=response_id,
888
+ )
889
+
890
+ def _permission_decision(
891
+ self,
892
+ short: str,
893
+ decision: str,
894
+ *,
895
+ reason: str | None,
896
+ request_id: str | None,
897
+ wait: bool,
898
+ timeout: float | None,
899
+ response_id: str | None = None,
900
+ ) -> SendResult:
901
+ """Resolve exactly one native permission and optionally observe its turn."""
902
+ op = "approve" if decision == "allow" else "reject"
903
+ deadline = self._input_deadline(timeout)
904
+ if request_id is not None and (
905
+ not isinstance(request_id, str) or not request_id or len(request_id) > 256
906
+ ):
907
+ raise InvalidRequest(f"{op}: request_id must be a nonempty bounded string")
908
+ rec = self._resolve_managed(short, op)
909
+ real = rec["workerId"]
910
+ # A decision against an AskUserQuestion or an MCP elicitation is the
911
+ # wrong verb; the host would reject it. Fail early with the same
912
+ # guidance the CLI prints.
913
+ wf = rec.get("waitingFor")
914
+ kind = pending_input_kind(wf)
915
+ if kind not in (None, KIND_PERMISSION):
916
+ what = ("an AskUserQuestion answer" if kind == "question"
917
+ else "an MCP elicitation response")
918
+ verb = "respond_elicitation" if kind == "elicitation" else wrong_verb_hint(kind)
919
+ raise SessionUnavailable(
920
+ f"session {real} is waiting for {what}; use {verb} instead",
921
+ short=real,
922
+ )
923
+
924
+ if not isinstance(wf, dict) or not wf.get("id"):
925
+ raise DeliveryRejected(f"{op}: no brokered permission request", short=real)
926
+ if not host_rpc.host_socket_live(real, rec):
927
+ raise SessionUnavailable(
928
+ f"{op}: session host not alive (state={(rec.get('lifecycle') or {}).get('state')})",
929
+ short=real,
930
+ )
931
+ if request_id is None:
932
+ request_id = wf["id"]
933
+ if not isinstance(request_id, str) or len(request_id) > 256:
934
+ raise InvalidRequest(f"{op}: original request_id must be bounded")
935
+ fence = answer_request_fence(wf, request_id)
936
+ if fence:
937
+ raise DeliveryRejected(f"{op}: {fence}", short=real, reason=fence)
938
+ turn_fence = self._input_turn_fence(
939
+ real, rec, request_id, "permission", deadline,
940
+ ) if wait else None
941
+ msg: dict = {"op": "permission-decision", "decision": decision,
942
+ "request_id": request_id}
943
+ if reason:
944
+ msg["reason"] = reason
945
+ if response_id is not None:
946
+ msg["response_id"] = response_id
947
+ reply = _rpc(real, msg, timeout=self._input_remaining(deadline, 5.0))
948
+ _original_input_ack(reply, op=op, worker_id=real,
949
+ request_id=request_id, response_id=response_id)
950
+ if turn_fence is not None:
951
+ return self._input_wait_reply(real, request_id, response_id,
952
+ *turn_fence, deadline)
953
+ return SendResult(short=real[:8], worker_id=real, engine="claude",
954
+ queued=True, state=S.BUSY)
955
+
956
+ def _steer_result(self, short: str, delivery_id: str, delivery: dict | None,
957
+ *, state: str | None, timed_out: bool) -> SteerResult:
958
+ owner = self._uuid_store.resolve(short)
959
+ value = delivery or {}
960
+ state_key = value.get("status")
961
+ placement = value.get("placement")
962
+ if state_key == "accepted":
963
+ status = ("consumed-current-turn" if placement == "current_turn"
964
+ else "started-next-turn" if placement == "new_turn"
965
+ else "unknown")
966
+ elif state_key == "pending" and value.get("owned"):
967
+ status = ("unknown" if value.get("evidence_status") == "unknown"
968
+ else "accepted-pending")
969
+ else:
970
+ status = state_key or "unknown"
971
+ owned = bool(value.get("owned"))
972
+ refusal = value.get("last_refusal") or {}
973
+ return SteerResult(
974
+ short=owner.short, worker_id=owner.worker_id, engine="claude",
975
+ delivery_id=value.get("delivery_id") or delivery_id,
976
+ accepted=owned, status=status,
977
+ timed_out=timed_out, state=state,
978
+ pending=owned and status == "accepted-pending",
979
+ evidence=value.get("evidence"),
980
+ evidence_uuid=value.get("evidence_uuid"),
981
+ attempts=value.get("attempts"),
982
+ reason=(value.get("rejection_message") or refusal.get("message")
983
+ or value.get("evidence_status")),
984
+ recommended_action=("wait" if timed_out else
985
+ value.get("recommended_action")),
986
+ )
987
+
988
+ def steer(
989
+ self,
990
+ short: str,
991
+ text: str,
992
+ *,
993
+ delivery_id: str | None = None,
994
+ wait: bool = True,
995
+ timeout: float | None = STEER_OBSERVE_DEFAULT_S,
996
+ caps: dict | None = None,
997
+ ) -> SteerResult:
998
+ """Steer a BUSY managed worker with a durably-delivered prompt.
999
+
1000
+ This is deliberately distinct from :meth:`send` (which starts a strict
1001
+ new turn on an idle worker) and from :meth:`interrupt`: it aims a
1002
+ prompt at a worker that is already working, with exactly-once delivery.
1003
+
1004
+ The prompt and its transcript fence are journaled atomically before the
1005
+ host acknowledges ownership, so ``accepted=True`` means CCX owns the
1006
+ delivery even if this process dies immediately afterwards. Retrying
1007
+ with the same ``delivery_id`` reconciles the original delivery and can
1008
+ never paste the prompt a second time — that is the correct response to
1009
+ a lost RPC reply.
1010
+
1011
+ With ``wait=True`` the call observes for up to ``timeout`` seconds for
1012
+ stronger placement evidence. ``status`` speaks one vocabulary:
1013
+ ``accepted-pending`` (durably owned, not visibly placed yet),
1014
+ ``consumed-current-turn`` (the running turn saw it),
1015
+ ``started-next-turn`` (it runs next), or ``unknown``. Observation
1016
+ elapsing is NOT a failure: the result has ``accepted=True``,
1017
+ ``status="accepted-pending"``, ``timed_out=True``, ``pending=True`` and
1018
+ ``recommended_action="wait"``. Never resend on that outcome; use
1019
+ :meth:`steer_status` (or ``ccx steer-status``) for placement.
1020
+
1021
+ ``caps`` is a per-message Claude quota cap, exactly as in :meth:`send`.
1022
+ It travels with this delivery — it is journaled with the prompt and
1023
+ armed only for the turn the steered text joins — so it survives a host
1024
+ restart without authorizing a second paste.
1025
+
1026
+ Raises :class:`~ccx.errors.DeliveryRejected` if the host refuses the
1027
+ steer (for example the worker is idle, waiting, or compacting), and
1028
+ :class:`~ccx.errors.SteerIndeterminate` when durable acceptance cannot
1029
+ be established or an accepted delivery resolves to ``unknown``.
1030
+ """
1031
+ from math import isfinite
1032
+ from uuid import uuid4
1033
+ from ccx.ops.send import send_turn
1034
+ from ccx.ops.wait import reconcile_turn
1035
+ from ccx.claude.delivery import ClaudeDeliveryConflict, valid_delivery_id
1036
+ from ccx.store.root import NativeEffectIndeterminate
1037
+ from .errors import OperationConflict
1038
+
1039
+ if timeout is not None and (
1040
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout < 0
1041
+ ):
1042
+ raise InvalidRequest("steer observation timeout must be finite and nonnegative")
1043
+ if not isinstance(text, str) or not text.strip():
1044
+ raise InvalidRequest("steer: prompt text is required")
1045
+ violation = commands_turns._send_payload_violation(text)
1046
+ if violation is not None:
1047
+ size, limit = violation
1048
+ raise PayloadTooLarge(
1049
+ payload_guidance(short, size, limit), short=short,
1050
+ size=size, limit=limit,
1051
+ )
1052
+ token = delivery_id if delivery_id is not None else str(uuid4())
1053
+ if not valid_delivery_id(token):
1054
+ raise InvalidRequest("steer requires a nonempty delivery_id of at most 128 characters")
1055
+ rec = self._resolve_managed(short, "steer")
1056
+ worker_id = rec["workerId"]
1057
+ handle = f"claude:{worker_id}"
1058
+ try:
1059
+ first = send_turn(
1060
+ handle, text, delivery_id=token, mode="steer_only", cap=caps,
1061
+ expected_generation=self._uuid_store.generation,
1062
+ )
1063
+ except ClaudeDeliveryConflict as exc:
1064
+ raise OperationConflict(str(exc), short=worker_id[:8]) from exc
1065
+ except NativeEffectIndeterminate as exc:
1066
+ raise SteerIndeterminate(
1067
+ f"steer native effect uncertain; reconcile original delivery {token}: {exc}",
1068
+ short=worker_id[:8], delivery_id=token,
1069
+ delivery_status="unknown",
1070
+ ) from exc
1071
+
1072
+ def project(original: dict, *, timed_out: bool) -> SteerResult:
1073
+ delivery = original.get("delivery") or {}
1074
+ lifecycle = self._uuid_store.record(worker_id).get("lifecycle") or {}
1075
+ value = self._steer_result(
1076
+ handle, token, delivery,
1077
+ state=lifecycle.get("state"), timed_out=timed_out,
1078
+ )
1079
+ if value.status == "rejected":
1080
+ raise DeliveryRejected(
1081
+ delivery.get("rejection_message") or "Claude refused steer",
1082
+ short=worker_id[:8], delivery_id=token,
1083
+ delivery_status="rejected",
1084
+ refusal=delivery.get("rejection_code"),
1085
+ state=value.state,
1086
+ )
1087
+ if not value.accepted:
1088
+ raise SteerIndeterminate(
1089
+ f"steer {token} lacks durable native ownership",
1090
+ short=worker_id[:8], delivery_id=token,
1091
+ delivery_status=value.status,
1092
+ )
1093
+ if value.status == "unknown":
1094
+ raise SteerIndeterminate(
1095
+ f"steer {token} placement remains unknown; do not re-send",
1096
+ short=worker_id[:8], delivery_id=token,
1097
+ delivery_status="unknown",
1098
+ )
1099
+ return value
1100
+
1101
+ if wait is False:
1102
+ return project(first, timed_out=False)
1103
+ deadline = None if timeout is None else time.monotonic() + timeout
1104
+ while True:
1105
+ self._uuid_store.revalidate()
1106
+ observed = reconcile_turn(
1107
+ handle, delivery_id=token, include_worker=False,
1108
+ expected_generation=self._uuid_store.generation,
1109
+ )
1110
+ item = project(observed, timed_out=False)
1111
+ if not item.pending:
1112
+ return item
1113
+ if deadline is not None and time.monotonic() >= deadline:
1114
+ return project(observed, timed_out=True)
1115
+ time.sleep(STEER_OBSERVE_POLL_S if deadline is None else
1116
+ min(STEER_OBSERVE_POLL_S, max(0, deadline - time.monotonic())))
1117
+
1118
+ def steer_status(self, short: str, delivery_id: str) -> SteerResult:
1119
+ """Reconcile one durable steering delivery without sending anything."""
1120
+ from ccx.ops.wait import reconcile_turn
1121
+ from ccx.claude.delivery import read_receipt, valid_delivery_id
1122
+ if not valid_delivery_id(delivery_id):
1123
+ raise InvalidRequest("steer status requires a valid delivery_id")
1124
+ rec = self._resolve_managed(short, "steer status")
1125
+ worker_id = rec["workerId"]
1126
+ handle = f"claude:{worker_id}"
1127
+ original = read_receipt(handle, delivery_id)
1128
+ if original is None or original.get("requestedMode") != "steer_only":
1129
+ raise SessionNotFound(
1130
+ f"no exact Claude steer delivery {delivery_id!r} for this worker",
1131
+ short=worker_id[:8],
1132
+ )
1133
+ probe = reconcile_turn(
1134
+ handle, delivery_id=delivery_id, include_worker=False,
1135
+ expected_generation=self._uuid_store.generation,
1136
+ )
1137
+ current = self._uuid_store.record(worker_id).get("lifecycle") or {}
1138
+ return self._steer_result(
1139
+ handle, delivery_id, probe.get("delivery"),
1140
+ state=current.get("state"), timed_out=False,
1141
+ )
1142
+
1143
+ def compact(
1144
+ self,
1145
+ short: str,
1146
+ *,
1147
+ wait: bool = True,
1148
+ timeout: float | None = COMPACT_WAIT_DEFAULT_S,
1149
+ operation_id: str | None = None,
1150
+ execution_timeout: float | None = None,
1151
+ ) -> CompactOperationResult:
1152
+ """Compact one exact original Claude worker, with native terminal proof.
1153
+
1154
+ The caller's observation budget never changes the immutable native
1155
+ effect identity; retry the same operation_id to reconcile, not resend.
1156
+ """
1157
+ from ccx.ops.compact import compact_worker
1158
+
1159
+ rec = self._resolve_managed(short, "compact")
1160
+ return CompactOperationResult(**compact_worker(
1161
+ f"claude:{rec['workerId']}",
1162
+ operation_id=operation_id if operation_id is not None
1163
+ else new_compact_operation_id(),
1164
+ wait=wait, timeout=timeout,
1165
+ execution_timeout=execution_timeout,
1166
+ ))
1167
+
1168
+ def compact_status(self, short: str, operation_id: str) -> CompactOperationResult:
1169
+ """Inspect one original compact journal without native submission/revival."""
1170
+ from ccx.ops.compact import compact_status
1171
+
1172
+ rec = self._resolve_managed(short, "compact status")
1173
+ return CompactOperationResult(**compact_status(
1174
+ f"claude:{rec['workerId']}", operation_id=operation_id,
1175
+ ))
1176
+
1177
+ def kill(self, short: str, *,
1178
+ timeout: float | None = KILL_TIMEOUT_DEFAULT_S) -> SessionInfo:
1179
+ """Confirm exact owned Claude host and native child are gone.
1180
+
1181
+ Durable session history is retained; automatic full-UUID resume is
1182
+ currently unavailable. An unproven teardown raises, never succeeds.
1183
+ """
1184
+ from ccx.ops.wait import park_worker
1185
+ from ccx.store.identity import IdentityError, UnknownWorker
1186
+ try:
1187
+ owner = self._uuid_store.resolve(short)
1188
+ except UnknownWorker as exc:
1189
+ raise worker_not_found(short) from exc
1190
+ except IdentityError as exc:
1191
+ raise InvalidRequest(f"kill: invalid full-UUID worker target: {exc}") from exc
1192
+ if owner.engine != "claude":
1193
+ raise InvalidRequest("kill requires a Claude full-UUID owner")
1194
+ park_worker(owner.worker, timeout=timeout,
1195
+ expected_generation=self._uuid_store.generation)
1196
+ return self._uuid_session_info(owner, self._uuid_store.record(owner.worker))
1197
+
1198
+ def interrupt(
1199
+ self,
1200
+ short: str,
1201
+ *,
1202
+ expected_turn_ref: str | None = None,
1203
+ ) -> dict:
1204
+ """Request cancellation of one exact active full-UUID Claude turn.
1205
+
1206
+ A successful return proves native acceptance, NOT interruption.
1207
+ ``confirmed`` and ``outcome`` expose separately observed terminality.
1208
+ """
1209
+ from ccx.ops.cancel import stop_worker
1210
+ rec = self._resolve_managed(short, "interrupt")
1211
+ result = stop_worker(
1212
+ f"claude:{rec['workerId']}", expected_turn_ref=expected_turn_ref,
1213
+ expected_generation=self._uuid_store.generation,
1214
+ )
1215
+ return {
1216
+ **result, "ok": True, "short": rec["workerId"][:8],
1217
+ "sent": True,
1218
+ "cancelled": (result["confirmed"] and result["outcome"] == "interrupted"),
1219
+ }
1220
+
1221
+ def set_model(self, short: str, model: str | None, *, restart: bool = False) -> SessionInfo:
1222
+ """Edit the next Claude spawn model, never an already running turn.
1223
+
1224
+ Plain edits require no warm owned host or native child. An explicit
1225
+ restart requires a proven settled-idle original and confirms teardown.
1226
+ A later send/resume reuses the original session with the saved model.
1227
+ """
1228
+ info, _restarted = self._set_spawn_config(
1229
+ short, {"model": model}, restart=restart,
1230
+ )
1231
+ return info
1232
+
1233
+ def set_effort(self, short: str, effort: str | None, *, restart: bool = False) -> SessionInfo:
1234
+ """Edit future Claude spawn effort (low/medium/high/xhigh/max).
1235
+
1236
+ Changing effort invalidates the model prompt cache on the next turn.
1237
+ None removes the override; no current native turn is reconfigured.
1238
+ """
1239
+ info, _restarted = self._set_spawn_config(
1240
+ short, {"effort": effort}, restart=restart,
1241
+ )
1242
+ return info
1243
+
1244
+ def _set_spawn_config(
1245
+ self, target: str, updates: dict[str, str | None], *,
1246
+ restart: bool | None, timeout: float = KILL_TIMEOUT_DEFAULT_S,
1247
+ ) -> tuple[SessionInfo, bool]:
1248
+ """The ONE original full-UUID Claude spawn-config admission and CAS.
1249
+
1250
+ restart=None means WorkerClient's settled-IDLE convenience selection;
1251
+ explicit public setters keep their existing restart=False signature.
1252
+ Only authenticated read-only host probes are used for admission, never
1253
+ ensure_ready or an unproven short-record status projection.
1254
+ """
1255
+ from math import isfinite
1256
+ from ccx.store.liveness import owned_process
1257
+ from ccx.store.root import GenerationError, RecordConflict
1258
+ from ccx.ops.terminate import confirmed_kill_uuid
1259
+
1260
+ if not updates or set(updates) - {"model", "effort", "permissions"}:
1261
+ raise InvalidRequest("Claude configuration requires model, effort and/or permissions")
1262
+ validated: dict[str, str | bool | None] = {}
1263
+ for field, value in updates.items():
1264
+ if field == "permissions":
1265
+ if type(value) is not str or value not in ("full", "restricted"):
1266
+ raise InvalidRequest("Claude permissions must be full or restricted")
1267
+ validated["skip_permissions"] = value == "full"
1268
+ validated["permission_mode"] = None
1269
+ if value == "full":
1270
+ # Full cannot retain a hidden explicit tool denial.
1271
+ validated["disallowed_tools"] = None
1272
+ continue
1273
+ if value is not None and (not isinstance(value, str) or not value.strip()):
1274
+ raise InvalidRequest(f"{field} must be nonblank text or None")
1275
+ normalized = value.strip() if isinstance(value, str) else None
1276
+ if field == "effort" and normalized not in (
1277
+ None, "low", "medium", "high", "xhigh", "max"
1278
+ ):
1279
+ raise InvalidRequest("Claude effort must be low/medium/high/xhigh/max or None")
1280
+ validated[field] = normalized
1281
+ if (type(timeout) not in (int, float)
1282
+ or not isfinite(timeout) or timeout <= 0):
1283
+ raise InvalidRequest("Claude config timeout must be positive and finite")
1284
+ if restart not in (True, False, None):
1285
+ raise InvalidRequest("Claude config restart must be true, false, or automatic")
1286
+ deadline = time.monotonic() + timeout
1287
+ rec = self._resolve_managed(target, "set config")
1288
+ owner = self._uuid_store.resolve(f"claude:{rec['workerId']}")
1289
+ handle = owner.worker
1290
+ native = rec.get("native") or {}
1291
+ session_id = native.get("sessionId")
1292
+ if not isinstance(session_id, str) or not session_id:
1293
+ raise SessionUnavailable("Claude session is missing; inspect the worker", short=owner.short)
1294
+
1295
+ def remaining() -> float:
1296
+ left = deadline - time.monotonic()
1297
+ if left <= 0:
1298
+ raise SessionUnavailable("Claude config observation deadline expired", short=owner.short)
1299
+ return left
1300
+
1301
+ # _rpc verifies the original generation, peer credentials and host
1302
+ # process START token on its actual socket, before any status request.
1303
+ attested = _rpc(handle, {"op": "startup-status"}, timeout=min(.5, remaining()))
1304
+ self._uuid_store.revalidate()
1305
+ current = self._uuid_store.record(handle)
1306
+ lc = current.get("lifecycle") or {}
1307
+
1308
+ host_presence = owned_process(lc.get("hostPid"), lc.get("hostStart"))
1309
+ child_presence = owned_process(lc.get("workerPid"), lc.get("workerStart"))
1310
+ warm = False
1311
+ if attested.get("ok") is True:
1312
+ proof = lc.get("nativeStartProof")
1313
+ if (not isinstance(proof, dict)
1314
+ or attested.get("workerId") != owner.worker_id
1315
+ or attested.get("engine") != "claude"
1316
+ or attested.get("generation") != self._uuid_store.generation
1317
+ or attested.get("nativeStartProof") != proof
1318
+ or proof.get("sessionId") != session_id
1319
+ or any(proof.get(field) != lc.get(field) for field in (
1320
+ "hostPid", "hostStart", "workerPid", "workerStart"
1321
+ ))
1322
+ or host_presence != "alive" or child_presence != "alive"
1323
+ or not lc.get("hostStart") or not lc.get("workerStart")):
1324
+ raise SessionUnavailable(
1325
+ "Claude process could not be verified; check its status before changing settings",
1326
+ short=owner.short,
1327
+ )
1328
+ status = _rpc(handle, {"op": "status"}, timeout=min(.5, remaining()))
1329
+ if status.get("ok") is not True:
1330
+ raise SessionUnavailable(
1331
+ "Claude status is unavailable; check it before changing settings",
1332
+ short=owner.short,
1333
+ )
1334
+ # Refuse every potentially active, queued or undecidable control:
1335
+ # no native model swap may land while any of these remain.
1336
+ if (status.get("state") != "idle" or lc.get("state") != S.IDLE
1337
+ or status.get("activeTurnRef") is not None
1338
+ or status.get("pendingDeliveries") != []
1339
+ or status.get("queuedInputs") != 0
1340
+ or status.get("waitingFor") is not None
1341
+ or status.get("compacting") is not False
1342
+ or (status.get("compactOperation") is not None
1343
+ and (not isinstance(status["compactOperation"], dict)
1344
+ or status["compactOperation"].get("status")
1345
+ not in {"succeeded", "noop", "failed"}))
1346
+ or status.get("steerJournalError")
1347
+ or status.get("compactJournalError")):
1348
+ raise DeliveryRejected(
1349
+ "Claude is busy or waiting for input; finish the turn before changing settings.",
1350
+ short=owner.short, state=status.get("state"), refusal="busy",
1351
+ )
1352
+ warm = True
1353
+ else:
1354
+ # A timeout, an unverified socket peer, or a live owned PID is
1355
+ # NOT proof of absence. Even a physically missing socket must
1356
+ # agree with both original host AND child process being gone.
1357
+ error = attested.get("error", "")
1358
+ if (not isinstance(error, str) or not error.startswith("unreachable:")
1359
+ or host_presence != "dead" or child_presence != "dead"
1360
+ or lc.get("state") in (S.BOOTING, S.RESUMING, S.BUSY, S.WAITING)):
1361
+ if lc.get("state") in (S.BOOTING, S.RESUMING, S.BUSY, S.WAITING):
1362
+ raise DeliveryRejected(
1363
+ "Claude is starting or busy; finish the turn before changing settings.",
1364
+ short=owner.short, state=lc.get("state"), refusal="busy",
1365
+ )
1366
+ raise SessionUnavailable(
1367
+ "Cannot verify whether Claude is running; check its status and retry.",
1368
+ short=owner.short,
1369
+ )
1370
+ if restart is False and warm:
1371
+ raise DeliveryRejected(
1372
+ "Claude is running; use --restart while idle or stop it first.",
1373
+ short=owner.short, state=lc.get("state"), refusal="busy",
1374
+ )
1375
+ restart_now = warm and restart is not False
1376
+ observed = {
1377
+ "lifecycle.state": lc.get("state"),
1378
+ "lifecycle.hostPid": lc.get("hostPid"),
1379
+ "lifecycle.hostStart": lc.get("hostStart"),
1380
+ "lifecycle.workerPid": lc.get("workerPid"),
1381
+ "lifecycle.workerStart": lc.get("workerStart"),
1382
+ "lifecycle.nativeStartProof": lc.get("nativeStartProof"),
1383
+ }
1384
+ try:
1385
+ patched = self._uuid_store.mutate_record(
1386
+ handle, patches={f"createArgs.{field}": value
1387
+ for field, value in validated.items()},
1388
+ expect=observed,
1389
+ )
1390
+ except (RecordConflict, GenerationError) as exc:
1391
+ raise SessionUnavailable(
1392
+ "Claude changed while saving; retry settings when idle",
1393
+ short=owner.short,
1394
+ ) from exc
1395
+ if not restart_now:
1396
+ return self._uuid_session_info(owner, patched), False
1397
+
1398
+ # The config is already durably saved. A failing/unconfirmed teardown
1399
+ # is NOT success or a reason to undo the CAS; the old host may still
1400
+ # run old settings. The caller may inspect/stop it separately.
1401
+ try:
1402
+ stopped = confirmed_kill_uuid(
1403
+ self._uuid_store, handle, timeout=remaining(),
1404
+ )
1405
+ if not stopped.get("killed"):
1406
+ raise SessionUnavailable("Claude did not confirm stopping; check its status")
1407
+ except Exception as exc:
1408
+ raise SessionUnavailable(
1409
+ "Settings were saved, but Claude may still be running; "
1410
+ "the updated model/effort/permissions are for a later spawn only",
1411
+ short=owner.short,
1412
+ ) from exc
1413
+ self._uuid_store.revalidate()
1414
+ return self._uuid_session_info(owner, self._uuid_store.record(handle)), True
1415
+
1416
+ def set_name(self, short: str, name: str | None) -> SessionInfo:
1417
+ """Rename exact managed full UUID using fresh-record semantic CAS."""
1418
+ from ccx.store.identity import IdentityError
1419
+ from ccx.store.root import GenerationError
1420
+ from ccx.store.identity import UnknownWorker
1421
+ try:
1422
+ owner = self._uuid_store.resolve(short)
1423
+ if owner.engine != "claude":
1424
+ raise InvalidRequest("CcxClient.set_name requires a Claude full-UUID worker")
1425
+ updated = self._uuid_store.rename_worker(
1426
+ owner.worker, engine="claude", name=name,
1427
+ )
1428
+ return self._uuid_session_info(owner, updated)
1429
+ except UnknownWorker as exc:
1430
+ raise worker_not_found(short) from exc
1431
+ except IdentityError as exc:
1432
+ raise InvalidRequest(f"Invalid Claude worker handle: {exc}") from exc
1433
+ except GenerationError as exc:
1434
+ raise SessionUnavailable(f"Worker records changed; reopen CCX: {exc}") from exc
1435
+
1436
+ # -- read paths ---------------------------------------------------------
1437
+
1438
+ def peek(self, short: str, *, live: bool = False) -> SessionStatus:
1439
+ """Recorded-only forensic status; native/liveness queries are fenced."""
1440
+ from ccx.store.identity import UnknownWorker, IdentityError
1441
+ from ccx.store.root import GenerationError
1442
+ if live:
1443
+ raise SessionUnavailable(
1444
+ "CcxClient.peek(live=True) requires full-UUID host adoption"
1445
+ )
1446
+ try:
1447
+ owner = self._uuid_store.resolve(short)
1448
+ if owner.engine != "claude":
1449
+ raise InvalidRequest("CcxClient.peek requires a Claude full-UUID target")
1450
+ rec = self._uuid_store.record(owner.worker)
1451
+ lc = rec.get("lifecycle")
1452
+ lc = lc if isinstance(lc, dict) else {}
1453
+ stats = rec.get("stats")
1454
+ stats = stats if isinstance(stats, dict) else {}
1455
+ import time as _time
1456
+ last = rec.get("lastTurnAt")
1457
+ age = max(0,int(_time.time()-last/1000)) if type(last) is int and last>0 else None
1458
+ return SessionStatus(
1459
+ short=owner.short, worker_id=owner.worker_id,
1460
+ engine="claude", state=lc.get("state"),
1461
+ turn=stats.get("turns"), last_turn_age_s=age,
1462
+ host_status="unobserved",
1463
+ mode="managed",
1464
+ waiting_for=PendingInput.from_waiting_for(rec.get("waitingFor")),
1465
+ context=commands_read._status_context_for_record(owner.worker, rec),
1466
+ background_task_summary=rec.get("backgroundTaskSummary"),
1467
+ )
1468
+ except UnknownWorker as exc:
1469
+ raise worker_not_found(short) from exc
1470
+ except IdentityError as exc:
1471
+ raise InvalidRequest(f"invalid Claude full-UUID target: {exc}") from exc
1472
+ except GenerationError as exc:
1473
+ raise SessionUnavailable(f"Worker record unavailable; inspect worker status: {exc}") from exc
1474
+
1475
+ def status(self, shorts: list[str], *, live: bool = False) -> list[SessionStatus]:
1476
+ """Batch exact-owned forensic status; never native host polling."""
1477
+ if live:
1478
+ raise SessionUnavailable("Live batch status is not supported; use recorded status")
1479
+ return [self.peek(value, live=False) for value in shorts]
1480
+
1481
+ def report(
1482
+ self, short, *, max_chars: int | None = None,
1483
+ live: bool = False,
1484
+ ) -> Report | dict:
1485
+ """Read Claude's latest turn reply; note an earlier confirmed reply if busy.
1486
+
1487
+ A list of targets returns ``{"reports": [...], "errors": {...}}``.
1488
+
1489
+ Turns without a final answer have an empty text and has_final_message=False.
1490
+ The report's note describes a newer active turn or a failed worker.
1491
+ """
1492
+ from ccx.ops.report import report_worker
1493
+ from ccx.store.root import GenerationError
1494
+ if live:
1495
+ raise SessionUnavailable(
1496
+ "Only CCX-managed Claude workers are supported; use a worker handle"
1497
+ )
1498
+ if max_chars is not None and (type(max_chars) is not int or max_chars<0):
1499
+ raise InvalidRequest("max_chars must be nonnegative integer")
1500
+ if isinstance(short,(list,tuple)):
1501
+ from .errors import CcxError
1502
+ out=[]
1503
+ errors={}
1504
+ for target in short:
1505
+ try:
1506
+ out.append(self.report(target,max_chars=max_chars))
1507
+ except CcxError as exc:
1508
+ errors[str(target)] = {"error": type(exc).__name__,
1509
+ "message": str(exc), "exit_code": exc.exit_code}
1510
+ return {"reports": out, "errors": errors}
1511
+ rec=self._resolve_managed(short,"report")
1512
+ wid=rec["workerId"]
1513
+ try:
1514
+ native=report_worker(f"claude:{wid}",max_chars=max_chars)
1515
+ except GenerationError as exc:
1516
+ raise SessionUnavailable(
1517
+ f"Claude report unavailable; inspect the worker: {exc}"
1518
+ ) from exc
1519
+ return Report(
1520
+ short=wid[:8],worker_id=wid,engine="claude",turn=None,
1521
+ text=native["text"],turn_ref=native.get("turn_ref"),
1522
+ delivery_id=native.get("delivery_id"),transcript=None,
1523
+ note=native.get("note"),
1524
+ )
1525
+
1526
+ def context(self, short: str, *, live: bool = False) -> ClaudeContext:
1527
+ """Recorded carried context for one owned full UUID.
1528
+
1529
+ `short` is a legacy parameter name, not a short-key target.
1530
+ `live=True` and external discovery are refused. No host or
1531
+ transcript rendering is performed; absent evidence is no-signal.
1532
+ """
1533
+ from ccx.claude.context import read_claude_observation
1534
+
1535
+ rec = self._resolve_record(short, live=live)
1536
+ owner = self._uuid_store.resolve(rec["workerId"])
1537
+ observed = read_claude_observation(self._uuid_store, owner, rec)
1538
+ return ClaudeContext.from_record(
1539
+ rec, observed.get("context") if observed else None,
1540
+ note=observed.get("note") if observed else None,
1541
+ )
1542
+
1543
+ def tasks(self, short: str, *, live: bool = False) -> list[ClaudeTask]:
1544
+ """Read this owned Claude worker's native TaskCreate/TaskUpdate list.
1545
+
1546
+ Task files belong to the distinct original native session UUID, never
1547
+ to the public full worker UUID or its eight-character display prefix.
1548
+ """
1549
+ from ccx.claude.tasks import read_task_list
1550
+ from ccx.store.root import GenerationError
1551
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
1552
+
1553
+ if live:
1554
+ raise SessionUnavailable("tasks(live=True): external/native Claude discovery unavailable")
1555
+ rec = self._resolve_managed(short, "tasks")
1556
+ try:
1557
+ owner = self._uuid_store.resolve("claude:" + rec["workerId"])
1558
+ native = rec.get("native")
1559
+ sid = native.get("sessionId") if isinstance(native, dict) else None
1560
+ try:
1561
+ canonical_worker_id(sid)
1562
+ except InvalidTarget as exc:
1563
+ raise SessionUnavailable(
1564
+ "tasks: original Claude native session ID is unproven",
1565
+ short=owner.short,
1566
+ ) from exc
1567
+ fingerprint = rec.get("createSha256")
1568
+ if not isinstance(fingerprint, str) or not fingerprint:
1569
+ raise SessionUnavailable(
1570
+ "tasks: original Claude create identity is unavailable",
1571
+ short=owner.short,
1572
+ )
1573
+ bound = self._uuid_store.creation_binding(
1574
+ owner.worker, engine="claude", fingerprint=fingerprint,
1575
+ )
1576
+ if not isinstance(bound, dict) or bound.get("sessionId") != sid:
1577
+ raise SessionUnavailable(
1578
+ "tasks: native session differs from original Claude creation",
1579
+ short=owner.short,
1580
+ )
1581
+ tasks = read_task_list(sid)
1582
+ self._uuid_store.revalidate()
1583
+ return tasks
1584
+ except GenerationError as exc:
1585
+ raise SessionUnavailable(f"tasks: original Claude source unavailable: {exc}") from exc
1586
+
1587
+ def _uuid_background_task_facts(self, short: str) -> dict:
1588
+ """Resolve an exact original Claude owner for the shared read."""
1589
+ from ccx.store.root import GenerationError
1590
+
1591
+ rec = self._resolve_managed(short, "background_tasks")
1592
+ try:
1593
+ owner = self._uuid_store.resolve("claude:" + rec["workerId"])
1594
+ except GenerationError as exc:
1595
+ raise SessionUnavailable(
1596
+ f"background_tasks: original Claude owner unavailable: {exc}",
1597
+ ) from exc
1598
+ return self._uuid_background_task_facts_owned(self._uuid_store, owner, rec)
1599
+
1600
+ @staticmethod
1601
+ def _uuid_background_task_facts_owned(store, owner, rec: dict) -> dict:
1602
+ """Read the verified original JSONL using the caller's pinned owner."""
1603
+ from ccx.claude.transcript import open_original_claude_source
1604
+ from ccx.store.root import GenerationError
1605
+
1606
+ try:
1607
+ with open_original_claude_source(store, owner, rec) as source:
1608
+ pinned = source[0] if source is not None else None
1609
+ view = {**rec, "transcript": pinned}
1610
+ facts = commands_read._background_task_facts(view)
1611
+ store.revalidate()
1612
+ return facts
1613
+ except (GenerationError, OSError) as exc:
1614
+ raise SessionUnavailable(
1615
+ f"background_tasks: original Claude native source unavailable: {exc}",
1616
+ ) from exc
1617
+
1618
+ def background_tasks(self, short: str, *, live: bool = False) -> list[dict]:
1619
+ """Transcript-derived Bash, subagent and Monitor facts for one owner.
1620
+
1621
+ An open task is advisory; its liveness never proves turn completion or
1622
+ changes the worker lifecycle. A missing original JSONL yields [].
1623
+ """
1624
+ if live:
1625
+ raise SessionUnavailable(
1626
+ "background_tasks(live=True): external/native Claude discovery unavailable",
1627
+ )
1628
+ return self._uuid_background_task_facts(short)["background_tasks"]
1629
+
1630
+ def logs(
1631
+ self,
1632
+ short: str,
1633
+ *,
1634
+ tail: int | None = None,
1635
+ jsonl: bool = False,
1636
+ live: bool = False,
1637
+ ) -> dict | str:
1638
+ """Read the original owned Claude transcript without native activity.
1639
+
1640
+ Cleaned mode returns the existing rendered document (optionally its
1641
+ last ``tail`` turns). ``jsonl=True`` returns the entire original raw
1642
+ transcript, including an unfinished final line. Missing transcripts
1643
+ are not replaced by a stale derived render.
1644
+ """
1645
+ from ccx.claude.transcript import open_original_claude_source
1646
+ from ccx.store.root import GenerationError
1647
+
1648
+ if live:
1649
+ raise InvalidRequest("logs(live=True): unmanaged Claude discovery unavailable")
1650
+ rec = self._resolve_managed(short, "logs")
1651
+ try:
1652
+ owner = self._uuid_store.resolve("claude:" + rec["workerId"])
1653
+ if jsonl:
1654
+ with open_original_claude_source(self._uuid_store, owner, rec) as source:
1655
+ if source is None:
1656
+ raise SessionNotFound(
1657
+ f"transcript not found for {owner.worker}", short=owner.short,
1658
+ )
1659
+ with source[0].open_reader() as stream:
1660
+ result = stream.read().decode("utf-8", "replace")
1661
+ else:
1662
+ result = render_transcript_json(owner.worker, store=self._uuid_store)
1663
+ if result is None:
1664
+ raise SessionNotFound(
1665
+ f"transcript not found for {owner.worker}", short=owner.short,
1666
+ )
1667
+ if tail:
1668
+ result["turns"] = result.get("turns", [])[-tail:]
1669
+ self._uuid_store.revalidate()
1670
+ return result
1671
+ except (GenerationError, OSError) as exc:
1672
+ raise SessionUnavailable(
1673
+ f"logs: original Claude transcript unavailable: {exc}",
1674
+ short=rec["workerId"][:8],
1675
+ ) from exc
1676
+
1677
+ def list(
1678
+ self,
1679
+ *,
1680
+ cwd: str | None = None,
1681
+ cwd_prefix: str | None = None,
1682
+ state: str | None = None,
1683
+ recent_hours: float | None = None,
1684
+ ) -> list[SessionInfo]:
1685
+ """List only managed Claude full UUIDs; no external/native discovery."""
1686
+ from ccx.system.cwd_paths import canonical_cwd
1687
+ from ccx.store.root import GenerationError
1688
+ if cwd is not None and cwd_prefix is not None:
1689
+ raise InvalidRequest("cwd and cwd_prefix are mutually exclusive")
1690
+ try:
1691
+ owned: dict = {}
1692
+ rows = self._uuid_store.list_workers(
1693
+ engine="claude", cwd=canonical_cwd(cwd) if cwd else None,
1694
+ state=state, records=owned,
1695
+ )
1696
+ except GenerationError as exc:
1697
+ raise SessionUnavailable(f"full-UUID worker list unavailable: {exc}") from exc
1698
+ if cwd_prefix:
1699
+ prefix=canonical_cwd(cwd_prefix)
1700
+ rows=[r for r in rows if isinstance(r.get("cwd"),str) and
1701
+ (r["cwd"]==prefix or r["cwd"].startswith(prefix.rstrip(os.sep)+os.sep))]
1702
+ if recent_hours is not None:
1703
+ import time as _time
1704
+ if (not isinstance(recent_hours,(int,float))
1705
+ or not 0<recent_hours<=1000000):
1706
+ raise InvalidRequest("recent_hours must be positive")
1707
+ floor=int((_time.time()-recent_hours*3600)*1000)
1708
+ rows=[r for r in rows if max(r.get("last_turn_at") or 0,
1709
+ r.get("created_at") or 0)>=floor]
1710
+ return [self._uuid_session_info(*owned[row["worker"]], inventory_row=row)
1711
+ for row in rows]
1712
+
1713
+ def wait(
1714
+ self,
1715
+ short: str,
1716
+ *,
1717
+ live: bool = False,
1718
+ next_turn: bool = False,
1719
+ timeout: float | None = None,
1720
+ after_turn: str | None = None,
1721
+ known_input: str | None = None,
1722
+ ) -> WaitResult:
1723
+ """Observe a full-UUID managed Claude worker without native input.
1724
+
1725
+ A numeric timeout returns a timeout verdict, not an exception. Next-turn
1726
+ checkpoints describe exact turn boundaries and input-gate changes; they
1727
+ never by themselves prove a completed assistant reply.
1728
+ """
1729
+ from ccx.ops.wait import wait_worker
1730
+ if live:
1731
+ raise InvalidRequest("external/live Claude wait is not supported for full UUID")
1732
+ rec = self._resolve_managed(short, "wait")
1733
+ result = wait_worker(
1734
+ "claude:" + rec["workerId"], timeout=timeout, next_turn=next_turn,
1735
+ after_turn=after_turn, known_input=known_input,
1736
+ expected_generation=self._uuid_store.generation,
1737
+ )
1738
+ return WaitResult.from_verdict(result)
1739
+
1740
+ def events(
1741
+ self,
1742
+ short: str,
1743
+ *,
1744
+ since_epoch: str | None = None,
1745
+ since_seq: int | None = None,
1746
+ limit: int | None = None,
1747
+ ) -> list[Event]:
1748
+ """Read original stamped Claude events from the owned full-UUID journal.
1749
+
1750
+ The typed (epoch, seq) cursor spans host restarts; a bare since_seq is
1751
+ only a legacy within-epoch filter. Newest limit applies after filtering.
1752
+ """
1753
+ from ccx.ops.worker_events import MAX_PAGE_EVENTS, read_full_uuid_event_page
1754
+ from ccx.store.root import GenerationError
1755
+ from ccx.store.identity import UnknownWorker
1756
+
1757
+ record = self._resolve_managed(short, "events")
1758
+ worker = f"claude:{record['workerId']}"
1759
+ parsed: list[Event] = []
1760
+ cursor = None
1761
+ try:
1762
+ while True:
1763
+ page = read_full_uuid_event_page(
1764
+ self._uuid_store, worker, after=cursor,
1765
+ limit=MAX_PAGE_EVENTS, native=True,
1766
+ )
1767
+ parsed.extend(Event.from_dict(obj) for obj in page["events"])
1768
+ if not page["has_more"]:
1769
+ break
1770
+ cursor = page["next_cursor"]
1771
+ except UnknownWorker as exc:
1772
+ raise worker_not_found(worker) from exc
1773
+ except GenerationError as exc:
1774
+ raise SessionUnavailable(f"events: original source unavailable: {exc}") from exc
1775
+
1776
+ out = filter_events_after(parsed, since_epoch, since_seq)
1777
+ if limit is not None and limit >= 0:
1778
+ out = out[-limit:]
1779
+ return out
1780
+
1781
+ def subscribe(
1782
+ self,
1783
+ short: str,
1784
+ *,
1785
+ since_epoch: str | None = None,
1786
+ since_seq: int | None = None,
1787
+ timeout: float | None = None,
1788
+ ) -> Iterator[Event]:
1789
+ """Replay typed original Claude events, then follow its owned journal.
1790
+
1791
+ Capture the ev2 source watermark before replay and dedupe overlapping
1792
+ historical/live events per epoch, not by a global sequence counter.
1793
+ """
1794
+ from math import isfinite
1795
+ from ccx.ops.worker_events import read_full_uuid_event_page, follow_full_uuid_events
1796
+ from ccx.store.root import GenerationError
1797
+ from ccx.store.identity import UnknownWorker
1798
+
1799
+ if timeout is not None and (
1800
+ type(timeout) not in (int, float) or not isfinite(timeout) or timeout < 0
1801
+ ):
1802
+ raise InvalidRequest("subscribe timeout must be nonnegative finite or None")
1803
+ deadline = None if timeout is None else time.monotonic() + timeout
1804
+ record = self._resolve_managed(short, "subscribe")
1805
+ worker = f"claude:{record['workerId']}"
1806
+ try:
1807
+ watermark = read_full_uuid_event_page(
1808
+ self._uuid_store, worker, tail=0, native=True,
1809
+ )["next_cursor"]
1810
+ except UnknownWorker as exc:
1811
+ raise worker_not_found(worker) from exc
1812
+ except GenerationError as exc:
1813
+ raise SessionUnavailable(
1814
+ f"subscribe: original source unavailable: {exc}"
1815
+ ) from exc
1816
+
1817
+ yielded_max: dict[str, int] = {}
1818
+
1819
+ def fresh(epoch, seq) -> bool:
1820
+ if epoch is None or seq is None:
1821
+ return True
1822
+ if epoch in yielded_max and seq <= yielded_max[epoch]:
1823
+ return False
1824
+ yielded_max[epoch] = seq
1825
+ return True
1826
+
1827
+ for event in self.events(
1828
+ worker, since_epoch=since_epoch, since_seq=since_seq,
1829
+ ):
1830
+ if fresh(event.epoch, event.seq):
1831
+ yield event
1832
+ remaining = (None if deadline is None
1833
+ else max(0.0, deadline - time.monotonic()))
1834
+ try:
1835
+ for raw in follow_full_uuid_events(
1836
+ self._uuid_store, worker, after=watermark, native=True,
1837
+ timeout=remaining, heartbeat=None,
1838
+ ):
1839
+ # The synthetic heartbeat is not a stamped source event.
1840
+ if raw.get("kind") == "heartbeat" and "seq" not in raw:
1841
+ continue
1842
+ event = Event.from_dict(raw)
1843
+ if fresh(event.epoch, event.seq):
1844
+ yield event
1845
+ except UnknownWorker as exc:
1846
+ raise worker_not_found(worker) from exc
1847
+ except GenerationError as exc:
1848
+ raise SessionUnavailable(
1849
+ f"subscribe: original source unavailable: {exc}"
1850
+ ) from exc
1851
+
1852
+ def usage(self, *, cached: bool = False) -> Usage:
1853
+ """Live quota utilization (the signal usage caps enforce). With
1854
+ ``cached=True`` serve a recent cached value instead of forcing a fresh
1855
+ fetch. Raises :class:`InvalidRequest` if no signal is available; plan data
1856
+ comes from native Claude Code unless the machine selected OAuth."""
1857
+ data = fetch_usage(force=not cached)
1858
+ if data is None:
1859
+ raise InvalidRequest("usage: no signal (Claude usage unavailable or held; see ccx usage)")
1860
+ return Usage(
1861
+ summary=usage_summary(data),
1862
+ raw=data,
1863
+ five_hour_reset=session_reset_status(data),
1864
+ )
1865
+
1866
+ def reset_five_hour_limit(self) -> FiveHourReset:
1867
+ """Claim Anthropic's weekly session-limit reset when provider-claimable.
1868
+
1869
+ The provider helper performs a mandatory live eligibility preflight, at
1870
+ most one mutation, and a read-only reconciliation. No local utilization
1871
+ threshold or entitlement calculation participates in authorization.
1872
+
1873
+ This is Claude Code's own reset offer (a limited rollout), claimed through
1874
+ its account endpoint, which isn't a public API and can change. Off by
1875
+ default: raises :class:`InvalidRequest` unless
1876
+ ``CCX_ENABLE_UNOFFICIAL_RESET=1`` is set.
1877
+ """
1878
+ outcome = reset_five_hour_limit()
1879
+ return FiveHourReset(
1880
+ result=str(outcome.get("result") or "error"),
1881
+ status=(
1882
+ outcome.get("status")
1883
+ if isinstance(outcome.get("status"), dict)
1884
+ else None
1885
+ ),
1886
+ next_available_at=(
1887
+ outcome.get("next_available_at")
1888
+ if isinstance(outcome.get("next_available_at"), str)
1889
+ else None
1890
+ ),
1891
+ weekly_resets_at=(
1892
+ outcome.get("weekly_resets_at")
1893
+ if isinstance(outcome.get("weekly_resets_at"), str)
1894
+ else None
1895
+ ),
1896
+ refreshed=bool(outcome.get("refreshed")),
1897
+ )
1898
+
1899
+
1900
+ __all__ = ["CcxClient"]