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,282 @@
1
+ """Cursor-based Protocol V1 projection over existing Claude analytics.
2
+
3
+ Claude already has durable/recomputable per-prompt analytics. This adapter exposes
4
+ that information without pretending those prompt segments are the managed host's
5
+ exact turn identities: local commands and other user-like transcript records can
6
+ exist outside a managed model turn.
7
+
8
+ The cursor is bound to worker + native Claude session and to a digest of the
9
+ prompt prefix the caller has acknowledged. Normal append-only growth advances
10
+ cheaply. If later transcript/subagent evidence enriches an already-returned
11
+ prompt, the prefix digest changes and the reader explicitly resynchronizes
12
+ instead of silently skipping the correction.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import json
20
+ from datetime import datetime, timezone
21
+ from typing import Any
22
+
23
+ from ccx.claude.analytics import ANALYTICS_SCHEMA_VERSION, compute_session_analytics
24
+ from ccx.errors import InvalidRequest, SessionNotFound, SessionUnavailable
25
+ from ccx.store.records import read_record
26
+
27
+ _CURSOR_VERSION = 1
28
+ _MAX_LIMIT = 1000
29
+
30
+
31
+ def _opaque_cursor(payload: dict[str, Any]) -> str:
32
+ raw = json.dumps(
33
+ payload,
34
+ sort_keys=True,
35
+ separators=(",", ":"),
36
+ ensure_ascii=False,
37
+ ).encode("utf-8")
38
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
39
+
40
+
41
+ def _parse_cursor(value: str | None) -> dict[str, Any] | None:
42
+ if value is None:
43
+ return None
44
+ if not isinstance(value, str) or not value:
45
+ raise InvalidRequest("telemetry cursor must be a non-empty opaque string")
46
+ try:
47
+ raw = value + "=" * (-len(value) % 4)
48
+ payload = json.loads(base64.urlsafe_b64decode(raw.encode("ascii")))
49
+ except Exception as error:
50
+ raise InvalidRequest("telemetry cursor is invalid") from error
51
+ if not isinstance(payload, dict) or payload.get("v") != _CURSOR_VERSION:
52
+ raise InvalidRequest("telemetry cursor is invalid")
53
+ count = payload.get("count")
54
+ if not isinstance(count, int) or isinstance(count, bool) or count < 0:
55
+ raise InvalidRequest("telemetry cursor is invalid")
56
+ for key in ("short", "session_id", "prefix_sha256"):
57
+ item = payload.get(key)
58
+ if not isinstance(item, str) or not item:
59
+ raise InvalidRequest("telemetry cursor is invalid")
60
+ schema = payload.get("analytics_schema_version")
61
+ if not isinstance(schema, int) or isinstance(schema, bool) or schema <= 0:
62
+ raise InvalidRequest("telemetry cursor is invalid")
63
+ return payload
64
+
65
+
66
+ def _prompt_prefix_digest(prompts: list[dict[str, Any]], count: int) -> str:
67
+ raw = json.dumps(
68
+ prompts[:count],
69
+ sort_keys=True,
70
+ separators=(",", ":"),
71
+ ensure_ascii=False,
72
+ ).encode("utf-8")
73
+ return hashlib.sha256(raw).hexdigest()
74
+
75
+
76
+ def _cursor(
77
+ *,
78
+ short: str,
79
+ session_id: str,
80
+ prompts: list[dict[str, Any]],
81
+ count: int,
82
+ ) -> str:
83
+ return _opaque_cursor(
84
+ {
85
+ "v": _CURSOR_VERSION,
86
+ "short": short,
87
+ "session_id": session_id,
88
+ "analytics_schema_version": ANALYTICS_SCHEMA_VERSION,
89
+ "count": count,
90
+ "prefix_sha256": _prompt_prefix_digest(prompts, count),
91
+ }
92
+ )
93
+
94
+
95
+ def _iso_ms(value: Any) -> str | None:
96
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
97
+ return None
98
+ try:
99
+ return (
100
+ datetime.fromtimestamp(float(value) / 1000.0, tz=timezone.utc)
101
+ .isoformat()
102
+ .replace("+00:00", "Z")
103
+ )
104
+ except (OverflowError, OSError, ValueError):
105
+ return None
106
+
107
+
108
+ def _turn_entry(prompt: dict[str, Any]) -> dict[str, Any]:
109
+ prompt_id = str(prompt.get("promptId") or f"turn-{prompt.get('turnIndex') or 0}")
110
+ tokens = prompt.get("tokens") if isinstance(prompt.get("tokens"), dict) else {}
111
+ started_ms = prompt.get("tsMs")
112
+ duration_ms = prompt.get("durationMs")
113
+ activity_end_ms = (
114
+ int(started_ms) + int(duration_ms)
115
+ if isinstance(started_ms, int)
116
+ and not isinstance(started_ms, bool)
117
+ and isinstance(duration_ms, int)
118
+ and not isinstance(duration_ms, bool)
119
+ and duration_ms >= 0
120
+ else None
121
+ )
122
+ return {
123
+ # Stable telemetry identity. Deliberately not exposed as turn_id/ref:
124
+ # analytics prompt identity and managed-host turn identity are different.
125
+ "telemetry_id": f"claude-prompt:{prompt_id}",
126
+ "turn_id": None,
127
+ "turn_ref": None,
128
+ "prompt_id": prompt_id,
129
+ "turn_index": prompt.get("turnIndex"),
130
+ "kind": "analytics-prompt",
131
+ "started_at": _iso_ms(started_ms),
132
+ "completed_at": None,
133
+ "activity_ended_at": _iso_ms(activity_end_ms),
134
+ "outcome": None,
135
+ "tokens": {
136
+ "input": int(tokens.get("input") or 0),
137
+ "output": int(tokens.get("output") or 0),
138
+ "cached_input": int(tokens.get("cacheRead") or 0),
139
+ "cache_creation": int(tokens.get("cacheCreation") or 0),
140
+ "reasoning_output": None,
141
+ },
142
+ "by_model": dict(prompt.get("byModel") or {}),
143
+ "tools": dict(prompt.get("tools") or {}),
144
+ "tool_errors": int(prompt.get("toolErrors") or 0),
145
+ "ask_user_questions": int(prompt.get("askUserQuestions") or 0),
146
+ "steering": dict(prompt.get("steering") or {}),
147
+ "subagents": dict(prompt.get("subagents") or {}),
148
+ "coverage": {
149
+ "identity": "analytics-prompt",
150
+ "instruction": "not-exported-by-analytics",
151
+ "steers": "counts-only",
152
+ "lifecycle": "not-correlated-to-managed-turn",
153
+ "context_after": "unavailable",
154
+ },
155
+ "sources": [f"claude-analytics-v{ANALYTICS_SCHEMA_VERSION}"],
156
+ }
157
+
158
+
159
+ def read_telemetry(
160
+ short: str,
161
+ *,
162
+ after_cursor: str | None = None,
163
+ limit: int = 200,
164
+ record: dict[str, Any] | None = None,
165
+ cursor_worker_id: str | None = None,
166
+ before_offset: int | None = None,
167
+ source_missing: bool = False,
168
+ ) -> dict[str, Any]:
169
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
170
+ raise InvalidRequest("telemetry limit must be a positive integer")
171
+ limit = min(limit, _MAX_LIMIT)
172
+
173
+ rec = record or read_record(short)
174
+ if not isinstance(rec, dict):
175
+ raise SessionNotFound(f"no such Claude worker: {short}", short=short)
176
+ short = str(rec.get("short") or short)
177
+ session_id = rec.get("sessionId")
178
+ if not isinstance(session_id, str) or not session_id:
179
+ raise SessionUnavailable(
180
+ f"Claude worker {short} has no durable session identity",
181
+ short=short,
182
+ )
183
+
184
+ # Read-only recomputation; full-UUID callers supply a pinned original
185
+ # source and its last complete line, never an analytics sidecar.
186
+ doc = (compute_session_analytics(rec)
187
+ if before_offset is None
188
+ else compute_session_analytics(rec, before_offset=before_offset))
189
+ cursor_owner = cursor_worker_id or short
190
+ prompts = [
191
+ value for value in (doc.get("prompts") or []) if isinstance(value, dict)
192
+ ]
193
+ parsed = _parse_cursor(after_cursor)
194
+ start = 0
195
+ gap = None
196
+ if parsed is not None:
197
+ reason = None
198
+ if parsed.get("short") != cursor_owner:
199
+ reason = "cursor_worker_mismatch"
200
+ elif parsed.get("session_id") != session_id:
201
+ reason = "cursor_session_mismatch"
202
+ elif parsed.get("analytics_schema_version") != ANALYTICS_SCHEMA_VERSION:
203
+ reason = "cursor_analytics_schema_mismatch"
204
+ elif int(parsed["count"]) > len(prompts):
205
+ reason = "cursor_ahead_of_prompt_history"
206
+ elif parsed.get("prefix_sha256") != _prompt_prefix_digest(
207
+ prompts, int(parsed["count"])
208
+ ):
209
+ reason = "cursor_prompt_prefix_changed"
210
+
211
+ if reason is None:
212
+ start = int(parsed["count"])
213
+ else:
214
+ gap = {
215
+ "reason": reason,
216
+ "requested_count": parsed.get("count"),
217
+ "current_count": len(prompts),
218
+ }
219
+
220
+ page = prompts[start : start + limit]
221
+ processed = start + len(page)
222
+ has_more = processed < len(prompts)
223
+
224
+ stats = doc.get("stats") if isinstance(doc.get("stats"), dict) else {}
225
+ warnings: list[str] = []
226
+ if source_missing:
227
+ warnings.append("original Claude native source has not appeared")
228
+ if int(stats.get("malformedLines") or 0):
229
+ warnings.append("Claude analytics skipped malformed transcript lines")
230
+ if int(stats.get("subagentFilesUnreadable") or 0):
231
+ warnings.append("one or more Claude subagent transcripts were unreadable")
232
+ if gap is not None:
233
+ warnings.append(
234
+ "requested telemetry cursor no longer matches the acknowledged "
235
+ "Claude analytics prefix; response resynchronized from the start"
236
+ )
237
+
238
+ lifecycle = (
239
+ rec.get("lifecycle") if isinstance(rec.get("lifecycle"), dict) else {}
240
+ )
241
+ state = lifecycle.get("state")
242
+ if state in {"booting", "busy", "waiting", "resuming"}:
243
+ warnings.append(
244
+ "worker is not settled; the current analytics tail may be enriched "
245
+ "and a later cursor read can request resynchronization"
246
+ )
247
+
248
+ totals = doc.get("totals") if isinstance(doc.get("totals"), dict) else {}
249
+ return {
250
+ "worker": {
251
+ "engine": "claude",
252
+ "short": short,
253
+ "name": rec.get("name"),
254
+ "session_id": session_id,
255
+ },
256
+ "telemetry_kind": f"claude-analytics-v{ANALYTICS_SCHEMA_VERSION}",
257
+ "turns": [_turn_entry(value) for value in page],
258
+ # Existing Claude analytics do not provide the Codex evidence-journal
259
+ # compaction projection. Keep this explicit rather than fabricating it.
260
+ "compactions": [],
261
+ "totals": totals,
262
+ "coverage": {
263
+ "status": "partial" if warnings else "complete",
264
+ "gap": gap,
265
+ "warnings": warnings,
266
+ "analytics_schema_version": ANALYTICS_SCHEMA_VERSION,
267
+ "identity": "analytics-prompt",
268
+ "native_turn_identity": "not-provided-by-source",
269
+ "compactions": "not-provided-by-source",
270
+ "worker_state": state,
271
+ },
272
+ "next_cursor": _cursor(
273
+ short=cursor_owner,
274
+ session_id=session_id,
275
+ prompts=prompts,
276
+ count=processed,
277
+ ),
278
+ "has_more": has_more,
279
+ }
280
+
281
+
282
+ __all__ = ["read_telemetry"]