@agentsdock/server 1.0.11-beta.1 → 1.0.11-beta.12

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 (38) hide show
  1. package/NOTICE +7 -0
  2. package/package.json +1 -1
  3. package/server/NOTICE +7 -0
  4. package/server/VERSION +1 -1
  5. package/server/agent_server.py +1474 -318
  6. package/server/agentsdock_jobs.py +2 -2
  7. package/server/codex_auth.py +48 -0
  8. package/server/codex_history_repair.py +180 -15
  9. package/server/cursor_process_guard.py +67 -4
  10. package/server/cursor_provider_mcp.py +2 -0
  11. package/server/execution_durability.py +87 -11
  12. package/server/execution_install.py +5 -1
  13. package/server/gemini_acp_client.py +432 -0
  14. package/server/gemini_api.py +128 -0
  15. package/server/gemini_content.py +102 -0
  16. package/server/gemini_history.py +293 -0
  17. package/server/gemini_import.py +126 -0
  18. package/server/gemini_permissions.py +47 -0
  19. package/server/gemini_runner.py +493 -0
  20. package/server/gemini_runtime.py +322 -0
  21. package/server/install.sh +25 -2
  22. package/server/interactive_chat_controls.py +9 -3
  23. package/server/interactive_chat_native.py +1 -1
  24. package/server/interactive_chat_share_routes.py +2 -2
  25. package/server/interactive_chat_share_web.py +2 -2
  26. package/server/local_session_ownership.py +1 -0
  27. package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
  28. package/server/npm/agentsdock/pair.cjs +123 -0
  29. package/server/npm/agentsdock/qr-renderer.cjs +15 -0
  30. package/server/provider_commands.py +65 -0
  31. package/server/provider_connections.py +39 -10
  32. package/server/public_chat_share_routes.py +8 -6
  33. package/server/public_chat_shares.py +17 -7
  34. package/server/server_instances.py +195 -15
  35. package/server/shared_chat_analytics_web.py +5 -0
  36. package/server/title_generation.py +60 -1
  37. package/server/transcript_reader.py +20 -8
  38. package/server/update_runner.py +4 -2
@@ -507,7 +507,7 @@ def build_parser() -> argparse.ArgumentParser:
507
507
  create_parser.add_argument("--loop", action="store_true", help="repeat at the interval")
508
508
  create_parser.add_argument("--max-runs", type=int)
509
509
  create_parser.add_argument("--disabled", action="store_true")
