ccx-native 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,770 @@
1
+ from __future__ import annotations
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import time
6
+ from typing import Any
7
+ from ccx.codex import ops as codex_ops
8
+ from ccx.claude import telemetry as claude_telemetry
9
+ from ccx.errors import (
10
+ DeliveryRejected,
11
+ InvalidRequest,
12
+ SessionUnavailable,
13
+ worker_not_found,
14
+ )
15
+ from ccx.workers import WorkerClient
16
+ from ccx.store.identity import canonical_worker_id
17
+ from ccx.ops.common import _DEFAULT_REPORT_CHARS, _DEFAULT_TRANSCRIPT_PAGE, _DEFAULT_TRANSCRIPT_TOOL_BYTES, _ENGINES, _TRANSCRIPT_CURSOR_PREFIX, _TRANSCRIPT_LOCATOR_PREFIX, _WORKERS, _decode_opaque, _encode_opaque, recorded_inspector, split_target
18
+
19
+
20
+ def _latest_native_report(
21
+ handle: str, engine: str, worker_id: str, *, deadline: float,
22
+ max_chars: int | None,
23
+ ) -> dict[str, Any]:
24
+ """Latest native turn's reply, with notes about unfinished or failed work.
25
+
26
+ Exact turn_ref reports and the send/new wait paths do not enter here.
27
+ In particular a phase-less/latest cached message is not completion proof.
28
+ """
29
+ from ccx.claude import delivery as claude_delivery
30
+ from ccx.codex import delivery as codex_delivery
31
+ from ccx import constants
32
+ from ccx.store.root import StorageIntegrityError, NativeActionDenied, require_ccx_active
33
+
34
+ store = require_ccx_active(constants.CCX_ROOT)
35
+ owner = store.resolve(handle)
36
+ record = store.record(handle)
37
+ lifecycle = record.get("lifecycle") or {}
38
+ state = lifecycle.get("state")
39
+ failed = state in {"crashed", "failed"}
40
+ running = not failed and bool(
41
+ lifecycle.get("activeTurnId") or lifecycle.get("activeTurnRef")
42
+ or state in {"busy", "waiting", "booting", "resuming", "starting"})
43
+ note = "the worker failed" if failed else None
44
+ # A turn still open when the worker failed has no final reply: its last
45
+ # text is partial (e.g. commentary before a tool call).
46
+ unfinished = failed and bool(
47
+ lifecycle.get("activeTurnId") or lifecycle.get("activeTurnRef"))
48
+ running_without_receipt = False
49
+
50
+ receipts = list((codex_delivery if engine == "codex" else
51
+ claude_delivery).iter_receipts(handle))
52
+ # A never-started original, without any recorded native turn/effect,
53
+ # cannot have a completed conversation. Avoid unsupported Codex listing.
54
+ never_started = (not receipts and not record.get("lastTurn")
55
+ and not record.get("lastSettledTurn")
56
+ and not record.get("lastReport")
57
+ and not lifecycle.get("activeTurnId")
58
+ and not lifecycle.get("activeTurnRef")
59
+ and (record.get("stats") or {}).get("turns", 0) == 0)
60
+ if never_started and record.get("historyKnownEmpty") is True:
61
+ raise SessionUnavailable("no conversation turn yet")
62
+
63
+ def settled(ref: str | None) -> dict[str, Any] | None:
64
+ if not ref:
65
+ return None
66
+ matched = [r for r in receipts if r.get("turnRef") == ref
67
+ and isinstance(r.get("settlement"), dict)
68
+ and (engine != "codex" or codex_delivery.route(r) == "new_turn")]
69
+ return matched[0] if len(matched) == 1 else None
70
+
71
+ running_note = ("a newer turn is waiting for input (see ccx peek)"
72
+ if state == "waiting" or record.get("waitingFor")
73
+ else "a newer turn is still running")
74
+ if running:
75
+ # A current turn may already have streamed partial assistant output.
76
+ # Only the last completed, receipt-proven turn is safe to show.
77
+ if engine == "codex":
78
+ last = record.get("lastTurn") or {}
79
+ ref = ("ccx-turn:codex:v1:" + last["turnId"]
80
+ if last.get("status") == "completed"
81
+ and isinstance(last.get("turnId"), str) else None)
82
+ else:
83
+ last = record.get("lastSettledTurn") or {}
84
+ ref = (last.get("turnRef") if last.get("outcome") not in
85
+ {"interrupted", "failed", "cancelled"} else None)
86
+ confirmed = settled(ref)
87
+ if confirmed is None:
88
+ if not ref:
89
+ raise SessionUnavailable("no completed reply yet; a turn is still running")
90
+ running_without_receipt = True
91
+ note = running_note
92
+ else:
93
+ result = report_worker(handle, turn_ref=confirmed["turnRef"],
94
+ timeout=max(0.001, deadline - time.monotonic()),
95
+ max_chars=max_chars)
96
+ result.setdefault("outcome", None)
97
+ result["note"] = running_note
98
+ return result
99
+
100
+ latest = None
101
+ chosen = None
102
+ latest_ref = None
103
+ latest_status = "completed"
104
+ if engine == "codex":
105
+ last = record.get("lastTurn") or {}
106
+ if last.get("status") == "completed" and isinstance(last.get("turnId"), str):
107
+ latest_ref = "ccx-turn:codex:v1:" + last["turnId"]
108
+ chosen = settled(latest_ref)
109
+ if chosen is None:
110
+ # An older codexd may have journaled the native completion
111
+ # before this process learned to settle the accepted receipt.
112
+ # Repair only the one exact opening delivery, never a steer
113
+ # or an unrelated older accepted turn. No thread/read here.
114
+ pending = [r for r in receipts
115
+ if r.get("turnRef") == latest_ref
116
+ and r.get("turnId") == last["turnId"]
117
+ and r.get("state") == "accepted"
118
+ and r.get("settlement") is None
119
+ and codex_delivery.route(r) == "new_turn"]
120
+ if len(pending) == 1:
121
+ from ccx.codex.completion import settle_from_journal
122
+ settle_from_journal(store, handle, last["turnId"])
123
+ receipts = list(codex_delivery.iter_receipts(handle))
124
+ chosen = settled(latest_ref)
125
+ if chosen is not None:
126
+ # lastTurn can still name the completed turn after another
127
+ # message was submitted and then killed or lost.
128
+ chosen_at = chosen.get("createdAtMs")
129
+ if type(chosen_at) is int and any(
130
+ r.get("state") in {"submitted", "accepted"}
131
+ and r.get("settlement") is None
132
+ and codex_delivery.route(r) == "new_turn"
133
+ and type(r.get("createdAtMs")) is int
134
+ and r["createdAtMs"] > chosen_at
135
+ for r in receipts
136
+ ):
137
+ note = "a newer message's outcome is unknown"
138
+ if chosen is None:
139
+ from ccx.codex.runtime import read_thread
140
+ from ccx.codex.reports import report_from_turn, native_timestamp_ms
141
+ native = record.get("native") or {}
142
+ thread_id = native.get("threadId")
143
+ try:
144
+ source = store.codex_source(handle)
145
+ except (StorageIntegrityError, SessionUnavailable, NativeActionDenied) as exc:
146
+ if never_started:
147
+ raise SessionUnavailable("no conversation turn yet") from exc
148
+ raise
149
+ if (not isinstance(thread_id, str) or not thread_id
150
+ or native.get("threadSource") != source
151
+ or not isinstance(record.get("cwd"), str)):
152
+ if never_started:
153
+ raise SessionUnavailable("no conversation turn yet")
154
+ raise SessionUnavailable("Codex conversation history is unavailable; check the worker and retry")
155
+ try:
156
+ thread = read_thread(
157
+ thread_id, cwd=record["cwd"], worker_id=worker_id,
158
+ expected_source=source,
159
+ timeout=max(0.001, deadline - time.monotonic()),
160
+ )
161
+ except SessionUnavailable as exc:
162
+ # The native Codex API does not universally implement empty
163
+ # thread/items/list. Only positively known empty histories
164
+ # warrant this more useful diagnosis.
165
+ if record.get("historyKnownEmpty") is True:
166
+ raise SessionUnavailable("no conversation turn yet") from exc
167
+ # A receipt proves its own settled reply even when Codex's
168
+ # thread history temporarily cannot be read. Do not imply
169
+ # this is necessarily the latest native conversation turn.
170
+ proven = [
171
+ r for r in receipts
172
+ if isinstance(r.get("settlement"), dict)
173
+ and codex_delivery.route(r) == "new_turn"
174
+ and type(r.get("createdAtMs")) is int
175
+ ]
176
+ if not proven:
177
+ raise
178
+ chosen = max(proven, key=lambda r: r["createdAtMs"])
179
+ result = report_worker(
180
+ handle, turn_ref=chosen["turnRef"],
181
+ timeout=max(0.001, deadline - time.monotonic()),
182
+ max_chars=max_chars,
183
+ )
184
+ result.setdefault("outcome", None)
185
+ result["note"] = "native history unavailable; showing the last recorded reply"
186
+ return result
187
+ if (not isinstance(thread, dict)
188
+ or thread.get("id") != thread_id
189
+ or thread.get("threadSource") != source):
190
+ raise SessionUnavailable("Codex report history does not match this worker")
191
+ native_turns = [t for t in (thread.get("turns") or [])
192
+ if isinstance(t, dict)]
193
+ compact_id = (record.get("lastCompact") or {}).get("turnId")
194
+ # Only skip positively identified internal compaction turns.
195
+ # No heuristic based on blank text or missing assistant output.
196
+ while native_turns and (
197
+ (isinstance(compact_id, str)
198
+ and native_turns[-1].get("id") == compact_id)
199
+ or any(
200
+ isinstance(item, dict) and item.get("type") == "contextCompaction"
201
+ for item in (native_turns[-1].get("items") or [])
202
+ )
203
+ ) and not (
204
+ any(
205
+ isinstance(item, dict) and item.get("type") == "userMessage"
206
+ for item in (native_turns[-1].get("items") or [])
207
+ )
208
+ or report_from_turn(native_turns[-1]) is not None
209
+ ):
210
+ # Automatic compaction inside a genuine user/reply turn is
211
+ # not an internal-only turn; never discard its final answer.
212
+ native_turns.pop()
213
+ if running_without_receipt:
214
+ while native_turns and (
215
+ native_turns[-1].get("status") != "completed"
216
+ or report_from_turn(native_turns[-1]) is None
217
+ ):
218
+ native_turns.pop()
219
+ if not native_turns:
220
+ raise SessionUnavailable("no completed reply yet; a turn is still running")
221
+ if native_turns and native_turns[-1].get("status") in {
222
+ "inProgress", "in_progress", "pending",
223
+ }:
224
+ raise DeliveryRejected(
225
+ "latest Codex turn is busy; no completed report is available",
226
+ short=worker_id[:8], state="busy", refusal="busy",
227
+ )
228
+ # The LAST native turn decides the outcome. A later failed or
229
+ # interrupted turn must never borrow an older successful answer.
230
+ if not native_turns:
231
+ raise SessionUnavailable("no conversation turn yet")
232
+ turn = native_turns[-1]
233
+ status = turn.get("status")
234
+ if status not in {"completed", "failed", "interrupted", "cancelled"}:
235
+ raise SessionUnavailable("latest Codex turn has no terminal outcome")
236
+ tid = turn.get("id")
237
+ if not isinstance(tid, str) or not tid:
238
+ raise SessionUnavailable("latest Codex terminal turn has no native identity")
239
+ latest_ref = "ccx-turn:codex:v1:" + tid
240
+ chosen = settled(latest_ref) if status == "completed" else None
241
+ selected = report_from_turn(turn) if status == "completed" else None
242
+ latest = {
243
+ "turn_ref": latest_ref, "turn_id": tid, "status": status,
244
+ "phase": selected.get("phase") if selected else None,
245
+ "item_id": selected.get("itemId") if selected else None,
246
+ "completed_at": (selected.get("completedAt") if selected else
247
+ native_timestamp_ms(turn.get("completedAt"))),
248
+ "text": selected["text"] if selected else "",
249
+ "has_final_message": selected is not None,
250
+ }
251
+ else:
252
+ from ccx.ops.conversation_paging import _reverse_boundary_events
253
+ from ccx.claude.managed_turns import report_window_for_turn_ref
254
+ from ccx.claude.transcript import _iter_clean_turns, open_original_claude_source
255
+ last = record.get("lastSettledTurn") or {}
256
+ ref = last.get("turnRef")
257
+ candidate = settled(ref)
258
+ try:
259
+ with open_original_claude_source(store, owner, record) as source:
260
+ if source is not None:
261
+ pinned, _stat, eof, _path, _parent = source
262
+ # A reverse read over the same pinned native fd finds the
263
+ # last genuine user boundary without decoding months of
264
+ # prior JSONL on every completion notification. Ignore
265
+ # Claude's synthetic auto-continue as the cleaner does.
266
+ ordinary_boundaries: list[int] = []
267
+ report_end = eof
268
+ if running_without_receipt and ref:
269
+ window, available = report_window_for_turn_ref(worker_id, ref)
270
+ end = window.get("endOffset") if available and window else None
271
+ if (type(end) is int and 0 <= end <= eof
272
+ and window.get("endOffsetKnown") is True):
273
+ report_end = end
274
+ if running_without_receipt and report_end == eof:
275
+ active_ref = lifecycle.get("activeTurnRef")
276
+ if isinstance(active_ref, str) and active_ref:
277
+ window, available = report_window_for_turn_ref(worker_id, active_ref)
278
+ start = window.get("startOffset") if available and window else None
279
+ if type(start) is int and 0 <= start <= eof:
280
+ report_end = start
281
+ for event in _reverse_boundary_events(pinned, before_offset=report_end):
282
+ content = (event.get("message") or {}).get("content")
283
+ if isinstance(content, list):
284
+ content = "\n".join(
285
+ block.get("text", "") for block in content
286
+ if isinstance(block, dict) and block.get("type") == "text"
287
+ )
288
+ if content == "Continue from where you left off.":
289
+ continue
290
+ if (not ordinary_boundaries and isinstance(content, str)
291
+ and (content.strip() == "/compact"
292
+ or "<command-name>/compact</command-name>" in content)):
293
+ # Claude's local /compact and its command echo
294
+ # are not a conversation turn. Ignore the entire
295
+ # trailing command when selecting a reply.
296
+ report_end = min(report_end, event["_ccx_start_offset"])
297
+ continue
298
+ ordinary_boundaries.append(event["_ccx_start_offset"])
299
+ # A second boundary is needed only for an existing
300
+ # receipt: without the successor's managed start event,
301
+ # its endOffset is unknown and the latest turn may be
302
+ # an entirely different user request after that receipt.
303
+ if len(ordinary_boundaries) >= (2 if candidate else 1):
304
+ break
305
+ start_offset = ordinary_boundaries[0] if ordinary_boundaries else 0
306
+ text = ""
307
+ seen = False
308
+ for part in _iter_clean_turns(
309
+ pinned, after_offset=start_offset, before_offset=report_end,
310
+ ):
311
+ if part.get("role") == "user" and not part.get("queued"):
312
+ text = ""
313
+ seen = True
314
+ elif part.get("role") == "assistant":
315
+ seen = True
316
+ answer = part.get("text")
317
+ if isinstance(answer, str) and answer.strip():
318
+ text = answer.strip()
319
+ if seen:
320
+ latest = {"text": text, "has_final_message": bool(text)}
321
+ # The durable lastSettledTurn outcome belongs to this
322
+ # native answer only when its managed start/end offsets
323
+ # enclose the latest ordinary user boundary. An older
324
+ # receipt or interrupted terminal cannot be borrowed.
325
+ if ref and (candidate is not None or last.get("outcome") == "interrupted"):
326
+ window, available = report_window_for_turn_ref(worker_id, ref)
327
+ start = window.get("startOffset") if available and window else None
328
+ end = window.get("endOffset") if available and window else None
329
+ if (type(start) is int and start >= 0
330
+ and window.get("endOffsetKnown") is True
331
+ and window.get("settled") is True
332
+ and ordinary_boundaries
333
+ and start <= start_offset
334
+ and (end is None or start_offset < end)
335
+ and (len(ordinary_boundaries) == 1
336
+ or ordinary_boundaries[1] < start)):
337
+ if last.get("outcome") == "interrupted":
338
+ latest_status = "interrupted"
339
+ else:
340
+ chosen = candidate
341
+ except (StorageIntegrityError, OSError, ValueError) as exc:
342
+ if never_started:
343
+ raise SessionUnavailable("no conversation turn yet") from exc
344
+ raise SessionUnavailable("Claude conversation history is unavailable; check the worker and retry") from exc
345
+ if latest is None and record.get("historyKnownEmpty") is True:
346
+ raise SessionUnavailable("no conversation turn yet")
347
+ if latest is None:
348
+ # A synthetic/legacy source can be absent even though a single
349
+ # original receipt has already been positively proved. Preserve
350
+ # that strict receipt path unless a newer recorded terminal names
351
+ # a different turn. Never fabricate native transcript evidence.
352
+ proven = [r for r in receipts if isinstance(r.get("settlement"), dict)]
353
+ if candidate is not None:
354
+ chosen = candidate
355
+ elif not ref and len(proven) == 1:
356
+ chosen = proven[0]
357
+
358
+ if chosen is not None:
359
+ # Invoke precisely the old receipt-backed path, with its original
360
+ # effect reconciliation and strict identity checks intact.
361
+ result = report_worker(handle, turn_ref=chosen["turnRef"],
362
+ timeout=max(0.001, deadline - time.monotonic()),
363
+ max_chars=max_chars)
364
+ result.setdefault("outcome", None)
365
+ result["note"] = note
366
+ return result
367
+ if latest is None:
368
+ raise SessionUnavailable("no conversation turn yet")
369
+ full_text = latest["text"]
370
+ store.revalidate()
371
+ return {
372
+ "worker": handle, "worker_id": worker_id, "engine": engine,
373
+ "short": worker_id[:8], "delivery_id": None, "delivery": None,
374
+ "native_evidence_sha256": None,
375
+ "turn_ref": latest.get("turn_ref"), "turn_id": latest.get("turn_id"),
376
+ "turn": None, "item_id": latest.get("item_id"),
377
+ "phase": latest.get("phase"),
378
+ "status": "failed" if unfinished else latest.get("status", latest_status),
379
+ # For Claude, the exact lastSettledTurn interrupted verdict is bound
380
+ # above to this latest original user boundary. Keep its JSON outcome
381
+ # alongside status; never relabel a different native turn.
382
+ "outcome": (latest.get("status") if latest.get("status") in
383
+ {"interrupted", "cancelled"} else
384
+ latest_status if latest_status in {"interrupted", "cancelled"} else None),
385
+ "text": full_text if max_chars is None else full_text[:max_chars],
386
+ "truncated": max_chars is not None and len(full_text) > max_chars,
387
+ "has_final_message": latest["has_final_message"] and not unfinished,
388
+ "settled_at_ms": latest.get("completed_at"),
389
+ "source": "native-transcript-latest",
390
+ "note": note,
391
+ }
392
+
393
+
394
+ def report_worker(
395
+ target: str, *, timeout: float = 90,
396
+ max_chars: int | None = _DEFAULT_REPORT_CHARS,
397
+ turn_ref: str | None = None,
398
+ ) -> dict[str, Any]:
399
+ """Default latest turn, or one exact source-proven original turn_ref."""
400
+ from ccx.ops.wait import reconcile_turn
401
+ from ccx.claude import delivery as claude_delivery
402
+ from ccx import constants
403
+ from ccx.store.root import require_ccx_active
404
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
405
+ raise InvalidRequest("report timeout must be positive")
406
+ deadline = time.monotonic() + timeout
407
+ if max_chars is not None and (
408
+ type(max_chars) is not int or max_chars < 0):
409
+ raise InvalidRequest("report max_chars must be nonnegative integer")
410
+ handle, engine, worker_id = split_target(target)
411
+ if turn_ref is None:
412
+ return _latest_native_report(
413
+ handle, engine, worker_id, deadline=deadline, max_chars=max_chars,
414
+ )
415
+ from ccx.codex import delivery as codex_delivery
416
+ receipts = list(codex_delivery.iter_receipts(handle)
417
+ if engine == "codex" else claude_delivery.iter_receipts(handle))
418
+ from ccx.ops import turn_reports
419
+ native_turn = turn_reports._ref_id(turn_ref, engine=engine)
420
+ has_receipt = any(
421
+ row.get("turnRef") == turn_ref
422
+ or (engine == "claude" and row.get("targetTurnRef") == turn_ref)
423
+ or (engine == "codex" and native_turn in (
424
+ row.get("turnId"), row.get("targetTurnId")
425
+ ))
426
+ for row in receipts
427
+ )
428
+ if not has_receipt:
429
+ # No original semantic delivery of any state names this R. A
430
+ # matching pending or uncertain token remains receipt-authoritative.
431
+ store = require_ccx_active(constants.CCX_ROOT)
432
+ owner = store.resolve(handle)
433
+ if engine == "codex":
434
+ from ccx.codex.runtime import read_thread
435
+ record = store.record(handle)
436
+ native_id = (record.get("native") or {}).get("threadId")
437
+ if not isinstance(native_id, str) or not native_id:
438
+ raise SessionUnavailable("Codex conversation is missing; inspect the worker")
439
+ if not isinstance(record.get("cwd"), str):
440
+ raise SessionUnavailable("Codex working directory is missing; inspect the worker")
441
+ thread = read_thread(
442
+ native_id, cwd=record["cwd"], worker_id=owner.worker_id,
443
+ expected_source=store.codex_source(owner.worker),
444
+ timeout=max(0.001, deadline - time.monotonic()),
445
+ )
446
+ else:
447
+ thread = None
448
+ exact = turn_reports.exact_report(store, owner, turn_ref, thread=thread)
449
+ full_text = exact["text"]
450
+ text = full_text if max_chars is None else full_text[:max_chars]
451
+ return {
452
+ "worker": handle, "worker_id": worker_id, "engine": engine,
453
+ "short": worker_id[:8], "delivery_id": None,
454
+ "delivery": None, "native_evidence_sha256": None,
455
+ "turn_ref": exact["turn_ref"], "turn_id": exact.get("turn_id"),
456
+ "turn": exact.get("turn"), "item_id": exact.get("item_id"),
457
+ "phase": exact.get("phase"), "status": "completed", "outcome": None,
458
+ "text": text, "truncated": len(text) != len(full_text),
459
+ "has_final_message": exact["has_final_message"],
460
+ "settled_at_ms": exact.get("completed_at"),
461
+ "source": "original-native-exact-turn",
462
+ }
463
+ if engine == "codex":
464
+ # Report observes the native original Codex thread/turn without any
465
+ # new provider mutation. Each receipt remains the only owner token.
466
+ for candidate in receipts:
467
+ if (candidate.get("state") == "accepted"
468
+ and not candidate.get("settlement")
469
+ and candidate.get("turnRef") == turn_ref):
470
+ reconcile_turn(
471
+ handle, delivery_id=candidate["deliveryId"],
472
+ timeout=max(0.001, deadline - time.monotonic()),
473
+ )
474
+ rows = [
475
+ row for row in codex_delivery.iter_receipts(handle)
476
+ if isinstance(row.get("settlement"), dict)
477
+ and row.get("turnRef") == turn_ref
478
+ ]
479
+ if not rows:
480
+ raise SessionUnavailable("Codex turn is not confirmed complete; try again later")
481
+ rows.sort(key=lambda row: (
482
+ -(row.get("settledAtMs") or 0), row.get("deliveryId") or ""))
483
+ row = rows[0]
484
+ actual = row["settlement"]
485
+ if (actual.get("workerId") != worker_id
486
+ or actual.get("turnId") != row.get("turnId")
487
+ or actual.get("status") != "completed"):
488
+ raise SessionUnavailable("Codex completion could not be verified; inspect the turn")
489
+ raw = actual["text"]
490
+ text = raw if max_chars is None else raw[:max_chars]
491
+ return {
492
+ "worker": handle, "worker_id": worker_id, "engine": "codex",
493
+ "short": worker_id[:8], "delivery_id": row["deliveryId"],
494
+ "turn_id": row["turnId"], "turn_ref": row["turnRef"],
495
+ "phase": "completed", "status": "completed", "outcome": None,
496
+ "text": text, "truncated": len(text) != len(raw),
497
+ "has_final_message": bool(raw),
498
+ "settled_at_ms": row.get("settledAtMs"),
499
+ "native_evidence_sha256": actual.get("nativeEvidenceSha256"),
500
+ "delivery": codex_delivery.public_receipt(row),
501
+ "source": "original-native-completion-receipt",
502
+ }
503
+ store = require_ccx_active(constants.CCX_ROOT)
504
+ matches = [
505
+ row for row in receipts
506
+ if isinstance(row.get("settlement"), dict)
507
+ and row.get("turnRef") == turn_ref
508
+ ]
509
+ if not matches:
510
+ raise SessionUnavailable(
511
+ "No verified completed reply is available for this turn"
512
+ )
513
+ row = matches[0]
514
+ # Host publication is a pair of fsynced effects: first the original
515
+ # semantic Stop receipt, then the global native-effect accepted receipt.
516
+ # A report racing those two writes (or arriving after a host crash) must
517
+ # forward-reconcile the same original Stop source, not return a false
518
+ # success while the globally durable native effect remains unknown.
519
+ verified = reconcile_turn(handle, delivery_id=row["deliveryId"])
520
+ if verified["status"] != "settled":
521
+ raise SessionUnavailable(
522
+ "Claude turn is not confirmed complete; reconcile its delivery"
523
+ )
524
+ settlement = row["settlement"]
525
+ if (settlement.get("workerId") != worker_id
526
+ or settlement.get("turnRef") != row.get("turnRef")
527
+ or settlement.get("status") != "completed"):
528
+ raise SessionUnavailable("Claude completion could not be verified; inspect the delivery")
529
+ full_text = settlement["text"]
530
+ text = full_text if max_chars is None else full_text[:max_chars]
531
+ return {
532
+ "worker": handle, "worker_id": worker_id, "engine": "claude",
533
+ "short": worker_id[:8], "delivery_id": row["deliveryId"],
534
+ "turn_ref": row["turnRef"],
535
+ "phase": "completed", "status": "completed", "outcome": None,
536
+ "text": text, "truncated": len(text) != len(full_text),
537
+ "has_final_message": bool(full_text),
538
+ "settled_at_ms": row.get("settledAtMs"),
539
+ "native_evidence_sha256": settlement.get("nativeEvidenceSha256"),
540
+ "delivery": claude_delivery.public_receipt(row),
541
+ "source": "original-native-completion-receipt",
542
+ }
543
+
544
+
545
+ def account_usage_read(
546
+ *,
547
+ engine: str,
548
+ timeout: float = 30,
549
+ ) -> dict[str, Any]:
550
+ if engine == "codex":
551
+ return codex_ops.account_usage_read(timeout=timeout)
552
+ if engine != "claude":
553
+ raise InvalidRequest(f"unsupported engine: {engine!r}")
554
+ result = _WORKERS.usage("claude", timeout=timeout)
555
+ windows = []
556
+ for window in result.get("windows") or []:
557
+ used = window.get("used_percent")
558
+ windows.append({
559
+ "provider": "claude",
560
+ "window_id": window.get("id"),
561
+ "limit_id": window.get("id"),
562
+ "kind": window.get("label") or "quota",
563
+ "window_duration_seconds": None,
564
+ "used_percent": used,
565
+ "remaining_percent": (
566
+ max(0.0, 100.0 - float(used))
567
+ if isinstance(used, (int, float))
568
+ and not isinstance(used, bool)
569
+ else None
570
+ ),
571
+ "resets_at": window.get("resets_at"),
572
+ })
573
+ return {
574
+ "provider": "claude",
575
+ "observed_at_ms": int(time.time() * 1000),
576
+ "windows": windows,
577
+ }
578
+
579
+
580
+ def _conversation_revision(document: dict[str, Any]) -> str:
581
+ # This revision fences the paged conversation CONTENT, not the read operation.
582
+ # generatedAt, live state and top-level coverage/evidence metadata can change
583
+ # while the ordered normalized turns are byte-for-byte identical.
584
+ revision_source = {
585
+ "schemaVersion": document.get("schemaVersion"),
586
+ "turns": document.get("turns") or [],
587
+ }
588
+ raw = json.dumps(
589
+ revision_source,
590
+ sort_keys=True,
591
+ separators=(",", ":"),
592
+ ensure_ascii=False,
593
+ ).encode("utf-8")
594
+ return hashlib.sha256(raw).hexdigest()
595
+
596
+
597
+ def read_transcript(
598
+ locator: str,
599
+ *,
600
+ cursor: str | None = None,
601
+ limit: int = _DEFAULT_TRANSCRIPT_PAGE,
602
+ max_tool_result_bytes: int | None = _DEFAULT_TRANSCRIPT_TOOL_BYTES,
603
+ timeout: float = 120,
604
+ ) -> dict[str, Any]:
605
+ """Read normalized turns from the original owned full-UUID conversation."""
606
+ from ccx.ops import conversation as normalized
607
+ from ccx.errors import CursorInvalidated, OperationTimeout
608
+ from ccx.store.identity import IdentityError, UnknownWorker
609
+
610
+ # Independent from the source-record ccxcp2 pager and its cursor semantics.
611
+ payload = _decode_opaque(_TRANSCRIPT_LOCATOR_PREFIX, locator,
612
+ label="transcript locator")
613
+ expected = {"v", "engine", "worker_id", "root", "generation"}
614
+ if (set(payload) != expected or type(payload["v"]) is not int
615
+ or payload["v"] != 2
616
+ or not isinstance(payload["engine"], str)
617
+ or payload["engine"] not in _ENGINES
618
+ or not isinstance(payload["root"], str)
619
+ or len(payload["root"]) != 64
620
+ or any(ch not in "0123456789abcdef" for ch in payload["root"])
621
+ or not isinstance(payload["generation"], str)
622
+ or not payload["generation"]):
623
+ raise InvalidRequest("invalid transcript locator")
624
+ try:
625
+ canonical_worker_id(payload["worker_id"])
626
+ except IdentityError as error:
627
+ raise InvalidRequest("invalid transcript locator worker") from error
628
+
629
+ offset = 0
630
+ prior_revision = None
631
+ if cursor is not None:
632
+ token = _decode_opaque(_TRANSCRIPT_CURSOR_PREFIX, cursor,
633
+ label="transcript cursor")
634
+ if (set(token) != expected | {"offset", "revision"}
635
+ or type(token["v"]) is not int or token["v"] != 2
636
+ or type(token["offset"]) is not int or token["offset"] < 0
637
+ or not isinstance(token["revision"], str)
638
+ or len(token["revision"]) != 64
639
+ or any(ch not in "0123456789abcdef" for ch in token["revision"])
640
+ or not isinstance(token["generation"], str)
641
+ or not token["generation"]):
642
+ raise InvalidRequest("invalid transcript cursor")
643
+ if any(token[key] != payload[key] for key in
644
+ ("engine", "worker_id", "root")):
645
+ raise InvalidRequest("transcript cursor belongs to another source")
646
+ offset, prior_revision = token["offset"], token["revision"]
647
+ if type(limit) is not int or not 1 <= limit <= 100:
648
+ raise InvalidRequest("transcript limit must be between 1 and 100")
649
+ normalized._requested_label(max_tool_result_bytes)
650
+ if (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
651
+ or not 0 < timeout < float("inf")):
652
+ raise InvalidRequest("transcript timeout must be positive and finite")
653
+ deadline = time.monotonic() + timeout
654
+
655
+ client = WorkerClient()
656
+ store = client._uuid_store
657
+ root = hashlib.sha256(os.fsencode(store.root)).hexdigest()
658
+ if payload["root"] != root:
659
+ raise InvalidRequest("transcript locator belongs to another root")
660
+ if (payload["generation"] != store.generation
661
+ or (cursor is not None and token["generation"] != store.generation)):
662
+ raise CursorInvalidated(
663
+ "transcript generation changed; restart with a new locator"
664
+ )
665
+ try:
666
+ owner = store.resolve(f"{payload['engine']}:{payload['worker_id']}")
667
+ except UnknownWorker as error:
668
+ raise worker_not_found(f"{payload['engine']}:{payload['worker_id']}") from error
669
+ except IdentityError as error:
670
+ raise InvalidRequest("transcript locator engine does not own worker") from error
671
+
672
+ remaining = deadline - time.monotonic()
673
+ if remaining <= 0:
674
+ raise OperationTimeout("transcript read deadline expired")
675
+ document = client.conversation(
676
+ owner.worker, max_tool_result_bytes=max_tool_result_bytes,
677
+ timeout=remaining,
678
+ )
679
+ revision = _conversation_revision(document)
680
+ turns = list(document.get("turns") or [])
681
+ page = turns[offset:offset + limit]
682
+ next_offset = offset + len(page)
683
+ has_more = next_offset < len(turns)
684
+ next_cursor = (
685
+ _encode_opaque(
686
+ _TRANSCRIPT_CURSOR_PREFIX,
687
+ {**payload, "offset": next_offset, "revision": revision},
688
+ )
689
+ if has_more
690
+ else None
691
+ )
692
+ # Reuse the existing protocol recorded-only snapshot; never restore live inspect.
693
+ inspected = recorded_inspector(store, owner.worker)
694
+ store.revalidate()
695
+ if time.monotonic() >= deadline:
696
+ raise OperationTimeout("transcript read deadline expired")
697
+ return {
698
+ "worker": inspected,
699
+ "transcript_locator": locator,
700
+ "conversation_schema_version": document.get("schemaVersion"),
701
+ "turns": page,
702
+ "completeness": document.get("completeness"),
703
+ "evidence": document.get("evidence"),
704
+ "source_revision": revision,
705
+ "source_changed": (
706
+ prior_revision is not None and prior_revision != revision
707
+ ),
708
+ "next_cursor": next_cursor,
709
+ "has_more": has_more,
710
+ }
711
+
712
+
713
+ def conversation_page(
714
+ target: str,
715
+ *,
716
+ cursor: str | None = None,
717
+ source_record_target: int = 40,
718
+ max_tool_result_bytes: int | None = 16_384,
719
+ timeout: float = 30,
720
+ ) -> dict[str, Any]:
721
+ return WorkerClient().conversation_page(
722
+ target,
723
+ cursor=cursor,
724
+ source_record_target=source_record_target,
725
+ max_tool_result_bytes=max_tool_result_bytes,
726
+ timeout=timeout,
727
+ )
728
+
729
+
730
+ def telemetry_worker(
731
+ target: str,
732
+ *,
733
+ after_cursor: str | None = None,
734
+ limit: int = 200,
735
+ timeout: float = 120,
736
+ ) -> dict[str, Any]:
737
+ """Original full-UUID source-bound Claude/Codex telemetry read."""
738
+ from ccx import constants
739
+ from ccx.store.root import require_ccx_active, GenerationError
740
+ from ccx.claude.transcript import open_original_claude_source
741
+
742
+ store = require_ccx_active(constants.CCX_ROOT)
743
+ owner = store.resolve(target)
744
+ if owner.engine == "codex":
745
+ result = codex_ops.telemetry_worker(
746
+ owner.worker, after_cursor=after_cursor,
747
+ limit=limit, timeout=timeout,
748
+ )
749
+ store.revalidate()
750
+ return result
751
+ rec = store.record(owner.worker)
752
+ sid = (rec.get("native") or {}).get("sessionId")
753
+ try:
754
+ with open_original_claude_source(store, owner, rec) as original:
755
+ pinned = original[0] if original is not None else ""
756
+ end_offset = original[2] if original is not None else 0
757
+ view = {**rec, "short": owner.short, "sessionId": sid,
758
+ "transcript": pinned}
759
+ result = claude_telemetry.read_telemetry(
760
+ owner.short, after_cursor=after_cursor, limit=limit,
761
+ record=view, cursor_worker_id=owner.worker_id,
762
+ before_offset=end_offset, source_missing=original is None,
763
+ )
764
+ store.revalidate()
765
+ return result
766
+ except (GenerationError, OSError) as error:
767
+ raise SessionUnavailable(
768
+ f"Cannot read Claude activity; check the worker: {error}",
769
+ short=owner.short,
770
+ ) from error