ccx-native 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Pure worker lifecycle projection shared by normalized public surfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
RUNTIME_SERVED = "served"
|
|
8
|
+
RUNTIME_ABSENT = "absent"
|
|
9
|
+
RUNTIME_INCONCLUSIVE = "inconclusive"
|
|
10
|
+
RUNTIME_PRESENCE = frozenset(
|
|
11
|
+
{RUNTIME_SERVED, RUNTIME_ABSENT, RUNTIME_INCONCLUSIVE}
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
_STATE = {
|
|
15
|
+
"booting": "starting",
|
|
16
|
+
"resuming": "starting",
|
|
17
|
+
"idle": "idle",
|
|
18
|
+
"busy": "busy",
|
|
19
|
+
"waiting": "waiting",
|
|
20
|
+
"dead": "stopped",
|
|
21
|
+
"idle-killed": "stopped",
|
|
22
|
+
"budget-stopped": "stopped",
|
|
23
|
+
"crashed": "failed",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_native_state(native: Any) -> str:
|
|
28
|
+
return _STATE.get(native, "unavailable")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def project_runtime_state(
|
|
32
|
+
recorded_native: str | None,
|
|
33
|
+
live_state: str | None,
|
|
34
|
+
runtime_presence: str,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Project one truthful normalized lifecycle from durable and live facts."""
|
|
37
|
+
if runtime_presence not in RUNTIME_PRESENCE:
|
|
38
|
+
raise ValueError(f"invalid runtime presence: {runtime_presence!r}")
|
|
39
|
+
|
|
40
|
+
if runtime_presence == RUNTIME_SERVED:
|
|
41
|
+
native = (
|
|
42
|
+
live_state
|
|
43
|
+
if isinstance(live_state, str) and live_state
|
|
44
|
+
else recorded_native
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
"state": normalize_native_state(native),
|
|
48
|
+
"native_state": native,
|
|
49
|
+
"loaded": True,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
native = recorded_native
|
|
53
|
+
if runtime_presence == RUNTIME_ABSENT:
|
|
54
|
+
if native == "idle":
|
|
55
|
+
normalized = "stopped"
|
|
56
|
+
elif native in {"busy", "waiting", "resuming"}:
|
|
57
|
+
normalized = "failed"
|
|
58
|
+
else:
|
|
59
|
+
normalized = normalize_native_state(native)
|
|
60
|
+
return {
|
|
61
|
+
"state": normalized,
|
|
62
|
+
"native_state": native,
|
|
63
|
+
"loaded": False,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"state": normalize_native_state(native),
|
|
68
|
+
"native_state": native,
|
|
69
|
+
"loaded": None,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def claude_recorded_delivery_busy(record: dict, receipts) -> bool:
|
|
74
|
+
"""Overlay an owned unsettled new turn on a lagging recorded IDLE state."""
|
|
75
|
+
if (record.get("lifecycle") or {}).get("state") != "idle":
|
|
76
|
+
return False
|
|
77
|
+
last = record.get("lastSettledTurn") or {}
|
|
78
|
+
floor = max(record.get("lastSettledAt") or 0, last.get("settledAtMs") or 0)
|
|
79
|
+
return any(
|
|
80
|
+
receipt.get("requestedMode") == "new_turn_only"
|
|
81
|
+
and receipt.get("state") in {"owned", "accepted"}
|
|
82
|
+
and not isinstance(receipt.get("settlement"), dict)
|
|
83
|
+
and (receipt.get("ownedAtMs") or receipt.get("acceptedAtMs")
|
|
84
|
+
or receipt.get("createdAtMs") or 0) > floor
|
|
85
|
+
for receipt in receipts
|
|
86
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Deterministic UTF-8/JSON previews for bounded public projections."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_DEFAULT_BREADTH = 64
|
|
13
|
+
_DEFAULT_DEPTH = 8
|
|
14
|
+
_MAX_PATH_SAMPLES = 32
|
|
15
|
+
_MAX_PATH_BYTES = 256
|
|
16
|
+
_OMIT = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class BoundedValue:
|
|
21
|
+
value: Any
|
|
22
|
+
truncated: bool
|
|
23
|
+
original_byte_count: int
|
|
24
|
+
returned_byte_count: int
|
|
25
|
+
sha256: str | None
|
|
26
|
+
omitted: bool = False
|
|
27
|
+
changed_paths: tuple[str, ...] = ()
|
|
28
|
+
changed_path_count: int = 0
|
|
29
|
+
omitted_count: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _PreviewState:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.paths: list[str] = []
|
|
35
|
+
self.changed_path_count = 0
|
|
36
|
+
self.omitted_count = 0
|
|
37
|
+
|
|
38
|
+
def snapshot(self) -> tuple[int, int, int]:
|
|
39
|
+
return (len(self.paths), self.changed_path_count, self.omitted_count)
|
|
40
|
+
|
|
41
|
+
def restore(self, snapshot: tuple[int, int, int]) -> None:
|
|
42
|
+
length, changed, omitted = snapshot
|
|
43
|
+
del self.paths[length:]
|
|
44
|
+
self.changed_path_count = changed
|
|
45
|
+
self.omitted_count = omitted
|
|
46
|
+
|
|
47
|
+
def add(self, path: str, *, omitted: bool = False, count: int = 1) -> None:
|
|
48
|
+
count = max(1, int(count))
|
|
49
|
+
self.changed_path_count += count
|
|
50
|
+
if omitted:
|
|
51
|
+
self.omitted_count += count
|
|
52
|
+
if len(self.paths) < _MAX_PATH_SAMPLES:
|
|
53
|
+
sample = _utf8_prefix(path or "$", _MAX_PATH_BYTES)
|
|
54
|
+
if sample not in self.paths:
|
|
55
|
+
self.paths.append(sample)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _canonical_json(value: Any) -> bytes:
|
|
59
|
+
return json.dumps(
|
|
60
|
+
value,
|
|
61
|
+
ensure_ascii=False,
|
|
62
|
+
allow_nan=False,
|
|
63
|
+
separators=(",", ":"),
|
|
64
|
+
sort_keys=True,
|
|
65
|
+
).encode("utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _value_bytes(value: Any) -> bytes:
|
|
69
|
+
# String metadata is raw UTF-8. Structured metadata is canonical JSON.
|
|
70
|
+
if isinstance(value, str):
|
|
71
|
+
return value.encode("utf-8")
|
|
72
|
+
return _canonical_json(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _encoded_size(value: Any) -> int:
|
|
76
|
+
return len(_canonical_json(value))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _utf8_prefix(value: str, max_bytes: int) -> str:
|
|
80
|
+
raw = value.encode("utf-8")
|
|
81
|
+
if len(raw) <= max_bytes:
|
|
82
|
+
return value
|
|
83
|
+
return raw[:max(0, max_bytes)].decode("utf-8", "ignore")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _json_string_prefix(value: str, max_encoded_bytes: int) -> str | object:
|
|
87
|
+
if max_encoded_bytes < 2: # even "" is two encoded bytes
|
|
88
|
+
return _OMIT
|
|
89
|
+
if _encoded_size(value) <= max_encoded_bytes:
|
|
90
|
+
return value
|
|
91
|
+
low, high = 0, len(value)
|
|
92
|
+
while low < high:
|
|
93
|
+
mid = (low + high + 1) // 2
|
|
94
|
+
if _encoded_size(value[:mid]) <= max_encoded_bytes:
|
|
95
|
+
low = mid
|
|
96
|
+
else:
|
|
97
|
+
high = mid - 1
|
|
98
|
+
return value[:low]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _dict_path(base: str, key: Any) -> str:
|
|
102
|
+
key_text = str(key)
|
|
103
|
+
if key_text.isidentifier():
|
|
104
|
+
return f"{base}.{key_text}" if base else key_text
|
|
105
|
+
encoded = json.dumps(key_text, ensure_ascii=False)
|
|
106
|
+
return f"{base}[{encoded}]" if base else f"[{encoded}]"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _list_path(base: str, index: int | str) -> str:
|
|
110
|
+
return f"{base}[{index}]" if base else f"[{index}]"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _preview_json(
|
|
114
|
+
value: Any,
|
|
115
|
+
max_bytes: int,
|
|
116
|
+
*,
|
|
117
|
+
state: _PreviewState,
|
|
118
|
+
path: str = "",
|
|
119
|
+
depth: int = 0,
|
|
120
|
+
breadth: int = _DEFAULT_BREADTH,
|
|
121
|
+
max_depth: int = _DEFAULT_DEPTH,
|
|
122
|
+
) -> Any:
|
|
123
|
+
"""Return a deterministic same-shape preview or ``_OMIT``.
|
|
124
|
+
|
|
125
|
+
Scalars are never substituted. If a scalar cannot fit the allotted encoded
|
|
126
|
+
JSON bytes, its containing key/list suffix is omitted and the path is
|
|
127
|
+
reported through ``state``.
|
|
128
|
+
"""
|
|
129
|
+
max_bytes = max(0, int(max_bytes))
|
|
130
|
+
if _encoded_size(value) <= max_bytes:
|
|
131
|
+
return copy.deepcopy(value)
|
|
132
|
+
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
clipped = _json_string_prefix(value, max_bytes)
|
|
135
|
+
if clipped is _OMIT:
|
|
136
|
+
state.add(path, omitted=True)
|
|
137
|
+
return _OMIT
|
|
138
|
+
state.add(path)
|
|
139
|
+
return clipped
|
|
140
|
+
|
|
141
|
+
if isinstance(value, list):
|
|
142
|
+
if max_bytes < 2:
|
|
143
|
+
state.add(path, omitted=True, count=max(1, len(value)))
|
|
144
|
+
return _OMIT
|
|
145
|
+
if depth >= max_depth:
|
|
146
|
+
if value:
|
|
147
|
+
state.add(_list_path(path, "0:"), omitted=True, count=len(value))
|
|
148
|
+
return []
|
|
149
|
+
out: list[Any] = []
|
|
150
|
+
entries = value[:breadth]
|
|
151
|
+
for index, entry in enumerate(entries):
|
|
152
|
+
remaining = max_bytes - _encoded_size(out) - 2
|
|
153
|
+
if remaining <= 0:
|
|
154
|
+
state.add(_list_path(path, f"{index}:"), omitted=True, count=len(value) - index)
|
|
155
|
+
break
|
|
156
|
+
remaining_slots = len(entries) - index
|
|
157
|
+
share = max(1, remaining // max(1, remaining_slots))
|
|
158
|
+
snapshot = state.snapshot()
|
|
159
|
+
candidate_entry = _preview_json(
|
|
160
|
+
entry,
|
|
161
|
+
share,
|
|
162
|
+
state=state,
|
|
163
|
+
path=_list_path(path, index),
|
|
164
|
+
depth=depth + 1,
|
|
165
|
+
breadth=breadth,
|
|
166
|
+
max_depth=max_depth,
|
|
167
|
+
)
|
|
168
|
+
if candidate_entry is _OMIT:
|
|
169
|
+
state.restore(snapshot)
|
|
170
|
+
state.add(_list_path(path, f"{index}:"), omitted=True, count=len(value) - index)
|
|
171
|
+
break
|
|
172
|
+
candidate = [*out, candidate_entry]
|
|
173
|
+
if _encoded_size(candidate) > max_bytes:
|
|
174
|
+
state.restore(snapshot)
|
|
175
|
+
state.add(_list_path(path, f"{index}:"), omitted=True, count=len(value) - index)
|
|
176
|
+
break
|
|
177
|
+
out = candidate
|
|
178
|
+
if len(value) > breadth and len(out) >= breadth:
|
|
179
|
+
state.add(_list_path(path, f"{breadth}:"), omitted=True, count=len(value) - breadth)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
if isinstance(value, dict):
|
|
183
|
+
if max_bytes < 2:
|
|
184
|
+
state.add(path, omitted=True, count=max(1, len(value)))
|
|
185
|
+
return _OMIT
|
|
186
|
+
if depth >= max_depth:
|
|
187
|
+
if value:
|
|
188
|
+
state.add(f"{path}.*" if path else "*", omitted=True, count=len(value))
|
|
189
|
+
return {}
|
|
190
|
+
out: dict[Any, Any] = {}
|
|
191
|
+
entries = list(value.items())[:breadth]
|
|
192
|
+
for index, (key, entry) in enumerate(entries):
|
|
193
|
+
child_path = _dict_path(path, key)
|
|
194
|
+
key_probe = {key: None}
|
|
195
|
+
key_bytes = _encoded_size(key_probe)
|
|
196
|
+
remaining = max_bytes - _encoded_size(out) - 2
|
|
197
|
+
if key_bytes >= remaining:
|
|
198
|
+
state.add(child_path, omitted=True)
|
|
199
|
+
continue
|
|
200
|
+
remaining_slots = len(entries) - index
|
|
201
|
+
share = max(1, remaining // max(1, remaining_slots) - key_bytes)
|
|
202
|
+
snapshot = state.snapshot()
|
|
203
|
+
candidate_entry = _preview_json(
|
|
204
|
+
entry,
|
|
205
|
+
share,
|
|
206
|
+
state=state,
|
|
207
|
+
path=child_path,
|
|
208
|
+
depth=depth + 1,
|
|
209
|
+
breadth=breadth,
|
|
210
|
+
max_depth=max_depth,
|
|
211
|
+
)
|
|
212
|
+
if candidate_entry is _OMIT:
|
|
213
|
+
# Child already recorded the exact omitted path.
|
|
214
|
+
continue
|
|
215
|
+
candidate = {**out, key: candidate_entry}
|
|
216
|
+
if _encoded_size(candidate) > max_bytes:
|
|
217
|
+
state.restore(snapshot)
|
|
218
|
+
state.add(child_path, omitted=True)
|
|
219
|
+
continue
|
|
220
|
+
out = candidate
|
|
221
|
+
if len(value) > breadth:
|
|
222
|
+
state.add(f"{path}.*" if path else "*", omitted=True, count=len(value) - breadth)
|
|
223
|
+
return out
|
|
224
|
+
|
|
225
|
+
# JSON scalars are either represented exactly or omitted. Substituting a
|
|
226
|
+
# false/zero value would fabricate provider data.
|
|
227
|
+
state.add(path, omitted=True)
|
|
228
|
+
return _OMIT
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def omit_value(value: Any) -> BoundedValue:
|
|
232
|
+
"""Describe a field deliberately omitted from a bounded projection."""
|
|
233
|
+
original = _value_bytes(value)
|
|
234
|
+
return BoundedValue(
|
|
235
|
+
None,
|
|
236
|
+
True,
|
|
237
|
+
len(original),
|
|
238
|
+
0,
|
|
239
|
+
hashlib.sha256(original).hexdigest(),
|
|
240
|
+
omitted=True,
|
|
241
|
+
changed_paths=(),
|
|
242
|
+
changed_path_count=1,
|
|
243
|
+
omitted_count=1,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def bound_value(value: Any, max_bytes: int | None) -> BoundedValue:
|
|
248
|
+
"""Bound one public value and return explicit full-value metadata.
|
|
249
|
+
|
|
250
|
+
Strings use raw UTF-8 byte counts/digests. Structured values use canonical
|
|
251
|
+
JSON: UTF-8, sorted keys, compact `,`/`:` separators, `ensure_ascii=False`,
|
|
252
|
+
and `allow_nan=False`.
|
|
253
|
+
"""
|
|
254
|
+
original = _value_bytes(value)
|
|
255
|
+
if max_bytes is None or len(original) <= max_bytes:
|
|
256
|
+
return BoundedValue(copy.deepcopy(value), False, len(original), len(original), None)
|
|
257
|
+
|
|
258
|
+
if isinstance(value, str):
|
|
259
|
+
projected = _utf8_prefix(value, max(0, max_bytes))
|
|
260
|
+
returned = _value_bytes(projected)
|
|
261
|
+
return BoundedValue(
|
|
262
|
+
projected,
|
|
263
|
+
True,
|
|
264
|
+
len(original),
|
|
265
|
+
len(returned),
|
|
266
|
+
hashlib.sha256(original).hexdigest(),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
state = _PreviewState()
|
|
270
|
+
projected = _preview_json(value, max(0, max_bytes), state=state)
|
|
271
|
+
if projected is _OMIT:
|
|
272
|
+
return BoundedValue(
|
|
273
|
+
None,
|
|
274
|
+
True,
|
|
275
|
+
len(original),
|
|
276
|
+
0,
|
|
277
|
+
hashlib.sha256(original).hexdigest(),
|
|
278
|
+
omitted=True,
|
|
279
|
+
changed_paths=tuple(state.paths),
|
|
280
|
+
changed_path_count=state.changed_path_count,
|
|
281
|
+
omitted_count=max(1, state.omitted_count),
|
|
282
|
+
)
|
|
283
|
+
returned = _value_bytes(projected)
|
|
284
|
+
return BoundedValue(
|
|
285
|
+
projected,
|
|
286
|
+
True,
|
|
287
|
+
len(original),
|
|
288
|
+
len(returned),
|
|
289
|
+
hashlib.sha256(original).hexdigest(),
|
|
290
|
+
changed_paths=tuple(state.paths),
|
|
291
|
+
changed_path_count=state.changed_path_count,
|
|
292
|
+
omitted_count=state.omitted_count,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
__all__ = ["BoundedValue", "bound_value", "omit_value"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Canonical directory spelling shared by original create and targeted untrust.
|
|
2
|
+
|
|
3
|
+
``Path.resolve`` follows symlinks on both platforms, but on case-insensitive
|
|
4
|
+
APFS it can retain the *caller's* case rather than the directory's actual
|
|
5
|
+
on-disk case. Darwin F_GETPATH returns the path of the opened directory inode.
|
|
6
|
+
For a directory that was removed after trust, resolve the longest existing
|
|
7
|
+
prefix and preserve the missing suffix for an exact ledger lookup.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ccx.system import oslayer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def canonical_cwd(path: str | Path) -> str:
|
|
17
|
+
"""Return the symlink-resolved, physical directory spelling if available.
|
|
18
|
+
|
|
19
|
+
The requested path may traverse legitimate symlinks, so the resolved
|
|
20
|
+
directory is opened without O_NOFOLLOW. On Linux, keep Path.resolve's
|
|
21
|
+
existing behavior. Darwin's pathname is read from the pinned directory
|
|
22
|
+
descriptor rather than reconstructed from the request spelling.
|
|
23
|
+
"""
|
|
24
|
+
resolved = Path(path).expanduser().resolve(strict=False)
|
|
25
|
+
return oslayer.physical_directory_spelling(resolved)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""One executable lookup shared by native engine preflight and launch."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from ccx import constants
|
|
10
|
+
from ccx.errors import EngineCliNotFound, InvalidRequest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_engine_binary(engine: str) -> str:
|
|
14
|
+
"""Return a runnable absolute executable, or a typed missing-CLI failure.
|
|
15
|
+
|
|
16
|
+
Claude's selected binary is pinned by constants at import, while Codex's
|
|
17
|
+
override is read at launch time. Both accept a literal executable path or
|
|
18
|
+
the same PATH lookup used when starting a native subprocess.
|
|
19
|
+
"""
|
|
20
|
+
if engine == "claude":
|
|
21
|
+
candidate = constants.CLAUDE_BIN
|
|
22
|
+
elif engine == "codex":
|
|
23
|
+
candidate = os.environ.get("CCX_CODEX_BIN") or (
|
|
24
|
+
shutil.which("codex") or os.path.expanduser("~/.local/bin/codex")
|
|
25
|
+
)
|
|
26
|
+
else:
|
|
27
|
+
raise InvalidRequest(f"unknown engine: {engine!r}")
|
|
28
|
+
found = shutil.which(candidate)
|
|
29
|
+
if found is None or not os.path.isfile(found):
|
|
30
|
+
title = engine.capitalize()
|
|
31
|
+
raise EngineCliNotFound(
|
|
32
|
+
f"{title} CLI not found (looked for {candidate}); "
|
|
33
|
+
f"install {title} or set CCX_{engine.upper()}_BIN"
|
|
34
|
+
)
|
|
35
|
+
return os.path.abspath(found)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def account_signed_out(engine: str, binary: str, *, timeout: float = 3) -> bool:
|
|
39
|
+
"""Check auth only after a usage read has failed; never initiate login."""
|
|
40
|
+
args = ("auth", "status") if engine == "claude" else ("login", "status")
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
[binary, *args], stdin=subprocess.DEVNULL, capture_output=True,
|
|
44
|
+
text=True, timeout=timeout, check=False,
|
|
45
|
+
)
|
|
46
|
+
except (OSError, subprocess.SubprocessError):
|
|
47
|
+
return False
|
|
48
|
+
combined = (result.stdout + "\n" + result.stderr).strip()
|
|
49
|
+
if engine == "claude":
|
|
50
|
+
try:
|
|
51
|
+
status = json.loads(result.stdout)
|
|
52
|
+
if isinstance(status, dict) and status.get("loggedIn") is False:
|
|
53
|
+
return True
|
|
54
|
+
except (ValueError, TypeError):
|
|
55
|
+
pass
|
|
56
|
+
lowered = combined.lower()
|
|
57
|
+
return any(phrase in lowered for phrase in (
|
|
58
|
+
"not logged in", "not signed in", "not authenticated", "logged out",
|
|
59
|
+
))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_invoked_path(path: str) -> str:
|
|
10
|
+
# Python can preserve an invoked path like //./ccx.py in __main__.__file__.
|
|
11
|
+
# Treat that as cwd-relative; otherwise os.path.abspath preserves the
|
|
12
|
+
# leading double slash and hooks later try to execute /ccx.py.
|
|
13
|
+
if path.startswith("//./"):
|
|
14
|
+
path = "." + path[3:]
|
|
15
|
+
return os.path.abspath(path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def current_executable_path() -> str:
|
|
19
|
+
"""Absolute script path suitable for re-exec and Claude hook commands."""
|
|
20
|
+
main_file = getattr(sys.modules.get("__main__"), "__file__", None)
|
|
21
|
+
if main_file and _looks_like_ccx_entrypoint(main_file):
|
|
22
|
+
return _normalize_invoked_path(main_file)
|
|
23
|
+
package_main = _package_main_path()
|
|
24
|
+
if package_main is not None:
|
|
25
|
+
return str(package_main)
|
|
26
|
+
# Fallback for unusual source layouts where the package file is not
|
|
27
|
+
# discoverable: keep the historic behavior.
|
|
28
|
+
if main_file:
|
|
29
|
+
return _normalize_invoked_path(main_file)
|
|
30
|
+
arg0 = sys.argv[0] if sys.argv else "ccx.py"
|
|
31
|
+
if os.path.isabs(arg0) or os.sep in arg0:
|
|
32
|
+
return _normalize_invoked_path(arg0)
|
|
33
|
+
found = shutil.which(arg0)
|
|
34
|
+
return _normalize_invoked_path(found or arg0)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _looks_like_ccx_entrypoint(path: str) -> bool:
|
|
38
|
+
p = pathlib.Path(_normalize_invoked_path(path))
|
|
39
|
+
if p.name == "ccx.py":
|
|
40
|
+
return True
|
|
41
|
+
return p.name == "__main__.py" and p.parent.name == "ccx"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _package_main_path() -> pathlib.Path | None:
|
|
45
|
+
pkg = sys.modules.get("ccx")
|
|
46
|
+
pkg_file = getattr(pkg, "__file__", None)
|
|
47
|
+
if not pkg_file:
|
|
48
|
+
return None
|
|
49
|
+
candidate = pathlib.Path(pkg_file).resolve().parent / "__main__.py"
|
|
50
|
+
return candidate if candidate.exists() else None
|