@agentsdock/server 1.0.9 → 1.0.10-beta.4

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.
@@ -138,13 +138,19 @@ class ClaudeGoalProjection:
138
138
  def consume(self, event: Any) -> bool:
139
139
  """Fold one native JSONL record; return whether visible goal changed."""
140
140
  if not isinstance(event, dict) or (
141
- event.get("type") != "attachment"
141
+ event.get("type") not in {"attachment", "system"}
142
142
  or event.get("sessionId") != self.provider_session_id
143
143
  or event.get("isSidechain") is not False
144
144
  ):
145
145
  return False
146
146
  attachment = event.get("attachment")
147
- if not isinstance(attachment, dict) or attachment.get("type") != "goal_status":
147
+ content = event.get("content")
148
+ stopped = (event.get("type") == "system" and event.get("subtype") == "informational"
149
+ and event.get("level") == "warning" and isinstance(content, str)
150
+ and re.match(r"\AA hook blocked the turn from ending [0-9]+ consecutive times [—-] overriding and ending turn\.", content))
151
+ if stopped:
152
+ attachment = {"type": "goal_status", "condition": (self.goal or {}).get("condition"), "met": False}
153
+ elif not isinstance(attachment, dict) or attachment.get("type") != "goal_status":
148
154
  return False
149
155
  event_id = event.get("uuid")
150
156
  if not isinstance(event_id, str) or len(event_id) != 36:
@@ -175,10 +181,16 @@ class ClaudeGoalProjection:
175
181
  return False
176
182
  goal = dict(previous)
177
183
  goal["status"] = "cleared" if sentinel else "achieved" if met else "active"
184
+ if stopped:
185
+ goal["execution_stop_reason"] = "continuation_limit"
186
+ goal["execution_stopped_at"] = timestamp
187
+ else:
188
+ goal.pop("execution_stop_reason", None)
189
+ goal.pop("execution_stopped_at", None)
178
190
  if sentinel:
179
191
  for field in ("iterations", "duration_ms", "tokens"):
180
192
  goal.pop(field, None)
181
- if not sentinel:
193
+ if not sentinel and not stopped:
182
194
  reason = attachment.get("reason")
183
195
  if isinstance(reason, str) and reason:
184
196
  goal["last_reason"] = reason[:8192]
@@ -233,7 +245,7 @@ class ClaudeGoalProjection:
233
245
  if self._discard_line:
234
246
  self._discard_line = False
235
247
  continue
236
- if len(line) > MAX_GOAL_RECORD_BYTES or b'"goal_status"' not in line:
248
+ if len(line) > MAX_GOAL_RECORD_BYTES or not (b'"goal_status"' in line or b"A hook blocked the turn from ending" in line):
237
249
  continue
238
250
  try:
239
251
  self.consume(json.loads(line))
@@ -1088,6 +1088,8 @@ class CodexAppServerClient:
1088
1088
  self._next_id += 1
1089
1089
  request_id = self._next_id
1090
1090
  future: asyncio.Future[Any] = loop.create_future()
1091
+ # Preserve request ownership for per-chat process handoff.
1092
+ future._codex_thread_id = str((params or {}).get("threadId") or (params or {}).get("ancestorThreadId") or "")
1091
1093
  self._pending[request_id] = (
1092
1094
  method,
1093
1095
  future,
@@ -1557,6 +1559,7 @@ class CodexAppServerClient:
1557
1559
  previous,
1558
1560
  )
1559
1561
  )
1562
+ task._codex_thread_id = thread_id
1560
1563
  self._callback_tails[owner] = task
1561
1564
  self._callback_tasks.add(task)
1562
1565
  task.add_done_callback(
@@ -1619,6 +1622,7 @@ class CodexAppServerClient:
1619
1622
  self._handle_server_request(request_id, method, params),
1620
1623
  name=f"codex-app-server-request-{request_id}",
1621
1624
  )
1625
+ task._codex_thread_id = str(params.get("threadId") or "")
1622
1626
  self._server_request_tasks[request_id] = task
