ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,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
+ )