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
package/ccx/ops/input.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from ccx.rpc import rpc as host_rpc
|
|
4
|
+
from ccx.codex import evidence as codex_evidence
|
|
5
|
+
from ccx.controllers.orchestrator import require_notify_parent
|
|
6
|
+
from ccx.notify.registry import cancel_registration as cancel_parent_notification, create_registration as create_parent_notification, public_registration as public_parent_notification, read_registration as read_parent_notification
|
|
7
|
+
from ccx.notify.daemon import prepare_notify_parent
|
|
8
|
+
from ccx.errors import (
|
|
9
|
+
CapabilityUnsupported,
|
|
10
|
+
DeliveryRejected,
|
|
11
|
+
InvalidRequest,
|
|
12
|
+
OperationConflict,
|
|
13
|
+
SessionUnavailable,
|
|
14
|
+
)
|
|
15
|
+
from ccx.ops.semantics import (
|
|
16
|
+
ELICITATION_ACTIONS,
|
|
17
|
+
KIND_ELICITATION,
|
|
18
|
+
KIND_PERMISSION,
|
|
19
|
+
KIND_QUESTION,
|
|
20
|
+
elicitation_response_problem,
|
|
21
|
+
pending_input_kind,
|
|
22
|
+
)
|
|
23
|
+
from ccx.store.worker_state import RUNTIME_SERVED
|
|
24
|
+
from ccx.ops.common import MAX_RESPONSE_ID, _CLIENT, _CODEX_CLIENT, recorded_inspector
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_answer_map(value: object) -> dict[str, str]:
|
|
28
|
+
if not isinstance(value, dict) or not value:
|
|
29
|
+
raise InvalidRequest("answers must be a non-empty object")
|
|
30
|
+
result: dict[str, str] = {}
|
|
31
|
+
for question, answer in value.items():
|
|
32
|
+
if not isinstance(question, str) or not question:
|
|
33
|
+
raise InvalidRequest("answer question keys must be non-empty strings")
|
|
34
|
+
if not isinstance(answer, str) or not answer:
|
|
35
|
+
raise InvalidRequest("answer values must be non-empty strings")
|
|
36
|
+
result[question] = answer
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _answer_text_pairs(
|
|
41
|
+
waiting_for: dict[str, Any], answers: dict[str, str]
|
|
42
|
+
) -> list[tuple[int, str]]:
|
|
43
|
+
source = waiting_for.get("input")
|
|
44
|
+
questions = (
|
|
45
|
+
source.get("questions")
|
|
46
|
+
if isinstance(source, dict)
|
|
47
|
+
else None
|
|
48
|
+
)
|
|
49
|
+
questions = list(questions or [])
|
|
50
|
+
expected: list[str] = []
|
|
51
|
+
for question in questions:
|
|
52
|
+
text = question.get("question") if isinstance(question, dict) else None
|
|
53
|
+
if isinstance(text, str) and text:
|
|
54
|
+
expected.append(text)
|
|
55
|
+
missing = [text for text in expected if text not in answers]
|
|
56
|
+
extra = [text for text in answers if text not in set(expected)]
|
|
57
|
+
if missing:
|
|
58
|
+
raise InvalidRequest(
|
|
59
|
+
"answers is missing question(s): " + "; ".join(missing)
|
|
60
|
+
)
|
|
61
|
+
if extra:
|
|
62
|
+
raise InvalidRequest(
|
|
63
|
+
"answers contains unknown question(s): " + "; ".join(extra)
|
|
64
|
+
)
|
|
65
|
+
return [
|
|
66
|
+
(index + 1, answers[text])
|
|
67
|
+
for index, text in enumerate(expected)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _original_input_evidence(store, owner, kind: str, request_id: str,
|
|
72
|
+
response_id: str, payload: dict, native_id: str):
|
|
73
|
+
"""Bounded read of the original native response event, never a short alias."""
|
|
74
|
+
from ccx.store.store import _digest
|
|
75
|
+
|
|
76
|
+
if owner.engine == "claude":
|
|
77
|
+
from ccx.ops.worker_events import read_full_uuid_event_page
|
|
78
|
+
event_type = ("question_answered" if kind == "answer"
|
|
79
|
+
else "elicitation_resolved" if kind == "respond"
|
|
80
|
+
else ("permission_approved"
|
|
81
|
+
if payload["decision"] == "approve"
|
|
82
|
+
else "permission_rejected"))
|
|
83
|
+
page = read_full_uuid_event_page(
|
|
84
|
+
store, owner.worker, tail=1000, kinds=["input.resolved"],
|
|
85
|
+
)
|
|
86
|
+
for event in reversed(page["events"]):
|
|
87
|
+
if (event.get("type") == event_type
|
|
88
|
+
and event.get("request_id") == request_id
|
|
89
|
+
and event.get("response_id") == response_id
|
|
90
|
+
and (kind != "respond"
|
|
91
|
+
or event.get("action") == payload["action"])):
|
|
92
|
+
label = f"host-event:{event.get('epoch')}:{event.get('seq')}"
|
|
93
|
+
sha = _digest({
|
|
94
|
+
"workerId": owner.worker_id, "requestId": request_id,
|
|
95
|
+
"responseId": response_id, "cursor": event["cursor"],
|
|
96
|
+
})
|
|
97
|
+
return label, sha
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
journal = codex_evidence.read_journal(owner.worker)
|
|
101
|
+
expected = "accept" if payload["decision"] == "approve" else "decline"
|
|
102
|
+
for event in reversed(journal.events):
|
|
103
|
+
params = event.get("params")
|
|
104
|
+
if (event.get("method") == codex_evidence.INPUT_RESOLVED_METHOD
|
|
105
|
+
and event.get("threadId") == native_id
|
|
106
|
+
and isinstance(params, dict)
|
|
107
|
+
and params.get("gateId") == request_id
|
|
108
|
+
and params.get("responseId") == response_id
|
|
109
|
+
and params.get("decision") == expected):
|
|
110
|
+
label = f"codex-evidence:{event.get('sequence')}"
|
|
111
|
+
sha = _digest({
|
|
112
|
+
"workerId": owner.worker_id, "threadId": native_id,
|
|
113
|
+
"requestId": request_id, "responseId": response_id,
|
|
114
|
+
"journalInstanceId": journal.journal_instance_id,
|
|
115
|
+
"sequence": event.get("sequence"), "decision": expected,
|
|
116
|
+
})
|
|
117
|
+
return label, sha
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _original_input_public(store, owner, kind: str, request_id: str,
|
|
122
|
+
response_id: str, receipt: dict, *,
|
|
123
|
+
evidence: str | None = None,
|
|
124
|
+
unresolved: bool = False,
|
|
125
|
+
notification: dict | None = None) -> dict:
|
|
126
|
+
"""Legacy protocol shape, read exclusively from the global native receipt."""
|
|
127
|
+
|
|
128
|
+
state = receipt["state"]
|
|
129
|
+
status = ("unknown" if unresolved else "submitted"
|
|
130
|
+
if state == "submitted-unknown" else state)
|
|
131
|
+
action = ("retry_same_id" if status == "prepared"
|
|
132
|
+
else "wait" if status == "submitted"
|
|
133
|
+
else "inspect" if status == "unknown" else "none")
|
|
134
|
+
proof = receipt.get("nativeEvidenceSha256")
|
|
135
|
+
row = {
|
|
136
|
+
"response_id": response_id, "request_id": request_id, "kind": kind,
|
|
137
|
+
"status": status, "owned": status in ("submitted", "accepted", "unknown"),
|
|
138
|
+
"recommended_action": action,
|
|
139
|
+
"notification_event_id": notification["eventId"] if notification else None,
|
|
140
|
+
"created_at_ms": None, "submitted_at_ms": None, "completed_at_ms": None,
|
|
141
|
+
"evidence": evidence or ("native-sha256:" + proof if proof else None),
|
|
142
|
+
"reason": ("original input fate remains unproven; inspect without resubmitting"
|
|
143
|
+
if unresolved else None),
|
|
144
|
+
}
|
|
145
|
+
return {"worker": recorded_inspector(store, owner.worker),
|
|
146
|
+
**row, "receipt": dict(row),
|
|
147
|
+
"notification": (public_parent_notification(notification)
|
|
148
|
+
if notification is not None else None)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _input_intent_digest(
|
|
152
|
+
*, worker_id: str, engine: str, generation: str, native_id: str,
|
|
153
|
+
native_source: str, kind: str, request_id: str, response_id: str,
|
|
154
|
+
payload: dict | None = None, payload_sha256: str | None = None,
|
|
155
|
+
) -> str:
|
|
156
|
+
"""One native input-intent digest, also verifying frozen notification hashes."""
|
|
157
|
+
from ccx.store.store import _digest
|
|
158
|
+
from ccx.store.root import _HASH
|
|
159
|
+
if (payload is None) == (payload_sha256 is None):
|
|
160
|
+
raise InvalidRequest("input digest needs exactly one payload or frozen hash")
|
|
161
|
+
if payload_sha256 is not None and (
|
|
162
|
+
not isinstance(payload_sha256, str) or not _HASH.fullmatch(payload_sha256)
|
|
163
|
+
):
|
|
164
|
+
raise InvalidRequest("input payload hash must be 64 lowercase hex characters")
|
|
165
|
+
return _digest({
|
|
166
|
+
"workerId": worker_id, "engine": engine,
|
|
167
|
+
"generation": generation, "nativeId": native_id,
|
|
168
|
+
"nativeSource": native_source, "kind": kind, "requestId": request_id,
|
|
169
|
+
"responseId": response_id,
|
|
170
|
+
"payloadSha256": _digest(payload) if payload_sha256 is None else payload_sha256,
|
|
171
|
+
"action": "answer",
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def valid_response_id(value: object) -> bool:
|
|
176
|
+
return (
|
|
177
|
+
isinstance(value, str)
|
|
178
|
+
and bool(value)
|
|
179
|
+
and len(value) <= MAX_RESPONSE_ID
|
|
180
|
+
and "\x00" not in value
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _original_input(
|
|
185
|
+
target: str, *, kind: str, request_id: str, response_id: str,
|
|
186
|
+
payload: dict, timeout: float | None,
|
|
187
|
+
notify_parent: bool = False, settle: bool = False,
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
"""One full-UUID response receipt and <=2s original native effect."""
|
|
190
|
+
from ccx import constants
|
|
191
|
+
from ccx.codex import runtime as codex_runtime
|
|
192
|
+
from ccx.store.root import NativeEffectIndeterminate, require_ccx_active
|
|
193
|
+
from ccx.store.store import OperationIntent, _digest
|
|
194
|
+
|
|
195
|
+
if (not isinstance(request_id, str) or not request_id
|
|
196
|
+
or len(request_id) > 256 or "\x00" in request_id):
|
|
197
|
+
raise InvalidRequest("request_id must be a nonempty bounded string")
|
|
198
|
+
if not valid_response_id(response_id):
|
|
199
|
+
raise InvalidRequest("response_id must be a nonempty string of at most 128 characters")
|
|
200
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
201
|
+
owner = store.resolve(target)
|
|
202
|
+
if settle and not notify_parent:
|
|
203
|
+
raise InvalidRequest("--settle requires --notify-parent")
|
|
204
|
+
if notify_parent and kind not in ("decide", "answer", "respond"):
|
|
205
|
+
raise CapabilityUnsupported("input notification requires original Claude input",
|
|
206
|
+
engine=owner.engine, capability="decision_notify_parent")
|
|
207
|
+
if notify_parent and settle and owner.engine != "codex":
|
|
208
|
+
raise InvalidRequest("Claude permission notification does not support --settle")
|
|
209
|
+
parent = None
|
|
210
|
+
if notify_parent:
|
|
211
|
+
parent = require_notify_parent()
|
|
212
|
+
prepare_notify_parent(parent) # existing original Codex return-address gate
|
|
213
|
+
if kind in ("answer", "respond") and owner.engine != "claude":
|
|
214
|
+
raise CapabilityUnsupported(
|
|
215
|
+
f"{kind} requires an original Claude input gate",
|
|
216
|
+
engine=owner.engine,
|
|
217
|
+
capability="answer_question" if kind == "answer" else "respond_elicitation",
|
|
218
|
+
)
|
|
219
|
+
if owner.engine == "codex" and len(request_id) > 128:
|
|
220
|
+
raise InvalidRequest("Codex approval requires a bounded original gateId")
|
|
221
|
+
rec = store.record(owner.worker)
|
|
222
|
+
native = rec.get("native") or {}
|
|
223
|
+
activation = store.native_activation(owner.worker)
|
|
224
|
+
binding = activation.get("nativeBinding") or {}
|
|
225
|
+
name = "sessionId" if owner.engine == "claude" else "threadId"
|
|
226
|
+
native_id = native.get(name)
|
|
227
|
+
source = (f"claude-session:{native_id}" if owner.engine == "claude"
|
|
228
|
+
else store.codex_source(owner.worker))
|
|
229
|
+
if (not isinstance(native_id, str) or not native_id
|
|
230
|
+
or activation.get("state") != "confirmed"
|
|
231
|
+
or activation.get("source") != source
|
|
232
|
+
or binding.get(name) != native_id
|
|
233
|
+
or (owner.engine == "codex"
|
|
234
|
+
and native.get("threadSource") != source)):
|
|
235
|
+
raise SessionUnavailable("original input native source is unconfirmed",
|
|
236
|
+
short=owner.short)
|
|
237
|
+
digest = _input_intent_digest(
|
|
238
|
+
worker_id=owner.worker_id, engine=owner.engine,
|
|
239
|
+
generation=store.generation, native_id=native_id, native_source=source,
|
|
240
|
+
kind=kind, request_id=request_id, response_id=response_id, payload=payload,
|
|
241
|
+
)
|
|
242
|
+
prior = store.read_receipt(owner.worker, "answer", response_id)
|
|
243
|
+
if prior is not None and (
|
|
244
|
+
prior.get("intentSha256") != digest
|
|
245
|
+
or prior.get("family") != "answer"
|
|
246
|
+
or prior.get("action") != "answer"
|
|
247
|
+
):
|
|
248
|
+
raise OperationConflict(
|
|
249
|
+
"response_id is already bound to another gate/kind/payload",
|
|
250
|
+
short=owner.short,
|
|
251
|
+
)
|
|
252
|
+
def previous_notification() -> dict:
|
|
253
|
+
# Read only: never arm a new parent event after any possible native
|
|
254
|
+
# response, nor re-select a later active turn/gate on replay.
|
|
255
|
+
from uuid import uuid5, NAMESPACE_URL
|
|
256
|
+
event_id = str(uuid5(NAMESPACE_URL,
|
|
257
|
+
"ccx-notify:" +
|
|
258
|
+
f"full-uuid-{owner.engine}-input:{store.generation}:{owner.worker_id}:{request_id}"))
|
|
259
|
+
row = read_parent_notification(event_id)
|
|
260
|
+
if row is None:
|
|
261
|
+
raise OperationConflict(
|
|
262
|
+
f"original input {response_id} was submitted without a parent event; "
|
|
263
|
+
"cannot arm after native effect", short=owner.short)
|
|
264
|
+
operation = row.get("operation") or {}
|
|
265
|
+
expected_kind = (f"claude-input-{kind}" if kind in ("answer", "respond")
|
|
266
|
+
else f"{owner.engine}-input-decision")
|
|
267
|
+
if (row.get("source") != "input"
|
|
268
|
+
or operation.get("kind") != expected_kind
|
|
269
|
+
or operation.get("gateId") != request_id
|
|
270
|
+
or operation.get("responseId") != response_id
|
|
271
|
+
or (kind in ("answer", "respond")
|
|
272
|
+
and operation.get("payloadSha256") != _digest(payload))
|
|
273
|
+
or (kind == "decide"
|
|
274
|
+
and (operation.get("decision") != payload["decision"]
|
|
275
|
+
or (owner.engine == "claude"
|
|
276
|
+
and operation.get("reason") != payload["reason"])))
|
|
277
|
+
or operation.get("intentSha256") != digest
|
|
278
|
+
or row.get("parent") != parent
|
|
279
|
+
or row.get("settlementMode") != ("orchestration" if settle else None)):
|
|
280
|
+
raise OperationConflict(
|
|
281
|
+
"original input event is owned by another input, parent or settlement mode",
|
|
282
|
+
short=owner.short)
|
|
283
|
+
public_parent_notification(row) # validates the frozen native proof and digest
|
|
284
|
+
return row
|
|
285
|
+
|
|
286
|
+
proof = None
|
|
287
|
+
if prior is not None:
|
|
288
|
+
if prior["state"] == "submitted-unknown":
|
|
289
|
+
proof = _original_input_evidence(
|
|
290
|
+
store, owner, kind, request_id, response_id, payload, native_id,
|
|
291
|
+
)
|
|
292
|
+
if proof:
|
|
293
|
+
original = OperationIntent(
|
|
294
|
+
owner.worker_id, owner.engine, "answer", response_id,
|
|
295
|
+
digest, "submitted-unknown", store.generation,
|
|
296
|
+
)
|
|
297
|
+
prior = store.settle_native_intent(
|
|
298
|
+
original, state="accepted", native_evidence_sha256=proof[1],
|
|
299
|
+
)
|
|
300
|
+
if prior["state"] != "prepared":
|
|
301
|
+
current = store.record(owner.worker)
|
|
302
|
+
if owner.engine == "claude":
|
|
303
|
+
waiting = current.get("waitingFor")
|
|
304
|
+
still_original = (isinstance(waiting, dict)
|
|
305
|
+
and waiting.get("id") == request_id)
|
|
306
|
+
else:
|
|
307
|
+
still_original = any(
|
|
308
|
+
isinstance(gate, dict) and gate.get("gateId") == request_id
|
|
309
|
+
for gate in current.get("pendingApprovals") or []
|
|
310
|
+
)
|
|
311
|
+
store.revalidate()
|
|
312
|
+
return _original_input_public(
|
|
313
|
+
store, owner, kind, request_id, response_id, prior,
|
|
314
|
+
evidence=proof[0] if proof else None,
|
|
315
|
+
unresolved=prior["state"] == "submitted-unknown"
|
|
316
|
+
and not still_original,
|
|
317
|
+
notification=previous_notification() if notify_parent else None,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Fresh/PREPARED only: any prior effect was handled before native gate selection.
|
|
321
|
+
input_turn_ref = None
|
|
322
|
+
if owner.engine == "claude":
|
|
323
|
+
current = rec.get("waitingFor")
|
|
324
|
+
expected = {"answer": KIND_QUESTION, "decide": KIND_PERMISSION,
|
|
325
|
+
"respond": KIND_ELICITATION}[kind]
|
|
326
|
+
if (not isinstance(current, dict)
|
|
327
|
+
or pending_input_kind(current) != expected
|
|
328
|
+
or current.get("id") != request_id):
|
|
329
|
+
raise DeliveryRejected(
|
|
330
|
+
"the exact original Claude input gate is not pending",
|
|
331
|
+
short=owner.short, refusal="request-id-mismatch",
|
|
332
|
+
)
|
|
333
|
+
if kind == "answer":
|
|
334
|
+
pairs = _answer_text_pairs(current, payload)
|
|
335
|
+
if not pairs:
|
|
336
|
+
raise InvalidRequest("no pending original question")
|
|
337
|
+
elif kind == "respond":
|
|
338
|
+
problem = elicitation_response_problem(
|
|
339
|
+
current, payload["action"], payload["content"],
|
|
340
|
+
)
|
|
341
|
+
if problem:
|
|
342
|
+
raise InvalidRequest(f"respond: {problem}", short=owner.short)
|
|
343
|
+
if not host_rpc.host_socket_live(owner.worker_id, rec):
|
|
344
|
+
raise SessionUnavailable("Claude cannot receive input; check worker status",
|
|
345
|
+
short=owner.short)
|
|
346
|
+
if notify_parent:
|
|
347
|
+
input_turn_ref, _prior_ref = _CLIENT._input_turn_fence(
|
|
348
|
+
owner.worker_id, rec, request_id, expected,
|
|
349
|
+
_CLIENT._input_deadline(timeout),
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
gate = next((
|
|
353
|
+
value for value in rec.get("pendingApprovals") or []
|
|
354
|
+
if isinstance(value, dict) and value.get("gateId") == request_id
|
|
355
|
+
), None)
|
|
356
|
+
if (gate is None or not isinstance(gate.get("turnId"), str)
|
|
357
|
+
or not gate["turnId"]):
|
|
358
|
+
raise DeliveryRejected("no original Codex approval for this gateId",
|
|
359
|
+
short=owner.short, refusal="request-id-mismatch")
|
|
360
|
+
runtime = codex_runtime.daemon_rpc({
|
|
361
|
+
"op": "status", "short": owner.worker_id,
|
|
362
|
+
"rpcGeneration": constants.CODEXD_RPC_GENERATION,
|
|
363
|
+
}, timeout=min(0.75, timeout if timeout is not None else 0.75))
|
|
364
|
+
if (runtime.get("ok") is not True
|
|
365
|
+
or runtime.get("transport") != RUNTIME_SERVED
|
|
366
|
+
or runtime.get("loaded") is not True):
|
|
367
|
+
raise SessionUnavailable("Codex approval is unavailable; check worker status",
|
|
368
|
+
short=owner.short)
|
|
369
|
+
store.authorize_native_action(owner.worker, "answer")
|
|
370
|
+
intent = store.begin_native_intent(
|
|
371
|
+
owner.worker, action="answer", family="answer",
|
|
372
|
+
operation_id=response_id, intent_sha256=digest,
|
|
373
|
+
)
|
|
374
|
+
if intent.replayed:
|
|
375
|
+
return _original_input_public(
|
|
376
|
+
store, owner, kind, request_id, response_id,
|
|
377
|
+
store.read_receipt(owner.worker, "answer", response_id),
|
|
378
|
+
notification=previous_notification() if notify_parent else None,
|
|
379
|
+
)
|
|
380
|
+
notification = None
|
|
381
|
+
if notify_parent:
|
|
382
|
+
# First original input intent is PREPARED; use its unchanged digest.
|
|
383
|
+
from ccx.notify.daemon import ensure_notify_daemon
|
|
384
|
+
operation = {
|
|
385
|
+
"kind": (f"claude-input-{kind}" if kind in ("answer", "respond")
|
|
386
|
+
else f"{owner.engine}-input-decision"),
|
|
387
|
+
"gateId": request_id, "responseId": response_id,
|
|
388
|
+
"nativeSourceId": native_id, "intentSha256": digest,
|
|
389
|
+
"selectedTurnRef": (input_turn_ref if owner.engine == "claude"
|
|
390
|
+
else f"ccx-turn:codex:v1:{gate['turnId']}"),
|
|
391
|
+
}
|
|
392
|
+
if kind in ("answer", "respond"):
|
|
393
|
+
operation["payloadSha256"] = _digest(payload)
|
|
394
|
+
else:
|
|
395
|
+
operation["decision"] = payload["decision"]
|
|
396
|
+
if owner.engine == "claude":
|
|
397
|
+
operation["reason"] = payload["reason"]
|
|
398
|
+
else:
|
|
399
|
+
operation["turnId"] = gate["turnId"]
|
|
400
|
+
notification = create_parent_notification(
|
|
401
|
+
parent=parent, worker_engine=owner.engine, worker_id=owner.worker_id,
|
|
402
|
+
worker_name=rec.get("name"), source="input", settle=settle,
|
|
403
|
+
operation=operation,
|
|
404
|
+
)
|
|
405
|
+
ensure_notify_daemon()
|
|
406
|
+
|
|
407
|
+
def native_submit(budget: float) -> dict:
|
|
408
|
+
try:
|
|
409
|
+
if owner.engine == "claude":
|
|
410
|
+
if kind == "answer":
|
|
411
|
+
_CLIENT.answer(
|
|
412
|
+
owner.worker, text_answers=pairs, request_id=request_id,
|
|
413
|
+
response_id=response_id, wait=False,
|
|
414
|
+
fallback_to_send=False, timeout=budget,
|
|
415
|
+
)
|
|
416
|
+
elif kind == "decide":
|
|
417
|
+
verb = (_CLIENT.approve if payload["decision"] == "approve"
|
|
418
|
+
else _CLIENT.reject)
|
|
419
|
+
verb(
|
|
420
|
+
owner.worker, request_id=request_id,
|
|
421
|
+
response_id=response_id, reason=payload["reason"],
|
|
422
|
+
wait=False, timeout=budget,
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
_CLIENT.respond_elicitation(
|
|
426
|
+
owner.worker, payload["action"], payload["content"],
|
|
427
|
+
request_id=request_id, response_id=response_id,
|
|
428
|
+
wait=False, timeout=budget,
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
verb = "approve" if payload["decision"] == "approve" else "reject"
|
|
432
|
+
_CODEX_CLIENT._decision(
|
|
433
|
+
owner.worker, verb, request_id, budget,
|
|
434
|
+
response_id=response_id, expected_turn_id=gate["turnId"],
|
|
435
|
+
)
|
|
436
|
+
except InvalidRequest as error:
|
|
437
|
+
# Accepted Python validators raise this before native IPC.
|
|
438
|
+
return {"noEffect": True, "definitive": True, "reason": str(error)}
|
|
439
|
+
except DeliveryRejected as error:
|
|
440
|
+
# Codex's private validator only rejects after definitive served
|
|
441
|
+
# refusal; Claude's exact request-id guard is pre-request.
|
|
442
|
+
if (owner.engine == "codex"
|
|
443
|
+
or getattr(error, "reason", None) == "request id mismatch"):
|
|
444
|
+
return {"noEffect": True, "definitive": True, "reason": str(error)}
|
|
445
|
+
return {"ambiguous": True}
|
|
446
|
+
except Exception:
|
|
447
|
+
# Connection reset, unreachable, timeout and SessionUnavailable
|
|
448
|
+
# may occur after sendall. Never call those definite no-effect.
|
|
449
|
+
return {"ambiguous": True}
|
|
450
|
+
return {"accepted": True}
|
|
451
|
+
|
|
452
|
+
reply = None
|
|
453
|
+
try:
|
|
454
|
+
reply = store.dispatch_native_intent_once(
|
|
455
|
+
intent, bounded_submit=native_submit,
|
|
456
|
+
dispatch_budget_s=min(2.0, timeout if timeout is not None else 2.0),
|
|
457
|
+
)
|
|
458
|
+
except NativeEffectIndeterminate:
|
|
459
|
+
pass
|
|
460
|
+
if reply and reply.get("accepted") is True:
|
|
461
|
+
sha = _digest({
|
|
462
|
+
"workerId": owner.worker_id, "kind": kind,
|
|
463
|
+
"nativeSource": source, "requestId": request_id,
|
|
464
|
+
"responseId": response_id, "positive": "original-native-ack",
|
|
465
|
+
})
|
|
466
|
+
receipt = store.settle_native_intent(
|
|
467
|
+
intent, state="accepted", native_evidence_sha256=sha,
|
|
468
|
+
)
|
|
469
|
+
elif reply and (reply.get("noEffect") is True
|
|
470
|
+
and reply.get("definitive") is True):
|
|
471
|
+
sha = _digest({
|
|
472
|
+
"workerId": owner.worker_id, "kind": kind,
|
|
473
|
+
"nativeSource": source, "requestId": request_id,
|
|
474
|
+
"responseId": response_id, "noEffect": reply.get("reason"),
|
|
475
|
+
})
|
|
476
|
+
receipt = store.settle_native_intent(
|
|
477
|
+
intent, state="rejected", native_evidence_sha256=sha,
|
|
478
|
+
)
|
|
479
|
+
if notification is not None:
|
|
480
|
+
cancel_parent_notification(
|
|
481
|
+
notification["eventId"],
|
|
482
|
+
str(reply.get("reason") or "original native input definitively refused"),
|
|
483
|
+
)
|
|
484
|
+
notification = read_parent_notification(notification["eventId"]) or notification
|
|
485
|
+
else:
|
|
486
|
+
receipt = store.read_receipt(owner.worker, "answer", response_id)
|
|
487
|
+
proof = _original_input_evidence(
|
|
488
|
+
store, owner, kind, request_id, response_id, payload, native_id,
|
|
489
|
+
)
|
|
490
|
+
if proof and receipt["state"] == "submitted-unknown":
|
|
491
|
+
receipt = store.settle_native_intent(
|
|
492
|
+
intent, state="accepted", native_evidence_sha256=proof[1],
|
|
493
|
+
)
|
|
494
|
+
store.revalidate()
|
|
495
|
+
return _original_input_public(
|
|
496
|
+
store, owner, kind, request_id, response_id, receipt,
|
|
497
|
+
evidence=proof[0] if proof else None, notification=notification,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def answer_input(
|
|
502
|
+
target: str, *, request_id: str, response_id: str,
|
|
503
|
+
answers: dict[str, str], timeout: float | None = 90,
|
|
504
|
+
notify_parent: bool = False,
|
|
505
|
+
) -> dict[str, Any]:
|
|
506
|
+
return _original_input(
|
|
507
|
+
target, kind="answer", request_id=request_id, response_id=response_id,
|
|
508
|
+
payload=_validate_answer_map(answers), timeout=timeout,
|
|
509
|
+
notify_parent=notify_parent,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def decide_input(
|
|
514
|
+
target: str, *, request_id: str, response_id: str,
|
|
515
|
+
decision: str, reason: str | None = None,
|
|
516
|
+
timeout: float | None = 90,
|
|
517
|
+
notify_parent: bool = False, settle: bool = False,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
if decision not in ("approve", "reject"):
|
|
520
|
+
raise InvalidRequest("decision must be approve or reject")
|
|
521
|
+
if reason is not None and (not isinstance(reason, str) or not reason):
|
|
522
|
+
raise InvalidRequest("reason must be a nonempty string")
|
|
523
|
+
from ccx import constants
|
|
524
|
+
from ccx.store.root import require_ccx_active
|
|
525
|
+
|
|
526
|
+
owner = require_ccx_active(constants.CCX_ROOT).resolve(target)
|
|
527
|
+
if settle and not notify_parent:
|
|
528
|
+
raise InvalidRequest("--settle requires --notify-parent")
|
|
529
|
+
if notify_parent and settle and owner.engine != "codex":
|
|
530
|
+
raise InvalidRequest("Claude permission notification does not support --settle")
|
|
531
|
+
if owner.engine == "codex" and reason is not None:
|
|
532
|
+
raise CapabilityUnsupported(
|
|
533
|
+
"Codex approvals do not accept a rationale",
|
|
534
|
+
engine="codex", capability="decision_reason",
|
|
535
|
+
)
|
|
536
|
+
return _original_input(
|
|
537
|
+
owner.worker, kind="decide", request_id=request_id,
|
|
538
|
+
response_id=response_id, payload={"decision": decision, "reason": reason},
|
|
539
|
+
timeout=timeout, notify_parent=notify_parent, settle=settle,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def respond_input(
|
|
544
|
+
target: str, *, request_id: str, response_id: str,
|
|
545
|
+
action: str, content: dict[str, Any] | None = None,
|
|
546
|
+
timeout: float | None = 90, notify_parent: bool = False,
|
|
547
|
+
) -> dict[str, Any]:
|
|
548
|
+
if action not in ELICITATION_ACTIONS:
|
|
549
|
+
raise InvalidRequest("action must be accept, decline or cancel")
|
|
550
|
+
if content is not None and (not isinstance(content, dict) or action != "accept"):
|
|
551
|
+
raise InvalidRequest("content is only valid as an object with action=accept")
|
|
552
|
+
return _original_input(
|
|
553
|
+
target, kind="respond", request_id=request_id, response_id=response_id,
|
|
554
|
+
payload={"action": action, "content": content}, timeout=timeout,
|
|
555
|
+
notify_parent=notify_parent,
|
|
556
|
+
)
|