1623
1627
  task.add_done_callback(
1624
1628
  lambda done, rid=request_id: self._finish_server_request(rid, done)
@@ -28,6 +28,37 @@ HANDOFF_MESSAGE = (
28
28
  )
29
29
  _REVISION_KEY = secrets.token_bytes(32)
30
30
  _MAX_AUTH_BYTES = 128 * 1024
31
+ HANDOFF_PROBE_FAILURES = frozenset({"thread_list_unavailable", "goal_state_unavailable",
32
+ "terminal_state_unavailable", "ownership_deadline", "native_unsubscribe_incomplete", "writer_release_failed"})
33
+
34
+
35
+ def handoff_detail(reason: str) -> dict:
36
+ """Content-free admission status, distinct from a native auth rejection."""
37
+ reasons = {
38
+ "active_turn": "this chat still has a running turn",
39
+ "maintenance": "this chat is finishing a provider operation",
40
+ "approval": "this chat has an unanswered approval or question",
41
+ "side_chat": "this chat has a running Side chat",
42
+ "active_goal": "this chat has an active goal",
43
+ "native_goal_active": "Codex reports an active goal for this chat",
44
+ "subagents": "this chat has running subagents",
45
+ "callback": "this chat is finishing a provider notification",
46
+ "runtime_request": "a provider request for this chat is still finishing",
47
+ "native_thread_in_use": "Codex still has work using this thread",
48
+ "background_terminals": "this chat has a running background terminal",
49
+ "thread_list_unavailable": "Codex could not report which threads it owns",
50
+ "goal_state_unavailable": "Codex could not report this chat's goal state",
51
+ "terminal_state_unavailable": "Codex could not report this chat's background terminals",
52
+ "ownership_deadline": "Codex took too long to report this chat's state",
53
+ "native_unsubscribe_incomplete": "Codex has not released this thread yet",
54
+ "writer_release_failed": "Codex could not release the old connection to this thread",
55
+ }
56
+ reason = reason if reason in reasons else "runtime_request"
57
+ action = ("Retry the message to check again." if reason in HANDOFF_PROBE_FAILURES
58
+ else "Retry after that operation finishes.")
59
+ return {"code": "codex_login_handoff", "reason": reason, "retryable": True,
60
+ "message": f"Codex sign-in handoff is waiting: {reasons[reason]}. "
61
+ f"Your message has not been sent. {action}"}
31
62
 
32
63
 
33
64
  @dataclass(frozen=True)
@@ -202,10 +202,26 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
202
202
 
203
203
  def _native_event_identity(event: dict) -> dict:
204
204
  """Keep bounded public identity, never retain tool bodies or message text."""
205
- return {key: event[key] for key in ("id", "seq", "type", "phase", "item_id")
205
+ return {key: event[key] for key in ("id", "seq", "type", "phase", "item_id", "provider_thread_id", "provider_turn_id")
206
206
  if key in event and (type(event[key]) is int or isinstance(event[key], str) and len(event[key]) <= 256)}
207
207
 
208
208
 
209
+ def _native_goal_steer_turn(event: dict) -> str | None:
210
+ """Accepted goal follow-ups already have a user bubble in this chat.
211
+
212
+ Keep their exact native turn: one goal run can span many provider turns,
213
+ including later genuine messages with identical text.
214
+ """
215
+ turn = event.get("provider_turn_id")
216
+ if (event.get("type") == "turn_steered" and event.get("backend") == "codex"
217
+ and event.get("purpose") == "codex_goal_resume"
218
+ and event.get("native_goal_steer") is True and event.get("native_steer") is True
219
+ and event.get("provider_user_authored") is True
220
+ and isinstance(turn, str) and 0 < len(turn) <= 256):
221
+ return turn
222
+ return None
223
+
224
+
209
225
  def _native_import_batch(event: dict) -> dict:
210
226
  result = {key: event.get(key) for key in ("seq", "provider_session_id", "source_path")}
211
227
  checkpoint = event.get("_history_sync_checkpoint")
@@ -242,10 +258,13 @@ def _native_owned_turn_ids(event: dict) -> set[str]:
242
258
  if isinstance(value := event.get(field), str) and 0 < len(value) <= 256}
243
259
 
244
260
 
245
- def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str]) -> dict:
261
+ def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str], provider_id: str) -> dict:
246
262
  owners: dict = {}
247
- for run, item_id, body_key in assistant_items:
248
- if run in completed_runs:
263
+ for (run, item_id, body_key), event in assistant_items.items():
264
+ # A restart or Stop can omit the completion receipt. An exact public
265
+ # item already delivered by this provider thread still identifies its
266
+ # native run; source reconciliation below supplies the matching turn.
267
+ if run in completed_runs or event.get("provider_thread_id") == provider_id:
249
268
  owners.setdefault((item_id, body_key), set()).add(run)
250
269
  return owners
251
270
 
