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/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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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"],
|
package/ccx/ops/turn_reports.py
CHANGED
|
@@ -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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
package/ccx/ops/worker_events.py
CHANGED
|
@@ -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"]
|
package/ccx/store/paths.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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,
|
package/ccx/store/store.py
CHANGED
|
@@ -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",
|
|
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",
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
8
|
-
description = "
|
|
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"
|
package/skills/ccx/SKILL.md
CHANGED
|
@@ -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
|
|
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.
|