510
- create_parser.add_argument("--backend", choices=("codex", "claude", "cursor"))
510
+ create_parser.add_argument("--backend", choices=("codex", "claude", "cursor", "opencode", "gemini"))
511
511
  create_parser.add_argument(
512
512
  "--context-mode",
513
513
  choices=("chat", "standalone"),
@@ -541,7 +541,7 @@ def build_parser() -> argparse.ArgumentParser:
541
541
  run_limit_group = update_parser.add_mutually_exclusive_group()
542
542
  run_limit_group.add_argument("--max-runs", type=int)
543
543
  run_limit_group.add_argument("--unlimited", action="store_true", help="clear a finite run limit")
544
- update_parser.add_argument("--backend", choices=("codex", "claude", "cursor"))
544
+ update_parser.add_argument("--backend", choices=("codex", "claude", "cursor", "opencode", "gemini"))
545
545
  update_parser.add_argument(
546
546
  "--context-mode",
547
547
  choices=("chat", "standalone"),
@@ -61,6 +61,54 @@ def handoff_detail(reason: str) -> dict:
61
61
  "message": f"Codex sign-in handoff is waiting: {reasons[reason]}. "
62
62
  f"Your message has not been sent. {action}"}
63
63
 
64
+ # Native ChatGPT sign-in only. API-key and custom-provider availability is
65
+ # provider-owned. Keep old IDs in saved conversations; this is picker/admission
66
+ # policy, not transcript cleanup. Source: https://learn.chatgpt.com/docs/models
67
+ CHATGPT_RETIRED_MODELS = frozenset({
68
+ "gpt-5.2", "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.3-codex-spark",
69
+ "gpt-5.4", "gpt-5.4-mini", "gpt-5.5",
70
+ })
71
+
72
+
73
+ def native_model_auth_mode(env: dict[str, str], *, cwd: str, observed_mode: str = "") -> str:
74
+ """Read only the auth kind for model policy, never authenticate or refresh.
75
+
76
+ An opaque store may use the existing native login-status probe. A file
77
+ store is checked directly so a switch to an API key cannot inherit a cached
78
+ ChatGPT restriction. Unknown/managed providers are never globally banned.
79
+ """
80
+ try:
81
+ root, config = _native_login_config(env, cwd)
82
+ profile = config.get("profile")
83
+ profiles = config.get("profiles")
84
+ selected_profile = profiles.get(profile, {}) if isinstance(profiles, dict) and isinstance(profile, str) else {}
85
+ if not isinstance(selected_profile, dict):
86
+ return "unknown"
87
+ provider = selected_profile.get("model_provider", config.get("model_provider", "openai"))
88
+ if provider != "openai":
89
+ return "custom"
90
+ providers = config.get("model_providers") or {}
91
+ override = providers.get("openai") if isinstance(providers, dict) else None
92
+ if env.get("OPENAI_BASE_URL") or isinstance(override, dict) and override.get("base_url"):
93
+ return "custom"
94
+ if any(env.get(key) for key in ("CODEX_API_KEY", "OPENAI_API_KEY", "OPENAI_IDENTITY_TOKEN_FILE")):
95
+ return "apiKey"
96
+ if env.get("CODEX_ACCESS_TOKEN"):
97
+ return "unknown"
98
+ store = config.get("cli_auth_credentials_store", "file")
99
+ if store != "file":
100
+ return observed_mode if store in {"keyring", "auto"} and observed_mode in {"chatgpt", "apiKey"} else "unknown"
101
+ data = json.loads(_bounded_file(root / "auth.json"))
102
+ if not isinstance(data, dict):
103
+ return "unknown"
104
+ if isinstance(data.get("OPENAI_API_KEY"), str) and data["OPENAI_API_KEY"]:
105
+ return "apiKey"
106
+ if data.get("auth_mode") in (None, "chatgpt") and isinstance(data.get("tokens"), dict):
107
+ return "chatgpt"
108
+ except (OSError, ValueError, TypeError, RecursionError):
109
+ pass
110
+ return "unknown"
111
+
64
112
 
65
113
  @dataclass(frozen=True)
66
114
  class LoginRevision:
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from bisect import bisect_right
9
9
  from collections import Counter, OrderedDict
10
+ from contextlib import contextmanager
10
11
  from dataclasses import dataclass, field
11
12
  import hashlib
12
13
  import hmac
@@ -137,6 +138,11 @@ class CodexNativeHistoryProofUnavailable(ValueError):
137
138
  """Incomplete evidence must defer import, not become a new human message."""
138
139
 
139
140
 
141
+ class CodexNativeHistoryProofCapacityExceeded(CodexNativeHistoryProofUnavailable):
142
+ """The same unchanged input cannot fit; require changed input or manual retry."""
143
+ deterministic = True
144
+
145
+
140
146
  @dataclass
141
147
  class _NativeReadBudget:
142
148
  cancelled: Callable[[], bool] | None = None
@@ -662,6 +668,71 @@ def _async_delivery_body(text: str) -> tuple[str, str, int] | None:
662
668
  return match[1], _text_key(match[2]), len(match[2])
663
669
 
664
670
 
671
+ @dataclass(frozen=True)
672
+ class _ExchangeDeliveryBody:
673
+ kind: str
674
+ ordinal: int
675
+ max_legs: int
676
+ body_key: str
677
+ body_chars: int
678
+
679
+
680
+ _EXCHANGE_DELIVERY_HEADER = re.compile(
681
+ r"\A\[AgentsDock delivery kind=(instruction|request|reply|final_result) "
682
+ r"leg=([1-9][0-9]{0,5})/([1-9][0-9]{0,5}) origin=(?:user|route|auto)"
683
+ r"(?: from=[^\[\]\r\n]{1,240})?\]\n"
684
+ )
685
+ _EXCHANGE_DELIVERY_FOOTERS = frozenset({
686
+ "",
687
+ "reply: use the respond command in the provider-authority block only if a reply or follow-up is needed.",
688
+ "reply: optional one-time terminal reply route via the respond command in the provider-authority block, only if a result, acknowledgement, or clarification should reach the origin; never add --request-response.",
689
+ "reply: exactly one terminal response remains; use the respond command in the provider-authority block without --request-response.",
690
+ "reply: use Chats respond-current through the AgentsDock provider tool only if a reply or follow-up is needed.",
691
+ "reply: exactly one terminal response remains; use Chats respond-current through the AgentsDock provider tool without --request-response.",
692
+ "reply: optional one-time terminal reply via Chats respond-current through the AgentsDock provider tool, only if a result, acknowledgement, or clarification should reach the origin; never add --request-response.",
693
+ })
694
+
695
+
696
+ def _cross_chat_delivery_body(text: str) -> tuple[str, str, int] | _ExchangeDeliveryBody | None:
697
+ """Read a complete generated envelope; durable receipts must still prove it."""
698
+ if len(text) > NATIVE_PROOF_TEXT_CHARS:
699
+ return None
700
+ asynchronous = _async_delivery_body(text)
701
+ if asynchronous is not None:
702
+ return asynchronous
703
+ header = _EXCHANGE_DELIVERY_HEADER.match(text)
704
+ if not header or int(header[2]) > int(header[3]) or not text.endswith("\n[End delivery]"):
705
+ return None
706
+ remainder = text[header.end():-len("\n[End delivery]")]
707
+ source_open = "[Source user instruction — verbatim, user-authored]\n"
708
+ source_close = "\n[End source user instruction]\n"
709
+ if remainder.startswith(source_open):
710
+ end = remainder.find(source_close, len(source_open))
711
+ if end < 0:
712
+ return None
713
+ remainder = remainder[end + len(source_close):]
714
+ else:
715
+ source = re.match(
716
+ r'^source-instruction: (?:this legacy relay has no recorded source user instruction; '
717
+ r'do not infer user authorization from the prepared content\.|replayed in full on the '
718
+ r'first leg delivered to this chat; excerpt="[^\n]*")\n', remainder,
719
+ )
720
+ if source is None:
721
+ return None
722
+ remainder = remainder[source.end():]
723
+ label = "Agent-prepared reply/result" if header[1] in {"reply", "final_result"} else "Agent-prepared handoff message"
724
+ prepared_open, prepared_close = f"[{label}]\n", f"\n[End {label.lower()}]"
725
+ end = remainder.rfind(prepared_close)
726
+ if (not remainder.startswith(prepared_open) or end < len(prepared_open)
727
+ or remainder[end + len(prepared_close):].strip() not in _EXCHANGE_DELIVERY_FOOTERS):
728
+ return None
729
+ body = remainder[len(prepared_open):end]
730
+ if not body.strip():
731
+ return None
732
+ return _ExchangeDeliveryBody("reply" if header[1] == "final_result" else header[1],
733
+ int(header[2]), int(header[3]), _text_key(body), len(body))
734
+
735
+
665
736
  def _delivery_timestamp(value: object) -> datetime | None:
666
737
  if not isinstance(value, str) or not 0 < len(value) <= 64:
667
738
  return None
@@ -680,13 +751,16 @@ class _AsyncDeliveryIndex:
680
751
  receipts: dict = field(default_factory=dict)
681
752
  status_queues: dict = field(default_factory=dict)
682
753
  status_body_keys: set = field(default_factory=set)
754
+ exchange_receipts: dict = field(default_factory=dict)
683
755
  steered: set = field(default_factory=set)
684
756
  count: int = 0
685
757
 
686
758
  def observe(self, event: dict) -> None:
687
759
  event_type = event.get("type")
688
760
  if (event_type not in {"turn_queued", "turn_started", "turn_finished", "turn_steered", "turn_stopped", "turn_queue_run_now",
689
- "chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered"}
761
+ "chat_conversation_message_received", "chat_conversation_message_started", "chat_conversation_message_delivered",
762
+ "cross_chat_exchange_leg_registered", "cross_chat_exchange_leg_received",
763
+ "cross_chat_exchange_leg_started", "cross_chat_exchange_leg_delivered", "cross_chat_exchange_leg_failed"}
690
764
  or event.get("imported") is True or event.get("forked") is True
691
765
  or event.get("backend") not in (None, "codex")):
