@agentsdock/server 1.0.7-beta.5 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. package/server/uv.lock +3 -1
@@ -354,6 +354,12 @@ class CodexAppServerTurn:
354
354
  # Native turn identities are retained only for this owner's lifetime.
355
355
  # Delayed duplicate starts must not resurrect one of its finished turns.
356
356
  _completed_turn_ids: set[str] = field(default_factory=set)
357
+ # Unlike turn_id, this identity does not follow native goal/child turns.
358
+ # History repair needs the turn that accepted the original user input.
359
+ initial_turn_id: str = field(init=False, default="")
360
+
361
+ def __post_init__(self) -> None:
362
+ self.initial_turn_id = self.turn_id
357
363
 
358
364
  async def next_notification(self, timeout: float | None = None) -> dict[str, Any]:
359
365
  return await self._subscription.next_notification(timeout)
@@ -498,6 +504,8 @@ class CodexAppServerTurn:
498
504
  f"provisional turn already bound to {self.turn_id}, not {resolved}"
499
505
  )
500
506
  self.turn_id = resolved
507
+ if not self.initial_turn_id:
508
+ self.initial_turn_id = resolved
501
509
  if not self._retain_thread_stream:
502
510
  self._subscription.turn_id = resolved
503
511
 
@@ -562,6 +570,7 @@ class CodexAppServerClient:
562
570
  process_factory: ProcessFactory | None = None,
563
571
  before_start: Callable[[], Awaitable[Any]] | None = None,
564
572
  server_request_handler: ServerRequestHandler | None = None,
573
+ notification_guard: Callable[[dict[str, Any]], bool] | None = None,
565
574
  initialize_params: dict[str, Any] | None = None,
566
575
  on_process_started: ProcessLifecycleHook | None = None,
567
576
  on_process_exited: ProcessLifecycleHook | None = None,
@@ -599,6 +608,7 @@ class CodexAppServerClient:
599
608
  # arbitrary/recycled PIDs, so never derive a signal target from them.
600
609
  self._owns_spawned_process_groups = process_factory is None and os.name == "posix"
601
610
  self._server_request_handler = server_request_handler or decline_server_request
611
+ self._notification_guard = notification_guard
602
612
  self._initialize_params = initialize_params or {
603
613
  "clientInfo": {
604
614
  "name": "agents_server",
@@ -635,6 +645,8 @@ class CodexAppServerClient:
635
645
  self._thread_names: dict[str, str] = {}
636
646
  self._subscriptions: set[CodexAppServerSubscription] = set()
637
647
  self._notification_handlers: set[NotificationHandler] = set()
648
+ self._account_usage_handlers: set[NotificationHandler] = set()
649
+ self._account_epoch = 0
638
650
  self._callback_tasks: set[asyncio.Task[Any]] = set()
639
651
  self._callback_tails: dict[
640
652
  tuple[NotificationHandler, str],
@@ -1401,10 +1413,19 @@ class CodexAppServerClient:
1401
1413
  # Authentication notifications are not chat events. Do not expose
1402
1414
  # account metadata or upstream login errors to event subscribers.
1403
1415
  if method in {"account/login/completed", "account/updated"}:
1416
+ self._account_epoch += 1
1417
+ self._dispatch_notification_handlers(
1418
+ {"method": "account/changed", "params": {}},
1419
+ tuple(self._account_usage_handlers), ordered=False,
1420
+ )
1404
1421
  return
1405
1422
  params = notification.get("params")
1406
1423
  if not isinstance(params, dict):
1407
1424
  return
1425
+ if method == "account/rateLimits/updated":
1426
+ self._dispatch_notification_handlers(
1427
+ notification, tuple(self._account_usage_handlers), ordered=False,
1428
+ )
1408
1429
 
1409
1430
  if method == "serverRequest/resolved":
1410
1431
  request_id = params.get("requestId")
@@ -1438,6 +1459,8 @@ class CodexAppServerClient:
1438
1459
  # keeping this id current is required for both event routing and
1439
1460
  # turn/interrupt to target the work that is actually running.
1440
1461
  if not active_turn.turn_id or method == "turn/started":
1462
+ if not active_turn.initial_turn_id:
1463
+ active_turn.initial_turn_id = turn_id
1441
1464
  active_turn.turn_id = turn_id
1442
1465
  if method == "turn/started":
1443
1466
  active_turn._completed = False
@@ -1457,35 +1480,14 @@ class CodexAppServerClient:
1457
1480
  "at": time.time(),
1458
1481
  })
1459
1482
 
1460
- for handler in tuple(self._notification_handlers):
1461
- owner = (handler, thread_id or "")
1462
- previous = self._callback_tails.get(owner)
1463
- if previous is None:
1464
- try:
1465
- result = handler(notification)
1466
- except Exception:
1467
- continue
1468
- if not inspect.isawaitable(result):
1469
- # Preserve the original inline contract for synchronous
1470
- # caches consumed by immediately following server requests.
1471
- continue
1472
- task = asyncio.ensure_future(result)
1473
- else:
1474
- task = asyncio.create_task(
1475
- self._invoke_notification_handler(
1476
- handler,
1477
- notification,
1478
- previous,
1479
- )
1480
- )
1481
- self._callback_tails[owner] = task
1482
- self._callback_tasks.add(task)
1483
- task.add_done_callback(
1484
- lambda completed, owner=owner: self._finish_callback_task(
1485
- owner,
1486
- completed,
1487
- )
1488
- )
1483
+ handlers = tuple(self._notification_handlers)
1484
+ try:
1485
+ allowed = self._notification_guard is None or self._notification_guard(notification)
1486
+ except Exception:
1487
+ allowed = False
1488
+ if not allowed:
1489
+ handlers = ()
1490
+ self._dispatch_notification_handlers(notification, handlers)
1489
1491
 
1490
1492
  # Synchronous handlers (including the scoped fork watcher) must see
1491
1493
  # whether this identity was known *before* this notification. Record a
@@ -1516,6 +1518,54 @@ class CodexAppServerClient:
1516
1518
  include_thread_subscription=True,
1517
1519
  )
