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
File without changes
@@ -0,0 +1,115 @@
1
+ """A small engine-neutral recorded snapshot of background work.
2
+
3
+ Producers own these facts; consumers only read the saved summary/event.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import time
9
+
10
+ KINDS = ("command", "monitor", "subagent", "notification")
11
+ OPEN = {"running", "pending"}
12
+ LABEL_LIMIT = 120
13
+ EVENT_LIMIT = 1000
14
+ ITEM_LIMIT = 100
15
+
16
+
17
+ def label(value: object, fallback: str) -> str:
18
+ text = re.sub(r"\s+", " ", str(value)) if isinstance(value, str) else ""
19
+ text = "".join(ch for ch in text if ch.isprintable()).strip()
20
+ return text[:LABEL_LIMIT] or fallback
21
+
22
+
23
+ def bounded_event(value: object) -> str | None:
24
+ """Bound one sanitized Monitor notification; labels remain much shorter."""
25
+ if not isinstance(value, str):
26
+ return None
27
+ return re.sub(r"\s+", " ", value).strip()[:EVENT_LIMIT]
28
+
29
+
30
+
31
+ def summary(items: list[dict], *, as_of_ms: int | None = None,
32
+ available: bool = True) -> dict:
33
+ if as_of_ms is None:
34
+ as_of_ms = int(time.time() * 1000)
35
+ if not available:
36
+ return {
37
+ "running_count": None, "pending_count": None, "by_kind": None,
38
+ "items": [], "items_truncated": 0,
39
+ "as_of_ms": as_of_ms, "available": False,
40
+ }
41
+ active = [dict(item) for item in items
42
+ if item.get("status") in OPEN and item.get("kind") in KINDS]
43
+ by_kind = {kind: sum(item["kind"] == kind for item in active) for kind in KINDS}
44
+ # Sort once, before applying the display bound. Unknown start times are
45
+ # after known starts, then every item is ordered by stable ID.
46
+ active.sort(key=lambda item: (
47
+ item.get("started_at_ms") if type(item.get("started_at_ms")) is int
48
+ else float("inf"), item["id"]))
49
+ # Keep snapshots small and deterministic; avoid leaking native content.
50
+ preview = [{key: (bounded_event(item[key]) if key == "event" else item[key])
51
+ for key in ("id", "kind", "status", "label", "event", "event_count",
52
+ "expires_at_ms", "notification_id", "delivery_id")
53
+ if key in item} for item in active[:ITEM_LIMIT]]
54
+ return {
55
+ "running_count": sum(by_kind[kind] for kind in KINDS if kind != "notification"),
56
+ "pending_count": by_kind["notification"],
57
+ "by_kind": by_kind, "items": preview,
58
+ "items_truncated": max(0, len(active) - len(preview)),
59
+ "as_of_ms": as_of_ms, "available": True,
60
+ }
61
+
62
+
63
+ def materially_changed(previous: dict | None, current: dict) -> bool:
64
+ if previous is None:
65
+ return True
66
+ return {key: value for key, value in previous.items() if key != "as_of_ms"} != {
67
+ key: value for key, value in current.items() if key != "as_of_ms"}
68
+
69
+
70
+ def claude_task_item(task: dict) -> dict | None:
71
+ task_id = task.get("task_id")
72
+ if not isinstance(task_id, str) or not task_id:
73
+ return None # launching tool-result has not yet proved native task identity
74
+ native_kind = task.get("kind")
75
+ kind = "command" if native_kind == "bash" else native_kind
76
+ if kind not in ("command", "monitor", "subagent"):
77
+ return None
78
+ item = {
79
+ "id": f"{kind}:{task_id}", "kind": kind,
80
+ "status": task.get("status") or "unknown",
81
+ "label": label(task.get("description"), f"background {kind}"),
82
+ "tool_use_id": task.get("tool_use_id"),
83
+ "started_at_ms": task.get("launched_at"),
84
+ }
85
+ if kind == "monitor":
86
+ launched, timeout = task.get("launched_at"), task.get("timeout_ms")
87
+ item["expires_at_ms"] = (launched + timeout if type(launched) is int
88
+ and type(timeout) is int and timeout > 0 else None)
89
+ item["event_count"] = task.get("event_count") or 0
90
+ if task.get("last_event"):
91
+ item["event"] = bounded_event(task["last_event"])
92
+ if task.get("exit_code") is not None:
93
+ item["exit_code"] = task["exit_code"]
94
+ return item
95
+
96
+
97
+ def diff_items(before: list[dict], after: list[dict]) -> list[tuple[str, dict]]:
98
+ """One source-row transition, never a second invented native observation."""
99
+ old = {item["id"]: item for item in before}
100
+ out = []
101
+ for item in after:
102
+ prior = old.get(item["id"])
103
+ if item == prior:
104
+ continue
105
+ status = item.get("status")
106
+ if prior is None or (prior.get("status") not in OPEN and status in OPEN):
107
+ transition = "reopened" if prior is not None else "started"
108
+ elif status not in OPEN and (prior is None or prior.get("status") in OPEN):
109
+ transition = {"completed": "finished", "failed": "failed",
110
+ "stopped": "stopped"}.get(status, "event")
111
+ else:
112
+ transition = "event"
113
+ out.append((transition, item))
114
+ return out
115
+
@@ -0,0 +1,296 @@
1
+ from __future__ import annotations
2
+ import time
3
+ from typing import Any
4
+ from ccx.errors import (
5
+ DeliveryRejected,
6
+ InvalidRequest,
7
+ OperationConflict,
8
+ SessionUnavailable,
9
+ )
10
+ from ccx.ops.common import split_target
11
+ from ccx.ops.wait import reconcile_turn
12
+
13
+
14
+ def _cancel_live_turn(handle: str, engine: str, worker_id: str,
15
+ *, timeout: float = 0.5) -> tuple[bool, str | None]:
16
+ """Authenticated exact active turn, or UNKNOWN (never inferred from absence)."""
17
+ if engine == "claude":
18
+ from ccx.rpc.rpc import _rpc
19
+ reply = _rpc(handle, {"op": "status"}, timeout=timeout)
20
+ if reply.get("ok") is True and "activeTurnRef" in reply:
21
+ return True, reply["activeTurnRef"]
22
+ return False, None
23
+ from ccx.codex.runtime import daemon_rpc
24
+ from ccx.constants import CODEXD_RPC_GENERATION
25
+ reply = daemon_rpc({"op": "status", "short": worker_id,
26
+ "rpcGeneration": CODEXD_RPC_GENERATION}, timeout=timeout)
27
+ if (reply.get("ok") is True and reply.get("loaded") is True
28
+ and "activeTurnId" in reply):
29
+ return True, ("ccx-turn:codex:v1:" + reply["activeTurnId"]
30
+ if isinstance(reply["activeTurnId"], str) else None)
31
+ return False, None
32
+
33
+
34
+ def cancel_turn(
35
+ target: str, *, cancellation_id: str, turn_ref: str,
36
+ timeout: float | None = 90,
37
+ expected_generation: str | None = None,
38
+ ) -> dict[str, Any]:
39
+ """Request one exact full-UUID turn interrupt, never replay its native write.
40
+
41
+ protocol cancellation_id is caller-owned. The existing GenerationStore 'cancel'
42
+ intent is the only ledger, independent of legacy short-keyed turn_cancel.
43
+ A native ACK means requested, not that model activity stopped.
44
+ """
45
+ from math import isfinite
46
+ from ccx import constants
47
+ from ccx.store.root import NativeEffectIndeterminate, require_ccx_active
48
+ from ccx.store.store import _digest
49
+ from ccx.store.identity import canonical_worker_id
50
+ from ccx.codex.delivery import receipt_for_turn as codex_receipt_for_turn
51
+
52
+ if (not isinstance(cancellation_id, str) or not cancellation_id
53
+ or len(cancellation_id) > 128 or "\x00" in cancellation_id):
54
+ raise InvalidRequest("cancellation_id must be nonempty and <=128 characters")
55
+ if (timeout is not None and (type(timeout) not in (int, float)
56
+ or not isfinite(timeout) or timeout <= 0)):
57
+ raise InvalidRequest("cancel timeout must be positive finite seconds")
58
+ handle, engine, worker_id = split_target(target)
59
+ canonical_worker_id(worker_id)
60
+ prefix = f"ccx-turn:{engine}:v1:"
61
+ if not isinstance(turn_ref, str) or not turn_ref.startswith(prefix) or not turn_ref[len(prefix):]:
62
+ raise InvalidRequest("turn_ref must name an exact managed turn for the selected engine")
63
+ native_turn_id = turn_ref[len(prefix):] if engine == "codex" else None
64
+ store = require_ccx_active(constants.CCX_ROOT,
65
+ expected_generation=expected_generation)
66
+ rec = store.record(handle)
67
+ source = rec.get("native") or {}
68
+ if (engine == "claude" and not isinstance(source.get("sessionId"), str)) or (
69
+ engine == "codex" and (not isinstance(source.get("threadId"), str)
70
+ or source.get("threadSource") != store.codex_source(handle))
71
+ ):
72
+ raise SessionUnavailable("original native worker binding is not verified")
73
+ identity = _digest({"workerId": worker_id, "engine": engine,
74
+ "turnRef": turn_ref, "operation": "cancel"})
75
+ prior = store.read_receipt(handle, "cancel", cancellation_id)
76
+ if prior is not None and prior.get("intentSha256") != identity:
77
+ raise OperationConflict("cancellation_id already names a different turn",
78
+ short=worker_id[:8], worker_id=worker_id)
79
+ # In particular, an invented native id is never a cancel capability.
80
+ if engine == "codex":
81
+ from ccx.codex.delivery import DeliveryReceiptError
82
+ try:
83
+ opening = codex_receipt_for_turn(handle, native_turn_id)
84
+ except DeliveryReceiptError:
85
+ # A damaged receipt somewhere proves nothing about this turn;
86
+ # the live exact turn below still decides, as with no receipt.
87
+ opening = None
88
+ else:
89
+ from ccx.claude.delivery import iter_receipts
90
+ opening = next((row for row in iter_receipts(handle)
91
+ if row.get("turnRef") == turn_ref
92
+ and row.get("route") == "new_turn"), None)
93
+ if prior is None and (not isinstance(opening, dict)
94
+ or opening.get("state") != "accepted"):
95
+ # Some genuine turns start without a CCX delivery receipt: human input
96
+ # on the shared managed Claude PTY, or a parent notification on Codex.
97
+ # Do not fabricate one: the live authenticated runtime's exact active
98
+ # turn is sufficient to request a one-shot native interrupt, under the
99
+ # same durable cancel intent. An absent/invented/stale turn is refused.
100
+ known, active = _cancel_live_turn(handle, engine, worker_id)
101
+ store.revalidate()
102
+ if not known or active != turn_ref:
103
+ raise DeliveryRejected("exact active turn has no original accepted delivery receipt",
104
+ short=worker_id[:8])
105
+ deadline = None if timeout is None else time.monotonic() + timeout
106
+
107
+ def public(state: str, *, outcome=None, reason=None, owned=False,
108
+ receipt=None) -> dict[str, Any]:
109
+ return {
110
+ "worker": {"worker": handle, "worker_id": worker_id,
111
+ "engine": engine, "short": worker_id[:8]},
112
+ "cancellation_id": cancellation_id, "turn_ref": turn_ref,
113
+ "status": state, "confirmed": state == "confirmed",
114
+ "owned": owned, "accepted": bool(receipt and receipt.get("state") == "accepted"),
115
+ "recommended_action": ("retry_same_id" if state == "prepared"
116
+ else "wait" if state == "submitted" else "none"),
117
+ "outcome": outcome, "reason": reason,
118
+ "receipt": receipt,
119
+ }
120
+
121
+ def observe(receipt: dict) -> dict | None:
122
+ # Original semantic receipt is the only proof of completed assistant
123
+ # text. A changed live ref proves INACTIVITY, not why the turn stopped.
124
+ def exact_claude_terminal() -> dict | None:
125
+ last = store.record(handle).get("lastSettledTurn") or {}
126
+ if (last.get("turnRef") == turn_ref
127
+ and last.get("outcome") in {"interrupted", "completed", "failed"}):
128
+ return public("confirmed", outcome=last["outcome"],
129
+ owned=True, receipt=receipt)
130
+ return None
131
+
132
+ if engine == "codex":
133
+ from ccx.codex.delivery import DeliveryReceiptError
134
+ try:
135
+ seen = reconcile_turn(handle, turn_id=native_turn_id,
136
+ expected_generation=store.generation)
137
+ except DeliveryReceiptError:
138
+ seen = {} # no receipt evidence; the live turn below decides
139
+ if seen.get("status") == "settled":
140
+ return public("confirmed", outcome="completed", owned=True,
141
+ receipt=receipt)
142
+ if seen.get("native_status") in ("interrupted", "cancelled"):
143
+ return public("confirmed", outcome="interrupted", owned=True,
144
+ receipt=receipt)
145
+ else:
146
+ from ccx.claude.delivery import read_receipt
147
+ latest = read_receipt(handle, opening["deliveryId"]) if opening else None
148
+ if (latest and latest.get("turnRef") == turn_ref
149
+ and isinstance(latest.get("settlement"), dict)):
150
+ return public("confirmed", outcome="completed", owned=True,
151
+ receipt=receipt)
152
+ # Positive completed-answer receipt wins above. Otherwise the
153
+ # exact original host finalizer proves this turn's outcome for
154
+ # both human and receipt-backed cancellations; it does not invent
155
+ # an opening delivery receipt or completed assistant text.
156
+ terminal = exact_claude_terminal()
157
+ if terminal is not None:
158
+ return terminal
159
+ known, active = _cancel_live_turn(handle, engine, worker_id)
160
+ store.revalidate()
161
+ if known and active != turn_ref:
162
+ if engine == "claude":
163
+ # The terminal CAS can land between our first record read and
164
+ # the host status RPC. Reread the same exact source before
165
+ # falling back to unknown; never report an invented outcome.
166
+ terminal = exact_claude_terminal()
167
+ if terminal is not None:
168
+ return terminal
169
+ return public("confirmed", outcome="unknown", owned=True,
170
+ receipt=receipt)
171
+ return None
172
+
173
+ # No preflight can replace the provider's expected-turn check, but an
174
+ # observed IDLE/other turn can reject without any native effect at all.
175
+ if prior is None or prior.get("state") == "prepared":
176
+ store.authorize_native_action(handle, "cancel")
177
+ known, active = _cancel_live_turn(handle, engine, worker_id)
178
+ if not known:
179
+ return public("prepared", reason="live active turn not proven")
180
+ if active != turn_ref:
181
+ return public("rejected", reason="active turn changed or no active turn")
182
+ attempt = store.begin_native_intent(
183
+ handle, family="cancel", action="cancel", operation_id=cancellation_id,
184
+ intent_sha256=identity,
185
+ )
186
+ if attempt.replayed:
187
+ prior = store.read_receipt(handle, "cancel", cancellation_id)
188
+ else:
189
+ from ccx.constants import CODEXD_RPC_GENERATION
190
+ from ccx.codex.runtime import daemon_rpc
191
+ from ccx.rpc.rpc import _rpc
192
+ budget = min(2., max(.01, deadline - time.monotonic())) if deadline else 2.
193
+ def submit(seconds):
194
+ if engine == "claude":
195
+ return _rpc(handle, {"op": "interrupt",
196
+ "expected_turn_ref": turn_ref}, timeout=seconds)
197
+ return daemon_rpc({"op": "interrupt", "short": worker_id,
198
+ "expectedTurnId": native_turn_id,
199
+ "rpcGeneration": CODEXD_RPC_GENERATION}, timeout=seconds)
200
+ try:
201
+ reply = store.dispatch_native_intent_once(
202
+ attempt, bounded_submit=submit, dispatch_budget_s=budget)
203
+ except NativeEffectIndeterminate:
204
+ # Durable submitted-unknown; a same-ID caller only observes.
205
+ prior = store.read_receipt(handle, "cancel", cancellation_id)
206
+ return public("submitted", reason="original interrupt reply unknown",
207
+ owned=True, receipt=prior)
208
+ if (reply.get("ok") is True and
209
+ (engine == "codex" and reply.get("turnId") == native_turn_id
210
+ or engine == "claude" and reply.get("turnRef") == turn_ref
211
+ and (reply.get("sent") is True or
212
+ (reply.get("sent") is False and
213
+ reply.get("already_observing") is True)))):
214
+ # A prior same-turn Escape is accepted, never proof of completion.
215
+ state = "accepted"
216
+ elif reply.get("ok") is False and reply.get("definitive") is True:
217
+ state = "rejected"
218
+ else:
219
+ prior = store.read_receipt(handle, "cancel", cancellation_id)
220
+ return public("submitted", reason="native interrupt not positively acknowledged",
221
+ owned=True, receipt=prior)
222
+ prior = store.settle_native_intent(
223
+ attempt, state=state, native_evidence_sha256=_digest({
224
+ "workerId": worker_id, "turnRef": turn_ref, "reply": reply,
225
+ }),
226
+ )
227
+ if state == "rejected":
228
+ return public("rejected", reason=str(reply.get("error")), receipt=prior)
229
+ if prior is None:
230
+ prior = store.read_receipt(handle, "cancel", cancellation_id)
231
+ if prior.get("state") == "rejected":
232
+ return public("rejected", reason="original native interrupt refused", receipt=prior)
233
+ # Acceptance and caller timeout do not mutate the durable native effect:
234
+ # a later same-ID call may still observe terminality, but cannot re-dispatch.
235
+ while True:
236
+ store.revalidate()
237
+ try:
238
+ verified = observe(prior)
239
+ except (SessionUnavailable, OSError):
240
+ store.revalidate() # changed ACTIVE is not a temporary daemon loss
241
+ verified = None # temporary runtime loss is NOT changed-turn proof
242
+ if verified is not None:
243
+ return verified
244
+ if deadline is not None and time.monotonic() >= deadline:
245
+ return public("unknown", reason="exact target terminality not proven",
246
+ owned=True, receipt=prior)
247
+ time.sleep(.2 if deadline is None else
248
+ min(.2, max(0., deadline-time.monotonic())))
249
+
250
+
251
+ def stop_worker(
252
+ target: str, *, timeout: float | None = 1.0,
253
+ expected_turn_ref: str | None = None,
254
+ expected_generation: str | None = None,
255
+ ) -> dict[str, Any]:
256
+ """One explicit stop request, fresh ID, exact ref; acceptance is not completion."""
257
+ from uuid import uuid4
258
+ from ccx import constants
259
+ from ccx.errors import TurnIncomplete
260
+ from ccx.store.root import require_ccx_active
261
+
262
+ handle, engine, worker_id = split_target(target)
263
+ store = require_ccx_active(constants.CCX_ROOT,
264
+ expected_generation=expected_generation)
265
+ if expected_turn_ref is None:
266
+ known, active = _cancel_live_turn(handle, engine, worker_id)
267
+ store.revalidate()
268
+ if not known:
269
+ raise SessionUnavailable("stop: active turn could not be observed",
270
+ short=worker_id[:8])
271
+ if not active:
272
+ raise DeliveryRejected("stop: worker has no active turn",
273
+ short=worker_id[:8])
274
+ else:
275
+ active = expected_turn_ref # cancel_turn validates the exact ref.
276
+ result = cancel_turn(handle, cancellation_id=str(uuid4()),
277
+ turn_ref=active, timeout=timeout,
278
+ expected_generation=store.generation)
279
+ if result["status"] == "rejected":
280
+ raise DeliveryRejected(result.get("reason") or "active turn changed",
281
+ short=worker_id[:8],
282
+ operation_id=result["cancellation_id"])
283
+ if result["status"] == "prepared":
284
+ raise SessionUnavailable("stop: original interrupt not submitted",
285
+ short=worker_id[:8])
286
+ if not result.get("accepted"):
287
+ error = TurnIncomplete(
288
+ "stop: native interrupt acceptance unconfirmed; "
289
+ "reconcile the original cancellation ID, do not repeat it",
290
+ short=worker_id[:8], turn_status=result["status"],
291
+ )
292
+ error.cancellation_id = result["cancellation_id"]
293
+ error.turn_ref = result["turn_ref"]
294
+ error.worker_id = worker_id
295
+ raise error
296
+ return result
@@ -0,0 +1,96 @@
1
+ """Fork a user-owned native Codex thread into a separate ordinary CCX worker."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from uuid import UUID
6
+
7
+ from ccx.errors import InvalidRequest, SessionUnavailable
8
+ from ccx.ops.creation_steps import validated_create_spec
9
+ from ccx.ops.creation import create
10
+ from ccx.store.identity import mint_worker_id
11
+ from ccx.constants import DEFAULT_IDLE_TIMEOUT_S
12
+
13
+
14
+ def import_thread(
15
+ thread_id: str, *, source: dict | None = None,
16
+ model: str | None = None, effort: str | None = None,
17
+ ) -> dict:
18
+ """Only the child is native-mutated; the original may remain open in Codex.
19
+
20
+ Importing a second time intentionally forks a second independent child.
21
+ """
22
+ if not isinstance(thread_id, str):
23
+ raise InvalidRequest("Codex import needs an exact native thread UUID")
24
+ try:
25
+ if str(UUID(thread_id)) != thread_id:
26
+ raise ValueError("noncanonical")
27
+ except (ValueError, TypeError, AttributeError) as exc:
28
+ raise InvalidRequest("Codex import needs an exact native thread UUID") from exc
29
+ if any(value is not None and (not isinstance(value, str) or not value.strip())
30
+ for value in (model, effort)):
31
+ raise InvalidRequest("import model/effort overrides must be nonempty strings")
32
+ if source is None:
33
+ from ccx.codex.runtime import read_import_source
34
+ source = read_import_source(thread_id)
35
+ thread = source.get("thread") if isinstance(source, dict) else None
36
+ last = source.get("lastTurn") if isinstance(source, dict) else None
37
+ if not isinstance(thread, dict) or thread.get("id") != thread_id:
38
+ raise SessionUnavailable("Codex import native thread ID was not confirmed")
39
+ status = thread.get("status")
40
+ status = status.get("type") if isinstance(status, dict) else status
41
+ if status not in (None, "idle", "notLoaded"):
42
+ raise SessionUnavailable("Codex source has an active turn; wait for it to finish")
43
+ if (not isinstance(last, dict) or not isinstance(last.get("id"), str)
44
+ or not last["id"] or last.get("status") != "completed"):
45
+ raise SessionUnavailable(
46
+ "Codex import needs a latest completed native turn; no worker created"
47
+ )
48
+ cwd = thread.get("cwd")
49
+ if not isinstance(cwd, str) or not cwd:
50
+ raise SessionUnavailable("Codex source has no recorded workspace")
51
+ # Native metadata may omit the latest model context. Never infer it from
52
+ # prose/rollout files; leave omitted settings at the normal CCX defaults.
53
+ native_model = last.get("model") or thread.get("model")
54
+ native_effort = (last.get("reasoningEffort") or last.get("effort")
55
+ or thread.get("reasoningEffort"))
56
+ chosen_model = model if model is not None else (
57
+ native_model if isinstance(native_model, str) and native_model.strip() else None
58
+ )
59
+ chosen_effort = effort if effort is not None else (
60
+ native_effort if isinstance(native_effort, str) and native_effort.strip() else None
61
+ )
62
+ directory, args = validated_create_spec(
63
+ engine="codex", cwd=cwd, model=chosen_model, effort=chosen_effort,
64
+ idle_timeout=DEFAULT_IDLE_TIMEOUT_S, permissions="full",
65
+ sandbox=None, approval_policy=None, agent=None,
66
+ append_system_prompt=None, allowed_tools=None, disallowed_tools=None,
67
+ add_dir=None, permission_mode=None,
68
+ )
69
+ from ccx.codex.runtime import validate_model
70
+ if model is not None:
71
+ validate_model(model, cwd=directory)
72
+ name = thread.get("name") or thread.get("title")
73
+ if not isinstance(name, str) or not name.strip():
74
+ name = None
75
+ completed_at = last.get("completedAt")
76
+ last_at = (int(completed_at * 1000)
77
+ if type(completed_at) in (float, int) and completed_at > 0 else None)
78
+ worker_id = mint_worker_id()
79
+ anchor = {
80
+ "engine": "codex", "sourceKind": "codex-thread",
81
+ "sourceWorkerId": None, "sourceNativeThreadId": thread_id,
82
+ "lastTurnId": last["id"], "cwd": directory,
83
+ "createArgs": args,
84
+ # The newest page cannot attest the historical total; zero here
85
+ # counts managed submissions, NOT all source turns.
86
+ "seed": {"stats": {"turns": 0}, "lastTurnAt": last_at,
87
+ "lastTurn": {"turnId": last["id"], "status": "completed"}},
88
+ "overrides": {"model": model, "effort": effort},
89
+ "forkedAtMs": int(time.time() * 1000),
90
+ }
91
+ return create(
92
+ engine="codex", cwd=directory, worker_id=worker_id, name=name,
93
+ model=chosen_model, effort=chosen_effort,
94
+ permissions="full", idle_timeout=DEFAULT_IDLE_TIMEOUT_S,
95
+ created_via="import", _fork_anchor=anchor, start_timeout=90,
96
+ )