@@ -659,16 +678,36 @@ class _AsyncDeliveryIndex:
659
678
  starts: dict = field(default_factory=dict)
660
679
  ends: dict = field(default_factory=dict)
661
680
  receipts: dict = field(default_factory=dict)
681
+ status_queues: dict = field(default_factory=dict)
682
+ status_body_keys: set = field(default_factory=set)
662
683
  steered: set = field(default_factory=set)
663
684
  count: int = 0
664
685
 
665
686
  def observe(self, event: dict) -> None:
666
687
  event_type = event.get("type")
667
- if (event_type not in {"turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
688
+ if (event_type not in {"turn_queued", "turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
668
689
  "chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered"}
669
690
  or event.get("imported") is True or event.get("forked") is True
670
691
  or event.get("backend") not in (None, "codex")):
671
692
  return
693
+ if event_type == "turn_queued":
694
+ # The private durable request is exact provider input. Public display
695
+ # labels and wrapper-looking text are never evidence by themselves.
696
+ queued_id, body = event.get("queued_id"), event.get("request_prompt")
697
+ if (event.get("purpose") == "cross_chat_handoff_delivery"
698
+ and event.get("cross_chat_exchange_status") is True
699
+ and not _runtime_human_provenance(event)
700
+ and isinstance(queued_id, str) and 0 < len(queued_id) <= 256
701
+ and isinstance(body, str) and 0 < len(body) <= NATIVE_PROOF_LINE_BYTES):
702
+ record = {key: event.get(key) for key in ("seq", "cross_chat_exchange_id",
703
+ "cross_chat_exchange_leg_id", "source_session_id", "target_session_id")}
704
+ record["body_key"] = _text_key(body)
705
+ self.status_queues.setdefault(queued_id, []).append(record)
706
+ self.status_body_keys.add(record["body_key"])
707
+ self.count += 1
708
+ if self.count > MAX_KEYS:
709
+ raise _Unproven()
710
+ return
672
711
  run = (event.get("interrupted_run_id") or event.get("run_id")) if event_type == "turn_queue_run_now" else event.get("run_id")
673
712
  if (event_type in {"turn_steered", "turn_stopped"} or event.get("native_steer") is True):
674
713
  if isinstance(run, str) and 0 < len(run) <= 256 and not run.startswith("import_"):
@@ -686,7 +725,8 @@ class _AsyncDeliveryIndex:
686
725
  "provider_thread_id", "provider_turn_id", "cross_chat_envelope_id", "handoff_id", "message_id",
687
726
  "source_session_id", "target_session_id", "source_title", "target_run_id", "conversation_mode",
688
727
  "kind", "action", "handoff_action", "handoff_body_chars", "handoff_body_sha256",
689
- "message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id")
728
+ "message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id",
729
+ "cross_chat_exchange_leg_id", "cross_chat_exchange_status", "queued_id")
690
730
  record = {key: event.get(key) for key in fields
691
731
  if event.get(key) is None or type(event.get(key)) in (int, bool)
692
732
  or isinstance(event.get(key), str) and len(event[key]) <= 512}
@@ -710,11 +750,36 @@ class _AsyncDeliveryIndex:
710
750
  raise _Unproven()
711
751
 
712
752
  def match(self, session_id: str, provider_id: str, run: str, origin: dict,
713
- body: tuple[str, str, int] | None, source_ids: set) -> dict | None:
753
+ body: tuple[str, str, int] | None, source_ids: set, body_key: str = "") -> dict | None:
714
754
  starts, ends = self.starts.get(run, []), self.ends.get(run, [])
715
- if body is None or len(starts) != 1 or len(ends) != 1 or run in self.steered:
755
+ if len(starts) != 1 or len(ends) != 1 or run in self.steered:
716
756
  return None
717
757
  start, end = starts[0], ends[0]