1518
1520
 
1521
+ @property
1522
+ def account_epoch(self) -> int:
1523
+ return self._account_epoch
1524
+
1525
+ def add_account_usage_handler(self, handler: NotificationHandler) -> None:
1526
+ """Observe rate limits/auth invalidation in native receive order.
1527
+
1528
+ Synchronous observers update their cache inline; any returned async
1529
+ invalidation broadcast is tracked without postponing later cache edits.
1530
+ Authentication payloads are never passed to these handlers.
1531
+ """
1532
+ self._account_usage_handlers.add(handler)
1533
+
1534
+ def _dispatch_notification_handlers(
1535
+ self, notification: dict[str, Any], handlers: tuple[NotificationHandler, ...],
1536
+ *, ordered: bool = True,
1537
+ ) -> None:
1538
+ thread_id, _turn_id = _notification_scope(notification)
1539
+ for handler in handlers:
1540
+ owner = (handler, thread_id or "")
1541
+ previous = self._callback_tails.get(owner) if ordered else None
1542
+ if previous is None:
1543
+ try:
1544
+ result = handler(notification)
1545
+ except Exception:
1546
+ continue
1547
+ if not inspect.isawaitable(result):
1548
+ # Preserve the original inline contract for synchronous
1549
+ # caches consumed by immediately following server requests.
1550
+ continue
1551
+ task = asyncio.ensure_future(result)
1552
+ else:
1553
+ task = asyncio.create_task(
1554
+ self._invoke_notification_handler(
1555
+ handler,
1556
+ notification,
1557
+ previous,
1558
+ )
1559
+ )
1560
+ self._callback_tails[owner] = task
1561
+ self._callback_tasks.add(task)
1562
+ task.add_done_callback(
1563
+ lambda completed, owner=owner: self._finish_callback_task(
1564
+ owner,
1565
+ completed,
1566
+ )
1567
+ )
1568
+
1519
1569
  async def _invoke_notification_handler(
1520
1570
  self,
1521
1571
  handler: NotificationHandler,
@@ -1530,6 +1580,8 @@ class CodexAppServerClient:
1530
1580
  # notification already received for the same handler.
1531
1581
  pass
1532
1582
  try:
1583
+ if self._notification_guard is not None and not self._notification_guard(notification):
1584
+ return
1533
1585
  result = handler(notification)
1534
1586
  except Exception:
1535
1587
  return
@@ -1749,7 +1801,11 @@ class CodexAppServerClient:
1749
1801
  isinstance(exc, CodexAppServerDisconnected)
1750
1802
  and exc.request_sent
1751
1803
  )
