ccx-native 0.1.0 → 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/README.md +4 -5
- package/ccx/__init__.py +4 -0
- package/ccx/claude/attach.py +103 -58
- package/ccx/claude/host/pty.py +20 -1
- package/ccx/claude/host/session.py +95 -35
- package/ccx/claude/transcript.py +321 -67
- package/ccx/claude/usage.py +5 -46
- package/ccx/claude/usage_control.py +93 -41
- package/ccx/cli/commands/control.py +1 -1
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +90 -6
- package/ccx/cli/main.py +3 -2
- package/ccx/cli/parser.py +122 -7
- package/ccx/client.py +8 -4
- package/ccx/codex/client.py +6 -0
- package/ccx/codex/daemon/daemon.py +3 -1
- package/ccx/codex/daemon/worker.py +41 -13
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/runtime.py +73 -31
- package/ccx/models.py +225 -0
- package/ccx/ops/cancel.py +18 -11
- package/ccx/ops/conversation.py +67 -14
- package/ccx/ops/conversation_paging.py +15 -14
- package/ccx/ops/creation_steps.py +0 -4
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/report.py +8 -5
- package/ccx/ops/turn_reports.py +47 -5
- package/ccx/ops/worker_events.py +5 -1
- package/ccx/store/paths.py +0 -4
- package/ccx/store/root.py +6 -21
- package/ccx/store/store.py +2 -2
- package/ccx/system/private_json.py +54 -0
- package/ccx/types.py +104 -0
- package/ccx/workers.py +70 -58
- package/package.json +2 -2
- package/pyproject.toml +2 -2
- package/skills/ccx/SKILL.md +12 -1
- package/skills/ccx/references/history.md +50 -0
package/ccx/ops/cancel.py
CHANGED
|
@@ -78,7 +78,13 @@ def cancel_turn(
|
|
|
78
78
|
short=worker_id[:8], worker_id=worker_id)
|
|
79
79
|
# In particular, an invented native id is never a cancel capability.
|
|
80
80
|
if engine == "codex":
|
|
81
|
-
|
|
81
|
+
from ccx.codex.delivery import DeliveryReceiptError
|
|
82
|
+
try:
|
|
83
|
+
opening = codex_receipt_for_turn(handle, native_turn_id)
|
|
84
|
+
except DeliveryReceiptError:
|
|
85
|
+
# A damaged receipt somewhere proves nothing about this turn;
|
|
86
|
+
# the live exact turn below still decides, as with no receipt.
|
|
87
|
+
opening = None
|
|
82
88
|
else:
|
|
83
89
|
from ccx.claude.delivery import iter_receipts
|
|
84
90
|
opening = next((row for row in iter_receipts(handle)
|
|
@@ -86,14 +92,11 @@ def cancel_turn(
|
|
|
86
92
|
and row.get("route") == "new_turn"), None)
|
|
87
93
|
if prior is None and (not isinstance(opening, dict)
|
|
88
94
|
or opening.get("state") != "accepted"):
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# one-shot native
|
|
93
|
-
#
|
|
94
|
-
if engine != "claude":
|
|
95
|
-
raise DeliveryRejected("exact active turn has no original accepted delivery receipt",
|
|
96
|
-
short=worker_id[:8])
|
|
95
|
+
# Some genuine turns start without a CCX delivery receipt: human input
|
|
96
|
+
# on the shared managed Claude PTY, or a parent notification on Codex.
|
|
97
|
+
# Do not fabricate one: the live authenticated runtime's exact active
|
|
98
|
+
# turn is sufficient to request a one-shot native interrupt, under the
|
|
99
|
+
# same durable cancel intent. An absent/invented/stale turn is refused.
|
|
97
100
|
known, active = _cancel_live_turn(handle, engine, worker_id)
|
|
98
101
|
store.revalidate()
|
|
99
102
|
if not known or active != turn_ref:
|
|
@@ -127,8 +130,12 @@ def cancel_turn(
|
|
|
127
130
|
return None
|
|
128
131
|
|
|
129
132
|
if engine == "codex":
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
from ccx.codex.delivery import DeliveryReceiptError
|
|
134
|
+
try:
|
|
135
|
+
seen = reconcile_turn(handle, turn_id=native_turn_id,
|
|
136
|
+
expected_generation=store.generation)
|
|
137
|
+
except DeliveryReceiptError:
|
|
138
|
+
seen = {} # no receipt evidence; the live turn below decides
|
|
132
139
|
if seen.get("status") == "settled":
|
|
133
140
|
return public("confirmed", outcome="completed", owned=True,
|
|
134
141
|
receipt=receipt)
|
package/ccx/ops/conversation.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"Normalized conversation conversion for both CCX engines.\n\nOne versioned document shape is produced from two very different native\nsources:\n\n* Claude — the raw JSONL transcript ccx already records for every managed\n worker. It is the authoritative source, so full mode reads it directly\n instead of the cleaned renderer (whose 4 KB per-tool-result cap stays exactly\n where it is, for ``logs()``/``peek``/dashboard previews).\n* Codex — the app-server ``thread/read(includeTurns=true)`` response.
|
|
1
|
+
"Normalized conversation conversion for both CCX engines.\n\nOne versioned document shape is produced from two very different native\nsources:\n\n* Claude — the raw JSONL transcript ccx already records for every managed\n worker. It is the authoritative source, so full mode reads it directly\n instead of the cleaned renderer (whose 4 KB per-tool-result cap stays exactly\n where it is, for ``logs()``/``peek``/dashboard previews).\n* Codex — the app-server ``thread/read(includeTurns=true)`` response, or the\n same thread rebuilt from Codex's local history database\n (``$CODEX_HOME/thread_history_1.sqlite``, read-only, schema-checked; see\n :mod:`ccx.codex.history_store`), which ``ccx history`` prefers and which\n falls back to app-server. Rollout files are never parsed, and the artifact is\n never called a raw transcript.\n\nThe document keys are camelCase because that is the shape the feature brief\nfroze and the shape ``transcript.render_transcript`` already returns;\n:class:`ccx.workers.WorkerClient` operation results stay snake_case. Only this\ndocument carries a ``schemaVersion``.\n\nEvery item carries an ``ordinal``: its position in the historical source this\nconverter was handed, per turn, dense and zero-based. The ``input``/``output``\nsplit is a classification, not an order — a Codex steer is user input that\nbelongs in the middle of a turn — so the ordinal is the only thing that\nreconstructs the source's own sequence.\n\nIt is not a claim about live chronology. Codex ``thread/read`` reconstructs a\nthread rather than replaying it: on 0.150.1 it has been observed to omit items\nthat certainly ran and to place user inputs differently from when they were\nactually delivered. The authoritative record of what CCX observed, in the order\nit observed it, is the evidence journal's ``sequence`` (:mod:`ccx.codex.evidence`).\nThe two orders are separate and must stay that way.\n\nMemory tradeoff (accepted for v1): both converters parse the whole native\nsource and materialize the whole normalized document, and\n:func:`write_export` serializes it in one ``json.dumps``. Peak memory is\nroughly 2-3x the raw native size, so a 50 MB transcript costs ~100-150 MB.\nTool results dominate large transcripts, so ``max_tool_result_bytes=N`` is the\nmitigation; the *native* copy is streamed to disk regardless, so full evidence\nsurvives even under a bound. Streaming conversion is deliberately deferred.\n"
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -533,14 +533,20 @@ _CLAUDE_BOOKKEEPING_TYPES = frozenset({
|
|
|
533
533
|
"last-prompt", "custom-title", "ai-title", "agent-name", "mode",
|
|
534
534
|
"permission-mode", "atis-latch", "file-history-snapshot",
|
|
535
535
|
"file-history-delta", "api-request", "api-request-shape",
|
|
536
|
-
"api-request-blob",
|
|
536
|
+
"api-request-blob", "session-owner", "queue-operation", "tools-used",
|
|
537
|
+
"cost-state",
|
|
537
538
|
})
|
|
538
539
|
_CLAUDE_BOOKKEEPING_ATTACHMENTS = frozenset({
|
|
539
540
|
"hook_success", "hook_non_blocking_error", "hook_permission_decision",
|
|
540
541
|
"hook_additional_context", "environment", "model", "deferred_tools_delta",
|
|
541
542
|
"agent_listing_delta", "mcp_instructions_delta", "skill_listing",
|
|
542
543
|
"total_tokens_reminder", "session_context", "date", "credential_org",
|
|
543
|
-
"remote_session_change", "prompt_snapshot",
|
|
544
|
+
"remote_session_change", "prompt_snapshot", "deferred_tools_record",
|
|
545
|
+
"silent_turn_reminder", "instructions", "nested_memory", "auto_mode",
|
|
546
|
+
"auto_mode_exit", "plan_mode", "plan_mode_exit", "edited_text_file",
|
|
547
|
+
"compact_file_reference", "thinking_drop", "invoked_skills",
|
|
548
|
+
"skill_mention", "command_permissions", "bash_output_audience_note",
|
|
549
|
+
"batching_reminder_sent", "session_cron_carry",
|
|
544
550
|
})
|
|
545
551
|
_CLAUDE_BOOKKEEPING_SYSTEM = frozenset({"stop_hook_summary", "turn_duration"})
|
|
546
552
|
|
|
@@ -613,12 +619,19 @@ def _normalize_claude_events(
|
|
|
613
619
|
events: list[dict],
|
|
614
620
|
counters: _Counters,
|
|
615
621
|
max_tool_result_bytes: int | None,
|
|
622
|
+
*,
|
|
623
|
+
fingerprints: bool = True,
|
|
616
624
|
) -> tuple[list[dict], str | None, list[dict]]:
|
|
617
|
-
"""Normalize Claude events once for full documents and bounded pages.
|
|
625
|
+
"""Normalize Claude events once for full documents and bounded pages.
|
|
626
|
+
|
|
627
|
+
Only paging cursors need each source record's ``_ccx_fingerprint``, and it
|
|
628
|
+
costs most of a large transcript's parse, so full documents skip it.
|
|
629
|
+
"""
|
|
618
630
|
turns: list[dict] = []
|
|
619
631
|
source_records: list[dict] = []
|
|
620
632
|
observed_model: str | None = None
|
|
621
633
|
current: dict | None = None
|
|
634
|
+
running: dict | None = None # current, until Claude records the turn's end
|
|
622
635
|
awaiting: dict[str, dict] = {}
|
|
623
636
|
usage_record_by_message: dict[str, dict] = {}
|
|
624
637
|
(
|
|
@@ -628,8 +641,8 @@ def _normalize_claude_events(
|
|
|
628
641
|
) = _claude_structural_links(events)
|
|
629
642
|
|
|
630
643
|
def open_turn() -> dict:
|
|
631
|
-
nonlocal current
|
|
632
|
-
current = _turn(len(turns) + 1)
|
|
644
|
+
nonlocal current, running
|
|
645
|
+
current = running = _turn(len(turns) + 1)
|
|
633
646
|
turns.append(current)
|
|
634
647
|
return current
|
|
635
648
|
|
|
@@ -669,11 +682,14 @@ def _normalize_claude_events(
|
|
|
669
682
|
ensure_ascii=False,
|
|
670
683
|
default=str,
|
|
671
684
|
).encode("utf-8")
|
|
672
|
-
).hexdigest(),
|
|
685
|
+
).hexdigest() if fingerprints else None,
|
|
673
686
|
}
|
|
674
687
|
source_records.append(record)
|
|
675
688
|
|
|
676
689
|
def place(target: dict, bucket: str, item: dict) -> dict:
|
|
690
|
+
if isinstance(ts, int): # the native row's time, named as Codex's
|
|
691
|
+
item["startedAtMs"] = ts
|
|
692
|
+
item["timingSource"] = "claude-transcript"
|
|
677
693
|
placed = _place(target, bucket, item)
|
|
678
694
|
if page_bearing:
|
|
679
695
|
record["items"].append(placed)
|
|
@@ -683,6 +699,8 @@ def _normalize_claude_events(
|
|
|
683
699
|
# Never page-bearing, so bounded pages are unaffected; the source
|
|
684
700
|
# record above still counts toward paging positions.
|
|
685
701
|
counters.bookkeeping_excluded += 1
|
|
702
|
+
if etype == "system" and evt.get("subtype") == "turn_duration":
|
|
703
|
+
running = None # Claude closed the current turn
|
|
686
704
|
continue
|
|
687
705
|
|
|
688
706
|
if etype == "user":
|
|
@@ -722,6 +740,9 @@ def _normalize_claude_events(
|
|
|
722
740
|
else:
|
|
723
741
|
place(opened, "input", _item(
|
|
724
742
|
"user-text", "user", evt.get("uuid"), text=text,
|
|
743
|
+
# Claude Code's own injected rows (command caveats,
|
|
744
|
+
# skill bodies, peer and scheduled prompts).
|
|
745
|
+
**({"meta": True} if evt.get("isMeta") else {}),
|
|
725
746
|
))
|
|
726
747
|
stamp(opened, ts)
|
|
727
748
|
elif btype == "tool_result":
|
|
@@ -734,6 +755,8 @@ def _normalize_claude_events(
|
|
|
734
755
|
if call is not None:
|
|
735
756
|
call["result"] = payload
|
|
736
757
|
call["status"] = "failed" if payload["isError"] else "completed"
|
|
758
|
+
if isinstance(ts, int):
|
|
759
|
+
call["completedAtMs"] = ts
|
|
737
760
|
continue
|
|
738
761
|
counters.warn(
|
|
739
762
|
f"tool_result {tid or '<no id>'} has no unpaired "
|
|
@@ -833,13 +856,42 @@ def _normalize_claude_events(
|
|
|
833
856
|
if etype == "attachment":
|
|
834
857
|
att = evt.get("attachment") or {}
|
|
835
858
|
if att.get("type") == "queued_command":
|
|
836
|
-
|
|
837
|
-
|
|
859
|
+
# A queued message is a string, or, when it carries an image
|
|
860
|
+
# or document, a content-block list like a user message's.
|
|
861
|
+
prompt = att.get("prompt")
|
|
862
|
+
media: list[dict] = []
|
|
863
|
+
others: list[dict] = []
|
|
864
|
+
if isinstance(prompt, list):
|
|
865
|
+
parts = []
|
|
866
|
+
for block in prompt:
|
|
867
|
+
if isinstance(block, str):
|
|
868
|
+
parts.append(block)
|
|
869
|
+
elif not isinstance(block, dict):
|
|
870
|
+
counters.omitted += 1
|
|
871
|
+
elif block.get("type") == "text":
|
|
872
|
+
parts.append(block.get("text") or "")
|
|
873
|
+
elif block.get("type") in ("image", "document"):
|
|
874
|
+
media.append(block)
|
|
875
|
+
else:
|
|
876
|
+
others.append(block)
|
|
877
|
+
text = "\n".join(p for p in parts if p.strip()).strip()
|
|
878
|
+
else:
|
|
879
|
+
text = prompt.strip() if isinstance(prompt, str) else ""
|
|
880
|
+
if not text and not media and not others:
|
|
838
881
|
continue
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
882
|
+
# Claude queues a steer or task notification into the running
|
|
883
|
+
# turn, so it stays there; it opens a turn only when none runs.
|
|
884
|
+
target = running or open_turn()
|
|
885
|
+
if text:
|
|
886
|
+
place(target, "input", _item(
|
|
887
|
+
"user-text", "queued_command", evt.get("uuid"), text=text,
|
|
888
|
+
))
|
|
889
|
+
for block in media:
|
|
890
|
+
place(target, "input", _claude_attachment(block))
|
|
891
|
+
for block in others: # kept, as in a user message
|
|
892
|
+
place(target, "output", _item(
|
|
893
|
+
"other", block.get("type"), None, native=safe_native(block),
|
|
894
|
+
))
|
|
843
895
|
stamp(target, ts)
|
|
844
896
|
else:
|
|
845
897
|
target = current or open_turn()
|
|
@@ -921,7 +973,7 @@ def build_claude(rec: dict, *, state: str | None, worker: str,
|
|
|
921
973
|
)
|
|
922
974
|
|
|
923
975
|
turns, observed_model, _records = _normalize_claude_events(
|
|
924
|
-
events, counters, max_tool_result_bytes,
|
|
976
|
+
events, counters, max_tool_result_bytes, fingerprints=False,
|
|
925
977
|
)
|
|
926
978
|
|
|
927
979
|
artifact = {
|
|
@@ -1203,6 +1255,7 @@ def _codex_item(entry: dict, counters: _Counters,
|
|
|
1203
1255
|
"tool-result", itype, item_id,
|
|
1204
1256
|
callId=entry.get("callId") or item_id,
|
|
1205
1257
|
name=entry.get("name"),
|
|
1258
|
+
namespace=entry.get("namespace"),
|
|
1206
1259
|
result=_result_payload(entry.get("output"), False, limit, counters),
|
|
1207
1260
|
)]
|
|
1208
1261
|
|
|
@@ -652,31 +652,32 @@ def _reverse_lines(
|
|
|
652
652
|
"""Yield (start offset, raw line) backwards from a certified source fence.
|
|
653
653
|
|
|
654
654
|
The pinned reader uses pread, not the mutable native pathname or shared fd
|
|
655
|
-
seek position.
|
|
655
|
+
seek position. A row longer than a read block is collected piece by piece
|
|
656
|
+
and joined once, so a multi-MB row costs its size, not its size squared.
|
|
656
657
|
"""
|
|
657
658
|
try:
|
|
658
659
|
reader = (path.open_reader() if isinstance(path, PinnedNativeSource)
|
|
659
660
|
else open(path, "rb"))
|
|
660
661
|
with reader as handle:
|
|
661
662
|
position = before_offset
|
|
662
|
-
|
|
663
|
+
pieces: list[bytes] = [] # the current row's tail, newest piece first
|
|
663
664
|
while position > 0:
|
|
664
665
|
size = min(block_size, position)
|
|
665
666
|
position -= size
|
|
666
667
|
handle.seek(position)
|
|
667
|
-
data = handle.read(size)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
suffix = b""
|
|
674
|
-
end = position + len(data)
|
|
675
|
-
for raw in reversed(parts):
|
|
676
|
-
start = end - len(raw)
|
|
668
|
+
data = handle.read(size)
|
|
669
|
+
cut = len(data)
|
|
670
|
+
while (newline := data.rfind(b"\n", 0, cut)) >= 0:
|
|
671
|
+
pieces.append(data[newline + 1:cut])
|
|
672
|
+
raw = b"".join(reversed(pieces))
|
|
673
|
+
pieces = []
|
|
677
674
|
if raw:
|
|
678
|
-
yield
|
|
679
|
-
|
|
675
|
+
yield position + newline + 1, raw
|
|
676
|
+
cut = newline
|
|
677
|
+
pieces.append(data[:cut])
|
|
678
|
+
raw = b"".join(reversed(pieces))
|
|
679
|
+
if raw:
|
|
680
|
+
yield 0, raw
|
|
680
681
|
except FileNotFoundError:
|
|
681
682
|
return
|
|
682
683
|
|
|
@@ -378,10 +378,6 @@ def prepare_worker(
|
|
|
378
378
|
if engine == "claude":
|
|
379
379
|
from ccx.store.paths import transcript_path_for
|
|
380
380
|
record["transcript"] = str(transcript_path_for(directory, session_id))
|
|
381
|
-
record["transcriptJson"] = str(
|
|
382
|
-
store.pointer.generation_root / "workers" /
|
|
383
|
-
"claude" / chosen / "transcript.json"
|
|
384
|
-
)
|
|
385
381
|
else:
|
|
386
382
|
record.update(
|
|
387
383
|
nativeArchive={"state": "active", "attempts": 0},
|