ccx-native 0.0.1 → 0.2.0

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