1752
- if ambiguous:
1804
+ # Ephemeral forks disappear with their owning process. After
1805
+ # explicit close there can be no late child left to delete;
1806
+ # durable forks still require their usual ambiguity cleanup.
1807
+ closing_ephemeral = payload.get("ephemeral") is True and self._closing
1808
+ if ambiguous and not closing_ephemeral:
1753
1809
  cleanup = asyncio.create_task(
1754
1810
  self._delete_late_fork_child(late_children)
1755
1811
  )
@@ -2628,6 +2684,7 @@ class CodexAppServerClient:
2628
2684
  # continue before its delayed start response is read.
2629
2685
  # Acceptance of that exact completed ID is proven; leave
2630
2686
  # the handle bound to the newer live turn, not the reply.
2687
+ provisional.initial_turn_id = turn_id
2631
2688
  return provisional
2632
2689
  raise CodexAppServerProtocolError(
2633
2690
  "turn/start response did not match its early notifications",
@@ -2635,6 +2692,7 @@ class CodexAppServerClient:
2635
2692
  safe_to_retry=False,
2636
2693
  )
2637
2694
  provisional.turn_id = turn_id
2695
+ provisional.initial_turn_id = turn_id
2638
2696
  if not retain_thread_stream:
2639
2697
  provisional._subscription.turn_id = turn_id
2640
2698
  return provisional
@@ -2696,6 +2754,7 @@ class CodexAppServerManager:
2696
2754
  process_factory: ProcessFactory | None = None,
2697
2755
  before_start: Callable[[], Awaitable[Any]] | None = None,
2698
2756
  server_request_handler: ServerRequestHandler | None = None,
2757
+ notification_guard: Callable[[dict[str, Any]], bool] | None = None,
2699
2758
  initialize_params: dict[str, Any] | None = None,
2700
2759
  on_process_started: ProcessLifecycleHook | None = None,
2701
2760
  on_process_exited: ProcessLifecycleHook | None = None,
@@ -2715,6 +2774,7 @@ class CodexAppServerManager:
2715
2774
  process_factory=process_factory,
2716
2775
  before_start=before_start,
2717
2776
  server_request_handler=server_request_handler,
2777
+ notification_guard=notification_guard,
2718
2778
  initialize_params=initialize_params,
2719
2779
  on_process_started=on_process_started,
2720
2780
  on_process_exited=on_process_exited,
@@ -1,7 +1,17 @@
1
1
  """Read-only native Codex authentication; custom keys belong to codex_provider."""
2
2
  from __future__ import annotations
3
3
 
4
+ import base64
5
+ from dataclasses import dataclass, field
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ from pathlib import Path
4
11
  import re
12
+ import secrets
13
+ import stat
14
+ import tomllib
5
15
 
6
16
  from fastapi import APIRouter, HTTPException, Request
7
17
  from fastapi.responses import JSONResponse
@@ -11,6 +21,115 @@ MAX_BODY_BYTES = 8192
11
21
  MAX_API_KEY_CHARS = 4096
12
22
  AUTH_TIMEOUT_SECONDS = 30.0
13
23
  BUSY_MESSAGE = "Wait for Codex chats, goals, queued turns and Side chat requests to finish before changing authentication."
