ccx-native 0.0.0-stage → 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 -2
  3. package/bin/ccx +56 -0
  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 +40 -4
  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,254 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import os
5
+ import pathlib
6
+ import re
7
+ import socket
8
+ import tempfile
9
+ import unicodedata
10
+
11
+ from ccx.system import oslayer
12
+ from ccx import constants
13
+ from ccx.constants import CLAUDE_HOME
14
+
15
+ def private_mkdir(path: pathlib.Path | str) -> pathlib.Path:
16
+ """``mkdir -p`` where every directory it creates, and the leaf, is 0700.
17
+
18
+ ``Path.mkdir(parents=True, mode=...)`` applies the mode to the leaf only;
19
+ the parents it creates get the umask default, which would leave e.g.
20
+ ``operations/`` listable by other users.
21
+ """
22
+ path = pathlib.Path(path)
23
+ missing = []
24
+ probe = path
25
+ while not probe.exists() and probe != probe.parent:
26
+ missing.append(probe)
27
+ probe = probe.parent
28
+ for directory in reversed(missing):
29
+ try:
30
+ directory.mkdir(mode=0o700)
31
+ except FileExistsError:
32
+ continue
33
+ directory.chmod(0o700)
34
+ path.chmod(0o700)
35
+ return path
36
+
37
+
38
+ def ensure_dirs():
39
+ """New runtime bootstrap is observational; never mkdir/chmod old short tree."""
40
+ from ccx import constants
41
+ from ccx.store.root import require_ccx_active
42
+ return require_ccx_active(constants.CCX_ROOT)
43
+
44
+
45
+
46
+ def peer_is_same_user(conn: socket.socket) -> bool:
47
+ """Whether an accepted AF_UNIX peer runs as this user (Linux SO_PEERCRED).
48
+
49
+ The 0700 directory and 0600 socket are the primary guard; this is the
50
+ second one, so a socket reachable by mistake still only answers its
51
+ owner. Missing or unreadable kernel credentials always fail closed;
52
+ file permissions alone cannot authenticate a connected peer.
53
+ """
54
+ try:
55
+ credentials = oslayer.peer_credentials(conn)
56
+ except oslayer.PeerIdentityUnavailable:
57
+ return False
58
+ return credentials.uid == os.getuid()
59
+
60
+
61
+ def accept_same_user(server: socket.socket) -> socket.socket | None:
62
+ """``server.accept()`` that closes and drops a peer of another user.
63
+
64
+ Returns None for a dropped peer; accept errors propagate as before.
65
+ """
66
+ conn, _ = server.accept()
67
+ if peer_is_same_user(conn):
68
+ return conn
69
+ try:
70
+ conn.close()
71
+ except OSError:
72
+ pass
73
+ return None
74
+
75
+
76
+ def _require_unix_socket_budget(path: pathlib.Path) -> None:
77
+ """Check the final and fixed-width staged bind paths without writing."""
78
+ # mkdtemp(prefix=".") generates exactly eight characters after the dot.
79
+ for candidate, kind in (
80
+ (path, "final"),
81
+ (path.parent / ".XXXXXXXX" / "s", "staged"),
82
+ ):
83
+ if not oslayer.fits_unix_path(candidate):
84
+ raise OSError(errno.ENAMETOOLONG,
85
+ f"{kind} Unix socket path exceeds AF_UNIX pathname budget",
86
+ os.fspath(candidate))
87
+
88
+
89
+ def bind_private_unix_socket(server: socket.socket, path: pathlib.Path | str) -> None:
90
+ """Bind ``server`` at ``path`` with mode 0600 and no permissive window.
91
+
92
+ Binding a socket creates the inode with umask permissions, so chmod alone
93
+ leaves an exposure window. Instead, bind inside a fresh private 0700
94
+ staging directory, chmod there, and atomically rename the 0600 socket.
95
+ This avoids changing the process-wide umask, which could race threads.
96
+ """
97
+ path = pathlib.Path(path)
98
+ _require_unix_socket_budget(path) # before mkdtemp creates any directory
99
+ staging = pathlib.Path(tempfile.mkdtemp(prefix=".", dir=path.parent))
100
+ try:
101
+ tmp = staging / "s"
102
+ server.bind(str(tmp))
103
+ os.chmod(tmp, 0o600)
104
+ os.replace(tmp, path)
105
+ finally:
106
+ try:
107
+ (staging / "s").unlink()
108
+ except FileNotFoundError:
109
+ pass
110
+ try:
111
+ os.rmdir(staging)
112
+ except OSError:
113
+ pass
114
+
115
+ def trust_ledger_path() -> pathlib.Path:
116
+ """Which ``.claude.json`` trust entries ccx itself wrote (see settings)."""
117
+ return constants.CCX_ROOT / "trust-ledger.json"
118
+
119
+ def _owner_path(target: str, filename: str) -> pathlib.Path:
120
+ """Inert per-owned-worker pathname; actual writers must use pinned fds."""
121
+ from ccx import constants
122
+ from ccx.store.root import require_ccx_active
123
+ from ccx.store.identity import EngineMismatch
124
+ store = require_ccx_active(constants.CCX_ROOT)
125
+ owner = store.resolve(target)
126
+ if owner.engine != "claude":
127
+ raise EngineMismatch("Claude CCX sidecar requires Claude full worker owner")
128
+ # The path is NOT a second authority: the authoritative owner is checked
129
+ # on each call, and native internal sidecar I/O must use the pinned fd.
130
+ return (store.pointer.generation_root / "workers" /
131
+ owner.engine / owner.worker_id / filename)
132
+
133
+
134
+ def sessions_dir() -> pathlib.Path:
135
+ from ccx import constants
136
+ from ccx.store.root import require_ccx_active
137
+ store = require_ccx_active(constants.CCX_ROOT)
138
+ return store.pointer.generation_root / "workers" / "claude"
139
+
140
+
141
+ def record_path(short: str) -> pathlib.Path:
142
+ return _owner_path(short, "record.json")
143
+
144
+
145
+ def transcript_json_path(short: str) -> pathlib.Path:
146
+ return _owner_path(short, "transcript.json")
147
+
148
+
149
+ def statusline_json_path(short: str) -> pathlib.Path:
150
+ return _owner_path(short, "statusline.json")
151
+
152
+
153
+ def settings_path(short: str) -> pathlib.Path:
154
+ return _owner_path(short, "settings.json")
155
+
156
+
157
+ def events_ndjson_path(short: str) -> pathlib.Path:
158
+ return _owner_path(short, "events.ndjson")
159
+
160
+
161
+ def conversation_page_index_path(short: str) -> pathlib.Path:
162
+ return _owner_path(short, "conversation-page-index.json")
163
+
164
+
165
+ def compact_operations_path(short: str) -> pathlib.Path:
166
+ return _owner_path(short, "compact.json")
167
+
168
+
169
+ def steer_deliveries_path(short: str) -> pathlib.Path:
170
+ return _owner_path(short, "deliveries.json")
171
+
172
+
173
+ def steer_deliveries_lock_path(short: str) -> pathlib.Path:
174
+ return _owner_path(short, "deliveries.lock")
175
+
176
+
177
+ def sock_path(short: str) -> pathlib.Path:
178
+ from ccx import constants
179
+ from ccx.store.root import require_ccx_active
180
+ from ccx.rpc.transport import plan_transport
181
+ store = require_ccx_active(constants.CCX_ROOT)
182
+ owner = store.resolve(short)
183
+ if owner.engine != "claude":
184
+ from ccx.store.identity import EngineMismatch
185
+ raise EngineMismatch("Claude socket cannot use Codex worker")
186
+ return plan_transport(store, target=owner.worker).final
187
+
188
+
189
+ def respawn_lock_path(short: str) -> pathlib.Path:
190
+ return _owner_path(short, "respawn.lock")
191
+
192
+
193
+ def host_pid_path(short: str) -> pathlib.Path:
194
+ return _owner_path(short, "host.pid")
195
+
196
+
197
+ def host_log_path(short: str) -> pathlib.Path:
198
+ return _owner_path(short, "host.log")
199
+
200
+
201
+ def claude_project_slug(cwd: str) -> str:
202
+ """Mirror Claude 2.1.311's ``hL`` key (UTF-16 units / 200-unit cap)."""
203
+ # Claude's macOS cwd is NFC before hL; on Linux it retains on-disk NFD.
204
+ if oslayer.claude_project_cwd_is_nfc():
205
+ cwd = unicodedata.normalize("NFC", cwd)
206
+ encoded = cwd.encode("utf-16-le", "surrogatepass")
207
+ units = [encoded[i] | encoded[i + 1] << 8 for i in range(0, len(encoded), 2)]
208
+ # Claude's JS /[^a-zA-Z0-9]/g replaces each astral surrogate separately.
209
+ slug = "".join(
210
+ chr(unit) if (48 <= unit <= 57 or 65 <= unit <= 90 or 97 <= unit <= 122)
211
+ else "-" for unit in units
212
+ )
213
+ if len(slug) <= 200:
214
+ return slug
215
+ h = 0
216
+ for unit in units:
217
+ h = (31 * h + unit) & 0xffffffff
218
+ value = abs(h if h < 0x80000000 else h - 0x100000000)
219
+ digits = "0123456789abcdefghijklmnopqrstuvwxyz"
220
+ suffix = ""
221
+ while value:
222
+ value, remainder = divmod(value, 36)
223
+ suffix = digits[remainder] + suffix
224
+ return f"{slug[:200]}-{suffix or '0'}"
225
+
226
+
227
+ def transcript_path_for(cwd: str, session_id: str) -> pathlib.Path:
228
+ """Mirror of Claude Code's path derivation for ~/.claude/projects/<slug>/<uuid>.jsonl."""
229
+ slug = claude_project_slug(cwd)
230
+ return CLAUDE_HOME / "projects" / slug / f"{session_id}.jsonl"
231
+
232
+ def task_list_dir_for(session_id: str) -> pathlib.Path | None:
233
+ """Claude Code's structured task-list directory for one native session.
234
+
235
+ Claude currently keys ordinary task lists by the native session UUID under
236
+ ``~/.claude/tasks/<session-id>``. Keep that private upstream layout behind
237
+ CCX so consumers never need to derive it themselves. A malformed record id
238
+ degrades to ``None`` instead of letting a path separator escape the task root.
239
+ """
240
+ if not session_id or pathlib.PurePath(session_id).name != session_id:
241
+ return None
242
+ if session_id in {".", ".."}:
243
+ return None
244
+ return CLAUDE_HOME / "tasks" / session_id
245
+
246
+ def subagent_dir_for(cwd: str, session_id: str) -> pathlib.Path:
247
+ slug = claude_project_slug(cwd)
248
+ return CLAUDE_HOME / "projects" / slug / session_id / "subagents"
249
+
250
+ def iter_subagent_transcripts(cwd: str, session_id: str):
251
+ root = subagent_dir_for(cwd, session_id)
252
+ if not root.exists():
253
+ return
254
+ yield from sorted(root.rglob("*.jsonl"))
@@ -0,0 +1,40 @@
1
+ """File-stamped caching for expensive derived reads."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+
8
+ # Avoid caching writes within coarse filesystem timestamp granularity.
9
+ _MIN_AGE_NS = 50_000_000
10
+
11
+
12
+ class StampedCache:
13
+ """Cache by mtime, size and inode; missing files delegate to the reader."""
14
+
15
+ def __init__(self) -> None:
16
+ self._entries: dict[str, tuple[tuple[int, int, int], object]] = {}
17
+ self._lock = threading.Lock()
18
+
19
+ def get(self, path, compute):
20
+ key = str(path)
21
+ try:
22
+ st = Path(path).stat()
23
+ except OSError:
24
+ with self._lock:
25
+ self._entries.pop(key, None)
26
+ return compute(path)
27
+ stamp = (st.st_mtime_ns, st.st_size, st.st_ino)
28
+ with self._lock:
29
+ hit = self._entries.get(key)
30
+ if hit is not None and hit[0] == stamp:
31
+ return hit[1]
32
+ value = compute(path)
33
+ if time.time_ns() - st.st_mtime_ns >= _MIN_AGE_NS:
34
+ with self._lock:
35
+ self._entries[key] = (stamp, value)
36
+ return value
37
+
38
+ def clear(self) -> None:
39
+ with self._lock:
40
+ self._entries.clear()
@@ -0,0 +1,309 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import tempfile
7
+ import time
8
+
9
+ from ccx.constants import SELF_SHORT_ENV, S
10
+ from ccx.store.paths import record_path, sessions_dir
11
+
12
+ NAME_MAX_LEN = 80
13
+
14
+ _CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]+")
15
+ _WS_RE = re.compile(r"\s+")
16
+
17
+
18
+ def sanitize_name(name: str | None) -> str:
19
+ """Normalize an operator-set display name to a safe single line.
20
+
21
+ Control characters and newlines collapse to a single space, surrounding
22
+ whitespace is stripped, and the result is capped at :data:`NAME_MAX_LEN`
23
+ characters. Returns ``""`` for ``None`` / empty / whitespace-only input — the
24
+ caller decides what default to substitute (the auto-name ``ccx-<short>``)."""
25
+ if not name:
26
+ return ""
27
+ collapsed = _WS_RE.sub(" ", _CONTROL_RE.sub(" ", name)).strip()
28
+ return collapsed[:NAME_MAX_LEN].strip()
29
+
30
+
31
+ def iter_session_records():
32
+ """Read only global full-ID managed Claude owners for observational search."""
33
+ store = _uuid_claude_store()
34
+ for row in store.list_workers(engine="claude"):
35
+ yield store.record(row["worker"])
36
+
37
+
38
+ def _uuid_claude_store():
39
+ """One operational ACTIVE-generation gate; never initialize a legacy root."""
40
+ from ccx.constants import CCX_ROOT
41
+ from ccx.store.root import require_ccx_active
42
+ return require_ccx_active(CCX_ROOT)
43
+
44
+
45
+ def read_record(target: str) -> dict:
46
+ """Exact CCX-owned Claude full UUID only, no short/name/path fallback."""
47
+ from ccx.store.identity import EngineMismatch
48
+ store = _uuid_claude_store()
49
+ owner = store.resolve(target)
50
+ if owner.engine != "claude":
51
+ raise EngineMismatch("Claude record lookup requires Claude full worker UUID")
52
+ return store.record(owner.worker)
53
+
54
+
55
+ def write_record(rec: dict) -> None:
56
+ """Explicit version-CAS only for existing CCX-owned Claude worker.
57
+
58
+ Historical host code that passes a stale whole-record snapshot will
59
+ fail RecordConflict. A missing revision, native session change, original
60
+ fork/owner change or short-keyed record fails closed before publication.
61
+ Prefer merge_lifecycle / GenerationStore.mutate_record for new writers.
62
+ """
63
+ from ccx.store.root import RecordConflict, StorageIntegrityError
64
+ if not isinstance(rec, dict) or rec.get("engine") != "claude":
65
+ raise StorageIntegrityError("Claude full-UUID record needs exact engine")
66
+ worker_id = rec.get("workerId")
67
+ revision = rec.get("recordRevision")
68
+ if type(revision) is not int or revision < 0:
69
+ raise RecordConflict(
70
+ "Claude whole-record update requires fresh original recordRevision"
71
+ )
72
+ store = _uuid_claude_store()
73
+ updated = store.replace_record_if_revision(
74
+ f"claude:{worker_id}", rec, expected_revision=revision,
75
+ )
76
+ rec.clear()
77
+ rec.update(updated)
78
+
79
+
80
+ def merge_lifecycle(target: str, **patch) -> dict:
81
+ """One locked fresh-disk semantic patch; no stale whole-record write."""
82
+ store = _uuid_claude_store()
83
+ owner = store.resolve(target)
84
+ if owner.engine != "claude":
85
+ from ccx.store.identity import EngineMismatch
86
+ raise EngineMismatch("Claude lifecycle update requires Claude full worker UUID")
87
+ return store.patch_lifecycle(owner.worker, changes=patch)
88
+
89
+
90
+ def default_claude_name(worker_id: str) -> str:
91
+ """Original v2 fallback display from a validated full UUID's short field."""
92
+ from ccx.store.identity import WorkerOwner
93
+ return "ccx-" + WorkerOwner(worker_id, "claude").short
94
+
95
+
96
+ def take_host_boot_fields(target: str) -> tuple[dict | None, dict]:
97
+ """Atomically consume only host-owned run cap and stale waiting preview."""
98
+ from ccx.store.root import RecordConflict
99
+ store = _uuid_claude_store()
100
+ for _ in range(6):
101
+ current = store.record(target)
102
+ cap = current.get("activeCap")
103
+ if "activeCap" not in current and "waitingFor" not in current:
104
+ return None, current
105
+ try:
106
+ committed = store.mutate_record(
107
+ target, removals=("activeCap", "waitingFor"),
108
+ expect={"activeCap": cap, "waitingFor": current.get("waitingFor")},
109
+ )
110
+ return cap, committed
111
+ except RecordConflict:
112
+ continue # changed host transient: re-read its actual value
113
+ raise RecordConflict("host boot transients changed during bounded consumption")
114
+
115
+
116
+ def restore_host_turn_count(target: str, minimum: int) -> dict:
117
+ """Repair a sidecar-authoritative settled turn count, never decrease it."""
118
+ from ccx.store.root import RecordConflict, StorageIntegrityError
119
+ if type(minimum) is not int or minimum < 0:
120
+ raise StorageIntegrityError("invalid host settled turn floor")
121
+ store = _uuid_claude_store()
122
+ for _ in range(6):
123
+ current = store.record(target)
124
+ value = (current.get("stats") or {}).get("turns")
125
+ if type(value) is not int or value < 0:
126
+ raise StorageIntegrityError("host turn counter malformed")
127
+ if value >= minimum:
128
+ return current
129
+ try:
130
+ return store.mutate_record(
131
+ target, patches={"stats.turns": minimum},
132
+ expect={"stats.turns": value},
133
+ )
134
+ except RecordConflict:
135
+ continue
136
+ raise RecordConflict("host turn count changed during bounded recovery")
137
+
138
+
139
+ def commit_host_turn_settlement(
140
+ target: str, *, turn_ref: str, prior_turns: int, outcome: str,
141
+ settled_at_ms: int, proposed_title: str | None = None,
142
+ context: dict | None = None, background_task_summary: dict | None = None,
143
+ ) -> dict:
144
+ """Atomic/idempotent settlement, rebased only over fields this host owns.
145
+
146
+ No lifecycle state is written here: an external terminal kill owns it.
147
+ Exact turn ref and counter/time fields are one CAS, including after a lost
148
+ acknowledgement. An independent CLI name/config patch is never lost.
149
+ """
150
+ from ccx.store.root import RecordConflict, StorageIntegrityError
151
+ if (not isinstance(turn_ref, str) or not turn_ref
152
+ or type(prior_turns) is not int or prior_turns < 0
153
+ or outcome not in ("completed", "interrupted", "unknown")
154
+ or type(settled_at_ms) is not int or settled_at_ms <= 0):
155
+ raise StorageIntegrityError("invalid exact host settlement")
156
+ store = _uuid_claude_store()
157
+ for _ in range(8):
158
+ current = store.record(target)
159
+ turns = (current.get("stats") or {}).get("turns")
160
+ prior = current.get("lastSettledTurn")
161
+ if (type(turns) is not int or turns < 0
162
+ or prior is not None and not isinstance(prior, dict)):
163
+ raise StorageIntegrityError("host settlement state malformed")
164
+ if prior and prior.get("turnRef") == turn_ref:
165
+ if (prior.get("outcome") != outcome
166
+ or type(prior.get("turn")) is not int
167
+ or turns < prior["turn"]):
168
+ raise RecordConflict("replayed host settlement contradicts original")
169
+ return current
170
+ if prior and type(prior.get("turn")) is int and prior["turn"] > prior_turns:
171
+ raise RecordConflict("a newer distinct host turn is already settled")
172
+ ordinal = max(turns, prior_turns) + 1
173
+ settled_time = max(settled_at_ms, int(current.get("lastSettledAt") or 0))
174
+ patches = {
175
+ "stats.turns": ordinal,
176
+ "lastSettledAt": settled_time,
177
+ "lastSettledTurn": {
178
+ "turn": ordinal, "turnRef": turn_ref, "outcome": outcome,
179
+ "settledAtMs": settled_time, "context": context,
180
+ },
181
+ }
182
+ if background_task_summary is not None:
183
+ patches["backgroundTaskSummary"] = background_task_summary
184
+ if outcome in ("completed", "interrupted"):
185
+ patches["lastTurnAt"] = max(settled_time, int(current.get("lastTurnAt") or 0))
186
+ expected = {"stats.turns": turns, "lastSettledTurn": prior}
187
+ fallback = default_claude_name(target)
188
+ if proposed_title and current.get("name") == fallback:
189
+ adopted = sanitize_name(proposed_title)
190
+ if adopted and adopted != fallback:
191
+ patches["name"] = adopted
192
+ expected["name"] = fallback
193
+ try:
194
+ return store.mutate_record(target, patches=patches, expect=expected)
195
+ except RecordConflict:
196
+ continue
197
+ raise RecordConflict("host settlement changed during bounded rebase")
198
+
199
+
200
+ _HOST_TERMINAL_STATES = frozenset({S.DEAD, S.CRASHED, S.IDLE_KILLED, S.BUDGET_STOPPED})
201
+
202
+
203
+ def host_live_transition(target: str, *, new_state: str, host_pid: int) -> dict | None:
204
+ """Only this host PID may publish a nonterminal state, never over a kill."""
205
+ from ccx.store.root import RecordConflict
206
+ if new_state in _HOST_TERMINAL_STATES:
207
+ raise ValueError("terminal host state must be published after socket teardown")
208
+ store = _uuid_claude_store()
209
+ for _ in range(6):
210
+ current = store.record(target)
211
+ lifecycle = current.get("lifecycle") or {}
212
+ if (lifecycle.get("state") in _HOST_TERMINAL_STATES
213
+ or lifecycle.get("hostPid") != host_pid):
214
+ return None
215
+ try:
216
+ return store.mutate_record(
217
+ target, patches={
218
+ "lifecycle.state": new_state,
219
+ "lifecycle.since": int(time.time() * 1000),
220
+ "lifecycle.lastError": None,
221
+ "lifecycle.apiError": None,
222
+ }, expect={"lifecycle.state": lifecycle.get("state"),
223
+ "lifecycle.hostPid": host_pid},
224
+ )
225
+ except RecordConflict:
226
+ continue
227
+ raise RecordConflict("host live state changed during bounded transition")
228
+
229
+
230
+ def host_terminal_transition(
231
+ target: str, *, state: str, host_pid: int, last_error: str | None,
232
+ clear_child: bool,
233
+ ) -> dict:
234
+ """Publish shutdown for this PID only; preserve another terminal verdict."""
235
+ from ccx.store.root import RecordConflict
236
+ if state not in _HOST_TERMINAL_STATES:
237
+ raise ValueError("host final state must be terminal")
238
+ store = _uuid_claude_store()
239
+ for _ in range(6):
240
+ current = store.record(target)
241
+ lifecycle = current.get("lifecycle") or {}
242
+ current_state = lifecycle.get("state")
243
+ if lifecycle.get("hostPid") != host_pid:
244
+ return current # other/cleared host owner always wins
245
+ patches = {"lifecycle.hostPid": None, "lifecycle.hostStart": None}
246
+ if clear_child:
247
+ patches.update({"lifecycle.workerPid": None, "lifecycle.workerStart": None})
248
+ if current_state not in _HOST_TERMINAL_STATES:
249
+ patches.update({
250
+ "lifecycle.state": state,
251
+ "lifecycle.since": int(time.time() * 1000),
252
+ "lifecycle.lastError": last_error,
253
+ })
254
+ try:
255
+ return store.mutate_record(
256
+ target, patches=patches,
257
+ expect={"lifecycle.state": current_state,
258
+ "lifecycle.hostPid": host_pid},
259
+ )
260
+ except RecordConflict:
261
+ continue
262
+ raise RecordConflict("terminal host state changed during bounded shutdown")
263
+
264
+
265
+ def spawned_by_from_env() -> dict | None:
266
+ """Return attested full-UUID worker origin, or untrusted native metadata."""
267
+ from ccx.constants import SELF_ENGINE_ENV, SELF_WORKER_ID_ENV
268
+ from ccx.errors import SessionUnavailable
269
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
270
+ old = os.environ.get(SELF_SHORT_ENV, "").strip()
271
+ if old:
272
+ raise SessionUnavailable(
273
+ "stale CCX_SELF_SHORT cannot identify a full-UUID parent"
274
+ )
275
+ full = os.environ.get(SELF_WORKER_ID_ENV, "").strip()
276
+ engine = os.environ.get(SELF_ENGINE_ENV, "").strip()
277
+ if full or engine:
278
+ if engine not in ("claude", "codex"):
279
+ raise SessionUnavailable("CCX_SELF_ENGINE must be explicit")
280
+ try:
281
+ wid = canonical_worker_id(full)
282
+ except InvalidTarget as exc:
283
+ raise SessionUnavailable("CCX_SELF_WORKER_ID is not full UUIDv4") from exc
284
+ try:
285
+ store = _uuid_claude_store()
286
+ owner = store.resolve(f"{engine}:{wid}")
287
+ except Exception as exc:
288
+ from ccx.store.root import GenerationError
289
+ from ccx.store.identity import IdentityError
290
+ if isinstance(exc, (GenerationError, IdentityError, OSError)):
291
+ raise SessionUnavailable(
292
+ f"unproven full UUID caller identity/generation: {exc}"
293
+ ) from exc
294
+ raise
295
+ return {"kind": "ccx-worker", "workerId": owner.worker_id,
296
+ "engine": owner.engine}
297
+ # Native provider IDs are not managed CCX worker routing authority.
298
+ claude = os.environ.get("CLAUDE_CODE_SESSION_ID", "").strip()
299
+ codex = os.environ.get("CODEX_THREAD_ID", "").strip()
300
+ if claude and codex:
301
+ return {"kind": "provider-native-unknown",
302
+ "facts": {"claudeSessionId": claude, "codexThreadId": codex}}
303
+ if claude:
304
+ return {"kind": "provider-native-unknown", "engine": "claude",
305
+ "nativeId": claude}
306
+ if codex:
307
+ return {"kind": "provider-native-unknown", "engine": "codex",
308
+ "nativeId": codex}
309
+ return None