ccx-native 0.0.1 → 0.2.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 +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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
|
+
]
|