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/report.py CHANGED
@@ -417,12 +417,14 @@ def report_worker(
417
417
  if engine == "codex" else claude_delivery.iter_receipts(handle))
418
418
  from ccx.ops import turn_reports
419
419
  native_turn = turn_reports._ref_id(turn_ref, engine=engine)
420
+ # A Codex steer only joins a turn it did not open and never gets that
421
+ # turn's settlement, so it is no claim on the turn's report: without an
422
+ # opening receipt, the turn is reported from its exact native history.
420
423
  has_receipt = any(
421
- row.get("turnRef") == turn_ref
422
- or (engine == "claude" and row.get("targetTurnRef") == turn_ref)
423
- or (engine == "codex" and native_turn in (
424
- row.get("turnId"), row.get("targetTurnId")
425
- ))
424
+ (row.get("turnRef") == turn_ref
425
+ or (engine == "claude" and row.get("targetTurnRef") == turn_ref)
426
+ or (engine == "codex" and row.get("turnId") == native_turn))
427
+ and not (engine == "codex" and codex_delivery.route(row) == "steer")
426
428
  for row in receipts
427
429
  )
428
430
  if not has_receipt:
@@ -466,6 +468,7 @@ def report_worker(
466
468
  for candidate in receipts:
467
469
  if (candidate.get("state") == "accepted"
468
470
  and not candidate.get("settlement")
471
+ and codex_delivery.route(candidate) != "steer"
469
472
  and candidate.get("turnRef") == turn_ref):
470
473
  reconcile_turn(
471
474
  handle, delivery_id=candidate["deliveryId"],
@@ -167,6 +167,34 @@ def codex_native_turn_evidence(
167
167
  return {"status": terminals[0]["status"], "error": terminals[0].get("error")}
168
168
 
169
169
 
170
+ def _codex_journal_contradicts(
171
+ child: str, record: dict, thread_id: str, turn_id: str,
172
+ ) -> bool:
173
+ """Does the local journal say anything against R being a completed turn?
174
+
175
+ Missing evidence (a gap elsewhere in the journal, or none for R) is not a
176
+ contradiction; another thread, a compaction or a non-completed terminal is.
177
+ """
178
+ from ccx.codex.evidence import build_index, read_journal
179
+
180
+ if ((record.get("activeCompact") or {}).get("turnId") == turn_id
181
+ or (record.get("lastCompact") or {}).get("turnId") == turn_id):
182
+ return True
183
+ journal = read_journal(child)
184
+ index = build_index(journal, health=record.get("evidence"))
185
+ if any(item.get("itemType") == "contextCompaction"
186
+ for item in index["items"].get(turn_id, {}).values()):
187
+ return True
188
+ events = [event for event in journal.events if event.get("turnId") == turn_id]
189
+ if any(event.get("threadId") != thread_id for event in events):
190
+ return True
191
+ return any(
192
+ not isinstance(turn, dict) or turn.get("id") != turn_id
193
+ or turn.get("status") != "completed"
194
+ for turn in ((event.get("params") or {}).get("turn") for event in events
195
+ if event.get("method") == "turn/completed"))
196
+
197
+
170
198
  def codex_source_report(
171
199
  thread: dict, worker_id: str, thread_id: str, turn_id: str,
172
200
  *, expected_source: str,
@@ -177,7 +205,9 @@ def codex_source_report(
177
205
  raise SessionUnavailable("Codex conversation belongs to another worker; check the worker")
178
206
  turns = [turn for turn in (thread.get("turns") or [])
179
207
  if isinstance(turn, dict) and turn.get("id") == turn_id]
180
- if len(turns) != 1 or turns[0].get("status") != "completed":
208
+ if (len(turns) != 1 or turns[0].get("status") != "completed"
209
+ or any(isinstance(item, dict) and item.get("type") == "contextCompaction"
210
+ for item in turns[0].get("items") or [])):
181
211
  raise SessionUnavailable("Completed Codex turn is unavailable; inspect worker status")
182
212
  selected = report_from_turn(turns[0])
183
213
  return {
@@ -287,10 +317,22 @@ def exact_report(
287
317
  result["completed_at"] = terminal.get("settledAtMs")
288
318
  elif owner.engine == "codex":
289
319
  proof = codex_native_turn_evidence(child, record, original, turn_id)
290
- if proof is None:
291
- raise SessionUnavailable("Codex completion record is missing; check status")
292
- if proof["status"] is None:
293
- raise OperationConflict("Codex turn is not complete; wait before reporting")
320
+ if proof is None or proof["status"] is None:
321
+ # The local journal cannot prove R: a gap elsewhere in it, or R's
322
+ # completion was missed. Unless it contradicts R, the exact
323
+ # source-verified native history decides: codex_source_report
324
+ # needs R itself completed there, and says so when it is not.
325
+ started = proof is not None
326
+ native_done = isinstance(thread, dict) and any(
327
+ isinstance(turn, dict) and turn.get("id") == turn_id
328
+ and turn.get("status") == "completed"
329
+ for turn in thread.get("turns") or [])
330
+ if (thread is None or (started and not native_done)
331
+ or _codex_journal_contradicts(child, record, original, turn_id)):
332
+ if started:
333
+ raise OperationConflict("Codex turn is not complete; wait before reporting")
334
+ raise SessionUnavailable("Codex completion record is missing; check status")
335
+ proof = {"status": "completed"}
294
336
  if proof["status"] != "completed":
295
337
  raise SessionUnavailable("Codex turn ended without completion; inspect worker status")
296
338
  if thread is None:
@@ -491,7 +491,7 @@ def _recorded_state(store: GenerationStore, target: str) -> dict:
491
491
  def wait_full_uuid_event_page(
492
492
  store: GenerationStore, target: str, *, after: str | None = None,
493
493
  tail: int | None = None, limit: int = 200, kinds: list[str] | None = None,
494
- wait: float = 0.0, native: bool = False,
494
+ wait: float = 0.0, native: bool = False, seen_live: bool = False,
495
495
  ) -> dict:
496
496
  """One same-generation held page; stat the owned journal before re-hashing.
497
497
 
@@ -510,6 +510,9 @@ def wait_full_uuid_event_page(
510
510
  native=native,
511
511
  )
512
512
  observer = RuntimeObserver(store, target)
513
+ # A follow that last reported live: a runtime gone before this read's
514
+ # first sample is still a change, reported at once.
515
+ observer.seen_live = seen_live
513
516
  observer.sample()
514
517
 
515
518
  def done(current: dict, gap, timed_out: bool) -> dict:
@@ -605,6 +608,7 @@ def follow_full_uuid_events(
605
608
  page = wait_full_uuid_event_page(
606
609
  store, target, after=cursor, kinds=kinds,
607
610
  limit=MAX_PAGE_EVENTS, wait=duration, native=native,
611
+ seen_live=runtime == "live",
608
612
  )
609
613
  # Presence comes from the held read only; an unheld page is one sample.
610
614
  changed, runtime = page["runtime"] != runtime, page["runtime"]
@@ -142,10 +142,6 @@ def record_path(short: str) -> pathlib.Path:
142
142
  return _owner_path(short, "record.json")
143
143
 
144
144
 
145
- def transcript_json_path(short: str) -> pathlib.Path:
146
- return _owner_path(short, "transcript.json")
147
-
148
-
149
145
  def statusline_json_path(short: str) -> pathlib.Path:
150
146
  return _owner_path(short, "statusline.json")
151
147
 
package/ccx/store/root.py CHANGED
@@ -298,32 +298,17 @@ def _read_json(fd: int, name: str, *, max_bytes: int = _FILE_LIMIT) -> dict | No
298
298
  return val
299
299
 
300
300
 
301
- def _write_json(
302
- fd: int, name: str, value: Mapping, *, rendered_transcript: bool = False,
303
- ) -> None:
301
+ def _write_json(fd: int, name: str, value: Mapping) -> None:
304
302
  """Fsync new 0600 inode, same-directory atomic replace and directory fsync.
305
303
 
306
- A cleaned Claude transcript is the one legacy JSON exception: its public
307
- schema retains indented turns and may exceed the generic 4 MiB metadata
308
- limit. Keep this explicit filename-scoped producer mode; all other store
309
- writes retain their original byte format and strict metadata bound. The
310
- runtime sidecar reader remains independently bounded to 4 MiB.
304
+ Every owned JSON file is compact and bounded to 4 MiB.
311
305
  """
312
306
  if not name or "/" in name or name in (".", ".."):
313
307
  raise StorageIntegrityError("unsafe owned output basename")
314
- if rendered_transcript:
315
- if name != "transcript.json":
316
- raise StorageIntegrityError("pretty unlimited JSON only allowed for owned transcript")
317
- # The previous renderer used json.dumps(..., indent=2)'s default
318
- # ensure_ascii=True. Preserve its surrogate-safe escaping and Unicode
319
- # roundtrip; only this rendered-transcript branch changes, not the
320
- # generic compact metadata writer below.
321
- raw = json.dumps(value, indent=2, ensure_ascii=True, allow_nan=False).encode("utf-8")
322
- else:
323
- raw = (json.dumps(value, sort_keys=True, separators=(",", ":"),
324
- ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8")
325
- if len(raw) > _FILE_LIMIT:
326
- raise StorageIntegrityError("owned JSON exceeds bounded writer size")
308
+ raw = (json.dumps(value, sort_keys=True, separators=(",", ":"),
309
+ ensure_ascii=False, allow_nan=False) + "\n").encode("utf-8")
310
+ if len(raw) > _FILE_LIMIT:
311
+ raise StorageIntegrityError("owned JSON exceeds bounded writer size")
327
312
  name_tmp = ".new-" + uuid4().hex
328
313
  output_fd = os.open(
329
314
  name_tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
@@ -663,7 +663,7 @@ class GenerationStore:
663
663
  Host/client readers use this rather than opening an unpinned
664
664
  pathname returned by worker_path().
665
665
  """
666
- if filename not in ("record.json", "settings.json", "transcript.json",
666
+ if filename not in ("record.json", "settings.json",
667
667
  "statusline.json", "compact.json",
668
668
  "context-observation.json"):
669
669
  raise StorageIntegrityError("unsupported CCX-owned JSON worker sidecar")
@@ -673,7 +673,7 @@ class GenerationStore:
673
673
  return _read_json(dir_fd, filename)
674
674
 
675
675
  def worker_path(self, target: str, filename: str = "record.json") -> Path:
676
- if filename not in ("record.json", "settings.json", "transcript.json",
676
+ if filename not in ("record.json", "settings.json",
677
677
  "statusline.json", "events.ndjson",
678
678
  "evidence.jsonl", "compact.json", "context-observation.json"):
679
679
  raise StorageIntegrityError("unknown worker sidecar family")
@@ -0,0 +1,54 @@
1
+ """Private JSON files shared by account-level caches and settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import stat
10
+ import tempfile
11
+
12
+
13
+ def _open_private_file(path: Path) -> int:
14
+ """Do not trust a symlink, another owner's file, or a readable private record."""
15
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
16
+ st = os.fstat(fd)
17
+ if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
18
+ os.close(fd)
19
+ raise PermissionError(f"not a private regular file owned by this user: {path}")
20
+ return fd
21
+
22
+
23
+ def _read_private_json(path: Path) -> dict | None:
24
+ with os.fdopen(_open_private_file(path), "rb") as handle:
25
+ value = json.load(handle)
26
+ return value if isinstance(value, dict) else None
27
+
28
+
29
+ def _atomic_private_json(path: Path, value: dict) -> None:
30
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
31
+ tmp = None
32
+ try:
33
+ fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
34
+ with os.fdopen(fd, "w", encoding="utf-8") as out:
35
+ json.dump(value, out, separators=(",", ":"), allow_nan=False)
36
+ out.flush()
37
+ os.fsync(out.fileno())
38
+ # Never overwrite a planted symlink/foreign file at the final path.
39
+ try:
40
+ existing = _open_private_file(path)
41
+ except FileNotFoundError:
42
+ pass
43
+ except OSError as exc:
44
+ # Replacing a planted symlink itself is safe: os.replace never
45
+ # follows the destination. Its target is neither read nor written.
46
+ if exc.errno != errno.ELOOP:
47
+ raise
48
+ else:
49
+ os.close(existing)
50
+ os.replace(tmp, path)
51
+ tmp = None
52
+ finally:
53
+ if tmp is not None:
54
+ os.unlink(tmp)
package/ccx/types.py CHANGED
@@ -634,6 +634,108 @@ class Event:
634
634
  )
635
635
 
636
636
 
637
+ @dataclass(frozen=True)
638
+ class ModelInfo:
639
+ """One selectable model name and its engine-neutral capabilities."""
640
+
641
+ id: str
642
+ resolved: str | None
643
+ display_name: str
644
+ description: str | None
645
+ alias: bool
646
+ default: bool
647
+ efforts: list[str] = field(default_factory=list)
648
+ default_effort: str | None = None
649
+ features: dict[str, bool] = field(default_factory=dict)
650
+
651
+ def as_dict(self) -> dict[str, Any]:
652
+ return {
653
+ "id": self.id,
654
+ "resolved": self.resolved,
655
+ "displayName": self.display_name,
656
+ "description": self.description,
657
+ "alias": self.alias,
658
+ "default": self.default,
659
+ "efforts": list(self.efforts),
660
+ "defaultEffort": self.default_effort,
661
+ "features": dict(self.features),
662
+ }
663
+
664
+ @classmethod
665
+ def from_dict(cls, value: dict) -> "ModelInfo":
666
+ ident = value.get("id")
667
+ resolved = value.get("resolved")
668
+ name = value.get("displayName")
669
+ if (not isinstance(ident, str) or not ident
670
+ or (resolved is not None and not isinstance(resolved, str))
671
+ or not isinstance(name, str) or not name):
672
+ raise ValueError("invalid cached model")
673
+ efforts = value.get("efforts")
674
+ features = value.get("features")
675
+ return cls(
676
+ id=ident,
677
+ resolved=resolved,
678
+ display_name=name,
679
+ description=(value.get("description")
680
+ if isinstance(value.get("description"), str) else None),
681
+ alias=(ident != resolved),
682
+ default=value.get("default") is True,
683
+ efforts=([s for s in efforts if isinstance(s, str)]
684
+ if isinstance(efforts, list) else []),
685
+ default_effort=(value.get("defaultEffort")
686
+ if isinstance(value.get("defaultEffort"), str) else None),
687
+ features=({
688
+ key: features[key] for key in ("fast", "auto", "thinkingOff")
689
+ if type(features.get(key)) is bool
690
+ } if isinstance(features, dict) else {}),
691
+ )
692
+
693
+
694
+ @dataclass(frozen=True)
695
+ class ModelCatalog:
696
+ """One engine's model list, with freshness and failure provenance."""
697
+
698
+ engine: str
699
+ engine_version: str | None
700
+ source: str
701
+ fetched_at: str
702
+ note: str | None
703
+ models: list[ModelInfo] = field(default_factory=list)
704
+
705
+ def as_dict(self) -> dict[str, Any]:
706
+ return {
707
+ "engine": self.engine,
708
+ "engineVersion": self.engine_version,
709
+ "source": self.source,
710
+ "fetchedAt": self.fetched_at,
711
+ "note": self.note,
712
+ "models": [model.as_dict() for model in self.models],
713
+ }
714
+
715
+ @classmethod
716
+ def from_dict(cls, value: dict) -> "ModelCatalog":
717
+ engine = value.get("engine")
718
+ fetched_at = value.get("fetchedAt")
719
+ models = value.get("models")
720
+ if (engine not in ("claude", "codex")
721
+ or not isinstance(fetched_at, str)
722
+ or not isinstance(models, list)):
723
+ raise ValueError("invalid cached model catalog")
724
+ entries = [ModelInfo.from_dict(item) for item in models
725
+ if isinstance(item, dict)]
726
+ if not entries:
727
+ raise ValueError("empty cached model catalog")
728
+ version = value.get("engineVersion")
729
+ return cls(
730
+ engine=engine,
731
+ engine_version=version if isinstance(version, str) else None,
732
+ source="live",
733
+ fetched_at=fetched_at,
734
+ note=None,
735
+ models=entries,
736
+ )
737
+
738
+
637
739
  @dataclass(frozen=True)
638
740
  class Usage:
639
741
  """Live quota utilization plus provider-normalized session-reset status.
@@ -700,6 +802,8 @@ __all__ = [
700
802
  "SteerResult",
701
803
  "WaitResult",
702
804
  "Event",
805
+ "ModelInfo",
806
+ "ModelCatalog",
703
807
  "Usage",
704
808
  "FiveHourReset",
705
809
  ]
package/ccx/workers.py CHANGED
@@ -6,7 +6,6 @@ CCX worker UUIDs independently of provider-native identifiers."""
6
6
  from __future__ import annotations
7
7
  import ccx.cli.commands.read as commands_read
8
8
 
9
- import json
10
9
  import os
11
10
  import signal
12
11
  import time
@@ -44,7 +43,7 @@ from .errors import (
44
43
  worker_not_found,
45
44
  )
46
45
  from ccx.store.liveness import pid_alive, recorded_host_loaded
47
- from ccx.store.paths import sock_path, statusline_json_path
46
+ from ccx.store.paths import sock_path
48
47
  from ccx.store.records import merge_lifecycle
49
48
  from ccx.rpc.rpc import _respawn, _respawn_failure_message, _rpc
50
49
  from ccx.ops.semantics import KIND_ELICITATION, pending_input_kind
@@ -178,6 +177,43 @@ def _codex_record(short: str) -> dict:
178
177
 
179
178
 
180
179
 
180
+ def _mark_recent_window(document: dict, limit: int, warning: str) -> None:
181
+ """Label a document that holds only the newest part of a history.
182
+
183
+ A bounded tail cannot claim global 1-based turn numbers, nor pretend to
184
+ describe the complete native history.
185
+ """
186
+ for turn in document["turns"]:
187
+ turn["sequence"] = None
188
+ complete = document["completeness"]
189
+ complete["sourceLimited"] = True
190
+ if complete.get("status") != "partial": # partial outranks source-limited
191
+ complete["status"] = "source-limited"
192
+ complete["warnings"] = [*(complete.get("warnings") or []), warning]
193
+ document["peekHistoryWindow"] = {"kind": "recent", "limit": limit}
194
+
195
+
196
+ def _note_history_db(document: dict, thread: dict) -> None:
197
+ """Say what a document read from Codex's local history database can't vouch for."""
198
+ from ccx.codex.history_store import VERIFIED_SCHEMA_VERSION
199
+
200
+ complete = document["completeness"]
201
+ warnings = list(complete.get("warnings") or [])
202
+ version = thread.get("historyDbVersion")
203
+ if version != VERIFIED_SCHEMA_VERSION:
204
+ warnings.append(
205
+ f"read from Codex's local history database, schema version {version} "
206
+ f"(checked against {VERIFIED_SCHEMA_VERSION})")
207
+ if any((t or {}).get("status") == "inProgress" for t in thread.get("turns") or []):
208
+ # The database is a projection of the rollout and can lag a running turn.
209
+ complete["sourceLimited"] = True
210
+ if complete.get("status") != "partial":
211
+ complete["status"] = "source-limited"
212
+ warnings.append("a turn is still running; Codex's local history database "
213
+ "may not have its latest items yet")
214
+ complete["warnings"] = warnings
215
+
216
+
181
217
  def _background_task_block_for_worker(store, owner) -> dict:
182
218
  """Bound the original background-task facts for facade wait and report."""
183
219
  from ccx.ops.common import background_task_block, recorded_background_facts
@@ -325,44 +361,6 @@ def _unsupported(engine: str, capability: str, detail: str) -> CapabilityUnsuppo
325
361
  )
326
362
 
327
363
 
328
- def _claude_observed_model(rec: dict) -> str | None:
329
- """The model Claude actually answered with, from the rendered transcript.
330
-
331
- Never falls back to the requested model, and never accepts ``<synthetic>``
332
- (the pseudo-model on API-error replies). Reads the already-rendered document
333
- so this stays cheap enough for ``status``.
334
- """
335
- path = rec.get("transcriptJson")
336
- if not path:
337
- return None
338
- try:
339
- with open(path, encoding="utf-8") as handle:
340
- doc = json.load(handle)
341
- except (OSError, ValueError):
342
- return None
343
- for turn in reversed(doc.get("turns") or []):
344
- model = turn.get("model")
345
- if isinstance(model, str) and model and model != "<synthetic>":
346
- return model
347
- return None
348
-
349
-
350
- def _claude_statusline_model(short: str) -> str | None:
351
- """Last model Claude reported through the compact statusline sidecar.
352
-
353
- Unlike context freshness, a settled worker's last observed model does not
354
- expire. This is a read-only fallback for inventory summaries when the
355
- cleaned transcript cache has not been rendered yet.
356
- """
357
- try:
358
- doc = json.loads(statusline_json_path(short).read_text())
359
- except (OSError, ValueError):
360
- return None
361
- model = doc.get("model") if isinstance(doc, dict) else None
362
- value = model.get("id") if isinstance(model, dict) else None
363
- return value if isinstance(value, str) and value and value != "<synthetic>" else None
364
-
365
-
366
364
  def _summary_context(block: dict | None, *, has_turn: bool, codex: bool) -> dict:
367
365
  """Normalize persisted context without rendering either engine transcript."""
368
366
  if not has_turn:
@@ -1789,8 +1787,14 @@ class WorkerClient:
1789
1787
  def _open_full_conversation(self, worker, *, max_tool_result_bytes,
1790
1788
  timeout, include_native=False,
1791
1789
  codex_recent_turns: int | None = None,
1792
- claude_recent_turns: int | None = None):
1793
- """Build once from the original owner, retaining its native source until exit."""
1790
+ claude_recent_turns: int | None = None,
1791
+ codex_history_db: bool = False):
1792
+ """Build once from the original owner, retaining its native source until exit.
1793
+
1794
+ ``codex_history_db`` reads a Codex thread from Codex's local history
1795
+ database first (``ccx.codex.history_store``), falling back to
1796
+ app-server when that read declines.
1797
+ """
1794
1798
  from ccx.codex import runtime as codex_runtime
1795
1799
  from ccx.codex import telemetry as codex_telemetry
1796
1800
  from ccx.claude.transcript import open_original_claude_source, PinnedNativeSource
@@ -1848,6 +1852,11 @@ class WorkerClient:
1848
1852
  max_tool_result_bytes=max_tool_result_bytes,
1849
1853
  before_offset=end_offset, after_offset=after_offset,
1850
1854
  )
1855
+ if after_offset > 0:
1856
+ _mark_recent_window(
1857
+ document, claude_recent_turns,
1858
+ f"showing only the turns since the last {claude_recent_turns} "
1859
+ "user prompts")
1851
1860
  # Public reference names the verified original pathname;
1852
1861
  # export uses the still-open native descriptor, not that path.
1853
1862
  document["nativeArtifacts"][0]["path"] = str(original_path)
@@ -1862,10 +1871,15 @@ class WorkerClient:
1862
1871
  "warnings": [*(evidence.get("warnings") or []),
1863
1872
  "excluded native evidence from a foreign thread"]}
1864
1873
  known_empty = rec.get("historyKnownEmpty") is True
1874
+ thread = None
1875
+ if codex_history_db and not known_empty and codex_recent_turns is None:
1876
+ # Codex's local history database; None means fall back.
1877
+ from ccx.codex.history_store import read_thread_db
1878
+ thread = read_thread_db(thread_id)
1865
1879
  if known_empty:
1866
1880
  thread = {"id": thread_id, "threadSource": store.codex_source(owner.worker),
1867
1881
  "turns": []}
1868
- else:
1882
+ elif thread is None:
1869
1883
  # Only rich Codex peek requests a bounded recent window.
1870
1884
  # Ordinary full conversation, logs and export retain
1871
1885
  # their unmodified source-read argument contract.
@@ -1883,22 +1897,13 @@ class WorkerClient:
1883
1897
  worker=owner.worker,
1884
1898
  max_tool_result_bytes=max_tool_result_bytes, evidence=evidence,
1885
1899
  )
1900
+ if thread.get("historySource") == "codex-history-db":
1901
+ _note_history_db(document, thread)
1886
1902
  if (codex_recent_turns is not None
1887
1903
  and len(thread.get("turns") or []) >= codex_recent_turns):
1888
- # A bounded tail cannot claim global 1-based turn numbers,
1889
- # nor pretend to describe the complete native history.
1890
- for turn in document["turns"]:
1891
- turn["sequence"] = None
1892
- complete = document["completeness"]
1893
- complete["sourceLimited"] = True
1894
- complete["status"] = "source-limited"
1895
- complete["warnings"] = [
1896
- *(complete.get("warnings") or []),
1897
- f"showing only the most recent {codex_recent_turns} native turns",
1898
- ]
1899
- document["peekHistoryWindow"] = {
1900
- "kind": "recent", "limit": codex_recent_turns,
1901
- }
1904
+ _mark_recent_window(
1905
+ document, codex_recent_turns,
1906
+ f"showing only the most recent {codex_recent_turns} native turns")
1902
1907
  # A proven empty history is a synthetic normalization view,
1903
1908
  # never evidence that a native thread/read actually occurred.
1904
1909
  native_source = None if known_empty else thread
@@ -1931,7 +1936,8 @@ class WorkerClient:
1931
1936
  def conversation(self, worker, *, max_tool_result_bytes: int | None = None,
1932
1937
  timeout: float | None = None,
1933
1938
  _recent_codex_turns: int | None = None,
1934
- _recent_claude_turns: int | None = None) -> dict:
1939
+ _recent_claude_turns: int | None = None,
1940
+ _codex_history_db: bool = False) -> dict:
1935
1941
  """Read a normalized full history from this original full-UUID owner.
1936
1942
 
1937
1943
  Claude reads a pinned native JSONL complete-line snapshot; Codex reads
@@ -1942,6 +1948,7 @@ class WorkerClient:
1942
1948
  worker, max_tool_result_bytes=max_tool_result_bytes,
1943
1949
  timeout=timeout, codex_recent_turns=_recent_codex_turns,
1944
1950
  claude_recent_turns=_recent_claude_turns,
1951
+ codex_history_db=_codex_history_db,
1945
1952
  ) as (_owner, document, _native, _files):
1946
1953
  return document
1947
1954
 
@@ -1977,6 +1984,11 @@ class WorkerClient:
1977
1984
 
1978
1985
  # -- account usage ------------------------------------------------------
1979
1986
 
1987
+ def models(self, engine: str | None = None, *, refresh: bool = False) -> list[dict]:
1988
+ """The models one or both engines offer, as ``ccx models --json``."""
1989
+ from ccx.models import read_models
1990
+ return [catalog.as_dict() for catalog in read_models(engine=engine, refresh=refresh)]
1991
+
1980
1992
  def usage(self, engine: str | None = None, *, include_tokens: bool = False,
1981
1993
  timeout: float = 30) -> dict:
1982
1994
  """Account-scoped quota utilization for one or both providers.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ccx-native",
3
- "version": "0.1.0",
4
- "description": "Control and monitor native Claude Code and Codex sessions from the CLI, Python or RPC, on your own subscriptions.",
3
+ "version": "0.2.0",
4
+ "description": "Native Claude Code and Codex sessions shared by your agents, scripts and terminals. Works with your own Claude and ChatGPT subscriptions.",
5
5
  "author": "Fernando <ccx@delega.ai>",
6
6
  "license": "MIT",
7
7
  "bin": {
package/pyproject.toml CHANGED
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ccx-native"
7
- version = "0.1.0"
8
- description = "Control and monitor native Claude Code and Codex sessions from the CLI, Python or RPC, on your own subscriptions."
7
+ version = "0.2.0"
8
+ description = "Native Claude Code and Codex sessions shared by your agents, scripts and terminals. Works with your own Claude and ChatGPT subscriptions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = "MIT"
@@ -61,7 +61,15 @@ ccx send "$W" "Now add a regression test."
61
61
  - `ccx peek TARGET`: state, latest messages, tools and errors. Start here.
62
62
  `ccx peek A B --status` checks several workers at once.
63
63
  - `ccx report TARGET`: the last reply.
64
- - `ccx logs TARGET`: conversation JSON when the report isn't enough.
64
+ - `ccx history TARGET...`: earlier turns as text. Lines carry addresses
65
+ (`#184.0`); `--turns 184.0` prints one message whole, `--turns 180-186` the
66
+ exchange. Search the workers you name with `--grep RE`, `--cmd pytest` or
67
+ `--errors` (the newest 20 hits each); `--view map` gives a long history at a
68
+ glance.
69
+ - After a compaction, read what you lost with `ccx history` on yourself
70
+ (`$CCX_SELF_WORKER_ID`), not the raw transcript. See
71
+ [references/history.md](references/history.md).
72
+ - `ccx logs TARGET`: the whole conversation as JSON, for programs.
65
73
  - `ccx list`: find workers by name, state or folder.
66
74
 
67
75
  ## Permissions
@@ -153,3 +161,6 @@ More in [references/troubleshooting.md](references/troubleshooting.md).
153
161
  - `ccx attach TARGET` opens the worker in a terminal; Ctrl-Z detaches.
154
162
  - `--cap-5h PCT` or `--cap-7d PCT` on `new` or `send` stops that turn when
155
163
  account usage reaches PCT%. `ccx usage` shows the current levels.
164
+ - `ccx models` lists each engine's models and the efforts each supports. A
165
+ Claude alias such as `opus` can point to a newer model after a Claude Code
166
+ update; pass the full id from the Resolved column to stay on one model.