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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  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 +40 -4
  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,710 @@
1
+ """Read-side lifecycle projection over Claude and Codex journals.
2
+
3
+ Cursor placement uses epoch and sequence when its byte-offset hint no longer
4
+ matches the journal. Unplaceable cursors report a gap and replay retained events.
5
+ Provider message content remains in the conversation and telemetry surfaces."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+ import pathlib
13
+ from typing import Any
14
+
15
+ from ccx.codex.delivery import turn_ref_for as codex_turn_ref
16
+ from ccx.codex.evidence import (
17
+ HEADER_METHOD,
18
+ COMPACT_STARTED_METHOD,
19
+ COMPACT_ENDED_METHOD,
20
+ DELIVERY_CHANGED_METHOD,
21
+ INPUT_REQUESTED_METHOD,
22
+ INPUT_RESOLVED_METHOD,
23
+ LIFECYCLE_METHOD,
24
+ header_instance_id,
25
+ journal_path as codex_journal_path,
26
+ )
27
+ from ccx.errors import InvalidRequest
28
+ from ccx.store.paths import events_ndjson_path
29
+ from ccx.store.worker_state import normalize_native_state
30
+
31
+ CURSOR_PREFIX = "ev1."
32
+ POLL_S = 0.2
33
+ PREVIEW_LIMIT = 500
34
+ LIMIT_DEFAULT = 200
35
+ LIMIT_MAX = 1000
36
+ HEARTBEAT_DEFAULT_S = 30.0
37
+
38
+ KIND_STATE = "state"
39
+ KIND_TURN_STARTED = "turn.started"
40
+ KIND_TURN_ENDED = "turn.ended"
41
+ KIND_TOOL_STARTED = "tool.started"
42
+ KIND_TOOL_FINISHED = "tool.finished"
43
+ KIND_INPUT_REQUESTED = "input.requested"
44
+ KIND_INPUT_RESOLVED = "input.resolved"
45
+ KIND_COMPACT_STARTED = "compact.started"
46
+ KIND_COMPACT_ENDED = "compact.ended"
47
+ KIND_ERROR = "error"
48
+ KIND_DELIVERY_CHANGED = "delivery.changed"
49
+ KIND_BACKGROUND_CHANGED = "background.changed"
50
+ KIND_CONTEXT_CHANGED = "context.changed"
51
+ KIND_HEARTBEAT = "heartbeat"
52
+
53
+ KINDS = frozenset({
54
+ KIND_STATE,
55
+ KIND_TURN_STARTED,
56
+ KIND_TURN_ENDED,
57
+ KIND_TOOL_STARTED,
58
+ KIND_TOOL_FINISHED,
59
+ KIND_INPUT_REQUESTED,
60
+ KIND_INPUT_RESOLVED,
61
+ KIND_COMPACT_STARTED,
62
+ KIND_COMPACT_ENDED,
63
+ KIND_ERROR,
64
+ KIND_DELIVERY_CHANGED,
65
+ KIND_BACKGROUND_CHANGED,
66
+ KIND_CONTEXT_CHANGED,
67
+ })
68
+
69
+ ENGINES = ("claude", "codex")
70
+
71
+ # ---- Claude ------------------------------------------------------------------
72
+
73
+ _CLAUDE_KINDS = {
74
+ "state": KIND_STATE,
75
+ "turn_start": KIND_TURN_STARTED,
76
+ "turn_complete": KIND_TURN_ENDED,
77
+ "turn_settled": KIND_TURN_ENDED,
78
+ "background_changed": KIND_BACKGROUND_CHANGED,
79
+ "context_changed": KIND_CONTEXT_CHANGED,
80
+ "tool_started": KIND_TOOL_STARTED,
81
+ "tool_finished": KIND_TOOL_FINISHED,
82
+ "permission_requested": KIND_INPUT_REQUESTED,
83
+ "elicitation_requested": KIND_INPUT_REQUESTED,
84
+ "permission_approved": KIND_INPUT_RESOLVED,
85
+ "permission_rejected": KIND_INPUT_RESOLVED,
86
+ "permission_timeout": KIND_INPUT_RESOLVED,
87
+ "question_answered": KIND_INPUT_RESOLVED,
88
+ "question_answered_external": KIND_INPUT_RESOLVED,
89
+ "elicitation_resolved": KIND_INPUT_RESOLVED,
90
+ "compact_started": KIND_COMPACT_STARTED,
91
+ "compact_complete": KIND_COMPACT_ENDED,
92
+ "compact_failed": KIND_COMPACT_ENDED,
93
+ "compact_unknown": KIND_COMPACT_ENDED,
94
+ "api_error": KIND_ERROR,
95
+ "send_failed": KIND_ERROR,
96
+ "boot_stalled": KIND_ERROR,
97
+ }
98
+
99
+
100
+ def _claude_position(obj: dict[str, Any]) -> tuple[str | None, int | None]:
101
+ epoch, seq = obj.get("epoch"), obj.get("seq")
102
+ if not isinstance(epoch, str) or not epoch:
103
+ epoch = None
104
+ if not isinstance(seq, int) or isinstance(seq, bool):
105
+ seq = None
106
+ return epoch, seq
107
+
108
+
109
+ def _claude_project(obj: dict[str, Any]) -> dict[str, Any] | None:
110
+ event_type = obj.get("type")
111
+ if not isinstance(event_type, str):
112
+ return None
113
+ event = dict(obj)
114
+ kind = _CLAUDE_KINDS.get(event_type)
115
+ if event_type == "turn_complete" and "outcome" not in event:
116
+ event["outcome"] = "interrupted" if event.get("interrupted") else "completed"
117
+ if event_type in ("compact_complete", "compact_failed", "compact_unknown"):
118
+ event["outcome"] = {
119
+ "compact_complete": "completed",
120
+ "compact_failed": "failed",
121
+ "compact_unknown": "unknown",
122
+ }[event_type]
123
+ event["kind"] = kind
124
+ return event
125
+
126
+
127
+ # ---- Codex -------------------------------------------------------------------
128
+
129
+ # Codex items that are a tool call, with the name the feed reports for them.
130
+ # MCP calls use Claude's `mcp__<server>__<tool>` spelling so one consumer
131
+ # rule matches both engines.
132
+ _CODEX_TOOL_ITEMS = {
133
+ "commandExecution": "command",
134
+ "fileChange": "apply_patch",
135
+ "mcpToolCall": None,
136
+ "dynamicToolCall": None,
137
+ "collabAgentToolCall": None,
138
+ "webSearch": "web_search",
139
+ "imageView": "view_image",
140
+ "imageGeneration": "image_generation",
141
+ }
142
+
143
+
144
+ def _preview(value: Any, limit: int = PREVIEW_LIMIT) -> str:
145
+ """Bounded JSON-ish preview, the same rule as the Claude host's."""
146
+ if value is None:
147
+ return ""
148
+ if isinstance(value, str):
149
+ rendered = value
150
+ else:
151
+ try:
152
+ rendered = json.dumps(value, ensure_ascii=False, default=str)
153
+ except (TypeError, ValueError):
154
+ rendered = str(value)
155
+ if len(rendered) > limit:
156
+ return rendered[:limit] + "…"
157
+ return rendered
158
+
159
+
160
+ def _codex_tool_name(item: dict[str, Any]) -> str:
161
+ item_type = item.get("type")
162
+ if item_type == "mcpToolCall":
163
+ return f"mcp__{item.get('server')}__{item.get('tool')}"
164
+ name = _CODEX_TOOL_ITEMS.get(item_type)
165
+ return name or str(item.get("tool") or item_type)
166
+
167
+
168
+ def _codex_tool_input(item: dict[str, Any]) -> Any:
169
+ item_type = item.get("type")
170
+ if item_type == "commandExecution":
171
+ return item.get("command")
172
+ if item_type == "fileChange":
173
+ changes = item.get("changes")
174
+ if isinstance(changes, list):
175
+ return [
176
+ change.get("path") for change in changes
177
+ if isinstance(change, dict)
178
+ ]
179
+ return None
180
+ if item_type == "webSearch":
181
+ return item.get("query")
182
+ if item_type in {"imageView", "imageGeneration"}:
183
+ return item.get("path") or item.get("prompt")
184
+ return item.get("arguments")
185
+
186
+
187
+ def _codex_tool_result(item: dict[str, Any]) -> Any:
188
+ item_type = item.get("type")
189
+ if item_type == "commandExecution":
190
+ return item.get("aggregatedOutput")
191
+ if item.get("error") is not None:
192
+ return item.get("error")
193
+ return item.get("result") or item.get("contentItems")
194
+
195
+
196
+ def _codex_tool_ok(item: dict[str, Any]) -> bool:
197
+ if item.get("status") != "completed":
198
+ return False
199
+ exit_code = item.get("exitCode")
200
+ return exit_code is None or exit_code == 0
201
+
202
+
203
+ def _codex_position(obj: dict[str, Any]) -> int | None:
204
+ seq = obj.get("sequence")
205
+ if not isinstance(seq, int) or isinstance(seq, bool):
206
+ return None
207
+ return seq
208
+
209
+
210
+ def _codex_project(obj: dict[str, Any]) -> dict[str, Any] | None:
211
+ method = obj.get("method")
212
+ params = obj.get("params")
213
+ params = params if isinstance(params, dict) else {}
214
+ observed = obj.get("observedAtMs")
215
+ event: dict[str, Any] = {
216
+ "type": method,
217
+ "ts": observed / 1000.0 if isinstance(observed, int) else None,
218
+ }
219
+ turn_id = obj.get("turnId")
220
+ if method == LIFECYCLE_METHOD:
221
+ event.update(kind=KIND_STATE, **{
222
+ "from": params.get("from"), "to": params.get("to"),
223
+ })
224
+ if "reason" in params:
225
+ event["reason"] = params.get("reason")
226
+ return event
227
+ if method in {COMPACT_STARTED_METHOD, COMPACT_ENDED_METHOD}:
228
+ event.update(
229
+ kind=(KIND_COMPACT_STARTED if method == COMPACT_STARTED_METHOD
230
+ else KIND_COMPACT_ENDED),
231
+ turn_id=turn_id if isinstance(turn_id, str) else params.get("turnId"),
232
+ operation_id=params.get("operationId"),
233
+ item_id=params.get("itemId"),
234
+ compact_id=params.get("compactId"),
235
+ )
236
+ if event["turn_id"]:
237
+ event["turn_ref"] = codex_turn_ref(event["turn_id"])
238
+ if method == COMPACT_ENDED_METHOD:
239
+ value = params.get("outcome")
240
+ event["outcome"] = value if value in {"completed", "failed", "unknown"} else "unknown"
241
+ if params.get("reason"):
242
+ event["reason"] = _preview(params["reason"])
243
+ return event
244
+ if method == INPUT_REQUESTED_METHOD:
245
+ request = params.get("request")
246
+ request = request if isinstance(request, dict) else {}
247
+ turn_id = request.get("turnId")
248
+ # The never-repeating gate id is the protocol request id; the native
249
+ # app-server id restarts with the app-server.
250
+ event.update(
251
+ kind=KIND_INPUT_REQUESTED,
252
+ request_id=request.get("gateId") or request.get("requestId"),
253
+ native_request_id=request.get("requestId"),
254
+ request=request,
255
+ )
256
+ if isinstance(turn_id, str) and turn_id:
257
+ event["turn_id"] = turn_id
258
+ event["turn_ref"] = codex_turn_ref(turn_id)
259
+ return event
260
+ if method == "ccx/context/changed":
261
+ from ccx.ops.context_events import snapshot
262
+ event.update(kind=KIND_CONTEXT_CHANGED, context=snapshot(params.get("context")))
263
+ if isinstance(turn_id, str) and turn_id:
264
+ event["turn_ref"] = codex_turn_ref(turn_id)
265
+ return event
266
+ if method == "ccx/background/changed":
267
+ event.update(kind=KIND_BACKGROUND_CHANGED,
268
+ transition=params.get("transition"),
269
+ item=params.get("item"),
270
+ summary=params.get("summary"))
271
+ return event
272
+ if method == DELIVERY_CHANGED_METHOD:
273
+ # A trigger to reconcile this delivery id, never acceptance proof.
274
+ event.update(
275
+ kind=KIND_DELIVERY_CHANGED,
276
+ delivery_id=params.get("deliveryId"),
277
+ status=params.get("state"),
278
+ route=params.get("route"),
279
+ placement=params.get("placement"),
280
+ turn_ref=params.get("turnRef"),
281
+ )
282
+ return event
283
+ if method == INPUT_RESOLVED_METHOD:
284
+ event.update(
285
+ kind=KIND_INPUT_RESOLVED,
286
+ request_id=params.get("gateId") or params.get("requestId"),
287
+ native_request_id=params.get("requestId"),
288
+ request_kind=params.get("kind"),
289
+ decision=params.get("decision"),
290
+ )
291
+ return event
292
+ if method not in {"turn/started", "turn/completed", "item/started",
293
+ "item/completed", "error"}:
294
+ # The header, token usage and compaction-context enrichment are
295
+ # journal bookkeeping and telemetry, not lifecycle events.
296
+ return None
297
+ if isinstance(turn_id, str) and turn_id:
298
+ event["turn_id"] = turn_id
299
+ event["turn_ref"] = codex_turn_ref(turn_id)
300
+ if method == "turn/started":
301
+ event["kind"] = KIND_TURN_STARTED
302
+ return event
303
+ if method == "turn/completed":
304
+ turn = params.get("turn")
305
+ turn = turn if isinstance(turn, dict) else {}
306
+ error = turn.get("error")
307
+ event.update(
308
+ kind=KIND_TURN_ENDED,
309
+ outcome=turn.get("status"),
310
+ error=(
311
+ _preview(error.get("message") if isinstance(error, dict) else error)
312
+ if error else None
313
+ ),
314
+ )
315
+ return event
316
+ if method == "error":
317
+ error = params.get("error")
318
+ error = error if isinstance(error, dict) else {}
319
+ event.update(
320
+ kind=KIND_ERROR,
321
+ message=_preview(error.get("message")),
322
+ error_type=error.get("type"),
323
+ will_retry=params.get("willRetry"),
324
+ )
325
+ return event
326
+ item = params.get("item")
327
+ item = item if isinstance(item, dict) else {}
328
+ item_type = item.get("type")
329
+ started = method == "item/started"
330
+ event.update(
331
+ item_id=obj.get("itemId"),
332
+ item_type=item_type,
333
+ native_ts_ms=obj.get("nativeTimestampMs"),
334
+ )
335
+ if item_type == "contextCompaction":
336
+ # Native provider item notifications are forensic evidence only.
337
+ # codexd projects each *admitted* compact as two sparse CCX-owned
338
+ # feed records; a reader never infers a terminal from a foreign item.
339
+ return None
340
+ if item_type not in _CODEX_TOOL_ITEMS:
341
+ # Content items belong to the conversation, not the lifecycle feed.
342
+ return None
343
+ event.update(
344
+ kind=KIND_TOOL_STARTED if started else KIND_TOOL_FINISHED,
345
+ tool_use_id=item.get("id"),
346
+ tool=_codex_tool_name(item),
347
+ )
348
+ if started:
349
+ event["input_preview"] = _preview(_codex_tool_input(item))
350
+ else:
351
+ event.update(
352
+ ok=_codex_tool_ok(item),
353
+ status=item.get("status"),
354
+ duration_ms=item.get("durationMs"),
355
+ result_preview=_preview(_codex_tool_result(item)),
356
+ )
357
+ return event
358
+
359
+
360
+ # ---- the journal scan --------------------------------------------------------
361
+
362
+ class _Line:
363
+ """One parsed journal line and where it sits."""
364
+
365
+ __slots__ = ("epoch", "seq", "end", "obj")
366
+
367
+ def __init__(self, epoch: str | None, seq: int, end: int, obj: dict):
368
+ self.epoch = epoch
369
+ self.seq = seq
370
+ self.end = end # byte offset just past this line
371
+ self.obj = obj
372
+
373
+
374
+ def journal_path(engine: str, short: str) -> pathlib.Path:
375
+ if engine == "claude":
376
+ return events_ndjson_path(short)
377
+ return codex_journal_path(short)
378
+
379
+
380
+ def _file_id(path: pathlib.Path) -> tuple[int | None, int]:
381
+ try:
382
+ st = path.stat()
383
+ except OSError:
384
+ return None, 0
385
+ return st.st_ino, st.st_size
386
+
387
+
388
+ def _scan(
389
+ engine: str,
390
+ path: pathlib.Path,
391
+ *,
392
+ start: int = 0,
393
+ epoch: str | None = None,
394
+ ) -> list[_Line]:
395
+ """Parse complete lines from byte ``start``. A partial last line (a write
396
+ still in flight) is left for the next read; malformed lines are skipped.
397
+
398
+ For Codex, every line's epoch is the journal instance its header names;
399
+ ``epoch`` seeds it when the scan starts past the header.
400
+ """
401
+ out: list[_Line] = []
402
+ try:
403
+ handle = path.open("rb")
404
+ except OSError:
405
+ return out
406
+ with handle:
407
+ if start:
408
+ handle.seek(start)
409
+ offset = start
410
+ for raw in handle:
411
+ if not raw.endswith(b"\n"):
412
+ break
413
+ offset += len(raw)
414
+ line = raw.strip()
415
+ if not line:
416
+ continue
417
+ try:
418
+ obj = json.loads(line)
419
+ except ValueError:
420
+ continue
421
+ if not isinstance(obj, dict):
422
+ continue
423
+ if engine == "claude":
424
+ line_epoch, seq = _claude_position(obj)
425
+ else:
426
+ seq = _codex_position(obj)
427
+ if obj.get("method") == HEADER_METHOD:
428
+ if epoch is None:
429
+ epoch = header_instance_id(obj)
430
+ line_epoch = epoch
431
+ if seq is None:
432
+ continue
433
+ out.append(_Line(line_epoch, seq, offset, obj))
434
+ return out
435
+
436
+
437
+ def _line_before(engine: str, path: pathlib.Path, offset: int) -> dict | None:
438
+ """The complete line that ends exactly at ``offset``, parsed, or None."""
439
+ if offset <= 0:
440
+ return None
441
+ try:
442
+ with path.open("rb") as handle:
443
+ window = min(offset, 1 << 20)
444
+ handle.seek(offset - window)
445
+ chunk = handle.read(window)
446
+ except OSError:
447
+ return None
448
+ if not chunk.endswith(b"\n"):
449
+ return None
450
+ body = chunk[:-1]
451
+ cut = body.rfind(b"\n")
452
+ if cut < 0 and window < offset:
453
+ return None # the line is longer than the window: rescan instead
454
+ try:
455
+ obj = json.loads(body[cut + 1:])
456
+ except ValueError:
457
+ return None
458
+ return obj if isinstance(obj, dict) else None
459
+
460
+
461
+ # ---- cursors -----------------------------------------------------------------
462
+
463
+ def encode_cursor(
464
+ engine: str,
465
+ short: str,
466
+ epoch: str | None,
467
+ seq: int,
468
+ offset: int,
469
+ inode: int | None,
470
+ ) -> str:
471
+ payload = {"e": engine, "s": short, "p": epoch, "q": seq, "o": offset,
472
+ "i": inode}
473
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
474
+ return CURSOR_PREFIX + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
475
+
476
+
477
+ def decode_cursor(value: str, *, engine: str, short: str) -> dict[str, Any]:
478
+ """Parse a feed cursor for this worker, or raise ``InvalidRequest``."""
479
+ if not isinstance(value, str) or not value.startswith(CURSOR_PREFIX):
480
+ raise InvalidRequest("event cursor is invalid")
481
+ body = value[len(CURSOR_PREFIX):]
482
+ try:
483
+ data = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
484
+ except Exception as error:
485
+ raise InvalidRequest("event cursor is invalid") from error
486
+ if not isinstance(data, dict):
487
+ raise InvalidRequest("event cursor is invalid")
488
+ seq, offset = data.get("q"), data.get("o")
489
+ if (
490
+ not isinstance(seq, int) or isinstance(seq, bool) or seq < 0
491
+ or not isinstance(offset, int) or isinstance(offset, bool) or offset < 0
492
+ or not (data.get("p") is None or isinstance(data.get("p"), str))
493
+ or not (data.get("i") is None or isinstance(data.get("i"), int))
494
+ ):
495
+ raise InvalidRequest("event cursor is invalid")
496
+ if data.get("e") != engine or data.get("s") != short:
497
+ raise InvalidRequest(
498
+ f"event cursor belongs to {data.get('e')}:{data.get('s')}, "
499
+ f"not {engine}:{short}"
500
+ )
501
+ return data
502
+
503
+
504
+ def _same_position(engine: str, obj: dict, cursor: dict) -> bool:
505
+ if engine == "claude":
506
+ return _claude_position(obj) == (cursor.get("p"), cursor.get("q"))
507
+ return _codex_position(obj) == cursor.get("q")
508
+
509
+
510
+ def _place(
511
+ engine: str, lines: list[_Line], cursor: dict
512
+ ) -> tuple[list[_Line], dict[str, Any] | None]:
513
+ """The lines after ``cursor`` in a full scan, plus a gap when the cursor
514
+ cannot be placed and everything retained is replayed instead."""
515
+ epoch, seq = cursor.get("p"), cursor.get("q")
516
+ if epoch is None and seq == 0:
517
+ # Taken before the journal held anything: everything is new.
518
+ return lines, None
519
+ if engine == "codex":
520
+ current = lines[0].epoch if lines else None
521
+ if lines and epoch != current:
522
+ return lines, {"reason": "cursor_journal_mismatch",
523
+ "requested_epoch": epoch, "current_epoch": current}
524
+ last = lines[-1].seq if lines else 0
525
+ if seq > last:
526
+ return lines, {"reason": "cursor_ahead_of_retained_tail",
527
+ "requested_seq": seq, "last_retained_seq": last}
528
+ if lines and seq < lines[0].seq - 1:
529
+ return lines, {"reason": "cursor_predates_retained_events",
530
+ "requested_seq": seq,
531
+ "first_retained_seq": lines[0].seq}
532
+ return [line for line in lines if line.seq > seq], None
533
+ # Claude: seq restarts with every host epoch, and the sidecar spans many.
534
+ block = [index for index, line in enumerate(lines) if line.epoch == epoch]
535
+ if not block:
536
+ return lines, {"reason": "cursor_not_retained",
537
+ "requested_epoch": epoch, "requested_seq": seq}
538
+ first, last = block[0], block[-1]
539
+ if seq < lines[first].seq - 1:
540
+ return lines[first:], {"reason": "cursor_predates_retained_events",
541
+ "requested_epoch": epoch, "requested_seq": seq,
542
+ "first_retained_seq": lines[first].seq}
543
+ out = [line for line in lines[first:last + 1] if line.seq > seq]
544
+ return out + lines[last + 1:], None
545
+
546
+
547
+ def _after(
548
+ engine: str, path: pathlib.Path, cursor: dict | None
549
+ ) -> tuple[list[_Line], dict[str, Any] | None, tuple[str | None, int, int]]:
550
+ """Lines after ``cursor``, a gap if any, and the end-of-file position
551
+ ``(epoch, seq, offset)`` a caller resumes from when nothing new lands."""
552
+ inode, size = _file_id(path)
553
+ if cursor is not None:
554
+ offset = cursor["o"]
555
+ if (
556
+ cursor.get("i") is not None and cursor.get("i") == inode
557
+ and offset <= size
558
+ and (offset == 0 and cursor["q"] == 0
559
+ or _same_position(engine, _line_before(engine, path, offset) or {}, cursor))
560
+ ):
561
+ lines = _scan(engine, path, start=offset, epoch=cursor.get("p"))
562
+ end = (lines[-1].epoch, lines[-1].seq, lines[-1].end) if lines else (
563
+ cursor.get("p"), cursor["q"], offset)
564
+ return lines, None, end
565
+ lines = _scan(engine, path)
566
+ end = (lines[-1].epoch, lines[-1].seq, lines[-1].end) if lines else (None, 0, 0)
567
+ if cursor is None:
568
+ return lines, None, end
569
+ placed, gap = _place(engine, lines, cursor)
570
+ return placed, gap, end
571
+
572
+
573
+ # ---- reading -----------------------------------------------------------------
574
+
575
+ def _project(engine: str, line: _Line) -> dict[str, Any] | None:
576
+ if engine == "claude":
577
+ return _claude_project(line.obj)
578
+ return _codex_project(line.obj)
579
+
580
+
581
+ def _envelope(
582
+ event: dict[str, Any], engine: str, short: str, line: _Line,
583
+ inode: int | None,
584
+ ) -> dict[str, Any]:
585
+ event["worker"] = f"{engine}:{short}"
586
+ event["engine"] = engine
587
+ event["seq"] = line.seq
588
+ event["epoch"] = line.epoch
589
+ event["cursor"] = encode_cursor(engine, short, line.epoch, line.seq,
590
+ line.end, inode)
591
+ if engine == "codex":
592
+ event["short"] = short
593
+ return event
594
+
595
+
596
+ def _check_kinds(kinds) -> frozenset[str] | None:
597
+ if kinds is None:
598
+ return None
599
+ if isinstance(kinds, str) or not all(isinstance(kind, str) for kind in kinds):
600
+ raise InvalidRequest("kinds must be a list of event kinds")
601
+ unknown = sorted(set(kinds) - KINDS)
602
+ if unknown:
603
+ raise InvalidRequest(
604
+ "unknown event kind(s): " + ", ".join(unknown)
605
+ + "; expected " + ", ".join(sorted(KINDS))
606
+ )
607
+ return frozenset(kinds)
608
+
609
+
610
+ def _check_limit(limit: int) -> int:
611
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
612
+ raise InvalidRequest("limit must be a positive integer")
613
+ return min(limit, LIMIT_MAX)
614
+
615
+
616
+ def _check_tail(tail, after) -> int | None:
617
+ if tail is None:
618
+ return None
619
+ if after is not None:
620
+ raise InvalidRequest("tail cannot be combined with a cursor")
621
+ if not isinstance(tail, int) or isinstance(tail, bool) or tail < 0:
622
+ raise InvalidRequest("tail must be zero or a positive integer")
623
+ return tail
624
+
625
+
626
+ def state_of(record: dict[str, Any] | None) -> dict[str, Any]:
627
+ """The worker's recorded state, normalized, for a page or heartbeat."""
628
+ native = ((record or {}).get("lifecycle") or {}).get("state")
629
+ return {"state": normalize_native_state(native), "native_state": native}
630
+
631
+
632
+ def read_page(
633
+ engine: str,
634
+ short: str,
635
+ *,
636
+ after: str | None = None,
637
+ tail: int | None = None,
638
+ limit: int = LIMIT_DEFAULT,
639
+ kinds=None,
640
+ native: bool = False,
641
+ ) -> dict[str, Any]:
642
+ """One page of events after ``after`` (oldest first), or the newest
643
+ ``tail`` events when there is no cursor; ``tail=0`` is "from now".
644
+
645
+ ``next_cursor`` always names where to resume: the last returned event,
646
+ or the end of the journal when filtered or non-lifecycle lines followed
647
+ it. ``has_more`` means a full page was cut short at ``limit``.
648
+
649
+ ``native`` returns the journal lines themselves, unprojected and
650
+ unfiltered — for the engine-level ``CcxClient`` surface, whose ``Event``
651
+ is the stamped host object verbatim.
652
+ """
653
+ if engine not in ENGINES:
654
+ raise InvalidRequest(f"unknown engine {engine!r}")
655
+ limit = _check_limit(limit)
656
+ tail = _check_tail(tail, after)
657
+ wanted = _check_kinds(kinds)
658
+ cursor = decode_cursor(after, engine=engine, short=short) if after else None
659
+ path = journal_path(engine, short)
660
+ inode, _size = _file_id(path)
661
+ lines, gap, end = _after(engine, path, cursor)
662
+
663
+ selected: list[tuple[_Line, dict[str, Any]]] = []
664
+ for line in lines:
665
+ if native:
666
+ selected.append((line, line.obj))
667
+ continue
668
+ event = _project(engine, line)
669
+ if event is None or (wanted is not None and event.get("kind") not in wanted):
670
+ continue
671
+ selected.append((line, event))
672
+ if tail is not None:
673
+ selected = selected[-tail:] if tail else []
674
+ has_more = False
675
+ else:
676
+ has_more = len(selected) > limit
677
+ selected = selected[:limit]
678
+ events = [
679
+ event if native else _envelope(event, engine, short, line, inode)
680
+ for line, event in selected
681
+ ]
682
+ if has_more:
683
+ line = selected[-1][0]
684
+ next_cursor = encode_cursor(engine, short, line.epoch, line.seq,
685
+ line.end, inode)
686
+ else:
687
+ next_cursor = encode_cursor(engine, short, end[0], end[1], end[2], inode)
688
+ return {
689
+ "worker": f"{engine}:{short}",
690
+ "engine": engine,
691
+ "events": events,
692
+ "next_cursor": next_cursor,
693
+ "has_more": has_more,
694
+ "gap": gap,
695
+ }
696
+
697
+
698
+ __all__ = [
699
+ "CURSOR_PREFIX",
700
+ "HEARTBEAT_DEFAULT_S",
701
+ "KINDS",
702
+ "KIND_HEARTBEAT",
703
+ "LIMIT_DEFAULT",
704
+ "LIMIT_MAX",
705
+ "decode_cursor",
706
+ "encode_cursor",
707
+ "journal_path",
708
+ "read_page",
709
+ "state_of",
710
+ ]