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,792 @@
1
+ """Normalized replayable Codex telemetry over sparse CCX evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from typing import Any
8
+
9
+ from ccx.codex import evidence as codex_evidence
10
+ from ccx.codex import runtime as codex_runtime
11
+ from ccx.codex.evidence import COMPACTION_CONTEXT_METHOD, TOKEN_USAGE_METHOD
12
+ from ccx.ops.conversation import build_codex
13
+ from ccx.errors import InvalidRequest
14
+
15
+ _CURSOR_VERSION = 2
16
+
17
+
18
+ def _cursor(
19
+ short: str,
20
+ journal_instance_id: str | None,
21
+ sequence: int,
22
+ ) -> str:
23
+ raw = json.dumps(
24
+ {
25
+ "v": _CURSOR_VERSION,
26
+ "short": short,
27
+ "journal_instance_id": journal_instance_id,
28
+ "sequence": sequence,
29
+ },
30
+ separators=(",", ":"),
31
+ sort_keys=True,
32
+ ).encode("utf-8")
33
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
34
+
35
+
36
+ def _parse_cursor(value: str | None) -> dict[str, Any] | None:
37
+ if value is None:
38
+ return None
39
+ if not isinstance(value, str) or not value:
40
+ raise InvalidRequest("telemetry cursor must be a non-empty opaque string")
41
+ try:
42
+ raw = value + "=" * (-len(value) % 4)
43
+ data = json.loads(base64.urlsafe_b64decode(raw.encode("ascii")))
44
+ except Exception as error:
45
+ raise InvalidRequest("telemetry cursor is invalid") from error
46
+ if (
47
+ not isinstance(data, dict)
48
+ or not isinstance(data.get("sequence"), int)
49
+ or isinstance(data.get("sequence"), bool)
50
+ or data["sequence"] < 0
51
+ ):
52
+ raise InvalidRequest("telemetry cursor is invalid")
53
+ version = data.get("v")
54
+ if version == 1:
55
+ return {
56
+ "version": 1,
57
+ "sequence": data["sequence"],
58
+ "short": None,
59
+ "journal_instance_id": None,
60
+ }
61
+ if version != _CURSOR_VERSION:
62
+ raise InvalidRequest("telemetry cursor is invalid")
63
+ short = data.get("short")
64
+ instance = data.get("journal_instance_id")
65
+ if not isinstance(short, str) or not short:
66
+ raise InvalidRequest("telemetry cursor is invalid")
67
+ if instance is not None and (
68
+ not isinstance(instance, str) or not instance
69
+ ):
70
+ raise InvalidRequest("telemetry cursor is invalid")
71
+ return {
72
+ "version": _CURSOR_VERSION,
73
+ "sequence": data["sequence"],
74
+ "short": short,
75
+ "journal_instance_id": instance,
76
+ }
77
+
78
+
79
+ def _turn_id(event: dict[str, Any]) -> str | None:
80
+ value = event.get("turnId")
81
+ return value if isinstance(value, str) and value else None
82
+
83
+
84
+ def _item_id(event: dict[str, Any]) -> str | None:
85
+ value = event.get("itemId")
86
+ return value if isinstance(value, str) and value else None
87
+
88
+
89
+ def _usage_projection(event: dict[str, Any]) -> dict[str, Any]:
90
+ params = event.get("params")
91
+ params = params if isinstance(params, dict) else {}
92
+ usage = params.get("tokenUsage")
93
+ usage = usage if isinstance(usage, dict) else {}
94
+ last = usage.get("last")
95
+ last = last if isinstance(last, dict) else {}
96
+ total = usage.get("total")
97
+ total = total if isinstance(total, dict) else {}
98
+ window = usage.get("modelContextWindow")
99
+ input_tokens = last.get("inputTokens")
100
+ zero_last = all(
101
+ int(last.get(key) or 0) == 0
102
+ for key in (
103
+ "inputTokens",
104
+ "cachedInputTokens",
105
+ "outputTokens",
106
+ "reasoningOutputTokens",
107
+ )
108
+ )
109
+ percent = (
110
+ input_tokens / window * 100
111
+ if (
112
+ isinstance(input_tokens, int)
113
+ and isinstance(window, int)
114
+ and window > 0
115
+ )
116
+ else None
117
+ )
118
+ return {
119
+ "context_after": (
120
+ {
121
+ "tokens_used": input_tokens,
122
+ "window_size": window if isinstance(window, int) else None,
123
+ "percent_used": percent,
124
+ "as_of_turn_ref": None,
125
+ "observed_at_ms": event.get("observedAtMs"),
126
+ }
127
+ if isinstance(input_tokens, int)
128
+ else None
129
+ ),
130
+ "cumulative": {
131
+ "input": total.get("inputTokens")
132
+ if isinstance(total.get("inputTokens"), int) else None,
133
+ "output": total.get("outputTokens")
134
+ if isinstance(total.get("outputTokens"), int) else None,
135
+ "cached_input": total.get("cachedInputTokens")
136
+ if isinstance(total.get("cachedInputTokens"), int) else None,
137
+ "reasoning_output": total.get("reasoningOutputTokens")
138
+ if isinstance(total.get("reasoningOutputTokens"), int) else None,
139
+ "total": total.get("totalTokens")
140
+ if isinstance(total.get("totalTokens"), int) else None,
141
+ },
142
+ "zero_last": zero_last,
143
+ "reset": False,
144
+ "sequence": event.get("sequence"),
145
+ "observed_at_ms": event.get("observedAtMs"),
146
+ "source": "codex-thread-token-usage-updated",
147
+ }
148
+
149
+
150
+ def _zero_cumulative() -> dict[str, int]:
151
+ return {
152
+ "input": 0,
153
+ "output": 0,
154
+ "cached_input": 0,
155
+ "reasoning_output": 0,
156
+ "total": 0,
157
+ }
158
+
159
+
160
+ def _turn_token_delta(
161
+ end: dict[str, Any] | None,
162
+ baseline: dict[str, Any] | None,
163
+ ) -> dict[str, int] | None:
164
+ if not isinstance(end, dict) or not isinstance(baseline, dict):
165
+ return None
166
+ out: dict[str, int] = {}
167
+ for key in ("input", "output", "cached_input", "reasoning_output", "total"):
168
+ newer = end.get(key)
169
+ older = baseline.get(key)
170
+ if not isinstance(newer, int) or not isinstance(older, int):
171
+ return None
172
+ delta = newer - older
173
+ if delta < 0:
174
+ return None
175
+ out[key] = delta
176
+ return out
177
+
178
+ def _user_text_items(turn: dict[str, Any]) -> list[dict[str, Any]]:
179
+ items = [
180
+ item
181
+ for item in (turn.get("input") or [])
182
+ if isinstance(item, dict) and item.get("kind") == "user-text"
183
+ ]
184
+ return sorted(
185
+ items,
186
+ key=lambda item: (
187
+ item.get("ordinal")
188
+ if isinstance(item.get("ordinal"), int)
189
+ else 1 << 30
190
+ ),
191
+ )
192
+
193
+
194
+ def _instruction_and_steers(
195
+ turn: dict[str, Any],
196
+ ) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
197
+ messages = _user_text_items(turn)
198
+ if not messages:
199
+ return None, []
200
+
201
+ opening_index = 0
202
+ for index, item in enumerate(messages):
203
+ if item.get("origin") == "ccx-start":
204
+ opening_index = index
205
+ break
206
+ opening = messages[opening_index]
207
+
208
+ instruction = {
209
+ "text": opening.get("text") if isinstance(opening.get("text"), str) else "",
210
+ "source": opening.get("origin") or "native-user-message",
211
+ "client_id": opening.get("clientId"),
212
+ "ordinal": opening.get("ordinal"),
213
+ }
214
+ steers = []
215
+ for index, item in enumerate(messages):
216
+ if index == opening_index:
217
+ continue
218
+ steers.append({
219
+ "text": item.get("text") if isinstance(item.get("text"), str) else "",
220
+ "source": item.get("origin") or "native-user-message",
221
+ "client_id": item.get("clientId"),
222
+ "ordinal": item.get("ordinal"),
223
+ "started_at_ms": item.get("startedAtMs"),
224
+ "completed_at_ms": item.get("completedAtMs"),
225
+ })
226
+ return instruction, steers
227
+
228
+
229
+ def _compaction_id(
230
+ *,
231
+ turn_id: str | None,
232
+ item_id: str | None,
233
+ operation_id: str | None,
234
+ sequence: int | None,
235
+ ) -> str:
236
+ if item_id:
237
+ return f"item:{item_id}"
238
+ if operation_id:
239
+ return f"operation:{operation_id}"
240
+ if turn_id:
241
+ return f"turn:{turn_id}"
242
+ return f"sequence:{sequence or 0}"
243
+
244
+
245
+ def _compactions(
246
+ events: list[dict[str, Any]],
247
+ ) -> tuple[list[dict[str, Any]], dict[int, str]]:
248
+ by_id: dict[str, dict[str, Any]] = {}
249
+ sequence_to_id: dict[int, str] = {}
250
+
251
+ for event in events:
252
+ if event.get("method") != "item/completed":
253
+ continue
254
+ params = event.get("params")
255
+ params = params if isinstance(params, dict) else {}
256
+ item = params.get("item")
257
+ item = item if isinstance(item, dict) else {}
258
+ if item.get("type") != "contextCompaction":
259
+ continue
260
+ turn_id = _turn_id(event)
261
+ item_id = _item_id(event) or (
262
+ item.get("id") if isinstance(item.get("id"), str) else None
263
+ )
264
+ sequence = event.get("sequence")
265
+ cid = _compaction_id(
266
+ turn_id=turn_id,
267
+ item_id=item_id,
268
+ operation_id=None,
269
+ sequence=sequence if isinstance(sequence, int) else None,
270
+ )
271
+ by_id[cid] = {
272
+ "compaction_id": cid,
273
+ "turn_id": turn_id,
274
+ "item_id": item_id,
275
+ "operation_id": None,
276
+ "kind": "native",
277
+ "completed_at_ms": (
278
+ params.get("completedAtMs")
279
+ if isinstance(params.get("completedAtMs"), int)
280
+ else event.get("observedAtMs")
281
+ ),
282
+ "context_before": None,
283
+ "context_after": None,
284
+ "coverage": {
285
+ "context_before": "unavailable",
286
+ "context_after": "unavailable",
287
+ },
288
+ "sources": ["codex-context-compaction-item"],
289
+ }
290
+ if isinstance(sequence, int):
291
+ sequence_to_id[sequence] = cid
292
+
293
+ for event in events:
294
+ if event.get("method") != COMPACTION_CONTEXT_METHOD:
295
+ continue
296
+ params = event.get("params")
297
+ params = params if isinstance(params, dict) else {}
298
+ item = params.get("item")
299
+ item = item if isinstance(item, dict) else {}
300
+ turn_id = _turn_id(event)
301
+ item_id = _item_id(event) or (
302
+ item.get("id") if isinstance(item.get("id"), str) else None
303
+ )
304
+ operation_id = (
305
+ params.get("operationId")
306
+ if isinstance(params.get("operationId"), str)
307
+ else None
308
+ )
309
+ sequence = event.get("sequence")
310
+ cid = _compaction_id(
311
+ turn_id=turn_id,
312
+ item_id=item_id,
313
+ operation_id=operation_id,
314
+ sequence=sequence if isinstance(sequence, int) else None,
315
+ )
316
+ entry = by_id.setdefault(cid, {
317
+ "compaction_id": cid,
318
+ "turn_id": turn_id,
319
+ "item_id": item_id,
320
+ "operation_id": operation_id,
321
+ "kind": "explicit" if operation_id else "native",
322
+ "completed_at_ms": params.get("completedAtMs"),
323
+ "context_before": None,
324
+ "context_after": None,
325
+ "coverage": {},
326
+ "sources": [],
327
+ })
328
+ entry["operation_id"] = operation_id or entry.get("operation_id")
329
+ if operation_id:
330
+ entry["kind"] = "explicit"
331
+ entry["context_before"] = params.get("contextBefore")
332
+ entry["context_after"] = params.get("contextAfter")
333
+ entry["coverage"] = {
334
+ "context_before": (
335
+ "observed" if params.get("contextBefore") is not None else "unavailable"
336
+ ),
337
+ "context_after": (
338
+ "observed"
339
+ if params.get("contextAfter") is not None
340
+ else "pending-next-turn"
341
+ ),
342
+ }
343
+ if "ccx-compaction-context" not in entry["sources"]:
344
+ entry["sources"].append("ccx-compaction-context")
345
+ if isinstance(sequence, int):
346
+ sequence_to_id[sequence] = cid
347
+
348
+ return list(by_id.values()), sequence_to_id
349
+
350
+
351
+ def _original_source(store, owner):
352
+ """Original owned Codex record, thread identity and filtered evidence.
353
+
354
+ Shared with full conversation: evidence cannot attribute an event with a
355
+ missing/foreign native threadId to this worker's original thread.
356
+ """
357
+ from ccx.store.root import NativeActionDenied
358
+
359
+ source = store.record(owner.worker)
360
+ native = source.get("native") or {}
361
+ activation = store.native_activation(owner.worker)
362
+ thread_id = native.get("threadId")
363
+ expected_source = store.codex_source(owner.worker)
364
+ if (not isinstance(thread_id, str) or not thread_id
365
+ or native.get("threadSource") != expected_source
366
+ or activation.get("state") != "confirmed"
367
+ or activation.get("source") != expected_source
368
+ or (activation.get("nativeBinding") or {}).get("threadId") != thread_id):
369
+ raise NativeActionDenied("Codex telemetry original thread/source unconfirmed")
370
+ record = {**source, "short": owner.short, "threadId": thread_id}
371
+ journal = codex_evidence.read_journal(owner.worker)
372
+ # The owned nofollow journal inode does not attest each native event's
373
+ # threadId; header and CCX-only feed events are not native turn evidence.
374
+ foreign_events = [
375
+ event for event in journal.events
376
+ if event.get("method") not in (
377
+ codex_evidence.HEADER_METHOD, *codex_evidence.FEED_METHODS,
378
+ ) and isinstance(event.get("threadId"), str)
379
+ and event["threadId"] and event["threadId"] != thread_id
380
+ ]
381
+ journal.events = [
382
+ event for event in journal.events
383
+ if event.get("method") in (
384
+ codex_evidence.HEADER_METHOD, *codex_evidence.FEED_METHODS,
385
+ ) or event.get("threadId") == thread_id
386
+ ]
387
+ evidence_index = codex_evidence.build_index(
388
+ journal,
389
+ health=(record.get("evidence") if isinstance(record.get("evidence"), dict)
390
+ else None),
391
+ )
392
+ return record, thread_id, journal, foreign_events, evidence_index
393
+
394
+
395
+ def read_telemetry(
396
+ target: str,
397
+ *,
398
+ after_cursor: str | None = None,
399
+ limit: int = 200,
400
+ timeout: float = 120,
401
+ ) -> dict[str, Any]:
402
+ """Return normalized per-turn telemetry and replay cursor for one worker."""
403
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
404
+ raise InvalidRequest("telemetry limit must be a positive integer")
405
+ if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or timeout <= 0:
406
+ raise InvalidRequest("telemetry timeout must be greater than zero")
407
+ limit = min(limit, 1000)
408
+
409
+ from ccx import constants
410
+ from ccx.store.root import require_ccx_active
411
+
412
+ store = require_ccx_active(constants.CCX_ROOT)
413
+ owner = store.resolve(target)
414
+ if owner.engine != "codex":
415
+ raise InvalidRequest("Codex telemetry requires an exact Codex full UUID")
416
+ record, thread_id, journal, foreign_events, evidence_index = _original_source(
417
+ store, owner,
418
+ )
419
+ short = owner.short
420
+ cursor_owner = owner.worker_id
421
+ parsed_cursor = _parse_cursor(after_cursor)
422
+ after_sequence = (
423
+ parsed_cursor.get("sequence")
424
+ if isinstance(parsed_cursor, dict)
425
+ else None
426
+ )
427
+ cursor_mismatch = None
428
+ if isinstance(parsed_cursor, dict):
429
+ if parsed_cursor.get("version") != _CURSOR_VERSION:
430
+ cursor_mismatch = "legacy_cursor"
431
+ elif parsed_cursor.get("short") != cursor_owner:
432
+ cursor_mismatch = "cursor_worker_mismatch"
433
+ elif (
434
+ parsed_cursor.get("journal_instance_id")
435
+ != journal.journal_instance_id
436
+ ):
437
+ cursor_mismatch = "cursor_journal_mismatch"
438
+ cursor_ahead = (
439
+ cursor_mismatch is None
440
+ and after_sequence is not None
441
+ and after_sequence > journal.last_sequence
442
+ )
443
+ effective_after_sequence = (
444
+ None if cursor_mismatch is not None or cursor_ahead else after_sequence
445
+ )
446
+
447
+ events = journal.events
448
+ delta_events = [
449
+ event
450
+ for event in events
451
+ if (
452
+ isinstance(event.get("sequence"), int)
453
+ and (
454
+ effective_after_sequence is None
455
+ or event["sequence"] > effective_after_sequence
456
+ )
457
+ )
458
+ ]
459
+ has_more = len(delta_events) > limit
460
+ processed_delta = delta_events[:limit]
461
+ if processed_delta:
462
+ processed_through = int(processed_delta[-1]["sequence"])
463
+ elif effective_after_sequence is not None:
464
+ processed_through = effective_after_sequence
465
+ else:
466
+ processed_through = journal.last_sequence
467
+ if not has_more:
468
+ processed_through = journal.last_sequence
469
+
470
+ event_sequences = [
471
+ event.get("sequence")
472
+ for event in events
473
+ if isinstance(event.get("sequence"), int)
474
+ ]
475
+ first_sequence = min(event_sequences) if event_sequences else None
476
+ gap = None
477
+ if cursor_mismatch is not None:
478
+ gap = {
479
+ "reason": cursor_mismatch,
480
+ "requested_after_sequence": after_sequence,
481
+ "first_retained_sequence": first_sequence,
482
+ "last_retained_sequence": journal.last_sequence,
483
+ "requested_worker": (
484
+ parsed_cursor.get("short")
485
+ if isinstance(parsed_cursor, dict) else None
486
+ ),
487
+ "current_worker": cursor_owner,
488
+ "requested_journal_instance_id": (
489
+ parsed_cursor.get("journal_instance_id")
490
+ if isinstance(parsed_cursor, dict) else None
491
+ ),
492
+ "current_journal_instance_id": journal.journal_instance_id,
493
+ }
494
+ elif cursor_ahead:
495
+ gap = {
496
+ "reason": "cursor_ahead_of_retained_tail",
497
+ "requested_after_sequence": after_sequence,
498
+ "last_retained_sequence": journal.last_sequence,
499
+ "first_retained_sequence": first_sequence,
500
+ }
501
+ elif (
502
+ after_sequence is not None
503
+ and first_sequence is not None
504
+ and after_sequence < first_sequence - 1
505
+ ):
506
+ gap = {
507
+ "reason": "cursor_predates_retained_evidence",
508
+ "requested_after_sequence": after_sequence,
509
+ "first_retained_sequence": first_sequence,
510
+ }
511
+
512
+ # Only an explicit successful native thread/start proves history is empty.
513
+ # Legacy records can have native history even when their turn counter is 0.
514
+ if record.get("historyKnownEmpty") is True:
515
+ conversation = {"turns": [], "completeness": {"status": "complete"}}
516
+ else:
517
+ thread = codex_runtime.read_thread(
518
+ thread_id, cwd=record["cwd"], timeout=float(timeout),
519
+ worker_id=owner.worker_id,
520
+ expected_source=store.codex_source(owner.worker),
521
+ )
522
+ conversation = build_codex(
523
+ record,
524
+ thread,
525
+ state=(record.get("lifecycle") or {}).get("state"),
526
+ worker=f"codex:{short}",
527
+ max_tool_result_bytes=0,
528
+ evidence=evidence_index,
529
+ )
530
+
531
+ latest_usage: dict[str, dict[str, Any]] = {}
532
+ turn_cumulative: dict[str, dict[str, Any]] = {}
533
+ touched_turns: set[str] = set()
534
+ usage_touched_turns: set[str] = set()
535
+ previous_observed_cumulative: dict[str, Any] | None = None
536
+ for event in events:
537
+ sequence = event.get("sequence")
538
+ tid = _turn_id(event)
539
+ in_delta = (
540
+ tid
541
+ and isinstance(sequence, int)
542
+ and (
543
+ effective_after_sequence is None
544
+ or sequence > effective_after_sequence
545
+ )
546
+ and sequence <= processed_through
547
+ )
548
+ if in_delta:
549
+ touched_turns.add(tid)
550
+ if event.get("method") == TOKEN_USAGE_METHOD and tid:
551
+ projected = _usage_projection(event)
552
+ cumulative = projected.get("cumulative")
553
+ projected["reset"] = bool(
554
+ projected.get("zero_last")
555
+ and isinstance(previous_observed_cumulative, dict)
556
+ and isinstance(cumulative, dict)
557
+ and cumulative == previous_observed_cumulative
558
+ )
559
+ if projected["reset"]:
560
+ projected["context_after"] = None
561
+ if isinstance(cumulative, dict) and any(
562
+ isinstance(cumulative.get(key), int)
563
+ for key in (
564
+ "input",
565
+ "output",
566
+ "cached_input",
567
+ "reasoning_output",
568
+ "total",
569
+ )
570
+ ):
571
+ previous_observed_cumulative = dict(cumulative)
572
+ current = latest_usage.get(tid)
573
+ if current is None or int(event.get("sequence") or 0) >= int(
574
+ current.get("sequence") or 0
575
+ ):
576
+ latest_usage[tid] = projected
577
+ turn_cumulative[tid] = dict(
578
+ projected.get("cumulative") or {}
579
+ )
580
+ if in_delta:
581
+ usage_touched_turns.add(tid)
582
+
583
+ compactions, compaction_sequence_ids = _compactions(events)
584
+ touched_compactions = {
585
+ cid
586
+ for sequence, cid in compaction_sequence_ids.items()
587
+ if (
588
+ (
589
+ effective_after_sequence is None
590
+ or sequence > effective_after_sequence
591
+ )
592
+ and sequence <= processed_through
593
+ )
594
+ }
595
+
596
+ conversation_turns = [
597
+ turn for turn in (conversation.get("turns") or [])
598
+ if isinstance(turn, dict)
599
+ ]
600
+ evidence_turns = evidence_index.get("turns") or {}
601
+ lifecycle_coverage_complete = all(
602
+ (
603
+ isinstance(turn.get("turnId"), str)
604
+ and bool(
605
+ (evidence_turns.get(turn.get("turnId")) or {}).get(
606
+ "sawTurnStarted"
607
+ )
608
+ )
609
+ and bool(
610
+ (evidence_turns.get(turn.get("turnId")) or {}).get(
611
+ "sawTurnCompleted"
612
+ )
613
+ )
614
+ )
615
+ for turn in conversation_turns
616
+ )
617
+ evidence_complete = (
618
+ evidence_index.get("status") == "complete"
619
+ and gap is None
620
+ and not foreign_events
621
+ and lifecycle_coverage_complete
622
+ )
623
+
624
+ # tokenUsage.total is cumulative across the native thread. Fold endpoints
625
+ # independently of journal arrival order, then subtract only the immediately
626
+ # preceding native turn endpoint. Missing predecessor evidence keeps a turn
627
+ # partial rather than attributing multiple turns to one delta.
628
+ turn_baseline: dict[str, dict[str, Any] | None] = {}
629
+ previous_turn_id: str | None = None
630
+ previous_endpoint: dict[str, Any] | None = None
631
+ for index, turn in enumerate(conversation_turns):
632
+ turn_id = turn.get("turnId")
633
+ if not isinstance(turn_id, str) or not turn_id:
634
+ previous_turn_id = None
635
+ previous_endpoint = None
636
+ continue
637
+ ev = evidence_turns.get(turn_id) or {}
638
+ if (
639
+ index == 0
640
+ and ev.get("sawTurnStarted")
641
+ ):
642
+ baseline: dict[str, Any] | None = _zero_cumulative()
643
+ else:
644
+ baseline = (
645
+ dict(previous_endpoint)
646
+ if isinstance(previous_endpoint, dict)
647
+ else None
648
+ )
649
+ turn_baseline[turn_id] = baseline
650
+
651
+ # A late endpoint for turn N changes the derived consumption of N+1,
652
+ # even though no new native event belongs to N+1. Re-emit that direct
653
+ # successor so cursor consumers can upsert the corrected projection.
654
+ if (
655
+ previous_turn_id in usage_touched_turns
656
+ and turn_id in turn_cumulative
657
+ ):
658
+ touched_turns.add(turn_id)
659
+
660
+ endpoint = turn_cumulative.get(turn_id)
661
+ previous_endpoint = (
662
+ dict(endpoint) if isinstance(endpoint, dict) else None
663
+ )
664
+ previous_turn_id = turn_id
665
+
666
+ compaction_turn_ids = {
667
+ item.get("turn_id")
668
+ for item in compactions
669
+ if isinstance(item.get("turn_id"), str)
670
+ }
671
+ turns = []
672
+ for turn in conversation_turns:
673
+ if not isinstance(turn, dict):
674
+ continue
675
+ turn_id = turn.get("turnId")
676
+ ev = (evidence_index.get("turns") or {}).get(turn_id) or {}
677
+ ev_sequences = ev.get("sequences") or []
678
+ first_ev_sequence = min(
679
+ (seq for seq in ev_sequences if isinstance(seq, int)),
680
+ default=None,
681
+ )
682
+ if after_sequence is not None and evidence_complete:
683
+ if turn_id not in touched_turns:
684
+ continue
685
+ elif after_sequence is None and has_more and first_ev_sequence is not None:
686
+ if first_ev_sequence > processed_through:
687
+ continue
688
+ instruction, steers = _instruction_and_steers(turn)
689
+ usage = latest_usage.get(turn_id)
690
+ baseline = turn_baseline.get(turn_id)
691
+ tokens = _turn_token_delta(turn_cumulative.get(turn_id), baseline)
692
+ context_after = usage.get("context_after") if usage else None
693
+ if (
694
+ turn_id in compaction_turn_ids
695
+ and usage is not None
696
+ and usage.get("zero_last")
697
+ ):
698
+ context_after = None
699
+ turns.append({
700
+ "turn_id": turn_id,
701
+ "kind": "compaction" if turn_id in compaction_turn_ids else "model",
702
+ "instruction": instruction,
703
+ "steers": steers,
704
+ "started_at": turn.get("startedAt"),
705
+ "completed_at": turn.get("completedAt"),
706
+ "observed_started_at_ms": ev.get("startedAtMs"),
707
+ "observed_completed_at_ms": ev.get("completedAtMs"),
708
+ "outcome": turn.get("status"),
709
+ "tokens": tokens,
710
+ "context_after": context_after,
711
+ "compaction_refs": [
712
+ item["compaction_id"]
713
+ for item in compactions
714
+ if item.get("turn_id") == turn_id
715
+ ],
716
+ "coverage": {
717
+ "lifecycle": (
718
+ "observed"
719
+ if ev.get("sawTurnStarted") and ev.get("sawTurnCompleted")
720
+ else "partial"
721
+ ),
722
+ "tokens": (
723
+ "observed" if tokens is not None
724
+ else ("partial" if usage is not None else "unavailable")
725
+ ),
726
+ "context_after": (
727
+ "observed" if context_after is not None
728
+ else (
729
+ "pending-next-turn"
730
+ if turn_id in compaction_turn_ids
731
+ else "unavailable"
732
+ )
733
+ ),
734
+ },
735
+ "sources": [
736
+ "codex-thread-turns-paged",
737
+ *(
738
+ ["ccx-evidence-lifecycle"]
739
+ if ev else []
740
+ ),
741
+ *(
742
+ ["codex-thread-token-usage-updated"]
743
+ if usage else []
744
+ ),
745
+ ],
746
+ })
747
+
748
+ if evidence_complete:
749
+ compactions = [
750
+ item for item in compactions
751
+ if item["compaction_id"] in touched_compactions
752
+ ]
753
+
754
+ warnings = list(evidence_index.get("warnings") or [])
755
+ if foreign_events:
756
+ warnings.append(
757
+ "excluded native telemetry events without the original Codex thread identity"
758
+ )
759
+ if gap:
760
+ warnings.append(
761
+ "requested telemetry cursor is outside the retained evidence range"
762
+ )
763
+ status = (
764
+ "complete"
765
+ if evidence_complete
766
+ else "partial"
767
+ )
768
+ store.revalidate()
769
+ return {
770
+ "worker": {
771
+ "engine": "codex",
772
+ "short": short,
773
+ "name": record.get("name"),
774
+ "thread_id": record.get("threadId"),
775
+ },
776
+ "turns": turns,
777
+ "compactions": compactions,
778
+ "coverage": {
779
+ "status": status,
780
+ "capture_started_at_ms": evidence_index.get("beganAtMs"),
781
+ "gap": gap,
782
+ "warnings": warnings,
783
+ "conversation": conversation.get("completeness"),
784
+ },
785
+ "next_cursor": _cursor(
786
+ cursor_owner, journal.journal_instance_id, processed_through
787
+ ),
788
+ "has_more": has_more,
789
+ }
790
+
791
+
792
+ __all__ = ["read_telemetry"]