758
+ if start.get("cross_chat_exchange_status") is True:
759
+ queued = self.status_queues.get(start.get("queued_id"), [])
760
+ if len(queued) != 1:
761
+ return None
762
+ receipt = queued[0]
763
+ identity = ("cross_chat_exchange_id", "cross_chat_exchange_leg_id",
764
+ "source_session_id", "target_session_id")
765
+ if (start.get("malformed") or end.get("malformed") or not start.get("body_key")
766
+ or any(not isinstance(start.get(key), str) or not start[key]
767
+ or receipt.get(key) != start[key] or end.get(key) != start[key] for key in identity)
768
+ or start.get("target_session_id") != session_id or start.get("source_session_id") == session_id
769
+ or start.get("backend") != "codex" or end.get("backend") != "codex"
770
+ or end.get("cross_chat_exchange_status") is not True or end.get("transport") != "app-server"
771
+ or end.get("provider_thread_id") != provider_id or end.get("provider_turn_id") != origin.get("turn_id")
772
+ or receipt["body_key"] != body_key or source_ids != {origin.get("event_id")} or not origin.get("event_id")
773
+ or any(type(record.get("seq")) is not int for record in (receipt, start, end))
774
+ or not receipt["seq"] < start["seq"] < end["seq"]
775
+ or not isinstance(start.get("id"), str) or not start["id"]):
776
+ return None
777
+ times = [_delivery_timestamp(value) for value in (start.get("ts"), origin.get("timestamp"), end.get("ts"))]
778
+ if any(value is None for value in times) or not times[0] <= times[1] <= times[2]:
779
+ return None
780
+ return _native_event_identity(start)
781
+ if body is None:
782
+ return None
718
783
  envelope = start.get("cross_chat_envelope_id")
719
784
  if (start.get("malformed") or end.get("malformed")
720
785
  or not isinstance(envelope, str) or not envelope or start.get("backend") != "codex"
@@ -810,7 +875,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
810
875
  owned = owners.setdefault((thread, turn), set())
811
876
  retained += run not in owned
812
877
  owned.add(run)
813
- kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
878
+ kind = "user" if event.get("type") == "turn_started" or _native_goal_steer_turn(event) else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
814
879
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
815
880
  if kind and isinstance(body, str) and body and len(body) <= 4 * 1024 * 1024:
816
881
  native.setdefault((run, kind, _text_key(body)), []).append(_native_event_identity(event))
@@ -889,14 +954,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
889
954
  assistant_native_keys = {}
890
955
  compactions, candidate_assistants = {}, set()
891
956
  compaction_tracker = CodexCompactionSummaryTracker(thread)
892
- delivery_bodies, delivery_source_ids = {}, {}
957
+ delivery_bodies, delivery_source_ids, full_user_keys = {}, {}, {}
893
958
  completed_runs = {run for (owner_thread, _turn), runs in owners.items()
894
959
  if owner_thread == thread for run in runs}
895
960
  assistant_item_owners = _native_assistant_item_owners({
896
961
  (run, item_id, body_key): event
897
962
  for (run, kind, body_key), events in native.items() if kind == "assistant"
898
963
  for event in events if (item_id := _public_assistant_item_id(event)) is not None
899
- }, completed_runs)
964
+ }, completed_runs, thread)
900
965
  assistant_owner_evidence: dict = {}
901
966
  assistant_source_turns: dict = {}
902
967
  allowed_header_owners, header_parents = {thread}, {}
@@ -970,6 +1035,21 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
970
1035
  raise _Unproven()
971
1036
  if (item["kind"], key[2], key[3]) not in candidate_keys:
972
1037
  continue
1038
+ if item["kind"] == "user" and key[3] is not None:
1039
+ # A display-trimmed input still has its complete source in this
1040
+ # checkpoint-verified record. Compare those bytes to the original
1041
+ # native input, never a shared preview or an unverified hash.
1042
+ full_text = None
1043
+ if record.get("type") == "event_msg" and payload.get("type") == "user_message":
1044
+ full_text = payload.get("message")
1045
+ elif record.get("type") == "response_item" and payload.get("role") == "user":
1046
+ blocks = payload.get("content")
1047
+ if (isinstance(blocks, list) and len(blocks) == 1 and isinstance(blocks[0], dict)
1048
+ and blocks[0].get("type") == "input_text"):
1049
+ full_text = blocks[0].get("text")
1050
+ if (isinstance(full_text, str) and full_text
1051
+ and _text_key(" ".join(full_text.split())) == key[3]):
1052
+ full_user_keys[key] = _text_key(full_text)
973
1053
  if item["kind"] == "user" and (body := _async_delivery_body(item["text"])):
974
1054
  delivery_bodies[key] = body
975
1055
  if item["kind"] == "assistant":
@@ -1027,9 +1107,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1027
1107
  if len(proofs) > MAX_TARGETS:
1028
1108
  raise _Unproven()
1029
1109
  continue
1030
- if len(source_ids) != 1 or source_hash is not None:
1031
- # Truncated source hashes need a separately retained native full-body
1032
- # hash. Until one exists, the bounded preview cannot prove equality.
1110
+ if len(source_ids) != 1 or source_hash is not None and key not in full_user_keys:
1033
1111
  continue