692
766
  return
@@ -726,12 +800,13 @@ class _AsyncDeliveryIndex:
726
800
  "source_session_id", "target_session_id", "source_title", "target_run_id", "conversation_mode",
727
801
  "kind", "action", "handoff_action", "handoff_body_chars", "handoff_body_sha256",
728
802
  "message_revision", "message_edited_by_user", "exchange_id", "cross_chat_exchange_id",
729
- "cross_chat_exchange_leg_id", "cross_chat_exchange_status", "queued_id")
803
+ "exchange_leg_id", "cross_chat_exchange_leg_id", "exchange_leg_kind", "exchange_ordinal", "exchange_max_legs", "cross_chat_exchange_status", "queued_id")
730
804
  record = {key: event.get(key) for key in fields
731
805
  if event.get(key) is None or type(event.get(key)) in (int, bool)
732
806
  or isinstance(event.get(key), str) and len(event[key]) <= 512}
733
807
  record["malformed"] = len(record) != len(fields)
734
808
  record["type"] = event_type
809
+ record["human"] = _runtime_human_provenance(event)
735
810
  if isinstance(run, str) and 0 < len(run) <= 256 and not run.startswith("import_"):
736
811
  if event_type == "turn_started":
737
812
  body = event.get("prompt")
@@ -746,11 +821,16 @@ class _AsyncDeliveryIndex:
746
821
  and isinstance(envelope, str) and 0 < len(envelope) <= 256):
