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,438 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import os
4
+ import pathlib
5
+ import socket
6
+ import sys
7
+ import time
8
+ from ccx.constants import (
9
+ BOUNDARY_SEND_ACK_S,
10
+ HOOK_RELAY_RETRY_POLL_S,
11
+ HOOK_RELAY_RETRY_S,
12
+ PERMISSION_HOOK_TIMEOUT_S,
13
+ PERMISSION_POLL_INTERVAL_S,
14
+ )
15
+ from ccx.store.liveness import lifecycle_host_alive
16
+ from ccx.store.paths import sock_path
17
+ from ccx.store.records import (
18
+ read_record,
19
+ )
20
+ from ccx.claude.transcript import (
21
+ api_error_from_event,
22
+ )
23
+ from ccx.claude.host.subagents import _hook_subagent_id
24
+
25
+
26
+ def _read_new_api_error(transcript_path: str | pathlib.Path, start_pos: int = 0) -> tuple[dict | None, int]:
27
+ """Scan newly-appended JSONL transcript bytes for a terminal API error.
28
+
29
+ Returns ``(summary, new_pos)``. If the file is truncated or recreated, the
30
+ scan restarts at byte 0. A partial final line is left unread so the next poll
31
+ can parse it after Claude finishes writing.
32
+ """
33
+ path = pathlib.Path(transcript_path)
34
+ try:
35
+ size = path.stat().st_size
36
+ except OSError:
37
+ return None, start_pos
38
+ pos = max(0, int(start_pos or 0))
39
+ if pos > size:
40
+ pos = 0
41
+ if pos == size:
42
+ return None, size
43
+ try:
44
+ with path.open("rb") as f:
45
+ f.seek(pos)
46
+ while True:
47
+ line_start = f.tell()
48
+ raw = f.readline()
49
+ if not raw:
50
+ return None, f.tell()
51
+ try:
52
+ evt = json.loads(raw.decode("utf-8"))
53
+ except (UnicodeDecodeError, ValueError):
54
+ if not raw.endswith(b"\n"):
55
+ return None, line_start
56
+ continue
57
+ if isinstance(evt, dict) and evt.get("isSidechain") is True:
58
+ continue # a subagent's error is not the session's
59
+ api_error = api_error_from_event(evt)
60
+ if api_error:
61
+ return api_error, f.tell()
62
+ except OSError:
63
+ return None, pos
64
+
65
+
66
+ def _native_interrupt_row(
67
+ evt: dict, prompt_ids: set[str] | None = None, *, strict: bool = False,
68
+ ) -> bool:
69
+ """A native cancellation marker, not user/assistant text quoting its words.
70
+
71
+ Claude's plain interruption row carries interruptedMessageId. Its tool
72
+ variant is an error tool_result. Sidechains are never parent cancellation.
73
+ Where Claude supplies promptId, it must match the current turn's UPS IDs.
74
+ """
75
+ if (not isinstance(evt, dict) or evt.get("type") != "user"
76
+ or evt.get("isSidechain") is True):
77
+ return False
78
+ pid = evt.get("promptId")
79
+ if prompt_ids and pid is not None and pid not in prompt_ids:
80
+ return False
81
+ content = (evt.get("message") or {}).get("content")
82
+ markers = {"[Request interrupted by user]",
83
+ "[Request interrupted by user for tool use]"}
84
+ if isinstance(content, str):
85
+ return content in markers and (not strict or bool(evt.get("interruptedMessageId")))
86
+ if isinstance(content, list):
87
+ for block in content:
88
+ if not isinstance(block, dict):
89
+ continue
90
+ if (block.get("type") == "tool_result" and block.get("is_error") is True
91
+ and isinstance(block.get("content"), str)
92
+ and block["content"] in markers):
93
+ return True
94
+ if (block.get("type") == "text" and block.get("text") in markers
95
+ and evt.get("interruptedMessageId")):
96
+ return True
97
+ return False
98
+
99
+
100
+ def _read_interrupt_marker(
101
+ transcript_path: str | pathlib.Path, start_pos: int = 0,
102
+ *, prompt_ids: set[str] | None = None, strict: bool = False,
103
+ ) -> tuple[bool, int]:
104
+ """Passively scan only complete, current parent JSONL rows for a marker.
105
+
106
+ An earlier interrupt, a quoted marker, a partial line, and a sidechain row
107
+ cannot confirm this turn. The scan offset is caller-owned and bounded by
108
+ the start of the current real turn; on truncated transcripts restart at 0.
109
+ """
110
+ path = pathlib.Path(transcript_path)
111
+ try:
112
+ size = path.stat().st_size
113
+ except OSError:
114
+ return False, start_pos
115
+ pos = max(0, int(start_pos or 0))
116
+ if pos > size:
117
+ pos = 0
118
+ if pos == size:
119
+ return False, size
120
+ try:
121
+ with path.open("rb") as f:
122
+ f.seek(pos)
123
+ while True:
124
+ line_start = f.tell()
125
+ raw = f.readline()
126
+ if not raw:
127
+ return False, f.tell()
128
+ if not raw.endswith(b"\n"):
129
+ return False, line_start
130
+ if b"[Request interrupted by user" not in raw:
131
+ continue
132
+ try:
133
+ evt = json.loads(raw.decode("utf-8"))
134
+ except (UnicodeDecodeError, ValueError):
135
+ continue
136
+ if _native_interrupt_row(evt, prompt_ids, strict=strict):
137
+ return True, f.tell()
138
+ except OSError:
139
+ return False, pos
140
+
141
+
142
+ def _permission_response(
143
+ decision: str,
144
+ tool_input: dict | None = None,
145
+ reason: str | None = None,
146
+ *,
147
+ updated_input: dict | None = None,
148
+ user_modified: bool = False,
149
+ ) -> dict:
150
+ behavior = "allow" if decision == "allow" else "deny"
151
+ default_reason = "ccx approved" if behavior == "allow" else "ccx rejected"
152
+ dec = {
153
+ "behavior": behavior,
154
+ "decisionReason": reason or default_reason,
155
+ }
156
+ if behavior == "allow":
157
+ dec["updatedInput"] = updated_input if updated_input is not None else (tool_input or {})
158
+ dec["userModified"] = user_modified
159
+ else:
160
+ # Claude surfaces a denied tool's message to the model from
161
+ # `decision.message`, not from `decisionReason`. Mirror the reason there
162
+ # so `ccx reject --reason` is visible in the interrupted turn.
163
+ dec["message"] = reason or default_reason
164
+ return {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": dec}}
165
+
166
+
167
+ def _recv_json_line(sock: socket.socket, timeout: float) -> dict:
168
+ sock.settimeout(timeout)
169
+ buf = bytearray()
170
+ while True:
171
+ chunk = sock.recv(4096)
172
+ if not chunk:
173
+ return {"ok": False, "error": "no reply"}
174
+ buf.extend(chunk)
175
+ if b"\n" in buf:
176
+ line = bytes(buf).split(b"\n", 1)[0]
177
+ try:
178
+ return json.loads(line)
179
+ except ValueError:
180
+ return {"ok": False, "error": "bad reply"}
181
+
182
+
183
+ def _relay_connect(short: str, *, timeout: float = 2.0,
184
+ retry_s: float = 0.0) -> socket.socket:
185
+ """Connect to the host socket, retrying a busy host for up to ``retry_s``.
186
+
187
+ The host serves its socket from one select loop. While that loop is busy
188
+ the listen backlog fills, and a further ``connect`` then fails at once with
189
+ EAGAIN (or times out). That is a host that is alive but behind, not a
190
+ host that is gone, so it is retried within the budget. A missing socket or
191
+ a refused connection means no host at all and is raised immediately.
192
+ """
193
+ deadline = time.monotonic() + max(0.0, retry_s)
194
+ while True:
195
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
196
+ s.settimeout(timeout)
197
+ try:
198
+ s.connect(str(sock_path(short)))
199
+ return s
200
+ except (BlockingIOError, socket.timeout):
201
+ s.close()
202
+ if time.monotonic() >= deadline:
203
+ raise
204
+ time.sleep(HOOK_RELAY_RETRY_POLL_S)
205
+ except BaseException:
206
+ s.close()
207
+ raise
208
+
209
+
210
+ def _host_rpc(short: str, msg: dict, timeout: float = 2.0, *,
211
+ connect_retry_s: float = 0.0) -> dict:
212
+ if msg.get("op") in ("permission-request", "elicitation-request",
213
+ "permission-poll", "permission-timeout"):
214
+ # Unlike native hook-event frames, these require the host's existing
215
+ # same-connection full-owner handshake; do not exempt them server-side.
216
+ from ccx.rpc.rpc import _rpc
217
+ return _rpc(short, msg, timeout=timeout)
218
+ with _relay_connect(short, timeout=timeout, retry_s=connect_retry_s) as s:
219
+ s.sendall((json.dumps(msg) + "\n").encode())
220
+ return _recv_json_line(s, timeout)
221
+
222
+
223
+ def hook_relay_retry_s(data) -> float:
224
+ """Connect budget for one hook's delivery to the host.
225
+
226
+ Parent hooks are lifecycle evidence (a lost Stop leaves the worker busy
227
+ forever), so they wait out a busy host. Subagent hooks only feed
228
+ attributed tool events and keep the single attempt."""
229
+ return 0.0 if _hook_subagent_id(data) is not None else HOOK_RELAY_RETRY_S
230
+
231
+
232
+ def _relay_broker_permission(short: str, data: dict, *,
233
+ register_op: str = "permission-request",
234
+ gone: dict | None = None,
235
+ timed_out: dict | None = None) -> dict | None:
236
+ """Register a no-skip permission request with the host and poll until a
237
+ decision arrives, the host dies, or the hook-timeout budget elapses.
238
+
239
+ Returns the decision dict ({"decision": "allow"|"deny", "reason": str}) or
240
+ None if the request could not even be registered (host unreachable).
241
+
242
+ Transient poll failures leave the request pending: the host may be busy
243
+ servicing the PTY. Polling ends only on a decision, a missing socket with
244
+ a dead host PID, or the hook-timeout deadline. The caller denies on timeout
245
+ so the blocked turn can proceed.
246
+
247
+ The Elicitation hook reuses the same poll loop with ``register_op=
248
+ "elicitation-request"`` and cancel-shaped ``gone``/``timed_out`` results."""
249
+ try:
250
+ reply = _host_rpc(short, {"op": register_op, "data": data}, timeout=2.0,
251
+ connect_retry_s=HOOK_RELAY_RETRY_S)
252
+ except Exception:
253
+ return None
254
+ request_id = reply.get("request_id")
255
+ if not (reply.get("ok") and request_id):
256
+ return None
257
+ host_lifecycle = (read_record(short) or {}).get("lifecycle") or {}
258
+ deadline = time.time() + max(1.0, PERMISSION_HOOK_TIMEOUT_S - 1.0)
259
+ while time.time() < deadline:
260
+ time.sleep(PERMISSION_POLL_INTERVAL_S)
261
+ try:
262
+ poll = _host_rpc(short, {"op": "permission-poll", "request_id": request_id}, timeout=2.0)
263
+ except Exception:
264
+ poll = None
265
+ if poll and poll.get("ok"):
266
+ if poll.get("decision"):
267
+ return poll["decision"]
268
+ if poll.get("unknown"):
269
+ # A restarted host that never saw this request: nobody can
270
+ # decide it any more.
271
+ return gone or {"decision": "deny",
272
+ "reason": "ccx: session host restarted before approval"}
273
+ continue # host answered, still no decision — keep waiting
274
+ # Transient/garbled reply. Only give up if the host is genuinely gone;
275
+ # otherwise keep polling until the real deadline.
276
+ if not sock_path(short).exists() and not lifecycle_host_alive(host_lifecycle):
277
+ return gone or {"decision": "deny", "reason": "ccx: session ended before approval"}
278
+ # Hook-timeout: notify the host (it clears the prompt and stays usable) and
279
+ # auto-deny so Claude unblocks instead of falling back to an interactive
280
+ # prompt that would hang this head-less PTY forever.
281
+ try:
282
+ _host_rpc(short, {"op": "permission-timeout", "request_id": request_id}, timeout=2.0)
283
+ except Exception:
284
+ pass
285
+ return timed_out or {"decision": "deny", "reason": "ccx: no approval within timeout, auto-denied"}
286
+
287
+
288
+ def _elicitation_response(action: str, content: dict | None = None) -> dict:
289
+ """Claude `Elicitation` hook output: answer the MCP server directly.
290
+
291
+ Returning an action suppresses Claude's own dialog, which would otherwise
292
+ block a head-less PTY forever. ``cancel`` is the safe default for every
293
+ failure: it means "dismissed", so the server records no decision on the
294
+ user's behalf (a ``decline`` is an explicit no that servers may persist)."""
295
+ out: dict = {"hookEventName": "Elicitation", "action": action}
296
+ if action == "accept" and content is not None:
297
+ out["content"] = content
298
+ return {"hookSpecificOutput": out}
299
+
300
+
301
+ def _relay_elicitation(short: str, data: dict) -> dict:
302
+ """Broker one Elicitation hook through the host; always returns hook output."""
303
+ decision = _relay_broker_permission(
304
+ short, data,
305
+ register_op="elicitation-request",
306
+ gone={"action": "cancel", "reason": "ccx: session ended before a response"},
307
+ timed_out={"action": "cancel", "reason": "ccx: no response within timeout"},
308
+ )
309
+ if not decision:
310
+ return _elicitation_response("cancel")
311
+ action = decision.get("action")
312
+ if action not in ("accept", "decline", "cancel"):
313
+ action = "cancel"
314
+ return _elicitation_response(action, decision.get("content"))
315
+
316
+
317
+ def cmd_hooks_relay(args):
318
+ """Read JSON hook payload from stdin, forward to the host socket as a hook-event op,
319
+ print the hook's response to stdout.
320
+
321
+ For most events: respond with "{}" (no-op continue).
322
+ For PermissionRequest on skip-permissions sessions: auto-allow ordinary tool
323
+ permissions with the original tool_input. AskUserQuestion is not a permission:
324
+ always broker it so the thread blocks for a real answer instead of silently
325
+ producing an empty/non-answer. For --no-skip-permissions sessions, never emit
326
+ an allow decision, even if an older frozen settings file still contains the hook."""
327
+ from ccx.constants import SELF_ENGINE_ENV, SELF_WORKER_ID_ENV
328
+ from ccx.claude.settings import HOOKS_WE_CARE_ABOUT
329
+
330
+ raw = sys.stdin.read()
331
+ try:
332
+ data = json.loads(raw) if raw.strip() else {}
333
+ except ValueError:
334
+ data = {}
335
+ if not isinstance(data, dict):
336
+ data = {}
337
+ # In V2 read_record raises for missing/foreign/short/corrupt workers.
338
+ # Missing proof can never authorize a permission (or a native hook event).
339
+ try:
340
+ rec = read_record(args.short)
341
+ except Exception:
342
+ rec = None
343
+ native = rec.get("native") if isinstance(rec, dict) else None
344
+ native_sid = native.get("sessionId") if isinstance(native, dict) else None
345
+ original = (
346
+ isinstance(rec, dict)
347
+ and rec.get("engine") == "claude"
348
+ and rec.get("workerId") == args.short
349
+ and isinstance(native_sid, str) and bool(native_sid)
350
+ and data.get("session_id") == native_sid
351
+ and os.environ.get(SELF_WORKER_ID_ENV) == args.short
352
+ and os.environ.get(SELF_ENGINE_ENV) == "claude"
353
+ and args.event in HOOKS_WE_CARE_ABOUT
354
+ )
355
+ if not original:
356
+ if args.event == "PermissionRequest":
357
+ sys.stdout.write(json.dumps(_permission_response(
358
+ "deny", data.get("tool_input"),
359
+ "ccx: unverified original managed worker hook",
360
+ )) + "\n")
361
+ elif args.event == "Elicitation":
362
+ sys.stdout.write(json.dumps(_elicitation_response("cancel")) + "\n")
363
+ else:
364
+ sys.stdout.write("{}\n")
365
+ return
366
+ if args.event == "UserPromptSubmit":
367
+ # Capture before the host ACK wait. Native paths can have hashed/long
368
+ # project slugs; this is only a byte-offset hint, never read authority.
369
+ transcript_path = data.get("transcript_path")
370
+ if (isinstance(transcript_path, str)
371
+ and os.path.isabs(transcript_path)
372
+ and os.path.basename(transcript_path) == native_sid + ".jsonl"):
373
+ try:
374
+ data["_ccx_report_start_offset"] = os.path.getsize(transcript_path)
375
+ except FileNotFoundError:
376
+ data["_ccx_report_start_offset"] = 0
377
+ except OSError:
378
+ pass
379
+ dargs = rec.get("createArgs") or {}
380
+ auto_allow_permissions = dargs.get("skip_permissions", True)
381
+ if args.event == "Elicitation":
382
+ # An MCP server asked the user for input mid-tool. Like AskUserQuestion
383
+ # it is a real question, brokered even on skip-permissions sessions.
384
+ sys.stdout.write(json.dumps(_relay_elicitation(args.short, data)) + "\n")
385
+ return
386
+ if args.event == "PermissionRequest":
387
+ tool_name = data.get("tool_name")
388
+ if auto_allow_permissions and tool_name != "AskUserQuestion":
389
+ sys.stdout.write(json.dumps(_permission_response(
390
+ "allow",
391
+ data.get("tool_input", {}),
392
+ "ccx auto-allow (--dangerously-skip-permissions session)",
393
+ )) + "\n")
394
+ return
395
+ # Broker through the host and wait for a CLI decision. Every failure mode
396
+ # resolves to a *deny* rather than a bare "{}": a no-op "{}" lets Claude
397
+ # fall back to its own interactive permission prompt, which in a head-less
398
+ # ccx PTY hangs forever. Denying keeps the session responsive and is the
399
+ # safe default whenever we cannot obtain an explicit allow.
400
+ tool_input = data.get("tool_input", {})
401
+ decision = _relay_broker_permission(args.short, data)
402
+ if decision is None:
403
+ sys.stdout.write(json.dumps(_permission_response(
404
+ "deny", tool_input, "ccx: session host unreachable, cannot broker approval")) + "\n")
405
+ return
406
+ if decision.get("external") is True:
407
+ # The native PostToolUse(/Failure) already resolved this exact
408
+ # original input. Empty hook output makes no second decision.
409
+ sys.stdout.write("{}\n")
410
+ return
411
+ sys.stdout.write(json.dumps(_permission_response(
412
+ decision.get("decision", "deny"),
413
+ tool_input,
414
+ decision.get("reason"),
415
+ updated_input=decision.get("updatedInput"),
416
+ user_modified=bool(decision.get("userModified")),
417
+ )) + "\n")
418
+ return
419
+
420
+ # When Claude fired this hook, not when the host reads it: relays run as
421
+ # separate processes and can deliver late or reordered.
422
+ data["_ccx_relay_ms"] = int(time.time() * 1000)
423
+ msg = {"op": "hook-event", "event": args.event, "data": data}
424
+ retry_s = hook_relay_retry_s(data)
425
+ try:
426
+ # Keep the relay connected until the host has consumed the complete
427
+ # event: Darwin may drop a just-closed relay before the host loop reads
428
+ # it. The bounded acknowledgement is never a reason to replay a hook.
429
+ # Hosts started before every event was acknowledged don't answer
430
+ # Notification or SubagentStart, so those wait only briefly.
431
+ brief = args.event in ("Notification", "SubagentStart")
432
+ _host_rpc(args.short, msg,
433
+ timeout=1.0 if brief else BOUNDARY_SEND_ACK_S + 2.0,
434
+ connect_retry_s=retry_s)
435
+ except Exception:
436
+ # Hook must not fail Claude. Swallow errors silently.
437
+ pass
438
+ sys.stdout.write("{}\n")