ccx-native 0.0.1 → 0.2.0

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