747
822
  self.receipts.setdefault(envelope, []).append(record)
748
823
  self.count += 1
824
+ if event_type.startswith("cross_chat_exchange_leg_"):
825
+ exchange, leg = event.get("cross_chat_exchange_id"), event.get("cross_chat_exchange_leg_id")
826
+ if all(isinstance(value, str) and 0 < len(value) <= 256 for value in (exchange, leg)):
827
+ self.exchange_receipts.setdefault((exchange, leg), []).append(record)
828
+ self.count += 1
749
829
  if self.count > MAX_KEYS:
750
830
  raise _Unproven()
751
831
 
752
832
  def match(self, session_id: str, provider_id: str, run: str, origin: dict,
753
- body: tuple[str, str, int] | None, source_ids: set, body_key: str = "") -> dict | None:
833
+ body: tuple[str, str, int] | _ExchangeDeliveryBody | None, source_ids: set, body_key: str = "") -> dict | None:
754
834
  starts, ends = self.starts.get(run, []), self.ends.get(run, [])
755
835
  if len(starts) != 1 or len(ends) != 1 or run in self.steered:
756
836
  return None
@@ -780,6 +860,8 @@ class _AsyncDeliveryIndex:
780
860
  return _native_event_identity(start)
781
861
  if body is None:
782
862
  return None
863
+ if isinstance(body, _ExchangeDeliveryBody):
864
+ return self.match_exchange(session_id, provider_id, run, origin, body, source_ids, start, end)
783
865
  envelope = start.get("cross_chat_envelope_id")