1034
1112
  source_origin = next(iter(source_ids.values()))
1035
1113
  if source_origin.get("kind") in ("subagent_notification", "turn_aborted", "provider_notice"):
@@ -1041,10 +1119,12 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1041
1119
  if len(owned_runs) != 1:
1042
1120
  continue
1043
1121
  native_run = next(iter(owned_runs))
1044
- native_matches = native.get((native_run, kind, body_key), [])
1045
- if not native_matches and kind == "user":
1122
+ native_body_key = full_user_keys.get(key, body_key)
1123
+ native_matches = [event for event in native.get((native_run, kind, native_body_key), [])
1124
+ if event.get("type") != "turn_steered" or event.get("provider_turn_id") == key[0]]
1125
+ if not native_matches and kind == "user" and source_hash is None:
1046
1126
  delivery = deliveries.match(session_id, thread, native_run, source_origin,
1047
- delivery_bodies.get(key), delivery_source_ids.get(key[0], set()))
1127
+ delivery_bodies.get(key), delivery_source_ids.get(key[0], set()), body_key)
1048
1128
  if delivery is not None:
1049
1129
  native_matches = [delivery]
1050
1130
  if not native_matches and kind == "assistant":
@@ -1059,7 +1139,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1059
1139
  if not native_matches:
1060
1140
  continue
1061
1141
  representative = min(native_matches, key=lambda event: event["seq"])
1062
- proofs[target] = {**next(iter(source_ids.values())), "native_event_id": representative["id"], "source_text_sha256": body_key}
1142
+ proofs[target] = {**next(iter(source_ids.values())), "native_event_id": representative["id"], "source_text_sha256": native_body_key}
1063
1143
  if len(proofs) > MAX_TARGETS:
1064
1144
  raise _Unproven()
1065
1145
  budget.check()
@@ -1137,10 +1217,10 @@ class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
1137
1217
  **({"_agentsdock_imported_prompt_hidden": True} if event["type"] == "turn_started" else {})}
1138
1218
 
1139
1219
 
1140
- def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: list[dict],
1220
+ def _prove_pending_native_inputs(session_id: str, provider_id: str, items: list[dict],
1141
1221
  source: Path, root: Path, checkpoint: dict, parse_item: Callable,
1142
1222
  previous_seq: int, owners: dict, deliveries: _AsyncDeliveryIndex,
1143
- budget: _NativeReadBudget) -> dict:
1223
+ budget: _NativeReadBudget, native_inputs: dict | None = None) -> dict:
1144
1224
  """Reuse pinned-prefix proof before broadcast; the synthetic batch is never stored."""
1145
1225
  cursor = checkpoint.get("cursor")
1146
1226
  if (checkpoint.get("version") != 1 or not isinstance(cursor, dict) or cursor.get("version") != 1
@@ -1149,16 +1229,21 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
1149
1229
  raise _Unproven()
1150
1230
  run = "import_pending_native_delivery_proof"
1151
1231
  candidates, indexes = [], {}
1232
+ steer_keys = {key[2] for key, events in (native_inputs or {}).items()
1233
+ if key[1] == "user" and any(event.get("type") == "turn_steered" for event in events)}
1152
1234
  for index, item in enumerate(items):
1153
1235
  budget.check()
1154
1236
  origin = item.get("provider_origin")
1155
1237
  if (item.get("kind") != "user" or not isinstance(item.get("text"), str)
1156
1238
  or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
1157
- or origin.get("session_id", provider_id) != provider_id or item.get("source_text_sha256") is not None
1158
- or not _async_delivery_body(item["text"])):
1239
+ or origin.get("session_id", provider_id) != provider_id
1240
+ or not (item.get("source_text_sha256") is not None
1241
+ or _async_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys
1242
+ or _text_key(item["text"]) in steer_keys)):
1159
1243
  continue
1160
1244
  candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
1161
1245
  "type": "turn_started", "backend": "codex", "imported": True, "prompt": item["text"],
1246
+ "source_text_sha256": item.get("source_text_sha256"),
1162
1247
  "ts": origin.get("timestamp"), "provider_user_authored": _runtime_human_provenance(item)})
1163
1248
  if candidate is not None:
1164
1249
  candidates.append(candidate)
@@ -1168,7 +1253,7 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
1168
1253
  if not candidates:
