ccx-native 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/ccx/__init__.py +4 -0
- package/ccx/claude/attach.py +103 -58
- package/ccx/claude/host/pty.py +20 -1
- package/ccx/claude/host/session.py +95 -35
- package/ccx/claude/transcript.py +321 -67
- package/ccx/claude/usage.py +5 -46
- package/ccx/claude/usage_control.py +93 -41
- package/ccx/cli/commands/control.py +1 -1
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +90 -6
- package/ccx/cli/main.py +3 -2
- package/ccx/cli/parser.py +122 -7
- package/ccx/client.py +8 -4
- package/ccx/codex/client.py +6 -0
- package/ccx/codex/daemon/daemon.py +3 -1
- package/ccx/codex/daemon/worker.py +41 -13
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/runtime.py +73 -31
- package/ccx/models.py +225 -0
- package/ccx/ops/cancel.py +18 -11
- package/ccx/ops/conversation.py +67 -14
- package/ccx/ops/conversation_paging.py +15 -14
- package/ccx/ops/creation_steps.py +0 -4
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/report.py +8 -5
- package/ccx/ops/turn_reports.py +47 -5
- package/ccx/ops/worker_events.py +5 -1
- package/ccx/store/paths.py +0 -4
- package/ccx/store/root.py +6 -21
- package/ccx/store/store.py +2 -2
- package/ccx/system/private_json.py +54 -0
- package/ccx/types.py +104 -0
- package/ccx/workers.py +70 -58
- package/package.json +2 -2
- package/pyproject.toml +2 -2
- package/skills/ccx/SKILL.md +12 -1
- package/skills/ccx/references/history.md +50 -0
package/ccx/claude/transcript.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
|
+
import difflib
|
|
5
|
+
import hashlib
|
|
4
6
|
from contextlib import ExitStack, contextmanager
|
|
5
7
|
import io
|
|
6
8
|
import json
|
|
@@ -12,7 +14,6 @@ import time
|
|
|
12
14
|
|
|
13
15
|
from ccx.system import oslayer
|
|
14
16
|
from ccx.claude.context import compaction_unknown_context, compute_context_usage
|
|
15
|
-
from ccx.store.paths import transcript_json_path
|
|
16
17
|
from ccx.store.record_cache import StampedCache
|
|
17
18
|
from ccx.store.records import read_record
|
|
18
19
|
|
|
@@ -193,6 +194,7 @@ def queued_prompt_accepted(
|
|
|
193
194
|
text: str,
|
|
194
195
|
*,
|
|
195
196
|
after_ms: int = 0,
|
|
197
|
+
after_offset: int = 0,
|
|
196
198
|
) -> bool:
|
|
197
199
|
"""Return True once Claude has natively accepted a queued prompt.
|
|
198
200
|
|
|
@@ -202,14 +204,16 @@ def queued_prompt_accepted(
|
|
|
202
204
|
PostToolUse/SubagentStop steering paste was actually accepted, not merely
|
|
203
205
|
written to the PTY.
|
|
204
206
|
|
|
205
|
-
Only the exact normalized prompt acknowledges this delivery.
|
|
207
|
+
Only the exact normalized prompt acknowledges this delivery. Callers pass
|
|
208
|
+
the transcript size from before the paste as ``after_offset``: this is
|
|
209
|
+
polled while a hook holds the turn, so it must not rescan a long history.
|
|
206
210
|
"""
|
|
207
211
|
# Claude trims a submitted prompt; an inline paste has no envelope that
|
|
208
212
|
# would keep the surrounding whitespace.
|
|
209
213
|
text = normalize_semantic_prompt_text(text).strip()
|
|
210
214
|
if not text:
|
|
211
215
|
return False
|
|
212
|
-
for evt in _iter_transcript(transcript_path):
|
|
216
|
+
for evt in _iter_transcript(transcript_path, after_offset=after_offset):
|
|
213
217
|
if evt.get("type") != "queue-operation":
|
|
214
218
|
continue
|
|
215
219
|
if evt.get("operation") != "enqueue":
|
|
@@ -357,7 +361,7 @@ _ESCAPED_PASTED_CONTENT_RE = re.compile(
|
|
|
357
361
|
# Claude stores a typed `<pasted_content>` tag escaped as `<\\pasted_content>`.
|
|
358
362
|
_ESCAPED_TAG_RE = re.compile(r"<\\(/?pasted_content)")
|
|
359
363
|
# Claude turns a prompt that is only an image path into an `[Image #N]` row.
|
|
360
|
-
|
|
364
|
+
BARE_IMAGE_PATH_RE = re.compile(r"^[/~][^\n]*\.(?:png|jpe?g|gif|webp)$", re.IGNORECASE)
|
|
361
365
|
_IMAGE_PLACEHOLDER_RE = re.compile(r"^\[Image #\d+\]$")
|
|
362
366
|
|
|
363
367
|
|
|
@@ -379,7 +383,26 @@ def prompt_matches(expected: str, actual: str | None) -> bool:
|
|
|
379
383
|
``[Image #N]`` placeholder Claude stores in its place."""
|
|
380
384
|
actual = normalize_semantic_prompt_text(actual).strip()
|
|
381
385
|
return bool(expected) and (actual == expected or bool(
|
|
382
|
-
|
|
386
|
+
BARE_IMAGE_PATH_RE.match(expected) and _IMAGE_PLACEHOLDER_RE.match(actual)))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def prompt_mismatch_event(delivery_id: str, expected: str, actual: str | None) -> dict:
|
|
390
|
+
"""A host event describing how native prompt text ``actual`` differs from
|
|
391
|
+
the sent prompt ``expected`` (already normalized and stripped), without
|
|
392
|
+
any of the text: sizes, hash prefixes, how many spans differ, and whether
|
|
393
|
+
it is the same characters in another order (a reordered paste)."""
|
|
394
|
+
actual = normalize_semantic_prompt_text(actual).strip()
|
|
395
|
+
spans = difflib.SequenceMatcher(None, expected, actual, autojunk=False).get_opcodes()
|
|
396
|
+
return {
|
|
397
|
+
"type": "semantic_prompt_mismatch",
|
|
398
|
+
"delivery_id": delivery_id,
|
|
399
|
+
"expected_chars": len(expected),
|
|
400
|
+
"actual_chars": len(actual),
|
|
401
|
+
"expected_sha256": hashlib.sha256(expected.encode()).hexdigest()[:16],
|
|
402
|
+
"actual_sha256": hashlib.sha256(actual.encode()).hexdigest()[:16],
|
|
403
|
+
"changed_spans": sum(op != "equal" for op, *_ in spans),
|
|
404
|
+
"same_characters": sorted(expected) == sorted(actual),
|
|
405
|
+
}
|
|
383
406
|
|
|
384
407
|
|
|
385
408
|
def exact_user_prompt_event(
|
|
@@ -1248,15 +1271,33 @@ class BackgroundTaskReducer:
|
|
|
1248
1271
|
|
|
1249
1272
|
parsed_id = output_file = None
|
|
1250
1273
|
if isinstance(body, str):
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1274
|
+
# Only this result's own launch counts. A foreground
|
|
1275
|
+
# command (e.g. `ccx peek other-worker`) may print another
|
|
1276
|
+
# worker's launch notice in its stdout; claiming that task
|
|
1277
|
+
# would keep it open here forever. Claude records the id
|
|
1278
|
+
# in toolUseResult.backgroundTaskId; without it, accept
|
|
1279
|
+
# only a notice that heads the result.
|
|
1280
|
+
if bg_id is None:
|
|
1281
|
+
notice = body.lstrip()
|
|
1282
|
+
if notice.startswith("Command running in background with ID:"):
|
|
1283
|
+
m = _BG_LAUNCH_ID_RE.search(notice)
|
|
1284
|
+
elif notice.startswith("Command did not complete within"):
|
|
1285
|
+
m = _BG_AUTO_LAUNCH_ID_RE.search(notice)
|
|
1286
|
+
else:
|
|
1287
|
+
m = None
|
|
1288
|
+
parsed_id = m.group(1) if m else None
|
|
1289
|
+
launched = bg_id or parsed_id
|
|
1290
|
+
if launched:
|
|
1291
|
+
# A command moved to the background after its timeout
|
|
1292
|
+
# prints its output so far before the notice, so match
|
|
1293
|
+
# the output path by task id rather than position.
|
|
1294
|
+
output_file = next((
|
|
1295
|
+
path
|
|
1296
|
+
for mo in _BG_OUTPUT_FILE_RE.finditer(body)
|
|
1297
|
+
if (path := mo.group(1).rstrip(".")).endswith(
|
|
1298
|
+
f"/{launched}.output"
|
|
1299
|
+
)
|
|
1300
|
+
), None)
|
|
1260
1301
|
parsed_monitor_id = None
|
|
1261
1302
|
if isinstance(body, str) and body.startswith("Monitor started (task "):
|
|
1262
1303
|
mm = _MONITOR_LAUNCH_ID_RE.search(body)
|
|
@@ -1879,7 +1920,7 @@ def _iter_clean_turns(
|
|
|
1879
1920
|
att = evt.get("attachment") or {}
|
|
1880
1921
|
if att.get("type") != "queued_command":
|
|
1881
1922
|
continue
|
|
1882
|
-
processed = _process_command_message(att.get("prompt")
|
|
1923
|
+
processed = _process_command_message(_block_text(att.get("prompt")))
|
|
1883
1924
|
text = (processed or "").strip()
|
|
1884
1925
|
if not text:
|
|
1885
1926
|
continue
|
|
@@ -2175,19 +2216,15 @@ def open_original_claude_source(store, owner, rec):
|
|
|
2175
2216
|
yield pinned_source, source_stat, end_offset, source_path, current
|
|
2176
2217
|
|
|
2177
2218
|
|
|
2178
|
-
def render_transcript_json(short: str, *, store=None) -> dict | None:
|
|
2179
|
-
"""Refresh one V2 Claude-owned render from the original native JSONL.
|
|
2180
2219
|
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
missing upstream JSONL returns None. Record transcriptJson is display metadata,
|
|
2187
|
-
never a pathname from which to read or onto which to write.
|
|
2220
|
+
def render_transcript(short: str, *, store=None) -> dict | None:
|
|
2221
|
+
"""Render one Claude worker's cleaned transcript from its original JSONL.
|
|
2222
|
+
|
|
2223
|
+
Read-only and on demand (``logs``): parses the whole native transcript, so
|
|
2224
|
+
nothing on a hot path may call it. Missing upstream JSONL returns None.
|
|
2188
2225
|
"""
|
|
2189
2226
|
from ccx import constants
|
|
2190
|
-
from ccx.store.root import
|
|
2227
|
+
from ccx.store.root import require_ccx_active
|
|
2191
2228
|
from ccx.store.identity import EngineMismatch
|
|
2192
2229
|
|
|
2193
2230
|
if store is None:
|
|
@@ -2198,12 +2235,8 @@ def render_transcript_json(short: str, *, store=None) -> dict | None:
|
|
|
2198
2235
|
rec = store.record(owner.worker)
|
|
2199
2236
|
with open_original_claude_source(store, owner, rec) as source:
|
|
2200
2237
|
if source is None:
|
|
2201
|
-
# Preserve original render's missing-native-file no-output behavior.
|
|
2202
2238
|
return None
|
|
2203
|
-
pinned_source,
|
|
2204
|
-
native_sid = rec["native"]["sessionId"]
|
|
2205
|
-
cwd = rec["cwd"]
|
|
2206
|
-
fingerprint = rec["createSha256"]
|
|
2239
|
+
pinned_source, _source_stat, end_offset, source_path, _current = source
|
|
2207
2240
|
stats = {"malformedLines": 0, "truncatedToolResults": 0}
|
|
2208
2241
|
turns = list(_iter_clean_turns(
|
|
2209
2242
|
pinned_source, stats, before_offset=end_offset,
|
|
@@ -2212,58 +2245,282 @@ def render_transcript_json(short: str, *, store=None) -> dict | None:
|
|
|
2212
2245
|
pending_compact = pending_compact_request(
|
|
2213
2246
|
pinned_source, before_offset=end_offset,
|
|
2214
2247
|
)
|
|
2215
|
-
|
|
2248
|
+
if pending_compact is not None:
|
|
2249
|
+
context = compaction_unknown_context(
|
|
2250
|
+
"compacting", as_of_ms=pending_compact.get("as_of_ms"),
|
|
2251
|
+
)
|
|
2252
|
+
return {
|
|
2253
|
+
"schemaVersion": TRANSCRIPT_SCHEMA_VERSION,
|
|
2254
|
+
"generatedAt": int(time.time() * 1000),
|
|
2255
|
+
"worker": owner.worker,
|
|
2256
|
+
"sessionId": rec["native"]["sessionId"],
|
|
2257
|
+
"name": rec.get("name", owner.short),
|
|
2258
|
+
"cwd": rec["cwd"],
|
|
2259
|
+
"transcriptJsonl": str(source_path),
|
|
2260
|
+
"turns": turns,
|
|
2261
|
+
"context": context,
|
|
2262
|
+
"stats": stats,
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def _valid_observed_model(model) -> bool:
|
|
2267
|
+
return (isinstance(model, str) and 0 < len(model) <= 256
|
|
2268
|
+
and model != "<synthetic>")
|
|
2269
|
+
|
|
2270
|
+
|
|
2271
|
+
class _ObservationReducer:
|
|
2272
|
+
"""The context, model and pending-compact facts of a cleaned transcript,
|
|
2273
|
+
fed one raw event at a time so the host can resume where it stopped.
|
|
2274
|
+
|
|
2275
|
+
It mirrors exactly the parts of ``_iter_clean_turns`` those facts depend
|
|
2276
|
+
on: which events emit a turn, how one response's lines group by message
|
|
2277
|
+
id, and which line's model and usage win. ``render_transcript`` stays the
|
|
2278
|
+
full renderer; tests pin the two together at every line boundary. The
|
|
2279
|
+
state is small and JSON-serializable.
|
|
2280
|
+
"""
|
|
2281
|
+
|
|
2282
|
+
def __init__(self, state: dict | None = None):
|
|
2283
|
+
state = state or {}
|
|
2284
|
+
self.any_turn = state.get("anyTurn", False)
|
|
2285
|
+
# Latest compact boundary or usable assistant usage, as a minimal
|
|
2286
|
+
# cleaned turn that compute_context_usage reads.
|
|
2287
|
+
self.anchor = state.get("anchor")
|
|
2288
|
+
self.model = state.get("model")
|
|
2289
|
+
self.pending_compact = state.get("pendingCompact")
|
|
2290
|
+
# The response still accumulating: {mid, ts, content, model, tokens}.
|
|
2291
|
+
self.open = state.get("open")
|
|
2292
|
+
|
|
2293
|
+
@staticmethod
|
|
2294
|
+
def valid_state(state) -> bool:
|
|
2295
|
+
if not isinstance(state, dict) or not isinstance(state.get("anyTurn"), bool):
|
|
2296
|
+
return False
|
|
2297
|
+
anchor, model = state.get("anchor"), state.get("model")
|
|
2298
|
+
pending, open_ = state.get("pendingCompact"), state.get("open")
|
|
2299
|
+
return ((anchor is None or isinstance(anchor, dict))
|
|
2300
|
+
and (model is None or _valid_observed_model(model))
|
|
2301
|
+
and (pending is None or isinstance(pending, dict))
|
|
2302
|
+
and (open_ is None or (isinstance(open_, dict)
|
|
2303
|
+
and isinstance(open_.get("content"), bool))))
|
|
2304
|
+
|
|
2305
|
+
def state(self) -> dict:
|
|
2306
|
+
return {"anyTurn": self.any_turn, "anchor": self.anchor, "model": self.model,
|
|
2307
|
+
"pendingCompact": self.pending_compact, "open": self.open}
|
|
2308
|
+
|
|
2309
|
+
def _flush(self) -> None:
|
|
2310
|
+
response, self.open = self.open, None
|
|
2311
|
+
if response is None or not response["content"]:
|
|
2312
|
+
return
|
|
2313
|
+
self.any_turn = True
|
|
2314
|
+
if _valid_observed_model(response.get("model")):
|
|
2315
|
+
self.model = response["model"]
|
|
2316
|
+
tokens = response.get("tokens")
|
|
2317
|
+
if tokens is not None:
|
|
2318
|
+
turn = {"role": "assistant", "ts": response.get("ts"), "tokens": tokens}
|
|
2319
|
+
if (compute_context_usage([turn]) or {}).get("state") == "known":
|
|
2320
|
+
self.anchor = turn
|
|
2321
|
+
|
|
2322
|
+
def _feed_compact(self, evt: dict, kind, ts) -> None:
|
|
2323
|
+
# Same rules as pending_compact_request.
|
|
2324
|
+
if kind == "system" and evt.get("subtype") == "compact_boundary":
|
|
2325
|
+
self.pending_compact = None
|
|
2326
|
+
return
|
|
2327
|
+
content = None
|
|
2328
|
+
if kind == "system" and evt.get("subtype") == "local_command":
|
|
2329
|
+
content = evt.get("content")
|
|
2330
|
+
elif kind == "user":
|
|
2331
|
+
content = (evt.get("message") or {}).get("content")
|
|
2332
|
+
if _is_compact_command_content(content):
|
|
2333
|
+
self.pending_compact = {"as_of_ms": ts}
|
|
2334
|
+
elif (self.pending_compact is not None and isinstance(content, str)
|
|
2335
|
+
and ("<local-command-stdout>" in content
|
|
2336
|
+
or "<local-command-stderr>" in content)):
|
|
2337
|
+
self.pending_compact = None
|
|
2338
|
+
|
|
2339
|
+
def feed(self, evt: dict) -> None:
|
|
2340
|
+
kind = evt.get("type")
|
|
2341
|
+
ts = _parse_event_ts_ms(evt)
|
|
2342
|
+
self._feed_compact(evt, kind, ts)
|
|
2343
|
+
if kind == "user":
|
|
2344
|
+
content = (evt.get("message") or {}).get("content", "")
|
|
2345
|
+
parts: list[str] = []
|
|
2346
|
+
has_tool_results = False
|
|
2347
|
+
if isinstance(content, str):
|
|
2348
|
+
processed = _process_command_message(content)
|
|
2349
|
+
if processed is None:
|
|
2350
|
+
return
|
|
2351
|
+
parts.append(processed)
|
|
2352
|
+
elif isinstance(content, list):
|
|
2353
|
+
for block in content:
|
|
2354
|
+
if not isinstance(block, dict):
|
|
2355
|
+
continue
|
|
2356
|
+
if block.get("type") == "text":
|
|
2357
|
+
processed = _process_command_message(block.get("text", ""))
|
|
2358
|
+
if processed is not None:
|
|
2359
|
+
parts.append(processed)
|
|
2360
|
+
elif block.get("type") == "tool_result":
|
|
2361
|
+
has_tool_results = True
|
|
2362
|
+
if has_tool_results and not parts:
|
|
2363
|
+
return
|
|
2364
|
+
self._flush()
|
|
2365
|
+
if "\n".join(p for p in parts if p).strip():
|
|
2366
|
+
self.any_turn = True
|
|
2367
|
+
elif kind == "assistant":
|
|
2368
|
+
msg = evt.get("message") or {}
|
|
2369
|
+
mid = msg.get("id")
|
|
2370
|
+
if self.open is not None and (not mid or self.open["mid"] != mid):
|
|
2371
|
+
self._flush()
|
|
2372
|
+
if self.open is None:
|
|
2373
|
+
self.open = {"mid": mid, "ts": ts, "content": False,
|
|
2374
|
+
"model": None, "tokens": None}
|
|
2375
|
+
content = msg.get("content", [])
|
|
2376
|
+
for block in content if isinstance(content, list) else ():
|
|
2377
|
+
if not isinstance(block, dict):
|
|
2378
|
+
continue
|
|
2379
|
+
text = block.get("text", "")
|
|
2380
|
+
if ((block.get("type") == "text" and isinstance(text, str) and text.strip())
|
|
2381
|
+
or block.get("type") == "tool_use"):
|
|
2382
|
+
self.open["content"] = True
|
|
2383
|
+
if msg.get("model"):
|
|
2384
|
+
self.open["model"] = msg["model"]
|
|
2385
|
+
usage = msg.get("usage", {}) or {}
|
|
2386
|
+
if usage:
|
|
2387
|
+
tokens = _usage_tokens(evt, usage)
|
|
2388
|
+
self.open["tokens"] = {
|
|
2389
|
+
key: tokens[key] for key in
|
|
2390
|
+
("input", "cache_read_input", "cache_creation_input")
|
|
2391
|
+
if key in tokens
|
|
2392
|
+
}
|
|
2393
|
+
elif kind == "attachment":
|
|
2394
|
+
attachment = evt.get("attachment") or {}
|
|
2395
|
+
if attachment.get("type") != "queued_command":
|
|
2396
|
+
return
|
|
2397
|
+
processed = _process_command_message(_block_text(attachment.get("prompt")))
|
|
2398
|
+
if (processed or "").strip():
|
|
2399
|
+
self._flush()
|
|
2400
|
+
self.any_turn = True
|
|
2401
|
+
elif kind == "system":
|
|
2402
|
+
self._flush()
|
|
2403
|
+
data = _clean_system_event(evt.get("subtype"), evt)
|
|
2404
|
+
if data is not None:
|
|
2405
|
+
self.any_turn = True
|
|
2406
|
+
if data[0] == "compact_boundary":
|
|
2407
|
+
self.anchor = {"role": "system", "kind": "compact_boundary", "ts": ts}
|
|
2408
|
+
|
|
2409
|
+
def observe(self) -> tuple[dict | None, str | None]:
|
|
2410
|
+
"""(context, observed model) as the full render would report them now:
|
|
2411
|
+
the open response counts as finished, as it does at end of file."""
|
|
2412
|
+
final = _ObservationReducer(copy.deepcopy(self.state()))
|
|
2413
|
+
final._flush()
|
|
2414
|
+
turns = [final.anchor or {"role": "user"}] if final.any_turn else []
|
|
2415
|
+
context = compute_context_usage(turns)
|
|
2416
|
+
if final.pending_compact is not None:
|
|
2216
2417
|
context = compaction_unknown_context(
|
|
2217
|
-
"compacting", as_of_ms=pending_compact.get("as_of_ms"),
|
|
2418
|
+
"compacting", as_of_ms=final.pending_compact.get("as_of_ms"),
|
|
2218
2419
|
)
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2420
|
+
return context, final.model
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
OBSERVATION_CURSOR_VERSION = 1
|
|
2424
|
+
# Bytes just before the resume offset whose hash proves the file was only
|
|
2425
|
+
# appended to since the last refresh (not truncated and rewritten in place).
|
|
2426
|
+
_OBSERVATION_FENCE_BYTES = 4096
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
def _observation_fence_sha(source: PinnedNativeSource, offset: int) -> str:
|
|
2430
|
+
start = max(0, offset - _OBSERVATION_FENCE_BYTES)
|
|
2431
|
+
return hashlib.sha256(os.pread(source.fd, offset - start, start)).hexdigest()
|
|
2432
|
+
|
|
2433
|
+
|
|
2434
|
+
def _resumable_observation_state(store, owner, native_sid, source, source_stat, end_offset):
|
|
2435
|
+
"""The prior observation's (offset, reducer state) when it still describes
|
|
2436
|
+
a prefix of this exact source, else None (start again from the top)."""
|
|
2437
|
+
try:
|
|
2438
|
+
doc = store.read_worker_sidecar(owner.worker, "context-observation.json")
|
|
2439
|
+
except Exception:
|
|
2440
|
+
return None
|
|
2441
|
+
if not isinstance(doc, dict):
|
|
2442
|
+
return None
|
|
2443
|
+
observed, cursor = doc.get("source"), doc.get("cursor")
|
|
2444
|
+
if (doc.get("schemaVersion") != 1 or doc.get("workerId") != owner.worker_id
|
|
2445
|
+
or doc.get("nativeSessionId") != native_sid
|
|
2446
|
+
or doc.get("generation") != store.generation
|
|
2447
|
+
or not isinstance(observed, dict) or not isinstance(cursor, dict)
|
|
2448
|
+
or observed.get("dev") != source_stat.st_dev
|
|
2449
|
+
or observed.get("ino") != source_stat.st_ino
|
|
2450
|
+
or type(observed.get("endOffset")) is not int
|
|
2451
|
+
or not 0 <= observed["endOffset"] <= end_offset
|
|
2452
|
+
or cursor.get("version") != OBSERVATION_CURSOR_VERSION
|
|
2453
|
+
or not _ObservationReducer.valid_state(cursor.get("state"))
|
|
2454
|
+
or cursor.get("fenceSha256") != _observation_fence_sha(source, observed["endOffset"])):
|
|
2455
|
+
return None
|
|
2456
|
+
return observed["endOffset"], cursor["state"]
|
|
2457
|
+
|
|
2458
|
+
|
|
2459
|
+
def refresh_context_observation(short: str, *, store=None) -> dict | None:
|
|
2460
|
+
"""Update one Claude worker's context/model observation from its JSONL.
|
|
2461
|
+
|
|
2462
|
+
Incremental: parses only what Claude appended since the previous
|
|
2463
|
+
observation of the same source file (same inode, same generation), so a
|
|
2464
|
+
long-lived worker's refresh costs what its last turn wrote, with constant
|
|
2465
|
+
memory. Anything else starts again from the top, streaming. Publishes only
|
|
2466
|
+
through the ACTIVE generation's exact full-UUID worker directory; missing
|
|
2467
|
+
upstream JSONL returns None.
|
|
2468
|
+
"""
|
|
2469
|
+
from ccx import constants
|
|
2470
|
+
from ccx.store.root import StorageIntegrityError, _write_json, require_ccx_active
|
|
2471
|
+
from ccx.store.identity import EngineMismatch
|
|
2472
|
+
|
|
2473
|
+
if store is None:
|
|
2474
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
2475
|
+
owner = store.resolve(short)
|
|
2476
|
+
if owner.engine != "claude":
|
|
2477
|
+
raise EngineMismatch("context observation requires a Claude full-UUID owner")
|
|
2478
|
+
rec = store.record(owner.worker)
|
|
2479
|
+
with open_original_claude_source(store, owner, rec) as source:
|
|
2480
|
+
if source is None:
|
|
2481
|
+
return None
|
|
2482
|
+
pinned_source, source_stat, end_offset, source_path, current = source
|
|
2483
|
+
native_sid = rec["native"]["sessionId"]
|
|
2484
|
+
cwd = rec["cwd"]
|
|
2485
|
+
fingerprint = rec["createSha256"]
|
|
2486
|
+
prior = _resumable_observation_state(
|
|
2487
|
+
store, owner, native_sid, pinned_source, source_stat, end_offset)
|
|
2488
|
+
start, state = prior if prior is not None else (0, None)
|
|
2489
|
+
reducer = _ObservationReducer(state)
|
|
2490
|
+
for event in _iter_transcript(
|
|
2491
|
+
pinned_source, after_offset=start, before_offset=end_offset,
|
|
2492
|
+
):
|
|
2493
|
+
reducer.feed(event)
|
|
2494
|
+
context, observed_model = reducer.observe()
|
|
2227
2495
|
observation = {
|
|
2228
2496
|
"schemaVersion": 1,
|
|
2229
2497
|
"workerId": owner.worker_id, "nativeSessionId": native_sid,
|
|
2230
2498
|
"generation": store.generation,
|
|
2231
2499
|
"source": {"dev": source_stat.st_dev, "ino": source_stat.st_ino,
|
|
2232
|
-
"endOffset": end_offset,
|
|
2500
|
+
"endOffset": end_offset,
|
|
2501
|
+
"observedAtMs": int(time.time() * 1000)},
|
|
2233
2502
|
"context": context, "observedModel": observed_model,
|
|
2503
|
+
"cursor": {"version": OBSERVATION_CURSOR_VERSION,
|
|
2504
|
+
"fenceSha256": _observation_fence_sha(pinned_source, end_offset),
|
|
2505
|
+
"state": reducer.state()},
|
|
2234
2506
|
}
|
|
2235
|
-
|
|
2236
|
-
|
|
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.
|
|
2507
|
+
# If the upstream pathname was replaced during the read, the pinned
|
|
2508
|
+
# old inode no longer proves the current provider source.
|
|
2250
2509
|
try:
|
|
2251
2510
|
current_source = os.stat(
|
|
2252
2511
|
native_sid + ".jsonl", dir_fd=current, follow_symlinks=False,
|
|
2253
2512
|
)
|
|
2254
2513
|
except OSError as exc:
|
|
2255
|
-
raise StorageIntegrityError("original Claude native JSONL disappeared during
|
|
2514
|
+
raise StorageIntegrityError("original Claude native JSONL disappeared during refresh") from exc
|
|
2256
2515
|
if (current_source.st_dev != source_stat.st_dev
|
|
2257
2516
|
or current_source.st_ino != source_stat.st_ino
|
|
2258
2517
|
or not stat.S_ISREG(current_source.st_mode)
|
|
2259
2518
|
or current_source.st_uid != os.getuid()
|
|
2260
2519
|
or current_source.st_mode & 0o022
|
|
2261
2520
|
or current_source.st_size < end_offset):
|
|
2262
|
-
raise StorageIntegrityError("original Claude native JSONL changed identity during
|
|
2263
|
-
#
|
|
2264
|
-
#
|
|
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.
|
|
2521
|
+
raise StorageIntegrityError("original Claude native JSONL changed identity during refresh")
|
|
2522
|
+
# Revalidate global ownership after the read; a generation rotation or
|
|
2523
|
+
# changed original source must not publish into a new owner's directory.
|
|
2267
2524
|
with store.lease():
|
|
2268
2525
|
latest_owner, latest = store._owned(owner.worker)
|
|
2269
2526
|
latest_native = latest.get("native")
|
|
@@ -2272,10 +2529,7 @@ def render_transcript_json(short: str, *, store=None) -> dict | None:
|
|
|
2272
2529
|
or latest.get("cwd") != cwd
|
|
2273
2530
|
or latest.get("transcript") != str(source_path)
|
|
2274
2531
|
or latest.get("createSha256") != fingerprint):
|
|
2275
|
-
raise StorageIntegrityError("Claude original JSONL ownership changed during
|
|
2532
|
+
raise StorageIntegrityError("Claude original JSONL ownership changed during refresh")
|
|
2276
2533
|
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
2534
|
_write_json(worker_dir_fd, "context-observation.json", observation)
|
|
2280
|
-
|
|
2281
|
-
return out
|
|
2535
|
+
return observation
|
package/ccx/claude/usage.py
CHANGED
|
@@ -4,20 +4,23 @@ import argparse
|
|
|
4
4
|
from contextlib import contextmanager
|
|
5
5
|
from email.utils import parsedate_to_datetime
|
|
6
6
|
import fcntl
|
|
7
|
-
import errno
|
|
8
7
|
import math
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
from datetime import datetime, timezone
|
|
11
10
|
import json
|
|
12
11
|
import os
|
|
13
12
|
import stat
|
|
14
|
-
import tempfile
|
|
15
13
|
import time
|
|
16
14
|
import urllib.error
|
|
17
15
|
import urllib.parse
|
|
18
16
|
import urllib.request
|
|
19
17
|
|
|
20
18
|
from ccx.system import oslayer
|
|
19
|
+
from ccx.system.private_json import (
|
|
20
|
+
_atomic_private_json,
|
|
21
|
+
_open_private_file,
|
|
22
|
+
_read_private_json,
|
|
23
|
+
)
|
|
21
24
|
from ccx.claude.oauth import oauth_headers, oauth_organization_uuid, oauth_token
|
|
22
25
|
from ccx.constants import (
|
|
23
26
|
CAP_METRICS,
|
|
@@ -56,22 +59,6 @@ _RETRY_HELD_INITIAL_S = 300.0
|
|
|
56
59
|
_RETRY_HELD_MAX_S = 1800.0
|
|
57
60
|
|
|
58
61
|
|
|
59
|
-
def _open_private_file(path: Path) -> int:
|
|
60
|
-
"""Do not trust a symlink, another owner's file, or a readable private record."""
|
|
61
|
-
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
62
|
-
st = os.fstat(fd)
|
|
63
|
-
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
|
|
64
|
-
os.close(fd)
|
|
65
|
-
raise PermissionError(f"not a private regular file owned by this user: {path}")
|
|
66
|
-
return fd
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _read_private_json(path: Path) -> dict | None:
|
|
70
|
-
with os.fdopen(_open_private_file(path), "rb") as handle:
|
|
71
|
-
value = json.load(handle)
|
|
72
|
-
return value if isinstance(value, dict) else None
|
|
73
|
-
|
|
74
|
-
|
|
75
62
|
def _open_usage_cache() -> int:
|
|
76
63
|
return _open_private_file(usage_cache_path())
|
|
77
64
|
|
|
@@ -107,34 +94,6 @@ def _cache_age(data: dict | None = None) -> float | None:
|
|
|
107
94
|
return max(0.0, time.time() - epoch) if epoch is not None else None
|
|
108
95
|
|
|
109
96
|
|
|
110
|
-
def _atomic_private_json(path: Path, value: dict) -> None:
|
|
111
|
-
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
112
|
-
tmp = None
|
|
113
|
-
try:
|
|
114
|
-
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
115
|
-
with os.fdopen(fd, "w", encoding="utf-8") as out:
|
|
116
|
-
json.dump(value, out, separators=(",", ":"), allow_nan=False)
|
|
117
|
-
out.flush()
|
|
118
|
-
os.fsync(out.fileno())
|
|
119
|
-
# Never overwrite a planted symlink/foreign file at the final path.
|
|
120
|
-
try:
|
|
121
|
-
existing = _open_private_file(path)
|
|
122
|
-
except FileNotFoundError:
|
|
123
|
-
pass
|
|
124
|
-
except OSError as exc:
|
|
125
|
-
# Replacing a planted *symlink itself* is safe: os.replace never
|
|
126
|
-
# follows the destination. Its target is neither read nor written.
|
|
127
|
-
if exc.errno != errno.ELOOP:
|
|
128
|
-
raise
|
|
129
|
-
else:
|
|
130
|
-
os.close(existing)
|
|
131
|
-
os.replace(tmp, path)
|
|
132
|
-
tmp = None
|
|
133
|
-
finally:
|
|
134
|
-
if tmp is not None:
|
|
135
|
-
os.unlink(tmp)
|
|
136
|
-
|
|
137
|
-
|
|
138
97
|
def _write_usage_cache(data: dict) -> None:
|
|
139
98
|
_atomic_private_json(usage_cache_path(), data)
|
|
140
99
|
|