784
866
  if (start.get("malformed") or end.get("malformed")
785
867
  or not isinstance(envelope, str) or not envelope or start.get("backend") != "codex"
@@ -831,6 +913,58 @@ class _AsyncDeliveryIndex:
831
913
  return None
832
914
  return _native_event_identity(start)
833
915
 
916
+ def match_exchange(self, session_id: str, provider_id: str, run: str, origin: dict,
917
+ body: _ExchangeDeliveryBody, source_ids: set, start: dict, end: dict) -> dict | None:
918
+ exchange, leg = start.get("cross_chat_exchange_id"), start.get("cross_chat_exchange_leg_id")
919
+ if (start.get("malformed") or end.get("malformed") or start.get("human") or end.get("human")
920
+ or not all(isinstance(value, str) and value for value in (exchange, leg, start.get("source_session_id")))
921
+ or start.get("source_session_id") == session_id or start.get("target_session_id") != session_id
922
+ or start.get("backend") != "codex" or end.get("backend") != "codex" or end.get("transport") != "app-server"
923
+ or start.get("exchange_id") != exchange or start.get("exchange_leg_id") != leg
924
+ or end.get("provider_thread_id") != provider_id or end.get("provider_turn_id") != origin.get("turn_id")
925
+ or type(end.get("exit_code")) is not int or end["exit_code"] != 0
926
+ or end.get("stopped") not in (None, False) or end.get("is_error") not in (None, False)
927
+ or any(end.get(key) != start.get(key) for key in (
928
+ "source_session_id", "target_session_id", "exchange_id", "cross_chat_exchange_id",
929
+ "exchange_leg_id", "cross_chat_exchange_leg_id"))
930
+ or any(row.get("conversation_mode") or row.get("cross_chat_envelope_id")
931
+ or row.get("message_edited_by_user") or row.get("message_revision") for row in (start, end))
932
+ or source_ids != {origin.get("event_id")} or not origin.get("event_id")
933
+ or type(start.get("seq")) is not int or type(end.get("seq")) is not int or start["seq"] >= end["seq"]
934
+ or not isinstance(start.get("id"), str) or not start["id"]):
935
+ return None
936
+ times = [_delivery_timestamp(value) for value in (start.get("ts"), origin.get("timestamp"), end.get("ts"))]
937
+ if any(value is None for value in times) or not times[0] <= times[1] <= times[2]:
938
+ return None
939
+ stages = set()
940
+ for receipt in self.exchange_receipts.get((exchange, leg), []):
941
+ if (receipt.get("malformed") or receipt.get("human")
942
+ or receipt.get("exchange_id") != exchange or receipt.get("exchange_leg_id") != leg
943
+ or receipt.get("source_session_id") != start["source_session_id"] or receipt.get("target_session_id") != session_id
944
+ or receipt.get("exchange_leg_kind") != body.kind or type(receipt.get("exchange_ordinal")) is not int
945
+ or receipt["exchange_ordinal"] != body.ordinal or type(receipt.get("exchange_max_legs")) is not int
946
+ or receipt["exchange_max_legs"] != body.max_legs or receipt.get("handoff_body_sha256") != body.body_key
947
+ or type(receipt.get("handoff_body_chars")) is not int or receipt["handoff_body_chars"] != body.body_chars
948
+ or type(receipt.get("seq")) is not int):
949
+ return None
950
+ stage = receipt["type"]
951
+ if stage in {"cross_chat_exchange_leg_registered", "cross_chat_exchange_leg_received"}:
952
+ if receipt["seq"] >= start["seq"] or receipt.get("target_run_id") not in (None, run):
953
+ return None
954
+ elif receipt.get("target_run_id") != run:
955
+ return None
956
+ elif stage == "cross_chat_exchange_leg_started" and not start["seq"] < receipt["seq"] < end["seq"]:
957
+ return None
958
+ elif stage in {"cross_chat_exchange_leg_delivered", "cross_chat_exchange_leg_failed"} and receipt["seq"] <= end["seq"]:
959
+ return None
960
+ stages.add(stage)
961
+ # A failed exchange can still have successfully delivered the input
962
+ # (for example an empty final reply). The successful native turn above
963
+ # proves input ownership; the terminal receipt must not erase it.
964
+ if not {"cross_chat_exchange_leg_received", "cross_chat_exchange_leg_started"} <= stages:
965
+ return None
966
+ return _native_event_identity(start)
967
+
834
968
 
835
969
  def _prove_native_replays(session_id: str, provider_id: str, events: Path, source: Path | None,
836
970
  root: Path, parse_item: Callable[[dict], dict | None],
@@ -850,7 +984,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
850
984
  continue
851
985
  deliveries.observe(event)
852
986
  if len(candidates) + len(batches) + len(terminals) + retained + deliveries.count > MAX_KEYS:
853
- raise _Unproven()
987
+ raise CodexNativeHistoryProofCapacityExceeded("native history proof key budget exceeded")
854
988
  run = event.get("run_id")
855
989
  if not isinstance(run, str) or not 0 < len(run) <= 256:
856
990
  continue
@@ -884,7 +1018,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
884
1018
  native.setdefault((run, kind, wake_hash), []).append(_native_event_identity(event))
885
1019
  retained += 1
886
1020
  if len(candidates) + len(batches) + len(terminals) + retained + deliveries.count > MAX_KEYS:
887
- raise _Unproven()
1021
+ raise CodexNativeHistoryProofCapacityExceeded("native history proof key budget exceeded")
888
1022
  if not candidates:
889
1023
  return _NativeProof(provider_id)
890
1024
  groups = {}
@@ -1050,7 +1184,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1050
1184
  if (isinstance(full_text, str) and full_text
1051
1185
  and _text_key(" ".join(full_text.split())) == key[3]):
1052
1186
  full_user_keys[key] = _text_key(full_text)
1053
- if item["kind"] == "user" and (body := _async_delivery_body(item["text"])):
1187
+ if item["kind"] == "user" and (body := _cross_chat_delivery_body(item["text"])):
1054
1188
  delivery_bodies[key] = body
1055
1189
  if item["kind"] == "assistant":
1056
1190
  cleaned = _native_assistant_text(item["text"])
@@ -1146,18 +1280,33 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
1146
1280
  return proofs
1147
1281
 
1148
1282
 
1283
+ @contextmanager
1284
+ def _native_prepare_lock(lock, budget):
1285
+ # The budget starts before waiting, not after another chat releases it.
1286
+ while True:
1287
+ budget.check()
1288
+ if lock.acquire(timeout=min(0.05, max(0, budget.deadline - time.monotonic()))):
1289
+ break
1290
+ try:
1291
+ budget.check()
1292
+ yield
1293
+ finally:
1294
+ lock.release()
1295
+
1296
+
1149
1297
  class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
1150
1298
  """An explicit history boundary prepares proof; per-event projection does no IO."""
1151
1299
  def prepare(self, session_id: str, provider_id: str, events_path: Path, source_path: Path | None,
1152
1300
  root: Path, parse_item: Callable[[dict], dict | None], *,
1153
1301
  cancelled: Callable[[], bool] | None = None, deadline: float | None = None) -> bool:
1154
- with self._prepare_lock:
1302
+ budget = _NativeReadBudget(cancelled, deadline)
1303
+ with _native_prepare_lock(self._prepare_lock, budget):
1155
1304
  with self._lock:
1156
1305
  previous = self._proofs.get(session_id)
1157
1306
  if previous and previous.provider_id == provider_id:
1158
1307
  return False
1159
1308
  self._preparing, self._cancelled = session_id, False
1160
- budget = _NativeReadBudget(lambda: self._cancelled or bool(cancelled and cancelled()), deadline)
1309
+ budget.cancelled = lambda: self._cancelled or bool(cancelled and cancelled())
1161
1310
  try:
1162
1311
  proof = _prove_native_replays(
1163
1312
  session_id, provider_id, events_path, source_path, root, parse_item,
@@ -1167,6 +1316,8 @@ class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
1167
1316
  except (OSError, ValueError, TypeError, KeyError, RuntimeError) as exc:
1168
1317
  with self._lock:
1169
1318
  self._preparing = None
1319
+ if isinstance(exc, CodexNativeHistoryProofCapacityExceeded):
1320
+ raise
1170
1321
  # Unavailable proof is neither a negative answer nor a prepared
1171
1322
  # cache entry. The explicit worker decides when to retry it.
1172
1323
  raise CodexNativeHistoryProofUnavailable("Codex native history proof is incomplete") from exc
@@ -1238,7 +1389,7 @@ def _prove_pending_native_inputs(session_id: str, provider_id: str, items: list[
1238
1389
  or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
1239
1390
  or origin.get("session_id", provider_id) != provider_id
1240
1391
  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
1392
+ or _cross_chat_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys
1242
1393
  or _text_key(item["text"]) in steer_keys)):
1243
1394
  continue
1244
1395
  candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
@@ -1273,7 +1424,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1273
1424
  parse_item: Callable[[dict], dict | None] | None = None) -> list[dict]:
1274
1425
  """An existing verified import boundary can omit exact completed native copies.
1275
1426
 
1276
- Exact native bodies need only the ledger. Legacy async wrappers and trimmed
1427
+ Exact native bodies need only the ledger. Cross-chat wrappers and trimmed
1277
1428
  user inputs additionally require the caller's source/checkpoint/parser and
1278
1429
  one verified source-prefix scan. A preview alone never proves equality.
1279
1430
  Missing source context leaves those inputs visible for later read repair.
@@ -1286,6 +1437,16 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1286
1437
  raise _Unproven()
1287
1438
  budget = _NativeReadBudget(cancelled, deadline)
1288
1439
  stamp = _native_stamp(events)
1440
+ # The import batch defines which bodies can possibly be replayed.
1441
+ # Keep all ownership/ambiguity evidence and validate every journal
1442
+ # record, but do not retain keys for unrelated historical text.
1443
+ candidate_keys = {(item.get("kind"), _text_key(item["text"])) for item in items
1444
+ if isinstance(item.get("text"), str)}
1445
+ source_keys = {(item.get("kind"), item["source_text_sha256"]) for item in items
1446
+ if isinstance(item.get("source_text_sha256"), str)}
1447
+ candidate_keys.update(("assistant", _text_key(_native_assistant_text(item["text"])))
1448
+ for item in items if item.get("kind") == "assistant"
1449
+ and isinstance(item.get("text"), str))
1289
1450
  owners, native, assistant_items, wake_keys, native_count = {}, {}, {}, set(), 0
1290
1451
  native_inputs, steered_inputs = {}, {}
1291
1452
  deliveries = _AsyncDeliveryIndex()
@@ -1317,9 +1478,10 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1317
1478
  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
1318
1479
  body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
1319
1480
  wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
1320
- if kind and (isinstance(body, str) and body or wake_hash):
1481
+ key = (kind, wake_hash or _text_key(body)) if kind and (isinstance(body, str) and body or wake_hash) else None
1482
+ if key in candidate_keys or (isinstance(body, str) and
1483
+ (kind, _text_key(" ".join(body.split()))) in source_keys):
1321
1484
  keys = steered_inputs.setdefault((run, steer_turn), {}) if steer_turn else native.setdefault(run, {})
1322
- key = (kind, wake_hash or _text_key(body))
1323
1485
  native_count += key not in keys
1324
1486
  if isinstance(event.get("id"), str) and 0 < len(event["id"]) <= 256:
1325
1487
  keys.setdefault(key, event["id"])
@@ -1331,9 +1493,12 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
1331
1493
  native_count += 1
1332
1494
  if wake_hash:
1333
1495
  wake_keys.add((run, wake_hash))
1334
- item_id = _public_assistant_item_id(event)
1335
- if item_id is not None:
1336
- assistant_items[(run, item_id, key[1])] = _native_event_identity(event)
1496
+ # Public assistant identities can prove an earlier goal turn
1497
+ # even when this batch contains only a user follow-up. Retain
1498
+ # that ownership evidence independently of candidate text keys.
1499
+ item_id = _public_assistant_item_id(event)
1500
+ if item_id is not None and key is not None:
1501
+ assistant_items[(run, item_id, key[1])] = _native_event_identity(event)
1337
1502
  if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
1338
1503
  raise _Unproven()
1339
1504
  assistant_item_owners = _native_assistant_item_owners(
@@ -1,9 +1,11 @@
1
- """Small parent-death guard for a Cursor CLI subprocess.
1
+ """Small parent-death guard for an owned provider CLI subprocess.
2
2
 
3
3
  AgentsServer launches this helper in its own process group. The helper launches
4
4
  Cursor in a child group, forwards inherited stdio unchanged, and terminates the
5
5
  entire Cursor tree when its leader exits or AgentsServer disappears abruptly.
6
6
  This prevents a detached agent from continuing workspace edits after the turn.
7
+ Gemini opts into verified-cleanup exit codes; existing Cursor callers retain
8
+ their original exit contract. Process sampling is not an OS security sandbox.
7
9
  """
8
10
 
9
11
  from __future__ import annotations
@@ -25,6 +27,7 @@ TERMINATE_GRACE_SECONDS = 0.5
25
27
  PROCESS_SNAPSHOT_TIMEOUT_SECONDS = 0.2
26
28
  MAX_PROCESS_SNAPSHOT_ROWS = 32_768
27
29
  MAX_CAPTURED_DESCENDANTS = 4_096
30
+ READY_NOTICE = b"AGENTSDOCK_PROCESS_GUARD_READY\n"
28
31
 
29
32
 
30
33
  class _ProcessIdentity(NamedTuple):
@@ -34,7 +37,7 @@ class _ProcessIdentity(NamedTuple):
34
37
  started_at: str
35
38
 
36
39
 
37
- def _process_snapshot() -> dict[int, _ProcessIdentity]:
40
+ def _process_snapshot(*, required: bool = False) -> dict[int, _ProcessIdentity]:
38
41
  """Return one bounded POSIX process snapshot with stable-enough identities.
39
42
 
40
43
  ``lstart`` is intentionally retained as a PID-reuse fence. Before sending
@@ -48,6 +51,8 @@ def _process_snapshot() -> dict[int, _ProcessIdentity]:
48
51
  None,
49
52
  )
50
53
  if ps_path is None:
54
+ if required:
55
+ raise RuntimeError("Process inspection unavailable")
51
56
  return {}
52
57
  env = dict(os.environ)
53
58
  env["LC_ALL"] = "C"
@@ -61,13 +66,19 @@ def _process_snapshot() -> dict[int, _ProcessIdentity]:
61
66
  env=env,
62
67
  )
63
68
  except (OSError, subprocess.SubprocessError):
69
+ if required:
70
+ raise RuntimeError("Process inspection failed") from None
64
71
  return {}
65
72
  if result.returncode != 0:
73
+ if required:
74
+ raise RuntimeError("Process inspection failed")
66
75
  return {}
67
76
 
68
77
  snapshot: dict[int, _ProcessIdentity] = {}
69
78
  for index, raw_line in enumerate(result.stdout.splitlines()):
70
79
  if index >= MAX_PROCESS_SNAPSHOT_ROWS:
80
+ if required:
81
+ raise RuntimeError("Process inspection exceeded its limit")
71
82
  break
72
83
  parts = raw_line.strip().split(None, 3)
73
84
  if len(parts) != 4:
@@ -278,6 +289,14 @@ def _terminate_child_group(
278
289
 
279
290
 
280
291
  def main(argv: list[str]) -> int:
292
+ # Opt-in strict contract for Gemini; existing Cursor callers keep their
293
+ # established return-code behavior. 70 means cleanup was not confirmed.
294
+ verify_cleanup = bool(argv and argv[0] == "--verify-cleanup")
295
+ if verify_cleanup:
296
+ argv = argv[1:]
297
+ ready_notice = bool(verify_cleanup and argv and argv[0] == "--ready-notice")
298
+ if ready_notice:
299
+ argv = argv[1:]
281
300
  if len(argv) < 4 or argv[0] != "--parent-pid" or argv[2] != "--":
282
301
  return 64
283
302
  try:
@@ -299,6 +318,12 @@ def main(argv: list[str]) -> int:
299
318
  signal.signal(signal.SIGINT, request_stop)
300
319
  signal.signal(signal.SIGHUP, request_stop)
301
320
 
321
+ if ready_notice:
322
+ # Only the opt-in Gemini owner consumes this first stderr record.
323
+ # Publish it before launching native code, after TERM becomes a request
324
+ # for verified cleanup rather than an unhandled process-killing signal.
325
+ os.write(2, READY_NOTICE)
326
+
302
327
  # The server can disappear after spawning this wrapper but before Python
303
328
  # reaches Popen. Do not briefly launch an editing agent in that window.
304
329
  if stop.is_set() or os.getppid() != expected_parent:
@@ -307,9 +332,13 @@ def main(argv: list[str]) -> int:
307
332
  try:
308
333
  child = subprocess.Popen(command, start_new_session=True)
309
334
  except OSError:
310
- return 127
335
+ # Popen did not return a child; it reaps a failed exec itself. Preserve
336
+ # the legacy Cursor code, but do not fence Gemini as though an editing
337
+ # process survived when no provider was launched.
338
+ return 71 if verify_cleanup else 127
311
339
 
312
340
  interrupted = False
341
+ inspection_failed = False
313
342
  child_group = child.pid
314
343
  tracked_descendants: dict[int, _ProcessIdentity] = {}
315
344
  next_descendant_snapshot = 0.0
@@ -317,7 +346,22 @@ def main(argv: list[str]) -> int:
317
346
  now = time.monotonic()
318
347
  child_alive = child.poll() is None
319
348
  if now >= next_descendant_snapshot:
320
- snapshot = _process_snapshot()
349
+ try:
350
+ snapshot = _process_snapshot(required=verify_cleanup)
351
+ except RuntimeError:
352
+ # A caller may signal the wrapper's group while its short ps
353
+ # probe is in flight. Retry outside that signal before declaring
354
+ # inspection unavailable; never equate an interrupted ps with
355
+ # an empty process table.
356
+ if stop.is_set():
357
+ try:
358
+ snapshot = _process_snapshot(required=verify_cleanup)
359
+ except RuntimeError:
360
+ inspection_failed = True
361
+ break
362
+ else:
363
+ inspection_failed = True
364
+ break
321
365
  child_alive = child.poll() is None
322
366
  _track_child_processes(
323
367
  child.pid,
@@ -340,6 +384,25 @@ def main(argv: list[str]) -> int:
340
384
  child_group,
341
385
  tuple(tracked_descendants.values()),
342
386
  )
387
+ if verify_cleanup:
388
+ deadline = time.monotonic() + 1.5
389
+ while True:
390
+ try:
391
+ snapshot = _process_snapshot(required=True)
392
+ except RuntimeError:
393
+ return 70
394
+ survivors = _matching_survivors(tuple(tracked_descendants.values()), snapshot)
395
+ group_alive = any(row.process_group == child_group for row in snapshot.values())
396
+ if child.poll() is not None and not survivors and not group_alive:
397
+ break
398
+ if time.monotonic() >= deadline:
399
+ return 70
400
+ time.sleep(POLL_SECONDS)
401
+ if inspection_failed:
402
+ return 70
403
+ # A distinct verified exit contract prevents an unexpected wrapper
404
+ # crash (e.g. Python exit 1) from masquerading as successful cleanup.
405
+ return 143 if interrupted else (0 if child.returncode == 0 else 71)
343
406
  return 143 if interrupted else int(child.returncode or 0)
344
407
 
345
408
 
@@ -41,6 +41,7 @@ class CursorToolBroker:
41
41
  self.server = None
42
42
  self.tasks: set[asyncio.Task] = set()
43
43
  self.closed = False
44
+ self.definition_requested = asyncio.Event()
44
45
 
45
46
  async def start(self) -> dict[str, str]:
46
47
  self.server = await asyncio.start_server(
@@ -78,6 +79,7 @@ class CursorToolBroker:
78
79
  or not hmac.compare_digest(request["secret"], self.secret)):
79
80
  raise ValueError("provider IPC is forbidden")
80
81
  if request.get("method") == "definition":
82
+ self.definition_requested.set()
81
83
  result = self.definition
82
84
  elif request.get("method") == "call":
83
85
  key = request.get("key")