24
+ HANDOFF_MESSAGE = (
25
+ "Refreshing Codex sign-in. Wait for this chat's running work, approvals, "
26
+ "goals and background terminals to finish, then retry. Your message has "
27
+ "not been sent to Codex."
28
+ )
29
+ _REVISION_KEY = secrets.token_bytes(32)
30
+ _MAX_AUTH_BYTES = 128 * 1024
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LoginRevision:
35
+ # Process-private change detection only, never authentication evidence.
36
+ # Do not put paths, account IDs, credentials or digests in repr/logs/API.
37
+ value: bytes = field(repr=False)
38
+
39
+
40
+ def _bounded_file(path: Path) -> bytes:
41
+ fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
42
+ with os.fdopen(fd, "rb") as stream:
43
+ before = os.fstat(stream.fileno())
44
+ if not stat.S_ISREG(before.st_mode) or before.st_size > _MAX_AUTH_BYTES:
45
+ raise ValueError("unsupported credential file")
46
+ raw = stream.read(_MAX_AUTH_BYTES + 1)
47
+ after = os.fstat(stream.fileno())
48
+ if len(raw) > _MAX_AUTH_BYTES or (before.st_mtime_ns, before.st_size) != (after.st_mtime_ns, after.st_size):
49
+ raise ValueError("credential file changed during read")
50
+ return raw
51
+
52
+
53
+ def _native_login_config(env: dict[str, str], cwd: str) -> tuple[Path, dict]:
54
+ home = env.get("HOME")
55
+ location = env.get("CODEX_HOME")
56
+ if not location and not home:
57
+ raise ValueError("native home unavailable")
58
+ if location and location.startswith("~/"):
59
+ if not home:
60
+ raise ValueError("native home unavailable")
61
+ location = str(Path(home) / location[2:])
62
+ root = Path(location) if location else Path(home) / ".codex"
63
+ if not root.is_absolute():
64
+ root = Path(cwd) / root
65
+ try:
66
+ config = tomllib.loads(_bounded_file(root / "config.toml").decode())
67
+ except FileNotFoundError:
68
+ config = {}
69
+ return root, config
70
+
71
+
72
+ def native_login_handoff_supported(env: dict[str, str], *, cwd: str) -> bool:
73
+ try:
74
+ _, config = _native_login_config(env, cwd)
75
+ # A process-only login cannot be recovered by starting a replacement.
76
+ # A malformed config must not retire an otherwise working generation.
77
+ return config.get("cli_auth_credentials_store", "file") in {"file", "keyring", "auto"}
78
+ except (OSError, ValueError, TypeError, RecursionError):
79
+ return False
80
+
81
+
82
+ def native_login_revision(env: dict[str, str], *, cwd: str) -> LoginRevision | None:
83
+ """Conservative file-store signal; native Codex still owns all credentials.
84
+
85
+ Ignore rotating access/refresh tokens and last_refresh. OIDC auth_time is
86
+ stable across refresh but changes on an interactive authentication. Without
87
+ it we can detect an account switch, not prove a same-account re-login;
88
+ Recheck CLIs provides an explicit handoff for that case and keyring stores.
89
+ Missing/malformed files are not evidence of a new login. No secret survives
90
+ this bounded read except a process-keyed comparison digest.
91
+ """
92
+ try:
93
+ root, config = _native_login_config(env, cwd)
94
+ if config.get("cli_auth_credentials_store", "file") != "file":
95
+ return None # Never infer keyring/auto/ephemeral state from a leftover file.
96
+ if any(env.get(key) for key in ("CODEX_ACCESS_TOKEN", "CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_IDENTITY_TOKEN_FILE")):
97
+ return None
98
+ data = json.loads(_bounded_file(root / "auth.json"))
99
+ if not isinstance(data, dict):
100
+ return None
101
+ key = data.get("OPENAI_API_KEY")
102
+ if isinstance(key, str) and key:
103
+ identity = ("apiKey", key)
104
+ else:
105
+ tokens = data.get("tokens")
106
+ if not isinstance(tokens, dict) or data.get("auth_mode") not in (None, "chatgpt"):
107
+ return None
108
+ encoded = tokens.get("id_token")
109
+ if not isinstance(encoded, str) or len(encoded) > 32768:
110
+ return None
111
+ parts = encoded.split(".")
112
+ if len(parts) != 3:
113
+ return None
114
+ claims = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4)))
115
+ if not isinstance(claims, dict):
116
+ return None
117
+ details = claims.get("https://api.openai.com/auth") or {}
118
+ if not isinstance(details, dict):
119
+ return None
120
+ account = tokens.get("account_id") or details.get("chatgpt_account_id")
121
+ subject = claims.get("sub")
122
+ if not isinstance(account, str) or not account or not isinstance(subject, str) or not subject:
123
+ return None
124
+ auth_time = claims.get("auth_time")
125
+ if auth_time is not None and (isinstance(auth_time, bool) or not isinstance(auth_time, int)):
126
+ return None
127
+ # Unverified claims are a change hint, never permission/account proof.
128
+ identity = ("chatgpt", account, subject, auth_time)
129
+ raw = json.dumps((str(root.resolve()), identity), separators=(",", ":")).encode()
130
+ return LoginRevision(hmac.new(_REVISION_KEY, raw, hashlib.sha256).digest())
131
+ except (OSError, ValueError, TypeError, RecursionError):
132
+ return None
14
133
 
15
134
 
16
135
  def capability(*, available: bool) -> dict:
@@ -157,7 +157,7 @@ def _native_stamp(path: Path) -> tuple[int, int, int, int]:
157
157
 
158
158
 
159
159
  def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _NativeReadBudget,
