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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Read-only exact native Claude session evidence for controlled CCX import.
|
|
2
|
+
|
|
3
|
+
A native SID is not a CCX worker target. Only the exact canonical original
|
|
4
|
+
session ID may be inspected for one owner-proven import. No external session
|
|
5
|
+
discovery, cache materialization, daemon control, reply or native-bg adoption.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import pathlib
|
|
12
|
+
|
|
13
|
+
from ccx.system import oslayer
|
|
14
|
+
from ccx.constants import CLAUDE_HOME
|
|
15
|
+
from ccx.store.paths import transcript_path_for
|
|
16
|
+
from ccx.claude.transcript import pending_ask_user_question
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
WAIT_NONE = "none"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
WAIT_QUESTION = "question"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
WAIT_PERMISSION = "permission"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
WAIT_SHELL = "shell"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
WAIT_UNKNOWN = "unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_BACKGROUND_KINDS = {"daemon", "daemon-worker", "bg", "background"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def short_from_id(identifier: str) -> str:
|
|
38
|
+
return identifier.split("-", 1)[0][:8]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _job_state(short: str) -> dict | None:
|
|
42
|
+
try:
|
|
43
|
+
return json.loads((CLAUDE_HOME / "jobs" / short / "state.json").read_text())
|
|
44
|
+
except (OSError, ValueError):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _session_files(identifier: str) -> list[dict]:
|
|
49
|
+
root = CLAUDE_HOME / "sessions"
|
|
50
|
+
if not root.exists():
|
|
51
|
+
return []
|
|
52
|
+
out = []
|
|
53
|
+
for p in root.glob("*.json"):
|
|
54
|
+
try:
|
|
55
|
+
meta = json.loads(p.read_text())
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
continue
|
|
58
|
+
sid = meta.get("sessionId")
|
|
59
|
+
if sid == identifier:
|
|
60
|
+
out.append(meta)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def exact_import_candidate(session_id: str) -> dict:
|
|
65
|
+
"""Read ONLY an exact native Claude SID's provider-owned import facts."""
|
|
66
|
+
import stat
|
|
67
|
+
from uuid import UUID
|
|
68
|
+
from ccx.errors import InvalidRequest, SessionNotFound, SessionUnavailable
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if str(UUID(session_id)) != session_id:
|
|
72
|
+
raise ValueError("noncanonical")
|
|
73
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
74
|
+
raise InvalidRequest("import needs the exact canonical native Claude sessionId") from exc
|
|
75
|
+
# Never resolve prefixes/names or materialize a cached external CCX record.
|
|
76
|
+
found = sorted((CLAUDE_HOME / "projects").glob(f"*/{session_id}.jsonl"))
|
|
77
|
+
if not found:
|
|
78
|
+
raise SessionNotFound("native Claude session transcript not found: " + session_id)
|
|
79
|
+
if len(found) != 1:
|
|
80
|
+
raise SessionUnavailable("ambiguous exact native Claude transcript locations: "
|
|
81
|
+
+ session_id)
|
|
82
|
+
path = found[0]
|
|
83
|
+
try:
|
|
84
|
+
st = path.lstat()
|
|
85
|
+
if not stat.S_ISREG(st.st_mode) or st.st_size <= 0:
|
|
86
|
+
raise SessionUnavailable("native Claude transcript is missing or empty")
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
raise SessionUnavailable("native Claude transcript is unavailable") from exc
|
|
89
|
+
|
|
90
|
+
meta = _session_files(session_id)
|
|
91
|
+
live = []
|
|
92
|
+
# PID-keyed metadata survives process exit/reuse. Establish presence from
|
|
93
|
+
# the kernel, not argv: unreadable argv must never turn a live original
|
|
94
|
+
# into an apparently stopped import candidate.
|
|
95
|
+
for entry in meta:
|
|
96
|
+
pid = entry.get("pid")
|
|
97
|
+
if type(pid) is not int or pid <= 0:
|
|
98
|
+
continue # no process to claim from this metadata row
|
|
99
|
+
observed = oslayer.process_info(pid)
|
|
100
|
+
if observed.presence == "dead":
|
|
101
|
+
continue # stopped originals keep their existing import policy
|
|
102
|
+
start = entry.get("procStart")
|
|
103
|
+
if (observed.presence != "alive"
|
|
104
|
+
or not oslayer.provider_start_matches(
|
|
105
|
+
start, observed.start_token, entry.get("pidDomain"))):
|
|
106
|
+
raise SessionUnavailable(
|
|
107
|
+
"live native Claude original PID/procStart identity unproven"
|
|
108
|
+
)
|
|
109
|
+
live.append(entry)
|
|
110
|
+
if len(live) > 1:
|
|
111
|
+
raise SessionUnavailable("more than one live native Claude process names session")
|
|
112
|
+
info = (live[0] if live else meta[0] if meta else {})
|
|
113
|
+
if len(meta) > 1 and not live and any(
|
|
114
|
+
(m.get("cwd"), m.get("kind")) !=
|
|
115
|
+
(info.get("cwd"), info.get("kind")) for m in meta):
|
|
116
|
+
raise SessionUnavailable("contradictory exact native Claude session metadata")
|
|
117
|
+
original_cwd = _cwd_from_transcript(path)
|
|
118
|
+
claimed_cwd = info.get("cwd")
|
|
119
|
+
if original_cwd and claimed_cwd and os.path.realpath(original_cwd) != os.path.realpath(claimed_cwd):
|
|
120
|
+
raise SessionUnavailable("native Claude transcript and session metadata cwd disagree")
|
|
121
|
+
cwd = claimed_cwd or original_cwd
|
|
122
|
+
if not isinstance(cwd, str) or not cwd or not os.path.isdir(cwd):
|
|
123
|
+
raise SessionUnavailable("native Claude source cwd is unavailable")
|
|
124
|
+
cwd = os.path.realpath(cwd)
|
|
125
|
+
if path != transcript_path_for(cwd, session_id):
|
|
126
|
+
raise SessionUnavailable("exact native Claude transcript does not match canonical cwd/project")
|
|
127
|
+
short = short_from_id(session_id)
|
|
128
|
+
job = _job_state(short)
|
|
129
|
+
if isinstance(job, dict) and job.get("sessionId") is None:
|
|
130
|
+
raise SessionUnavailable("native-bg job short collision has no exact session identity")
|
|
131
|
+
background = (isinstance(job, dict) and job.get("sessionId") == session_id
|
|
132
|
+
or info.get("kind") in _BACKGROUND_KINDS)
|
|
133
|
+
return {
|
|
134
|
+
"sessionId": session_id,
|
|
135
|
+
"short": short,
|
|
136
|
+
"cwd": cwd,
|
|
137
|
+
"transcript": str(path),
|
|
138
|
+
"name": info.get("name"),
|
|
139
|
+
"live": bool(live),
|
|
140
|
+
"status": info.get("status") if live else None,
|
|
141
|
+
"waitKind": _wait_kind(bool(live), info.get("status"), info.get("waitingFor")),
|
|
142
|
+
"backgroundAgent": background,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _cwd_from_transcript(path: pathlib.Path) -> str | None:
|
|
147
|
+
try:
|
|
148
|
+
with path.open() as f:
|
|
149
|
+
for line in f:
|
|
150
|
+
try:
|
|
151
|
+
evt = json.loads(line)
|
|
152
|
+
except ValueError:
|
|
153
|
+
continue
|
|
154
|
+
cwd = evt.get("cwd")
|
|
155
|
+
if cwd:
|
|
156
|
+
return cwd
|
|
157
|
+
except OSError:
|
|
158
|
+
return None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _wait_kind(live: bool, status: str | None, waiting_for: str | None) -> str:
|
|
163
|
+
if not live:
|
|
164
|
+
return WAIT_NONE
|
|
165
|
+
if waiting_for == "permission prompt":
|
|
166
|
+
return WAIT_PERMISSION
|
|
167
|
+
if waiting_for == "dialog open":
|
|
168
|
+
return WAIT_QUESTION
|
|
169
|
+
if status == "shell" or waiting_for == "sandbox request":
|
|
170
|
+
return WAIT_SHELL
|
|
171
|
+
if waiting_for in ("worker request", "input needed"):
|
|
172
|
+
return WAIT_UNKNOWN
|
|
173
|
+
return WAIT_NONE
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def import_eligibility(rec: dict) -> tuple[bool, str, str]:
|
|
177
|
+
"""Can this exact native Claude session be imported under CCX management?
|
|
178
|
+
|
|
179
|
+
Product rule: a regular external Claude session may be imported when it is
|
|
180
|
+
**idle** — including a *live* idle one — or when it is stopped/not-live with
|
|
181
|
+
a transcript at rest. Everything non-idle refuses: busy, any wait
|
|
182
|
+
(permission/question/shell/unknown), native-bg, missing transcript, missing cwd.
|
|
183
|
+
|
|
184
|
+
Note: importing a live-idle session is an *active resume*, not a passive
|
|
185
|
+
attach — the original terminal must not be used afterward (the caller warns
|
|
186
|
+
about this). ccx deliberately does not require closing the old terminal."""
|
|
187
|
+
short = rec.get("short") or short_from_id(rec.get("sessionId") or "?")
|
|
188
|
+
status = rec.get("status")
|
|
189
|
+
live = bool(rec.get("live"))
|
|
190
|
+
wait_kind = rec.get("waitKind") or WAIT_NONE
|
|
191
|
+
cwd = rec.get("cwd") or ""
|
|
192
|
+
|
|
193
|
+
if rec.get("backgroundAgent"):
|
|
194
|
+
return False, "native-bg", (
|
|
195
|
+
f"session {short} is a native Claude background agent; native-bg "
|
|
196
|
+
"import is not supported. Inspect or manage that job through "
|
|
197
|
+
"Claude's own interface; CCX cannot control or import it"
|
|
198
|
+
)
|
|
199
|
+
# Live, non-idle states first — the common, most-actionable "wait and retry"
|
|
200
|
+
# refusals. Only an *idle* live session (and a stopped one) is importable.
|
|
201
|
+
if live:
|
|
202
|
+
if wait_kind == WAIT_PERMISSION:
|
|
203
|
+
return False, "permission", (
|
|
204
|
+
f"session {short} is waiting on a permission prompt owned by the "
|
|
205
|
+
"Claude TUI; approve/reject it there, wait for the session to go "
|
|
206
|
+
"idle, then import"
|
|
207
|
+
)
|
|
208
|
+
if wait_kind == WAIT_QUESTION:
|
|
209
|
+
return False, "question", (
|
|
210
|
+
f"session {short} is waiting on a question/dialog; answer it in the "
|
|
211
|
+
"Claude TUI, wait for the session to go idle, then import"
|
|
212
|
+
)
|
|
213
|
+
if wait_kind == WAIT_SHELL:
|
|
214
|
+
return False, "shell", (
|
|
215
|
+
f"session {short} is in a shell command; let it return to the prompt, "
|
|
216
|
+
"then import"
|
|
217
|
+
)
|
|
218
|
+
if wait_kind != WAIT_NONE: # unknown / worker-request / input-needed
|
|
219
|
+
return False, "unknown", (
|
|
220
|
+
f"session {short} is in an unknown wait state ({wait_kind}); resolve it "
|
|
221
|
+
"in the Claude TUI and import once it is idle"
|
|
222
|
+
)
|
|
223
|
+
if status == "busy":
|
|
224
|
+
return False, "busy", (
|
|
225
|
+
f"session {short} is busy (a turn is running); wait for the current "
|
|
226
|
+
"turn to finish, then import"
|
|
227
|
+
)
|
|
228
|
+
if status != "idle":
|
|
229
|
+
return False, "not-idle", (
|
|
230
|
+
f"session {short} is live with status={status or 'unknown'}; import only "
|
|
231
|
+
"when it is idle and not waiting"
|
|
232
|
+
)
|
|
233
|
+
# A stopped/non-live session can still have an unanswered AskUserQuestion as
|
|
234
|
+
# the last tool use in the transcript. Resuming that under ccx would re-enter
|
|
235
|
+
# the question during the import auto-continue turn, before an operator has a
|
|
236
|
+
# clean `ccx answer` opportunity, so refuse it just like a live question wait.
|
|
237
|
+
transcript = rec.get("transcript")
|
|
238
|
+
if transcript and os.path.exists(transcript):
|
|
239
|
+
pend = pending_ask_user_question(transcript)
|
|
240
|
+
if pend and pend.get("questions"):
|
|
241
|
+
return False, "question", (
|
|
242
|
+
f"session {short} has an unanswered question/dialog in its transcript; "
|
|
243
|
+
"answer or abandon it in Claude, wait for the session to go idle, then import"
|
|
244
|
+
)
|
|
245
|
+
# Preconditions for a clean resume-under-ccx (checked after the live-state
|
|
246
|
+
# gate so a busy session reports 'busy', not an incidental cwd/transcript edge).
|
|
247
|
+
if not transcript or not os.path.isfile(transcript):
|
|
248
|
+
return False, "no-transcript", (
|
|
249
|
+
f"session {short} has no transcript yet; start a turn in Claude before importing"
|
|
250
|
+
)
|
|
251
|
+
if not (cwd and os.path.isdir(cwd)):
|
|
252
|
+
return False, "no-cwd", f"session {short} cwd does not exist: {cwd or '(unset)'}"
|
|
253
|
+
return True, "ok", f"session {short} is importable"
|
|
File without changes
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import stat
|
|
6
|
+
import time
|
|
7
|
+
from ccx.ops.semantics import KIND_ELICITATION
|
|
8
|
+
from ccx.constants import (
|
|
9
|
+
CCX_EVENTS_KEEP,
|
|
10
|
+
CCX_EVENTS_MAX,
|
|
11
|
+
EVENT_PREVIEW_LIMIT,
|
|
12
|
+
EVENT_SCHEMA_VERSION,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _permission_preview(req: dict | None) -> dict | None:
|
|
17
|
+
"""Build the persisted/broadcast preview of a pending permission request.
|
|
18
|
+
|
|
19
|
+
Large arbitrary tool inputs (e.g. a big Write) are truncated to a short
|
|
20
|
+
preview so the on-disk record and broadcast stay small. AskUserQuestion is the
|
|
21
|
+
exception: its `input.questions` is the structure `ccx answer` must read to map
|
|
22
|
+
options/text to the right question and validate labels. Truncating it away
|
|
23
|
+
would leave the answer path with zero questions and produce misleading errors
|
|
24
|
+
for long question sets, so AUQ input is always kept whole."""
|
|
25
|
+
if not req:
|
|
26
|
+
return None
|
|
27
|
+
if req.get("kind") == KIND_ELICITATION:
|
|
28
|
+
return _elicitation_preview(req)
|
|
29
|
+
tool_name = req.get("tool_name")
|
|
30
|
+
tool_input = req.get("tool_input")
|
|
31
|
+
try:
|
|
32
|
+
rendered = json.dumps(tool_input, ensure_ascii=False)
|
|
33
|
+
except TypeError:
|
|
34
|
+
rendered = json.dumps(str(tool_input))
|
|
35
|
+
if tool_name == "AskUserQuestion":
|
|
36
|
+
safe_input = tool_input
|
|
37
|
+
elif len(rendered) > 2000:
|
|
38
|
+
safe_input = {"preview": rendered[:2000] + "…", "truncated": True}
|
|
39
|
+
else:
|
|
40
|
+
safe_input = tool_input
|
|
41
|
+
return {
|
|
42
|
+
"type": "permission",
|
|
43
|
+
"id": req.get("id"),
|
|
44
|
+
"tool": tool_name,
|
|
45
|
+
"input": safe_input,
|
|
46
|
+
"requested_at": int(req.get("requested_at", time.time()) * 1000),
|
|
47
|
+
"message": req.get("message"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _elicitation_preview(req: dict) -> dict:
|
|
52
|
+
"""Persisted/broadcast preview of a pending MCP elicitation.
|
|
53
|
+
|
|
54
|
+
The schema is kept whole (it is what a responder must satisfy) but the free
|
|
55
|
+
text is bounded. ``id`` is ``None`` for a dialog CCX only observed from a
|
|
56
|
+
notification: it has no brokered hook request, so it cannot be answered."""
|
|
57
|
+
message = req.get("message")
|
|
58
|
+
if isinstance(message, str) and len(message) > 4000:
|
|
59
|
+
message = message[:4000] + "…"
|
|
60
|
+
return {
|
|
61
|
+
"type": KIND_ELICITATION,
|
|
62
|
+
"id": req.get("id"),
|
|
63
|
+
"tool": None,
|
|
64
|
+
"server": req.get("server"),
|
|
65
|
+
"mode": req.get("mode") or "form",
|
|
66
|
+
"message": message,
|
|
67
|
+
"url": req.get("url"),
|
|
68
|
+
"elicitation_id": req.get("elicitation_id"),
|
|
69
|
+
"schema": req.get("schema"),
|
|
70
|
+
"requested_at": int(req.get("requested_at", time.time()) * 1000),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _elicitation_request(data: dict, request_id: str | None) -> dict:
|
|
75
|
+
"""Host-side pending gate for one Elicitation hook payload."""
|
|
76
|
+
schema = data.get("requested_schema")
|
|
77
|
+
return {
|
|
78
|
+
"kind": KIND_ELICITATION,
|
|
79
|
+
"id": request_id,
|
|
80
|
+
"server": data.get("mcp_server_name"),
|
|
81
|
+
"mode": data.get("mode") or "form",
|
|
82
|
+
"message": data.get("message"),
|
|
83
|
+
"url": data.get("url"),
|
|
84
|
+
"elicitation_id": data.get("elicitation_id"),
|
|
85
|
+
"schema": schema if isinstance(schema, dict) else None,
|
|
86
|
+
"requested_at": time.time(),
|
|
87
|
+
"raw": data,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _stamp_event(evt: dict, *, short: str, seq: int, epoch: str, ts: float,
|
|
92
|
+
v: int = EVENT_SCHEMA_VERSION) -> dict:
|
|
93
|
+
"""Return a copy of `evt` with the standard envelope fields stamped on.
|
|
94
|
+
|
|
95
|
+
The envelope (v/type/short/seq/epoch/ts) is authoritative: `seq` is a
|
|
96
|
+
per-host monotonic counter, `epoch` identifies this host life (it changes on
|
|
97
|
+
every respawn, so a subscriber can tell that `seq` numbering restarted), and
|
|
98
|
+
`ts` is the broadcast wall-clock. `type` is supplied by the caller and left
|
|
99
|
+
untouched; any other event-specific fields pass through unchanged."""
|
|
100
|
+
out = dict(evt)
|
|
101
|
+
out["v"] = v
|
|
102
|
+
out["short"] = short
|
|
103
|
+
out["seq"] = seq
|
|
104
|
+
out["epoch"] = epoch
|
|
105
|
+
out["ts"] = ts
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _event_preview(value, limit: int = EVENT_PREVIEW_LIMIT) -> str:
|
|
110
|
+
"""Bounded JSON-ish preview of an arbitrary tool input/result for an event.
|
|
111
|
+
|
|
112
|
+
Always returns a short string so the broadcast and sidecar line stay small.
|
|
113
|
+
Falls back to `str()` for anything not JSON-serializable; never raises."""
|
|
114
|
+
if value is None:
|
|
115
|
+
return ""
|
|
116
|
+
try:
|
|
117
|
+
rendered = json.dumps(value, ensure_ascii=False, default=str)
|
|
118
|
+
except (TypeError, ValueError):
|
|
119
|
+
rendered = str(value)
|
|
120
|
+
if len(rendered) > limit:
|
|
121
|
+
return rendered[:limit] + "…"
|
|
122
|
+
return rendered
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _bounded_text(value, limit: int = EVENT_PREVIEW_LIMIT) -> str | None:
|
|
126
|
+
"""Bounded plain-text preview (no JSON quoting) for message-like fields.
|
|
127
|
+
|
|
128
|
+
Returns None for an absent value so callers can omit/null the field rather
|
|
129
|
+
than emit a misleading empty string."""
|
|
130
|
+
if value is None:
|
|
131
|
+
return None
|
|
132
|
+
s = value if isinstance(value, str) else str(value)
|
|
133
|
+
if len(s) > limit:
|
|
134
|
+
return s[:limit] + "…"
|
|
135
|
+
return s
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _hook_tool_use_id(data: dict):
|
|
139
|
+
"""Best-effort extraction of the tool_use id across hook payload spellings."""
|
|
140
|
+
return (data.get("tool_use_id") or data.get("toolUseID")
|
|
141
|
+
or data.get("toolUseId"))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _externally_completed_question(req: dict | None, data: dict) -> bool:
|
|
145
|
+
"""Exact original input/name match for a native tool outcome (incl. failure).
|
|
146
|
+
|
|
147
|
+
Hook result content can contain user answers; *only* the tool_input at
|
|
148
|
+
invocation time is the correlation key. Tool-use IDs may be absent or
|
|
149
|
+
inconsistent between PermissionRequest and PostToolUse, and may not make
|
|
150
|
+
different original inputs equivalent.
|
|
151
|
+
"""
|
|
152
|
+
return bool(
|
|
153
|
+
isinstance(req, dict) and req.get("kind") != KIND_ELICITATION
|
|
154
|
+
and isinstance(req.get("tool_name"), str)
|
|
155
|
+
and isinstance(req.get("tool_input"), dict)
|
|
156
|
+
and isinstance(data, dict)
|
|
157
|
+
and req["tool_name"] == data.get("tool_name")
|
|
158
|
+
and isinstance(data.get("tool_input"), dict)
|
|
159
|
+
and req["tool_input"] == data["tool_input"]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _oldest_native_gate(published: dict | None, queued: list[dict], data: dict):
|
|
164
|
+
"""Oldest arrival wins when two native calls have identical full input."""
|
|
165
|
+
for req in ([published] if published is not None else []) + list(queued):
|
|
166
|
+
if _externally_completed_question(req, data):
|
|
167
|
+
return req
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _hook_tool_result(data: dict):
|
|
172
|
+
"""The tool's result blob, accepting either `tool_response` (newer hook
|
|
173
|
+
payloads) or `tool_output` (older spelling) as the key."""
|
|
174
|
+
resp = data.get("tool_response")
|
|
175
|
+
if resp is None:
|
|
176
|
+
resp = data.get("tool_output")
|
|
177
|
+
return resp
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _tool_started_event(data: dict, turn: int) -> dict:
|
|
181
|
+
return {
|
|
182
|
+
"type": "tool_started",
|
|
183
|
+
"tool_use_id": _hook_tool_use_id(data),
|
|
184
|
+
"tool": data.get("tool_name"),
|
|
185
|
+
"input_preview": _event_preview(data.get("tool_input")),
|
|
186
|
+
"turn": turn,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _tool_ok(data: dict) -> bool:
|
|
191
|
+
"""Best-effort success flag for a finished tool. Defaults True; flips to
|
|
192
|
+
False only when the hook's result blob carries an explicit error marker."""
|
|
193
|
+
resp = _hook_tool_result(data)
|
|
194
|
+
if isinstance(resp, dict):
|
|
195
|
+
if resp.get("is_error") or resp.get("isError"):
|
|
196
|
+
return False
|
|
197
|
+
if resp.get("error"):
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _tool_finished_event(data: dict, turn: int, duration_ms: int | None) -> dict:
|
|
203
|
+
return {
|
|
204
|
+
"type": "tool_finished",
|
|
205
|
+
"tool_use_id": _hook_tool_use_id(data),
|
|
206
|
+
"tool": data.get("tool_name"),
|
|
207
|
+
"duration_ms": duration_ms,
|
|
208
|
+
"ok": _tool_ok(data),
|
|
209
|
+
"result_preview": _event_preview(_hook_tool_result(data)),
|
|
210
|
+
"turn": turn,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _subagent_finished_event(data: dict, turn: int) -> dict:
|
|
215
|
+
agent_id = (data.get("agent_id") or data.get("subagent_id")
|
|
216
|
+
or data.get("agentId") or data.get("subagentId"))
|
|
217
|
+
agent_type = (data.get("agent_type") or data.get("subagent_type")
|
|
218
|
+
or data.get("agentType") or data.get("subagentType"))
|
|
219
|
+
last_message = (data.get("last_message") or data.get("lastMessage")
|
|
220
|
+
or data.get("result"))
|
|
221
|
+
return {
|
|
222
|
+
"type": "subagent_finished",
|
|
223
|
+
"agent_id": agent_id,
|
|
224
|
+
"agent_type": agent_type,
|
|
225
|
+
# None (not "") when absent, so consumers don't see a misleading empty
|
|
226
|
+
# message for a subagent whose last text the hook didn't carry.
|
|
227
|
+
"last_message": _bounded_text(last_message),
|
|
228
|
+
"turn": turn,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _count_lines(path) -> int:
|
|
233
|
+
"""Fast newline count for the sidecar (binary, chunked, no parsing). Used to
|
|
234
|
+
seed the in-memory line counter at host start. Returns 0 if absent/unreadable."""
|
|
235
|
+
path = pathlib.Path(path)
|
|
236
|
+
n = 0
|
|
237
|
+
try:
|
|
238
|
+
with path.open("rb") as f:
|
|
239
|
+
while True:
|
|
240
|
+
chunk = f.read(1 << 20)
|
|
241
|
+
if not chunk:
|
|
242
|
+
break
|
|
243
|
+
n += chunk.count(b"\n")
|
|
244
|
+
except OSError:
|
|
245
|
+
return 0
|
|
246
|
+
return n
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _rotate_events_file(path, *, max_lines: int = CCX_EVENTS_MAX,
|
|
250
|
+
keep_lines: int = CCX_EVENTS_KEEP) -> bool:
|
|
251
|
+
"""Bounded rotation: if `path` holds more than `max_lines` lines, rewrite it
|
|
252
|
+
keeping only the last `keep_lines`. Returns True if a rotation happened.
|
|
253
|
+
|
|
254
|
+
Atomic via a temp file + os.replace, so a concurrent reader always sees a
|
|
255
|
+
complete file. The caller must reopen its append handle afterward — the old
|
|
256
|
+
handle still points at the now-unlinked inode."""
|
|
257
|
+
path = pathlib.Path(path)
|
|
258
|
+
try:
|
|
259
|
+
with path.open("r", encoding="utf-8") as f:
|
|
260
|
+
lines = f.readlines()
|
|
261
|
+
except (OSError, ValueError):
|
|
262
|
+
return False
|
|
263
|
+
if len(lines) <= max_lines:
|
|
264
|
+
return False
|
|
265
|
+
tail = lines[-keep_lines:]
|
|
266
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
267
|
+
# Private like the original journal: readers refuse group/other bits, and
|
|
268
|
+
# a plain open() would inherit the host's umask (0664 under umask 002).
|
|
269
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
270
|
+
0o600)
|
|
271
|
+
os.fchmod(fd, 0o600)
|
|
272
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
273
|
+
f.writelines(tail)
|
|
274
|
+
os.replace(tmp, path)
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _open_event_journal(store, short: str):
|
|
279
|
+
"""Append handle on the worker's private event journal, or None.
|
|
280
|
+
|
|
281
|
+
Created 0600; an existing journal we own with loose group/other bits (from
|
|
282
|
+
an older rotation) is tightened rather than abandoned. Anything else —
|
|
283
|
+
not a regular file, another owner, a symlink — is refused.
|
|
284
|
+
"""
|
|
285
|
+
with store.lease():
|
|
286
|
+
with store._gen("workers", "claude", short) as fd:
|
|
287
|
+
event_fd = os.open(
|
|
288
|
+
"events.ndjson", os.O_WRONLY | os.O_CREAT | os.O_APPEND |
|
|
289
|
+
os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=fd,
|
|
290
|
+
)
|
|
291
|
+
stat_event = os.fstat(event_fd)
|
|
292
|
+
if not stat.S_ISREG(stat_event.st_mode) or stat_event.st_uid != os.getuid():
|
|
293
|
+
os.close(event_fd)
|
|
294
|
+
raise PermissionError("worker event journal is untrusted")
|
|
295
|
+
if stat_event.st_mode & 0o077:
|
|
296
|
+
os.fchmod(event_fd, 0o600)
|
|
297
|
+
return os.fdopen(event_fd, "ab", buffering=0)
|