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.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- 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 +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,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
|