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