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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. 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