ccx-native 0.0.1 → 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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -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 +762 -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 +250 -0
  16. package/ccx/claude/host/session.py +5184 -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 +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -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/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. 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")
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+ import fcntl
3
+ import os
4
+ import re
5
+ import select
6
+ import struct
7
+ import termios
8
+ import time
9
+ from ccx.constants import (
10
+ CLEAR_KILL_REPEAT,
11
+ POST_CLEAR_SETTLE_S,
12
+ PASTE_INLINE_MAX_NEWLINES,
13
+ PASTE_INLINE_MAX_UNITS,
14
+ PASTE_INLINE_ROWS_RESERVED,
15
+ PASTE_SUBMIT_DELAY_S,
16
+ PASTE_STAGED_BYTES_PER_S,
17
+ PASTE_STAGED_MAX_DELAY_S,
18
+ PASTE_STAGED_THRESHOLD_BYTES,
19
+ PTY_QUIESCENCE_S,
20
+ PTY_WRITE_TIMEOUT_S,
21
+ )
22
+
23
+
24
+ def set_winsize(fd, rows, cols):
25
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
26
+
27
+
28
+ def paste_bytes(text: str) -> bytes:
29
+ """Frame text as one bracketed paste, without the submitting Enter.
30
+
31
+ Claude's decoder emits the pasted text only after the closing marker. The
32
+ caller sends Enter separately after this complete payload has drained.
33
+ """
34
+ return b"\x1b[200~" + text.encode() + b"\x1b[201~"
35
+
36
+
37
+ def paste_submit_delay(text: str) -> float:
38
+ """How long Ink gets to materialize a paste before CCX presses Enter.
39
+
40
+ Small prompts remain effectively immediate. Large prompts are staged: Ink
41
+ asynchronously turns them into a collapsed ``[Pasted text …]`` attachment,
42
+ and an Enter delivered before that work finishes is silently discarded.
43
+ The caller must wait without blocking the host loop so PTY output continues
44
+ to drain during this interval.
45
+ """
46
+ size = len(text.encode())
47
+ if size <= PASTE_STAGED_THRESHOLD_BYTES:
48
+ return PASTE_SUBMIT_DELAY_S
49
+ return min(PASTE_STAGED_MAX_DELAY_S, size / PASTE_STAGED_BYTES_PER_S)
50
+
51
+
52
+ def get_winsize_rows(fd) -> int:
53
+ """The PTY's current row count (viewers may resize it at any time)."""
54
+ rows, _cols, _x, _y = struct.unpack(
55
+ "HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\0" * 8))
56
+ return rows
57
+
58
+
59
+ def inline_paste_newline_cap(rows: int) -> int:
60
+ """Line breaks one paste may carry and still be inserted as typed text."""
61
+ return max(0, min(rows - PASTE_INLINE_ROWS_RESERVED, PASTE_INLINE_MAX_NEWLINES))
62
+
63
+
64
+ def paste_chunks(text: str, max_newlines: int,
65
+ max_units: int = PASTE_INLINE_MAX_UNITS) -> list[str] | None:
66
+ """Split text into pastes small enough for Claude to insert as typed text.
67
+
68
+ Sizes are measured the way Claude's paste handler measures them: UTF-16
69
+ units after it normalizes CRLF/CR to LF and expands tabs to four spaces.
70
+ A CRLF pair is never split. Returns None when the text has a line break but
71
+ no paste may carry one (a PTY under ``PASTE_INLINE_ROWS_RESERVED + 1``
72
+ rows), so the caller falls back to a single paste.
73
+ """
74
+ chunks: list[str] = []
75
+ cur: list[str] = []
76
+ units = newlines = 0
77
+ i = 0
78
+ while i < len(text):
79
+ ch = text[i]
80
+ if ch == "\r" and text[i + 1:i + 2] == "\n":
81
+ ch = "\r\n"
82
+ is_newline = ch in ("\n", "\r", "\r\n")
83
+ if is_newline and max_newlines < 1:
84
+ return None
85
+ cost = 4 if ch == "\t" else 2 if ord(ch[0]) > 0xFFFF else 1
86
+ if cur and units + cost > max_units:
87
+ chunks.append("".join(cur))
88
+ cur, units, newlines = [], 0, 0
89
+ cur.append(ch)
90
+ units += cost
91
+ i += len(ch)
92
+ if is_newline:
93
+ newlines += 1
94
+ if newlines >= max_newlines:
95
+ chunks.append("".join(cur))
96
+ cur, units, newlines = [], 0, 0
97
+ if cur:
98
+ chunks.append("".join(cur))
99
+ return chunks
100
+
101
+
102
+ def prompt_paste(text: str, rows: int) -> tuple[bytes, float, int]:
103
+ """Frame a prompt for Claude's editor: ``(payload, submit_delay, pastes)``.
104
+
105
+ Normally a run of small pastes (see ``paste_chunks``) that Claude inserts
106
+ verbatim, so the submitted prompt reaches the model as the user's own
107
+ words rather than wrapped in <pasted_content> tags, and Enter can follow
108
+ immediately. A PTY too short for inline line breaks falls back to one
109
+ paste with the size-aware staged submit.
110
+ """
111
+ chunks = paste_chunks(text, inline_paste_newline_cap(rows))
112
+ if chunks is None:
113
+ return paste_bytes(text), paste_submit_delay(text), 1
114
+ chunks = chunks or [text]
115
+ return b"".join(paste_bytes(c) for c in chunks), PASTE_SUBMIT_DELAY_S, len(chunks)
116
+
117
+
118
+ def clear_input_bytes() -> bytes:
119
+ """Ctrl-C (Claude's app:interrupt). At an idle prompt it empties the whole
120
+ editor however many lines it holds, where a Ctrl-U burst only removes one
121
+ wrapped visual line per press. On an empty editor it only arms Claude's
122
+ exit confirmation, so a second press within 800ms would exit the worker:
123
+ the host spaces these presses at least ``CLEAR_INPUT_MIN_GAP_S`` apart."""
124
+ return b"\x03"
125
+
126
+
127
+ def stalled_paste_clear_action(slot: dict, now: float, last_pty: float) -> str | None:
128
+ """Only reject a partial PTY write after an explicit quiet editor clear."""
129
+ if not last_pty or now - last_pty < PTY_QUIESCENCE_S:
130
+ return None
131
+ unstuck = slot.get("stalled_unstuck_at")
132
+ if unstuck is None:
133
+ return "unstick"
134
+ if now - unstuck < POST_CLEAR_SETTLE_S:
135
+ return None
136
+ cleared = slot.get("stalled_cleared_at")
137
+ if cleared is None:
138
+ return "clear"
139
+ if now - cleared < POST_CLEAR_SETTLE_S:
140
+ return None
141
+ return "reject"
142
+
143
+
144
+ def write_all(fd: int, payload: bytes, *, timeout_s: float = PTY_WRITE_TIMEOUT_S,
145
+ absolute_timeout_s: float | None = None,
146
+ on_partial=None, on_read=None) -> bool:
147
+ """Drain short PTY writes, reading peer output to avoid bidirectional stalls.
148
+
149
+ The timeout bounds *lack of write progress*, not total payload time. A busy
150
+ Ink renderer can fill the opposite PTY buffer, stop reading input, and
151
+ deadlock a write-only host. ``on_read`` drains that output through the same
152
+ path as the host loop; it returns False on EOF. PTY write boundaries do not
153
+ change the logical bracketed pastes. ``absolute_timeout_s`` is an
154
+ optional non-renewable elapsed-time budget used only by attached-terminal
155
+ input; normal semantic writes preserve their progress-based timeout.
156
+ """
157
+ start = time.monotonic()
158
+ deadline = start + timeout_s
159
+ absolute_deadline = (start + absolute_timeout_s
160
+ if absolute_timeout_s is not None else None)
161
+ sent = 0
162
+ while sent < len(payload):
163
+ if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
164
+ return False
165
+ try:
166
+ written = os.write(fd, payload[sent:])
167
+ except InterruptedError:
168
+ continue
169
+ except BlockingIOError:
170
+ written = 0
171
+ except OSError:
172
+ return False
173
+
174
+ if written > 0:
175
+ sent += written
176
+ deadline = time.monotonic() + timeout_s
177
+ if sent < len(payload) and on_partial is not None:
178
+ on_partial(written, sent, len(payload))
179
+ continue
180
+
181
+ remaining = deadline - time.monotonic()
182
+ if absolute_deadline is not None:
183
+ remaining = min(remaining, absolute_deadline - time.monotonic())
184
+ if remaining <= 0:
185
+ return False
186
+ try:
187
+ readable, writable, _ = select.select(
188
+ [fd] if on_read is not None else [], [fd], [], remaining
189
+ )
190
+ except InterruptedError:
191
+ continue
192
+ except OSError:
193
+ return False
194
+ if readable and on_read() is False:
195
+ return False
196
+ if not readable and not writable:
197
+ return False
198
+ return True
199
+
200
+
201
+ _ANSI_RE = re.compile(
202
+ rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
203
+ rb"|\x1b\[[0-9;?]*[ -/]*[@-~]"
204
+ rb"|\x1b[@-Z\\-_]"
205
+ )
206
+
207
+
208
+ def pty_tail_reason(buf: bytes | bytearray, *, max_chars: int = 300) -> str | None:
209
+ """Distill a dead worker's last PTY output into a one-line failure reason.
210
+
211
+ A worker that dies at boot (an unknown `--agent`, a rejected `--model`, an
212
+ onboarding/trust gate) prints why on the PTY and exits. Nothing else records
213
+ that: the host log holds only the launcher's stdio, so without this the
214
+ record kept an opaque state=crashed with lastError=None and the operator had
215
+ no way to tell a typo'd flag from a real crash.
216
+
217
+ Returns None when there is nothing quotable. Box-drawing and spinner residue
218
+ from a half-rendered TUI is dropped — a line must carry alphanumeric text to
219
+ count as a message — and consecutive duplicate lines (Ink re-renders the same
220
+ frame) collapse to one.
221
+ """
222
+ text = _ANSI_RE.sub(b"", bytes(buf)).decode("utf-8", "replace")
223
+ lines: list[str] = []
224
+ for raw in text.replace("\r", "\n").split("\n"):
225
+ line = "".join(c for c in raw if c.isprintable()).strip()
226
+ if not line or not any(c.isalnum() for c in line):
227
+ continue
228
+ if not lines or lines[-1] != line:
229
+ lines.append(line)
230
+ if not lines:
231
+ return None
232
+ return " ".join(lines[-3:])[:max_chars]
233
+
234
+
235
+ def unstick_bytes() -> bytes:
236
+ """A lone closing bracketed-paste marker (\\x1b[201~). If a lost first paste
237
+ left Claude's decoder stuck IN_PASTE (its closing marker never arrived),
238
+ control keys are swallowed into the paste buffer and ignored; this marker
239
+ forces the decoder back to normal and materializes whatever partial it held
240
+ into the visible editor, where a kill key can then remove it."""
241
+ return b"\x1b[201~"
242
+
243
+
244
+ def drain_bytes(kill_repeat: int = CLEAR_KILL_REPEAT) -> bytes:
245
+ """A burst of Ctrl-U (\\x15), used for the single-line ``/compact``
246
+ residue. Ctrl-U is bound to delete-to-line-start, not whole-buffer clear:
247
+ each press removes one wrapped visual line, so it cannot empty a long
248
+ multi-line prompt (see clear_input_bytes()). No-op once the buffer is
249
+ empty."""
250
+ return b"\x15" * kill_repeat