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
@@ -0,0 +1,1081 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import sys
4
+ import time
5
+ from ccx.ops.compact_ops import (
6
+ new_operation_id as new_compact_operation_id,
7
+ )
8
+ from ccx.constants import (
9
+ EXIT_ERROR,
10
+ EXIT_NOT_FOUND,
11
+ EXIT_REJECTED,
12
+ EXIT_TURN_INCOMPLETE,
13
+ EXIT_UNAVAILABLE,
14
+ SEND_TEXT_MAX_BYTES,
15
+ STEER_OBSERVE_DEFAULT_S,
16
+ )
17
+ from ccx.errors import (
18
+ CcxError,
19
+ InvalidRequest,
20
+ SessionUnavailable,
21
+ )
22
+ from ccx.controllers.orchestrator import require_notify_parent
23
+ from ccx.notify.daemon import prepare_notify_parent
24
+ from ccx.notify.registry import public_registration as public_parent_notification
25
+ from ccx.rpc import rpc as host_rpc
26
+ from ccx.ops.semantics import (
27
+ KIND_ELICITATION,
28
+ KIND_PERMISSION,
29
+ KIND_QUESTION,
30
+ elicitation_field_content as _elicitation_field_content,
31
+ payload_guidance as _payload_guidance, # noqa: F401
32
+ pending_input_kind,
33
+ question_answer_map as _pure_question_answer_map,
34
+ question_answer_message as _question_answer_message,
35
+ send_payload_violation as _pure_send_payload_violation,
36
+ )
37
+ from ccx.claude.usage import (
38
+ _caps_from_args,
39
+ )
40
+ from ccx.codex.settle import send_settle_failure as _send_settle_failure
41
+ from ccx.cli.commands._common import _has_structured_answer_args, _nbwait_exit_code, _prepare_parent_notification, _resolve_prompt_source, _uuid_input_error, _uuid_input_guard, _uuid_input_id, _uuid_input_print
42
+
43
+
44
+ def _validate_after_turn(after_turn: str | None, next_turn: bool,
45
+ known_input: str | None = None) -> None:
46
+ """Reject a malformed ``after_turn`` / ``known_input`` selector before
47
+ any observation."""
48
+ if known_input is not None:
49
+ if not isinstance(known_input, str) or not known_input:
50
+ raise InvalidRequest("known_input must be a non-empty request id")
51
+ if not next_turn:
52
+ raise InvalidRequest("known_input requires next_turn")
53
+ if after_turn is None:
54
+ return
55
+ if not next_turn:
56
+ raise InvalidRequest("after_turn requires next_turn")
57
+ from ccx.ops.turn_reports import _ref_id
58
+ _ref_id(after_turn, engine="claude")
59
+
60
+
61
+ _AFTER_TURN_UNSUPPORTED = (
62
+ "--after-turn is supported only for managed Claude sessions: other session "
63
+ "modes have no settled-turn identity to compare"
64
+ )
65
+
66
+
67
+ _KNOWN_INPUT_UNSUPPORTED = (
68
+ "--known-input is supported only for managed Claude sessions: other session "
69
+ "modes have no input-gate identity to compare"
70
+ )
71
+
72
+
73
+ def _checkpoint_unsupported(after_turn: str | None) -> str:
74
+ return _AFTER_TURN_UNSUPPORTED if after_turn is not None else _KNOWN_INPUT_UNSUPPORTED
75
+
76
+
77
+ def _emit_full_uuid_wait(args, result: dict) -> None:
78
+ """One display/exit mapping for Claude and Codex native wait verdicts."""
79
+ state = result["status"]
80
+ ok = state in {"idle", "stopped"}
81
+ if getattr(args, "json", False):
82
+ print(json.dumps({"ok": ok, **result}, indent=2))
83
+ else:
84
+ print(f"wait: {state} {result['worker']}")
85
+ if result.get("last_settled_turn"):
86
+ print(f" last_settled_turn: {result['last_settled_turn']['turn_ref']}")
87
+ if result.get("active_turn_ref"):
88
+ print(f" active_turn_ref: {result['active_turn_ref']}")
89
+ if result.get("waiting_for"):
90
+ print(f" waiting_for: {json.dumps(result['waiting_for'])}")
91
+ if result.get("reason"):
92
+ print(f" reason: {result['reason']}")
93
+ if result.get("error"):
94
+ print(f" error: {result['error']}")
95
+ if not ok:
96
+ raise SystemExit(
97
+ EXIT_UNAVAILABLE if state == "unavailable"
98
+ else _nbwait_exit_code(state, result.get("apiError"))
99
+ )
100
+
101
+
102
+ def cmd_wait(args):
103
+ from ccx.ops.wait import wait_worker
104
+ try:
105
+ verdict = wait_worker(
106
+ args.target, timeout=getattr(args, "timeout", None),
107
+ next_turn=bool(getattr(args, "next_turn", False)),
108
+ after_turn=getattr(args, "after_turn", None),
109
+ known_input=getattr(args, "known_input", None),
110
+ )
111
+ except CcxError as error:
112
+ print(f"[ccx] wait: {error}", file=sys.stderr)
113
+ raise SystemExit(error.exit_code) from error
114
+ _emit_full_uuid_wait(args, verdict)
115
+
116
+
117
+ def cmd_answer(args):
118
+ """Answer the original QUESTION or notify a separate, positively dead-host SEND."""
119
+ from ccx.client import CcxClient
120
+
121
+ try:
122
+ notified = bool(getattr(args, "notify_parent", False))
123
+ if not notified:
124
+ _uuid_input_guard(args, "answer")
125
+ text = _resolve_prompt_source(
126
+ args, "text", "answer", required=not _has_structured_answer_args(args),
127
+ )
128
+ client = CcxClient()
129
+ request_id = getattr(args, "request_id", None)
130
+ if notified:
131
+ from uuid import uuid5, NAMESPACE_URL
132
+ from ccx.claude.delivery import (
133
+ ClaudeDeliveryError, read_receipt as read_semantic_receipt,
134
+ )
135
+ from ccx.errors import OperationConflict
136
+ from ccx.notify.registry import read_registration as read_parent_notification
137
+ from ccx.ops.input import answer_input
138
+ from ccx.ops.wait import reconcile_turn
139
+ from ccx.ops.send import send_turn
140
+ from ccx.store.root import GenerationError, NativeEffectIndeterminate
141
+
142
+ record = client._resolve_managed(args.target, "answer")
143
+ request_id = _uuid_input_id(args, record.get("waitingFor"), KIND_QUESTION)
144
+ parent = _prepare_parent_notification(args)
145
+ worker_id = record["workerId"]
146
+ generation = client._uuid_store.generation
147
+ handle = f"claude:{worker_id}"
148
+ token = str(uuid5(
149
+ NAMESPACE_URL,
150
+ f"ccx-input-decision:{generation}:{worker_id}:{request_id}",
151
+ ))
152
+ event_id = str(uuid5(
153
+ NAMESPACE_URL,
154
+ "ccx-notify:" +
155
+ f"full-uuid-claude-input:{generation}:{worker_id}:{request_id}",
156
+ ))
157
+ # F is a different original SEND, never an original native answer.
158
+ fallback_id = str(uuid5(
159
+ NAMESPACE_URL,
160
+ f"ccx-question-deadhost-new-send:{generation}:{worker_id}:{request_id}",
161
+ ))
162
+ fallback_event = str(uuid5(
163
+ NAMESPACE_URL,
164
+ f"ccx-notify:full-uuid-claude-send:{generation}:{worker_id}:{fallback_id}",
165
+ ))
166
+ guidance = (
167
+ f"inspect delivery_id={fallback_id} and "
168
+ f"notification_event_id={fallback_event}; never remap or resend "
169
+ "an uncertain native effect"
170
+ )
171
+ f_path = False
172
+ try:
173
+ # FIRST: original F receipt, before any current-question mapping
174
+ # or host-liveness decision. SEND core owns all replay states.
175
+ try:
176
+ prior_fallback = read_semantic_receipt(handle, fallback_id)
177
+ except (GenerationError, ClaudeDeliveryError) as error:
178
+ raise SessionUnavailable(
179
+ f"Cannot verify the earlier answer delivery; inspect it: {error}; {guidance}",
180
+ short=worker_id[:8],
181
+ ) from error
182
+ f_path = prior_fallback is not None
183
+ # SECOND: original native Q receipt. Q core already replays it
184
+ # before checking a now-dead host; never convert Q to F.
185
+ prior_question = (client._uuid_store.read_receipt(handle, "answer", token)
186
+ if prior_fallback is None else None)
187
+ pending = record.get("waitingFor")
188
+ same_question = (
189
+ isinstance(pending, dict)
190
+ and pending_input_kind(pending) == KIND_QUESTION
191
+ and pending.get("id") == request_id
192
+ )
193
+ if prior_fallback is not None and not same_question:
194
+ # A revived/successor host has no original formatted preview.
195
+ # Read-only reconciliation, never reuse the stored SEND text.
196
+ original_event = read_parent_notification(fallback_event)
197
+ if original_event is None or (
198
+ original_event.get("source") != "send"
199
+ or (original_event.get("operation") or {}).get("kind") != "claude-turn"
200
+ or (original_event.get("operation") or {}).get("deliveryId") != fallback_id
201
+ or original_event.get("parent") != parent
202
+ ):
203
+ raise OperationConflict(
204
+ f"Answer delivery belongs to another request; {guidance}",
205
+ short=worker_id[:8],
206
+ )
207
+ notification = public_parent_notification(original_event)
208
+ observed = reconcile_turn(
209
+ handle, delivery_id=fallback_id, expected_generation=generation,
210
+ )
211
+ if observed.get("delivery") is None:
212
+ raise OperationConflict(
213
+ f"Cannot read the earlier answer delivery; {guidance}",
214
+ short=worker_id[:8],
215
+ )
216
+ delivery = observed["delivery"]
217
+ send_status = observed["status"]
218
+ settled = send_status == "settled"
219
+ preview_gone = True
220
+ else:
221
+ if not same_question:
222
+ raise SessionUnavailable(
223
+ f"The pending question {request_id!r} is no longer current; "
224
+ f"inspect response_id={token} and notification_event_id={event_id}; "
225
+ "never remap to a later question", short=worker_id[:8],
226
+ )
227
+ text = _resolve_prompt_source(
228
+ args, "text", "answer",
229
+ required=not _has_structured_answer_args(args),
230
+ )
231
+ answers, problem = _pure_question_answer_map(
232
+ (pending.get("input") or {}).get("questions") or [],
233
+ text=text, options=getattr(args, "option", None),
234
+ text_answers=getattr(args, "text_answer", None),
235
+ )
236
+ if problem:
237
+ raise InvalidRequest(f"answer: {problem}", short=worker_id[:8])
238
+ if not answers:
239
+ raise InvalidRequest("answer: answer text is empty", short=worker_id[:8])
240
+
241
+ if prior_fallback is None and (
242
+ prior_question is not None or host_rpc.host_socket_live(worker_id, record)
243
+ ):
244
+ # Q's ordinary receipt-first native path, including replay
245
+ # on a dead host; never catch its errors and convert to F.
246
+ accepted = answer_input(
247
+ handle, request_id=request_id, response_id=token,
248
+ answers=answers, notify_parent=True,
249
+ timeout=(getattr(args, "timeout", None)
250
+ if getattr(args, "timeout", None) is not None else 90),
251
+ )
252
+ notification = accepted["notification"]
253
+ public = {
254
+ "ok": accepted["status"] == "accepted",
255
+ "worker": handle, "worker_id": worker_id,
256
+ "short": worker_id[:8], "request_id": request_id,
257
+ "response_id": token, "decision": "answer",
258
+ "status": accepted["status"], "owned": accepted["owned"],
259
+ "recommended_action": accepted["recommended_action"],
260
+ "input_mode": "native-input",
261
+ "original_gate_answered": accepted["status"] == "accepted",
262
+ "notify_parent": notification,
263
+ }
264
+ if args.json:
265
+ print(json.dumps(public, ensure_ascii=False, indent=2))
266
+ else:
267
+ print(f"answer input {accepted['status']} for original request {request_id}")
268
+ print(f"Worker: {handle}")
269
+ print(f"notify: armed {notification['event_id']} -> Codex parent {notification['parent']}")
270
+ print(f"notify: {notification['instruction']}")
271
+ if accepted["status"] in {"submitted", "unknown", "prepared"}:
272
+ raise SystemExit(EXIT_TURN_INCOMPLETE)
273
+ if accepted["status"] == "rejected":
274
+ raise SystemExit(EXIT_REJECTED)
275
+ return
276
+
277
+ if prior_fallback is None:
278
+ f_path = True
279
+ # Fresh F only. Positive owned-PID death, native SID
280
+ # proof and no Q answer receipt are ONE shared guard.
281
+ # Never call the original answer input first.
282
+ client._require_dead_original_question_host(
283
+ worker_id, record, response_id=token,
284
+ )
285
+ first = send_turn(
286
+ handle, _question_answer_message(answers),
287
+ delivery_id=fallback_id, mode="new_turn_only",
288
+ cap=None, notify_parent=True, settle=False,
289
+ expected_generation=generation,
290
+ )
291
+ notification = first["notification"]
292
+ delivery = first["delivery"]
293
+ send_status = first["status"]
294
+ settled = bool(first["settled"])
295
+ preview_gone = False
296
+ except OperationConflict as error:
297
+ if not f_path:
298
+ raise # Preserve the original native Q error path.
299
+ raise OperationConflict(
300
+ f"{error}; {guidance}", short=worker_id[:8],
301
+ ) from error
302
+ except NativeEffectIndeterminate as error:
303
+ if not f_path:
304
+ raise # The original Q path never degrades to SEND.
305
+ print(f"[ccx] Answer delivery outcome is unknown; reconcile it: {error}; {guidance}",
306
+ file=sys.stderr)
307
+ raise SystemExit(EXIT_TURN_INCOMPLETE) from None
308
+ except (GenerationError, ClaudeDeliveryError) as error:
309
+ if not f_path:
310
+ raise
311
+ raise SessionUnavailable(
312
+ f"Answer delivery unavailable; check the worker: {error}; {guidance}",
313
+ short=worker_id[:8],
314
+ ) from error
315
+
316
+ # Both the fresh/same-preview SEND and preview-gone read-only
317
+ # projection describe the original NEW delivery, never native Q.
318
+ pending_send = not settled and send_status != "rejected"
319
+ public = {
320
+ "ok": send_status in {"accepted", "settled"},
321
+ "worker": handle, "worker_id": worker_id, "short": worker_id[:8],
322
+ "request_id": request_id, "original_gate": request_id,
323
+ "decision": "answer", "input_mode": "new-message-fallback",
324
+ "original_gate_answered": False,
325
+ "delivery_id": fallback_id, "delivery": delivery,
326
+ "status": send_status, "owned": bool(delivery.get("owned")),
327
+ "pending": pending_send, "settled": settled,
328
+ "completed_model_turn": settled,
329
+ "turn_ref": delivery.get("turn_ref"),
330
+ "recommended_action": ("inspect" if preview_gone and pending_send
331
+ else delivery.get("recommended_action")),
332
+ "notify_parent": notification,
333
+ }
334
+ if preview_gone:
335
+ public["inspection_guidance"] = guidance
336
+ if args.json:
337
+ print(json.dumps(public, ensure_ascii=False, indent=2))
338
+ else:
339
+ print(f"Claude question {request_id} was NOT answered; "
340
+ "a separate NEW user message delivery was attempted/recorded")
341
+ print(f"Worker: {handle}")
342
+ print(f"delivery_id: {fallback_id}; status: {send_status}; "
343
+ f"owned: {public['owned']}; model turn completed: {settled}")
344
+ print(f"notify: source=send event {notification['event_id']} "
345
+ f"({notification['state']}); parent machine placement "
346
+ "does NOT imply parent model completion")
347
+ if preview_gone or pending_send:
348
+ print(guidance)
349
+ if send_status == "rejected":
350
+ raise SystemExit(EXIT_REJECTED)
351
+ if pending_send and not public["ok"]:
352
+ raise SystemExit(EXIT_TURN_INCOMPLETE)
353
+ return
354
+
355
+ if request_id is None:
356
+ pending = client._resolve_managed(args.target, "answer").get("waitingFor")
357
+ request_id = _uuid_input_id(args, pending, KIND_QUESTION)
358
+ result = client.answer(
359
+ args.target, text, request_id=request_id,
360
+ options=getattr(args, "option", None),
361
+ text_answers=getattr(args, "text_answer", None),
362
+ wait=getattr(args, "wait", False),
363
+ timeout=getattr(args, "timeout", None),
364
+ fallback_to_send=True, # Main CLI only; Python stays opt-in.
365
+ )
366
+ _uuid_input_print(args, "answer", request_id, result)
367
+ except CcxError as error:
368
+ _uuid_input_error("answer", error)
369
+
370
+
371
+ def _send_payload_violation(text: str) -> tuple[int, int] | None:
372
+ "Adapter over the pure :func:`ccx.ops.semantics.send_payload_violation`,\n reading the limit from this module's ``SEND_TEXT_MAX_BYTES`` global so\n tests (and callers) that patch ``C.SEND_TEXT_MAX_BYTES`` keep working."
373
+ return _pure_send_payload_violation(text, limit=SEND_TEXT_MAX_BYTES)
374
+
375
+
376
+ def _publish_cli_send_variables(store, owner, args, text, token, *, cap=None) -> str:
377
+ """Publish CLI durable/share variables before the original native SEND.
378
+
379
+ Caller already owns an exact full UUID; this helper neither submits a turn
380
+ nor allocates a delivery token or registers a parent notification.
381
+ """
382
+ from ccx.controllers import live_vars
383
+ from ccx.claude.delivery import ClaudeDeliveryError, valid_delivery_id, validate_cap
384
+ from ccx.errors import InvalidRequest
385
+ from ccx.controllers.orchestrator import require_notify_parent
386
+
387
+ if args.var is not None and args.vars_from is not None:
388
+ raise InvalidRequest("--var and --vars-from are mutually exclusive")
389
+ values = live_vars.parse_cli_vars(args.var) if args.var is not None else None
390
+
391
+ # Original protocol SEND independently checks these again. Do not publish
392
+ # variables for invalid *options*; native busy/receipt fate is later.
393
+ if not isinstance(text, str) or not text.strip():
394
+ raise InvalidRequest("send: prompt text is required")
395
+ if not valid_delivery_id(token):
396
+ raise InvalidRequest("send: delivery_id is missing or unsafe")
397
+ if owner.engine == "claude":
398
+ try:
399
+ validate_cap(cap)
400
+ except ClaudeDeliveryError as error:
401
+ raise InvalidRequest(str(error)) from error
402
+ if args.notify_parent:
403
+ require_notify_parent()
404
+
405
+ record = store.record(owner.worker)
406
+ live_vars.require_send_variable_permission(owner, record)
407
+
408
+ if args.vars_from is not None:
409
+ source = store.resolve(args.vars_from)
410
+ attachment = live_vars.prepare_shared_durable_attachment(
411
+ source.engine, source.worker_id,
412
+ )
413
+ else:
414
+ attachment = live_vars.prepare_durable_attachment(values)
415
+
416
+ if attachment is not None and (attachment.names or attachment.state_id is not None):
417
+ if live_vars.should_publish_send_attachment(
418
+ store, owner, token, caps=cap,
419
+ ):
420
+ live_vars.publish_attachment(attachment, owner.engine, owner.worker_id)
421
+ names = store.variables_info(owner.worker)["names"]
422
+ text = live_vars.with_variable_hint(text, names)
423
+ store.revalidate()
424
+ return text
425
+
426
+
427
+ def cmd_send(args):
428
+ """One globally owned full-UUID Claude native send through original receipts.
429
+
430
+ Existing host IPC authenticates the root/generation/full worker/PID/start
431
+ on the same connection before PTY input. Original native-effect intent is
432
+ fsynced submitted-unknown before dispatch. The host's accepted ownership
433
+ is not a completed turn: --wait polls only positively evidenced original
434
+ Stop, never a model-wait while holding an activation lock.
435
+ """
436
+ from ccx import constants
437
+ from ccx.ops.send import send_turn
438
+ from ccx.ops.wait import reconcile_turn
439
+ from ccx.ops.report import report_worker
440
+ from ccx.store.root import require_ccx_active, GenerationError, NativeEffectIndeterminate
441
+ from ccx.store.identity import IdentityError, EngineMismatch
442
+ from ccx.claude.delivery import ClaudeDeliveryError
443
+ from ccx.errors import BudgetStopped, DeliveryRejected, OperationConflict, SessionUnavailable
444
+ from uuid import uuid4
445
+ if args.timeout is not None and args.timeout <= 0:
446
+ print("ccx send: positive --timeout required", file=sys.stderr)
447
+ raise SystemExit(EXIT_ERROR)
448
+ text = _resolve_prompt_source(args, "text", "send", required=True)
449
+ target = args.target
450
+ token = getattr(args, "delivery_id", None) or str(uuid4())
451
+ try:
452
+ store = require_ccx_active(constants.CCX_ROOT)
453
+ owner = store.resolve(target)
454
+ if owner.engine != "claude":
455
+ raise EngineMismatch("Send requires a Claude worker handle")
456
+ caps = _caps_from_args(args)
457
+ if args.var is not None or args.vars_from is not None:
458
+ text = _publish_cli_send_variables(
459
+ store, owner, args, text, token, cap=caps,
460
+ )
461
+ first = send_turn(
462
+ owner.worker, text, delivery_id=token,
463
+ mode="new_turn_only", cap=caps,
464
+ notify_parent=bool(args.notify_parent),
465
+ )
466
+ should_wait = not (args.no_wait or args.notify_parent)
467
+ begin = time.monotonic()
468
+ observation = None
469
+ failure = None
470
+ if should_wait:
471
+ while True:
472
+ observation = reconcile_turn(
473
+ owner.worker, delivery_id=token,
474
+ timeout=(0.5 if args.timeout is None else
475
+ max(0.001, min(0.5, args.timeout-(time.monotonic()-begin)))),
476
+ )
477
+ if observation["status"] in ("settled", "rejected"):
478
+ break
479
+ failure = _send_settle_failure(owner.worker, observation)
480
+ if failure is not None:
481
+ break
482
+ if (args.timeout is not None
483
+ and time.monotonic() - begin >= args.timeout):
484
+ break
485
+ time.sleep(.10)
486
+ delivery = (observation.get("delivery")
487
+ if observation is not None else first.get("delivery"))
488
+ settled = bool(
489
+ first.get("settled") if observation is None
490
+ else observation["status"] == "settled"
491
+ )
492
+ summary = {
493
+ "worker": owner.worker, "worker_id": owner.worker_id,
494
+ "engine": owner.engine, "short": owner.short,
495
+ "delivery_id": token,
496
+ "status": (
497
+ failure["status"] if failure is not None else
498
+ observation["status"] if observation is not None
499
+ else first["status"]
500
+ ),
501
+ "settled": settled,
502
+ "delivery": delivery,
503
+ }
504
+ if failure is not None:
505
+ summary["error"] = failure.get("error")
506
+ if args.notify_parent:
507
+ summary["notify_parent"] = first.get("notification")
508
+ if settled and not args.notify_parent:
509
+ # Report THIS delivery's original R, never the worker's latest.
510
+ turn_ref = (delivery or {}).get("turn_ref")
511
+ if not turn_ref:
512
+ raise SessionUnavailable(
513
+ "Completed delivery has no turn ID; "
514
+ "reconcile its delivery ID"
515
+ )
516
+ summary["report"] = report_worker(owner.worker, turn_ref=turn_ref)
517
+ if args.json:
518
+ print(json.dumps(summary, ensure_ascii=False))
519
+ else:
520
+ print(owner.worker)
521
+ print(f"delivery_id: {token}")
522
+ if args.notify_parent:
523
+ notification = summary["notify_parent"] or {}
524
+ print(f"notification: {notification.get('event_id')} ({notification.get('state')})")
525
+ elif settled:
526
+ print(summary["report"]["text"])
527
+ else:
528
+ if args.no_wait and summary["status"] == "owned":
529
+ print("queued for delivery (status: owned); "
530
+ f"check with ccx reconcile {owner.worker} --delivery-id {token}")
531
+ else:
532
+ print(f"status: {summary['status']}" +
533
+ (f"; error: {summary['error']}" if summary.get("error") else ""))
534
+ if summary["status"] == "rejected":
535
+ print(
536
+ "ccx send rejected: " +
537
+ str((delivery or {}).get("rejection_message") or "the worker refused the message"),
538
+ file=sys.stderr,
539
+ )
540
+ raise SystemExit(EXIT_REJECTED)
541
+ if should_wait and not settled:
542
+ # A proven failed/terminal state differs from an unknown timeout.
543
+ raise SystemExit(
544
+ (EXIT_UNAVAILABLE if failure["status"] == "unavailable" else
545
+ _nbwait_exit_code(failure["status"], failure.get("apiError")))
546
+ if failure is not None else EXIT_TURN_INCOMPLETE
547
+ )
548
+ except NativeEffectIndeterminate as error:
549
+ # Original global submitted-unknown is not a definite refusal and is
550
+ # never permission to resend this caller-owned Claude delivery token.
551
+ print(f"ccx send: delivery {token!r} is indeterminate; "
552
+ f"reconcile that same token, never resend: {error}", file=sys.stderr)
553
+ raise SystemExit(EXIT_TURN_INCOMPLETE) from None
554
+ except OperationConflict as error:
555
+ print(f"ccx send conflict: {error}", file=sys.stderr)
556
+ raise SystemExit(error.exit_code) from None
557
+ except BudgetStopped as error:
558
+ if args.json:
559
+ print(json.dumps({
560
+ "worker": owner.worker, "worker_id": owner.worker_id,
561
+ "engine": "claude", "short": owner.short,
562
+ "delivery_id": token, "status": "budget-stopped",
563
+ "code": "budget_stopped", "reason": error.reason or str(error),
564
+ "settled": False,
565
+ }, ensure_ascii=False))
566
+ else:
567
+ print(f"ccx send: {error}", file=sys.stderr)
568
+ raise SystemExit(error.exit_code) from None
569
+ except DeliveryRejected as error:
570
+ # Only the typed host-busy proof from the original readiness probe is
571
+ # a definite refusal: it is raised before any receipt or native input.
572
+ if ((error.refusal == "busy" and error.state == "busy"
573
+ or error.refusal in {"waiting", "state_unavailable"}
574
+ and error.state == "waiting")
575
+ and error.delivery_status == "rejected"):
576
+ print(f"ccx send rejected: {error}", file=sys.stderr)
577
+ raise SystemExit(EXIT_REJECTED) from None
578
+ print(f"ccx send unavailable: {error}", file=sys.stderr)
579
+ raise SystemExit(3) from None
580
+ except CcxError as error:
581
+ print(f"ccx send unavailable: {error}", file=sys.stderr)
582
+ raise SystemExit(error.exit_code) from None
583
+ except (GenerationError, IdentityError, ClaudeDeliveryError) as error:
584
+ print(f"ccx send unavailable: {error}", file=sys.stderr)
585
+ raise SystemExit(3) from None
586
+
587
+
588
+ def cmd_vars(args):
589
+ """Read only full-owned CCX variable state (not live object execution)."""
590
+ from ccx import constants
591
+ from ccx.store.root import GenerationError, require_ccx_active
592
+ from ccx.store.identity import IdentityError
593
+ store = require_ccx_active(constants.CCX_ROOT)
594
+ try:
595
+ value = store.variables_info(args.target, name=getattr(args, "name", None))
596
+ except IdentityError as exc:
597
+ print(f"ccx vars: {exc}", file=sys.stderr)
598
+ raise SystemExit(2) from None
599
+ except GenerationError as exc:
600
+ print(f"ccx vars: durable state unavailable: {exc}", file=sys.stderr)
601
+ raise SystemExit(3) from None
602
+ if getattr(args, "json", False):
603
+ print(json.dumps(value, indent=2, ensure_ascii=False))
604
+ elif getattr(args, "name", None) is not None:
605
+ print(json.dumps(value["value"], indent=2, ensure_ascii=False))
606
+ elif value["names"]:
607
+ print("\n".join(value["names"]))
608
+ else:
609
+ print("no variables")
610
+
611
+
612
+ def cmd_notify(args):
613
+ """Arm an independent read-only observation of one original Claude turn."""
614
+ from ccx import constants
615
+ from ccx.errors import CcxError, InvalidRequest, SessionNotFound, SessionUnavailable
616
+ from ccx.controllers.orchestrator import require_notify_parent
617
+ from ccx.notify.registry import create_registration, public_registration
618
+ from ccx.notify.daemon import ensure_notify_daemon
619
+ from ccx.notify.select import select_current_turn, select_future_turn
620
+ from ccx.store.root import GenerationError, require_ccx_active
621
+ from ccx.store.identity import IdentityError, UnknownWorker
622
+
623
+ future = bool(getattr(args, "next_turn", False))
624
+ try:
625
+ try:
626
+ store = require_ccx_active(constants.CCX_ROOT)
627
+ owner = store.resolve(args.target)
628
+ if owner.engine != "claude":
629
+ raise InvalidRequest("Notification requires a Claude worker handle")
630
+ parent = require_notify_parent()
631
+ selection = (select_future_turn(owner.worker) if future
632
+ else select_current_turn(owner.worker))
633
+ reg = create_registration(
634
+ parent=parent, worker_engine="claude", worker_id=owner.worker_id,
635
+ worker_name=selection["name"], operation=selection["operation"],
636
+ source="notify",
637
+ )
638
+ except UnknownWorker as exc:
639
+ raise SessionNotFound(f"Worker not found: {args.target}") from exc
640
+ except IdentityError as exc:
641
+ raise InvalidRequest(f"Invalid worker handle for notification: {exc}") from exc
642
+ except (GenerationError, OSError) as exc:
643
+ # Selection or publication failed; never reported as an armed event.
644
+ raise SessionUnavailable(f"Cannot select a worker turn to notify about; inspect the worker: {exc}") from exc
645
+ available = True
646
+ try:
647
+ ensure_notify_daemon()
648
+ except (CcxError, GenerationError, OSError) as exc:
649
+ # Durable publication already happened. Re-arming would allocate
650
+ # another independent event, so report this one as still armed.
651
+ available = False
652
+ print(f"ccx notify: event durably armed; supervisor unavailable: {exc}",
653
+ file=sys.stderr)
654
+ result = {**public_registration(reg), "armed": True,
655
+ "selected_state": selection["state"],
656
+ "notifier_available": available,
657
+ **({"wait_mode": "next-turn"} if future else {})}
658
+ except CcxError as exc:
659
+ print(f"ccx notify: {str(exc).removeprefix('ccx notify: ')}", file=sys.stderr)
660
+ raise SystemExit(exc.exit_code) from None
661
+ if args.json:
662
+ print(json.dumps(result, ensure_ascii=False))
663
+ else:
664
+ print(f"{result['worker']} event_id={result['event_id']} "
665
+ f"selection={result['selection_mode']} state={result['state']}")
666
+
667
+
668
+ def _cmd_permission_decision(args, decision: str):
669
+ """One current native permission decision, optionally its exact reply."""
670
+ from ccx.client import CcxClient
671
+
672
+ verb = "approve" if decision == "allow" else "reject"
673
+ try:
674
+ if getattr(args, "notify_parent", False):
675
+ # Reuse the same original prefix/broker lookup as ordinary Claude.
676
+ # The parent is captured before any child native input effect.
677
+ _prepare_parent_notification(args)
678
+ if getattr(args, "settle", False):
679
+ raise InvalidRequest("Claude permission --notify-parent does not support --settle")
680
+ from uuid import uuid5, NAMESPACE_URL
681
+ from ccx import constants
682
+ from ccx.ops.input import decide_input
683
+ from ccx.store.root import require_ccx_active
684
+ client = CcxClient()
685
+ record = client._resolve_managed(args.target, verb)
686
+ request_id = _uuid_input_id(args, record.get("waitingFor"), KIND_PERMISSION)
687
+ worker_id = record["workerId"]
688
+ store = require_ccx_active(constants.CCX_ROOT)
689
+ token = str(uuid5(
690
+ NAMESPACE_URL,
691
+ f"ccx-input-decision:{store.generation}:{worker_id}:{request_id}",
692
+ ))
693
+ reason = getattr(args, "reason", None)
694
+ if reason == "": # CLI-only empty reason is native absence.
695
+ reason = None
696
+ accepted = decide_input(
697
+ f"claude:{worker_id}", request_id=request_id, response_id=token,
698
+ decision="approve" if decision == "allow" else "reject",
699
+ reason=reason, notify_parent=True,
700
+ timeout=(getattr(args, "timeout", None) if getattr(args, "timeout", None)
701
+ is not None else 90),
702
+ )
703
+ notification = accepted["notification"]
704
+ public = {
705
+ "ok": accepted["status"] == "accepted",
706
+ "worker": f"claude:{worker_id}", "worker_id": worker_id,
707
+ "short": worker_id[:8], "request_id": request_id,
708
+ "response_id": token, "decision": decision,
709
+ "status": accepted["status"], "owned": accepted["owned"],
710
+ "recommended_action": accepted["recommended_action"],
711
+ "input_mode": "native-input",
712
+ "original_gate_answered": accepted["status"] == "accepted",
713
+ "notify_parent": notification,
714
+ }
715
+ if args.json:
716
+ print(json.dumps(public, ensure_ascii=False, indent=2))
717
+ else:
718
+ print(f"{verb} permission input {accepted['status']} for original request {request_id}")
719
+ print(f"Worker: claude:{worker_id}")
720
+ print(f"notify: armed {notification['event_id']} -> Codex parent {notification['parent']}")
721
+ print(f"notify: {notification['instruction']}")
722
+ if accepted["status"] in {"submitted", "unknown"}:
723
+ raise SystemExit(EXIT_TURN_INCOMPLETE)
724
+ if accepted["status"] == "rejected":
725
+ raise SystemExit(EXIT_REJECTED)
726
+ return
727
+ _uuid_input_guard(args, verb)
728
+ client = CcxClient()
729
+ request_id = getattr(args, "request_id", None)
730
+ if request_id is None:
731
+ pending = client._resolve_managed(args.target, verb).get("waitingFor")
732
+ request_id = _uuid_input_id(args, pending, KIND_PERMISSION)
733
+ method = client.approve if decision == "allow" else client.reject
734
+ result = method(
735
+ args.target, request_id=request_id,
736
+ reason=getattr(args, "reason", None),
737
+ wait=getattr(args, "wait", False),
738
+ timeout=getattr(args, "timeout", None),
739
+ )
740
+ _uuid_input_print(args, verb, request_id, result)
741
+ except CcxError as error:
742
+ _uuid_input_error(verb, error)
743
+
744
+
745
+ def cmd_respond(args):
746
+ """Resolve one original MCP elicitation with the existing client."""
747
+ from ccx.client import CcxClient
748
+
749
+ try:
750
+ notified = bool(getattr(args, "notify_parent", False))
751
+ if notified:
752
+ _prepare_parent_notification(args) # own Codex caller before input effect
753
+ else:
754
+ _uuid_input_guard(args, "respond")
755
+ action = "accept" if args.accept else "decline" if args.decline else "cancel"
756
+ client = CcxClient()
757
+ record = client._resolve_managed(args.target, "respond")
758
+ pending = record.get("waitingFor")
759
+ request_id = _uuid_input_id(args, pending, KIND_ELICITATION)
760
+ if notified:
761
+ from uuid import uuid5, NAMESPACE_URL
762
+ from ccx.ops.input import respond_input
763
+ worker_id = record["workerId"]
764
+ generation = client._uuid_store.generation
765
+ token = str(uuid5(
766
+ NAMESPACE_URL, f"ccx-input-decision:{generation}:{worker_id}:{request_id}",
767
+ ))
768
+ event_id = str(uuid5(
769
+ NAMESPACE_URL,
770
+ "ccx-notify:" +
771
+ f"full-uuid-claude-input:{generation}:{worker_id}:{request_id}",
772
+ ))
773
+ if (args.field and (
774
+ not isinstance(pending, dict)
775
+ or pending_input_kind(pending) != KIND_ELICITATION
776
+ or pending.get("id") != request_id)):
777
+ raise SessionUnavailable(
778
+ f"The requested input form {request_id!r} is no longer "
779
+ f"current; inspect response_id={token} and "
780
+ f"notification_event_id={event_id}; never remap fields to a later gate",
781
+ short=worker_id[:8],
782
+ )
783
+ content = None
784
+ if args.content is not None or args.field:
785
+ if action != "accept":
786
+ raise InvalidRequest("respond: only --accept takes --content or --field")
787
+ content = {}
788
+ if args.content is not None:
789
+ try:
790
+ content = json.loads(args.content)
791
+ except ValueError as error:
792
+ raise InvalidRequest(f"respond: invalid content JSON: {error}") from error
793
+ if not isinstance(content, dict):
794
+ raise InvalidRequest("respond: --content must be a JSON object")
795
+ pairs = []
796
+ for raw in args.field or []:
797
+ name, sep, value = raw.partition("=")
798
+ if not sep or not name:
799
+ raise InvalidRequest(f"respond: --field expects NAME=VALUE, got {raw!r}")
800
+ pairs.append((name, value))
801
+ typed, error = _elicitation_field_content(pending, pairs)
802
+ if error:
803
+ raise InvalidRequest(f"respond: {error}")
804
+ content.update(typed)
805
+ if notified:
806
+ accepted = respond_input(
807
+ f"claude:{worker_id}", request_id=request_id, response_id=token,
808
+ action=action, content=content, notify_parent=True,
809
+ timeout=(getattr(args, "timeout", None)
810
+ if getattr(args, "timeout", None) is not None else 90),
811
+ )
812
+ notification = accepted["notification"]
813
+ public = {
814
+ "ok": accepted["status"] == "accepted",
815
+ "worker": f"claude:{worker_id}", "worker_id": worker_id,
816
+ "short": worker_id[:8], "request_id": request_id,
817
+ "response_id": token, "action": action,
818
+ "status": accepted["status"], "owned": accepted["owned"],
819
+ "recommended_action": accepted["recommended_action"],
820
+ "input_mode": "native-input",
821
+ "original_gate_answered": accepted["status"] == "accepted",
822
+ "notify_parent": notification,
823
+ }
824
+ if args.json:
825
+ print(json.dumps(public, ensure_ascii=False, indent=2))
826
+ else:
827
+ print(f"{action} MCP input {accepted['status']} for original request {request_id}")
828
+ print(f"Worker: claude:{worker_id}")
829
+ print(f"notify: {notification['state']} {notification['event_id']} "
830
+ f"-> Codex parent {notification['parent']}")
831
+ print(f"notify: {notification['instruction']}")
832
+ if accepted["status"] in {"submitted", "unknown", "prepared"}:
833
+ raise SystemExit(EXIT_TURN_INCOMPLETE)
834
+ if accepted["status"] == "rejected":
835
+ raise SystemExit(EXIT_REJECTED)
836
+ return
837
+ result = client.respond_elicitation(
838
+ args.target, action, content, request_id=request_id,
839
+ wait=getattr(args, "wait", False),
840
+ timeout=getattr(args, "timeout", None),
841
+ )
842
+ _uuid_input_print(args, action, request_id, result)
843
+ except CcxError as error:
844
+ _uuid_input_error("respond", error)
845
+
846
+
847
+ def cmd_approve(args):
848
+ _cmd_permission_decision(args, "allow")
849
+
850
+
851
+ def cmd_reject(args):
852
+ _cmd_permission_decision(args, "deny")
853
+
854
+
855
+ def cmd_steer(args):
856
+ """One exact owned Claude steer through the native semantic receipt."""
857
+ from uuid import uuid4
858
+ from ccx.client import CcxClient
859
+ from ccx.claude.delivery import valid_delivery_id
860
+ from ccx.errors import CcxError
861
+ text = _resolve_prompt_source(args, "text", "steer", required=True)
862
+ token = getattr(args, "delivery_id", None) or str(uuid4())
863
+ if not valid_delivery_id(token):
864
+ print("ccx steer: invalid --delivery-id (nonempty, at most 128 characters)",
865
+ file=sys.stderr)
866
+ raise SystemExit(2)
867
+ try:
868
+ result = CcxClient().steer(
869
+ args.target, text, delivery_id=token,
870
+ wait=not args.no_wait,
871
+ timeout=STEER_OBSERVE_DEFAULT_S if args.timeout is None else args.timeout,
872
+ caps=_caps_from_args(args),
873
+ )
874
+ except CcxError as error:
875
+ print(f"ccx steer: {error} (delivery_id={token})", file=sys.stderr)
876
+ raise SystemExit(error.exit_code) from error
877
+ out = result.as_dict()
878
+ if args.json:
879
+ print(json.dumps(out, indent=2))
880
+ else:
881
+ print(f"steer: {out['status']} {out['worker']}")
882
+ print(f"delivery_id: {token}")
883
+ if result.timed_out:
884
+ print("Placement still pending; the prompt was NOT re-sent.")
885
+ print(f"Inspect with ccx reconcile {out['worker']} --delivery-id {token}.")
886
+ elif result.evidence:
887
+ print(f"evidence: {result.evidence}")
888
+
889
+
890
+ def cmd_reconcile(args):
891
+ """Read the exact Claude receipt; native steer placement is not turn completion."""
892
+ from ccx.claude import delivery as claude_delivery
893
+ from ccx.client import CcxClient
894
+ from ccx.ops.wait import reconcile_turn
895
+ from ccx.store.root import GenerationError
896
+
897
+ try:
898
+ receipt = claude_delivery.read_receipt(args.target, args.delivery_id)
899
+ if isinstance(receipt, dict) and receipt.get("requestedMode") == "steer_only":
900
+ result = CcxClient().steer_status(args.target, args.delivery_id)
901
+ output, status = result.as_dict(), result.status
902
+ else:
903
+ output = reconcile_turn(args.target, delivery_id=args.delivery_id)
904
+ status = output["status"]
905
+ except (CcxError, GenerationError, claude_delivery.ClaudeDeliveryError) as error:
906
+ print(f"ccx reconcile: {error}", file=sys.stderr)
907
+ raise SystemExit(getattr(error, "exit_code", EXIT_UNAVAILABLE)) from error
908
+ if args.json:
909
+ print(json.dumps(output, ensure_ascii=False, indent=2))
910
+ else:
911
+ print(f"{args.target} delivery {args.delivery_id}: {status}")
912
+ if output.get("evidence"):
913
+ print(f"evidence: {output['evidence']}")
914
+ if status == "not_found":
915
+ raise SystemExit(EXIT_NOT_FOUND)
916
+ if status in {"rejected", "not_delivered"}:
917
+ raise SystemExit(EXIT_REJECTED)
918
+ if status in {"unknown", "prepared", "owned", "submitted", "pending"}:
919
+ raise SystemExit(EXIT_TURN_INCOMPLETE)
920
+
921
+
922
+ def cmd_compact(args):
923
+ """One full-UUID Claude compact through the existing typed original core."""
924
+ from ccx.client import CcxClient
925
+ from ccx.errors import CapabilityUnsupported, CcxError
926
+
927
+ token = getattr(args, "operation_id", None)
928
+ if token is None:
929
+ token = new_compact_operation_id()
930
+ notification = None
931
+ if getattr(args, "notify_parent", False):
932
+ from ccx import constants
933
+ from ccx.errors import InvalidRequest, OperationConflict
934
+ from ccx.notify.registry import compact_notification_failure, create_registration, public_registration
935
+ from ccx.store.root import GenerationError, require_ccx_active
936
+ try:
937
+ if getattr(args, "settle", False):
938
+ raise InvalidRequest("Claude compact does not support --settle")
939
+ store = require_ccx_active(constants.CCX_ROOT)
940
+ owner = store.resolve(args.target)
941
+ if owner.engine != "claude":
942
+ raise InvalidRequest("compact notification needs a managed Claude worker")
943
+ record = store.record(owner.worker)
944
+ parent = require_notify_parent()
945
+ prepare_notify_parent(parent)
946
+ notification = create_registration(
947
+ parent=parent, worker_engine="claude", worker_id=owner.worker_id,
948
+ worker_name=record.get("name"),
949
+ operation={"kind": "claude-compact", "operationId": token,
950
+ "nativeSourceId": (record.get("native") or {}).get("sessionId")},
951
+ source="compact",
952
+ )
953
+ # A cancelled event is sticky; no same-ID retry may turn its
954
+ # original no-effect refusal into a new unnotified native compact.
955
+ if notification["state"] in {"cancelled", "undeliverable"}:
956
+ raise OperationConflict("Compaction notification already finished; start a new request")
957
+ if notification["state"] in {"ready", "delivered"}:
958
+ prior = store.read_receipt(owner.worker, "compact", token)
959
+ if prior is None or prior.get("state") not in {"submitted-unknown", "accepted"}:
960
+ raise OperationConflict("Compaction result could not be verified; inspect its operation")
961
+ # Publication was fsynced before the existing native compact core.
962
+ result = CcxClient().compact(
963
+ owner.worker, wait=False, timeout=args.timeout, operation_id=token)
964
+ except (CcxError, GenerationError, OSError) as exc:
965
+ if notification is not None:
966
+ try:
967
+ uncertain, state = compact_notification_failure(notification, str(exc))
968
+ except (ValueError, OSError) as failure:
969
+ uncertain, state = True, f"unreadable ({failure})"
970
+ code = EXIT_TURN_INCOMPLETE if uncertain else getattr(exc, "exit_code", EXIT_UNAVAILABLE)
971
+ print(f"ccx compact: {exc}; operation_id={token}; "
972
+ f"event_id={notification['eventId']}; notification_state={state}"
973
+ + ("; native effect uncertain, do not retry with a new ID"
974
+ if uncertain else "; no native compact was submitted"),
975
+ file=sys.stderr)
976
+ raise SystemExit(code) from exc
977
+ print(f"ccx compact: {exc} (operation_id={token})", file=sys.stderr)
978
+ raise SystemExit(getattr(exc, "exit_code", EXIT_UNAVAILABLE)) from exc
979
+ out = {**result.as_dict(), "notify_parent": public_registration(notification),
980
+ "accepted": True, "settled": False}
981
+ if args.json:
982
+ print(json.dumps(out, indent=2))
983
+ else:
984
+ print(f"compact {result.operation_status}: {out['worker']}")
985
+ print(f"operation_id: {token}")
986
+ print(f"notify: {out['notify_parent']['state']} {notification['eventId']} (parent model completion unproven)")
987
+ return
988
+ try:
989
+ if getattr(args, "settle", False):
990
+ raise CapabilityUnsupported("Claude compact does not support --settle",
991
+ engine="claude", capability="compact_settle")
992
+ result = CcxClient().compact(
993
+ args.target, wait=not args.no_wait, timeout=args.timeout,
994
+ operation_id=token,
995
+ )
996
+ except CcxError as exc:
997
+ # A caller can only safely reconcile the *same* native operation ID.
998
+ print(f"ccx compact: {exc} (operation_id={token})", file=sys.stderr)
999
+ raise SystemExit(exc.exit_code) from exc
1000
+
1001
+ out = result.as_dict()
1002
+ if args.json:
1003
+ print(json.dumps(out, indent=2))
1004
+ elif args.no_wait:
1005
+ print(f"compact {result.operation_status or 'accepted'} (not yet proven): "
1006
+ f"{out['worker']}")
1007
+ print(f"operation_id: {token}")
1008
+ elif result.compacted:
1009
+ if type(result.pre_tokens) is int and type(result.post_tokens) is int:
1010
+ print(f"compacted {result.pre_tokens} -> {result.post_tokens} tokens")
1011
+ else:
1012
+ print("compacted")
1013
+ print(f"operation_id: {token}")
1014
+ else:
1015
+ print(result.message or "nothing to compact")
1016
+ print(f"operation_id: {token}")
1017
+
1018
+
1019
+ def cmd_set(args):
1020
+ """Configure future Claude spawns with the existing exact public core."""
1021
+ from ccx.client import CcxClient
1022
+ from ccx.errors import CcxError
1023
+
1024
+ name_given = args.name is not None
1025
+ model_given = args.model is not None
1026
+ effort_given = args.effort is not None
1027
+ permissions_given = args.permissions is not None
1028
+ config_given = model_given or effort_given or permissions_given
1029
+ if not (name_given or config_given):
1030
+ print("ccx set: nothing to change (use --name, --model, --effort or --permissions)",
1031
+ file=sys.stderr)
1032
+ raise SystemExit(1)
1033
+ if args.restart and not config_given:
1034
+ print("ccx set: --restart requires --model, --effort and/or --permissions; "
1035
+ "no rename performed", file=sys.stderr)
1036
+ raise SystemExit(1)
1037
+ try:
1038
+ client = CcxClient()
1039
+ restarted = False
1040
+ if config_given:
1041
+ changes = {}
1042
+ if model_given:
1043
+ # Preserve the established Claude CLI meaning: unlike Codex,
1044
+ # --model default is a literal provider model name.
1045
+ changes["model"] = args.model
1046
+ if effort_given:
1047
+ changes["effort"] = None if args.effort == "default" else args.effort
1048
+ if permissions_given:
1049
+ changes["permissions"] = args.permissions
1050
+ # One original owner CAS, and at most ONE confirmed host stop,
1051
+ # even when all three config switches are specified together.
1052
+ info, restarted = client._set_spawn_config(
1053
+ args.target, changes, restart=args.restart,
1054
+ )
1055
+ else:
1056
+ info = None
1057
+ if name_given:
1058
+ # A refused config must not have already renamed this worker.
1059
+ info = client.set_name(args.target, args.name)
1060
+ assert info is not None
1061
+ except CcxError as error:
1062
+ print(f"ccx set: {error}", file=sys.stderr)
1063
+ raise SystemExit(error.exit_code) from error
1064
+ result = {
1065
+ "worker": f"claude:{info.worker_id}", "worker_id": info.worker_id,
1066
+ "engine": "claude", "short": info.short,
1067
+ "action": ("restart" if restarted else "set")
1068
+ if config_given else "rename",
1069
+ }
1070
+ if name_given:
1071
+ result["name"] = info.name
1072
+ if model_given:
1073
+ result["model"] = info.model
1074
+ if effort_given:
1075
+ result["effort"] = info.effort
1076
+ if permissions_given:
1077
+ result["permissions"] = args.permissions
1078
+ if args.json:
1079
+ print(json.dumps(result, sort_keys=True, ensure_ascii=False))
1080
+ else:
1081
+ print(result["worker"])