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,867 @@
1
+ 'Durable delivery journal for managed Claude steering (``ccx steer``).\n\nSteering a BUSY worker is the one CCX write whose delivery cannot be proven\nsynchronously: the prompt is pasted into Ink, and Claude decides — later, and\nin its own journal — whether it joins the running turn or the next one. The\nprompt must not be re-pasted merely because native evidence is delayed.\n\nThis module makes the prompt durable *before* it is ever written to the PTY,\nand makes every later decision evidence-driven:\n\n* the full pending prompt and its transcript byte fence are journaled\n atomically before the host acknowledges ownership;\n* retrying the same ``delivery_id`` returns the original entry and never\n produces a second paste;\n* placement is resolved from native Claude evidence fenced by transcript byte\n offset (see :func:`ccx.claude.transcript.steer_evidence_events`), never from\n timestamps or whole-file text containment;\n* when evidence is inconclusive the entry stays honestly pending, and a\n delivery that was pasted with no native trace at all eventually becomes\n ``unknown`` rather than being re-sent.\n\nThe journal is a bounded per-session sidecar (``<short>.deliveries.json``) for\nthe same reason the compact journal is: the session record has several\nindependent writers and a stale read-modify-write must never erase a terminal\ndelivery result. Those writers live in different processes, so every mutation\nholds an exclusive ``flock`` on ``<short>.deliveries.lock`` across its whole\nread -> mutate -> atomic-replace cycle (see :func:`journal_lock`). Reads stay\nlock-free: publication is one ``os.replace``, so a reader never sees a torn\njournal.\n'
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import time
9
+ import uuid
10
+ from typing import Any
11
+
12
+ from ccx.store.journal_io import journal_lock as _journal_lock, write_json
13
+
14
+ from ccx.constants import EXIT_REJECTED, STEER_EVIDENCE_GRACE_S
15
+ from ccx.errors import CcxError
16
+ from ccx.store.paths import steer_deliveries_lock_path, steer_deliveries_path
17
+ from ccx.claude.transcript import STEER_EVIDENCE_RANK, steer_evidence_events
18
+
19
+ JOURNAL_VERSION = 1
20
+ JOURNAL_KEEP = 32
21
+ PREVIEW_LIMIT = 200
22
+ MAX_DELIVERY_ID = 128
23
+
24
+ #: Journaled but not yet proven to be placed in a turn.
25
+ PENDING_STATUSES = frozenset({"accepted", "pasted", "enqueued"})
26
+ #: Resolved: ``consumed`` (current turn), ``started`` (next turn),
27
+ #: ``unknown`` (accepted but unprovable), ``failed`` (proven never written).
28
+ TERMINAL_STATUSES = frozenset({"consumed", "started", "unknown", "failed",
29
+ "delivered", "abandoned"})
30
+ ALL_STATUSES = PENDING_STATUSES | TERMINAL_STATUSES
31
+
32
+ #: Statuses that prove Claude itself owns the prompt.
33
+ NATIVE_ACCEPTED_STATUSES = frozenset({"enqueued", "consumed", "started"})
34
+
35
+ # --- public placement vocabulary ---------------------------------------------
36
+ #
37
+ # Internal statuses record how far a delivery got through CCX's own machinery.
38
+ # Public surfaces (CLI, RPC, Python results) must speak only about placement,
39
+ # because that is the only thing a caller can act on:
40
+ #
41
+ # accepted-pending CCX durably owns the prompt; Claude has not visibly
42
+ # placed it in a turn yet (internally accepted/pasted/
43
+ # enqueued). Never a reason to resend.
44
+ # consumed-current-turn the running turn saw it (queued_command attachment).
45
+ # started-next-turn it runs as the next turn (user event / dequeue).
46
+ # unknown genuinely indeterminate transport or evidence.
47
+ PUBLIC_ACCEPTED_PENDING = "accepted-pending"
48
+ PUBLIC_CONSUMED = "consumed-current-turn"
49
+ PUBLIC_STARTED = "started-next-turn"
50
+ PUBLIC_UNKNOWN = "unknown"
51
+
52
+ PUBLIC_STATUS = {
53
+ "accepted": PUBLIC_ACCEPTED_PENDING,
54
+ "pasted": PUBLIC_ACCEPTED_PENDING,
55
+ "enqueued": PUBLIC_ACCEPTED_PENDING,
56
+ "consumed": PUBLIC_CONSUMED,
57
+ "started": PUBLIC_STARTED,
58
+ "unknown": PUBLIC_UNKNOWN,
59
+ # No code path produces `failed` any more: once a prompt may have reached
60
+ # Ink, "proven undelivered" is not a claim CCX can make. Kept mapped so an
61
+ # old journal never leaks a non-vocabulary status.
62
+ "failed": PUBLIC_UNKNOWN,
63
+ "delivered": PUBLIC_UNKNOWN, # placement from exact evidence below
64
+ "abandoned": PUBLIC_UNKNOWN,
65
+ }
66
+ PUBLIC_TERMINAL_STATUSES = frozenset({PUBLIC_CONSUMED, PUBLIC_STARTED, PUBLIC_UNKNOWN})
67
+
68
+
69
+ def public_status(status: str | None) -> str | None:
70
+ """Map an internal status onto the approved placement vocabulary."""
71
+ if status is None:
72
+ return None
73
+ return PUBLIC_STATUS.get(status, PUBLIC_UNKNOWN)
74
+
75
+
76
+ #: Statuses whose placement may still be decided by later native evidence.
77
+ #: ``unknown`` is included deliberately: it records missing evidence, not a
78
+ #: disproven delivery, so its prompt text is retained for correlation.
79
+ RECONCILABLE_STATUSES = PENDING_STATUSES | {"unknown"}
80
+
81
+ _ALLOWED_TRANSITIONS = {
82
+ "accepted": frozenset({"accepted", "pasted", "enqueued", "consumed",
83
+ "started", "failed", "unknown", "delivered", "abandoned"}),
84
+ # Once written to the PTY we can no longer claim it was never delivered,
85
+ # so `failed` is unreachable from here — only evidence or `unknown`.
86
+ "pasted": frozenset({"pasted", "enqueued", "consumed", "started", "unknown",
87
+ "delivered", "abandoned"}),
88
+ "enqueued": frozenset({"enqueued", "consumed", "started", "unknown",
89
+ "delivered", "abandoned"}),
90
+ "consumed": frozenset({"consumed"}),
91
+ "started": frozenset({"started"}),
92
+ "failed": frozenset({"failed"}),
93
+ # `unknown` means evidence was missing, not that placement was disproven;
94
+ # a late native trace is stronger and may still resolve it.
95
+ "unknown": frozenset({"unknown", "consumed", "started",
96
+ "delivered", "abandoned"}),
97
+ "delivered": frozenset({"delivered"}),
98
+ "abandoned": frozenset({"abandoned"}),
99
+ }
100
+
101
+ _EVIDENCE_STATUS = {"enqueued": "enqueued", "started": "started",
102
+ "consumed": "consumed"}
103
+
104
+
105
+ class SteerJournalError(RuntimeError):
106
+ """The durable steering journal exists but cannot be trusted."""
107
+
108
+
109
+ class SteerJournalBusy(CcxError, SteerJournalError):
110
+ """Another process holds the journal's mutation lock right now.
111
+
112
+ Distinct from corruption: the journal is fine, this caller simply refused
113
+ to wait any longer. Callers must treat it as "nothing was written" — a
114
+ delivery is never journaled, marked, or transitioned on this path — which
115
+ is what keeps a lock timeout from turning into a second paste. A typed
116
+ refusal (CLI exit 5): retrying the same delivery is safe.
117
+ """
118
+
119
+ exit_code = EXIT_REJECTED
120
+
121
+
122
+ def now_ms() -> int:
123
+ return int(time.time() * 1000)
124
+
125
+
126
+ def new_delivery_id() -> str:
127
+ return str(uuid.uuid4())
128
+
129
+
130
+ def valid_delivery_id(delivery_id) -> bool:
131
+ return (isinstance(delivery_id, str) and bool(delivery_id)
132
+ and len(delivery_id) <= MAX_DELIVERY_ID)
133
+
134
+
135
+ def preview(text: str | None, limit: int = PREVIEW_LIMIT) -> str | None:
136
+ if not isinstance(text, str) or not text:
137
+ return None
138
+ return text if len(text) <= limit else text[:limit] + "…"
139
+
140
+
141
+ def transcript_offset(path: str | pathlib.Path | None) -> int:
142
+ try:
143
+ return pathlib.Path(path).stat().st_size
144
+ except (OSError, TypeError):
145
+ return 0
146
+
147
+
148
+ def transcript_identity(path: str | pathlib.Path | None) -> tuple[int | None, int | None]:
149
+ try:
150
+ stat = pathlib.Path(path).stat()
151
+ except (OSError, TypeError):
152
+ return None, None
153
+ return stat.st_dev, stat.st_ino
154
+
155
+
156
+ def empty_journal() -> dict[str, Any]:
157
+ return {"version": JOURNAL_VERSION, "deliveries": []}
158
+
159
+
160
+ def read_journal(short: str) -> dict[str, Any]:
161
+ path = steer_deliveries_path(short)
162
+ try:
163
+ value = json.loads(path.read_text())
164
+ except FileNotFoundError:
165
+ return empty_journal()
166
+ except OSError as exc:
167
+ raise SteerJournalError(f"cannot read steering journal: {exc}") from exc
168
+ except ValueError as exc:
169
+ raise SteerJournalError("steering journal is not valid JSON") from exc
170
+ if not isinstance(value, dict) or not isinstance(value.get("deliveries"), list):
171
+ raise SteerJournalError("steering journal has an invalid shape")
172
+ return value
173
+
174
+
175
+ def write_journal(short: str, journal: dict[str, Any]) -> None:
176
+ path = steer_deliveries_path(short)
177
+ path.parent.mkdir(parents=True, exist_ok=True)
178
+ deliveries = list(journal.get("deliveries") or [])
179
+ # Never evict a pending delivery: its full prompt is the only copy of work
180
+ # the caller already owns.
181
+ pending = [d for d in deliveries if d.get("status") in PENDING_STATUSES]
182
+ terminal = [d for d in deliveries if d.get("status") not in PENDING_STATUSES]
183
+ # Keep the newest terminals that still fit beside the pending entries. A
184
+ # bare negative slice would turn "no room left" into `terminal[-0:]`, which
185
+ # keeps every terminal — an unbounded journal exactly when it is fullest.
186
+ room = max(0, JOURNAL_KEEP - len(pending))
187
+ kept = (terminal[-room:] if room else []) + pending
188
+ # Resolved entries keep identity and evidence, not the full prompt body.
189
+ trimmed = []
190
+ for entry in kept:
191
+ if entry.get("status") in RECONCILABLE_STATUSES or not entry.get("text"):
192
+ trimmed.append(entry)
193
+ else:
194
+ trimmed.append({**entry, "text": None})
195
+ value = {
196
+ "version": JOURNAL_VERSION,
197
+ "updated_at": now_ms(),
198
+ "deliveries": trimmed,
199
+ }
200
+ write_json(path, value)
201
+
202
+
203
+ # Hold the lock across read-modify-replace so concurrent writers cannot erase
204
+ # an acknowledged delivery. Atomic publication lets readers stay lock-free.
205
+
206
+ #: Bound for the Claude host, whose select loop is single-threaded: it must
207
+ #: never wait behind a SIGSTOPed or wedged client holding the sidecar lock.
208
+ #: Short enough that a stalled peer costs one skipped reconcile pass rather
209
+ #: than a frozen worker.
210
+ HOST_LOCK_TIMEOUT_S = 2.0
211
+ #: Bound for out-of-host callers (CLI/Python client reconciliation). They may
212
+ #: wait longer than the host — nothing else is blocked behind them — but never
213
+ #: indefinitely.
214
+ CLIENT_LOCK_TIMEOUT_S = 15.0
215
+
216
+
217
+ def journal_lock(short: str, *, timeout: float | None = None):
218
+ return _journal_lock(
219
+ steer_deliveries_lock_path(short),
220
+ timeout=CLIENT_LOCK_TIMEOUT_S if timeout is None else timeout,
221
+ busy_error=SteerJournalBusy, label=f"steering journal for {short}",
222
+ )
223
+
224
+
225
+ def get_delivery(short: str, delivery_id: str | None) -> dict | None:
226
+ if not delivery_id:
227
+ return None
228
+ for entry in reversed(read_journal(short).get("deliveries") or []):
229
+ if entry.get("delivery_id") == delivery_id:
230
+ return dict(entry)
231
+ return None
232
+
233
+
234
+ def pending_deliveries(short: str) -> list[dict]:
235
+ return [dict(d) for d in (read_journal(short).get("deliveries") or [])
236
+ if d.get("status") in PENDING_STATUSES]
237
+
238
+
239
+ def unproven_deliveries(short: str) -> list[dict]:
240
+ """Deliveries whose placement inside Ink is still unproven.
241
+
242
+ A pending entry is obviously unproven. So is a terminal ``unknown`` that
243
+ carries no evidence: it means ccx wrote prompt text into the editor and
244
+ never learned where that text went. Claude may still be holding it as a
245
+ queued prompt, so the input surface is not provably free — the text can
246
+ surface as a turn at any later moment (observed in the wild ~33 minutes
247
+ after the delivery gave up). Anything that seizes the input surface for a
248
+ local slash command must refuse while such an entry exists; the host's
249
+ reconciliation pump clears it as soon as native evidence appears.
250
+ """
251
+ return [dict(d) for d in (read_journal(short).get("deliveries") or [])
252
+ if d.get("status") in RECONCILABLE_STATUSES and not d.get("evidence")]
253
+
254
+
255
+ def begin_delivery(
256
+ short: str,
257
+ *,
258
+ delivery_id: str,
259
+ text: str,
260
+ transcript: str | None,
261
+ host_epoch: str,
262
+ state: str | None = None,
263
+ cap: dict | None = None,
264
+ requested_mode: str | None = None,
265
+ route: str | None = None,
266
+ target_turn_ref: str | None = None,
267
+ timeout: float | None = None,
268
+ ) -> tuple[dict, bool]:
269
+ """Journal a steer durably, or return the idempotent prior delivery.
270
+
271
+ The second element is False when this ``delivery_id`` was already owned —
272
+ the caller must then not queue anything: the original entry is the single
273
+ source of truth for that delivery, whatever state it reached.
274
+
275
+ Raises :class:`SteerJournalBusy` if the journal lock cannot be taken within
276
+ ``timeout``. Nothing is journaled then, so no ownership is acknowledged and
277
+ the caller may retry with the same ``delivery_id``.
278
+ """
279
+ with journal_lock(short, timeout=timeout):
280
+ return _begin_delivery_locked(
281
+ short, delivery_id=delivery_id, text=text, transcript=transcript,
282
+ host_epoch=host_epoch, state=state, cap=cap,
283
+ requested_mode=requested_mode, route=route,
284
+ target_turn_ref=target_turn_ref,
285
+ )
286
+
287
+
288
+ def _begin_delivery_locked(
289
+ short: str,
290
+ *,
291
+ delivery_id: str,
292
+ text: str,
293
+ transcript: str | None,
294
+ host_epoch: str,
295
+ state: str | None = None,
296
+ cap: dict | None = None,
297
+ requested_mode: str | None = None,
298
+ route: str | None = None,
299
+ target_turn_ref: str | None = None,
300
+ ) -> tuple[dict, bool]:
301
+ journal = read_journal(short)
302
+ deliveries = journal.get("deliveries") or []
303
+ for entry in deliveries:
304
+ if entry.get("delivery_id") == delivery_id:
305
+ return dict(entry), False
306
+ accepted_at = now_ms()
307
+ fence_dev, fence_inode = transcript_identity(transcript)
308
+ entry = {
309
+ "delivery_id": delivery_id,
310
+ "status": "accepted",
311
+ "accepted_at": accepted_at,
312
+ "updated_at": accepted_at,
313
+ "host_epoch": host_epoch,
314
+ "accepted_state": state,
315
+ "transcript": str(transcript) if transcript else None,
316
+ "fence_offset": transcript_offset(transcript),
317
+ "fence_dev": fence_dev,
318
+ "fence_inode": fence_inode,
319
+ "text": text,
320
+ "text_len": len(text or ""),
321
+ "preview": preview(text),
322
+ "cap": cap or None,
323
+ "requested_mode": requested_mode,
324
+ "route": route,
325
+ "target_turn_ref": target_turn_ref,
326
+ "attempts": 0,
327
+ "pasted_at": None,
328
+ "evidence": None,
329
+ "evidence_offset": None,
330
+ "evidence_uuid": None,
331
+ "reason": None,
332
+ }
333
+ deliveries.append(entry)
334
+ journal["deliveries"] = deliveries
335
+ write_journal(short, journal)
336
+ return dict(entry), True
337
+
338
+
339
+ def transition_delivery(short: str, delivery_id: str, status: str,
340
+ *, timeout: float | None = None, **patch: Any) -> dict:
341
+ if status not in ALL_STATUSES:
342
+ raise ValueError(f"invalid steering delivery status: {status}")
343
+ with journal_lock(short, timeout=timeout):
344
+ return _transition_delivery_locked(short, delivery_id, status, **patch)
345
+
346
+
347
+ def _transition_delivery_locked(short: str, delivery_id: str, status: str,
348
+ **patch: Any) -> dict:
349
+ journal = read_journal(short)
350
+ deliveries = journal.get("deliveries") or []
351
+ for index, entry in enumerate(deliveries):
352
+ if entry.get("delivery_id") != delivery_id:
353
+ continue
354
+ previous = entry.get("status")
355
+ if status not in _ALLOWED_TRANSITIONS.get(previous, frozenset()):
356
+ raise ValueError(
357
+ f"non-monotonic steering transition {previous!r} -> {status!r}"
358
+ )
359
+ updated = {**entry, **patch, "status": status, "updated_at": now_ms()}
360
+ deliveries[index] = updated
361
+ journal["deliveries"] = deliveries
362
+ write_journal(short, journal)
363
+ return dict(updated)
364
+ raise KeyError(f"unknown steering delivery: {delivery_id}")
365
+
366
+
367
+ def mark_pasted(short: str, delivery_id: str, *, offset: int | None = None,
368
+ timeout: float | None = None) -> dict:
369
+ """Record a PTY write attempt before it happens.
370
+
371
+ Journaling the attempt first is what makes a crash mid-write safe: the
372
+ recovery path can see that the text may already be in Ink and must not
373
+ write it again.
374
+
375
+ One delivery is written at a time; its own text and transcript byte fence
376
+ distinguish it from any other delivery with identical text.
377
+ """
378
+ with journal_lock(short, timeout=timeout):
379
+ # Read and increment inside one hold: the attempt counter is itself a
380
+ # read-modify-write, and losing an attempt is how a recovered host
381
+ # concludes a written prompt was never written and pastes it twice.
382
+ entry = get_delivery(short, delivery_id)
383
+ attempts = int((entry or {}).get("attempts") or 0) + 1
384
+ return transition_delivery(
385
+ short, delivery_id, "pasted",
386
+ attempts=attempts,
387
+ pasted_at=now_ms(),
388
+ paste_offset=offset,
389
+ )
390
+
391
+
392
+ def _fence_valid(entry: dict, transcript: str | None) -> bool:
393
+ path = transcript or entry.get("transcript")
394
+ if not path:
395
+ return False
396
+ fence_dev = entry.get("fence_dev")
397
+ fence_inode = entry.get("fence_inode")
398
+ if fence_dev is None or fence_inode is None:
399
+ return True
400
+ current_dev, current_inode = transcript_identity(path)
401
+ return current_dev == fence_dev and current_inode == fence_inode
402
+
403
+
404
+ def _scan_from(entry: dict) -> int:
405
+ """Byte fence for one entry: never earlier than its own paste attempt."""
406
+ return max(int(entry.get("fence_offset") or 0),
407
+ int(entry.get("paste_offset") or 0))
408
+
409
+
410
+ def reconcile_pending(
411
+ short: str,
412
+ *,
413
+ transcript: str | None = None,
414
+ now: float | None = None,
415
+ grace_s: float = STEER_EVIDENCE_GRACE_S,
416
+ timeout: float | None = None,
417
+ ) -> list[dict]:
418
+ """Resolve pending deliveries from native transcript evidence.
419
+
420
+ Original deliveries are considered in acceptance order. One native
421
+ evidence event belongs to at most one delivery, even with identical text.
422
+
423
+ ``now``/``grace_s`` are injectable so tests never need a real wall clock.
424
+ """
425
+ plan = _reconcile_plan(short, transcript=transcript, now=now,
426
+ grace_s=grace_s)
427
+ if not plan:
428
+ return []
429
+ return _apply_reconcile_plan(short, plan, timeout=timeout)
430
+
431
+
432
+ def _evidence_claim(offset, evidence_uuid):
433
+ """Identity of one native evidence event, or None when it has neither.
434
+
435
+ An offset of ``None`` is not an identity: putting it in the claimed set
436
+ would make the first offset-less event block every later one, so fall back
437
+ to the event's own uuid.
438
+ """
439
+ if offset is not None:
440
+ return ("offset", offset)
441
+ if evidence_uuid is not None:
442
+ return ("uuid", evidence_uuid)
443
+ return None
444
+
445
+
446
+ def _reconcile_plan(
447
+ short: str,
448
+ *,
449
+ transcript: str | None = None,
450
+ now: float | None = None,
451
+ grace_s: float = STEER_EVIDENCE_GRACE_S,
452
+ ) -> list[tuple[str, str, dict]]:
453
+ """Decide what the evidence implies, holding no lock.
454
+
455
+ Reading the journal and scanning the transcript are the slow parts of
456
+ reconciliation, and doing them under the mutation lock would hold it for as
457
+ long as a transcript takes to walk — while the single-threaded host may be
458
+ waiting for it. They are pure reads over an atomically published journal,
459
+ so they are safe here; :func:`_apply_reconcile_plan` re-validates every
460
+ decision against the live journal before writing.
461
+ """
462
+ journal = read_journal(short)
463
+ deliveries = journal.get("deliveries") or []
464
+ pending = [d for d in deliveries if d.get("status") in RECONCILABLE_STATUSES]
465
+ if not pending:
466
+ return []
467
+ claimed: dict[tuple, str] = {}
468
+ for d in deliveries:
469
+ claim = _evidence_claim(d.get("evidence_offset"), d.get("evidence_uuid"))
470
+ if claim is not None:
471
+ claimed.setdefault(claim, d["delivery_id"])
472
+ now = time.time() if now is None else now
473
+ plan: list[tuple[str, str, dict]] = []
474
+ for entry in sorted(pending, key=lambda d: d.get("accepted_at") or 0):
475
+ delivery_id = entry["delivery_id"]
476
+ path = transcript or entry.get("transcript")
477
+ # Only stronger evidence advances the original delivery.
478
+ floor = STEER_EVIDENCE_RANK.get(entry.get("status"), 0)
479
+ best = None
480
+ if path and _fence_valid(entry, transcript):
481
+ for evidence in steer_evidence_events(
482
+ path, entry.get("text") or "", after_offset=_scan_from(entry),
483
+ ):
484
+ claim = _evidence_claim(evidence.get("offset"),
485
+ evidence.get("uuid"))
486
+ if (claim is not None and claim in claimed
487
+ and claimed[claim] != delivery_id):
488
+ continue
489
+ if evidence["rank"] <= floor:
490
+ continue
491
+ if best is None or evidence["rank"] > best["rank"]:
492
+ best = evidence
493
+ if best is not None:
494
+ best_claim = _evidence_claim(best.get("offset"), best.get("uuid"))
495
+ if best_claim is not None:
496
+ claimed[best_claim] = delivery_id
497
+ plan.append((delivery_id, _EVIDENCE_STATUS[best["kind"]], {
498
+ "evidence": best["kind"],
499
+ "evidence_offset": best.get("offset"),
500
+ "evidence_uuid": best.get("uuid"),
501
+ "reason": None,
502
+ }))
503
+ continue
504
+ # A possibly written input without native evidence stays unknown, never
505
+ # eligible for a second paste.
506
+ if entry.get("status") == "pasted":
507
+ pasted_at = (entry.get("pasted_at") or entry.get("accepted_at") or 0) / 1000.0
508
+ if grace_s is not None and now - pasted_at >= grace_s:
509
+ plan.append((delivery_id, "unknown", {
510
+ "reason": "no native evidence within the delivery "
511
+ "grace window",
512
+ }))
513
+ return plan
514
+
515
+
516
+ def _apply_reconcile_plan(short: str, plan: list[tuple[str, str, dict]], *,
517
+ timeout: float | None = None) -> list[dict]:
518
+ """Write a plan under one short hold, re-validating against the live journal.
519
+
520
+ The plan was computed lock-free, so the journal may have moved on: another
521
+ process may have resolved an entry, or claimed the very evidence event this
522
+ plan wanted to use. Both are re-checked here, and a stale decision is
523
+ dropped rather than written — a delivery only ever moves forward, and one
524
+ native event still resolves at most one original delivery.
525
+ """
526
+ resolved: list[dict] = []
527
+ with journal_lock(short, timeout=timeout):
528
+ current = {d.get("delivery_id"): d
529
+ for d in (read_journal(short).get("deliveries") or [])}
530
+ # Include both already settled and still planned deliveries: a claim
531
+ # can be reused only by its own original delivery.
532
+ claimed: dict[tuple, str] = {}
533
+ for delivery_id, entry in current.items():
534
+ claim = _evidence_claim(entry.get("evidence_offset"),
535
+ entry.get("evidence_uuid"))
536
+ if claim is not None:
537
+ claimed.setdefault(claim, delivery_id)
538
+ for delivery_id, status, patch in plan:
539
+ entry = current.get(delivery_id)
540
+ if entry is None:
541
+ continue
542
+ if status not in _ALLOWED_TRANSITIONS.get(entry.get("status"),
543
+ frozenset()):
544
+ continue
545
+ if STEER_EVIDENCE_RANK.get(status, 0) <= \
546
+ STEER_EVIDENCE_RANK.get(entry.get("status"), 0) \
547
+ and status != "unknown":
548
+ continue
549
+ claim = _evidence_claim(patch.get("evidence_offset"),
550
+ patch.get("evidence_uuid"))
551
+ if (claim is not None and claim in claimed
552
+ and claimed[claim] != delivery_id):
553
+ continue
554
+ updated = transition_delivery(short, delivery_id, status, **patch)
555
+ if claim is not None:
556
+ claimed[claim] = delivery_id
557
+ current[delivery_id] = updated
558
+ resolved.append(updated)
559
+ return resolved
560
+
561
+
562
+ def reset_semantic_delivery_for_retry(
563
+ short: str,
564
+ delivery_id: str,
565
+ *,
566
+ timeout: float | None = None,
567
+ ) -> dict:
568
+ """Reset one semantic hot entry after positive non-submission proof.
569
+
570
+ Only a proven unsubmitted idle paste may be reset after clearing Ink.
571
+ A possibly placed native steer is never eligible for another paste.
572
+ """
573
+ with journal_lock(short, timeout=timeout):
574
+ journal = read_journal(short)
575
+ deliveries = journal.get("deliveries") or []
576
+ for index, entry in enumerate(deliveries):
577
+ if entry.get("delivery_id") != delivery_id:
578
+ continue
579
+ if entry.get("status") != "pasted":
580
+ raise SteerJournalError(
581
+ f"semantic retry reset requires pasted status, got {entry.get('status')!r}"
582
+ )
583
+ updated = {
584
+ **entry,
585
+ "status": "accepted",
586
+ "maybe_written": False,
587
+ "cleared_at": now_ms(),
588
+ "reason": "prior paste cleared after positive non-submission proof",
589
+ "updated_at": now_ms(),
590
+ }
591
+ deliveries[index] = updated
592
+ journal["deliveries"] = deliveries
593
+ write_journal(short, journal)
594
+ return dict(updated)
595
+ raise KeyError(f"unknown steering delivery: {delivery_id}")
596
+
597
+
598
+ def fail_semantic_delivery_unwritten(
599
+ short: str,
600
+ delivery_id: str,
601
+ *,
602
+ reason: str,
603
+ timeout: float | None = None,
604
+ ) -> dict:
605
+ """Terminalize a semantic hot entry after proven non-delivery + clear."""
606
+ with journal_lock(short, timeout=timeout):
607
+ journal = read_journal(short)
608
+ deliveries = journal.get("deliveries") or []
609
+ for index, entry in enumerate(deliveries):
610
+ if entry.get("delivery_id") != delivery_id:
611
+ continue
612
+ if entry.get("status") in {"consumed", "started", "enqueued"}:
613
+ raise SteerJournalError(
614
+ f"cannot fail natively accepted semantic delivery ({entry.get('status')})"
615
+ )
616
+ updated = {
617
+ **entry,
618
+ "status": "failed",
619
+ "maybe_written": False,
620
+ "reason": reason,
621
+ "updated_at": now_ms(),
622
+ }
623
+ deliveries[index] = updated
624
+ journal["deliveries"] = deliveries
625
+ write_journal(short, journal)
626
+ return dict(updated)
627
+ raise KeyError(f"unknown steering delivery: {delivery_id}")
628
+
629
+
630
+ def reconcile_delivery(short: str, delivery_id: str, *,
631
+ transcript: str | None = None,
632
+ now: float | None = None,
633
+ timeout: float | None = None) -> dict | None:
634
+ """Reconcile the whole journal, then return one delivery's snapshot.
635
+
636
+ Not held under one lock end to end: reconciliation re-validates its own
637
+ decisions, and the snapshot read afterwards is atomic on its own. That
638
+ keeps the lock hold to the write itself.
639
+ """
640
+ entry = get_delivery(short, delivery_id)
641
+ if entry is None:
642
+ return None
643
+ if entry.get("status") in PENDING_STATUSES or entry.get("status") == "unknown":
644
+ try:
645
+ reconcile_pending(short, transcript=transcript, now=now,
646
+ timeout=timeout)
647
+ except SteerJournalError:
648
+ raise
649
+ except Exception:
650
+ pass
651
+ entry = get_delivery(short, delivery_id) or entry
652
+ return entry
653
+
654
+
655
+ def public_delivery(entry: dict | None) -> dict | None:
656
+ """Bounded RPC/CLI-safe projection.
657
+
658
+ Identity, placement, and evidence only — never the full prompt, and never
659
+ an internal status name: ``status`` is always one of the four approved
660
+ placement values (see :data:`PUBLIC_STATUS`).
661
+ """
662
+ if entry is None:
663
+ return None
664
+ return {
665
+ "delivery_id": entry.get("delivery_id"),
666
+ "status": (PUBLIC_CONSUMED if entry.get("status") == "delivered"
667
+ and entry.get("evidence") == "consumed" else
668
+ PUBLIC_STARTED if entry.get("status") == "delivered"
669
+ and entry.get("evidence") == "started" else
670
+ public_status(entry.get("status"))),
671
+ "accepted_at": entry.get("accepted_at"),
672
+ "updated_at": entry.get("updated_at"),
673
+ "attempts": int(entry.get("attempts") or 0),
674
+ "text_len": entry.get("text_len"),
675
+ "preview": entry.get("preview"),
676
+ "evidence": entry.get("evidence"),
677
+ "evidence_uuid": entry.get("evidence_uuid"),
678
+ "reason": entry.get("reason"),
679
+ "pending": public_status(entry.get("status")) == PUBLIC_ACCEPTED_PENDING,
680
+ }
681
+
682
+
683
+ def observe_delivery(
684
+ short: str,
685
+ delivery_id: str,
686
+ *,
687
+ transcript: str | None,
688
+ status_reader=None,
689
+ timeout: float | None = None,
690
+ ) -> tuple[dict | None, str | None]:
691
+ """Read one reconciled delivery snapshot for CLI and Python callers.
692
+
693
+ ``status_reader`` is an optional zero-argument callback returning the live
694
+ host's RPC envelope; any unavailable/bad reply falls back to the durable
695
+ journal plus transcript. Journal corruption deliberately propagates so
696
+ public surfaces can report an indeterminate delivery instead of silently
697
+ treating it as absent (and retrying it).
698
+ """
699
+ current = None
700
+ native_state = None
701
+ if status_reader is not None:
702
+ try:
703
+ reply = status_reader() or {}
704
+ except Exception:
705
+ reply = {}
706
+ native_state = reply.get("state")
707
+ if reply.get("ok"):
708
+ current = reply.get("delivery")
709
+ if current is None:
710
+ current = get_delivery(short, delivery_id)
711
+ if current is not None:
712
+ current = public_delivery(
713
+ reconcile_delivery(short, delivery_id, transcript=transcript,
714
+ timeout=timeout)
715
+ or current
716
+ )
717
+ return current, native_state
718
+
719
+ def finalize_dead_claude_steers(
720
+ store, target: str, *, child_pid: int | None, child_start: str | None,
721
+ ) -> list[str]:
722
+ """Once the previous child is positively dead, settle fenced native steers.
723
+
724
+ One owner-pinned fd and one bounded scan per unresolved delivery. An
725
+ unreadable/replaced source, unproved child death, absent fence or malformed
726
+ JSONL leaves the receipt unresolved. Hot journal first, then semantic:
727
+ a crash between writes is repaired from the hot terminal on next kill/revive.
728
+ No text is ever sent or replayed.
729
+ """
730
+ from ccx.claude.delivery import (read_receipt, finalize_dead_steer)
731
+ from ccx.store.liveness import owned_process
732
+ from ccx.claude.transcript import (
733
+ _block_text, _has_queued_command_marker, _iter_transcript,
734
+ normalize_semantic_prompt_text, open_original_claude_source, prompt_matches,
735
+ )
736
+ from ccx.store.root import StorageIntegrityError
737
+
738
+ owner = store.resolve(target)
739
+ if owner.engine != "claude":
740
+ return []
741
+ if (not isinstance(child_pid, int) or child_pid <= 0
742
+ or not child_start or owned_process(child_pid, child_start) != "dead"):
743
+ return []
744
+ entries = [d for d in read_journal(owner.worker_id).get("deliveries", [])
745
+ if d.get("status") in
746
+ (RECONCILABLE_STATUSES | {"delivered", "abandoned", "consumed", "started"})]
747
+ if not entries:
748
+ return []
749
+ resolved: list[str] = []
750
+ record = store.record(owner.worker)
751
+ # The native owner/source is checked with descriptor-pinned origin binding.
752
+ with open_original_claude_source(store, owner, record) as source:
753
+ if source is None:
754
+ return []
755
+ pinned, stat, eof, path, _parent = source
756
+ claimed: dict[tuple, str] = {}
757
+ for row in read_journal(owner.worker_id).get("deliveries", []):
758
+ claim = _evidence_claim(row.get("evidence_offset"), row.get("evidence_uuid"))
759
+ if claim is not None:
760
+ claimed.setdefault(claim, row.get("delivery_id"))
761
+ for item in entries:
762
+ token = item.get("delivery_id")
763
+ if not isinstance(token, str) or not token:
764
+ continue
765
+ receipt = read_receipt(owner.worker, token)
766
+ route = (receipt or {}).get("route")
767
+ if (route not in {"steer", "new_turn"}
768
+ or item.get("route") != route):
769
+ continue # no hot-only authority or contradictory routing
770
+ if (route == "new_turn" and receipt.get("state") not in
771
+ {"owned", "delivered", "abandoned"}):
772
+ continue # an already accepted original turn needs no repair
773
+ if (item.get("transcript") != str(path)
774
+ or item.get("fence_dev") != stat.st_dev
775
+ or item.get("fence_inode") != stat.st_ino
776
+ or type(item.get("fence_offset")) is not int
777
+ or not 0 <= _scan_from(item) <= eof):
778
+ continue
779
+ status = item.get("status")
780
+ if status in {"delivered", "abandoned", "consumed", "started"}:
781
+ fate = ("abandoned" if status == "abandoned" else "delivered")
782
+ evidence = ({"kind": item.get("evidence"),
783
+ "offset": item.get("evidence_offset"),
784
+ "uuid": item.get("evidence_uuid")}
785
+ if fate == "delivered" else None)
786
+ if (route == "steer" and fate == "delivered" and (
787
+ evidence["kind"] not in {"enqueued", "started", "consumed"}
788
+ or type(evidence["offset"]) is not int
789
+ )):
790
+ continue
791
+ # For owned new-turns, generic hot started/consumed is already
792
+ # accepted delivery evidence, even without a native offset.
793
+ # A proven-dead child must not strand the semantic receipt.
794
+ else:
795
+ prompt = item.get("text")
796
+ if not isinstance(prompt, str) or not prompt.strip():
797
+ continue
798
+ stats = {}
799
+ best = None
800
+ try:
801
+ if route == "new_turn":
802
+ # Unlike steering, new-turn acceptance is only a
803
+ # non-meta, non-queued ordinary USER row with exactly
804
+ # matching normalized text after the original fence.
805
+ import hashlib
806
+ if hashlib.sha256(prompt.encode("utf-8")).hexdigest() != receipt.get(
807
+ "promptSha256"
808
+ ):
809
+ continue
810
+ expected = normalize_semantic_prompt_text(prompt).strip()
811
+ for event in _iter_transcript(
812
+ pinned, stats=stats, after_offset=_scan_from(item),
813
+ before_offset=eof,
814
+ ):
815
+ if (event.get("type") != "user"
816
+ or event.get("isMeta")
817
+ or _has_queued_command_marker(event)):
818
+ continue
819
+ content = (event.get("message") or {}).get("content")
820
+ if not prompt_matches(expected, _block_text(content)):
821
+ continue
822
+ evidence = {"kind": "started",
823
+ "offset": event.get("_ccx_end_offset"),
824
+ "uuid": event.get("uuid")}
825
+ claim = _evidence_claim(evidence["offset"], evidence["uuid"])
826
+ if claim is None or claim not in claimed or claimed[claim] == token:
827
+ best = evidence
828
+ break
829
+ else:
830
+ for evidence in steer_evidence_events(
831
+ pinned, prompt, after_offset=_scan_from(item),
832
+ before_offset=eof, stats=stats,
833
+ ):
834
+ claim = _evidence_claim(evidence.get("offset"),
835
+ evidence.get("uuid"))
836
+ if (claim is not None and claim in claimed
837
+ and claimed[claim] != token):
838
+ continue
839
+ if best is None or evidence["rank"] > best["rank"]:
840
+ best = evidence
841
+ except (OSError, ValueError, StorageIntegrityError):
842
+ continue
843
+ if stats.get("malformedLines"):
844
+ continue
845
+ fate = "delivered" if best else "abandoned"
846
+ evidence = best
847
+ if status in RECONCILABLE_STATUSES:
848
+ patch = {
849
+ "evidence": evidence.get("kind") if evidence else None,
850
+ "evidence_offset": evidence.get("offset") if evidence else None,
851
+ "evidence_uuid": evidence.get("uuid") if evidence else None,
852
+ "reason": ("previous native Claude child exited; no fenced "
853
+ f"native {route} evidence" if fate == "abandoned" else None),
854
+ }
855
+ try:
856
+ transition_delivery(owner.worker_id, token, fate, **patch)
857
+ except ValueError:
858
+ # Concurrent evidence reconciliation won. Do not overwrite.
859
+ continue
860
+ finalize_dead_steer(owner.worker, token, fate=fate, evidence=evidence)
861
+ if evidence:
862
+ claim = _evidence_claim(evidence.get("offset"), evidence.get("uuid"))
863
+ if claim:
864
+ claimed[claim] = token
865
+ resolved.append(token)
866
+ store.revalidate()
867
+ return resolved