ccx-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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"])
|