1169
1254
  return {}
1170
1255
  proof = _prove_native_source(provider_id, source, root,
1171
- {run: (previous_seq + 1, previous_seq + len(items) + 2, checkpoint)}, candidates, {},
1256
+ {run: (previous_seq + 1, previous_seq + len(items) + 2, checkpoint)}, candidates, native_inputs or {},
1172
1257
  {(provider_id, turn): runs for turn, runs in owners.items()}, parse_item, budget,
1173
1258
  session_id=session_id, deliveries=deliveries, require_verified_checkpoint=True)
1174
1259
  result = {}
@@ -1188,10 +1273,10 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1188
1273
  parse_item: Callable[[dict], dict | None] | None = None) -> list[dict]:
1189
1274
  """An existing verified import boundary can omit exact completed native copies.
1190
1275
 
1191
- Exact native bodies need only the ledger. Legacy async wrappers additionally
1192
- require the caller's source/checkpoint/parser and one verified source-prefix
1193
- scan: a delta range alone cannot disambiguate a later same-turn human quote.
1194
- Missing source context leaves those wrappers visible for later read repair.
1276
+ Exact native bodies need only the ledger. Legacy async wrappers and trimmed
1277
+ user inputs additionally require the caller's source/checkpoint/parser and
1278
+ one verified source-prefix scan. A preview alone never proves equality.
1279
+ Missing source context leaves those inputs visible for later read repair.
1195
1280
  Incomplete proof raises; the caller must defer import and its cursor commit.
1196
1281
  """
1197
1282
  if not items:
@@ -1202,11 +1287,11 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1202
1287
  budget = _NativeReadBudget(cancelled, deadline)
1203
1288
  stamp = _native_stamp(events)
1204
1289
  owners, native, assistant_items, wake_keys, native_count = {}, {}, {}, set(), 0
1290
+ native_inputs, steered_inputs = {}, {}
1205
1291
  deliveries = _AsyncDeliveryIndex()
1206
1292
  prove_deliveries = (source_path is not None and root is not None
1207
1293
  and isinstance(sync_checkpoint, dict) and callable(parse_item)
1208
- and any(item.get("kind") == "user" and isinstance(item.get("text"), str)
1209
- and _async_delivery_body(item["text"]) for item in items))
1294
+ and any(item.get("kind") == "user" and isinstance(item.get("text"), str) for item in items))
1210
1295
  previous_seq, owner_count = 0, 0
1211
1296
  for event, _offset, _line in _native_records(events, stamp, budget):
1212
1297
  seq = event.get("seq")
@@ -1228,24 +1313,31 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1228
1313
  owned = owners.setdefault(turn_id, set())
1229
1314
  owner_count += run not in owned
1230
1315
  owned.add(run)
1231
- kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
1316
+ steer_turn = _native_goal_steer_turn(event)
1317
+ kind = "user" if event.get("type") == "turn_started" or steer_turn else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
1232
1318
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
1233
1319
  wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
1234
1320
  if kind and (isinstance(body, str) and body or wake_hash):
1235
- keys = native.setdefault(run, {})
1321
+ keys = steered_inputs.setdefault((run, steer_turn), {}) if steer_turn else native.setdefault(run, {})
1236
1322
  key = (kind, wake_hash or _text_key(body))
1237
1323
  native_count += key not in keys
1238
1324
  if isinstance(event.get("id"), str) and 0 < len(event["id"]) <= 256:
1239
1325
  keys.setdefault(key, event["id"])
1326
+ if prove_deliveries and kind == "user" and isinstance(body, str) and body:
1327
+ inputs = native_inputs.setdefault((run, kind, key[1]), [])
1328
+ if not any(prior.get("type") == event.get("type")
1329
+ and prior.get("provider_turn_id") == event.get("provider_turn_id") for prior in inputs):
1330
+ inputs.append(_native_event_identity(event))
1331
+ native_count += 1
1240
1332
  if wake_hash:
1241
1333
  wake_keys.add((run, wake_hash))
1242
1334
  item_id = _public_assistant_item_id(event)
1243
1335
  if item_id is not None:
1244
- assistant_items[(run, item_id, key[1])] = event["id"]
1336
+ assistant_items[(run, item_id, key[1])] = _native_event_identity(event)
1245
1337
  if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
1246
1338
  raise _Unproven()
1247
1339
  assistant_item_owners = _native_assistant_item_owners(
1248
- assistant_items, {run for runs in owners.values() for run in runs},
1340
+ assistant_items, {run for runs in owners.values() for run in runs}, provider_id,
1249
1341
  )
