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/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
- opening = codex_receipt_for_turn(handle, native_turn_id)
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
- # Human input on the shared managed Claude PTY starts a genuine parent
90
- # turn without a CCX delivery receipt. Do not fabricate one: the live
91
- # authenticated host's exact activeTurnRef is sufficient to request a
92
- # one-shot native Escape, under the same durable cancel intent. This
93
- # exception never applies to Codex or an absent/invented/stale turn.
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
- seen = reconcile_turn(handle, turn_id=native_turn_id,
131
- expected_generation=store.generation)
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)
@@ -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. Rollout\n files are never parsed, and the artifact is 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_json`` already writes;\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"
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
- text = (att.get("prompt") or "").strip()
837
- if not text:
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
- target = open_turn()
840
- place(target, "input", _item(
841
- "user-text", "queued_command", evt.get("uuid"), text=text,
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. The buffer also handles JSONL rows larger than a read block.
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
- suffix = b""
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) + suffix
668
- parts = data.split(b"\n")
669
- if position > 0:
670
- suffix = parts[0]
671
- parts = parts[1:]
672
- else:
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 start, raw
679
- end = start - 1
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},