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.
@@ -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
- _IMAGE_PATH_RE = re.compile(r"^[/~][^\n]*\.(?:png|jpe?g|gif|webp)$", re.IGNORECASE)
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
- _IMAGE_PATH_RE.match(expected) and _IMAGE_PLACEHOLDER_RE.match(actual)))
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
- 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(".")
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") or "")
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
- 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.
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 StorageIntegrityError, _write_json, require_ccx_active
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, source_stat, end_offset, source_path, current = 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
- if pending_compact is not None:
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
- 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)
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, "observedAtMs": observed_at},
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
- 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.
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 render") from exc
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 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.
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 render")
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
- _write_json(worker_dir_fd, "transcript.json", out, rendered_transcript=True)
2281
- return out
2535
+ return observation
@@ -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