160
- *, end: int | None = None):
160
+ *, end: int | None = None, provider_source: bool = False):
161
161
  """Stream one fixed prefix; tool volume does not consume retained-key budget.
162
162
 
163
163
  A source proof needs only the prefix ending at its newest checkpoint. Never
@@ -181,7 +181,14 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
181
181
  if not line or len(line) > NATIVE_PROOF_LINE_BYTES or not line.endswith(b"\n"):
182
182
  raise _Unproven()
183
183
  offset += len(line)
184
- record = json.loads(line)
184
+ try:
185
+ record = json.loads(line)
186
+ except (ValueError, UnicodeDecodeError):
187
+ if not provider_source:
188
+ raise
189
+ # The provider importer skips malformed records. Their exact
190
+ # bytes still participate in the checkpoint digest below.
191
+ record = {}
185
192
  if not isinstance(record, dict):
186
193
  raise _Unproven()
187
194
  yield record, offset, line
@@ -229,6 +236,20 @@ def _public_assistant_item_id(event: dict) -> str | None:
229
236
  return item_id if public and isinstance(item_id, str) and 0 < len(item_id) <= 256 else None
230
237
 
231
238
 
239
+ def _native_owned_turn_ids(event: dict) -> set[str]:
240
+ """One logical run can retain its initial input across native continuations."""
241
+ return {value for field in ("provider_turn_id", "provider_initial_turn_id")
242
+ if isinstance(value := event.get(field), str) and 0 < len(value) <= 256}
243
+
244
+
245
+ def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str]) -> dict:
246
+ owners: dict = {}
247
+ for run, item_id, body_key in assistant_items:
248
+ if run in completed_runs:
249
+ owners.setdefault((item_id, body_key), set()).add(run)
250
+ return owners
251
+
252
+
232
253
  @dataclass
233
254
  class _SourceBudget:
234
255
  bytes_remaining: int
@@ -783,11 +804,12 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
783
804
  candidates.append(target)
784
805
  elif event.get("imported") is not True and event.get("backend") in (None, "codex"):
785
806
  if event.get("type") == "turn_finished" and event.get("backend") == "codex" and event.get("transport") == "app-server":
786
- thread, turn = event.get("provider_thread_id"), event.get("provider_turn_id")
787
- if isinstance(thread, str) and _PROVIDER_ID.fullmatch(thread) and isinstance(turn, str) and 0 < len(turn) <= 256:
788
- owned = owners.setdefault((thread, turn), set())
789
- retained += run not in owned
790
- owned.add(run)
807
+ thread = event.get("provider_thread_id")
808
+ if isinstance(thread, str) and _PROVIDER_ID.fullmatch(thread):
809
+ for turn in _native_owned_turn_ids(event):
810
+ owned = owners.setdefault((thread, turn), set())
811
+ retained += run not in owned
812
+ owned.add(run)
791
813
  kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
792
814
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
793
815
  if kind and isinstance(body, str) and body and len(body) <= 4 * 1024 * 1024:
@@ -868,10 +890,19 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
868
890
  compactions, candidate_assistants = {}, set()
869
891
  compaction_tracker = CodexCompactionSummaryTracker(thread)
870
892
  delivery_bodies, delivery_source_ids = {}, {}
893
+ completed_runs = {run for (owner_thread, _turn), runs in owners.items()
894
+ if owner_thread == thread for run in runs}
895
+ assistant_item_owners = _native_assistant_item_owners({
896
+ (run, item_id, body_key): event
897
+ for (run, kind, body_key), events in native.items() if kind == "assistant"
898
+ for event in events if (item_id := _public_assistant_item_id(event)) is not None
899
+ }, completed_runs)
900
+ assistant_owner_evidence: dict = {}
901
+ assistant_source_turns: dict = {}
871
902
  allowed_header_owners, header_parents = {thread}, {}
872
903
  context_turn = None
873
904
  retained = 0
874
- for record, offset, line in _native_records(source, stamp, budget, end=max(wanted)):
905
+ for record, offset, line in _native_records(source, stamp, budget, end=max(wanted), provider_source=True):
875
906
  payload = record.get("payload")
876
907
  if offset == len(line) or record.get("type") == "session_meta":
877
908
  if record.get("type") != "session_meta" or not isinstance(payload, dict):
@@ -922,6 +953,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
922
953
  or not isinstance(timestamp, str) or not 0 < len(timestamp) <= 64):
923
954
  continue
924
955
  key = (turn, item["kind"], _text_key(item["text"]), item.get("source_text_sha256"))
956
+ if origin and item["kind"] == "assistant" and item.get("source_text_sha256") is None:
957
+ item_key = (origin["event_id"], _text_key(_native_assistant_text(item["text"])))
958
+ if item_key in assistant_item_owners:
959
+ assistant_source_turns.setdefault(item_key, set()).add(turn)
960
+ assistant_owner_evidence.setdefault(turn, []).append((offset, item_key))
961
+ retained += 1
962
+ if retained > MAX_KEYS:
963
+ raise _Unproven()
925
964
  if origin and item["kind"] == "user" and any(run in deliveries.starts for run in owners.get((thread, turn), ())):
926
965
  ids = delivery_source_ids.setdefault(turn, set())
927
966
  if len(ids) < 2:
@@ -968,7 +1007,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
968
1007
  if len(matches) != 1:
969
1008
  continue
970
1009
  key = next(iter(matches))
971
- source_ids, owned_runs = canonical.get(key, {}), owners.get((thread, key[0]), set())
1010
+ source_ids = canonical.get(key, {})
1011
+ owned_runs = set(owners.get((thread, key[0]), set()))
1012
+ # Older ledgers retained only the final continuation's turn ID. An
1013
+ # exact public assistant item proves the earlier turn belongs to the
1014
+ # same completed native run, without classifying any user wording.
1015
+ for offset, item_key in assistant_owner_evidence.get(key[0], ()):
1016
+ if offset <= end and len(assistant_source_turns[item_key]) == 1:
1017
+ owned_runs.update(assistant_item_owners[item_key])
972
1018
  # Compaction owns a particular source item and timestamp. Another
973
1019
  # genuine answer with the same body in this turn is a distinct item.
974
1020
  timestamp_origins = [origin for origin in source_ids.values() if origin["timestamp"] == timestamp]
@@ -1177,11 +1223,11 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1177
1223
  or not 0 < len(run) <= 256 or run.startswith("import_")):
1178
1224
  continue
1179
1225
  if (event.get("type") == "turn_finished" and event.get("backend") == "codex"
1180
- and event.get("transport") == "app-server" and event.get("provider_thread_id") == provider_id
1181
- and isinstance(event.get("provider_turn_id"), str) and 0 < len(event["provider_turn_id"]) <= 256):
1182
- owned = owners.setdefault(event["provider_turn_id"], set())
1183
- owner_count += run not in owned
1184
- owned.add(run)
1226
+ and event.get("transport") == "app-server" and event.get("provider_thread_id") == provider_id):
1227
+ for turn_id in _native_owned_turn_ids(event):
1228
+ owned = owners.setdefault(turn_id, set())
1229
+ owner_count += run not in owned
1230
+ owned.add(run)
1185
1231
  kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
1186
1232
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
1187
1233
  wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
@@ -1198,6 +1244,25 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1198
1244
  assistant_items[(run, item_id, key[1])] = event["id"]
1199
1245
  if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
1200
1246
  raise _Unproven()
1247
+ assistant_item_owners = _native_assistant_item_owners(
1248
+ assistant_items, {run for runs in owners.values() for run in runs},
1249
+ )
1250
+ assistant_source_turns: dict = {}
1251
+ for item in items:
1252
+ budget.check()
1253
+ origin = item.get("provider_origin")
1254
+ if (item.get("kind") != "assistant" or not isinstance(item.get("text"), str)
1255
+ or item.get("source_text_sha256") is not None or not isinstance(origin, dict)
1256
+ or origin.get("provider") != "codex" or origin.get("kind") != "assistant"
1257
+ or origin.get("session_id", provider_id) != provider_id
1258
+ or not isinstance(origin.get("turn_id"), str) or not 0 < len(origin["turn_id"]) <= 256):
1259
+ continue
1260
+ item_key = (origin.get("event_id"), _text_key(_native_assistant_text(item["text"])))
1261
+ if item_key in assistant_item_owners:
1262
+ assistant_source_turns.setdefault(item_key, set()).add(origin["turn_id"])
1263
+ for item_key, turns in assistant_source_turns.items():
1264
+ if len(turns) == 1:
1265
+ owners.setdefault(next(iter(turns)), set()).update(assistant_item_owners[item_key])
1201
1266
  delivery_proofs = _prove_pending_async_deliveries(
1202
1267
  session_id, provider_id, items, source_path, root, sync_checkpoint, parse_item,
1203
1268
  previous_seq, owners, deliveries, budget,