1250
1342
  assistant_source_turns: dict = {}
1251
1343
  for item in items:
@@ -1263,10 +1355,17 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1263
1355
  for item_key, turns in assistant_source_turns.items():
1264
1356
  if len(turns) == 1:
1265
1357
  owners.setdefault(next(iter(turns)), set()).update(assistant_item_owners[item_key])
1266
- delivery_proofs = _prove_pending_async_deliveries(
1358
+ if prove_deliveries and steered_inputs:
1359
+ # The unmatched import can contain only follow-ups: its assistant
1360
+ # messages were already reconciled. Preserve public item identities
1361
+ # for the source scan to recover earlier turns of a long goal run.
1362
+ for (run, _item_id, body_key), event in assistant_items.items():
1363
+ native_inputs.setdefault((run, "assistant", body_key), []).append(event)
1364
+ delivery_proofs = _prove_pending_native_inputs(
1267
1365
  session_id, provider_id, items, source_path, root, sync_checkpoint, parse_item,
1268
- previous_seq, owners, deliveries, budget,
1269
- ) if prove_deliveries and deliveries.starts else {}
1366
+ previous_seq, owners, deliveries, budget, native_inputs,
1367
+ ) if prove_deliveries and (deliveries.starts or steered_inputs or any(
1368
+ item.get("kind") == "user" and item.get("source_text_sha256") is not None for item in items)) else {}
1270
1369
  # A hidden wake has no public prompt body. Require a single exact source
1271
1370
  # item in this verified import range, in addition to native turn ownership.
1272
1371
  wake_source_ids = {}
@@ -1287,9 +1386,15 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1287
1386
  and origin.get("session_id", provider_id) == provider_id and len(runs) == 1
1288
1387
  and item.get("source_text_sha256") is None and isinstance(item.get("text"), str))
1289
1388
  known = owned and (item["kind"], _text_key(item["text"])) in native.get(next(iter(runs)), {})
1290
- delivery = delivery_proofs.get(index) if owned else None
1389
+ # Steers never use delta-local text equality as proof. An equal
1390
+ # source item can precede this delta's checkpoint in the same
1391
+ # native turn. Only the full verified prefix can establish that
1392
+ # this source occurrence is the already-shown follow-up.
1393
+ # This proof already established exact source bytes, native owner,
1394
+ # original event and checkpoint even when the display is trimmed.
1395
+ delivery = delivery_proofs.get(index)
1291
1396
  known = known or delivery is not None
1292
- if known and (next(iter(runs)), _text_key(item["text"])) in wake_keys:
1397
+ if known and runs and (next(iter(runs)), _text_key(item["text"])) in wake_keys:
1293
1398
  known = (bool(origin.get("event_id")) and bool(origin.get("timestamp"))
1294
1399
  and len(wake_source_ids.get((origin.get("turn_id"), _text_key(item["text"])), set())) == 1)
1295
1400
  if owned and not known and item["kind"] == "assistant":
@@ -1305,7 +1410,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1305
1410
  "provider_history_repair": "source_proven_native_replay",
1306
1411
  "provider_origin": {**origin, "session_id": provider_id,
1307
1412
  "native_event_id": delivery["native_event_id"] if delivery else native[next(iter(runs))][key],
1308
- "source_text_sha256": key[1]}})
1413
+ "source_text_sha256": delivery["source_text_sha256"] if delivery else key[1]}})
1309
1414
  budget.check()
1310
1415
  return result
1311
1416
  except (OSError, ValueError, TypeError, KeyError, RuntimeError) as exc:
@@ -195,6 +195,19 @@ def session_choice(value) -> str:
195
195
  return choice
196
196
 
197
197
 
198
+ RUNTIME_FIELDS = ("codex_provider", "codex_provider_revision", "codex_provider_binding", "model", "effort", "_codex_provider_removed")
199
+
200
+
201
+ def requested_session(session: dict) -> dict:
202
+ """Desired runtime is separate from the owner of an in-flight turn."""
203
+ pending = session.get("_codex_provider_pending")
204
+ return {**session, **pending} if isinstance(pending, dict) else dict(session)
205
+
206
+
207
+ def runtime_selection(session: dict) -> dict:
208
+ return {key: session.get(key) for key in RUNTIME_FIELDS}
209
+
210
+
198
211
  class ProviderStore:
