ccx-native 0.0.1 → 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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -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 +807 -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 +269 -0
  16. package/ccx/claude/host/session.py +5244 -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 +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,711 @@
1
+ """Settled UUID fork planning, lineage, and original-host Claude copy proof."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import subprocess
9
+ import stat
10
+ import time
11
+
12
+ from ccx.errors import InvalidRequest, OperationConflict, OperationTimeout, SessionUnavailable
13
+
14
+ MATERIALIZE_TIMEOUT_S = 180.0
15
+
16
+
17
+ def now_ms() -> int:
18
+ return int(time.time() * 1000)
19
+
20
+ def _remaining(deadline: float | None) -> float | None:
21
+ if deadline is None:
22
+ return None
23
+ left = deadline - time.monotonic()
24
+ if left <= 0:
25
+ raise OperationTimeout("fork deadline expired")
26
+ return left
27
+
28
+
29
+ # -- Claude -----------------------------------------------------------------
30
+
31
+ def _rows(path: str | os.PathLike, deadline: float | None = None, *,
32
+ messages: bool = True, before_offset: int | None = None,
33
+ stats: dict | None = None):
34
+ """Main-chain rows of a Claude transcript (only user/assistant messages
35
+ unless ``messages`` is false), checking ``deadline`` as it scans."""
36
+ from ccx.claude.transcript import _iter_transcript
37
+ for index, row in enumerate(_iter_transcript(path, stats=stats, before_offset=before_offset)):
38
+ if deadline is not None and index % 256 == 0:
39
+ _remaining(deadline)
40
+ if not messages or (row.get("type") in ("user", "assistant")
41
+ and isinstance(row.get("uuid"), str)):
42
+ yield row
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+ def _contains_fork_point(path: pathlib.Path, session_id: str, resume_at: str) -> bool:
52
+ return any(row.get("uuid") == resume_at and row.get("sessionId") == session_id
53
+ for row in _rows(path))
54
+
55
+
56
+ def _claude_copy_proof(record: dict | None) -> dict | None:
57
+ """Only a child/native/anchor-matched immutable-copy fact counts as copied.
58
+
59
+ Pure projection: the later F1b host publishes lifecycle.nativeForkProof
60
+ through existing fresh-record CAS. A short, timestamp, or copied file alone
61
+ is not authority to start or recopy a managed native session.
62
+ """
63
+ if not isinstance(record, dict):
64
+ return None
65
+ fork = record.get("forkedFrom")
66
+ anchor = record.get("forkAnchor")
67
+ proof = (record.get("lifecycle") or {}).get("nativeForkProof")
68
+ native = record.get("native")
69
+ if not all(isinstance(part, dict) for part in (fork, anchor, proof, native)):
70
+ return None
71
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
72
+ try:
73
+ worker_id = canonical_worker_id(record.get("workerId"))
74
+ source_id = canonical_worker_id(anchor.get("sourceWorkerId"))
75
+ sid = canonical_worker_id(native.get("sessionId"))
76
+ except InvalidTarget:
77
+ return None
78
+ if (record.get("engine") != "claude" or anchor.get("engine") != "claude"
79
+ or source_id == worker_id or sid == worker_id
80
+ or not isinstance(anchor.get("resumeAt"), str) or not anchor["resumeAt"]
81
+ or not isinstance(anchor.get("sourceNativeSessionId"), str)
82
+ or not anchor["sourceNativeSessionId"]
83
+ or any(fork.get(key) != anchor.get(key) for key in (
84
+ "engine", "sourceWorkerId", "sourceNativeSessionId", "resumeAt",
85
+ "forkedAtMs",
86
+ ))
87
+ or proof.get("workerId") != worker_id
88
+ or proof.get("nativeSessionId") != sid
89
+ or proof.get("forkAnchor") != anchor
90
+ or type(proof.get("copiedAtMs")) is not int
91
+ or proof["copiedAtMs"] <= 0
92
+ or type(proof.get("inheritedBytes")) is not int
93
+ or proof["inheritedBytes"] <= 0):
94
+ return None
95
+ tasks = proof.get("inheritedOpenTaskIds")
96
+ if (not isinstance(tasks, list) or len(tasks) > 1024
97
+ or any(not isinstance(x, str) or not x or len(x) > 256 for x in tasks)):
98
+ return None
99
+ return proof
100
+
101
+
102
+ def claude_fork_pending(record: dict) -> bool:
103
+ """An unproven owned Claude fork is NEVER a recopy authorization."""
104
+ return isinstance(record, dict) and record.get("engine") == "claude" and bool(
105
+ record.get("forkAnchor")
106
+ ) and _claude_copy_proof(record) is None
107
+
108
+
109
+ def ensure_claude_fork_materialized(worker_id: str, *, deadline: float | None = None) -> dict:
110
+ """Refuse unproven UUID fork history; only F1b's original host may copy.
111
+
112
+ rpc._respawn and historical creation still call this name. They must not
113
+ enter the retired short-keyed rename/quarantine/kill/recopy operation.
114
+ """
115
+ from ccx.store.records import read_record
116
+ record = read_record(worker_id)
117
+ if claude_fork_pending(record):
118
+ raise SessionUnavailable(
119
+ f"Conversation copy for fork {worker_id} is not verified; "
120
+ "keep its worker ID and inspect status before retrying",
121
+ short=worker_id,
122
+ )
123
+ return record
124
+
125
+
126
+ def _run_materializer(record: dict, fork: dict, *, deadline: float | None = None) -> None:
127
+ import signal
128
+ from ccx.constants import CLAUDE_BIN
129
+ from ccx.claude.host.session import worker_env
130
+ worker_id = record["workerId"]
131
+ session_id = record["native"]["sessionId"]
132
+ budget = MATERIALIZE_TIMEOUT_S
133
+ if deadline is not None:
134
+ budget = min(budget, deadline - time.time())
135
+ if budget <= 0:
136
+ raise OperationTimeout(f"deadline expired before Claude fork {worker_id} was copied",
137
+ short=worker_id)
138
+ argv = [
139
+ CLAUDE_BIN, "-p",
140
+ "--session-id", session_id, "--fork-session",
141
+ "--resume", fork.get("transcript") or fork["sourceNativeSessionId"],
142
+ "--resume-session-at", fork["resumeAt"],
143
+ "--output-format", "json",
144
+ # A copy, not a session start: no MCP servers and no user hooks.
145
+ "--strict-mcp-config", "--settings", json.dumps({"disableAllHooks": True}),
146
+ # A local command: appends without a model call and sets the title.
147
+ "/rename " + record.get("name", f"ccx-{worker_id[:8]}"),
148
+ ]
149
+ try:
150
+ child = subprocess.Popen(
151
+ argv, cwd=fork["cwd"], env=worker_env(worker_id=worker_id),
152
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
153
+ text=True, start_new_session=True,
154
+ )
155
+ except OSError as error:
156
+ raise SessionUnavailable(f"Claude fork copy failed for {worker_id}: {error}",
157
+ short=worker_id) from error
158
+ try:
159
+ stdout, stderr = child.communicate(timeout=budget)
160
+ except subprocess.TimeoutExpired:
161
+ with contextlib.suppress(OSError):
162
+ os.killpg(child.pid, signal.SIGKILL)
163
+ child.communicate()
164
+ raise OperationTimeout(f"Claude fork copy for {worker_id} did not finish in time",
165
+ short=worker_id) from None
166
+ result = None
167
+ for line in reversed(stdout.strip().splitlines()):
168
+ with contextlib.suppress(ValueError):
169
+ result = json.loads(line)
170
+ break
171
+ if (
172
+ child.returncode != 0 or not isinstance(result, dict)
173
+ or result.get("is_error") or result.get("type") != "result"
174
+ or result.get("session_id") != session_id
175
+ or result.get("num_turns") != 0
176
+ or result.get("local_command") != "rename"
177
+ ):
178
+ detail = (stderr or stdout or "").strip()[-400:]
179
+ raise SessionUnavailable(f"Claude fork copy failed for {worker_id} "
180
+ f"(rc={child.returncode}): {detail}", short=worker_id)
181
+
182
+
183
+ def copy_original_claude_fork(store, record: dict, *, submission_id: str,
184
+ host_pid: int, host_start: str) -> dict:
185
+ """Only the submitted original host may admit ONE copy, after socket.listen.
186
+
187
+ This record CAS is copy submission admission, not a new native-create
188
+ mechanism. A crash anywhere after it is indeterminate, never a recopy.
189
+ """
190
+ from ccx.claude.transcript import extract_background_tasks
191
+ from ccx.store.root import CreationIndeterminate
192
+ from ccx.store.store import fingerprint_v2
193
+
194
+ worker_id = record["workerId"]
195
+ current = store.record(f"claude:{worker_id}")
196
+ anchor = current.get("forkAnchor")
197
+ native = current.get("native") or {}
198
+ lifecycle = current.get("lifecycle") or {}
199
+ if (not isinstance(anchor, dict) or anchor != record.get("forkAnchor")
200
+ or current.get("forkedFrom") != record.get("forkedFrom")
201
+ or native.get("sessionId") != (record.get("native") or {}).get("sessionId")
202
+ or not claude_fork_pending(current)
203
+ or lifecycle.get("nativeForkCopyAttempt") is not None
204
+ or lifecycle.get("nativeForkProof") is not None
205
+ or lifecycle.get("hostPid") != host_pid
206
+ or str(lifecycle.get("hostStart")) != str(host_start)):
207
+ raise CreationIndeterminate(f"Claude fork {worker_id} copy admission unproven; never recopy")
208
+ with store.lease():
209
+ owner, claim = store._owner(worker_id), store._claim(worker_id)
210
+ if (not isinstance(owner, dict) or not isinstance(claim, dict)
211
+ or (owner.get("createIntent") or {}).get("forkAnchor") != anchor
212
+ or claim.get("forkAnchor") != anchor
213
+ or claim.get("nativeBinding") != {"sessionId": native["sessionId"]}
214
+ or claim.get("createSha256") != fingerprint_v2(
215
+ engine="claude", cwd=anchor["cwd"],
216
+ create_args=anchor["createArgs"], fork_anchor=anchor)):
217
+ raise CreationIndeterminate(f"Claude fork {worker_id} original anchor/claim differs")
218
+ activation = store.native_activation(f"claude:{worker_id}")
219
+ if (activation.get("state") != "submitted-unknown"
220
+ or activation.get("source") != f"claude-session:{native['sessionId']}"
221
+ or activation.get("submissionId") != submission_id
222
+ or not isinstance(submission_id, str) or not submission_id):
223
+ raise CreationIndeterminate(f"Claude fork {worker_id} lacks original submitted host")
224
+
225
+ child_path = pathlib.Path(current["transcript"])
226
+ from ccx.store.paths import transcript_path_for
227
+ if (current["transcript"] != str(transcript_path_for(anchor["cwd"], native["sessionId"]))
228
+ or child_path.exists() or child_path.is_symlink()):
229
+ raise CreationIndeterminate(
230
+ f"Claude fork {worker_id} already has unproven native bytes; never adopt or recopy")
231
+ store.mutate_record(
232
+ f"claude:{worker_id}",
233
+ patches={"lifecycle.nativeForkCopyAttempt": submission_id},
234
+ expect={"lifecycle.nativeForkCopyAttempt": None,
235
+ "lifecycle.nativeForkProof": None,
236
+ "lifecycle.hostPid": host_pid,
237
+ "lifecycle.hostStart": host_start},
238
+ )
239
+ _run_materializer(current, anchor)
240
+ try:
241
+ st = child_path.lstat()
242
+ if not stat.S_ISREG(st.st_mode) or st.st_size == 0:
243
+ raise ValueError("Forked Claude conversation is missing; inspect the worker")
244
+ with child_path.open("rb") as stream:
245
+ stream.seek(-1, os.SEEK_END)
246
+ if stream.read(1) != b"\n":
247
+ raise ValueError("incomplete child native JSONL")
248
+ malformed = {}
249
+ found = False
250
+ for row in _rows(child_path, messages=False, stats=malformed):
251
+ if row.get("type") in ("user", "assistant") and row.get("uuid"):
252
+ if row.get("sessionId") != native["sessionId"]:
253
+ raise ValueError("foreign child native SID in copied history")
254
+ if row["uuid"] == anchor["resumeAt"]:
255
+ found = True
256
+ if malformed.get("malformedLines") or not found:
257
+ raise ValueError("child transcript lacks complete exact original fork message")
258
+ tasks = sorted({task["task_id"] for task in extract_background_tasks(child_path)
259
+ if task.get("status") in ("running", "unknown")
260
+ and task.get("task_id")})
261
+ if (len(tasks) > 1024 or any(not isinstance(task, str) or len(task) > 256
262
+ for task in tasks)):
263
+ raise ValueError("inherited open task identifiers exceed proof bound")
264
+ if child_path.stat().st_size != st.st_size:
265
+ raise ValueError("Forked Claude conversation changed during copy; retry the fork")
266
+ except (OSError, ValueError, TypeError, KeyError) as exc:
267
+ raise SessionUnavailable(
268
+ f"Claude fork {worker_id} has no exact copied child history: {exc}",
269
+ short=worker_id,
270
+ ) from exc
271
+
272
+ proof = {"workerId": worker_id, "nativeSessionId": native["sessionId"],
273
+ "forkAnchor": anchor, "copiedAtMs": now_ms(),
274
+ "inheritedBytes": st.st_size, "inheritedOpenTaskIds": tasks}
275
+ committed = store.mutate_record(
276
+ f"claude:{worker_id}",
277
+ patches={"lifecycle.nativeForkProof": proof},
278
+ expect={"lifecycle.nativeForkProof": None,
279
+ "lifecycle.nativeForkCopyAttempt": submission_id,
280
+ "lifecycle.hostPid": host_pid,
281
+ "lifecycle.hostStart": host_start},
282
+ )
283
+ if _claude_copy_proof(committed) != proof:
284
+ raise CreationIndeterminate(f"Claude fork {worker_id} copy proof was not authenticated")
285
+ return committed
286
+
287
+
288
+ def inherited_task_filter(record: dict | None, tasks: list[dict]) -> list[dict]:
289
+ """Source-owned open tasks do not become the child's live work."""
290
+ proof = _claude_copy_proof(record)
291
+ inherited = set(proof["inheritedOpenTaskIds"]) if proof else set()
292
+ return [task for task in tasks if task.get("task_id") not in inherited] if inherited else tasks
293
+
294
+
295
+ def inherited_offset(record: dict | None) -> int:
296
+ """Token/usage accounting starts after independently proven native copy."""
297
+ proof = _claude_copy_proof(record)
298
+ return proof["inheritedBytes"] if proof else 0
299
+
300
+
301
+ # -- source and original child plan -----------------------------------------
302
+
303
+
304
+ def _conflict(record: dict, reason: str, text: str) -> OperationConflict:
305
+ return OperationConflict(text, short=record.get("short"), reason=reason,
306
+ worker_id=record.get("workerId"))
307
+
308
+
309
+ def _settled(record: dict) -> None:
310
+ """Refuse an observable turn, input, queue, or compact before choosing R."""
311
+ lifecycle = record.get("lifecycle") or {}
312
+ state = lifecycle.get("state")
313
+ if (state == "waiting" or record.get("waitingFor")
314
+ or record.get("pendingApprovals")):
315
+ raise _conflict(record, "source_waiting", "source has unresolved native input")
316
+ if (state not in {"idle", "idle-killed", "crashed", "dead", "budget-stopped"}
317
+ or lifecycle.get("activeTurnId") or record.get("activeCompact")
318
+ or record.get("pendingSend")):
319
+ raise _conflict(record, "source_busy", "source turn, queue or compact not settled")
320
+ if record.get("terminalOnly"):
321
+ raise SessionUnavailable("Source worker has no verified conversation; inspect it before forking")
322
+
323
+
324
+ def _no_pending_effects(owner, record: dict) -> None:
325
+ """Existing original semantic receipts/compact journal, no new tracker."""
326
+ if owner.engine == "claude":
327
+ from ccx.claude.delivery import iter_receipts
328
+ from ccx.ops.compact_ops import PENDING_STATUSES, read_journal
329
+ if any(op["status"] in PENDING_STATUSES for op in
330
+ read_journal(owner.worker)["operations"]):
331
+ raise _conflict(record, "source_busy", "source has native compact pending")
332
+ else:
333
+ from ccx.codex.delivery import iter_receipts
334
+ # An accepted receipt's settlement is optional observation metadata (steer
335
+ # and unobserved async sends never get one); the native settled point
336
+ # below is the authority for accepted work, so only admission refuses.
337
+ if any(row["state"] in {"prepared", "owned", "submitted"}
338
+ for row in iter_receipts(owner.worker)):
339
+ raise _conflict(record, "source_busy", "source has a pending original send")
340
+
341
+
342
+ def _claude_point(store, owner, record: dict, deadline: float | None) -> dict:
343
+ """Pin accepted original native file; terminal JSONL row is the fork point."""
344
+ from ccx.claude.transcript import open_original_claude_source
345
+ from ccx.claude.managed_turns import turn_settlements
346
+ with open_original_claude_source(store, owner, record) as source:
347
+ if source is None:
348
+ raise InvalidRequest("Source Claude worker has no conversation to fork")
349
+ pinned, before, complete_end, native_path, _dir_fd = source
350
+ if not complete_end or complete_end != before.st_size:
351
+ raise _conflict(record, "source_busy", "source native transcript is incomplete")
352
+ malformed = {}
353
+ last = None
354
+ sid = record["native"]["sessionId"]
355
+ for row in _rows(pinned, deadline, before_offset=complete_end, stats=malformed):
356
+ if row.get("sessionId") != sid:
357
+ raise SessionUnavailable("Claude original native transcript has a foreign session")
358
+ last = row
359
+ if malformed.get("malformedLines"):
360
+ raise SessionUnavailable("Claude history is unreadable; inspect the source worker")
361
+ if last is None:
362
+ raise InvalidRequest("source has no Claude conversation to fork")
363
+ if _turn_open(last):
364
+ raise _conflict(
365
+ record, "source_busy",
366
+ "Source worker has an unfinished turn; wait for it to end or "
367
+ "stop the worker before forking",
368
+ )
369
+ now = os.fstat(pinned.fd)
370
+ if (now.st_size, now.st_mtime_ns) != (before.st_size, before.st_mtime_ns):
371
+ raise _conflict(record, "source_busy", "source native transcript changed during fork planning")
372
+ terminals = turn_settlements(owner.worker)
373
+ if terminals:
374
+ latest = terminals[-1]
375
+ count = (record.get("stats") or {}).get("turns")
376
+ if (type(count) is int and latest["turn"] < count
377
+ or (type(record.get("lastSettledAt")) is int
378
+ and latest["settledAtMs"] < record["lastSettledAt"])):
379
+ raise _conflict(record, "source_busy", "Claude original turn settlement is stale")
380
+ return {
381
+ "sourceNativeSessionId": sid, "resumeAt": last["uuid"],
382
+ "transcript": str(native_path),
383
+ }
384
+
385
+
386
+ def _turn_open(row: dict) -> bool:
387
+ """An incomplete assistant/tool continuation is not a settled fork point."""
388
+ if row.get("type") == "assistant":
389
+ return (row.get("message") or {}).get("stop_reason") not in {
390
+ "end_turn", "stop_sequence", "max_tokens", "refusal",
391
+ }
392
+ if row.get("isMeta"):
393
+ return False
394
+ content = (row.get("message") or {}).get("content")
395
+ if isinstance(content, list):
396
+ content = " ".join(x.get("text") or "" for x in content if isinstance(x, dict))
397
+ return not isinstance(content, str) or not content.lstrip().startswith((
398
+ "[Request interrupted by user", "<command-name>", "<local-command-",
399
+ "<command-message>",
400
+ ))
401
+
402
+
403
+ def _codex_point(store, owner, record: dict, deadline: float | None) -> dict:
404
+ """Read the exact owned native thread without starting a model turn."""
405
+ from ccx.codex.runtime import read_thread
406
+ native = record.get("native") or {}
407
+ tid = native.get("threadId")
408
+ source = store.codex_source(owner.worker)
409
+ if (native.get("nativeState") != "thread-bound"
410
+ or native.get("threadSource") != source
411
+ or not isinstance(tid, str) or not tid):
412
+ raise SessionUnavailable("Codex source conversation cannot be verified; inspect it before forking")
413
+ thread = read_thread(
414
+ tid, cwd=record["cwd"], worker_id=owner.worker_id,
415
+ expected_source=source, limit=1, newest_first=True,
416
+ timeout=min(30., _remaining(deadline)) if deadline is not None else 30.,
417
+ )
418
+ if (thread.get("id") != tid
419
+ or thread.get("threadSource") != source):
420
+ raise SessionUnavailable("Codex source conversation changed; inspect it before forking")
421
+ turns = thread.get("turns")
422
+ native_state = thread.get("status")
423
+ if isinstance(native_state, dict):
424
+ native_state = native_state.get("type")
425
+ if native_state not in (None, "idle"):
426
+ raise _conflict(record, "source_busy", "Codex original native thread is not idle")
427
+ if not isinstance(turns, list) or not turns:
428
+ raise InvalidRequest("Source Codex worker has no turn to fork")
429
+ last = turns[-1]
430
+ if (not isinstance(last, dict) or not isinstance(last.get("id"), str)
431
+ or not last["id"] or last.get("status") != "completed"):
432
+ raise _conflict(record, "source_busy", "last native Codex turn is not completed")
433
+ recorded = record.get("lastTurn") or {}
434
+ if recorded.get("turnId") not in (None, last["id"]):
435
+ raise _conflict(record, "source_busy", "Codex source record/last native turn disagree")
436
+ return {"sourceNativeThreadId": tid, "lastTurnId": last["id"]}
437
+
438
+
439
+ def _launch(engine: str, args: dict) -> dict:
440
+ """The original normalized creation arguments, preserved for retry."""
441
+ if engine == "claude":
442
+ return {
443
+ "model": args.get("model"), "effort": args.get("effort"),
444
+ "agent": args.get("agent"),
445
+ "append_system_prompt": args.get("append_system_prompt"),
446
+ "allowed_tools": args.get("allowed_tools"),
447
+ "disallowed_tools": args.get("disallowed_tools"),
448
+ "add_dir": args.get("add_dir") or None,
449
+ "permission_mode": args.get("permission_mode"),
450
+ "permissions": "full" if args.get("skip_permissions") is True else "restricted",
451
+ "idle_timeout": args.get("idle_timeout_s"),
452
+ }
453
+ return {
454
+ "model": args.get("model"), "effort": args.get("effort"),
455
+ "sandbox": args.get("sandbox"), "approval_policy": args.get("approvalPolicy"),
456
+ "idle_timeout": args.get("idleTimeout"),
457
+ }
458
+
459
+
460
+ def _retry(store, target, child_id: str, overrides: dict) -> dict | None:
461
+ """Inspect only existing immutable owner/claim/record; NEVER reread source."""
462
+ from ccx.store.root import CreationIndeterminate, CreationConflict
463
+ from ccx.store.store import fingerprint_v2
464
+ from ccx.store.identity import parse_managed_target
465
+ source = parse_managed_target(target)
466
+ with store.lease():
467
+ owner = store._owner(child_id)
468
+ if owner is None:
469
+ return None
470
+ if not isinstance(owner, dict):
471
+ raise CreationIndeterminate(f"fork {child_id} original owner is unreadable")
472
+ intent = owner.get("createIntent")
473
+ if not isinstance(intent, dict):
474
+ raise CreationIndeterminate(f"fork {child_id} original immutable intent unavailable")
475
+ anchor = intent.get("forkAnchor")
476
+ if (not isinstance(anchor, dict)
477
+ or anchor.get("sourceWorkerId") != source.worker_id
478
+ or (source.qualified_engine is not None
479
+ and source.qualified_engine != anchor.get("engine"))
480
+ or anchor.get("overrides") != overrides):
481
+ raise OperationConflict(
482
+ f"worker_id {child_id} belongs to another original creation/fork",
483
+ worker_id=child_id, reason="same_worker_id_create_spec_mismatch",
484
+ )
485
+ engine = anchor.get("engine")
486
+ original_args = anchor.get("createArgs")
487
+ cwd = anchor.get("cwd")
488
+ claim = store._claim(child_id)
489
+ record = store._record(engine, child_id) if engine in {"claude", "codex"} else None
490
+ binding = claim.get("nativeBinding") if isinstance(claim, dict) else None
491
+ if (owner.get("status") not in {"worker", "indeterminate-reserved"}
492
+ or not isinstance(binding, dict)
493
+ or not isinstance(claim, dict) or not isinstance(record, dict)
494
+ or not isinstance(original_args, dict) or not isinstance(cwd, str)
495
+ or record.get("forkAnchor") != anchor
496
+ or record.get("createSha256") != claim.get("createSha256")
497
+ or record.get("fingerprintVersion") != 2
498
+ or record.get("workerId") != child_id or record.get("engine") != engine
499
+ or record.get("cwd") != cwd
500
+ or not isinstance(record.get("native"), dict)
501
+ or any(record["native"].get(k) != v for k, v in
502
+ binding.items())):
503
+ raise CreationIndeterminate(
504
+ f"fork {child_id} lacks original immutable child record proof"
505
+ )
506
+ lineage = record.get("forkedFrom")
507
+ lineage_keys = ("engine", "sourceWorkerId", "forkedAtMs") + (
508
+ ("sourceNativeSessionId", "resumeAt") if engine == "claude"
509
+ else ("sourceNativeThreadId", "lastTurnId")
510
+ )
511
+ if not isinstance(lineage, dict) or any(
512
+ lineage.get(key) != anchor.get(key) for key in lineage_keys
513
+ ):
514
+ raise CreationIndeterminate(
515
+ f"fork {child_id} first-record native point differs from original claim"
516
+ )
517
+ if (claim.get("forkAnchor") != anchor
518
+ or claim.get("workerId") != child_id
519
+ or claim.get("engine") != engine
520
+ or claim.get("generation") != store.generation
521
+ or claim.get("fingerprintVersion") != 2
522
+ or not isinstance(claim.get("createSha256"), str)
523
+ or fingerprint_v2(engine=engine, cwd=cwd, create_args=original_args,
524
+ fork_anchor=anchor) != claim["createSha256"]):
525
+ raise CreationIndeterminate(
526
+ f"fork {child_id} immutable first-create fingerprint changed"
527
+ )
528
+ try:
529
+ store._check_original_create_intent(
530
+ owner, engine=engine, worker_id=child_id,
531
+ fingerprint=claim["createSha256"],
532
+ native=claim["nativeBinding"], anchor=anchor,
533
+ )
534
+ except CreationConflict as exc:
535
+ raise CreationIndeterminate(
536
+ f"fork {child_id} has contradictory original identity"
537
+ ) from exc
538
+ return {
539
+ "engine": engine, "source": None, "cwd": cwd,
540
+ "fork_anchor": anchor, "launch": _launch(engine, original_args),
541
+ "name": record.get("name"), "seed": anchor.get("seed") or {}, "existing": True,
542
+ }
543
+
544
+
545
+ def plan(target: str, *, worker_id: str | None = None,
546
+ model: str | None = None, effort: str | None = None,
547
+ deadline: float | None = None,
548
+ expected_generation: str | None = None,
549
+ require_binary: bool = False) -> dict:
550
+ """Prepare one frozen native fork point, without child creation or effects.
551
+
552
+ A supplied child UUID is checked FIRST: if it exists, only its original
553
+ full-ID owner/claim/record and frozen anchor can define a retry.
554
+ """
555
+ from ccx import constants
556
+ from ccx.store.root import require_ccx_active
557
+ from ccx.store.identity import canonical_worker_id
558
+ from ccx.ops.creation_steps import validated_create_spec
559
+ store = require_ccx_active(
560
+ constants.CCX_ROOT, expected_generation=expected_generation,
561
+ )
562
+ for value in (model, effort):
563
+ if value is not None and (not isinstance(value, str) or not value.strip()):
564
+ raise InvalidRequest("fork model/effort overrides must be nonempty strings")
565
+ overrides = {"model": model, "effort": effort}
566
+ if worker_id is not None:
567
+ child_id = canonical_worker_id(worker_id)
568
+ previous = _retry(store, target, child_id, overrides)
569
+ if previous is not None:
570
+ store.revalidate()
571
+ return previous
572
+ _remaining(deadline)
573
+ owner = store.resolve(target)
574
+ if require_binary:
575
+ # After child-first conflict checks, but before any Codex native read.
576
+ from ccx.system.engine_binary import resolve_engine_binary
577
+ resolve_engine_binary(owner.engine)
578
+ record = store.record(owner.worker)
579
+ _settled(record)
580
+ store.authorize_native_action(owner.worker, "send") # original claim, no provider call
581
+ _no_pending_effects(owner, record)
582
+ if owner.engine == "claude":
583
+ point = _claude_point(store, owner, record, deadline)
584
+ else:
585
+ if record.get("historyKnownEmpty") is True:
586
+ raise InvalidRequest(
587
+ "cannot fork: source Codex thread has no completed conversation "
588
+ "turn; send its first prompt and retry"
589
+ )
590
+ point = _codex_point(store, owner, record, deadline)
591
+ _remaining(deadline)
592
+ # Fresh re-read refuses a source that acquired an in-flight turn meanwhile;
593
+ # native point itself is immutable, even if later activity starts.
594
+ current = store.record(owner.worker)
595
+ _settled(current)
596
+ _no_pending_effects(owner, current)
597
+ if (current.get("recordRevision") != record.get("recordRevision")
598
+ or current.get("native") != record.get("native")):
599
+ raise _conflict(record, "source_busy", "source changed while planning its fork")
600
+ original = record.get("createArgs")
601
+ if not isinstance(original, dict):
602
+ raise SessionUnavailable("Source worker settings are missing; inspect it before forking")
603
+ launch = _launch(owner.engine, original)
604
+ launch.update({key: value for key, value in overrides.items() if value is not None})
605
+ cwd, normalized = validated_create_spec(
606
+ engine=owner.engine, cwd=record["cwd"],
607
+ model=launch["model"], effort=launch["effort"],
608
+ idle_timeout=launch["idle_timeout"],
609
+ permissions=launch.get("permissions"),
610
+ sandbox=launch.get("sandbox"), approval_policy=launch.get("approval_policy"),
611
+ agent=launch.get("agent"), append_system_prompt=launch.get("append_system_prompt"),
612
+ allowed_tools=launch.get("allowed_tools"),
613
+ disallowed_tools=launch.get("disallowed_tools"),
614
+ add_dir=launch.get("add_dir"), permission_mode=launch.get("permission_mode"),
615
+ )
616
+ # Only settled/observed source metadata may seed the child's ordinal;
617
+ # subsequent source reconfiguration cannot change this original snapshot.
618
+ stats = record.get("stats") or {}
619
+ turns = stats.get("turns")
620
+ seed = {"stats": {"turns": turns if type(turns) is int and turns >= 0 else 0},
621
+ "lastTurnAt": record.get("lastTurnAt")}
622
+ if owner.engine == "codex":
623
+ last = record.get("lastTurn") or {}
624
+ if last.get("turnId") == point["lastTurnId"]:
625
+ seed["lastTurn"] = dict(last)
626
+ anchor = {
627
+ "engine": owner.engine, "sourceWorkerId": owner.worker_id,
628
+ **point, "cwd": cwd, "createArgs": normalized, "seed": seed,
629
+ "overrides": overrides, "forkedAtMs": now_ms(),
630
+ }
631
+ store.revalidate()
632
+ return {
633
+ "engine": owner.engine, "source": record, "cwd": cwd,
634
+ "fork_anchor": anchor, "launch": _launch(owner.engine, normalized),
635
+ "name": f"{record.get('name') or owner.short} (fork)",
636
+ "seed": seed, "existing": False,
637
+ }
638
+
639
+
640
+ def fork(
641
+ target: str, *, worker_id: str | None = None,
642
+ name: str | None = None, model: str | None = None,
643
+ effort: str | None = None, created_via: str = "local-json-v1",
644
+ prompt: str | None = None,
645
+ caps: dict | None = None, wait: bool = False,
646
+ attachment=None, delivery_id: str | None = None,
647
+ parent: dict | None = None, settle: bool = False,
648
+ deadline: float | None = None, expected_generation: str | None = None,
649
+ start_timeout: float | None = None,
650
+ ) -> dict:
651
+ """Original-native fork with optional first turn through creation.create.
652
+
653
+ A caller-known child is checked by the planner against its original intent
654
+ before any source observation. Native replay belongs solely to creation.
655
+ """
656
+ from ccx.ops.creation import create
657
+
658
+ _remaining(deadline)
659
+ planned = plan(
660
+ target, worker_id=worker_id, model=model, effort=effort,
661
+ deadline=deadline, expected_generation=expected_generation,
662
+ require_binary=True,
663
+ )
664
+ # Only an explicit fork --model override requires a new live provider
665
+ # validation. An inherited model is frozen in the original fork spec.
666
+ if planned["engine"] == "codex" and model is not None:
667
+ from ccx.codex.runtime import validate_model
668
+ validate_model(model, cwd=planned["cwd"])
669
+ remaining = _remaining(deadline)
670
+ native_budget = 90.0 if start_timeout is None else start_timeout
671
+ if remaining is not None:
672
+ native_budget = min(native_budget, remaining)
673
+ result = create(
674
+ engine=planned["engine"], cwd=planned["cwd"],
675
+ worker_id=worker_id, name=name if name is not None else planned["name"],
676
+ _fork_anchor=planned["fork_anchor"], **planned["launch"],
677
+ created_via=created_via, start_timeout=native_budget,
678
+ expected_generation=expected_generation, deadline=deadline,
679
+ prompt=prompt, caps=caps, wait=wait,
680
+ attachment=attachment, delivery_id=delivery_id,
681
+ parent=parent, settle=settle,
682
+ )
683
+ return {**result, "forked_from": public_lineage(result["record"])}
684
+
685
+
686
+ def public_lineage(record: dict | None) -> dict | None:
687
+ """Original full-UUID source handle; native session/thread IDs stay separate."""
688
+ if not isinstance(record, dict):
689
+ return None
690
+ fork = record.get("forkedFrom")
691
+ if not isinstance(fork, dict):
692
+ return None
693
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
694
+ engine = fork.get("engine")
695
+ try:
696
+ source_id = canonical_worker_id(fork.get("sourceWorkerId"))
697
+ except InvalidTarget:
698
+ source_id = None
699
+ if engine not in {"claude", "codex"}:
700
+ return None
701
+ return {
702
+ "worker": f"{engine}:{source_id}" if source_id else None,
703
+ "engine": engine, "short": source_id[:8] if source_id else None,
704
+ "worker_id": source_id,
705
+ "session_id": fork.get("sourceNativeSessionId") if engine == "claude" else None,
706
+ "thread_id": fork.get("sourceNativeThreadId") if engine == "codex" else None,
707
+ "forked_at_ms": fork.get("forkedAtMs"),
708
+ **({"source_kind": "codex-thread"}
709
+ if engine == "codex" and fork.get("sourceKind") == "codex-thread"
710
+ and fork.get("sourceWorkerId") is None else {}),
711
+ }