ccx-native 0.0.0-stage → 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 -2
- package/bin/ccx +56 -0
- 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 +40 -4
- 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,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"]
|