199
212
  """Atomic metadata pointer plus private, endpoint-bound credential records."""
200
213
  def __init__(self, root: Path):
@@ -329,6 +342,8 @@ class ProviderStore:
329
342
  def for_session(self, session: dict, *, include_key=False) -> dict | None:
330
343
  if session_choice(session.get("codex_provider")) == "default":
331
344
  return None
345
+ if session.get("_codex_provider_removed"):
346
+ raise HTTPException(409, "Configure the custom Codex endpoint before using this chat.")
332
347
  revision = session.get("codex_provider_revision")
333
348
  selected = self.selection(include_key=include_key, revision=revision, include_revision=True)
334
349
  if selected is None:
@@ -345,6 +360,30 @@ class ProviderStore:
345
360
  selected["model"] = validate_model(model)
346
361
  return selected
347
362
 
363
+ def control(self, session: dict, *, available=True, summary=False) -> dict:
364
+ requested = requested_session(session)
365
+ pending = isinstance(session.get("_codex_provider_pending"), dict)
366
+ requested_provider = session_choice(requested.get("codex_provider"))
367
+ def endpoint(value):
368
+ if session_choice(value.get("codex_provider")) != "custom" or value.get("_codex_provider_removed"):
369
+ return None
370
+ try:
371
+ selected = self._public_selections.get(value.get("codex_provider_revision")) or self.for_session(value)
372
+ return selected.get("base_url") if selected else None
373
+ except HTTPException:
374
+ return None
375
+ return {"pending": pending,
376
+ "pending_reason": session.get("_codex_provider_pending_reason") if pending else None,
377
+ "requested_provider": requested_provider,
378
+ "active_provider": session_choice(session.get("codex_provider")),
379
+ "requested_model": requested.get("model"), "requested_effort": requested.get("effort"),
380
+ # The next-turn picker must use the requested endpoint's catalog,
381
+ # while active runtime fields continue describing running work.
382
+ # Avoid duplicating the active catalog on ordinary session events.
383
+ "requested_catalog": self.catalog(available=available, session=requested, summary=summary)
384
+ if pending and requested_provider == "custom" else None,
385
+ "requested_base_url": endpoint(requested), "active_base_url": endpoint(session)}
386
+
348
387
  def registration(self, *, include_key=False) -> dict | None:
349
388
  # A damaged optional endpoint must not prevent normal Codex startup.
350
389
  # Custom chat admission and the admin status route still fail closed.
@@ -575,6 +614,17 @@ class ProviderStore:
575
614
  if selected:
576
615
  self._atomic(self._binding_name(thread_id), {"binding": binding(selected),
577
616
  "credential_id": selected.get("credential_id")})
617
+ else:
618
+ self._atomic(self._binding_name(thread_id), {"binding": None, "credential_id": None})
619
+
620
+ def thread_binding(self, thread_id: str) -> dict:
621
+ """Snapshot rollback metadata without reopening revoked credentials."""
622
+ with self.lock:
623
+ return self._read(self._binding_name(thread_id)) or {"binding": None, "credential_id": None}
624
+
625
+ def restore_thread_binding(self, thread_id: str, record: dict) -> None:
626
+ with self.lock:
627
+ self._atomic(self._binding_name(thread_id), record)
578
628
 
579
629
  def for_thread(self, thread_id: str, *, include_key=False) -> dict | None:
580
630
  with self.lock:
@@ -1076,7 +1126,7 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
1076
1126
  session = session_lookup(session_id) if session_lookup else None
1077
1127
  if not session:
1078
1128
  raise HTTPException(404, "Chat not found.")
1079
- selected = await asyncio.to_thread(store.for_session, session, include_key=True)
1129
+ selected = await asyncio.to_thread(store.for_session, requested_session(session), include_key=True)
1080
1130
  else:
1081
1131
  selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
1082
1132
  if selected is None:
@@ -1141,7 +1191,7 @@ def create_router(*, authorize, store: ProviderStore, mutate, probe, available,
1141
1191
  session = session_lookup(session_id) if session_lookup else None
1142
1192
  if not session:
1143
1193
  raise HTTPException(404, "Chat not found.")
1144
- selected = await asyncio.to_thread(store.for_session, session, include_key=True)
1194
+ selected = await asyncio.to_thread(store.for_session, requested_session(session), include_key=True)
1145
1195
  else:
1146
1196
  selected = await asyncio.to_thread(store.selection, include_key=True, include_revision=True)
1147
1197
  if selected is None: