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,2281 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from contextlib import ExitStack, contextmanager
5
+ import io
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import re
10
+ import stat
11
+ import time
12
+
13
+ from ccx.system import oslayer
14
+ from ccx.claude.context import compaction_unknown_context, compute_context_usage
15
+ from ccx.store.paths import transcript_json_path
16
+ from ccx.store.record_cache import StampedCache
17
+ from ccx.store.records import read_record
18
+
19
+
20
+ class PinnedNativeSource:
21
+ """Retained original native FD, read with independent logical offsets.
22
+
23
+ Darwin /dev/fd/N and dup(N) share the original file description's seek
24
+ position. pread leaves that offset unchanged while preserving inode
25
+ identity even if the provider path is replaced. The owner closes the FD.
26
+ """
27
+
28
+ def __init__(self, fd: int):
29
+ self.fd = fd
30
+
31
+ def size(self) -> int:
32
+ return os.fstat(self.fd).st_size
33
+
34
+ def open_reader(self) -> io.BufferedReader:
35
+ held = self
36
+
37
+ class _PositionedReader(io.RawIOBase):
38
+ def __init__(self):
39
+ self.position = 0
40
+
41
+ def readable(self):
42
+ return True
43
+
44
+ def seekable(self):
45
+ return True
46
+
47
+ def tell(self):
48
+ return self.position
49
+
50
+ def seek(self, offset, whence=os.SEEK_SET):
51
+ if whence == os.SEEK_CUR:
52
+ offset += self.position
53
+ elif whence == os.SEEK_END:
54
+ offset += held.size()
55
+ elif whence != os.SEEK_SET:
56
+ raise ValueError("invalid positioned source seek mode")
57
+ if offset < 0:
58
+ raise ValueError("negative positioned source seek")
59
+ self.position = offset
60
+ return offset
61
+
62
+ def readinto(self, target):
63
+ data = os.pread(held.fd, len(target), self.position)
64
+ target[:len(data)] = data
65
+ self.position += len(data)
66
+ return len(data)
67
+
68
+ return io.BufferedReader(_PositionedReader())
69
+
70
+ def read_ai_title(path: str | pathlib.Path, *, max_lines: int = 100) -> str | None:
71
+ """Return the first ai-title from the early lines of a JSONL transcript, or None.
72
+
73
+ Claude Code writes the ``ai-title`` event before the first assistant
74
+ response — typically around line 6 — so scanning only the first
75
+ ``max_lines`` lines is both sufficient and cheap. Accepts both the
76
+ current ``aiTitle`` key and the older ``title`` key for compatibility.
77
+ """
78
+ path = pathlib.Path(path)
79
+ try:
80
+ with path.open() as f:
81
+ for i, raw in enumerate(f):
82
+ if i >= max_lines:
83
+ break
84
+ raw = raw.strip()
85
+ if not raw:
86
+ continue
87
+ try:
88
+ evt = json.loads(raw)
89
+ except ValueError:
90
+ continue
91
+ if evt.get("type") != "ai-title" or evt.get("isSidechain") is True:
92
+ continue
93
+ title = evt.get("aiTitle") or evt.get("title")
94
+ if isinstance(title, str) and title:
95
+ return title
96
+ except OSError:
97
+ pass
98
+ return None
99
+
100
+
101
+ def _iter_transcript(
102
+ path: str | pathlib.Path,
103
+ stats: dict | None = None,
104
+ *,
105
+ after_offset: int = 0,
106
+ before_offset: int | None = None,
107
+ include_sidechain: bool = False,
108
+ ):
109
+ """Yield parsed JSON events from a transcript JSONL, skipping malformed lines.
110
+
111
+ If a mutable `stats` dict is passed, each dropped malformed line increments
112
+ `stats["malformedLines"]` so callers can surface lossy parsing. Counting is
113
+ opt-in; existing callers that pass nothing see no behavior change.
114
+
115
+ Subagent (`isSidechain: true`) rows are never the session's own: they are
116
+ skipped unless `include_sidechain` is set by a reader that deliberately
117
+ reads a subagent transcript. Current Claude writes them only to
118
+ `<session>/subagents/` files, so a main transcript normally has none."""
119
+ try:
120
+ reader = (path.open_reader() if isinstance(path, PinnedNativeSource)
121
+ else open(path, "rb"))
122
+ with reader as f:
123
+ offset = max(0, int(after_offset or 0))
124
+ try:
125
+ size = (path.size() if isinstance(path, PinnedNativeSource)
126
+ else pathlib.Path(path).stat().st_size)
127
+ except OSError:
128
+ size = 0
129
+ # A replaced/truncated transcript invalidates the byte cursor.
130
+ # Never restart at zero for a fenced operation: that could
131
+ # misattribute an older boundary to a newer compact.
132
+ if offset > size:
133
+ return
134
+ f.seek(offset)
135
+ while True:
136
+ start_offset = f.tell()
137
+ line = f.readline()
138
+ if not line:
139
+ break
140
+ end_offset = f.tell()
141
+ if before_offset is not None and end_offset > before_offset:
142
+ break
143
+ line = line.strip()
144
+ if not line:
145
+ continue
146
+ try:
147
+ event = json.loads(line)
148
+ if (
149
+ not include_sidechain
150
+ and isinstance(event, dict)
151
+ and event.get("isSidechain") is True
152
+ ):
153
+ continue
154
+ if isinstance(event, dict):
155
+ event["_ccx_start_offset"] = start_offset
156
+ event["_ccx_end_offset"] = end_offset
157
+ yield event
158
+ except (ValueError, UnicodeDecodeError):
159
+ if stats is not None:
160
+ stats["malformedLines"] = stats.get("malformedLines", 0) + 1
161
+ continue
162
+ except FileNotFoundError:
163
+ return
164
+
165
+ def _parse_event_ts_ms(evt: dict) -> int | None:
166
+ """Pull a millisecond timestamp from a transcript event if present."""
167
+ ts = evt.get("timestamp")
168
+ if isinstance(ts, str):
169
+ # ISO 8601
170
+ try:
171
+ import datetime as _dt
172
+ return int(_dt.datetime.fromisoformat(ts.replace("Z","+00:00")).timestamp() * 1000)
173
+ except Exception:
174
+ return None
175
+ if isinstance(ts, (int, float)):
176
+ return int(ts if ts > 10**12 else ts * 1000)
177
+ return None
178
+
179
+
180
+ def paste_normalized(text: str) -> str:
181
+ """The text Claude records for a bracketed paste of ``text``.
182
+
183
+ Claude's paste handler turns CRLF/CR into LF and expands each tab to four
184
+ spaces before inserting a paste (2.1.294), so the prompt CCX wrote and the
185
+ prompt Claude journals differ whenever either appears. Idempotent, so it is
186
+ safe on both sides of a comparison.
187
+ """
188
+ return text.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ")
189
+
190
+
191
+ def queued_prompt_accepted(
192
+ transcript_path: str | pathlib.Path,
193
+ text: str,
194
+ *,
195
+ after_ms: int = 0,
196
+ ) -> bool:
197
+ """Return True once Claude has natively accepted a queued prompt.
198
+
199
+ Claude Code records text pasted while a turn is between tool steps as a raw
200
+ JSONL `queue-operation` event before surfacing it to the model as a
201
+ `queued_command` attachment. ccx uses this as the acknowledgement that a
202
+ PostToolUse/SubagentStop steering paste was actually accepted, not merely
203
+ written to the PTY.
204
+
205
+ Only the exact normalized prompt acknowledges this delivery.
206
+ """
207
+ # Claude trims a submitted prompt; an inline paste has no envelope that
208
+ # would keep the surrounding whitespace.
209
+ text = normalize_semantic_prompt_text(text).strip()
210
+ if not text:
211
+ return False
212
+ for evt in _iter_transcript(transcript_path):
213
+ if evt.get("type") != "queue-operation":
214
+ continue
215
+ if evt.get("operation") != "enqueue":
216
+ continue
217
+ content = evt.get("content")
218
+ if not isinstance(content, str) or not prompt_matches(text, content):
219
+ continue
220
+ ts_ms = _parse_event_ts_ms(evt)
221
+ if after_ms and ts_ms is not None and ts_ms < after_ms:
222
+ continue
223
+ return True
224
+ return False
225
+
226
+
227
+ # --- steering delivery evidence ------------------------------------------------
228
+ #
229
+ # A steered prompt leaves several distinguishable native traces. Ranked from
230
+ # weakest to strongest placement proof:
231
+ #
232
+ # enqueued Claude recorded a `queue-operation`/`enqueue` for the text. The
233
+ # TUI owns the prompt: accepted, but not yet placed in a turn.
234
+ # started The text appears as an ordinary user event (or a `dequeue`),
235
+ # i.e. it will run as the next turn.
236
+ # consumed The text reached the model as a `queued_command` attachment on
237
+ # the running turn — it affected the current turn.
238
+ #
239
+ # Matching is normalized equality within one event, fenced by transcript byte
240
+ # offset. Offsets (not timestamps) are the correlation key: Claude journals
241
+ # some events out of clock order, and identical prompt text may legitimately
242
+ # occur more than once in a conversation.
243
+ STEER_EVIDENCE_RANK = {"enqueued": 1, "started": 2, "consumed": 3}
244
+
245
+
246
+ def _block_text(content) -> str:
247
+ """Flatten a message content value (string or block list) to plain text."""
248
+ if isinstance(content, str):
249
+ return content
250
+ if isinstance(content, list):
251
+ parts = []
252
+ for block in content:
253
+ if isinstance(block, str):
254
+ parts.append(block)
255
+ elif isinstance(block, dict):
256
+ for key in ("text", "content"):
257
+ value = block.get(key)
258
+ if isinstance(value, str):
259
+ parts.append(value)
260
+ elif isinstance(value, list):
261
+ parts.append(_block_text(value))
262
+ return "\n".join(parts)
263
+ return ""
264
+
265
+
266
+ def _has_queued_command_marker(evt: dict) -> bool:
267
+ """True when a user event carries Claude's queued-command attachment.
268
+
269
+ Claude surfaces a prompt that was enqueued mid-turn to the model as a
270
+ `queued_command` attachment rather than a fresh user turn, so this marker
271
+ is the proof that the steering affected the turn already running.
272
+ """
273
+ content = (evt.get("message") or {}).get("content")
274
+ blocks = content if isinstance(content, list) else []
275
+ for block in blocks:
276
+ if isinstance(block, dict) and block.get("type") == "queued_command":
277
+ return True
278
+ for attachment in evt.get("attachments") or []:
279
+ if isinstance(attachment, dict) and attachment.get("type") == "queued_command":
280
+ return True
281
+ return False
282
+
283
+
284
+ def steer_evidence_events(
285
+ transcript_path: str | pathlib.Path,
286
+ text: str,
287
+ *,
288
+ after_offset: int = 0,
289
+ before_offset: int | None = None,
290
+ stats: dict | None = None,
291
+ ):
292
+ """Yield native placement evidence for one steered prompt.
293
+
294
+ Each item is ``{"kind", "offset", "uuid", "rank"}`` in file order. Callers
295
+ correlate by ``offset`` so two deliveries with identical text can never
296
+ claim the same event.
297
+ """
298
+ # Claude trims a submitted prompt; an inline paste has no envelope that
299
+ # would keep the surrounding whitespace.
300
+ text = normalize_semantic_prompt_text(text).strip()
301
+ if not text:
302
+ return
303
+ for evt in _iter_transcript(transcript_path, stats=stats,
304
+ after_offset=after_offset,
305
+ before_offset=before_offset):
306
+ kind = None
307
+ if evt.get("type") == "queue-operation":
308
+ content = evt.get("content")
309
+ if not isinstance(content, str) or not prompt_matches(text, content):
310
+ continue
311
+ operation = evt.get("operation")
312
+ if operation == "enqueue":
313
+ kind = "enqueued"
314
+ elif operation == "dequeue":
315
+ kind = "started"
316
+ else:
317
+ continue
318
+ elif evt.get("type") == "user":
319
+ body = _block_text((evt.get("message") or {}).get("content"))
320
+ if not prompt_matches(text, body):
321
+ continue
322
+ kind = "consumed" if _has_queued_command_marker(evt) else "started"
323
+ elif evt.get("type") == "attachment":
324
+ attachment = evt.get("attachment") or {}
325
+ if attachment.get("type") != "queued_command":
326
+ continue
327
+ body = _block_text(attachment.get("prompt") or attachment.get("content"))
328
+ if not prompt_matches(text, body):
329
+ continue
330
+ # live Claude Code shape: a mid-turn queued message is persisted as
331
+ # its own attachment event, not necessarily inside a user event.
332
+ kind = "consumed"
333
+ else:
334
+ continue
335
+ yield {
336
+ "kind": kind,
337
+ "rank": STEER_EVIDENCE_RANK[kind],
338
+ "offset": evt.get("_ccx_end_offset"),
339
+ "uuid": evt.get("uuid"),
340
+ }
341
+
342
+
343
+ _PASTED_CONTENT_RE = re.compile(
344
+ r"""^\s*<pasted_content id="([^"]+)">\n(.*)\n</pasted_content id="\1">\s*$""",
345
+ re.DOTALL,
346
+ )
347
+
348
+
349
+ # Native queued attachments also persist the envelope's newline delimiters
350
+ # as literal escape sequences. Only unwrap a complete, same-id envelope.
351
+ _ESCAPED_PASTED_CONTENT_RE = re.compile(
352
+ r'''^\s*<pasted_content id="([^"]+)">\\n(.*)\\n</pasted_content id="\1">\s*$''',
353
+ re.DOTALL,
354
+ )
355
+
356
+
357
+ # Claude stores a typed `<pasted_content>` tag escaped as `<\\pasted_content>`.
358
+ _ESCAPED_TAG_RE = re.compile(r"<\\(/?pasted_content)")
359
+ # Claude turns a prompt that is only an image path into an `[Image #N]` row.
360
+ _IMAGE_PATH_RE = re.compile(r"^[/~][^\n]*\.(?:png|jpe?g|gif|webp)$", re.IGNORECASE)
361
+ _IMAGE_PLACEHOLDER_RE = re.compile(r"^\[Image #\d+\]$")
362
+
363
+
364
+ def normalize_semantic_prompt_text(value: str | None) -> str:
365
+ """Normalize Claude's empirically observed bracketed-paste envelope and
366
+ the edits its paste handler makes (see ``paste_normalized``)."""
367
+ if not isinstance(value, str):
368
+ return ""
369
+ match = (_PASTED_CONTENT_RE.match(value)
370
+ or _ESCAPED_PASTED_CONTENT_RE.match(value))
371
+ if match is not None:
372
+ value = match.group(2)
373
+ return _ESCAPED_TAG_RE.sub(r"<\1", paste_normalized(value))
374
+
375
+
376
+ def prompt_matches(expected: str, actual: str | None) -> bool:
377
+ """Whether native prompt text ``actual`` is the sent prompt ``expected``
378
+ (already normalized and stripped). A bare image path also matches the
379
+ ``[Image #N]`` placeholder Claude stores in its place."""
380
+ actual = normalize_semantic_prompt_text(actual).strip()
381
+ return bool(expected) and (actual == expected or bool(
382
+ _IMAGE_PATH_RE.match(expected) and _IMAGE_PLACEHOLDER_RE.match(actual)))
383
+
384
+
385
+ def exact_user_prompt_event(
386
+ transcript_path: str | pathlib.Path,
387
+ text: str,
388
+ *,
389
+ after_offset: int = 0,
390
+ ) -> dict | None:
391
+ """Return exact ordinary-user start evidence for one semantic prompt.
392
+
393
+ Prompts are single-entry and must not claim an unrelated prompt merely
394
+ because it contains the same substring.
395
+ """
396
+ expected = normalize_semantic_prompt_text(text).strip()
397
+ if not expected:
398
+ return None
399
+ for evt in _iter_transcript(transcript_path, after_offset=after_offset):
400
+ if evt.get("type") != "user" or evt.get("isMeta"):
401
+ continue
402
+ content = (evt.get("message") or {}).get("content")
403
+ if not prompt_matches(expected, _block_text(content)):
404
+ continue
405
+ if _has_queued_command_marker(evt):
406
+ continue
407
+ return {
408
+ "uuid": evt.get("uuid"),
409
+ "offset": evt.get("_ccx_end_offset"),
410
+ "timestamp_ms": _parse_event_ts_ms(evt),
411
+ }
412
+ return None
413
+
414
+
415
+ def original_started_user_event(
416
+ transcript_path: str | pathlib.Path,
417
+ text: str,
418
+ *,
419
+ after_offset: int,
420
+ evidence_uuid: str | None,
421
+ evidence_offset: int | None,
422
+ ) -> dict | None:
423
+ """Reverify one hot `started` claim as the original native USER row.
424
+
425
+ Native paste envelopes are unwrapped before exact comparison. A dequeue or an attachment
426
+ can also be `started`/related evidence; neither is an ordinary user turn.
427
+ Require that the journal's *same UUID and end offset* refer to a real
428
+ non-meta, non-queued user row after the original paste fence. The caller
429
+ must separately prove the host epoch, owner and selected native source.
430
+ """
431
+ if (not isinstance(evidence_uuid, str) or not evidence_uuid
432
+ or type(evidence_offset) is not int or evidence_offset <= 0
433
+ or type(after_offset) is not int or after_offset < 0):
434
+ return None
435
+ normalized = normalize_semantic_prompt_text(text).strip()
436
+ if not normalized:
437
+ return None
438
+ for event in _iter_transcript(transcript_path, after_offset=after_offset):
439
+ offset = event.get("_ccx_end_offset")
440
+ if type(offset) is not int:
441
+ continue
442
+ if offset > evidence_offset:
443
+ break
444
+ if (offset == evidence_offset and event.get("uuid") == evidence_uuid
445
+ and event.get("type") == "user" and not event.get("isMeta")
446
+ and not _has_queued_command_marker(event)
447
+ and prompt_matches(normalized, _block_text(
448
+ (event.get("message") or {}).get("content")))):
449
+ return {"uuid": evidence_uuid, "offset": evidence_offset,
450
+ "kind": "native-started-user"}
451
+ return None
452
+
453
+
454
+ def _is_compact_command_content(content) -> bool:
455
+ return (
456
+ isinstance(content, str)
457
+ and (
458
+ content.strip() == "/compact"
459
+ or "<command-name>/compact<" in content
460
+ )
461
+ )
462
+
463
+
464
+ #: Wrappers Claude uses to journal a slash command's own output as a user event.
465
+ #: They are the command's result, never a prompt somebody submitted.
466
+ _LOCAL_COMMAND_OUTPUT_TAGS = ("<local-command-stdout>", "<local-command-stderr>")
467
+
468
+
469
+ def _is_local_command_output(content) -> bool:
470
+ return (
471
+ isinstance(content, str)
472
+ and content.lstrip().startswith(_LOCAL_COMMAND_OUTPUT_TAGS)
473
+ )
474
+
475
+
476
+ def is_compact_command_text(text) -> bool:
477
+ """Public: does this prompt text represent a submitted ``/compact``?
478
+
479
+ Claude records a submitted ``/compact`` as an ordinary user event, so a
480
+ ``UserPromptSubmit`` hook fires for it exactly as it does for a real
481
+ prompt. Callers that must tell "our own compact command came back" from
482
+ "somebody else's turn took the input surface" discriminate on content.
483
+ """
484
+ return _is_compact_command_content(text)
485
+
486
+
487
+ def foreign_prompt_since(
488
+ transcript_path: str | pathlib.Path,
489
+ *,
490
+ after_offset: int = 0,
491
+ ) -> bool:
492
+ """Return True when a real, non-``/compact`` user prompt follows the fence.
493
+
494
+ Positive proof that Ink handed its input surface to an ordinary turn. Only
495
+ genuine prompts count: tool results are also ``type="user"`` but carry block
496
+ lists rather than a plain string, and meta/synthetic records are skipped.
497
+
498
+ This is the transcript-side twin of the ``UserPromptSubmit`` discriminator.
499
+ A host that missed the hook (restart, hook relay failure) can still reach
500
+ the same conclusion by reading the file.
501
+
502
+ Compaction's own bookkeeping is excluded, because callers also fence past a
503
+ finished compact boundary to ask "did a new turn start while we were
504
+ settling?". Three records land there that are ``type="user"`` with plain
505
+ string content but are not prompts: the generated summary
506
+ (``isCompactSummary``), the ``<command-name>/compact`` echo, and the
507
+ ``<local-command-stdout>Compacted…`` result. Counting any of them as a
508
+ foreign turn would report a busy worker after every single compaction.
509
+ """
510
+ for evt in _iter_transcript(transcript_path, after_offset=after_offset):
511
+ if evt.get("type") != "user" or evt.get("isMeta"):
512
+ continue
513
+ if evt.get("isCompactSummary"):
514
+ continue
515
+ content = (evt.get("message") or {}).get("content")
516
+ if not isinstance(content, str) or not content.strip():
517
+ continue
518
+ if _is_compact_command_content(content):
519
+ continue
520
+ if _is_local_command_output(content):
521
+ continue
522
+ return True
523
+ return False
524
+
525
+
526
+ def compact_command_seen_since(
527
+ transcript_path: str | pathlib.Path,
528
+ *,
529
+ after_ms: int = 0,
530
+ after_offset: int = 0,
531
+ ) -> bool:
532
+ """Return True once the transcript records a submitted ``/compact`` command.
533
+
534
+ Submission acknowledgement only — the *result* (a ``compact_boundary`` or a
535
+ "Not enough messages to compact" stdout) can arrive minutes later; see
536
+ :func:`compact_result_since` for that. Claude Code logs a submitted slash
537
+ command as a user event whose content is the literal command text and/or a
538
+ ``<command-name>`` local-command record.
539
+ """
540
+ for evt in _iter_transcript(transcript_path, after_offset=after_offset):
541
+ content = None
542
+ if evt.get("type") == "system" and evt.get("subtype") == "local_command":
543
+ content = evt.get("content")
544
+ elif evt.get("type") == "user":
545
+ content = (evt.get("message") or {}).get("content")
546
+ if not _is_compact_command_content(content):
547
+ continue
548
+ ts_ms = _parse_event_ts_ms(evt)
549
+ if after_ms and ts_ms is not None and ts_ms < after_ms:
550
+ continue
551
+ return True
552
+ return False
553
+
554
+
555
+ def pending_compact_request(
556
+ transcript_path: str | pathlib.Path, *, before_offset: int | None = None,
557
+ ) -> dict | None:
558
+ """Return the latest submitted ``/compact`` that has no terminal result.
559
+
560
+ File order is authoritative. Compaction timestamps can run slightly
561
+ backwards around the boundary because Claude journals the generated summary
562
+ and preserved messages together. A successful boundary or either
563
+ local-command wrapper settles the pending request: stdout covers command
564
+ replay and harmless no-ops, stderr covers failures. Both are matched on the
565
+ wrapper alone, without parsing the human text inside it — settling here only
566
+ means "the command finished", and :func:`compact_result_since` is what reads
567
+ the verdict.
568
+ """
569
+ pending = None
570
+ for evt in _iter_transcript(transcript_path, before_offset=before_offset):
571
+ if evt.get("type") == "system" and evt.get("subtype") == "compact_boundary":
572
+ pending = None
573
+ continue
574
+
575
+ content = None
576
+ if evt.get("type") == "system" and evt.get("subtype") == "local_command":
577
+ content = evt.get("content")
578
+ elif evt.get("type") == "user":
579
+ content = (evt.get("message") or {}).get("content")
580
+
581
+ if _is_compact_command_content(content):
582
+ pending = {"as_of_ms": _parse_event_ts_ms(evt)}
583
+ continue
584
+ if (
585
+ pending is not None
586
+ and isinstance(content, str)
587
+ and (
588
+ "<local-command-stdout>" in content
589
+ or "<local-command-stderr>" in content
590
+ )
591
+ ):
592
+ pending = None
593
+ return pending
594
+
595
+
596
+ # A failed ``/compact`` is reported as local-command stderr. Two shapes reach it:
597
+ # Claude Code's own compaction errors ("Error during compaction: <detail>",
598
+ # "Compaction failed \xb7 <why>"), and a rethrow whose String(Error) adds a
599
+ # leading "Error: ". Both are matched on the compaction markers rather than on
600
+ # the stderr wrapper alone, so an unrelated slash command's stderr inside the
601
+ # fence window can never be mistaken for a compact verdict.
602
+ _COMPACT_STDERR_RE = re.compile(
603
+ r"<local-command-stderr>(.*?)</local-command-stderr>", re.DOTALL,
604
+ )
605
+ # anchored at the start of the stderr payload, never merely contained in it.
606
+ # `failed` is monotonic, so a false positive is unrepairable: it would terminally
607
+ # fail an operation that may still be running, and no later boundary could undo
608
+ # it. A message that merely mentions compaction in passing must not qualify.
609
+ _COMPACT_FAILURE_RE = re.compile(
610
+ r"^(?:Error:\s*)?(?:Error during compaction:|Compaction failed(?:\s|$))",
611
+ )
612
+
613
+ # When compaction fails because of an API error, Claude Code embeds the already
614
+ # formatted message rather than structured fields, so everything must be read
615
+ # back out of the prose — unlike the turn path, which reads `apiErrorStatus` and
616
+ # `error` directly.
617
+ _API_ERROR_RE = re.compile(r"\bAPI Error(?:\s*\([^)]*\))?:")
618
+ _API_ERROR_STATUS_RE = re.compile(r"API Error(?:\s*\([^)]*\))?:\s*(\d{3})\b")
619
+ _REPEATED_OVERLOAD_RE = re.compile(r"Repeated (\d{3}) Overloaded errors")
620
+
621
+ # Claude Code writes this phrase on exactly the branches its own rule calls
622
+ # transient (5xx, repeated 529, lost connection), so it is evidence rather than
623
+ # a guess — which matters for the failures that carry no status at all, such as
624
+ # a stream dying after a 200.
625
+ _TRANSIENT_PHRASE = "usually temporary"
626
+
627
+
628
+ def _compact_failure_from_content(content: str) -> dict | None:
629
+ """Return the failure verdict carried by a local-command stderr record."""
630
+ match = _COMPACT_STDERR_RE.search(content)
631
+ if match is None:
632
+ return None
633
+ message = match.group(1).strip()
634
+ if _COMPACT_FAILURE_RE.match(message) is None:
635
+ return None
636
+ # `String(err)` on the rethrow path prefixes the class name; the message
637
+ # underneath is the part a human needs.
638
+ if message.startswith("Error: "):
639
+ message = message[len("Error: "):]
640
+
641
+ if _API_ERROR_RE.search(message) is None:
642
+ # Compaction failed on its own terms — the conversation could not be
643
+ # reduced, media was unstrippable. Nothing to attribute to the API, and
644
+ # retrying will fail the same way.
645
+ return {"message": message, "api_error": None}
646
+
647
+ status = None
648
+ status_match = _API_ERROR_STATUS_RE.search(message) or \
649
+ _REPEATED_OVERLOAD_RE.search(message)
650
+ if status_match is not None:
651
+ status = int(status_match.group(1))
652
+ # The API cause is recorded even when no status could be recovered; dropping
653
+ # it there would silently downgrade a retryable server fault to "compaction
654
+ # is impossible".
655
+ return {
656
+ "message": message,
657
+ "api_error": {
658
+ "status": status,
659
+ "error": None,
660
+ "request_id": None,
661
+ "text": message,
662
+ "ts": None,
663
+ "transient": (
664
+ (status is not None and status >= 500)
665
+ or _TRANSIENT_PHRASE in message.lower()
666
+ ),
667
+ "rate_limited": status == 429,
668
+ "reset_text": _reset_text_from(message) if status == 429 else None,
669
+ },
670
+ }
671
+
672
+
673
+ def compact_result_since(
674
+ transcript_path: str | pathlib.Path,
675
+ *,
676
+ after_ms: int = 0,
677
+ after_offset: int = 0,
678
+ before_offset: int | None = None,
679
+ bounded_manual: bool = False,
680
+ require_command: bool = False,
681
+ ) -> dict | None:
682
+ """Return a native ``/compact`` result after a time or byte fence.
683
+
684
+ Claude Code records ``/compact`` as a local slash-command, not as a normal
685
+ user prompt/assistant turn, so all three outcomes are local-command records
686
+ rather than turn events:
687
+
688
+ * success — a ``system`` event with ``subtype="compact_boundary"``;
689
+ * harmless no-op ("Not enough messages to compact") — local-command stdout;
690
+ * failure — local-command **stderr**, which is the only trace a failed
691
+ compaction leaves. Claude Code does not retry compaction, so a failure here
692
+ is terminal for the operation even though the worker itself is healthy.
693
+
694
+ ``after_offset`` is the production correlation fence; file order remains
695
+ authoritative even when Claude journals timestamps out of order.
696
+ """
697
+ command_seen = False
698
+ stats: dict = {}
699
+ for evt in _iter_transcript(transcript_path, stats, after_offset=after_offset,
700
+ before_offset=before_offset):
701
+ if bounded_manual and (stats.get("malformedLines") or not isinstance(evt, dict)):
702
+ return None
703
+ if bounded_manual:
704
+ content = (evt.get("message") or {}).get("content") if evt.get("type") == "user" else evt.get("content")
705
+ if _is_compact_command_content(content):
706
+ # Claude 2.1.312 journals a pasted `/compact` twice: the bare
707
+ # prompt text, then its `<command-name>` wrapper. That pair is
708
+ # one submission; any further command record is another one.
709
+ wrapper = "<command-name>/compact<" in content
710
+ if command_seen == "wrapper" or (command_seen == "bare" and not wrapper):
711
+ break
712
+ command_seen = "wrapper" if wrapper else "bare"
713
+ continue
714
+ if (
715
+ evt.get("type") == "user" and not evt.get("isMeta")
716
+ and not evt.get("isCompactSummary")
717
+ and isinstance(content, str) and content.strip()
718
+ and not _is_local_command_output(content)
719
+ ):
720
+ break
721
+ if require_command and not command_seen:
722
+ continue
723
+ ts_ms = _parse_event_ts_ms(evt)
724
+ if after_ms and ts_ms is not None and ts_ms < after_ms:
725
+ continue
726
+
727
+ if evt.get("type") == "system" and evt.get("subtype") == "compact_boundary":
728
+ meta = evt.get("compactMetadata") or {}
729
+ if bounded_manual and meta.get("trigger") != "manual":
730
+ # A different compaction closes this attribution window.
731
+ return None
732
+ return {
733
+ "ok": True,
734
+ "compacted": True,
735
+ "message": evt.get("content") or "Conversation compacted",
736
+ "api_error": None,
737
+ "pre_tokens": meta.get("preTokens"),
738
+ "post_tokens": meta.get("postTokens"),
739
+ "duration_ms": meta.get("durationMs"),
740
+ "trigger": meta.get("trigger"),
741
+ "timestamp": ts_ms,
742
+ "event_uuid": evt.get("uuid"),
743
+ "end_offset": evt.get("_ccx_end_offset"),
744
+ }
745
+
746
+ content = None
747
+ if evt.get("type") == "system" and evt.get("subtype") == "local_command":
748
+ content = evt.get("content")
749
+ elif evt.get("type") == "user":
750
+ content = (evt.get("message") or {}).get("content")
751
+ if not isinstance(content, str):
752
+ continue
753
+ if bounded_manual and not (
754
+ (evt.get("type") == "system" and evt.get("subtype") == "local_command")
755
+ or _is_local_command_output(content)
756
+ ):
757
+ continue
758
+ if "Not enough messages to compact" in content:
759
+ return {
760
+ "ok": True,
761
+ "compacted": False,
762
+ "message": "Not enough messages to compact.",
763
+ "api_error": None,
764
+ "pre_tokens": None,
765
+ "post_tokens": None,
766
+ "duration_ms": None,
767
+ "trigger": "manual",
768
+ "timestamp": ts_ms,
769
+ "event_uuid": evt.get("uuid"),
770
+ "end_offset": evt.get("_ccx_end_offset"),
771
+ }
772
+ failure = _compact_failure_from_content(content)
773
+ if failure is not None:
774
+ return {
775
+ "ok": False,
776
+ "compacted": False,
777
+ "message": failure["message"],
778
+ "api_error": failure["api_error"],
779
+ "pre_tokens": None,
780
+ "post_tokens": None,
781
+ "duration_ms": None,
782
+ "trigger": "manual",
783
+ "timestamp": ts_ms,
784
+ "event_uuid": evt.get("uuid"),
785
+ "end_offset": evt.get("_ccx_end_offset"),
786
+ }
787
+ return None
788
+
789
+
790
+ # Claude Code's own transience rule, mirrored from the 2.1.x bundle:
791
+ #
792
+ # isTransient = apiErrorIsTransient === true
793
+ # || error === "overloaded" || error === "server_error"
794
+ #
795
+ # "Transient" means the failure is a property of the server or the connection at
796
+ # that instant rather than of the request, so the identical call could succeed
797
+ # later. It is not a retry instruction. Claude Code has already spent its own
798
+ # retry budget before any error reaches the transcript (roughly ten attempts over
799
+ # several minutes, honoring retry-after), so CCX never retries on top of that; it
800
+ # propagates the classification and lets the caller decide whether coming back
801
+ # later is meaningful at all.
802
+ TRANSIENT_API_ERROR_KINDS = frozenset({"overloaded", "server_error"})
803
+
804
+ # Rate limiting is deliberately not folded into "transient". Retrying is
805
+ # meaningful, but only after a reset the caller does not control, and it is an
806
+ # account condition rather than a server fault — the two need different handling,
807
+ # so callers must be able to tell them apart without matching on prose.
808
+ #
809
+ # The flags are independent, not exclusive: Claude Code marks a 429 that is a
810
+ # capacity throttle (rather than a quota wall) with apiErrorIsTransient, so
811
+ # `transient and rate_limited` together means "throttled, worth retrying soon",
812
+ # while `rate_limited` alone means "quota exhausted until reset".
813
+ RATE_LIMIT_API_ERROR_KIND = "rate_limit"
814
+
815
+ # The reset wording inside a rate-limit message, e.g.
816
+ # "You've hit your session limit \xb7 resets 8:40pm (Europe/Madrid)"
817
+ # Captured verbatim and never parsed into a timestamp: this is human prose whose
818
+ # format is not a contract, and a parser would fail silently when it drifts.
819
+ _RESET_TEXT_RE = re.compile(r"\bresets\b[^·\n]*")
820
+
821
+
822
+ def _reset_text_from(text: str) -> str | None:
823
+ match = _RESET_TEXT_RE.search(text)
824
+ return match.group(0).strip() if match else None
825
+
826
+
827
+ # Prefixes Claude Code has already applied to its own message. Stripping them
828
+ # keeps the composed reason from reading "…overloaded (529): API Error: 529
829
+ # Overloaded…", which says the same thing three times.
830
+ _REDUNDANT_MESSAGE_PREFIXES = ("Error during compaction: ", "API Error: ")
831
+
832
+
833
+ def api_error_reason(api_error: dict) -> str:
834
+ """One human line describing an API failure, for logs and lifecycle state.
835
+
836
+ Shape: ``Claude API <label>[ (<status>)]: <Claude's message>``. CCX writes
837
+ only the head; the sentence after it is Claude's own, so the wording cannot
838
+ drift out of step with what Claude actually reports.
839
+
840
+ The one edit is dropping prefixes Claude has *already* applied — the head
841
+ restates them, and left in place the line reads "…overloaded (529): API
842
+ Error: 529 Overloaded…". This composes a display string only: the untouched
843
+ original always remains available as the classification's ``text``.
844
+ """
845
+ kind = api_error.get("error")
846
+ status = api_error.get("status")
847
+ if api_error.get("rate_limited"):
848
+ label = "rate limit"
849
+ elif status == 529 or kind == "overloaded":
850
+ label = "overloaded"
851
+ elif kind:
852
+ label = str(kind).replace("_", " ")
853
+ else:
854
+ label = "error"
855
+
856
+ message = (api_error.get("text") or "").strip()
857
+ changed = True
858
+ while changed:
859
+ changed = False
860
+ for prefix in _REDUNDANT_MESSAGE_PREFIXES:
861
+ if message.startswith(prefix):
862
+ message = message[len(prefix):].lstrip()
863
+ changed = True
864
+
865
+ head = f"Claude API {label}"
866
+ if isinstance(status, int):
867
+ head = f"{head} ({status})"
868
+ return f"{head}: {message}" if message else head
869
+
870
+
871
+ def api_error_from_event(evt: dict) -> dict | None:
872
+ """Classify Claude Code's synthetic API-error message for one raw event.
873
+
874
+ Claude may write a terminal API/server error to the raw transcript without
875
+ firing the Stop hook. The raw event carries explicit markers; the cleaned
876
+ transcript intentionally drops them, so lifecycle code needs this raw-event
877
+ helper.
878
+
879
+ ``error`` is Claude Code's own error class (``server_error``, ``rate_limit``,
880
+ ``authentication_failed``, ...) and ``status`` the HTTP status — absent for
881
+ failures that have none, such as a stream that dies after a 200. ``text`` is
882
+ Claude's message verbatim: it is already written for a human, so CCX reuses
883
+ it rather than inventing parallel prose that would drift out of date.
884
+ """
885
+ if evt.get("type") != "assistant" or evt.get("isApiErrorMessage") is not True:
886
+ return None
887
+ msg = evt.get("message") or {}
888
+ text_parts = []
889
+ for block in msg.get("content") or []:
890
+ if isinstance(block, dict) and block.get("type") == "text":
891
+ text = block.get("text")
892
+ if isinstance(text, str) and text:
893
+ text_parts.append(text)
894
+ text = "\n".join(text_parts).strip()
895
+ kind = evt.get("error")
896
+ rate_limited = kind == RATE_LIMIT_API_ERROR_KIND
897
+ return {
898
+ "status": evt.get("apiErrorStatus"),
899
+ "error": kind,
900
+ "request_id": evt.get("requestId"),
901
+ "text": text,
902
+ "ts": _parse_event_ts_ms(evt),
903
+ "transient": (
904
+ evt.get("apiErrorIsTransient") is True
905
+ or kind in TRANSIENT_API_ERROR_KINDS
906
+ ),
907
+ "rate_limited": rate_limited,
908
+ "reset_text": _reset_text_from(text) if rate_limited else None,
909
+ }
910
+
911
+
912
+ # Background-task launch result content, e.g.
913
+ # "Command running in background with ID: b2e5smis8. Output is being
914
+ # written to: /tmp/.../tasks/b2e5smis8.output. You will be notified ..."
915
+ _BG_LAUNCH_ID_RE = re.compile(r"running in background with ID:\s*([A-Za-z0-9_-]+)")
916
+ _BG_AUTO_LAUNCH_ID_RE = re.compile(
917
+ r"moved to the background \(ID:\s*([A-Za-z0-9_-]+)\)",
918
+ )
919
+ _BG_OUTPUT_FILE_RE = re.compile(r"written to:\s*(\S+)")
920
+ _MONITOR_LAUNCH_ID_RE = re.compile(r"Monitor started \(task\s+([A-Za-z0-9_-]+)")
921
+ # Async Agent launch result content, e.g.
922
+ # "Async agent launched successfully. ...\nagentId: aba8230838a028bad (internal
923
+ # ID ...)\n...\noutput_file: /tmp/.../tasks/aba8230838a028bad.output\n..."
924
+ _ASYNC_AGENT_LAUNCH_PREFIX = "Async agent launched successfully"
925
+ _ASYNC_AGENT_ID_RE = re.compile(r"agentId:\s*([A-Za-z0-9_-]+)")
926
+ _ASYNC_AGENT_OUTPUT_FILE_RE = re.compile(r"output_file:\s*(\S+)")
927
+ # Exit code embedded in a completion summary, e.g. "(exit code 0)".
928
+ _BG_EXIT_CODE_RE = re.compile(r"exit code:?\s*(\d+)")
929
+ # Notification metadata precedes the first free-text field; an agent <result>
930
+ # may quote another notification, so task ids are only read before it.
931
+ _TASK_NOTIFICATION_TEXT_TAG_RE = re.compile(r"<(?:summary|note|result|event)>")
932
+ _TASK_ID_TAG_RE = re.compile(r"<task-id>([^<]*)</task-id>")
933
+ _TASK_LAST_EVENT_LIMIT = 1000
934
+
935
+
936
+ def _xml_tag(content: str, name: str) -> str | None:
937
+ """Return the inner text of the first <name>…</name> in `content`, or None."""
938
+ m = re.search(rf"<{name}>(.*?)</{name}>", content, re.DOTALL)
939
+ return m.group(1).strip() if m else None
940
+
941
+
942
+ def _parse_task_notification(content) -> dict | None:
943
+ """Parse a Claude Code `<task-notification>` enqueue payload.
944
+
945
+ The harness writes one when a background task or async agent finishes (as
946
+ a `queue-operation`/enqueue whose content the worker later auto-continues
947
+ on). `task_ids` lists every header `<task-id>`: a resumed session's
948
+ "previous session" sweep names several agents in one notification. Returns
949
+ the matched fields, or None when `content` is not a task notification.
950
+ `exit_code` is parsed from the summary when present."""
951
+ if not isinstance(content, str) or "<task-notification>" not in content:
952
+ return None
953
+ head = content[:content.index("<task-notification>")]
954
+ rest = content[len(head):]
955
+ m = _TASK_NOTIFICATION_TEXT_TAG_RE.search(rest)
956
+ header = rest[:m.start()] if m else rest
957
+ task_ids = [t.strip() for t in _TASK_ID_TAG_RE.findall(header) if t.strip()]
958
+ summary = _xml_tag(content, "summary")
959
+ exit_code = None
960
+ if summary:
961
+ m = _BG_EXIT_CODE_RE.search(summary)
962
+ if m:
963
+ exit_code = int(m.group(1))
964
+ status = _xml_tag(content, "status")
965
+ return {
966
+ "task_ids": task_ids,
967
+ "tool_use_id": _xml_tag(content, "tool-use-id"),
968
+ "status": status.lower() if status else None,
969
+ "summary": summary,
970
+ "event": _xml_tag(content, "event"),
971
+ "output_file": _xml_tag(content, "output-file"),
972
+ "exit_code": exit_code,
973
+ }
974
+
975
+
976
+ # Background-task statuses ccx classifies a task into. Anything a notification
977
+ # reports that isn't clearly terminal becomes "unknown"; a launched task with
978
+ # no notification yet is "running" (not a new lifecycle state — an open
979
+ # background task does not mean the worker is unfinished).
980
+ _BG_STATUS_RUNNING = "running"
981
+ _BG_STATUS_COMPLETED = "completed"
982
+ _BG_STATUS_FAILED = "failed"
983
+ _BG_STATUS_STOPPED = "stopped"
984
+ _BG_STATUS_UNKNOWN = "unknown"
985
+ # Claude's raw terminal notification statuses. `killed` (TaskStop, session
986
+ # exit) is reported as `stopped`, matching the successful TaskStop result that
987
+ # accompanies it, so the normalized vocabulary stays closed.
988
+ _BG_RAW_TERMINAL_STATUS = {
989
+ "completed": _BG_STATUS_COMPLETED,
990
+ "failed": _BG_STATUS_FAILED,
991
+ "stopped": _BG_STATUS_STOPPED,
992
+ "killed": _BG_STATUS_STOPPED,
993
+ }
994
+
995
+
996
+ def _task_kind(tool_name: str | None) -> str:
997
+ if tool_name in ("Agent", "Task"):
998
+ return "subagent"
999
+ if tool_name == "Monitor":
1000
+ return "monitor"
1001
+ return "bash"
1002
+
1003
+
1004
+ def _task_terminal_status(raw_status: str | None, exit_code: int | None) -> str | None:
1005
+ if raw_status in _BG_RAW_TERMINAL_STATUS:
1006
+ return _BG_RAW_TERMINAL_STATUS[raw_status]
1007
+ if exit_code is not None:
1008
+ return _BG_STATUS_COMPLETED if exit_code == 0 else _BG_STATUS_FAILED
1009
+ return None
1010
+
1011
+
1012
+ def _async_agent_launch(result: dict, tur, body) -> dict | None:
1013
+ """Launch facts when an Agent/Task tool_result started async work.
1014
+
1015
+ Claude's launch input flag is unreliable (absent, the string "true", or
1016
+ True), so only the result counts. Structured `toolUseResult` decides when
1017
+ present — `status:"async_launched"`, or `isAsync` with no status; any other
1018
+ status is a synchronous run. Otherwise fall back to the result text."""
1019
+ if result.get("is_error") is True:
1020
+ return None
1021
+ if isinstance(tur, dict) and ("status" in tur or "isAsync" in tur):
1022
+ status = tur.get("status")
1023
+ agent_id = tur.get("agentId")
1024
+ is_async = status == "async_launched" or (
1025
+ status is None and tur.get("isAsync") is True)
1026
+ if not (is_async and isinstance(agent_id, str) and agent_id):
1027
+ return None
1028
+ output_file = tur.get("outputFile")
1029
+ description = tur.get("description")
1030
+ return {
1031
+ "task_id": agent_id,
1032
+ "output_file": output_file if isinstance(output_file, str) else None,
1033
+ "description": description if isinstance(description, str) else None,
1034
+ }
1035
+ if not isinstance(body, str) or not body.lstrip().startswith(_ASYNC_AGENT_LAUNCH_PREFIX):
1036
+ return None
1037
+ m = _ASYNC_AGENT_ID_RE.search(body)
1038
+ if not m:
1039
+ return None
1040
+ mo = _ASYNC_AGENT_OUTPUT_FILE_RE.search(body)
1041
+ return {
1042
+ "task_id": m.group(1),
1043
+ "output_file": mo.group(1) if mo else None,
1044
+ "description": None,
1045
+ }
1046
+
1047
+
1048
+ def _clip_task_event(text: str | None) -> str | None:
1049
+ if text is None:
1050
+ return None
1051
+ return text if len(text) <= _TASK_LAST_EVENT_LIMIT else text[:_TASK_LAST_EVENT_LIMIT] + "..."
1052
+
1053
+
1054
+ def extract_background_tasks(transcript_path: str | pathlib.Path) -> list[dict]:
1055
+ """Return transcript-derived background tasks for a session, oldest first.
1056
+
1057
+ A background task is launched explicitly by a `Bash` tool_use with
1058
+ `run_in_background: true`, implicitly when Claude moves a foreground Bash
1059
+ command to the background after its timeout, by a `Monitor`, or by an
1060
+ `Agent`/`Task` call whose result reports an async launch. Its launching
1061
+ tool_result carries a stable task id (`backgroundTaskId`, Monitor
1062
+ `taskId`, or `agentId`). When the work ends the harness enqueues a
1063
+ `<task-notification>` keyed by that id (and usually a tool-use id). A
1064
+ successful `TaskStop` result is separate terminal evidence because Claude
1065
+ does not always enqueue a notification for it. A successful `SendMessage`
1066
+ that resumes an agent (`resumedAgentId`) reopens its task.
1067
+ An eager Bash/Monitor launch whose result has no task id is stopped;
1068
+ without a result it remains pending/running. Monitor expiry/timeout event
1069
+ notices also stop the task, including persistent Monitors.
1070
+
1071
+ Evidence pairs with a task by its task id; the tool-use id is a fallback
1072
+ only when the evidence names no known task. Evidence is folded in
1073
+ transcript order, so a terminal notification settles a task and a later
1074
+ resume reopens it. A resumed agent's notifications carry the resuming
1075
+ SendMessage's tool-use id; a terminal record naming a superseded
1076
+ invocation of that agent is ignored.
1077
+
1078
+ Each task dict: `task_id`, `tool_use_id`, `kind` (bash/monitor/subagent),
1079
+ `command`, `description`, `output_file`, `status`
1080
+ (running/completed/failed/stopped/unknown), `exit_code`, `summary`,
1081
+ `launched_at`, `completed_at` (ms timestamps when available). Claude's
1082
+ `killed` status is reported as `stopped`.
1083
+
1084
+ Pure-transcript and read-only — never blocks, and an open task is reported
1085
+ as `running`, never as a worker-level lifecycle state. Only work evidenced
1086
+ in this transcript counts: background work started inside a subagent is
1087
+ not visible here. Claude Code appends resume catch-up notifications to the
1088
+ same raw transcript today; if a future version splits resumed sessions
1089
+ across raw files, an open prior task may remain transcript-running until
1090
+ higher-level liveness marks it stale.
1091
+ Returns [] when no background tasks (or no transcript) are present."""
1092
+ reducer = BackgroundTaskReducer()
1093
+ for event in _iter_transcript(transcript_path):
1094
+ reducer.feed(event)
1095
+ return reducer.tasks()
1096
+
1097
+
1098
+ class BackgroundTaskReducer:
1099
+ """Transcript-only task fold: ingest appended rows, snapshot current tasks.
1100
+
1101
+ Both historical extraction and the host incremental tail use this one
1102
+ parser. Candidate/results/evidence maps persist; materializing a snapshot
1103
+ folds only task evidence, never unrelated transcript history.
1104
+ """
1105
+
1106
+ def __init__(self):
1107
+ self.candidates: dict[str, dict] = {} # possible launch tool_use_id -> facts
1108
+ self.launches: dict[str, dict] = {} # proven launch tool_use_id -> facts
1109
+ self.results: dict[str, dict] = {} # launch tool_use_id -> task result facts
1110
+ self.stop_requests: dict[str, str] = {} # TaskStop tool_use_id -> requested task id
1111
+ self.resume_requests: set[str] = set() # SendMessage tool_use_ids
1112
+ # Terminal, Monitor-event and reopen evidence, appended in transcript order.
1113
+ self.evidence: list[dict] = []
1114
+
1115
+ def feed(self, evt: dict) -> bool:
1116
+ """Ingest one row; tell the owner whether background facts might change."""
1117
+ candidates = self.candidates
1118
+ launches = self.launches
1119
+ results = self.results
1120
+ stop_requests = self.stop_requests
1121
+ resume_requests = self.resume_requests
1122
+ evidence = self.evidence
1123
+ t = evt.get("type")
1124
+ ts = _parse_event_ts_ms(evt)
1125
+ if t == "assistant":
1126
+ msg = evt.get("message") or {}
1127
+ for c in (msg.get("content") or []):
1128
+ if not isinstance(c, dict) or c.get("type") != "tool_use":
1129
+ continue
1130
+ inp = c.get("input")
1131
+ if not isinstance(inp, dict):
1132
+ continue
1133
+ name = c.get("name")
1134
+ tid = c.get("id")
1135
+ if not tid:
1136
+ continue
1137
+ if name == "TaskStop":
1138
+ requested_id = inp.get("task_id")
1139
+ if isinstance(requested_id, str) and requested_id:
1140
+ stop_requests[tid] = requested_id
1141
+ continue
1142
+ if name == "SendMessage":
1143
+ resume_requests.add(tid)
1144
+ continue
1145
+ is_agent = name in ("Agent", "Task")
1146
+ is_monitor = name == "Monitor"
1147
+ # Agent launches are proven by their result, never the flag.
1148
+ is_bg = inp.get("run_in_background") is True and not is_agent
1149
+ is_bash = name == "Bash"
1150
+ if not (is_bash or is_bg or is_monitor or is_agent):
1151
+ continue
1152
+ candidate = {
1153
+ "tool_use_id": tid,
1154
+ "kind": _task_kind(name),
1155
+ "command": inp.get("command") if isinstance(inp.get("command"), str) else None,
1156
+ "description": inp.get("description") if isinstance(inp.get("description"), str) else None,
1157
+ "persistent": inp.get("persistent") if is_monitor else None,
1158
+ "timeout_ms": inp.get("timeout_ms") if is_monitor else None,
1159
+ "launched_at": ts,
1160
+ "last_open_offset": evt.get("_ccx_start_offset"),
1161
+ }
1162
+ candidates[tid] = candidate
1163
+ if is_bg or is_monitor:
1164
+ launches[tid] = candidate
1165
+ elif t == "user":
1166
+ msg = evt.get("message") or {}
1167
+ content = msg.get("content")
1168
+ if not isinstance(content, list):
1169
+ return False
1170
+ tool_results = [c for c in content
1171
+ if isinstance(c, dict) and c.get("type") == "tool_result"]
1172
+ tur = evt.get("toolUseResult")
1173
+ # toolUseResult is a per-event sibling; only trust it to label a
1174
+ # tool_result when this event carries exactly one (the common shape).
1175
+ single_tur = tur if len(tool_results) == 1 else None
1176
+ bg_id = None
1177
+ monitor_id = monitor_timeout_ms = monitor_persistent = None
1178
+ if isinstance(single_tur, dict):
1179
+ bg_id = single_tur.get("backgroundTaskId")
1180
+ monitor_id = single_tur.get("taskId")
1181
+ monitor_timeout_ms = single_tur.get("timeoutMs")
1182
+ monitor_persistent = single_tur.get("persistent")
1183
+ for c in tool_results:
1184
+ tid = c.get("tool_use_id")
1185
+ if not tid:
1186
+ continue
1187
+ body = _format_tool_result_content(c.get("content"))
1188
+
1189
+ requested_stop_id = stop_requests.get(tid)
1190
+ if requested_stop_id is not None:
1191
+ stop_result = single_tur if isinstance(single_tur, dict) else None
1192
+ stopped_id = stop_result.get("task_id") if stop_result else None
1193
+ stop_message = stop_result.get("message") if stop_result else None
1194
+ stopped_command = stop_result.get("command") if stop_result else None
1195
+ if (
1196
+ c.get("is_error") is not True
1197
+ and stopped_id == requested_stop_id
1198
+ and isinstance(stop_message, str)
1199
+ and stop_message.startswith("Successfully stopped task:")
1200
+ ):
1201
+ evidence.append({
1202
+ "task_id": stopped_id,
1203
+ "tool_use_id": tid,
1204
+ "status": _BG_STATUS_STOPPED,
1205
+ "summary": stop_message,
1206
+ "event": None,
1207
+ "output_file": None,
1208
+ "exit_code": None,
1209
+ "command": (
1210
+ stopped_command
1211
+ if isinstance(stopped_command, str)
1212
+ else None
1213
+ ),
1214
+ "completed_at": ts,
1215
+ })
1216
+ continue
1217
+
1218
+ if tid in resume_requests:
1219
+ resume = single_tur if isinstance(single_tur, dict) else {}
1220
+ resumed_id = resume.get("resumedAgentId")
1221
+ if (
1222
+ c.get("is_error") is not True
1223
+ and resume.get("success") is True
1224
+ and isinstance(resumed_id, str)
1225
+ and resumed_id
1226
+ ):
1227
+ evidence.append({
1228
+ "task_id": resumed_id,
1229
+ "tool_use_id": tid,
1230
+ "reopen": True,
1231
+ "reopened_at": ts,
1232
+ "last_open_offset": evt.get("_ccx_start_offset"),
1233
+ })
1234
+ continue
1235
+
1236
+ if (candidates.get(tid) or {}).get("kind") == "subagent":
1237
+ agent = _async_agent_launch(c, single_tur, body)
1238
+ if agent is not None:
1239
+ launches[tid] = candidates[tid]
1240
+ results[tid] = {
1241
+ "task_id": agent["task_id"],
1242
+ "output_file": agent["output_file"],
1243
+ "description": agent["description"],
1244
+ "result_at": ts,
1245
+ "last_open_offset": evt.get("_ccx_start_offset"),
1246
+ }
1247
+ continue
1248
+
1249
+ parsed_id = output_file = None
1250
+ if isinstance(body, str):
1251
+ m = (
1252
+ _BG_LAUNCH_ID_RE.search(body)
1253
+ or _BG_AUTO_LAUNCH_ID_RE.search(body)
1254
+ )
1255
+ if m:
1256
+ parsed_id = m.group(1)
1257
+ mo = _BG_OUTPUT_FILE_RE.search(body)
1258
+ if mo:
1259
+ output_file = mo.group(1).rstrip(".")
1260
+ parsed_monitor_id = None
1261
+ if isinstance(body, str) and body.startswith("Monitor started (task "):
1262
+ mm = _MONITOR_LAUNCH_ID_RE.search(body)
1263
+ if mm:
1264
+ parsed_monitor_id = mm.group(1)
1265
+ launch = launches.get(tid) or {}
1266
+ is_monitor_result = launch.get("kind") == "monitor" or parsed_monitor_id is not None
1267
+ task_id = bg_id or parsed_id
1268
+ if is_monitor_result:
1269
+ task_id = monitor_id or parsed_monitor_id or task_id
1270
+ # Only record results that are genuinely background launches.
1271
+ if task_id is None:
1272
+ if launch.get("kind") in ("bash", "monitor"):
1273
+ evidence.append({
1274
+ "tool_use_id": tid,
1275
+ "status": _BG_STATUS_STOPPED,
1276
+ "completed_at": ts,
1277
+ "summary": _clip_task_event(body),
1278
+ })
1279
+ continue
1280
+ if tid in candidates:
1281
+ launches[tid] = candidates[tid]
1282
+ res = {
1283
+ "task_id": task_id,
1284
+ "output_file": output_file,
1285
+ "result_at": ts,
1286
+ "last_open_offset": evt.get("_ccx_start_offset"),
1287
+ }
1288
+ if is_monitor_result:
1289
+ if monitor_timeout_ms is not None:
1290
+ res["timeout_ms"] = monitor_timeout_ms
1291
+ if monitor_persistent is not None:
1292
+ res["persistent"] = monitor_persistent
1293
+ results[tid] = res
1294
+ elif t == "queue-operation" and evt.get("operation") == "enqueue":
1295
+ parsed = _parse_task_notification(evt.get("content"))
1296
+ if parsed:
1297
+ task_ids = parsed.pop("task_ids")
1298
+ # A multi-id sweep names no single launching tool use.
1299
+ if len(task_ids) > 1:
1300
+ parsed["tool_use_id"] = None
1301
+ for task_id in task_ids or [None]:
1302
+ evidence.append({
1303
+ **parsed, "task_id": task_id, "completed_at": ts,
1304
+ "last_open_offset": evt.get("_ccx_start_offset"),
1305
+ })
1306
+ return parsed is not None
1307
+ if t == "user":
1308
+ return bool(tool_results)
1309
+ if t == "assistant":
1310
+ return any(
1311
+ isinstance(part, dict) and part.get("type") == "tool_use"
1312
+ and part.get("name") in {"Bash", "Monitor", "Agent", "Task",
1313
+ "TaskStop", "SendMessage"}
1314
+ for part in (evt.get("message") or {}).get("content") or []
1315
+ )
1316
+ return False
1317
+
1318
+ def tasks(self) -> list[dict]:
1319
+ candidates = self.candidates
1320
+ launches = self.launches
1321
+ results = self.results
1322
+ stop_requests = self.stop_requests
1323
+ resume_requests = self.resume_requests
1324
+ evidence = self.evidence
1325
+ # Task identity: launch tool_use_id, with its task id as the pairing key.
1326
+ # A resume of an agent never launched in this transcript is still live
1327
+ # work; it becomes a task keyed by the resuming SendMessage.
1328
+ task_keys = set(launches) | set(results)
1329
+ launches_by_task: dict[str, list[str]] = {}
1330
+ for tid in sorted(task_keys):
1331
+ task_id = (results.get(tid) or {}).get("task_id")
1332
+ if task_id:
1333
+ launches_by_task.setdefault(task_id, []).append(tid)
1334
+ for rec in evidence:
1335
+ if rec.get("reopen") and rec["task_id"] not in launches_by_task:
1336
+ tid = rec["tool_use_id"]
1337
+ launches[tid] = {
1338
+ "tool_use_id": tid,
1339
+ "kind": "subagent",
1340
+ "launched_at": rec.get("reopened_at"),
1341
+ "last_open_offset": rec.get("last_open_offset"),
1342
+ }
1343
+ results[tid] = {"task_id": rec["task_id"]}
1344
+ launches_by_task[rec["task_id"]] = [tid]
1345
+ task_keys.add(tid)
1346
+
1347
+ def _evidence_targets(rec: dict) -> list[str]:
1348
+ task_id = rec.get("task_id")
1349
+ if task_id and task_id in launches_by_task:
1350
+ return launches_by_task[task_id]
1351
+ tool_use_id = rec.get("tool_use_id")
1352
+ if tool_use_id in task_keys:
1353
+ launched_id = (results.get(tool_use_id) or {}).get("task_id")
1354
+ # Never let a tool-use id contradict the task id it launched.
1355
+ if not (task_id and launched_id and task_id != launched_id):
1356
+ return [tool_use_id]
1357
+ return []
1358
+
1359
+ evidence_by_launch: dict[str, list[dict]] = {}
1360
+ for rec in evidence:
1361
+ for target in _evidence_targets(rec):
1362
+ evidence_by_launch.setdefault(target, []).append(rec)
1363
+
1364
+ tasks: list[dict] = []
1365
+ for tid in sorted(task_keys):
1366
+ launch = launches.get(tid) or {}
1367
+ res = results.get(tid) or {}
1368
+ kind = launch.get("kind") or "bash"
1369
+ task_id = res.get("task_id")
1370
+
1371
+ output_file = res.get("output_file")
1372
+ last_open_offset = res.get("last_open_offset", launch.get("last_open_offset"))
1373
+ terminal = None
1374
+ event_count = 0
1375
+ last_event = last_event_at = None
1376
+ superseded: set[str] = set() # earlier invocations of a resumed agent
1377
+ invocation = tid
1378
+ for rec in evidence_by_launch.get(tid, []):
1379
+ if rec.get("reopen"):
1380
+ if kind == "subagent":
1381
+ superseded.add(invocation)
1382
+ invocation = rec["tool_use_id"]
1383
+ terminal = None
1384
+ last_open_offset = rec.get("last_open_offset", last_open_offset)
1385
+ continue
1386
+ ev_text = rec.get("event")
1387
+ if ev_text is not None:
1388
+ event_count += 1
1389
+ if kind == "monitor" and terminal is None:
1390
+ last_open_offset = rec.get("last_open_offset", last_open_offset)
1391
+ last_event = _clip_task_event(ev_text)
1392
+ last_event_at = rec.get("completed_at")
1393
+ if kind == "monitor" and ev_text.startswith(
1394
+ ("[Monitor expired", "[Monitor timed out")
1395
+ ):
1396
+ terminal = {**rec, "status": _BG_STATUS_STOPPED,
1397
+ "summary": _clip_task_event(ev_text)}
1398
+ if rec.get("status") is None:
1399
+ continue
1400
+ if rec.get("tool_use_id") in superseded:
1401
+ continue
1402
+ if _task_terminal_status(rec.get("status"), rec.get("exit_code")):
1403
+ terminal = rec
1404
+
1405
+ exit_code = completed_at = summary = None
1406
+ if terminal:
1407
+ exit_code = terminal.get("exit_code")
1408
+ status = _task_terminal_status(terminal.get("status"), exit_code) or _BG_STATUS_UNKNOWN
1409
+ completed_at = terminal.get("completed_at")
1410
+ summary = terminal.get("summary")
1411
+ output_file = output_file or terminal.get("output_file")
1412
+ task_id = task_id or terminal.get("task_id")
1413
+ else:
1414
+ status = _BG_STATUS_RUNNING
1415
+
1416
+ task = {
1417
+ "task_id": task_id,
1418
+ "tool_use_id": tid,
1419
+ "kind": kind,
1420
+ "command": launch.get("command") or (terminal or {}).get("command"),
1421
+ "description": launch.get("description") or res.get("description"),
1422
+ "output_file": output_file,
1423
+ "status": status,
1424
+ "exit_code": exit_code,
1425
+ "summary": summary,
1426
+ "launched_at": launch.get("launched_at"),
1427
+ "completed_at": completed_at,
1428
+ "last_open_offset": last_open_offset,
1429
+ }
1430
+ if kind == "monitor":
1431
+ task["persistent"] = (
1432
+ res.get("persistent")
1433
+ if res.get("persistent") is not None
1434
+ else launch.get("persistent")
1435
+ )
1436
+ task["timeout_ms"] = (
1437
+ res.get("timeout_ms")
1438
+ if res.get("timeout_ms") is not None
1439
+ else launch.get("timeout_ms")
1440
+ )
1441
+ task["event_count"] = event_count
1442
+ task["last_event"] = last_event
1443
+ task["last_event_at"] = last_event_at
1444
+ tasks.append(task)
1445
+
1446
+ tasks.sort(key=lambda t: (t.get("launched_at") or 0, t.get("tool_use_id") or ""))
1447
+ return tasks
1448
+
1449
+
1450
+ def read_background_append(
1451
+ path: str | pathlib.Path | PinnedNativeSource,
1452
+ offset: int,
1453
+ *,
1454
+ max_bytes: int = 2 * 1024 * 1024,
1455
+ ) -> tuple[list[dict], int]:
1456
+ """Read only complete newly appended JSONL rows, returning the safe cursor.
1457
+
1458
+ The final partial line is left for the next host tick. PinnedNativeSource
1459
+ uses pread, so this never disturbs any other reader's transcript offset.
1460
+ """
1461
+ size = path.size() if isinstance(path, PinnedNativeSource) else pathlib.Path(path).stat().st_size
1462
+ if offset < 0 or offset > size:
1463
+ raise ValueError("background transcript changed beneath append cursor")
1464
+ if offset == size:
1465
+ return [], offset
1466
+ reader = (path.open_reader() if isinstance(path, PinnedNativeSource)
1467
+ else open(path, "rb"))
1468
+ with reader as stream:
1469
+ stream.seek(offset)
1470
+ chunk = stream.read(min(max_bytes, size - offset))
1471
+ end = chunk.rfind(b"\n")
1472
+ if end < 0:
1473
+ return [], offset
1474
+ rows: list[dict] = []
1475
+ start = offset
1476
+ for line in chunk[:end + 1].splitlines(keepends=True):
1477
+ next_offset = start + len(line)
1478
+ try:
1479
+ event = json.loads(line)
1480
+ except (ValueError, UnicodeDecodeError):
1481
+ event = None
1482
+ if isinstance(event, dict) and event.get("isSidechain") is not True:
1483
+ event["_ccx_start_offset"] = start
1484
+ event["_ccx_end_offset"] = next_offset
1485
+ rows.append(event)
1486
+ start = next_offset
1487
+ return rows, offset + end + 1
1488
+
1489
+
1490
+ def background_task_summary(tasks: list[dict]) -> dict:
1491
+ """Compact counts for a list from :func:`extract_background_tasks`.
1492
+
1493
+ Returns `background_task_count` (all tasks ever launched in the transcript)
1494
+ and `running_background_task_count` (those with no terminal completion or
1495
+ stop evidence yet). Safe on an empty list."""
1496
+ return {
1497
+ "background_task_count": len(tasks),
1498
+ "running_background_task_count": sum(
1499
+ 1 for t in tasks if t.get("status") == _BG_STATUS_RUNNING),
1500
+ }
1501
+
1502
+
1503
+ def native_transcript_start(value, path) -> int | None:
1504
+ """Return a valid native start byte offset within this same JSONL file."""
1505
+ if type(value) is not int or value < 0:
1506
+ return None
1507
+ try:
1508
+ size = (path.size() if isinstance(path, PinnedNativeSource)
1509
+ else os.stat(path).st_size)
1510
+ return value if value <= size else None
1511
+ except (OSError, TypeError, ValueError):
1512
+ return None
1513
+
1514
+
1515
+ def background_task_current(task: dict, native_start: int | None) -> bool:
1516
+ """An open task has meaningful activity from this native Claude process."""
1517
+ if task.get("status") != _BG_STATUS_RUNNING:
1518
+ return False
1519
+ opened = task.get("last_open_offset")
1520
+ return (type(native_start) is not int or native_start < 0
1521
+ or type(opened) is not int or opened < 0
1522
+ or opened >= native_start)
1523
+
1524
+
1525
+ def _cache_miss_reason(evt: dict, usage: dict) -> dict | str | None:
1526
+ """Claude has moved cache diagnostics across versions; preserve either shape."""
1527
+ server_diag = ((usage.get("server_tool_use") or {}).get("diagnostics") or {})
1528
+ if server_diag.get("cache_miss_reason") is not None:
1529
+ return server_diag.get("cache_miss_reason")
1530
+ msg = evt.get("message") or {}
1531
+ diag = msg.get("diagnostics") or evt.get("diagnostics") or {}
1532
+ if isinstance(diag, dict):
1533
+ return diag.get("cache_miss_reason")
1534
+ return None
1535
+
1536
+
1537
+ def _usage_tokens(evt: dict, usage: dict) -> dict:
1538
+ tokens = {
1539
+ "input": usage.get("input_tokens"),
1540
+ "output": usage.get("output_tokens"),
1541
+ }
1542
+ if usage.get("cache_read_input_tokens") is not None:
1543
+ tokens["cache_read_input"] = usage.get("cache_read_input_tokens")
1544
+ if usage.get("cache_creation_input_tokens") is not None:
1545
+ tokens["cache_creation_input"] = usage.get("cache_creation_input_tokens")
1546
+ cache_creation = usage.get("cache_creation")
1547
+ if isinstance(cache_creation, dict):
1548
+ tokens["cache_creation"] = cache_creation
1549
+ miss_reason = _cache_miss_reason(evt, usage)
1550
+ if miss_reason is not None:
1551
+ tokens["cache_miss_reason"] = miss_reason
1552
+ return tokens
1553
+
1554
+
1555
+ def _merge_usage_objects(
1556
+ prior: dict | None, newer: dict | None
1557
+ ) -> dict:
1558
+ """Merge repeated usage snapshots for one Claude API message.
1559
+
1560
+ Later informative fields win; omitted/None fields do not erase facts already
1561
+ observed on an earlier content-block record for the same message id.
1562
+ """
1563
+ merged = dict(prior or {})
1564
+ for key, value in (newer or {}).items():
1565
+ if value is not None:
1566
+ merged[key] = value
1567
+ return merged
1568
+
1569
+
1570
+ def _format_tool_call(name: str, inp: dict) -> tuple[str, str | None]:
1571
+ """Render a tool_use input as (header, body).
1572
+
1573
+ Ported from claude-history's src/tool_format.rs.
1574
+ header is one short line ("Bash: ls -la", "Read: /path:100-150"); body
1575
+ is optional extra detail (Edit diff, Task prompt) or None.
1576
+ """
1577
+ if not isinstance(inp, dict):
1578
+ inp = {}
1579
+ g = lambda k, d="": inp.get(k) if isinstance(inp.get(k), str) else d
1580
+ if name == "Bash":
1581
+ return f"Bash: {g('command')}", None
1582
+ if name == "Read":
1583
+ p = g("file_path")
1584
+ off = inp.get("offset"); lim = inp.get("limit")
1585
+ if isinstance(off, int) and isinstance(lim, int):
1586
+ return f"Read: {p}:{off}-{off+lim}", None
1587
+ if isinstance(off, int):
1588
+ return f"Read: {p}:{off}", None
1589
+ return f"Read: {p}", None
1590
+ if name == "Grep":
1591
+ path, glob = g("path"), g("glob")
1592
+ loc = (f"{path}/{glob}" if path and glob else path or glob or ".")
1593
+ return f'Grep: "{g("pattern")}" in {loc}', None
1594
+ if name == "Glob":
1595
+ pat = g("pattern"); path = g("path")
1596
+ return (f"Glob: {pat} in {path}" if path else f"Glob: {pat}"), None
1597
+ if name == "Edit":
1598
+ p = g("file_path"); old = g("old_string"); new = g("new_string")
1599
+ body = None
1600
+ if old or new:
1601
+ body = "\n".join(f"- {l}" for l in old.splitlines()) + ("\n" if old and new else "") + "\n".join(f"+ {l}" for l in new.splitlines())
1602
+ return f"Edit: {p}", body
1603
+ if name == "Write":
1604
+ return f"Write: {g('file_path')}", None
1605
+ if name == "Task":
1606
+ return f"Task ({g('subagent_type','agent')}): {g('description')}", (g("prompt") or None)
1607
+ if name == "WebFetch":
1608
+ return f"Fetch: {g('url')}", (g("prompt") or None)
1609
+ if name == "WebSearch":
1610
+ return f'Search: "{g("query")}"', None
1611
+ # Fallback: dump JSON
1612
+ try:
1613
+ body = json.dumps(inp, indent=2)
1614
+ except Exception:
1615
+ body = None
1616
+ return f"{name}:", body
1617
+
1618
+ def _process_command_message(text: str) -> str | None:
1619
+ """Filter and unwrap <local-command-*> / <command-name> tags.
1620
+
1621
+ Returns None if the message should be dropped, otherwise the cleaned text.
1622
+ Ported from claude-history's display.rs::process_command_message.
1623
+ """
1624
+ t = text.strip()
1625
+ # local-command-caveat → drop entirely
1626
+ if t.startswith("<local-command-caveat>") and t.endswith("</local-command-caveat>"):
1627
+ return None
1628
+ # local-command-stdout → unwrap; drop if empty
1629
+ if t.startswith("<local-command-stdout>") and t.endswith("</local-command-stdout>"):
1630
+ inner = t[len("<local-command-stdout>"):-len("</local-command-stdout>")].strip()
1631
+ return inner or None
1632
+ # <command-name>...</command-name> [<command-args>...</command-args>]
1633
+ if "<command-name>" in t and "</command-name>" in t:
1634
+ i, j = t.find("<command-name>") + len("<command-name>"), t.find("</command-name>")
1635
+ if i < j:
1636
+ cmd = t[i:j]
1637
+ if cmd == "/clear":
1638
+ return None
1639
+ if "<command-args>" in t and "</command-args>" in t:
1640
+ ai, aj = t.find("<command-args>") + len("<command-args>"), t.find("</command-args>")
1641
+ args = t[ai:aj].strip() if ai < aj else ""
1642
+ if args:
1643
+ return f"{cmd} {args}"
1644
+ return cmd
1645
+ return text
1646
+
1647
+ def _format_tool_result_content(content) -> str:
1648
+ """Stringify a tool_result content field. Strings pass through; everything
1649
+ else gets pretty JSON. Empty/missing → '<no content>'."""
1650
+ if content is None:
1651
+ return "<no content>"
1652
+ if isinstance(content, str):
1653
+ return content
1654
+ if isinstance(content, list):
1655
+ # The common case: [{type:"text", text:"..."}]
1656
+ parts = []
1657
+ for c in content:
1658
+ if isinstance(c, dict) and c.get("type") == "text":
1659
+ parts.append(c.get("text", ""))
1660
+ elif isinstance(c, dict):
1661
+ try:
1662
+ parts.append(json.dumps(c, indent=2))
1663
+ except Exception:
1664
+ parts.append(str(c))
1665
+ else:
1666
+ parts.append(str(c))
1667
+ return "".join(parts) if parts else "<no content>"
1668
+ try:
1669
+ return json.dumps(content, indent=2)
1670
+ except Exception:
1671
+ return str(content)
1672
+
1673
+ _TOOL_RESULT_TRUNC_JSON = 4000 # per-block char cap for tool_result.content
1674
+
1675
+ # Version of the cleaned-transcript JSON contract. Bump only on a
1676
+ # backward-incompatible change to the rendered shape (renamed/removed fields,
1677
+ # changed semantics). Purely additive fields do not require a bump.
1678
+ TRANSCRIPT_SCHEMA_VERSION = 2
1679
+
1680
+ def _iter_clean_turns(
1681
+ src: str | pathlib.Path,
1682
+ stats: dict | None = None,
1683
+ *,
1684
+ after_offset: int = 0,
1685
+ before_offset: int | None = None,
1686
+ ):
1687
+ """Yield cleaned turn dicts from a native JSONL transcript.
1688
+
1689
+ Schema (per yielded dict):
1690
+ role: "user" | "assistant" | "system"
1691
+ ts: int ms (or None)
1692
+ text: str (the human-visible message text; command tags filtered)
1693
+ — user/assistant turns only.
1694
+ queued: True — user turns only, present only on prompts delivered mid-turn
1695
+ as a `queued_command` attachment (steering a busy session, or a
1696
+ task-notification landing while a turn runs). Absent on ordinary prompts.
1697
+ On these turns `ts` is the *delivery* moment (when the model ingested
1698
+ the prompt) and `queued_at` is when it was queued. Claude Code stamps
1699
+ the attachment with the enqueue time even though it writes the line at
1700
+ delivery, so a raw `ts` would run backwards vs the surrounding turns
1701
+ and break timestamp-ordered consumers (e.g. tail splicing).
1702
+ tools: list of {name, summary, input, result?} — for assistant turns only
1703
+ result has shape {is_error: bool, content: str}; content truncated to
1704
+ _TOOL_RESULT_TRUNC_JSON chars with a "<truncated>" sentinel field if so.
1705
+
1706
+ System turns surface Claude Code "system events" as a general channel. They
1707
+ carry no `text`/`tools`; instead a `kind` discriminator and a `data` payload:
1708
+ {role: "system", kind: <str>, ts: int ms (or None), data: {...}}
1709
+ Currently only the compaction boundary is wired up; unknown system subtypes
1710
+ are dropped (no behavior change). Shape for kind == "compact_boundary":
1711
+ data: {summary: str, pre_tokens: int|None, post_tokens: int|None,
1712
+ duration_ms: int|None, trigger: "manual"|"auto"}
1713
+
1714
+ If a mutable `stats` dict is passed, lossy cleaning is tallied into it:
1715
+ `malformedLines` (dropped unparseable JSONL lines) and
1716
+ `truncatedToolResults` (tool_result blocks clipped to the char cap)."""
1717
+ events = list(
1718
+ _iter_transcript(
1719
+ src,
1720
+ stats,
1721
+ after_offset=after_offset,
1722
+ before_offset=before_offset,
1723
+ )
1724
+ )
1725
+
1726
+ # Build {tool_use_id: {is_error, content}} from all subsequent user messages
1727
+ tool_results: dict[str, dict] = {}
1728
+ for evt in events:
1729
+ if evt.get("type") != "user":
1730
+ continue
1731
+ content = evt.get("message", {}).get("content", "")
1732
+ if not isinstance(content, list):
1733
+ continue
1734
+ for c in content:
1735
+ if isinstance(c, dict) and c.get("type") == "tool_result":
1736
+ tid = c.get("tool_use_id")
1737
+ if not tid:
1738
+ continue
1739
+ body = _format_tool_result_content(c.get("content"))
1740
+ truncated = False
1741
+ if len(body) > _TOOL_RESULT_TRUNC_JSON:
1742
+ body = body[:_TOOL_RESULT_TRUNC_JSON]
1743
+ truncated = True
1744
+ if stats is not None:
1745
+ stats["truncatedToolResults"] = stats.get("truncatedToolResults", 0) + 1
1746
+ tool_results[tid] = {
1747
+ "is_error": bool(c.get("is_error")),
1748
+ "content": body,
1749
+ **({"truncated": True} if truncated else {}),
1750
+ }
1751
+
1752
+ # Claude Code writes each content block of a single model response (thinking /
1753
+ # text / each tool_use) as its own JSONL line, all sharing one message.id. They
1754
+ # are one logical assistant turn, so we accumulate consecutive same-id lines
1755
+ # here and flush them as a single turn (text joined, tools concatenated in
1756
+ # order). `pending` is the in-flight accumulator, or None between responses:
1757
+ # {"mid","ts","text_parts","tools","model","usage","usage_evt"}
1758
+ pending: dict | None = None
1759
+ # Timestamp of the latest event seen so far — the delivery clock for queued
1760
+ # attachments (whose own stamp is the earlier enqueue time; see docstring).
1761
+ last_ts: int | None = None
1762
+
1763
+ def _flush_pending():
1764
+ """Yield the accumulated assistant turn (0 or 1 dict), then reset. Mirrors
1765
+ the original per-line emit: a turn with neither text nor tools is dropped."""
1766
+ nonlocal pending
1767
+ if pending is None:
1768
+ return
1769
+ p = pending
1770
+ pending = None
1771
+ text = "\n".join(p["text_parts"]).strip()
1772
+ if not text and not p["tools"]:
1773
+ return
1774
+ turn = {"role": "assistant", "ts": p["ts"], "text": text, "tools": p["tools"]}
1775
+ if p["mid"]:
1776
+ turn["id"] = p["mid"]
1777
+ if p["model"]:
1778
+ turn["model"] = p["model"]
1779
+ if p["usage"]:
1780
+ turn["tokens"] = _usage_tokens(p["usage_evt"], p["usage"])
1781
+ yield turn
1782
+
1783
+ for evt in events:
1784
+ t = evt.get("type")
1785
+ ts = _parse_event_ts_ms(evt)
1786
+ prev_ts = last_ts
1787
+ if ts is not None:
1788
+ last_ts = ts if last_ts is None else max(last_ts, ts)
1789
+ if t == "user":
1790
+ content = evt.get("message", {}).get("content", "")
1791
+ user_text_parts = []
1792
+ has_tool_results = False
1793
+ if isinstance(content, str):
1794
+ processed = _process_command_message(content)
1795
+ if processed is None:
1796
+ continue
1797
+ user_text_parts.append(processed)
1798
+ elif isinstance(content, list):
1799
+ for c in content:
1800
+ if not isinstance(c, dict):
1801
+ continue
1802
+ ct = c.get("type")
1803
+ if ct == "text":
1804
+ processed = _process_command_message(c.get("text", ""))
1805
+ if processed is not None:
1806
+ user_text_parts.append(processed)
1807
+ elif ct == "tool_result":
1808
+ has_tool_results = True
1809
+ # Skip pure tool-result echo messages — they're paired into the prior assistant turn
1810
+ if has_tool_results and not user_text_parts:
1811
+ continue
1812
+ yield from _flush_pending()
1813
+ text = "\n".join(p for p in user_text_parts if p).strip()
1814
+ if text:
1815
+ turn = {"role": "user", "ts": ts, "text": text}
1816
+ if evt.get("uuid"):
1817
+ turn["id"] = evt["uuid"]
1818
+ yield turn
1819
+ elif t == "assistant":
1820
+ msg = evt.get("message", {}) or {}
1821
+ mid = msg.get("id")
1822
+ # Boundary between two model responses: flush the prior one. An id-less
1823
+ # line never groups (flush + start its own) so unrelated turns can't be
1824
+ # merged on a missing id.
1825
+ if pending is not None and (not mid or pending["mid"] != mid):
1826
+ yield from _flush_pending()
1827
+ content = msg.get("content", [])
1828
+ text_parts = []
1829
+ tools = []
1830
+ for c in content:
1831
+ if not isinstance(c, dict):
1832
+ continue
1833
+ ct = c.get("type")
1834
+ if ct == "text":
1835
+ text_parts.append(c.get("text", ""))
1836
+ elif ct == "tool_use":
1837
+ name = c.get("name", "?")
1838
+ inp = c.get("input", {}) or {}
1839
+ summary, _body = _format_tool_call(name, inp)
1840
+ tool_entry = {
1841
+ "name": name,
1842
+ "summary": summary,
1843
+ "input": inp,
1844
+ }
1845
+ tid = c.get("id")
1846
+ if tid:
1847
+ tool_entry["id"] = tid
1848
+ if tid and tid in tool_results:
1849
+ tool_entry["result"] = tool_results[tid]
1850
+ tools.append(tool_entry)
1851
+ if pending is None:
1852
+ pending = {
1853
+ "mid": mid,
1854
+ "ts": ts, # first line's ts = when the response began
1855
+ "text_parts": [],
1856
+ "tools": [],
1857
+ "model": None,
1858
+ "usage": None,
1859
+ "usage_evt": None,
1860
+ }
1861
+ pending["text_parts"].extend(text_parts)
1862
+ pending["tools"].extend(tools)
1863
+ # model/usage live on the response's final line (the most informative),
1864
+ # so let later lines overwrite earlier ones.
1865
+ if msg.get("model"):
1866
+ pending["model"] = msg["model"]
1867
+ usage = msg.get("usage", {}) or {}
1868
+ if usage:
1869
+ pending["usage"] = usage
1870
+ pending["usage_evt"] = evt
1871
+ elif t == "attachment":
1872
+ # A prompt delivered while a turn was already running (queued
1873
+ # steering, or a task-notification landing mid-turn). Claude Code
1874
+ # hands it to the model as a `queued_command` attachment — never as
1875
+ # a `user` event — so without this branch the message is invisible
1876
+ # in the rendered transcript even though the model read it. The
1877
+ # attachment (not the `queue-operation` enqueue) is the proof of
1878
+ # delivery: an enqueue can be removed without ever being consumed.
1879
+ att = evt.get("attachment") or {}
1880
+ if att.get("type") != "queued_command":
1881
+ continue
1882
+ processed = _process_command_message(att.get("prompt") or "")
1883
+ text = (processed or "").strip()
1884
+ if not text:
1885
+ continue
1886
+ yield from _flush_pending()
1887
+ delivered_ts = ts if prev_ts is None else max(ts or 0, prev_ts) or None
1888
+ turn = {"role": "user", "ts": delivered_ts, "text": text, "queued": True}
1889
+ if evt.get("uuid"):
1890
+ turn["id"] = evt["uuid"]
1891
+ if ts is not None:
1892
+ turn["queued_at"] = ts
1893
+ yield turn
1894
+ elif t == "system":
1895
+ yield from _flush_pending()
1896
+ # General system-event channel: dispatch on subtype so future kinds
1897
+ # can be added with a single handler. Unhandled subtypes drop
1898
+ # silently (no behavior change from when system events were ignored).
1899
+ data = _clean_system_event(evt.get("subtype"), evt)
1900
+ if data is None:
1901
+ continue
1902
+ kind, payload = data
1903
+ turn = {"role": "system", "kind": kind, "ts": ts, "data": payload}
1904
+ if evt.get("uuid"):
1905
+ turn["id"] = evt["uuid"]
1906
+ yield turn
1907
+ # A response still accumulating at EOF (file ends mid-turn, e.g. paused on a
1908
+ # pending tool) is real content — emit it.
1909
+ yield from _flush_pending()
1910
+
1911
+
1912
+ def exact_assistant_text(
1913
+ transcript_path: str | pathlib.Path,
1914
+ *,
1915
+ after_offset: int,
1916
+ before_offset: int | None = None,
1917
+ ) -> str:
1918
+ """Return the last non-empty assistant text in one exact byte-bounded window.
1919
+
1920
+ The caller owns the turn-to-byte mapping. This helper deliberately does no
1921
+ timestamp, prompt-text, or ordinal correlation and never looks outside the
1922
+ supplied raw transcript range.
1923
+ """
1924
+ text = ""
1925
+ for turn in _iter_clean_turns(
1926
+ transcript_path,
1927
+ after_offset=after_offset,
1928
+ before_offset=before_offset,
1929
+ ):
1930
+ if turn.get("role") != "assistant":
1931
+ continue
1932
+ candidate = turn.get("text")
1933
+ if isinstance(candidate, str) and candidate.strip():
1934
+ text = candidate.strip()
1935
+ return text
1936
+
1937
+ def _clean_system_event(subtype, evt: dict):
1938
+ """Map a raw system event to a (kind, data) pair for a cleaned turn, or None
1939
+ to drop it. Add new (subtype -> kind, data) handlers here as they are wired
1940
+ up; currently only ``compact_boundary`` is surfaced."""
1941
+ if subtype == "compact_boundary":
1942
+ meta = evt.get("compactMetadata") or {}
1943
+ return "compact_boundary", {
1944
+ "summary": evt.get("content") or "",
1945
+ "pre_tokens": meta.get("preTokens"),
1946
+ "post_tokens": meta.get("postTokens"),
1947
+ "duration_ms": meta.get("durationMs"),
1948
+ "trigger": meta.get("trigger"),
1949
+ }
1950
+ return None
1951
+
1952
+ def pending_ask_user_question(transcript_path: str | pathlib.Path) -> dict | None:
1953
+ """Return the last AskUserQuestion tool_use with no matching tool_result, or
1954
+ None. A dangling AskUserQuestion is an unanswered question dialog — useful as
1955
+ a soft `wait_kind=question` corroborator and for rendering options in `peek`.
1956
+ Read-only; shape: {"toolUseId": str, "questions": [...]}.
1957
+
1958
+ Answering this reads the whole raw JSONL (only lines that can matter are
1959
+ decoded), and `list` / `peek` ask it of external rows on every pass —
1960
+ megabytes of re-read for a file that usually hasn't moved. The stamp-keyed
1961
+ cache makes an unchanged
1962
+ transcript free while keeping the answer provably current: any append
1963
+ changes (mtime_ns, size) and forces a rescan. Result is copied out so a
1964
+ caller mutating the returned dict cannot poison the entry."""
1965
+ result = _pending_question_cache.get(transcript_path, _pending_ask_user_question_uncached)
1966
+ return copy.deepcopy(result) if result is not None else None
1967
+
1968
+
1969
+ def clear_pending_question_cache() -> None:
1970
+ """Drop every cached transcript scan (tests)."""
1971
+ _pending_question_cache.clear()
1972
+
1973
+
1974
+ _pending_question_cache = StampedCache()
1975
+
1976
+
1977
+ _ASK_USER_QUESTION = b"AskUserQuestion"
1978
+
1979
+
1980
+ def _lines_containing(data: bytes, needles):
1981
+ """Yield, in file order, each stripped JSONL line of ``data`` holding at
1982
+ least one of the byte strings in the live mapping ``needles`` (its values).
1983
+
1984
+ The mapping may change between yields; later lines are matched against its
1985
+ current contents. Each needle's next occurrence is found with one
1986
+ ``bytes.find`` and remembered until the scan passes it, so the cost is a few
1987
+ C-speed searches per matching line instead of a Python step per line."""
1988
+ next_at: dict[bytes, int] = {}
1989
+ pos = 0
1990
+ while True:
1991
+ hit = -1
1992
+ for needle in list(needles.values()):
1993
+ at = next_at.get(needle)
1994
+ if at is None or (0 <= at < pos):
1995
+ at = data.find(needle, pos)
1996
+ next_at[needle] = at
1997
+ if at >= 0 and (hit < 0 or at < hit):
1998
+ hit = at
1999
+ if hit < 0:
2000
+ return
2001
+ start = data.rfind(b"\n", 0, hit) + 1
2002
+ end = data.find(b"\n", hit)
2003
+ if end < 0:
2004
+ end = len(data)
2005
+ pos = end + 1
2006
+ yield data[start:end].strip()
2007
+
2008
+
2009
+ def _pending_ask_user_question_uncached(transcript_path: str | pathlib.Path) -> dict | None:
2010
+ asked: dict[str, dict] = {}
2011
+ answered: set[str] = set()
2012
+ try:
2013
+ data = pathlib.Path(transcript_path).read_bytes()
2014
+ except FileNotFoundError:
2015
+ return None
2016
+ # Only two kinds of line can change the answer: one naming the tool, and a
2017
+ # tool_result for a question still unanswered (an answered id stays
2018
+ # answered). Both carry a literal the raw bytes must contain, so only those
2019
+ # lines are decoded; the rest of a long transcript is never parsed. Decoding
2020
+ # mirrors _iter_transcript: malformed and sidechain lines are skipped.
2021
+ needles: dict[object, bytes] = {None: _ASK_USER_QUESTION}
2022
+ for line in _lines_containing(data, needles):
2023
+ try:
2024
+ evt = json.loads(line)
2025
+ except (ValueError, UnicodeDecodeError):
2026
+ continue
2027
+ if not isinstance(evt, dict) or evt.get("isSidechain") is True:
2028
+ continue
2029
+ content = (evt.get("message") or {}).get("content")
2030
+ if not isinstance(content, list):
2031
+ continue
2032
+ for b in content:
2033
+ if not isinstance(b, dict):
2034
+ continue
2035
+ if b.get("type") == "tool_use" and b.get("name") == "AskUserQuestion":
2036
+ tid = b.get("id")
2037
+ asked[tid] = b.get("input") or {}
2038
+ if isinstance(tid, str) and tid and tid not in answered:
2039
+ # The id as JSON writes it inside a string literal.
2040
+ needles[tid] = json.dumps(tid, ensure_ascii=False)[1:-1].encode()
2041
+ elif b.get("type") == "tool_result" and b.get("tool_use_id") in asked:
2042
+ answered.add(b.get("tool_use_id"))
2043
+ if b.get("tool_use_id") is not None:
2044
+ needles.pop(b.get("tool_use_id"), None)
2045
+ pend = [(tid, inp) for tid, inp in asked.items() if tid and tid not in answered]
2046
+ if not pend:
2047
+ return None
2048
+ tid, inp = pend[-1]
2049
+ return {"toolUseId": tid, "questions": inp.get("questions", [])}
2050
+
2051
+
2052
+ def _private_native_source_group(gid: int) -> bool:
2053
+ """Prove an explicitly named user-private primary group via NSS.
2054
+
2055
+ A group's ``gr_mem`` omits users whose *primary* group is that gid. Check
2056
+ both supplementary and primary memberships, and refuse any lookup or
2057
+ enumeration ambiguity. A named shared group must not gain write authority
2058
+ over the provider's original JSONL merely by sharing our effective gid.
2059
+ """
2060
+ import grp
2061
+ import pwd
2062
+
2063
+ try:
2064
+ account = pwd.getpwuid(os.getuid())
2065
+ group = grp.getgrgid(gid)
2066
+ accounts = pwd.getpwall()
2067
+ except (KeyError, OSError):
2068
+ return False
2069
+ if (account.pw_uid != os.getuid()
2070
+ or gid != account.pw_gid or gid != os.getgid()
2071
+ or group.gr_gid != gid or group.gr_name != account.pw_name
2072
+ or not isinstance(accounts, list) or not accounts
2073
+ or not isinstance(group.gr_mem, (list, tuple))
2074
+ or any(member != account.pw_name for member in group.gr_mem)):
2075
+ return False
2076
+ group_primaries = [entry for entry in accounts if entry.pw_gid == gid]
2077
+ return (bool(group_primaries)
2078
+ and all(entry.pw_name == account.pw_name
2079
+ and entry.pw_uid == account.pw_uid
2080
+ for entry in group_primaries))
2081
+
2082
+
2083
+ def _native_source_directory_safe(info) -> bool:
2084
+ """Require our owner; group write only under a proven private group."""
2085
+ if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
2086
+ or info.st_mode & 0o002):
2087
+ return False
2088
+ return not (info.st_mode & 0o020) or _private_native_source_group(info.st_gid)
2089
+
2090
+
2091
+ def _complete_line_end(fd: int, size: int) -> int:
2092
+ """Last complete JSONL byte boundary in a pinned native file."""
2093
+ pos = size
2094
+ while pos:
2095
+ start = max(0, pos - 65536)
2096
+ part = os.pread(fd, pos - start, start)
2097
+ index = part.rfind(b"\n")
2098
+ if index >= 0:
2099
+ return start + index + 1
2100
+ pos = start
2101
+ return 0
2102
+
2103
+
2104
+ @contextmanager
2105
+ def open_original_claude_source(store, owner, rec):
2106
+ """Pin one immutable-original Claude JSONL to its owner and native create claim.
2107
+
2108
+ Callers read the held original fd with offset-independent pread, not the
2109
+ mutable provider path or a Darwin /dev/fd alias sharing the seek offset.
2110
+ A missing upstream file remains distinct from an unsafe original.
2111
+ Yield the pinned source/stat, last complete line, derived original path and
2112
+ directory fd (renderer uses the latter to verify identity before publish).
2113
+ """
2114
+ from ccx.store import paths
2115
+ from ccx.store.root import StorageIntegrityError, _DIR_FLAGS, _FILE_FLAGS
2116
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
2117
+
2118
+ native = rec.get("native")
2119
+ native_sid = native.get("sessionId") if isinstance(native, dict) else None
2120
+ try:
2121
+ canonical_worker_id(native_sid)
2122
+ except InvalidTarget as exc:
2123
+ raise StorageIntegrityError("Claude owner has no original native session ID") from exc
2124
+ fingerprint = rec.get("createSha256")
2125
+ if not isinstance(fingerprint, str) or not fingerprint:
2126
+ raise StorageIntegrityError("Claude original creation fingerprint is missing")
2127
+ original = store.creation_binding(
2128
+ owner.worker, engine="claude", fingerprint=fingerprint,
2129
+ )
2130
+ if not isinstance(original, dict) or original.get("sessionId") != native_sid:
2131
+ raise StorageIntegrityError("Claude JSONL source differs from original native binding")
2132
+ cwd = rec.get("cwd")
2133
+ if not isinstance(cwd, str) or not os.path.isabs(cwd):
2134
+ raise StorageIntegrityError("Claude original working directory is not absolute")
2135
+ source_path = paths.transcript_path_for(cwd, native_sid)
2136
+ if rec.get("transcript") != str(source_path):
2137
+ raise StorageIntegrityError("Claude transcript source is not original native project JSONL")
2138
+ # A provider JSONL is outside CCX_ROOT, but has a derivable native owner:
2139
+ # open its original <native UUID>.jsonl through no-follow parent fds. The
2140
+ # retained native fd also prevents pathname replacement from switching
2141
+ # the transcript between turn cleanup and compact-boundary detection.
2142
+ with ExitStack() as held:
2143
+ try:
2144
+ current = os.open(paths.CLAUDE_HOME, _DIR_FLAGS)
2145
+ held.callback(os.close, current)
2146
+ for component in ("projects", source_path.parent.name):
2147
+ directory = os.fstat(current)
2148
+ if not _native_source_directory_safe(directory):
2149
+ raise StorageIntegrityError("unsafe native Claude source directory")
2150
+ next_fd = os.open(component, _DIR_FLAGS, dir_fd=current)
2151
+ held.callback(os.close, next_fd)
2152
+ current = next_fd
2153
+ directory = os.fstat(current)
2154
+ if not _native_source_directory_safe(directory):
2155
+ raise StorageIntegrityError("unsafe native Claude source directory")
2156
+ native_fd = os.open(
2157
+ native_sid + ".jsonl", _FILE_FLAGS | os.O_NONBLOCK, dir_fd=current,
2158
+ )
2159
+ held.callback(os.close, native_fd)
2160
+ except FileNotFoundError:
2161
+ # No upstream JSONL yet. Leave the renderer a no-output sentinel;
2162
+ # compact callers must classify this as missing terminal evidence.
2163
+ yield None
2164
+ return
2165
+ except OSError as exc:
2166
+ raise StorageIntegrityError("unsafe Claude native transcript source") from exc
2167
+ source_stat = os.fstat(native_fd)
2168
+ if (not stat.S_ISREG(source_stat.st_mode)
2169
+ or source_stat.st_uid != os.getuid()
2170
+ or source_stat.st_mode & 0o022):
2171
+ raise StorageIntegrityError("Claude native transcript must be an owned regular file")
2172
+ # A held original inode; each parse gets an independent pread cursor.
2173
+ pinned_source = PinnedNativeSource(native_fd)
2174
+ end_offset = _complete_line_end(native_fd, source_stat.st_size)
2175
+ yield pinned_source, source_stat, end_offset, source_path, current
2176
+
2177
+
2178
+ def render_transcript_json(short: str, *, store=None) -> dict | None:
2179
+ """Refresh one V2 Claude-owned render from the original native JSONL.
2180
+
2181
+ The record's native session ID is not the CCX worker UUID. Bind source
2182
+ derivation to the original immutable create intent, pin the provider file
2183
+ before either read, and publish only through the ACTIVE generation's exact
2184
+ full-UUID worker directory. An optional pinned store preserves the
2185
+ caller's original ACTIVE generation. The published dict is also returned;
2186
+ missing upstream JSONL returns None. Record transcriptJson is display metadata,
2187
+ never a pathname from which to read or onto which to write.
2188
+ """
2189
+ from ccx import constants
2190
+ from ccx.store.root import StorageIntegrityError, _write_json, require_ccx_active
2191
+ from ccx.store.identity import EngineMismatch
2192
+
2193
+ if store is None:
2194
+ store = require_ccx_active(constants.CCX_ROOT)
2195
+ owner = store.resolve(short)
2196
+ if owner.engine != "claude":
2197
+ raise EngineMismatch("transcript rendering requires a Claude full-UUID owner")
2198
+ rec = store.record(owner.worker)
2199
+ with open_original_claude_source(store, owner, rec) as source:
2200
+ if source is None:
2201
+ # Preserve original render's missing-native-file no-output behavior.
2202
+ return None
2203
+ pinned_source, source_stat, end_offset, source_path, current = source
2204
+ native_sid = rec["native"]["sessionId"]
2205
+ cwd = rec["cwd"]
2206
+ fingerprint = rec["createSha256"]
2207
+ stats = {"malformedLines": 0, "truncatedToolResults": 0}
2208
+ turns = list(_iter_clean_turns(
2209
+ pinned_source, stats, before_offset=end_offset,
2210
+ ))
2211
+ context = compute_context_usage(turns)
2212
+ pending_compact = pending_compact_request(
2213
+ pinned_source, before_offset=end_offset,
2214
+ )
2215
+ if pending_compact is not None:
2216
+ context = compaction_unknown_context(
2217
+ "compacting", as_of_ms=pending_compact.get("as_of_ms"),
2218
+ )
2219
+ observed_model = next((
2220
+ turn["model"] for turn in reversed(turns)
2221
+ if turn.get("role") == "assistant"
2222
+ and isinstance(turn.get("model"), str)
2223
+ and 0 < len(turn["model"]) <= 256
2224
+ and turn["model"] != "<synthetic>"
2225
+ ), None)
2226
+ observed_at = int(time.time() * 1000)
2227
+ observation = {
2228
+ "schemaVersion": 1,
2229
+ "workerId": owner.worker_id, "nativeSessionId": native_sid,
2230
+ "generation": store.generation,
2231
+ "source": {"dev": source_stat.st_dev, "ino": source_stat.st_ino,
2232
+ "endOffset": end_offset, "observedAtMs": observed_at},
2233
+ "context": context, "observedModel": observed_model,
2234
+ }
2235
+ out = {
2236
+ "schemaVersion": TRANSCRIPT_SCHEMA_VERSION,
2237
+ "generatedAt": int(time.time() * 1000),
2238
+ "worker": owner.worker,
2239
+ "sessionId": native_sid,
2240
+ "name": rec.get("name", owner.short),
2241
+ "cwd": cwd,
2242
+ "transcriptJsonl": str(source_path),
2243
+ "turns": turns,
2244
+ "context": context,
2245
+ "stats": stats,
2246
+ }
2247
+ # If the upstream original pathname was replaced between the two
2248
+ # parsing passes and publication, the pinned old inode cannot prove a
2249
+ # current original provider source. Normal append preserves inode.
2250
+ try:
2251
+ current_source = os.stat(
2252
+ native_sid + ".jsonl", dir_fd=current, follow_symlinks=False,
2253
+ )
2254
+ except OSError as exc:
2255
+ raise StorageIntegrityError("original Claude native JSONL disappeared during render") from exc
2256
+ if (current_source.st_dev != source_stat.st_dev
2257
+ or current_source.st_ino != source_stat.st_ino
2258
+ or not stat.S_ISREG(current_source.st_mode)
2259
+ or current_source.st_uid != os.getuid()
2260
+ or current_source.st_mode & 0o022
2261
+ or current_source.st_size < end_offset):
2262
+ raise StorageIntegrityError("original Claude native JSONL changed identity during render")
2263
+ # Pin and revalidate global ownership after the expensive native read;
2264
+ # a generation rotation or changed original source must not publish
2265
+ # into a new owner's directory. The existing atomic writer creates its
2266
+ # temp inode 0600 before exposing it, then fsyncs and replaces via fd.
2267
+ with store.lease():
2268
+ latest_owner, latest = store._owned(owner.worker)
2269
+ latest_native = latest.get("native")
2270
+ if (latest_owner != owner or not isinstance(latest_native, dict)
2271
+ or latest_native.get("sessionId") != native_sid
2272
+ or latest.get("cwd") != cwd
2273
+ or latest.get("transcript") != str(source_path)
2274
+ or latest.get("createSha256") != fingerprint):
2275
+ raise StorageIntegrityError("Claude original JSONL ownership changed during render")
2276
+ with store._gen("workers", "claude", owner.worker_id) as worker_dir_fd:
2277
+ # Separate atomically published files are fine: readers consume
2278
+ # this complete observation, never a transactional turn pair.
2279
+ _write_json(worker_dir_fd, "context-observation.json", observation)
2280
+ _write_json(worker_dir_fd, "transcript.json", out, rendered_transcript=True)
2281
+ return out