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,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)