@agentsdock/server 1.0.10 → 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 (39) 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 +1658 -498
  6. package/server/agentsdock_jobs.py +2 -2
  7. package/server/claude_history_repair.py +46 -61
  8. package/server/codex_auth.py +76 -7
  9. package/server/codex_history_repair.py +203 -38
  10. package/server/cursor_process_guard.py +67 -4
  11. package/server/cursor_provider_mcp.py +2 -0
  12. package/server/execution_durability.py +87 -11
  13. package/server/execution_install.py +5 -1
  14. package/server/gemini_acp_client.py +432 -0
  15. package/server/gemini_api.py +128 -0
  16. package/server/gemini_content.py +102 -0
  17. package/server/gemini_history.py +293 -0
  18. package/server/gemini_import.py +126 -0
  19. package/server/gemini_permissions.py +47 -0
  20. package/server/gemini_runner.py +493 -0
  21. package/server/gemini_runtime.py +322 -0
  22. package/server/install.sh +26 -2
  23. package/server/interactive_chat_controls.py +9 -3
  24. package/server/interactive_chat_native.py +1 -1
  25. package/server/interactive_chat_share_routes.py +2 -2
  26. package/server/interactive_chat_share_web.py +2 -2
  27. package/server/local_session_ownership.py +1 -0
  28. package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
  29. package/server/npm/agentsdock/pair.cjs +123 -0
  30. package/server/npm/agentsdock/qr-renderer.cjs +15 -0
  31. package/server/provider_commands.py +65 -0
  32. package/server/provider_connections.py +39 -10
  33. package/server/public_chat_share_routes.py +8 -6
  34. package/server/public_chat_shares.py +17 -7
  35. package/server/server_instances.py +217 -15
  36. package/server/shared_chat_analytics_web.py +5 -0
  37. package/server/title_generation.py +60 -1
  38. package/server/transcript_reader.py +334 -0
  39. package/server/update_runner.py +4 -2
@@ -26,6 +26,7 @@ import importlib.util
26
26
  import inspect
27
27
  import io
28
28
  import json
29
+ from transcript_reader import jsonl_records, scan_check, SCAN_SECONDS
29
30
  import logging
30
31
  import logging.handlers
31
32
  import math
@@ -125,6 +126,7 @@ from provider_commands import (
125
126
  claude_provider_command_inventory,
126
127
  codex_provider_command_inventory,
127
128
  empty_provider_command_inventory,
129
+ gemini_provider_skill_inventory,
128
130
  opencode_provider_skill_inventory,
129
131
  validate_opencode_provider_skill_record,
130
132
  )
@@ -207,7 +209,8 @@ BACKEND_CLAUDE = "claude"
207
209
  BACKEND_CODEX = "codex"
208
210
  BACKEND_CURSOR = "cursor"
209
211
  BACKEND_OPENCODE = "opencode"
210
- VALID_BACKENDS = {BACKEND_CLAUDE, BACKEND_CODEX, BACKEND_CURSOR, BACKEND_OPENCODE}
212
+ BACKEND_GEMINI = "gemini"
213
+ VALID_BACKENDS = {BACKEND_CLAUDE, BACKEND_CODEX, BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
211
214
  CODEX_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
212
215
  CODEX_EFFORT_ALIASES = {
213
216
  "extra high": "xhigh",
@@ -471,39 +474,17 @@ def boolean_setting(suffix: str, default: bool) -> bool:
471
474
  return default
472
475
 
473
476
 
474
- CODEX_GOALS_DEFAULT_ENABLED = boolean_setting("CODEX_GOALS_ENABLED", True)
477
+ CODEX_GOALS_DEFAULT_ENABLED = True
475
478
 
476
479
 
477
480
  def read_codex_goals_enabled(path: Path | None = None) -> bool:
478
- """Read the persistent server-wide Codex goals policy.
481
+ """Goals are available without a server-wide opt-in.
479
482
 
480
- Existing installations have no settings file, so goals remain enabled by
481
- default. An environment setting can choose the first-run default, while
482
- an administrator's persisted choice remains authoritative afterward.
483
+ Ignore the retired environment/config switch, including persisted False
484
+ from older clients. Do not rewrite shared settings or resume paused goals.
485
+ Keep the optional path argument for compatibility with existing callers.
483
486
  """
484
-
485
- settings_path = path or CODEX_SETTINGS_FILE
486
- try:
487
- value = json.loads(settings_path.read_text())
488
- except FileNotFoundError:
489
- return CODEX_GOALS_DEFAULT_ENABLED
490
- except (json.JSONDecodeError, OSError) as exc:
491
- logger.warning(
492
- "could not read Codex server settings from %s: %s",
493
- settings_path,
494
- exc,
495
- )
496
- return CODEX_GOALS_DEFAULT_ENABLED
497
- if not isinstance(value, dict) or not isinstance(
498
- value.get("goals_enabled"),
499
- bool,
500
- ):
501
- logger.warning(
502
- "ignoring malformed Codex server settings at %s",
503
- settings_path,
504
- )
505
- return CODEX_GOALS_DEFAULT_ENABLED
506
- return bool(value["goals_enabled"])
487
+ return True
507
488
 
508
489
 
509
490
  # Per-thread Codex config overrides (codex-cli 0.149.1: thread/start,
@@ -683,6 +664,7 @@ def codex_effective_thread_config(sess: dict[str, Any]) -> dict[str, Any]:
683
664
  CODEX_GOALS_ENABLED = read_codex_goals_enabled()
684
665
 
685
666
  CODEX_FALLBACK_MODELS = (
667
+ ("gpt-6-luna", "GPT-6-Luna"),
686
668
  ("gpt-5.6-sol", "GPT-5.6-Sol"),
687
669
  ("gpt-5.6-terra", "GPT-5.6-Terra"),
688
670
  ("gpt-5.6-luna", "GPT-5.6-Luna"),
@@ -693,6 +675,7 @@ CODEX_FALLBACK_MODELS = (
693
675
  CODEX_FALLBACK_EFFORTS = ("low", "medium", "high", "xhigh", "max", "ultra")
694
676
  CODEX_EFFORT_ORDER = ("none", "minimal", *CODEX_FALLBACK_EFFORTS)
695
677
  CODEX_FALLBACK_MODEL_EFFORTS = {
678
+ "gpt-6-luna": ("low", "medium", "high", "xhigh", "max"),
696
679
  "gpt-5.6-sol": ("low", "medium", "high", "xhigh", "max", "ultra"),
697
680
  "gpt-5.6-terra": ("low", "medium", "high", "xhigh", "max", "ultra"),
698
681
  "gpt-5.6-luna": ("low", "medium", "high", "xhigh", "max"),
@@ -1319,8 +1302,10 @@ HISTORY_SYNC_EVENT_SCAN_LIMIT = int(
1319
1302
  MAX_IMPORTED_TEXT_CHARS = int(agentsdock_setting("HISTORY_IMPORT_TEXT_CHARS", "12000"))
1320
1303
  MAX_LOCAL_SESSION_LIST_ITEMS = 500
1321
1304
  MAX_LOCAL_SESSION_SCAN_FILES = 10_000
1322
- MAX_LOCAL_TRANSCRIPT_BYTES = 256 * 1024 * 1024
1323
- MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 4 * 1024 * 1024
1305
+ # Retained text/metadata budget. Opaque native media/tool bytes are streamed.
1306
+ # Private timeline/index readers still use this as their raw record bound.
1307
+ MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 32 * 1024 * 1024
1308
+ HISTORY_SYNC_BATCH_BYTES = 16 * 1024 * 1024
1324
1309
  MAX_LOCAL_TRANSCRIPT_SCAN_LINES = 250_000
1325
1310
  MAX_CODEX_SESSION_INDEX_BYTES = 16 * 1024 * 1024
1326
1311
  MAX_CODEX_SESSION_INDEX_LINES = 100_000
@@ -6337,6 +6322,7 @@ class CreateSessionRequest(BaseModel):
6337
6322
  # Same three names as Cursor, but "default" means OpenCode's own
6338
6323
  # permissions, which do allow shell.
6339
6324
  opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
6325
+ gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
6340
6326
 
6341
6327
 
6342
6328
  MAX_BULK_IMPORT_ITEMS = 25
@@ -6348,9 +6334,10 @@ PROVIDER_SESSION_IDENTIFIER_RE = re.compile(
6348
6334
 
6349
6335
  class BulkImportSessionItem(BaseModel):
6350
6336
  provider_session_id: str = Field(min_length=1, max_length=256)
6351
- backend: Literal["claude", "codex", "cursor"]
6337
+ backend: Literal["claude", "codex", "cursor", "gemini"]
6352
6338
  cwd: str | None = Field(default=None, max_length=MAX_WORKSPACE_PATH_CHARS)
6353
6339
  title: str | None = Field(default=None, max_length=MAX_LOCAL_SESSION_LABEL_CHARS)
6340
+ provider_connection: Literal["custom"] | None = None
6354
6341
 
6355
6342
  @field_validator("provider_session_id")
6356
6343
  @classmethod
@@ -6374,6 +6361,7 @@ class BulkImportSessionsRequest(BaseModel):
6374
6361
 
6375
6362
 
6376
6363
  class UpdateSessionRequest(BaseModel):
6364
+ gemini_connect_api: bool | None = Field(default=None, strict=True)
6377
6365
  title: str | None = None
6378
6366
  auto_title_enabled: bool | None = None
6379
6367
  folder: str | None = None
@@ -6402,6 +6390,7 @@ class UpdateSessionRequest(BaseModel):
6402
6390
  provider_jobs_access: Literal["full", "read_only", "blocked"] | None = None
6403
6391
  cursor_permission_mode: Literal["default", "full_access", "plan"] | None = None
6404
6392
  opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
6393
+ gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
6405
6394
 
6406
6395
 
6407
6396
  SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
@@ -6420,6 +6409,7 @@ SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
6420
6409
  "provider_jobs_access",
6421
6410
  "cursor_permission_mode",
6422
6411
  "opencode_permission_mode",
6412
+ "gemini_permission_mode",
6423
6413
  "archived",
6424
6414
  })
6425
6415
 
@@ -6569,12 +6559,25 @@ class EmergencyAlertRequest(BaseModel):
6569
6559
  class AcknowledgeEmergencyRequest(BaseModel):
6570
6560
  model_config = {"extra": "forbid"}
6571
6561
 
6572
- expected_alert_id: str = Field(
6562
+ expected_alert_id: str | None = Field(
6563
+ default=None,
6564
+ min_length=8,
6565
+ max_length=80,
6566
+ pattern=r"^emergency_[0-9a-f]{32}$",
6567
+ )
6568
+ through_alert_id: str | None = Field(
6569
+ default=None,
6573
6570
  min_length=8,
6574
6571
  max_length=80,
6575
6572
  pattern=r"^emergency_[0-9a-f]{32}$",
6576
6573
  )
6577
6574
 
6575
+ @model_validator(mode="after")
6576
+ def exactly_one_alert_boundary(self):
6577
+ if (self.expected_alert_id is None) == (self.through_alert_id is None):
6578
+ raise ValueError("provide exactly one of expected_alert_id or through_alert_id")
6579
+ return self
6580
+
6578
6581
 
6579
6582
  class ChatReference(BaseModel):
6580
6583
  model_config = {"extra": "forbid"}
@@ -6743,6 +6746,7 @@ class ProviderCommandSelectionUnavailable(HTTPException):
6743
6746
 
6744
6747
  class TurnRequest(BaseModel):
6745
6748
  prompt: str
6749
+ client_request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,128}$")
6746
6750
  # Server-derived attribution only; the guest router accepts neither field
6747
6751
  # from its JSON body and never accepts arbitrary TurnRequest controls.
6748
6752
  shared_chat_id: str | None = Field(default=None, pattern=r"^interactive_[a-f0-9]{32}$")
@@ -7055,6 +7059,10 @@ def normalize_runtime_effort(backend: str, effort: Any, *, strict: bool = False)
7055
7059
  clean = str(effort or "").strip().lower()
7056
7060
  if not clean:
7057
7061
  return None
7062
+ if backend == BACKEND_GEMINI:
7063
+ if strict:
7064
+ raise HTTPException(400, "Gemini reasoning settings are managed by its native model.")
7065
+ return None
7058
7066
  if str(backend or "").strip().lower() != BACKEND_CODEX:
7059
7067
  return clean
7060
7068
 
@@ -7200,7 +7208,7 @@ def preview_session_runtime_update(
7200
7208
 
7201
7209
  current_connection = sess.get("provider_connection") or "default"
7202
7210
  connection = (patch.get("provider_connection") or "default") if "provider_connection" in patch else "default" if backend_changed else current_connection
7203
- if connection not in {"default", "custom"} or connection == "custom" and prospective_backend not in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR}:
7211
+ if connection not in {"default", "custom"} or connection == "custom" and prospective_backend not in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR, BACKEND_GEMINI}:
7204
7212
  raise HTTPException(400, "This backend does not support that API connection.")
7205
7213
  connection_changed = current_connection != connection
7206
7214
  if connection_changed and session_backend_locked(sess):
@@ -7212,11 +7220,21 @@ def preview_session_runtime_update(
7212
7220
  if "model" in patch
7213
7221
  else None if backend_changed or provider_changed else sess.get("model")
7214
7222
  )
7223
+ previous_model = prospective_model
7224
+ if prospective_backend == BACKEND_CODEX and prospective_provider == "default" and prospective_model:
7225
+ prospective_model = codex_available_model(str(prospective_model))
7215
7226
  prospective_effort = (
7216
7227
  patch.get("effort")
7217
7228
  if "effort" in patch
7218
7229
  else None if backend_changed or provider_changed else sess.get("effort")
7219
7230
  )
7231
+ if prospective_model != previous_model and prospective_effort:
7232
+ # Saved/queued settings may contain a level unsupported by the new
7233
+ # model. Validate the vocabulary, then preserve the nearest level.
7234
+ prospective_effort = clamp_codex_runtime_effort(
7235
+ str(prospective_model),
7236
+ normalize_runtime_effort(BACKEND_CODEX, prospective_effort, strict="effort" in patch),
7237
+ )
7220
7238
  normalized_effort = (
7221
7239
  normalize_runtime_effort(prospective_backend, prospective_effort, strict="effort" in patch)
7222
7240
  if prospective_provider == "custom" else
@@ -7235,7 +7253,7 @@ def preview_session_runtime_update(
7235
7253
  preview["model"] = prospective_model
7236
7254
  preview["effort"] = normalized_effort
7237
7255
  preview["provider_connection"] = connection
7238
- if connection_changed or connection == "default":
7256
+ if backend_changed or connection_changed or connection == "default":
7239
7257
  preview.pop("provider_connection_revision", None)
7240
7258
  if connection == "custom":
7241
7259
  selected_connection = PROVIDER_CONNECTION_STORE.bind(preview)
@@ -7257,6 +7275,7 @@ def preview_session_runtime_update(
7257
7275
  BACKEND_CODEX: "codex_thread_id",
7258
7276
  BACKEND_CURSOR: "cursor_session_id",
7259
7277
  BACKEND_OPENCODE: "opencode_session_id",
7278
+ BACKEND_GEMINI: "gemini_session_id",
7260
7279
  }[prospective_backend]
7261
7280
  preview["session_id"] = sess.get(provider_id_field)
7262
7281
  return preview
@@ -9648,35 +9667,66 @@ def normalized_emergency_alert(value: Any) -> dict[str, Any] | None:
9648
9667
  }
9649
9668
 
9650
9669
 
9651
- def reversed_jsonl_events(path: Path) -> Iterator[dict[str, Any]]:
9652
- """Yield durable JSONL records newest-first without a lossy line cap."""
9670
+ def reversed_jsonl_events(
9671
+ path: Path, *, retained_fields: frozenset[str] | None = None,
9672
+ ) -> Iterator[dict[str, Any]]:
9673
+ """Read newest-first in linear work, without accumulating a giant line.
9674
+
9675
+ Find offsets in fixed blocks. Emergency readers only retain lifecycle
9676
+ fields; unrelated text/media is validated and skipped in bounded chunks.
9677
+ Other consumers retain the full event contract.
9678
+ """
9653
9679
 
9654
9680
  if not path.exists():
9655
9681
  return
9656
9682
  chunk_size = 256 * 1024
9657
9683
  with path.open("rb") as source:
9658
9684
  source.seek(0, os.SEEK_END)
9659
- position = source.tell()
9660
- remainder = b""
9685
+ position = end = source.tell()
9686
+
9687
+ def read_event(start: int, stop: int) -> dict[str, Any] | None:
9688
+ if stop <= start:
9689
+ return None
9690
+ source.seek(start)
9691
+ if retained_fields is not None:
9692
+ for event, _, _ in jsonl_records(
9693
+ source, end=stop, project=False, require_newline=False,
9694
+ retained_fields=retained_fields, deadline=float("inf"),
9695
+ # This is a server-owned journal, not a native import.
9696
+ # A tool payload can legitimately contain many values.
9697
+ # Keep retained metadata bounded; scan omitted values in
9698
+ # linear time without the importer's value-count ceiling.
9699
+ max_nodes=None, max_depth=512,
9700
+ ):
9701
+ return event
9702
+ return None
9703
+ with suppress(ValueError, UnicodeError):
9704
+ event = json.loads(source.read(stop - start).decode("utf-8", "replace"))
9705
+ if isinstance(event, dict):
9706
+ return event
9707
+ return None
9708
+
9661
9709
  while position > 0:
9662
9710
  read_size = min(chunk_size, position)
9663
9711
  position -= read_size
9664
9712
  source.seek(position)
9665
- block = source.read(read_size) + remainder
9666
- lines = block.split(b"\n")
9667
- remainder = lines[0]
9668
- for raw_line in reversed(lines[1:]):
9669
- if not raw_line.strip():
9670
- continue
9671
- with suppress(Exception):
9672
- event = json.loads(raw_line.decode("utf-8", "replace"))
9673
- if isinstance(event, dict):
9674
- yield event
9675
- if remainder.strip():
9676
- with suppress(Exception):
9677
- event = json.loads(remainder.decode("utf-8", "replace"))
9678
- if isinstance(event, dict):
9713
+ block = source.read(read_size)
9714
+ offset = len(block)
9715
+ while (offset := block.rfind(b"\n", 0, offset)) >= 0:
9716
+ start = position + offset + 1
9717
+ event = read_event(start, end)
9718
+ if event is not None:
9679
9719
  yield event
9720
+ end = start - 1
9721
+ event = read_event(0, end)
9722
+ if event is not None:
9723
+ yield event
9724
+
9725
+
9726
+ EMERGENCY_EVENT_FIELDS = frozenset({
9727
+ "id", "seq", "type", "ts", "session_id", "run_id",
9728
+ "emergency_alert", "emergency_alert_id", "emergency_request_id", "emergency_request_digest",
9729
+ })
9680
9730
 
9681
9731
 
9682
9732
  def emergency_lifecycle_events_after(
@@ -9687,7 +9737,9 @@ def emergency_lifecycle_events_after(
9687
9737
 
9688
9738
  newest_seq = max(0, int(after_seq or 0))
9689
9739
  events: list[dict[str, Any]] = []
9690
- for event in reversed_jsonl_events(events_path(session_id)):
9740
+ for event in reversed_jsonl_events(
9741
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
9742
+ ):
9691
9743
  seq = int(event.get("seq") or 0)
9692
9744
  newest_seq = max(newest_seq, seq)
9693
9745
  if seq <= after_seq:
@@ -10178,6 +10230,9 @@ class SessionStore:
10178
10230
  if reconcile_session_emergency_alerts(session_id, sess):
10179
10231
  runtime_changed = True
10180
10232
  backend = str(sess.get("backend") or DEFAULT_BACKEND).strip().lower()
10233
+ if not sess.get("gemini_permission_mode"):
10234
+ sess["gemini_permission_mode"] = "default"
10235
+ runtime_changed = True
10181
10236
  claude_permission_mode = effective_claude_permission_mode(sess)
10182
10237
  if sess.get("claude_permission_mode") != claude_permission_mode:
10183
10238
  sess["claude_permission_mode"] = claude_permission_mode
@@ -10542,13 +10597,25 @@ class SessionStore:
10542
10597
  if initializing_fork and parent_id and req.provider_connection == "custom":
10543
10598
  parent = self.sessions.get(parent_id) or {}
10544
10599
  runtime_source.update({name: parent.get(name) for name in ("provider_connection", "provider_connection_revision")})
10545
- if req.provider_connection == "custom" and not initializing_fork and any((req.provider_session_id, req.session_id, req.claude_session_id, req.opencode_session_id)):
10600
+ if backend != BACKEND_GEMINI and req.provider_connection == "custom" and not initializing_fork and any((req.provider_session_id, req.session_id, req.claude_session_id, req.opencode_session_id)):
10546
10601
  raise HTTPException(409, "Import native conversations with their native login. Create a new chat for a custom API.")
10547
10602
  runtime = preview_session_runtime_update(runtime_source, {
10548
10603
  "codex_provider": req.codex_provider, "provider_connection": req.provider_connection, "model": req.model, "effort": req.effort,
10549
10604
  })
10550
10605
  model, effort = runtime["model"], runtime["effort"]
10551
10606
  session_cwd = req.cwd or DEFAULT_CWD
10607
+ if backend == BACKEND_GEMINI:
10608
+ if req.provider_session_id or req.session_id or req.import_history:
10609
+ from gemini_import import initial_history
10610
+ from gemini_acp_client import GeminiProtocolError
10611
+ try:
10612
+ await asyncio.to_thread(initial_history, sys.modules[__name__], runner_env(),
10613
+ req.provider_session_id or req.session_id or "", str(Path(session_cwd).expanduser().resolve()))
10614
+ except (GeminiProtocolError, OSError, ValueError):
10615
+ raise HTTPException(400, "Gemini history does not uniquely match this session ID and workspace.") from None
10616
+ if not Path(session_cwd).expanduser().is_dir():
10617
+ raise HTTPException(400, "Gemini requires an existing workspace directory.")
10618
+ session_cwd = str(Path(session_cwd).expanduser().resolve())
10552
10619
  if backend == BACKEND_OPENCODE:
10553
10620
  session_cwd = validated_opencode_cwd(
10554
10621
  session_cwd,
@@ -10585,6 +10652,7 @@ class SessionStore:
10585
10652
  claude_session_id if backend == BACKEND_CLAUDE
10586
10653
  else codex_thread_id if backend == BACKEND_CODEX
10587
10654
  else cursor_session_id if backend == BACKEND_CURSOR
10655
+ else provider_id if backend == BACKEND_GEMINI
10588
10656
  else opencode_session_id
10589
10657
  )
10590
10658
  # A Claude chat can park an inactive Codex identity for later backend
@@ -10651,6 +10719,9 @@ class SessionStore:
10651
10719
  "claude_session_id": claude_session_id,
10652
10720
  "codex_thread_id": codex_thread_id,
10653
10721
  "cursor_session_id": cursor_session_id,
10722
+ "gemini_session_id": provider_id if backend == BACKEND_GEMINI else None,
10723
+ "gemini_native_history": bool(backend == BACKEND_GEMINI and provider_id),
10724
+ "gemini_session_cwd": session_cwd if backend == BACKEND_GEMINI and provider_id else None,
10654
10725
  "opencode_session_id": opencode_session_id,
10655
10726
  "opencode_session_cwd": (
10656
10727
  session_cwd
@@ -10670,6 +10741,7 @@ class SessionStore:
10670
10741
  "opencode_permission_mode": (
10671
10742
  req.opencode_permission_mode or OPENCODE_DEFAULT_PERMISSION_MODE
10672
10743
  ),
10744
+ "gemini_permission_mode": req.gemini_permission_mode or "default",
10673
10745
  "codex_approval_policy": (
10674
10746
  req.codex_approval_policy or CODEX_DEFAULT_APPROVAL_POLICY
10675
10747
  ),
@@ -10706,6 +10778,8 @@ class SessionStore:
10706
10778
  "created_at": now,
10707
10779
  "updated_at": now,
10708
10780
  }
10781
+ if not active_provider_id and not parent_id and sess["title"] == "New chat":
10782
+ sess["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": {}}
10709
10783
  if req.subagent_limit is not None:
10710
10784
  sess["subagent_limit"] = req.subagent_limit
10711
10785
  if initializing_fork and parent_id:
@@ -10864,6 +10938,7 @@ class SessionStore:
10864
10938
  if patch.get("auto_title_enabled") is not None:
10865
10939
  sess["auto_title_enabled"] = bool(patch["auto_title_enabled"])
10866
10940
  for key, default, allowed in (
10941
+ ("gemini_permission_mode", "default", {"default", "autoEdit", "yolo", "plan"}),
10867
10942
  (
10868
10943
  "claude_permission_mode",
10869
10944
  CLAUDE_DEFAULT_PERMISSION_MODE,
@@ -10939,14 +11014,15 @@ class SessionStore:
10939
11014
  sess["subagent_limit"] = patch["subagent_limit"]
10940
11015
  if "system_prompt" in patch:
10941
11016
  sess["system_prompt"] = clean_session_system_prompt(patch["system_prompt"])
11017
+ if {"cwd", "backend", "provider_connection"}.intersection(patch):
11018
+ sess.pop("gemini_permission_modes", None)
10942
11019
  for key in ("model", "effort"):
10943
11020
  if key in patch:
10944
11021
  value = patch[key]
10945
11022
  if key == "effort":
10946
11023
  sess[key] = normalized_prospective_effort
10947
11024
  else:
10948
- clean = str(value).strip() if value is not None else ""
10949
- sess[key] = clean or None
11025
+ sess[key] = runtime_preview["model"]
10950
11026
  if reset_implicit_incompatible_effort:
10951
11027
  # Older clients update the model and effort in separate
10952
11028
  # requests. Let that model change succeed while clearing a
@@ -11066,7 +11142,8 @@ class SessionStore:
11066
11142
  }
11067
11143
  sess.update(title=title, _title_source=source, _title_auto_value=title)
11068
11144
  if source == "prompt" and isinstance(prompt_seed, str):
11069
- sess["_title_seed"] = prompt_seed[:1600]
11145
+ sess["_title_seed"] = (title_generation.gemini_title_seed(prompt_seed)
11146
+ if sess.get("backend") == BACKEND_GEMINI else prompt_seed[:1600])
11070
11147
  elif source in {"provider", "generated"}:
11071
11148
  sess.pop("_title_seed", None)
11072
11149
  sess["updated_at"] = now_iso()
@@ -11203,9 +11280,18 @@ class SessionStore:
11203
11280
  sid: str,
11204
11281
  event: dict[str, Any],
11205
11282
  ) -> dict[str, Any]:
11283
+ # Disk replay must not hold the global sessions lock: other chats and
11284
+ # health/recovery controls must remain usable during a large scan.
11285
+ snapshot = self.sessions.get(sid)
11286
+ if not snapshot:
11287
+ raise HTTPException(status_code=404, detail="session not found")
11288
+ scan_after = max(0, int(snapshot.get("_emergency_reconciled_through_seq") or 0))
11289
+ lifecycle_events, reconciled_through = await asyncio.to_thread(
11290
+ emergency_lifecycle_events_after, sid, scan_after,
11291
+ )
11206
11292
  async with self._lock:
11207
11293
  sess = self.sessions.get(sid)
11208
- if not sess:
11294
+ if not sess or sess is not snapshot:
11209
11295
  raise HTTPException(status_code=404, detail="session not found")
11210
11296
  previous_present = "_emergency_alerts" in sess
11211
11297
  previous = sess.get("_emergency_alerts")
@@ -11215,11 +11301,10 @@ class SessionStore:
11215
11301
  0,
11216
11302
  int(sess.get("_emergency_reconciled_through_seq") or 0),
11217
11303
  )
11218
- lifecycle_events, reconciled_through = await asyncio.to_thread(
11219
- emergency_lifecycle_events_after,
11220
- sid,
11221
- previous_reconciled_through,
11222
- )
11304
+ # A concurrent projection may have advanced while we read. Never
11305
+ # replay an older raise over a newer acknowledgement.
11306
+ lifecycle_events = [candidate for candidate in lifecycle_events
11307
+ if int(candidate.get("seq") or 0) > previous_reconciled_through]
11223
11308
  event_seq = int(event.get("seq") or 0)
11224
11309
  if (
11225
11310
  event_seq > previous_reconciled_through
@@ -11390,6 +11475,9 @@ class SessionStore:
11390
11475
  sess.pop("cursor_instruction_version", None)
11391
11476
  elif backend == BACKEND_OPENCODE:
11392
11477
  sess["opencode_session_id"] = provider_id
11478
+ elif backend == BACKEND_GEMINI:
11479
+ sess["gemini_session_id"] = provider_id
11480
+ sess["gemini_session_cwd"] = cwd
11393
11481
  if backend == BACKEND_CLAUDE and cwd:
11394
11482
  sess["claude_session_cwd"] = cwd
11395
11483
  if backend == BACKEND_OPENCODE and cwd:
@@ -13082,6 +13170,9 @@ class JobStore:
13082
13170
  client_capabilities=[
13083
13171
  ASYNC_ROUTE_V1_CLIENT_CAPABILITY,
13084
13172
  *([CROSS_CHAT_HANDOFFS_V2_CLIENT_CAPABILITY] if chat_references else []),
13173
+ *([GEMINI_CLIENT_CAPABILITY] if (
13174
+ job.get("backend") or (parent_session or {}).get("backend")
13175
+ ) == BACKEND_GEMINI else []),
13085
13176
  ],
13086
13177
  chat_references=chat_references,
13087
13178
  team_references=team_references,
@@ -14101,6 +14192,8 @@ RUN_NOW_TURNS: dict[str, dict[str, Any]] = {}
14101
14192
  # that the scan has not discovered yet. A completed failed task intentionally
14102
14193
  # remains installed and keeps admission fail-closed.
14103
14194
  QUEUE_RECOVERY_TASK: asyncio.Task[tuple[int, int]] | None = None
14195
+ QUEUE_RECOVERY_READY: set[str] = set()
14196
+ QUEUE_CHECKPOINT_DIRTY: set[str] = set()
14104
14197
  QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS = 2.0
14105
14198
  STEERING_SESSIONS: set[str] = set()
14106
14199
  # A non-native Force Send keeps the steering fence while the interrupted
@@ -16561,6 +16654,7 @@ HANDOFF_DIGEST_FINALIZING: set[str] = set()
16561
16654
  RUNTIME_DIAGNOSTICS: dict[str, dict[str, Any]] = {}
16562
16655
  RUNTIME_DIAGNOSTICS_LOCK = threading.RLock()
16563
16656
  RUNTIME_DIAGNOSTIC_GENERATIONS: dict[str, int] = {}
16657
+ CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
16564
16658
  ABANDONED_FORK_PROVIDER_THREADS: set[str] = set()
16565
16659
  ABANDONED_FORK_PROVIDER_THREADS_LOCK = asyncio.Lock()
16566
16660
 
@@ -17365,6 +17459,7 @@ def should_bump_session_updated_at(event_type: str, event: dict[str, Any]) -> bo
17365
17459
 
17366
17460
 
17367
17461
  async def update_session_event_metadata(session_id: str, event: dict[str, Any]) -> None:
17462
+ QUEUE_CHECKPOINT_DIRTY.add(session_id)
17368
17463
  sess = STORE.sessions.get(session_id)
17369
17464
  if not sess:
17370
17465
  return
@@ -17492,7 +17587,22 @@ async def update_session_event_metadata(session_id: str, event: dict[str, Any])
17492
17587
  await STORE.save(flush=False)
17493
17588
 
17494
17589
 
17495
- async def wait_for_queue_recovery_admission() -> None:
17590
+ def queue_recovery_readiness() -> dict[str, Any]:
17591
+ recovery = QUEUE_RECOVERY_TASK
17592
+ if recovery is None or (recovery.done() and not recovery.cancelled() and recovery.exception() is None):
17593
+ return {"ready": True, "state": "ready", "message": None}
17594
+ if not recovery.done():
17595
+ return {
17596
+ "ready": False, "state": "recovering",
17597
+ "message": "Queued messages are being restored after restart. Retry shortly; do not resend accepted messages.",
17598
+ }
17599
+ return {
17600
+ "ready": False, "state": "failed",
17601
+ "message": "Queued message recovery failed. Chat admission remains closed to preserve saved messages. Restart the managed server; if it repeats, inspect server logs.",
17602
+ }
17603
+
17604
+
17605
+ async def wait_for_queue_recovery_admission(session_id: str | None = None) -> None:
17496
17606
  """Wait briefly for the one startup queue scan, then fail retryably.
17497
17607
 
17498
17608
  The shared recovery task is shielded because disconnecting one request
@@ -17501,42 +17611,34 @@ async def wait_for_queue_recovery_admission() -> None:
17501
17611
  ordering hole with only a partial in-memory queue.
17502
17612
  """
17503
17613
 
17614
+ if session_id is not None and session_id in QUEUE_RECOVERY_READY:
17615
+ return
17504
17616
  recovery = QUEUE_RECOVERY_TASK
17505
17617
  if recovery is None or recovery is asyncio.current_task():
17506
17618
  return
17507
- try:
17508
- await asyncio.wait_for(
17509
- asyncio.shield(recovery),
17510
- timeout=max(0.0, QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS),
17511
- )
17512
- except asyncio.TimeoutError as exc:
17619
+ # asyncio.wait does not propagate the task's own TimeoutError and never
17620
+ # cancels the shared recovery when this request times out/disconnects.
17621
+ done, _ = await asyncio.wait(
17622
+ {recovery}, timeout=max(0.0, QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS),
17623
+ )
17624
+ if not done:
17513
17625
  raise TransientAdmissionWait(
17514
17626
  status_code=503,
17515
- detail=(
17516
- "queued turn recovery is still in progress; retry this "
17517
- "request shortly"
17518
- ),
17519
- ) from exc
17627
+ detail="queued turn recovery is still in progress; retry this request shortly",
17628
+ )
17629
+ try:
17630
+ recovery.result()
17520
17631
  except asyncio.CancelledError:
17521
- # Caller cancellation propagates normally. If the shared task itself
17522
- # was cancelled outside shutdown, convert that failed recovery into a
17523
- # retryable fail-closed admission result.
17524
- if recovery.cancelled() and not SERVER_SHUTTING_DOWN:
17525
- raise TransientAdmissionWait(
17526
- status_code=503,
17527
- detail=(
17528
- "queued turn recovery did not complete; restart or retry "
17529
- "the managed server"
17530
- ),
17531
- ) from None
17532
- raise
17632
+ if SERVER_SHUTTING_DOWN:
17633
+ raise
17634
+ raise TransientAdmissionWait(
17635
+ status_code=503,
17636
+ detail="queued turn recovery did not complete; restart the managed server; if it repeats, inspect server logs",
17637
+ ) from None
17533
17638
  except Exception as exc:
17534
17639
  raise TransientAdmissionWait(
17535
17640
  status_code=503,
17536
- detail=(
17537
- "queued turn recovery did not complete; restart or retry the "
17538
- "managed server"
17539
- ),
17641
+ detail="queued turn recovery did not complete; restart the managed server; if it repeats, inspect server logs",
17540
17642
  ) from exc
17541
17643
 
17542
17644
 
@@ -17549,7 +17651,7 @@ async def enqueue_turn(
17549
17651
  secure_peer_route_snapshots: list[dict[str, Any]] | None = None,
17550
17652
  reciprocal_route_grant: dict[str, Any] | None = None,
17551
17653
  ) -> dict[str, Any]:
17552
- await wait_for_queue_recovery_admission()
17654
+ await wait_for_queue_recovery_admission(session_id)
17553
17655
  if not routed_references_match_visible_prompt(
17554
17656
  req.prompt,
17555
17657
  req.display_prompt,
@@ -17821,6 +17923,7 @@ async def enqueue_turn(
17821
17923
  ),
17822
17924
  "prompt": display_prompt,
17823
17925
  "request_prompt": req.prompt,
17926
+ "client_request_id": getattr(req, "client_request_id", None),
17824
17927
  "display_prompt": req.display_prompt,
17825
17928
  "file_ids": list(req.file_ids),
17826
17929
  "position": position,
@@ -18086,7 +18189,7 @@ async def enqueue_turn(
18086
18189
 
18087
18190
 
18088
18191
  async def unqueue_turn(session_id: str, queued_id: str) -> dict[str, Any]:
18089
- await wait_for_queue_recovery_admission()
18192
+ await wait_for_queue_recovery_admission(session_id)
18090
18193
  if session_id not in STORE.sessions:
18091
18194
  raise HTTPException(status_code=404, detail="session not found")
18092
18195
 
@@ -20750,7 +20853,7 @@ def provider_tool_active_matches(
20750
20853
  expected_transport = (
20751
20854
  CODEX_TRANSPORT_APP_SERVER
20752
20855
  if backend == BACKEND_CODEX
20753
- else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE}
20856
+ else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
20754
20857
  else CLAUDE_TRANSPORT_AGENT_SDK
20755
20858
  )
20756
20859
  matches = bool(
@@ -20775,7 +20878,7 @@ def provider_tool_active_matches(
20775
20878
  and str(active.get("provider_turn_id") or "") == provider_turn_id
20776
20879
  and active.get("provider_turn_ready") is True
20777
20880
  )
20778
- elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE}:
20881
+ elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
20779
20882
  proc = active.get("proc")
20780
20883
  matches = bool(
20781
20884
  matches and cursor_owner_token
@@ -20848,7 +20951,7 @@ async def provider_tool_capability_snapshot(
20848
20951
  and codex_native_mailbox_owner_matches(session_id, run_id, provider_thread_id, provider_turn_id))
20849
20952
  ):
20850
20953
  raise ProviderToolError("provider tool turn is stale")
20851
- elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE}:
20954
+ elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
20852
20955
  raise ProviderToolError("provider tool backend is unsupported")
20853
20956
 
20854
20957
  async with ACTIVE_LOCK:
@@ -22512,7 +22615,7 @@ def public_queued_turn(
22512
22615
 
22513
22616
 
22514
22617
  async def queued_turns_snapshot(session_id: str) -> list[dict[str, Any]]:
22515
- await wait_for_queue_recovery_admission()
22618
+ await wait_for_queue_recovery_admission(session_id)
22516
22619
  async with QUEUE_LOCK:
22517
22620
  queue = list(QUEUED_TURNS.get(session_id) or [])
22518
22621
  run_now = RUN_NOW_TURNS.get(session_id)
@@ -22572,7 +22675,7 @@ def reject_promoted_queue_mutation(session_id: str, queued_id: str) -> None:
22572
22675
 
22573
22676
 
22574
22677
  async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedTurnRequest) -> dict[str, Any]:
22575
- await wait_for_queue_recovery_admission()
22678
+ await wait_for_queue_recovery_admission(session_id)
22576
22679
  if session_id not in STORE.sessions:
22577
22680
  raise HTTPException(status_code=404, detail="session not found")
22578
22681
  async_result = await update_async_queued_message(session_id, queued_id, req)
@@ -23019,7 +23122,7 @@ async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedT
23019
23122
 
23020
23123
 
23021
23124
  async def move_queued_turn(session_id: str, queued_id: str, req: MoveQueuedTurnRequest) -> dict[str, Any]:
23022
- await wait_for_queue_recovery_admission()
23125
+ await wait_for_queue_recovery_admission(session_id)
23023
23126
  if session_id not in STORE.sessions:
23024
23127
  raise HTTPException(status_code=404, detail="session not found")
23025
23128
  direction = req.direction.strip().lower()
@@ -23534,7 +23637,7 @@ async def reconcile_idle_queued_turns(*, reason: str) -> int:
23534
23637
 
23535
23638
  async def run_queued_turn_now(session_id: str, queued_id: str) -> dict[str, Any]:
23536
23639
  """Coalesce duplicate Force Send requests without replaying a steer."""
23537
- await wait_for_queue_recovery_admission()
23640
+ await wait_for_queue_recovery_admission(session_id)
23538
23641
  ensure_session_not_deleting(session_id)
23539
23642
  await reconcile_idle_queue_session(
23540
23643
  session_id,
@@ -25179,7 +25282,7 @@ def schedule_steered_turn_slot_waiter(
25179
25282
 
25180
25283
 
25181
25284
  async def start_next_queued_turn(session_id: str) -> None:
25182
- await wait_for_queue_recovery_admission()
25285
+ await wait_for_queue_recovery_admission(session_id)
25183
25286
  await reconcile_idle_queue_session(
25184
25287
  session_id,
25185
25288
  schedule=False,
@@ -25821,14 +25924,78 @@ def queued_turn_from_event(event: dict[str, Any], sess: dict[str, Any], position
25821
25924
  }
25822
25925
 
25823
25926
 
25824
- def queued_event_lines(path: Path, *, include_envelopes: bool = False) -> Iterator[bytes]:
25927
+ def queued_event_lines(path: Path, *, include_envelopes: bool = False,
25928
+ scan: dict[str, Any] | None = None) -> Iterator[bytes]:
25825
25929
  with path.open("rb") as source:
25826
- for raw_line in source:
25930
+ if scan is not None:
25931
+ source.seek(scan["offset"])
25932
+ while True:
25933
+ remaining = scan["end"] - source.tell() if scan is not None else -1
25934
+ if remaining == 0:
25935
+ break
25936
+ raw_line = source.readline(remaining)
25937
+ if not raw_line:
25938
+ break
25939
+ if scan is not None:
25940
+ if not raw_line.endswith(b"\n"):
25941
+ break
25942
+ scan["offset"] = source.tell()
25827
25943
  if (b'"queued_id"' in raw_line or b'"turn_queue_paused"' in raw_line
25828
25944
  or (include_envelopes and b'"cross_chat_envelope_id"' in raw_line)):
25829
25945
  yield raw_line
25830
25946
 
25831
25947
 
25948
+ def queue_checkpoint_anchor(path: Path, offset: int) -> str:
25949
+ with path.open("rb") as stream:
25950
+ stream.seek(max(0, offset - 4096))
25951
+ return hashlib.sha256(stream.read(min(offset, 4096))).hexdigest()
25952
+
25953
+
25954
+ def queue_checkpoint_seed(path: Path, stamp, *, enabled: bool) -> tuple[dict, list]:
25955
+ if enabled:
25956
+ try:
25957
+ checkpoint_path = path.with_name("queue-recovery.json")
25958
+ if checkpoint_path.stat().st_size > 16 * 1024 * 1024:
25959
+ raise ValueError("oversized queue checkpoint")
25960
+ value = json.loads(checkpoint_path.read_text())
25961
+ offset = value["offset"]
25962
+ rows = value["pending"]
25963
+ if (value["version"] == 1 and value["dev"] == stamp.st_dev
25964
+ and value["ino"] == stamp.st_ino and isinstance(offset, int)
25965
+ and 0 <= offset <= stamp.st_size
25966
+ and (offset != stamp.st_size or value["mtime_ns"] == stamp.st_mtime_ns)
25967
+ and value["anchor"] == queue_checkpoint_anchor(path, offset)
25968
+ and isinstance(rows, list)
25969
+ and all(isinstance(row, dict) and isinstance(row.get("queued_id"), str) for row in rows)):
25970
+ return {"offset": offset, "end": stamp.st_size}, rows
25971
+ except (OSError, ValueError, KeyError, TypeError):
25972
+ pass
25973
+ return {"offset": 0, "end": stamp.st_size}, []
25974
+
25975
+
25976
+ def save_queue_checkpoint(path: Path, stamp, offset: int, rows: list) -> None:
25977
+ # These logs are server-owned append-only journals. Replacement/truncation
25978
+ # invalidates the checkpoint; a bounded boundary anchor detects regrowth.
25979
+ current = path.stat()
25980
+ if (current.st_dev, current.st_ino) != (stamp.st_dev, stamp.st_ino) or current.st_size < offset:
25981
+ return
25982
+ if current.st_size == stamp.st_size and current.st_mtime_ns != stamp.st_mtime_ns:
25983
+ return
25984
+ value = {"version": 1, "dev": stamp.st_dev, "ino": stamp.st_ino,
25985
+ "mtime_ns": stamp.st_mtime_ns, "offset": offset,
25986
+ "anchor": queue_checkpoint_anchor(path, offset), "pending": rows}
25987
+ target = path.with_name("queue-recovery.json")
25988
+ temporary = target.with_suffix(f".{uuid.uuid4().hex}.tmp")
25989
+ try:
25990
+ with temporary.open("x") as stream:
25991
+ json.dump(value, stream, separators=(",", ":"))
25992
+ stream.flush()
25993
+ os.fsync(stream.fileno())
25994
+ temporary.replace(target)
25995
+ finally:
25996
+ temporary.unlink(missing_ok=True)
25997
+
25998
+
25832
25999
  def record_mailbox_migration_evidence(evidence: dict[str, dict[str, Any]], event: dict[str, Any]) -> None:
25833
26000
  item = evidence.get(str(event.get("cross_chat_envelope_id") or ""))
25834
26001
  if item is None:
@@ -25862,11 +26029,13 @@ def scan_queued_turns_from_events(
25862
26029
  value["complete"] = False
25863
26030
  if not path.exists():
25864
26031
  continue
25865
- proof_stamp = path.stat() if evidence else None
26032
+ scan_stamp = path.stat()
26033
+ proof_stamp = scan_stamp if evidence else None
25866
26034
  proof_complete = True
25867
- pending: dict[str, dict[str, Any]] = {}
25868
- order: list[str] = []
25869
- for raw_line in (queued_event_lines(path, include_envelopes=True) if evidence else queued_event_lines(path)):
26035
+ scan, prior_rows = queue_checkpoint_seed(path, scan_stamp, enabled=not evidence)
26036
+ pending: dict[str, dict[str, Any]] = {row["queued_id"]: row for row in prior_rows}
26037
+ order: list[str] = [row["queued_id"] for row in prior_rows]
26038
+ for raw_line in queued_event_lines(path, include_envelopes=bool(evidence), scan=scan):
25870
26039
  try:
25871
26040
  event = json.loads(raw_line.decode("utf-8", "replace"))
25872
26041
  except Exception:
@@ -26026,7 +26195,9 @@ def scan_queued_turns_from_events(
26026
26195
  unchanged = ((proof_stamp.st_dev, proof_stamp.st_ino, proof_stamp.st_size, proof_stamp.st_mtime_ns)
26027
26196
  == (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns))
26028
26197
  for value in evidence.values():
26029
- value["complete"] = proof_complete and unchanged
26198
+ # A snapshot ending in a partial record was not fully scanned.
26199
+ # It may hide a terminal/admission event, so never migrate it.
26200
+ value["complete"] = proof_complete and unchanged and scan["offset"] == scan["end"]
26030
26201
  items = [
26031
26202
  pending[qid]
26032
26203
  for qid in order
@@ -26037,6 +26208,9 @@ def scan_queued_turns_from_events(
26037
26208
  ]
26038
26209
  if items:
26039
26210
  recovered[session_id] = items
26211
+ if proof_complete:
26212
+ with suppress(OSError):
26213
+ save_queue_checkpoint(path, scan_stamp, scan["offset"], [pending[qid] for qid in order if qid in pending])
26040
26214
  return recovered
26041
26215
 
26042
26216
 
@@ -26200,13 +26374,48 @@ async def migrate_unstarted_chat_mailbox_backlog(
26200
26374
  return migrated
26201
26375
 
26202
26376
 
26377
+ async def queue_checkpoint_loop() -> None:
26378
+ while True:
26379
+ await asyncio.sleep(60)
26380
+ if QUEUE_RECOVERY_TASK is not None:
26381
+ continue
26382
+ for session_id in tuple(QUEUE_CHECKPOINT_DIRTY):
26383
+ sess = STORE.sessions.get(session_id)
26384
+ QUEUE_CHECKPOINT_DIRTY.discard(session_id)
26385
+ if not sess or session_id in DELETING_SESSIONS:
26386
+ continue
26387
+ try:
26388
+ await asyncio.to_thread(scan_queued_turns_from_events, [(session_id, dict(sess))])
26389
+ except Exception as exc:
26390
+ QUEUE_CHECKPOINT_DIRTY.add(session_id)
26391
+ logger.warning("queue checkpoint deferred session=%s error=%s", session_id, type(exc).__name__)
26392
+
26393
+
26203
26394
  async def recover_queued_turns_after_start() -> tuple[int, int]:
26395
+ QUEUE_RECOVERY_READY.clear()
26396
+ candidates = await CROSS_CHAT.pending_mailbox_migration_candidates()
26397
+ sessions = [(sid, dict(sess)) for sid, sess in STORE.sessions.items()]
26398
+ # Recover small/checkpointed chats first. A legacy giant only fences itself.
26399
+ def recovery_cost(item):
26400
+ path = events_path(item[0])
26401
+ try:
26402
+ checkpoint = json.loads(path.with_name("queue-recovery.json").read_text())
26403
+ return max(0, path.stat().st_size - int(checkpoint.get("offset", 0)))
26404
+ except (OSError, ValueError, TypeError):
26405
+ return path.stat().st_size if path.exists() else 0
26406
+ sessions = await asyncio.to_thread(lambda: sorted(sessions, key=recovery_cost))
26407
+ rebuilt = scheduled = 0
26408
+ for sid, sess in sessions:
26409
+ relevant = [row for row in candidates if row.get("target_session_id") == sid]
26410
+ count, started = await _recover_queued_turns_for_sessions([(sid, sess)], relevant)
26411
+ rebuilt += count
26412
+ scheduled += started
26413
+ QUEUE_RECOVERY_READY.add(sid)
26414
+ return rebuilt, scheduled
26415
+
26416
+
26417
+ async def _recover_queued_turns_for_sessions(session_items, migration_candidates) -> tuple[int, int]:
26204
26418
  started = time.monotonic()
26205
- session_items = [
26206
- (session_id, dict(sess))
26207
- for session_id, sess in STORE.sessions.items()
26208
- ]
26209
- migration_candidates = await CROSS_CHAT.pending_mailbox_migration_candidates()
26210
26419
  mailbox_evidence = {str(record["id"]): {"target_session_id": record["target_session_id"]}
26211
26420
  for record in migration_candidates if is_async_route_message(record)}
26212
26421
  recovered = await asyncio.to_thread(scan_queued_turns_from_events, session_items,
@@ -27595,6 +27804,40 @@ def linux_process_still_exists(
27595
27804
  return True
27596
27805
 
27597
27806
 
27807
+ def systemd_worker_preserves_descendants(service_cgroup: str) -> bool:
27808
+ """Prove the effective stop policy of this exact native worker, read-only.
27809
+
27810
+ Never infer policy from an on-disk unit: runtime drop-ins and an unloaded
27811
+ replacement can differ. Unknown/malformed ownership retains the existing
27812
+ requirement that the service cgroup contain no other processes.
27813
+ """
27814
+
27815
+ if not sys.platform.startswith("linux"):
27816
+ return False
27817
+ properties = {"LoadState", "ActiveState", "MainPID", "ControlGroup", "KillMode", "SendSIGKILL"}
27818
+ try:
27819
+ result = subprocess.run(
27820
+ ["systemctl", "--user", "show", AGENTS_SERVER_SYSTEMD_UNIT, "--no-pager",
27821
+ "--property=" + ",".join(sorted(properties))],
27822
+ stdin=subprocess.DEVNULL, capture_output=True, text=True,
27823
+ timeout=2.0, check=False,
27824
+ )
27825
+ except (OSError, subprocess.SubprocessError):
27826
+ return False
27827
+ if result.returncode != 0 or len(result.stdout) > 16_384:
27828
+ return False
27829
+ values: dict[str, str] = {}
27830
+ for line in result.stdout.splitlines():
27831
+ key, separator, value = line.partition("=")
27832
+ if not separator or key in values or key not in properties:
27833
+ return False
27834
+ values[key] = value
27835
+ return values == {
27836
+ "LoadState": "loaded", "ActiveState": "active", "MainPID": str(os.getpid()),
27837
+ "ControlGroup": service_cgroup, "KillMode": "process", "SendSIGKILL": "no",
27838
+ } and agents_server_systemd_cgroup() == service_cgroup
27839
+
27840
+
27598
27841
  def managed_update_service_cgroup_state(
27599
27842
  *,
27600
27843
  service_cgroup: str | None = None,
@@ -27666,10 +27909,11 @@ def managed_update_service_cgroup_state(
27666
27909
  })
27667
27910
  return state
27668
27911
  unknown = tuple(sorted(service_pids - {os.getpid()}))
27912
+ preserved = bool(unknown) and systemd_worker_preserves_descendants(selected_cgroup)
27669
27913
  state.update({
27670
- "safe": not unknown,
27914
+ "safe": not unknown or preserved,
27671
27915
  "unknown_descendant_count": len(unknown),
27672
- "inspection": "verified",
27916
+ "inspection": "verified-preserved" if preserved else "verified",
27673
27917
  "_unknown_descendant_pids": unknown,
27674
27918
  })
27675
27919
  return state
@@ -29981,6 +30225,14 @@ async def reconcile_provider_task_exit(
29981
30225
  prevents delayed cleanup from touching a replacement turn.
29982
30226
  """
29983
30227
 
30228
+ if backend == BACKEND_GEMINI:
30229
+ async with ACTIVE_LOCK:
30230
+ active = ACTIVE.get(session_id) or {}
30231
+ if active.get("run_id") == run_id and active.get("gemini_cleanup_failed"):
30232
+ # Keep the exact owner fenced. A dead consumer is not proof
30233
+ # that its editing tool processes stopped; Stop/Delete retry
30234
+ # the owned cleanup before releasing this slot.
30235
+ return False
29984
30236
  persisted_active = (STORE.sessions.get(session_id) or {}).get("active_run")
29985
30237
  terminal_missing = bool(
29986
30238
  isinstance(persisted_active, dict)
@@ -32112,6 +32364,9 @@ SEMANTIC_TIMELINE_EVENT_BUDGET_PER_ITEM = 4
32112
32364
  SEMANTIC_TIMELINE_ESSENTIAL_LIMIT_PER_ITEM = 32
32113
32365
  SEMANTIC_TIMELINE_ESSENTIAL_PAGE_OVERFLOW_LIMIT = 128
32114
32366
  SEMANTIC_TIMELINE_TRACE_ANCHOR_PAGE_OVERFLOW_LIMIT = 128
32367
+ # Same-ID history corrections clear previously cached replay bubbles. They
32368
+ # need their own bounded allowance, not the slots reserved for chat content.
32369
+ SEMANTIC_TIMELINE_REPAIR_PAGE_OVERFLOW_LIMIT = 256
32115
32370
  JOB_RUN_HISTORY_TEXT_LIMIT = 64 * 1024
32116
32371
  SEMANTIC_TIMELINE_ESSENTIAL_DETAIL_TYPES = {
32117
32372
  "artifact_created",
@@ -32391,14 +32646,14 @@ CODEX_GOAL_HISTORY_REPAIR_CACHE = CodexGoalHistoryRepairCache()
32391
32646
  CODEX_NATIVE_HISTORY_REPAIR_CACHE = CodexNativeHistoryRepairCache()
32392
32647
 
32393
32648
 
32394
- def prepare_provider_history_metadata_repair(session_id: str) -> None:
32395
- """Prepare bounded source proofs only at explicit history-read boundaries."""
32649
+ def prepare_provider_history_metadata_repair(session_id: str) -> bool | None:
32650
+ """Prepare bounded source proofs in background history maintenance."""
32396
32651
  prepare_claude_history_metadata_repair(session_id)
32397
32652
  prepare_codex_goal_history_repair(session_id)
32398
- prepare_codex_native_history_repair(session_id)
32653
+ return prepare_codex_native_history_repair(session_id)
32399
32654
 
32400
32655
 
32401
- def prepare_codex_native_history_repair(session_id: str) -> None:
32656
+ def prepare_codex_native_history_repair(session_id: str) -> bool | None:
32402
32657
  session = STORE.sessions.get(session_id)
32403
32658
  if not isinstance(session, dict) or session.get("backend") != BACKEND_CODEX or session.get("_deleting") is True:
32404
32659
  CODEX_NATIVE_HISTORY_REPAIR_CACHE.forget(session_id)
@@ -32426,11 +32681,13 @@ def prepare_codex_native_history_repair(session_id: str) -> None:
32426
32681
  session_id, provider_id, events_path(session_id), source, CODEX_SESSIONS_ROOT,
32427
32682
  lambda event: codex_history_event_item(event, expected_session_id=session_id),
32428
32683
  )
32429
- except CodexNativeHistoryProofUnavailable:
32684
+ except CodexNativeHistoryProofUnavailable as exc:
32685
+ if getattr(exc, "deterministic", False):
32686
+ raise
32430
32687
  # Incomplete evidence is retryable at a later explicit read boundary,
32431
32688
  # not a successful empty proof and not a reason to break chat opening.
32432
32689
  logger.debug("native history repair deferred session=%s", session_id)
32433
- return
32690
+ return False
32434
32691
  if changed:
32435
32692
  HISTORY_SEARCH_REPAIR_DIRTY.add(session_id)
32436
32693
  HISTORY_SEARCH_DIRTY.add(session_id)
@@ -32993,7 +33250,6 @@ def build_timeline_index(session_id: str) -> dict[str, Any]:
32993
33250
 
32994
33251
 
32995
33252
  def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
32996
- prepare_provider_history_metadata_repair(session_id)
32997
33253
  claude_metadata_signature = CLAUDE_METADATA_REPAIR_CACHE.signature(session_id)
32998
33254
  codex_goal_history_signature = (
32999
33255
  CODEX_GOAL_HISTORY_REPAIR_CACHE.signature(session_id),
@@ -33081,6 +33337,15 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
33081
33337
  if can_append
33082
33338
  else []
33083
33339
  )
33340
+ # Keep provisional turn lookup linear in the journal size. Scheduled-job
33341
+ # streams can have hundreds of thousands of events with no ordinary turn;
33342
+ # scanning every preceding landmark for each one makes cold opens quadratic.
33343
+ turn_record_keys_by_run: dict[str, list[str]] = {}
33344
+ for existing in records:
33345
+ existing_key = str(existing.get("key") or "")
33346
+ if existing_key.startswith("turn:"):
33347
+ existing_run = existing_key[5:].split(":start-", 1)[0]
33348
+ turn_record_keys_by_run.setdefault(existing_run, []).append(existing_key)
33084
33349
  dirty_record_keys: set[str] = set()
33085
33350
  active_turn_key: str | None = cached.get("active_turn_key") if can_append else None
33086
33351
  current_turn_by_run: dict[str, str] = (
@@ -33401,6 +33666,8 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
33401
33666
  }
33402
33667
  by_key[key] = record
33403
33668
  records.append(record)
33669
+ if key.startswith("turn:"):
33670
+ turn_record_keys_by_run.setdefault(key[5:].split(":start-", 1)[0], []).append(key)
33404
33671
  if advance_timeline:
33405
33672
  latest_timeline_landmark_key = key
33406
33673
  record["start_seq"] = min(int(record["start_seq"]), seq)
@@ -33867,16 +34134,10 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
33867
34134
  job_by_run[run_id] = job_id
33868
34135
  prior_key = current_turn_by_run.pop(run_id, None) if run_id else None
33869
34136
  if not prior_key and run_id:
33870
- base_turn_key = f"turn:{run_id}"
33871
- prior_key = next(
33872
- (
33873
- str(candidate.get("key") or "")
33874
- for candidate in reversed(records)
33875
- if str(candidate.get("key") or "") == base_turn_key
33876
- or str(candidate.get("key") or "").startswith(f"{base_turn_key}:start-")
33877
- ),
33878
- None,
33879
- )
34137
+ prior_keys = turn_record_keys_by_run.get(run_id, [])
34138
+ while prior_keys and prior_keys[-1] not in by_key:
34139
+ prior_keys.pop()
34140
+ prior_key = prior_keys[-1] if prior_keys else None
33880
34141
  prior = by_key.get(prior_key or "")
33881
34142
  group_key, group_created = assign_job_timeline_group(
33882
34143
  event,
@@ -35502,9 +35763,6 @@ def collect_semantic_timeline_events(
35502
35763
  deduplicated[identity] = event
35503
35764
  return True
35504
35765
 
35505
- for event in page_repairs:
35506
- append_with_limit(event, event_limit)
35507
-
35508
35766
  # Every selected semantic item gets one representative before any item
35509
35767
  # receives detail. The page-size clamp in read_semantic_timeline_page()
35510
35768
  # guarantees that this baseline always fits.
@@ -35519,6 +35777,22 @@ def collect_semantic_timeline_events(
35519
35777
  for event in secondary:
35520
35778
  append_with_limit(event, event_limit)
35521
35779
 
35780
+ # Reserve both sides of ordinary turns before emitting repair receipts.
35781
+ # A single imported run can contain hundreds of corrected boundaries:
35782
+ # charging them to the ordinary budget used to leave only trace anchors
35783
+ # for recent turns. Extend that budget only by receipts actually added,
35784
+ # retaining its remaining detail capacity and the absolute response cap.
35785
+ repair_allowance = min(
35786
+ SEMANTIC_TIMELINE_REPAIR_PAGE_OVERFLOW_LIMIT,
35787
+ MAX_EVENT_RESPONSE_LIMIT - event_limit,
35788
+ )
35789
+ repair_limit = len(deduplicated) + repair_allowance
35790
+ repair_count = 0
35791
+ for event in page_repairs:
35792
+ if append_with_limit(event, repair_limit):
35793
+ repair_count += 1
35794
+ event_limit += repair_count
35795
+
35522
35796
  # Files and reviewable diffs are semantic output, not optional trace
35523
35797
  # detail. Give each selected item a generous but bounded overflow and also
35524
35798
  # cap total page overflow. This preserves normal multi-video workflows
@@ -38401,7 +38675,7 @@ def cross_chat_supported_target_backends() -> list[str]:
38401
38675
  # Only cached diagnostics/private metadata are read here; health must not
38402
38676
  # launch CLIs or authenticate remotely. Each target's actual turn admission
38403
38677
  # still checks its own binding, CLI compatibility and selected login mode.
38404
- for backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
38678
+ for backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
38405
38679
  with RUNTIME_DIAGNOSTICS_LOCK:
38406
38680
  diagnostic = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
38407
38681
  ready = diagnostic.get("status") == "ready"
@@ -38527,15 +38801,21 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
38527
38801
  BACKEND_CLAUDE: CLAUDE_TRANSPORT_AGENT_SDK,
38528
38802
  BACKEND_CURSOR: "headless-stream-json",
38529
38803
  BACKEND_OPENCODE: "headless-stream-json",
38804
+ BACKEND_GEMINI: "acp",
38530
38805
  },
38531
38806
  }
38532
38807
 
38533
38808
 
38534
38809
  OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY = "opencode_cross_chat_v1"
38810
+ GEMINI_CLIENT_CAPABILITY = "gemini_acp_v1"
38535
38811
 
38536
38812
 
38537
38813
  def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]:
38538
38814
  backend = str(target.get("backend") or DEFAULT_BACKEND).strip().lower()
38815
+ if backend == BACKEND_GEMINI:
38816
+ if not cross_chat_target_backend_supported(backend):
38817
+ raise HTTPException(409, "The target Gemini CLI is unavailable.")
38818
+ return [GEMINI_CLIENT_CAPABILITY]
38539
38819
  if backend == BACKEND_CODEX:
38540
38820
  if not cross_chat_target_backend_supported(backend):
38541
38821
  raise HTTPException(
@@ -38578,6 +38858,7 @@ CROSS_CHAT_DELIVERY_CAPABILITIES_BY_BACKEND: dict[str, frozenset[str]] = {
38578
38858
  BACKEND_CLAUDE: frozenset({CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY}),
38579
38859
  BACKEND_CURSOR: frozenset(),
38580
38860
  BACKEND_OPENCODE: frozenset({OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY}),
38861
+ BACKEND_GEMINI: frozenset({GEMINI_CLIENT_CAPABILITY}),
38581
38862
  }
38582
38863
 
38583
38864
 
@@ -43311,7 +43592,9 @@ def find_emergency_request_event(
43311
43592
  request_id: str,
43312
43593
  source_run_id: str,
43313
43594
  ) -> dict[str, Any] | None:
43314
- for event in reversed_jsonl_events(events_path(session_id)):
43595
+ for event in reversed_jsonl_events(
43596
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43597
+ ):
43315
43598
  if (
43316
43599
  event.get("type") == "emergency_alert_raised"
43317
43600
  and event.get("emergency_request_id") == request_id
@@ -43326,7 +43609,9 @@ def emergency_alert_was_acknowledged(
43326
43609
  alert_id: str,
43327
43610
  raised_seq: int,
43328
43611
  ) -> bool:
43329
- for event in reversed_jsonl_events(events_path(session_id)):
43612
+ for event in reversed_jsonl_events(
43613
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43614
+ ):
43330
43615
  seq = int(event.get("seq") or 0)
43331
43616
  if seq <= raised_seq:
43332
43617
  return False
@@ -43342,7 +43627,9 @@ def find_emergency_acknowledgement_event(
43342
43627
  session_id: str,
43343
43628
  alert_id: str,
43344
43629
  ) -> dict[str, Any] | None:
43345
- for event in reversed_jsonl_events(events_path(session_id)):
43630
+ for event in reversed_jsonl_events(
43631
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43632
+ ):
43346
43633
  if (
43347
43634
  event.get("type") == "emergency_alert_acknowledged"
43348
43635
  and event.get("emergency_alert_id") == alert_id
@@ -43351,6 +43638,41 @@ def find_emergency_acknowledgement_event(
43351
43638
  return None
43352
43639
 
43353
43640
 
43641
+ def emergency_alert_batch_through(
43642
+ session_id: str,
43643
+ through_alert_id: str,
43644
+ ) -> tuple[dict[str, Any] | None, list[str]]:
43645
+ """Freeze active IDs at an exact durable raise boundary, including retries.
43646
+
43647
+ Read lifecycle facts rather than a potentially interrupted summary save.
43648
+ Reverse order lets each alert's newest fact determine whether it is active.
43649
+ A previously acknowledged boundary remains a valid, immutable cut-off.
43650
+ """
43651
+ settled: set[str] = set()
43652
+ active: list[tuple[int, str]] = []
43653
+ boundary = None
43654
+ for event in reversed_jsonl_events(
43655
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43656
+ ):
43657
+ if event.get("type") == "emergency_alert_acknowledged":
43658
+ settled.add(str(event.get("emergency_alert_id") or ""))
43659
+ elif event.get("type") == "emergency_alert_raised":
43660
+ alert = normalized_emergency_alert(event.get("emergency_alert"))
43661
+ seq = int(event.get("seq") or 0)
43662
+ if alert is None or seq <= 0:
43663
+ continue
43664
+ alert_id = alert["id"]
43665
+ if alert_id == through_alert_id:
43666
+ boundary = event
43667
+ if alert_id not in settled:
43668
+ active.append((seq, alert_id))
43669
+ settled.add(alert_id)
43670
+ if boundary is None:
43671
+ return None, []
43672
+ boundary_seq = int(boundary["seq"])
43673
+ return boundary, [alert_id for seq, alert_id in reversed(active) if seq <= boundary_seq]
43674
+
43675
+
43354
43676
  def emergency_alert_response(
43355
43677
  session_id: str,
43356
43678
  event: dict[str, Any],
@@ -44941,98 +45263,46 @@ def bounded_jsonl_paths(
44941
45263
  continue
44942
45264
 
44943
45265
 
44944
- def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
44945
- """Parse a transcript with hard byte, line-size, and line-count bounds."""
44946
45266
 
44947
- size = path.stat().st_size
44948
- if size > MAX_LOCAL_TRANSCRIPT_BYTES:
44949
- raise ValueError(
44950
- f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_BYTES}-byte import limit"
44951
- )
44952
- consumed = 0
45267
+
45268
+ def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
45269
+ """Project native records with bounded memory, not a source-byte cap."""
45270
+ initial = path.stat()
45271
+ expected = {field: int(getattr(initial, field)) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")}
44953
45272
  with path.open("rb") as stream:
44954
- for line_number in range(1, MAX_LOCAL_TRANSCRIPT_SCAN_LINES + 1):
44955
- raw_line = stream.readline(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1)
44956
- if not raw_line:
44957
- return
44958
- consumed += len(raw_line)
44959
- if consumed > MAX_LOCAL_TRANSCRIPT_BYTES:
44960
- raise ValueError("transcript changed while it was being imported")
44961
- if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
44962
- raise ValueError(f"transcript line {line_number} exceeds the import limit")
44963
- if not raw_line.strip():
44964
- continue
44965
- try:
44966
- event = json.loads(raw_line)
44967
- except (json.JSONDecodeError, UnicodeDecodeError):
44968
- if preserve_invalid:
44969
- yield None
44970
- continue
44971
- if isinstance(event, dict):
45273
+ opened = os.fstat(stream.fileno())
45274
+ if any(int(getattr(opened, field)) != expected[field] for field in expected):
45275
+ raise ValueError("transcript changed before parsing")
45276
+ for event, _end, raw in jsonl_records(stream, end=initial.st_size,
45277
+ require_newline=False, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
45278
+ max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
45279
+ if not raw.blank and (event is not None or preserve_invalid):
44972
45280
  yield event
44973
- elif preserve_invalid:
44974
- yield None
44975
- if stream.read(1):
44976
- raise ValueError(
44977
- f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
44978
- )
45281
+ finished = os.fstat(stream.fileno())
45282
+ if any(int(getattr(finished, field)) != expected[field] for field in expected):
45283
+ raise ValueError("transcript changed during parsing")
44979
45284
 
44980
45285
 
44981
45286
  def bounded_jsonl_records_range(
44982
- path: Path,
44983
- start: int,
44984
- end: int,
44985
- *,
44986
- expected_stat: dict[str, int],
45287
+ path: Path, start: int, end: int, *, expected_stat: dict[str, int],
45288
+ require_newline: bool = True, allow_partial: bool = False,
44987
45289
  ) -> Iterator[tuple[dict[str, Any] | None, int]]:
44988
45290
  """Read every record and ending offset in one immutable JSONL range."""
44989
-
44990
- if start < 0 or end < start or end > MAX_LOCAL_TRANSCRIPT_BYTES:
45291
+ if start < 0 or end < start or end > expected_stat["st_size"]:
44991
45292
  raise ValueError("transcript cursor range is invalid")
44992
- remaining = end - start
44993
- if remaining == 0:
44994
- return
44995
45293
  with path.open("rb") as stream:
44996
45294
  before = os.fstat(stream.fileno())
44997
- if any(
44998
- int(getattr(before, field)) != int(expected_stat[field])
44999
- for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
45000
- ):
45295
+ if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
45001
45296
  raise ValueError("transcript changed before cursor parsing")
45002
45297
  stream.seek(start)
45003
- for line_number in range(1, MAX_LOCAL_TRANSCRIPT_SCAN_LINES + 1):
45004
- if remaining <= 0:
45298
+ for count, (event, offset, _raw) in enumerate(jsonl_records(stream, end=end,
45299
+ require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
45300
+ max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES), 1):
45301
+ yield event, offset
45302
+ if allow_partial and count >= MAX_LOCAL_TRANSCRIPT_SCAN_LINES:
45005
45303
  break
45006
- raw_line = stream.readline(
45007
- min(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1, remaining)
45008
- )
45009
- if not raw_line:
45010
- raise ValueError("transcript was truncated during cursor parsing")
45011
- remaining -= len(raw_line)
45012
- if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
45013
- raise ValueError(
45014
- f"transcript cursor line {line_number} exceeds the import limit"
45015
- )
45016
- if not raw_line.endswith(b"\n"):
45017
- raise ValueError("transcript cursor does not end on a complete line")
45018
- event: dict[str, Any] | None = None
45019
- if raw_line.strip():
45020
- try:
45021
- loaded = json.loads(raw_line)
45022
- except (json.JSONDecodeError, UnicodeDecodeError):
45023
- loaded = None
45024
- if isinstance(loaded, dict):
45025
- event = loaded
45026
- yield event, int(stream.tell())
45027
- if remaining > 0:
45028
- raise ValueError(
45029
- f"transcript cursor range exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
45030
- )
45031
45304
  after = os.fstat(stream.fileno())
45032
- if any(
45033
- int(getattr(after, field)) != int(expected_stat[field])
45034
- for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
45035
- ):
45305
+ if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
45036
45306
  raise ValueError("transcript changed during cursor parsing")
45037
45307
 
45038
45308
 
@@ -45324,20 +45594,7 @@ def find_codex_history(provider_id: str) -> Path | None:
45324
45594
  clean_provider_id = provider_session_identifier(provider_id)
45325
45595
  if clean_provider_id is None:
45326
45596
  return None
45327
- if not CODEX_SESSIONS_ROOT.exists():
45328
- return None
45329
- selected: Path | None = None
45330
- selected_mtime = -1.0
45331
- for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
45332
- transcript_id, _cwd = codex_transcript_meta(path)
45333
- if transcript_id != clean_provider_id:
45334
- continue
45335
- with suppress(OSError):
45336
- mtime = path.stat().st_mtime
45337
- if mtime > selected_mtime:
45338
- selected = path
45339
- selected_mtime = mtime
45340
- return selected
45597
+ return codex_rollout_path(clean_provider_id)
45341
45598
 
45342
45599
 
45343
45600
  def codex_compaction_count(sess: dict[str, Any]) -> int:
@@ -45379,7 +45636,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
45379
45636
  """Locate ``rollout-*-<thread_id>.jsonl`` under CODEX_SESSIONS_ROOT.
45380
45637
 
45381
45638
  Blocking filesystem work; call from a worker thread. Hits are cached per
45382
- thread id and re-validated with one stat. Misses are cached briefly so a
45639
+ thread id and re-validated with a bounded metadata read. Misses are cached briefly so a
45383
45640
  thread whose rollout has not been flushed yet does not trigger a directory
45384
45641
  walk on every runtime snapshot.
45385
45642
  """
@@ -45389,7 +45646,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
45389
45646
  return None
45390
45647
  cached = CODEX_ROLLOUT_PATH_CACHE.get(clean_id)
45391
45648
  if cached is not None:
45392
- if cached.is_file():
45649
+ if contained_jsonl_path(cached, CODEX_SESSIONS_ROOT) is not None and codex_transcript_meta(cached)[0] == clean_id:
45393
45650
  return cached
45394
45651
  CODEX_ROLLOUT_PATH_CACHE.pop(clean_id, None)
45395
45652
  missed_at = CODEX_ROLLOUT_PATH_MISSES.get(clean_id)
@@ -45402,17 +45659,29 @@ def codex_rollout_path(thread_id: str) -> Path | None:
45402
45659
  suffix = f"-{clean_id}.jsonl"
45403
45660
  selected: Path | None = None
45404
45661
  selected_mtime = -1.0
45662
+ legacy_paths: list[Path] = []
45405
45663
  for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
45406
45664
  name = path.name
45407
45665
  if not (name.startswith("rollout-") and name.endswith(suffix)):
45666
+ legacy_paths.append(path)
45408
45667
  continue
45409
- if not codex_rollout_head_matches(path, clean_id):
45668
+ if codex_transcript_meta(path)[0] != clean_id:
45410
45669
  continue
45411
45670
  with suppress(OSError):
45412
45671
  mtime = path.stat().st_mtime
45413
45672
  if mtime > selected_mtime:
45414
45673
  selected = path
45415
45674
  selected_mtime = mtime
45675
+ if selected is None:
45676
+ # Preserve discovery of renamed/legacy transcripts without reading
45677
+ # unrelated headers on the ordinary filename lookup path.
45678
+ for path in legacy_paths:
45679
+ if codex_transcript_meta(path)[0] != clean_id:
45680
+ continue
45681
+ with suppress(OSError):
45682
+ mtime = path.stat().st_mtime
45683
+ if mtime > selected_mtime:
45684
+ selected, selected_mtime = path, mtime
45416
45685
  if selected is None:
45417
45686
  CODEX_ROLLOUT_PATH_MISSES[clean_id] = now
45418
45687
  if len(CODEX_ROLLOUT_PATH_MISSES) > 1024:
@@ -46603,6 +46872,8 @@ def session_provider_id(sess: dict[str, Any]) -> str | None:
46603
46872
  return sess.get("cursor_session_id") or sess.get("session_id")
46604
46873
  if backend == BACKEND_OPENCODE:
46605
46874
  return sess.get("opencode_session_id") or sess.get("session_id")
46875
+ if backend == BACKEND_GEMINI:
46876
+ return sess.get("gemini_session_id") or sess.get("session_id")
46606
46877
  return sess.get("session_id")
46607
46878
 
46608
46879
 
@@ -46742,6 +47013,12 @@ def read_native_session_title(sess: dict[str, Any]) -> str | None:
46742
47013
  return native_session_title(codex_session_index_thread_names().get(provider_id))
46743
47014
  if backend == BACKEND_CURSOR:
46744
47015
  return cursor_native_session_title(provider_id, sess.get("cwd"))
47016
+ if backend == BACKEND_GEMINI:
47017
+ from gemini_history import native_snapshot
47018
+ from gemini_runtime import history_environment
47019
+ env = history_environment(runner_env(), sess, PROVIDER_CONNECTION_STORE.for_session(sess), PROVIDER_CONNECTION_STORE.root)
47020
+ record = native_snapshot(env, str(sess.get("cwd") or ""), provider_id)
47021
+ return native_session_title(record.get("summary"))
46745
47022
  if backend == "opencode":
46746
47023
  # Current native storage. A schema change, missing store, or busy DB
46747
47024
  # fails closed. mode=ro must never create or migrate a provider DB.
@@ -46826,7 +47103,8 @@ def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = Fa
46826
47103
  return bool(
46827
47104
  os.environ.get("AGENTSDOCK_AUTO_TITLES", "1").strip().lower() not in {"0", "false", "off"}
46828
47105
  and not SERVER_SHUTTING_DOWN
46829
- and sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
47106
+ and (sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
47107
+ or (sess.get("backend") == BACKEND_GEMINI and sess.get("provider_connection") == "custom"))
46830
47108
  and sess.get("auto_title_enabled", True) is not False
46831
47109
  and not sess.get("archived") and not sess.get("parent_id") and not sess.get("fork_from")
46832
47110
  and sess.get("id") not in DELETING_SESSIONS
@@ -46853,6 +47131,9 @@ async def close_generated_session_titles() -> None:
46853
47131
 
46854
47132
  async def generate_session_title(session_id: str, snapshot: dict, reply: str) -> None:
46855
47133
  async with GENERATED_TITLE_SLOTS:
47134
+ if snapshot["backend"] == BACKEND_GEMINI:
47135
+ # A native summary may arrive while this optional task waits.
47136
+ await refresh_native_session_title(session_id)
46856
47137
  # Persist the claim before spending any provider usage. A restart does
46857
47138
  # not retry an ambiguous request. This is one optional attempt per chat.
46858
47139
  async with STORE._lock:
@@ -46879,6 +47160,27 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
46879
47160
  options["model"] = codex_runtime_settings(snapshot)[0]
46880
47161
  options["executable"] = CODEX_BIN
46881
47162
  options["provider_selection"] = CODEX_PROVIDER_STORE.for_session(snapshot, include_key=True)
47163
+ elif snapshot["backend"] == BACKEND_GEMINI:
47164
+ from gemini_api import default_model
47165
+ from gemini_history import native_snapshot
47166
+ connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
47167
+ model = default_model(connection)
47168
+ if model == "auto":
47169
+ from gemini_runtime import history_environment
47170
+ env = history_environment(runner_env(), snapshot, connection, PROVIDER_CONNECTION_STORE.root)
47171
+ native = await asyncio.wait_for(asyncio.to_thread(native_snapshot, env,
47172
+ str(snapshot.get("cwd") or ""), session_provider_id(snapshot)), timeout=1.0)
47173
+ model = next((row.get("model") for row in reversed(native["messages"])
47174
+ if row.get("type") == "gemini" and row.get("model") and row.get("model") != "auto"), None)
47175
+ # Recheck revocation after the awaited metadata read and before
47176
+ # sending anything. Never take credentials from the current default.
47177
+ connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
47178
+ current = STORE.sessions.get(session_id)
47179
+ if (not current or not generated_title_eligible(current, allow_attempted=True)
47180
+ or title_runtime_key(current) != title_runtime_key(snapshot)
47181
+ or current.get("title") != snapshot.get("title")):
47182
+ return
47183
+ options = {"model": model, "provider_selection": connection}
46882
47184
  else:
46883
47185
  options["executable"] = resolve_cursor_executable()
46884
47186
  if not options["executable"]:
@@ -46890,6 +47192,9 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
46890
47192
  snapshot["backend"], snapshot["_title_seed"], reply, **options,
46891
47193
  )
46892
47194
  if result:
47195
+ if snapshot["backend"] == BACKEND_GEMINI:
47196
+ await refresh_native_session_title(session_id)
47197
+ PROVIDER_CONNECTION_STORE.for_session(snapshot) # Forget also fences a pending result.
46893
47198
  changed = await STORE.adopt_auto_title(
46894
47199
  session_id, result, source="generated", backend=snapshot["backend"],
46895
47200
  provider_id=session_provider_id(snapshot), expected_title=snapshot.get("title"),
@@ -47040,7 +47345,7 @@ def codex_transcript_meta(
47040
47345
  ) -> tuple[str | None, str | None]:
47041
47346
  """Return (session_id, cwd) from a Codex transcript's session_meta record."""
47042
47347
  with suppress(OSError, ValueError):
47043
- for index, event in enumerate(bounded_jsonl_events(path)):
47348
+ for index, event in enumerate(codex_transcript_metadata_records(path)):
47044
47349
  if index >= CODEX_TRANSCRIPT_SCAN_LINES:
47045
47350
  break
47046
47351
  if event.get("type") != "session_meta":
@@ -47056,6 +47361,23 @@ def codex_transcript_meta(
47056
47361
  return None, None
47057
47362
 
47058
47363
 
47364
+ def codex_transcript_metadata_records(path: Path) -> Iterator[dict[str, Any]]:
47365
+ """Discovery reads a bounded header, independent of total rollout size."""
47366
+ remaining = 1024 * 1024
47367
+ with path.open("rb") as stream:
47368
+ for _ in range(CODEX_TRANSCRIPT_SCAN_LINES):
47369
+ line = stream.readline(min(remaining, MAX_LOCAL_TRANSCRIPT_LINE_BYTES) + 1)
47370
+ if not line or len(line) > remaining or not line.endswith(b"\n"):
47371
+ return
47372
+ remaining -= len(line)
47373
+ try:
47374
+ record = json.loads(line)
47375
+ except (ValueError, UnicodeDecodeError):
47376
+ continue
47377
+ if isinstance(record, dict):
47378
+ yield record
47379
+
47380
+
47059
47381
  def codex_transcript_preview(path: Path) -> str | None:
47060
47382
  """Return a short snippet of the first user message, for a local-session label."""
47061
47383
  with suppress(OSError, ValueError):
@@ -47288,6 +47610,7 @@ def local_session_candidates(
47288
47610
  known_provider_keys: set[tuple[str, str]] | None = None,
47289
47611
  *,
47290
47612
  include_cursor: bool = False,
47613
+ include_gemini: bool = False,
47291
47614
  ) -> list[dict[str, Any]]:
47292
47615
  """Enumerate sessions not already used by this or another local instance."""
47293
47616
 
@@ -47316,6 +47639,11 @@ def local_session_candidates(
47316
47639
  candidates.extend(local_cursor_session_candidates({
47317
47640
  provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_CURSOR
47318
47641
  }))
47642
+ if include_gemini:
47643
+ from gemini_import import candidates as gemini_candidates
47644
+ candidates.extend(gemini_candidates(sys.modules[__name__], runner_env(), {
47645
+ provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_GEMINI
47646
+ }))
47319
47647
  candidates.sort(key=lambda c: c["updated_at"], reverse=True)
47320
47648
  return candidates[:max(1, min(limit, MAX_LOCAL_SESSION_LIST_ITEMS))]
47321
47649
 
@@ -47363,6 +47691,8 @@ def standalone_provider_session(sess: dict[str, Any]) -> dict[str, Any]:
47363
47691
  isolated["codex_thread_id"] = None
47364
47692
  isolated["cursor_session_id"] = None
47365
47693
  isolated["opencode_session_id"] = None
47694
+ isolated["gemini_session_id"] = None
47695
+ isolated.pop("gemini_session_cwd", None)
47366
47696
  isolated["backend_locked"] = False
47367
47697
  isolated.pop("cursor_instruction_hash", None)
47368
47698
  isolated.pop("cursor_instruction_version", None)
@@ -47402,6 +47732,12 @@ def provider_history_path(sess: dict[str, Any]) -> Path | None:
47402
47732
  if backend == BACKEND_CLAUDE:
47403
47733
  return find_claude_history(provider_id)
47404
47734
  if backend == BACKEND_CODEX:
47735
+ cursor = normalized_history_sync_cursor(sess)
47736
+ if cursor and cursor.get("provider_session_id") == provider_id:
47737
+ path = contained_jsonl_path(Path(cursor["source_path"]), CODEX_SESSIONS_ROOT)
47738
+ if path is not None and codex_transcript_meta(path)[0] == provider_id:
47739
+ CODEX_ROLLOUT_PATH_CACHE[provider_id] = path
47740
+ return path
47405
47741
  return find_codex_history(provider_id)
47406
47742
  return None
47407
47743
 
@@ -47494,7 +47830,6 @@ def normalized_history_sync_cursor(
47494
47830
  or int(raw.get(field, 0)) < 0
47495
47831
  for field in optional_integer_fields
47496
47832
  )
47497
- or int(raw["source_size"]) > MAX_LOCAL_TRANSCRIPT_BYTES
47498
47833
  or int(raw["source_offset"]) > int(raw["source_size"])
47499
47834
  or (
47500
47835
  timeline_pending_active
@@ -47525,6 +47860,8 @@ def normalized_history_sync_cursor(
47525
47860
  raw.get("timeline_pending_through_seq") or 0
47526
47861
  ),
47527
47862
  "timeline_pending_active": timeline_pending_active,
47863
+ **({"bootstrap_timeline_seq": raw["bootstrap_timeline_seq"]}
47864
+ if type(raw.get("bootstrap_timeline_seq")) is int and raw["bootstrap_timeline_seq"] >= 0 else {}),
47528
47865
  "checkpoint_seq": int(raw.get("checkpoint_seq", raw["timeline_seq"])),
47529
47866
  }
47530
47867
  # Distinguish a pre-feature cursor from an initialized conservative empty
@@ -47675,10 +48012,6 @@ def provider_history_source_snapshot(
47675
48012
  """Hash a stable transcript through its last complete JSONL record."""
47676
48013
 
47677
48014
  initial = path.stat()
47678
- if initial.st_size > MAX_LOCAL_TRANSCRIPT_BYTES:
47679
- raise ValueError(
47680
- f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_BYTES}-byte import limit"
47681
- )
47682
48015
  # Provider CLIs may rotate a JSONL file with atomic replacement or move it
47683
48016
  # between dated directories. Backend/provider identity is validated before
47684
48017
  # this helper; byte-for-byte prefix continuity is authoritative across a
@@ -47693,7 +48026,8 @@ def provider_history_source_snapshot(
47693
48026
  prefix_digest = hashlib.sha256() if verify_offset is not None else None
47694
48027
  prefix_hashed = 0
47695
48028
  complete_size = 0
47696
- pending = b""
48029
+ running_digest = hashlib.sha256()
48030
+ deadline = time.monotonic() + SCAN_SECONDS
47697
48031
  with path.open("rb") as stream:
47698
48032
  opened = os.fstat(stream.fileno())
47699
48033
  expected_identity = (
@@ -47709,7 +48043,12 @@ def provider_history_source_snapshot(
47709
48043
  int(opened.st_mtime_ns),
47710
48044
  ) != expected_identity:
47711
48045
  raise ValueError("transcript changed before cursor snapshot")
47712
- remaining = int(initial.st_size)
48046
+ # Verify the existing prefix, then prepare at most one bounded suffix.
48047
+ # Total file size is not an import limit. Prefix verification remains
48048
+ # necessary: provider transcripts can be replaced or rewritten.
48049
+ snapshot_limit = min(int(initial.st_size), (verify_offset or 0) + HISTORY_SYNC_BATCH_BYTES)
48050
+ remaining = snapshot_limit
48051
+ consumed = 0
47713
48052
  while remaining > 0:
47714
48053
  chunk = stream.read(min(1024 * 1024, remaining))
47715
48054
  if not chunk:
@@ -47719,15 +48058,20 @@ def provider_history_source_snapshot(
47719
48058
  take = min(len(chunk), int(verify_offset) - prefix_hashed)
47720
48059
  prefix_digest.update(chunk[:take])
47721
48060
  prefix_hashed += take
47722
- pending += chunk
47723
- newline = pending.rfind(b"\n")
48061
+ scan_check(deadline, budget_seconds=SCAN_SECONDS)
48062
+ newline = chunk.rfind(b"\n")
47724
48063
  if newline >= 0:
47725
- complete = pending[:newline + 1]
47726
- complete_digest.update(complete)
47727
- complete_size += len(complete)
47728
- pending = pending[newline + 1:]
47729
- if len(pending) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
47730
- raise ValueError("incomplete transcript line exceeds the import limit")
48064
+ running_digest.update(chunk[:newline + 1])
48065
+ complete_digest = running_digest.copy()
48066
+ complete_size = consumed + newline + 1
48067
+ running_digest.update(chunk[newline + 1:])
48068
+ else:
48069
+ running_digest.update(chunk)
48070
+ consumed += len(chunk)
48071
+ # A single streamed record may exceed a batch. Extend only until
48072
+ # at least one new complete record exists, under the scan deadline.
48073
+ if remaining == 0 and complete_size <= (verify_offset or 0) and consumed < int(initial.st_size):
48074
+ remaining = min(1024 * 1024, int(initial.st_size) - consumed)
47731
48075
  finished = os.fstat(stream.fileno())
47732
48076
  if (
47733
48077
  int(finished.st_dev),
@@ -47880,6 +48224,7 @@ def parse_provider_history_delta(
47880
48224
  start,
47881
48225
  end,
47882
48226
  expected_stat=expected_stat,
48227
+ allow_partial=True,
47883
48228
  ):
47884
48229
  if blocked_on_unseen_message:
47885
48230
  # Exhaust the fixed range so its final fstat validation still
@@ -48010,51 +48355,27 @@ def load_provider_history_with_cursor(
48010
48355
  if "claude_interruption_context" in previous
48011
48356
  else seed_claude_interruption_context(path, start, expected_stat=snapshot["expected_stat"], provider_session_id=provider_id)
48012
48357
  )
48013
- if continued and previous:
48014
- (
48015
- items,
48016
- cursor_offset,
48017
- last_item_digest,
48018
- delta_overflow,
48019
- ) = parse_provider_history_delta(
48020
- path,
48021
- backend,
48022
- start,
48023
- end,
48024
- limit=limit,
48025
- expected_stat=snapshot["expected_stat"],
48026
- previous_last_item_digest=str(
48027
- previous.get("last_item_digest") or ""
48028
- ),
48029
- expected_session_id=str(sess.get("id") or "") or None,
48030
- interruption_context=interruption_context,
48031
- codex_phase_context=codex_phase_context,
48032
- source_text_context=source_text_context,
48033
- )
48034
- caught_up = not delta_overflow and cursor_offset == end
48035
- else:
48036
- events = bounded_jsonl_events_range(
48037
- path,
48038
- 0,
48039
- end,
48040
- expected_stat=snapshot["expected_stat"],
48041
- preserve_invalid=backend == BACKEND_CLAUDE,
48042
- )
48043
- if backend == BACKEND_CLAUDE:
48044
- items = parse_claude_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None, interruption_context=interruption_context)
48045
- elif backend == BACKEND_CODEX:
48046
- items = parse_codex_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None)
48047
- else:
48048
- return None, [], None, False
48049
- cursor_offset = end
48050
- last_item_digest = (
48051
- history_item_cursor_digest(items[-1]) if items else ""
48052
- )
48053
- if items and items[-1].get("source_text_sha256"):
48054
- source_text_context["sha256"] = items[-1]["source_text_sha256"]
48055
- if backend == BACKEND_CODEX and items and items[-1].get("phase") in ("commentary", "final_answer"):
48056
- codex_phase_context["phase"] = items[-1]["phase"]
48057
- caught_up = True
48358
+ (
48359
+ items,
48360
+ cursor_offset,
48361
+ last_item_digest,
48362
+ delta_overflow,
48363
+ ) = parse_provider_history_delta(
48364
+ path,
48365
+ backend,
48366
+ start,
48367
+ end,
48368
+ limit=limit,
48369
+ expected_stat=snapshot["expected_stat"],
48370
+ previous_last_item_digest=str(
48371
+ (previous or {}).get("last_item_digest") or ""
48372
+ ),
48373
+ expected_session_id=str(sess.get("id") or "") or None,
48374
+ interruption_context=interruption_context,
48375
+ codex_phase_context=codex_phase_context,
48376
+ source_text_context=source_text_context,
48377
+ )
48378
+ caught_up = not delta_overflow and cursor_offset == end and end == int(snapshot["source_size"])
48058
48379
  source_digest = (
48059
48380
  str(snapshot["source_digest"])
48060
48381
  if cursor_offset == end
@@ -48417,6 +48738,7 @@ def reconcile_cursor_history_items(
48417
48738
  *,
48418
48739
  timeline_after_seq: int,
48419
48740
  timeline_through_seq: int,
48741
+ bootstrap: bool = False,
48420
48742
  ) -> tuple[list[dict[str, str]], int]:
48421
48743
  """Subtract timeline-owned occurrences from one exact provider delta.
48422
48744
 
@@ -48440,14 +48762,15 @@ def reconcile_cursor_history_items(
48440
48762
  timeline_after_seq=after_seq,
48441
48763
  timeline_through_seq=through_seq,
48442
48764
  tail=False,
48443
- include_imported=False,
48765
+ include_imported=bootstrap,
48444
48766
  message_details=message_details,
48445
48767
  )
48446
48768
 
48447
48769
  fresh: list[dict[str, str]] = []
48448
48770
  timeline_index = 0
48449
48771
  consumed_seq = after_seq
48450
- for item in items:
48772
+ last_match_index = -1
48773
+ for item_index, item in enumerate(items):
48451
48774
  item_details = history_message_match_details(item.get("kind", ""), item.get("text", ""), item, provider_item=True)
48452
48775
  if (
48453
48776
  timeline_index < len(timeline_messages)
@@ -48457,6 +48780,7 @@ def reconcile_cursor_history_items(
48457
48780
  ):
48458
48781
  consumed_seq = timeline_messages[timeline_index][0]
48459
48782
  timeline_index += 1
48783
+ last_match_index = item_index
48460
48784
  else:
48461
48785
  fresh.append(item)
48462
48786
 
@@ -48471,6 +48795,11 @@ def reconcile_cursor_history_items(
48471
48795
  if timeline_window_truncated and timeline_messages
48472
48796
  else through_seq
48473
48797
  )
48798
+ if bootstrap:
48799
+ # Initial backfill uses the same conservative tail semantics as a
48800
+ # single full import. Old imported rows are credits too; keep only
48801
+ # source items after the entire captured preexisting timeline.
48802
+ fresh = items[last_match_index + 1:] if consumed_seq == through_seq and not timeline_window_truncated else []
48474
48803
  return fresh, consumed_seq
48475
48804
 
48476
48805
 
@@ -48592,10 +48921,48 @@ def unsynced_history_items(
48592
48921
  return items[last_matched + 1:]
48593
48922
 
48594
48923
 
48924
+ class HistoryPreparationStale(RuntimeError):
48925
+ """The read-only plan lost its ownership or immutable input generation."""
48926
+
48927
+
48928
+ def history_file_stamp(path: Path) -> tuple[int, int, int, int] | None:
48929
+ try:
48930
+ value = path.stat()
48931
+ return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns
48932
+ except FileNotFoundError:
48933
+ return None
48934
+
48935
+
48936
+ @asynccontextmanager
48937
+ async def history_commit_guard(sess, cursor, timeline_stamp, source_stamp, enabled):
48938
+ if not enabled:
48939
+ yield
48940
+ return
48941
+ session_id = str(sess["id"])
48942
+ async with session_lifecycle_lock(session_id):
48943
+ current = STORE.sessions.get(session_id)
48944
+ if (not current or session_id in DELETING_SESSIONS
48945
+ or session_id in DELETED_SESSION_TOMBSTONES
48946
+ or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
48947
+ or session_id in SERVER_MAINTENANCE_SESSIONS
48948
+ or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))
48949
+ or current.get("backend") != sess.get("backend")
48950
+ or session_provider_id(current) != session_provider_id(sess)
48951
+ or normalized_history_sync_cursor(current) != cursor
48952
+ or history_file_stamp(events_path(session_id)) != timeline_stamp):
48953
+ raise HistoryPreparationStale("chat changed during history preparation")
48954
+ if source_stamp is not None:
48955
+ path, size, mtime, dev, ino = source_stamp
48956
+ if history_file_stamp(Path(path)) != (dev, ino, size, mtime):
48957
+ raise HistoryPreparationStale("provider transcript changed during history preparation")
48958
+ yield
48959
+
48960
+
48595
48961
  async def sync_provider_history(
48596
48962
  sess: dict[str, Any],
48597
48963
  *,
48598
48964
  limit: int | None = None,
48965
+ background: bool = False,
48599
48966
  ) -> dict[str, Any]:
48600
48967
  """Append provider messages added outside this chat since the last sync.
48601
48968
 
@@ -48616,6 +48983,7 @@ async def sync_provider_history(
48616
48983
  }
48617
48984
  live_session = STORE.sessions.get(session_id) or {}
48618
48985
  previous_cursor = normalized_history_sync_cursor(live_session)
48986
+ timeline_stamp = history_file_stamp(events_path(session_id))
48619
48987
  # A durable cursor makes the unchanged fast path survive process restart;
48620
48988
  # all five stat/identity fields must still describe the fully-consumed
48621
48989
  # source before parsing can be skipped.
@@ -48638,6 +49006,10 @@ async def sync_provider_history(
48638
49006
  "source_path": None,
48639
49007
  "message": "No provider transcript found.",
48640
49008
  }
49009
+ if (previous_cursor is not None and next_cursor is not None
49010
+ and next_cursor["source_offset"] == previous_cursor["source_offset"]
49011
+ and not next_cursor.get("source_caught_up", True)):
49012
+ return {"imported": 0, "deferred": True, "reason": "incomplete_source_record"}
48641
49013
  if next_cursor is not None and previous_cursor is not None:
48642
49014
  recovered = await asyncio.to_thread(
48643
49015
  committed_history_sync_checkpoint,
@@ -48646,11 +49018,11 @@ async def sync_provider_history(
48646
49018
  )
48647
49019
  if recovered is not None:
48648
49020
  recovered_cursor, _terminal_seq = recovered
48649
- await persist_history_sync_cursor(
48650
- sess,
48651
- recovered_cursor,
48652
- timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
48653
- )
49021
+ async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
49022
+ await persist_history_sync_cursor(
49023
+ sess, recovered_cursor,
49024
+ timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
49025
+ )
48654
49026
  return {
48655
49027
  "imported": 0,
48656
49028
  "source_path": str(source_path),
@@ -48660,14 +49032,18 @@ async def sync_provider_history(
48660
49032
  last_event_seq_from_file,
48661
49033
  events_path(session_id),
48662
49034
  )
48663
- if continued and previous_cursor is not None:
48664
- timeline_scan_through_seq = timeline_latest_seq
49035
+ bootstrap_boundary = (previous_cursor or {}).get("bootstrap_timeline_seq") if continued else None
49036
+ if not continued and next_cursor is not None and not next_cursor.get("source_caught_up", True):
49037
+ bootstrap_boundary = timeline_latest_seq
49038
+ if (continued and previous_cursor is not None) or (next_cursor is not None and not next_cursor.get("source_caught_up", True)):
49039
+ timeline_scan_through_seq = timeline_latest_seq if bootstrap_boundary is None else bootstrap_boundary
48665
49040
  fresh, consumed_timeline_seq = await asyncio.to_thread(
48666
49041
  reconcile_cursor_history_items,
48667
49042
  session_id,
48668
49043
  items,
48669
- timeline_after_seq=int(previous_cursor.get("timeline_seq") or 0),
49044
+ timeline_after_seq=int((previous_cursor or {}).get("timeline_seq") or 0) if continued else 0,
48670
49045
  timeline_through_seq=timeline_scan_through_seq,
49046
+ **({"bootstrap": True} if bootstrap_boundary is not None else {}),
48671
49047
  )
48672
49048
  else:
48673
49049
  # First sync or invalidated/rotated source: retain the conservative
@@ -48688,11 +49064,11 @@ async def sync_provider_history(
48688
49064
  )
48689
49065
  if recovered is not None:
48690
49066
  recovered_cursor, _terminal_seq = recovered
48691
- await persist_history_sync_cursor(
48692
- sess,
48693
- recovered_cursor,
48694
- timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
48695
- )
49067
+ async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
49068
+ await persist_history_sync_cursor(
49069
+ sess, recovered_cursor,
49070
+ timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
49071
+ )
48696
49072
  return {
48697
49073
  "imported": 0,
48698
49074
  "source_path": str(source_path),
@@ -48703,6 +49079,8 @@ async def sync_provider_history(
48703
49079
  timeline_seq = timeline_latest_seq
48704
49080
  if next_cursor is not None:
48705
49081
  caught_up = bool(next_cursor.pop("source_caught_up", True))
49082
+ if bootstrap_boundary is not None and not caught_up:
49083
+ next_cursor["bootstrap_timeline_seq"] = bootstrap_boundary
48706
49084
  next_cursor["timeline_pending_through_seq"] = 0
48707
49085
  next_cursor["timeline_pending_active"] = False
48708
49086
  if not caught_up:
@@ -48718,50 +49096,59 @@ async def sync_provider_history(
48718
49096
  caught_up=caught_up,
48719
49097
  )
48720
49098
 
48721
- result: dict[str, Any]
48722
- if not fresh:
48723
- result = {
48724
- "imported": 0,
48725
- "source_path": str(source_path),
48726
- "message": "Already up to date with the provider transcript.",
48727
- }
48728
- else:
48729
- # The imported batch is fsynced atomically together with its exact next
48730
- # cursor. A cursor moves only after that authoritative commit; if the
48731
- # registry save then fails, retry recovers the embedded checkpoint
48732
- # without relying on ambiguous content alignment.
49099
+ if background and fresh:
48733
49100
  try:
48734
- result = await append_imported_history(
48735
- sess,
48736
- source_path,
48737
- fresh,
48738
- sync_checkpoint=checkpoint,
48739
- )
49101
+ fresh = await prepare_imported_history_items(sess, source_path, fresh, checkpoint)
48740
49102
  except CodexNativeHistoryProofUnavailable:
48741
- # Do not persist next_cursor: these source bytes have not yet been
48742
- # proven against native history. A later explicit sync can retry.
48743
- return {
48744
- "imported": 0, "source_path": str(source_path), "deferred": True,
48745
- "reason": "native_history_proof_unavailable",
48746
- "message": "History reconciliation deferred; no messages were imported.",
49103
+ return {"imported": 0, "deferred": True, "reason": "native_history_proof_unavailable"}
49104
+ async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
49105
+ result: dict[str, Any]
49106
+ if not fresh:
49107
+ result = {
49108
+ "imported": 0,
49109
+ "source_path": str(source_path),
49110
+ "message": "Already up to date with the provider transcript.",
48747
49111
  }
48748
- if next_cursor is not None:
48749
- if caught_up:
48750
- timeline_seq = max(
49112
+ else:
49113
+ # The imported batch is fsynced atomically together with its exact next
49114
+ # cursor. A cursor moves only after that authoritative commit; if the
49115
+ # registry save then fails, retry recovers the embedded checkpoint
49116
+ # without relying on ambiguous content alignment.
49117
+ try:
49118
+ result = await append_imported_history(
49119
+ sess,
49120
+ source_path,
49121
+ fresh,
49122
+ sync_checkpoint=checkpoint,
49123
+ _prepared=background,
49124
+ _expected_timeline_stamp=timeline_stamp,
49125
+ )
49126
+ except CodexNativeHistoryProofUnavailable:
49127
+ # Do not persist next_cursor: these source bytes have not yet been
49128
+ # proven against native history. A later explicit sync can retry.
49129
+ return {
49130
+ "imported": 0, "source_path": str(source_path), "deferred": True,
49131
+ "reason": "native_history_proof_unavailable",
49132
+ "message": "History reconciliation deferred; no messages were imported.",
49133
+ }
49134
+ if next_cursor is not None:
49135
+ if caught_up:
49136
+ timeline_seq = max(
49137
+ timeline_latest_seq,
49138
+ int(result.get("timeline_seq") or 0),
49139
+ )
49140
+ next_cursor["checkpoint_seq"] = max(
49141
+ int(next_cursor.get("checkpoint_seq") or 0),
48751
49142
  timeline_latest_seq,
48752
49143
  int(result.get("timeline_seq") or 0),
48753
49144
  )
48754
- next_cursor["checkpoint_seq"] = max(
48755
- int(next_cursor.get("checkpoint_seq") or 0),
48756
- timeline_latest_seq,
48757
- int(result.get("timeline_seq") or 0),
48758
- )
48759
- await persist_history_sync_cursor(
48760
- sess,
48761
- next_cursor,
48762
- timeline_seq=timeline_seq,
48763
- )
48764
- return result
49145
+ await persist_history_sync_cursor(
49146
+ sess,
49147
+ next_cursor,
49148
+ timeline_seq=timeline_seq,
49149
+ )
49150
+ result["pending"] = not caught_up
49151
+ return result
48765
49152
 
48766
49153
 
48767
49154
  def provider_history_source_stamp(sess: dict[str, Any]) -> list[Any] | None:
@@ -48843,73 +49230,115 @@ async def persist_history_sync_cursor(
48843
49230
  return True
48844
49231
 
48845
49232
 
48846
- async def run_provider_history_sync(session_id: str) -> None:
48847
- """Sync one chat in the background; never surface failure to the opener."""
49233
+ HISTORY_MAINTENANCE_SLOTS = asyncio.Semaphore(2)
49234
+ HISTORY_MAINTENANCE_STATE: dict[str, dict[str, Any]] = {}
49235
+ HISTORY_MAINTENANCE_TASKS: dict[str, asyncio.Task] = {}
49236
+ HISTORY_PROJECTION_REVISIONS: dict[str, int] = {}
49237
+
49238
+
49239
+ def history_projection_notice(session_id: str) -> dict[str, Any]:
49240
+ return {"type": "history_projection_changed", "session_id": session_id,
49241
+ "revision": f"{SERVER_INSTANCE_ID}:{HISTORY_PROJECTION_REVISIONS.get(session_id, 0)}"}
49242
+
49243
+
49244
+ def history_maintenance_key(sess: dict[str, Any]) -> list[Any]:
49245
+ provider_id = session_provider_id(sess)
49246
+ cursor = normalized_history_sync_cursor(sess)
49247
+ source = (Path(cursor["source_path"]) if cursor else CODEX_ROLLOUT_PATH_CACHE.get(provider_id))
49248
+ return [2, sess.get("backend"), provider_id,
49249
+ list(history_file_stamp(events_path(str(sess["id"]))) or ()),
49250
+ str(source) if source else None,
49251
+ list(history_file_stamp(source) or ()) if source else None]
49252
+
49253
+
49254
+ def history_maintenance_state(session_id: str) -> dict[str, Any]:
49255
+ if session_id not in HISTORY_MAINTENANCE_STATE:
49256
+ try:
49257
+ raw = json.loads((events_path(session_id).parent / "history-maintenance.json").read_text())
49258
+ HISTORY_MAINTENANCE_STATE[session_id] = raw if isinstance(raw, dict) else {}
49259
+ except (OSError, ValueError):
49260
+ HISTORY_MAINTENANCE_STATE[session_id] = {}
49261
+ return HISTORY_MAINTENANCE_STATE[session_id]
48848
49262
 
49263
+
49264
+ def save_history_maintenance_state(session_id: str, value: dict[str, Any]) -> None:
49265
+ path = events_path(session_id).parent / "history-maintenance.json"
49266
+ if not path.parent.is_dir():
49267
+ return
49268
+ temporary = path.with_suffix(".tmp")
49269
+ temporary.write_text(json.dumps(value, separators=(",", ":")))
49270
+ temporary.replace(path)
49271
+
49272
+
49273
+ async def run_provider_history_sync(session_id: str) -> None:
49274
+ """Prepare in the background; hold admission only for validated commit."""
49275
+ state = history_maintenance_state(session_id)
49276
+ key = None
49277
+ pending = False
48849
49278
  try:
48850
- # Turn admission, deletion, backend changes, and provider-native
48851
- # controls use this same lifecycle lock. Revalidate after acquiring it
48852
- # so a sync scheduled while idle cannot import a transcript that a new
48853
- # live turn is concurrently extending.
48854
- async with session_lifecycle_lock(session_id):
49279
+ async with HISTORY_MAINTENANCE_SLOTS:
48855
49280
  sess = STORE.sessions.get(session_id)
48856
- if (
48857
- not sess
48858
- or session_id in DELETING_SESSIONS
48859
- or session_id in DELETED_SESSION_TOMBSTONES
48860
- ):
49281
+ if (not sess or session_id in DELETING_SESSIONS
49282
+ or session_id in DELETED_SESSION_TOMBSTONES
49283
+ or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
49284
+ or session_id in SERVER_MAINTENANCE_SESSIONS):
48861
49285
  return
48862
- async with ACTIVE_LOCK:
48863
- if (
48864
- session_id in BUSY_SESSIONS
48865
- or ACTIVE.get(session_id) is not None
48866
- or session_id in SERVER_MAINTENANCE_SESSIONS
48867
- or any(
48868
- task is not asyncio.current_task() and not task.done()
48869
- for task in tuple(SESSION_TURN_TASKS.get(session_id) or ())
48870
- )
48871
- ):
48872
- return
49286
+ key = history_maintenance_key(sess)
49287
+ state.update(status="running", reason=None)
48873
49288
  await refresh_native_session_title(session_id)
48874
- result = await sync_provider_history(dict(sess))
48875
- if result.get("imported"):
48876
- logger.info(
48877
- "synced %d provider message(s) added outside session=%s",
48878
- result["imported"],
48879
- session_id,
48880
- )
49289
+ repair_caches = (CLAUDE_METADATA_REPAIR_CACHE, CODEX_GOAL_HISTORY_REPAIR_CACHE,
49290
+ CODEX_NATIVE_HISTORY_REPAIR_CACHE)
49291
+ before_repair = tuple(cache.signature(session_id) for cache in repair_caches)
49292
+ result = await sync_provider_history(dict(sess), background=True)
49293
+ pending = bool(result.get("pending")) and not result.get("deferred")
49294
+ # Forward imports have already proved their own batch. Repair of
49295
+ # older displayed rows must not starve the remaining source suffix.
49296
+ repaired = None if pending else await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
49297
+ if before_repair != tuple(cache.signature(session_id) for cache in repair_caches):
49298
+ HISTORY_PROJECTION_REVISIONS[session_id] = HISTORY_PROJECTION_REVISIONS.get(session_id, 0) + 1
49299
+ await HUB.broadcast(session_id, history_projection_notice(session_id))
49300
+ deferred = result.get("deferred") or repaired is False
49301
+ state.update(status="deferred" if deferred else "pending" if pending else "idle", deterministic=False,
49302
+ reason=result.get("reason") or ("native_history_proof_unavailable" if repaired is False else None))
49303
+ if result.get("imported"):
49304
+ logger.info("synced %d provider message(s) added outside session=%s", result["imported"], session_id)
48881
49305
  except asyncio.CancelledError:
49306
+ state.update(status="deferred", reason="cancelled")
48882
49307
  raise
48883
49308
  except Exception as exc:
48884
- # A read-only catch-up must never break opening a chat.
48885
- logger.warning(
48886
- "provider history sync failed session=%s error=%s",
48887
- session_id,
48888
- type(exc).__name__,
48889
- )
49309
+ state.update(status="deferred", reason=type(exc).__name__, deterministic=isinstance(exc, ValueError))
49310
+ logger.warning("provider history sync deferred session=%s error=%s", session_id, type(exc).__name__)
48890
49311
  finally:
49312
+ if key is not None:
49313
+ # A changing input retries on its next open; unchanged failures
49314
+ # survive reconnects and process restarts without a scan storm.
49315
+ current = STORE.sessions.get(session_id)
49316
+ state.update(key=history_maintenance_key(current) if current else key,
49317
+ retry_at=time.time() + (60 if state.get("status") == "deferred" else 1 if pending else 15))
49318
+ with suppress(OSError):
49319
+ await asyncio.to_thread(save_history_maintenance_state, session_id, dict(state))
48891
49320
  HISTORY_SYNC_SCHEDULED.discard(session_id)
49321
+ HISTORY_MAINTENANCE_TASKS.pop(session_id, None)
49322
+ if pending and state.get("status") == "pending" and not SERVER_SHUTTING_DOWN:
49323
+ def continue_history():
49324
+ current = STORE.sessions.get(session_id)
49325
+ if current is not None:
49326
+ schedule_provider_history_sync(current)
49327
+ asyncio.get_running_loop().call_later(1.1, continue_history)
48892
49328
 
48893
49329
 
48894
49330
  def schedule_provider_history_sync(sess: dict[str, Any]) -> None:
48895
- """Catch one chat's transcript up when opened, without blocking the open.
48896
-
48897
- Opening a chat must stay fast, and the client is already subscribed to
48898
- the event stream, so recovered messages arrive live instead of delaying
48899
- the response.
48900
- """
48901
-
48902
49331
  session_id = str(sess.get("id") or "")
48903
- if not session_id or session_id in HISTORY_SYNC_SCHEDULED:
49332
+ if not session_id or session_id in HISTORY_SYNC_SCHEDULED or not session_provider_id(sess):
48904
49333
  return
48905
- if not session_provider_id(sess):
48906
- return
48907
- # A turn in flight is already streaming the provider's own output and its
48908
- # transcript writes are still landing.
48909
49334
  if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
48910
49335
  return
49336
+ state = history_maintenance_state(session_id)
49337
+ if state.get("key") == history_maintenance_key(sess) and (
49338
+ state.get("deterministic") or time.time() < state.get("retry_at", 0)):
49339
+ return
48911
49340
  HISTORY_SYNC_SCHEDULED.add(session_id)
48912
- asyncio.create_task(run_provider_history_sync(session_id))
49341
+ HISTORY_MAINTENANCE_TASKS[session_id] = asyncio.create_task(run_provider_history_sync(session_id))
48913
49342
 
48914
49343
 
48915
49344
  async def filter_codex_history_for_import(
@@ -48937,13 +49366,10 @@ async def filter_codex_history_for_import(
48937
49366
  raise
48938
49367
 
48939
49368
 
48940
- async def append_imported_history(
48941
- sess: dict[str, Any],
48942
- source_path: Path,
48943
- items: list[dict[str, Any]],
48944
- *,
48945
- sync_checkpoint: dict[str, Any] | None = None,
48946
- ) -> dict[str, Any]:
49369
+ async def prepare_imported_history_items(
49370
+ sess: dict[str, Any], source_path: Path, items: list[dict[str, Any]],
49371
+ sync_checkpoint: dict[str, Any] | None,
49372
+ ) -> list[dict[str, Any]]:
48947
49373
  session_id = str(sess["id"])
48948
49374
  provider_id = str(session_provider_id(sess) or "")
48949
49375
  backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
@@ -48970,6 +49396,23 @@ async def append_imported_history(
48970
49396
  normalize_user=normalize_wake_user, normalize_full_user=normalize_full_wake_user,
48971
49397
  normalize_assistant=clean_assistant_text,
48972
49398
  )
49399
+ return items
49400
+
49401
+
49402
+ async def append_imported_history(
49403
+ sess: dict[str, Any],
49404
+ source_path: Path,
49405
+ items: list[dict[str, Any]],
49406
+ *,
49407
+ sync_checkpoint: dict[str, Any] | None = None,
49408
+ _prepared: bool = False,
49409
+ _expected_timeline_stamp: tuple | None = None,
49410
+ ) -> dict[str, Any]:
49411
+ session_id = str(sess["id"])
49412
+ provider_id = str(session_provider_id(sess) or "")
49413
+ backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
49414
+ if not _prepared:
49415
+ items = await prepare_imported_history_items(sess, source_path, items, sync_checkpoint)
48973
49416
  metadata_only = bool(items) and all(item.get("kind") == "interruption" or (
48974
49417
  item.get("provider_history_repair") in {"source_proven_import", "source_proven_native_replay", "source_proven_assistant_replay"}
48975
49418
  and item.get("text") == ""
@@ -49059,7 +49502,13 @@ async def append_imported_history(
49059
49502
  imported_events.append(imported_history_terminal_event(
49060
49503
  run_id, backend, metadata_only=metadata_only, source_timestamp=last_source_timestamp,
49061
49504
  ))
49062
- committed = await append_durable_event_batch(session_id, imported_events)
49505
+ if _prepared:
49506
+ async with event_delivery_lock(session_id):
49507
+ if history_file_stamp(events_path(session_id)) != _expected_timeline_stamp:
49508
+ raise HistoryPreparationStale("timeline changed before history publication")
49509
+ committed = await append_durable_event_batch_locked(session_id, imported_events)
49510
+ else:
49511
+ committed = await append_durable_event_batch(session_id, imported_events)
49063
49512
  if len(committed) != len(imported_events):
49064
49513
  raise RuntimeError("history event batch was not fully persisted")
49065
49514
  if backend == BACKEND_CODEX:
@@ -49197,6 +49646,12 @@ async def import_session_history(sess: dict[str, Any], *, force: bool = False, l
49197
49646
  backend = (sess.get("backend") or DEFAULT_BACKEND).lower()
49198
49647
  if not provider_id:
49199
49648
  return {"imported": 0, "source_path": None, "message": "No provider session ID set."}
49649
+ if backend == BACKEND_GEMINI:
49650
+ if await asyncio.to_thread(lambda: any(event.get("type") == "history_imported" for event in iter_session_events(session_id))):
49651
+ return {"imported": 0, "source_path": None, "message": "Gemini initial text history already imported; automatic reconciliation is not supported."}
49652
+ from gemini_import import initial_history
49653
+ source, items = await asyncio.to_thread(initial_history, sys.modules[__name__], runner_env(), provider_id, str(sess.get("cwd") or ""), limit)
49654
+ return await append_imported_history(sess, source, items)
49200
49655
  if backend == BACKEND_CURSOR:
49201
49656
  # Initial public-text snapshot only. The private native store, not these
49202
49657
  # display messages, supplies resume context. Cursor exports are rewritable
@@ -50234,9 +50689,10 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
50234
50689
  detail_fields = () if summary else (
50235
50690
  "auto_title_enabled",
50236
50691
  "system_prompt", "session_id", "claude_session_id", "codex_thread_id",
50237
- "cursor_session_id", "opencode_session_id",
50692
+ "cursor_session_id", "opencode_session_id", "gemini_session_id",
50238
50693
  "claude_permission_mode", "cursor_permission_mode",
50239
50694
  "opencode_permission_mode",
50695
+ "gemini_permission_mode", "gemini_permission_modes",
50240
50696
  "codex_approval_policy", "codex_sandbox_mode",
50241
50697
  "codex_permission_profile", "codex_approvals_reviewer",
50242
50698
  "provider_jobs_access",
@@ -50257,6 +50713,11 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
50257
50713
  "last_read_agent_event_seq", "last_read_agent_event_at", "manual_unread",
50258
50714
  )
50259
50715
  }
50716
+ maintenance = HISTORY_MAINTENANCE_STATE.get(str(sess.get("id") or ""), {})
50717
+ # Never-started maintenance adds no useful information to high-volume lists.
50718
+ # Once observed, retain idle/cleared fields so caches can retire old errors.
50719
+ if not summary or maintenance:
50720
+ public["history_maintenance"] = {key: maintenance.get(key) for key in ("status", "reason", "retry_at")}
50260
50721
  # Omit only never-configured defaults from high-volume lists. A stored null
50261
50722
  # is a clear tombstone and must survive summary/cache merges.
50262
50723
  if not summary or "subagent_limit" in sess:
@@ -50280,7 +50741,14 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
50280
50741
  # Provider ids are intentionally omitted from summary responses, but the
50281
50742
  # UI still needs the authoritative first-turn backend fence.
50282
50743
  public["backend_locked"] = session_backend_locked(sess)
50744
+ # Capability, not proof of emptiness: dismiss-empty repeats the full proof.
50745
+ if sess.get("title") == "New chat" and not public["backend_locked"]:
50746
+ public["empty_chat_cleanup"] = "client-v1"
50283
50747
  public["codex_provider"] = codex_provider.session_choice(sess.get("codex_provider"))
50748
+ if summary and "codex_goal" in sess:
50749
+ goal = sess.get("codex_goal")
50750
+ public["codex_goal_status"] = goal.get("status") if isinstance(goal, dict) else None
50751
+ public["codex_goal_updated_at"] = goal.get("updatedAt") if isinstance(goal, dict) else None
50284
50752
  connection = sess.get("provider_connection") or "default"
50285
50753
  # Keep legacy native summaries sparse, but retain a stored default as the
50286
50754
  # tombstone that clears a previously selected custom API in client caches.
@@ -54177,26 +54645,29 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
54177
54645
  schedule_codex_manager_drain()
54178
54646
 
54179
54647
 
54180
- async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> None:
54648
+ async def refresh_codex_app_server_login(*, request_handoff: bool = False, source: str = "manager_lookup") -> bool:
54181
54649
  """Change normal-Codex admission; never kill a credential-owning process.
54182
54650
 
54183
- Catalog refresh only observes the native login revision. Only an explicit
54184
- provider reload requests handoff for opaque credential stores. Neither
54185
- operation logs in or rotates native credentials.
54651
+ Ordinary lookups/catalog refreshes only observe native file revisions.
54652
+ Explicit native-admin login reload also covers opaque credential stores.
54653
+ This is not a token refresh or login operation.
54186
54654
  """
54187
54655
  global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
54188
54656
  if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
54189
- return
54657
+ return False
54658
+ # Sources are server-owned labels, never request text, account IDs or keys.
54659
+ if source not in {"manager_lookup", "turn_admission", "account_status", "catalog_refresh", "manual_login_reload"}:
54660
+ source = "manager_lookup"
54190
54661
  async with CODEX_LOGIN_PROBE_LOCK:
54191
54662
  if not await asyncio.to_thread(codex_auth.native_login_handoff_supported,
54192
54663
  codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD)):
54193
- return
54664
+ return False
54194
54665
  revision = await asyncio.to_thread(codex_auth.native_login_revision,
54195
54666
  codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD))
54196
54667
  async with CODEX_GOALS_CONFIG_LOCK:
54197
54668
  async with CODEX_APP_SERVER_MANAGER_LOCK:
54198
54669
  if CODEX_APP_SERVER_MANAGER_CLEANUP_EPOCH is not None or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
54199
- return
54670
+ return False
54200
54671
  observed_change = revision is not None and revision != CODEX_LOGIN_REVISION
54201
54672
  CODEX_LOGIN_REVISION = revision
54202
54673
  if observed_change or request_handoff:
@@ -54212,12 +54683,17 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
54212
54683
  if revision is not None and revision == previous:
54213
54684
  manager._agentsdock_login_superseded = False
54214
54685
  if changed or (request_handoff and revision is None):
54686
+ if getattr(manager, "_agentsdock_login_superseded", False) is not True:
54687
+ reason = "login_revision_changed" if changed else "explicit_reload_opaque_login"
54688
+ manager._agentsdock_login_handoff_source = source
54689
+ logger.info("Codex login handoff requested source=%s reason=%s manager=%x", source, reason, id(manager))
54215
54690
  manager._agentsdock_login_superseded = True
54216
54691
  if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
54217
54692
  CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
54218
54693
  if CODEX_APP_SERVER_MANAGER is manager:
54219
54694
  CODEX_APP_SERVER_MANAGER = None
54220
54695
  schedule_codex_manager_drain()
54696
+ return True
54221
54697
 
54222
54698
 
54223
54699
  def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
@@ -54320,6 +54796,17 @@ def watch_codex_provider_handoff_blockers(
54320
54796
 
54321
54797
  async def release_idle_codex_manager_session(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> bool:
54322
54798
  """Called under the session lifecycle lock; retain ownership on any doubt."""
54799
+ def retained(reason: str) -> bool:
54800
+ if getattr(manager, "_agentsdock_login_superseded", False) is True:
54801
+ reasons = getattr(manager, "_agentsdock_login_handoff_blockers", None)
54802
+ if reasons is None:
54803
+ reasons = manager._agentsdock_login_handoff_blockers = {}
54804
+ if reasons.get(session_id) != reason:
54805
+ reasons[session_id] = reason
54806
+ logger.info("Codex login handoff waiting source=%s manager=%x session=%s reason=%s",
54807
+ getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id, reason)
54808
+ return False
54809
+
54323
54810
  def blocked(*, own_maintenance=False):
54324
54811
  return (codex_manager_session_busy(manager, session_id, ignore_task=ignore_task,
54325
54812
  ignore_maintenance=own_maintenance)
@@ -54360,7 +54847,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54360
54847
  sum(not task.done() for task in manager.client._server_request_tasks.values()),
54361
54848
  error_type,
54362
54849
  )
54363
- return False
54850
+ return retained(reasons[session_id])
54364
54851
 
54365
54852
  if blocked():
54366
54853
  return await defer("runtime_borrowers")
@@ -54440,6 +54927,10 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54440
54927
  if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
54441
54928
  CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
54442
54929
  CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
54930
+ if getattr(manager, "_agentsdock_login_superseded", False) is True:
54931
+ getattr(manager, "_agentsdock_login_handoff_blockers", {}).pop(session_id, None)
54932
+ logger.info("Codex login handoff released source=%s manager=%x session=%s",
54933
+ getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id)
54443
54934
  return True
54444
54935
  except Exception as exc:
54445
54936
  logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
@@ -54459,7 +54950,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
54459
54950
  session.pop("_codex_provider_pending", None)
54460
54951
  if codex_provider.session_choice(session.get("codex_provider")) == "custom":
54461
54952
  return
54462
- await refresh_codex_app_server_login()
54953
+ await refresh_codex_app_server_login(source="turn_admission")
54463
54954
  session_id = str(session.get("id") or "")
54464
54955
  manager = existing_codex_app_server_manager(session)
54465
54956
  if getattr(manager, "_agentsdock_login_superseded", False) is not True:
@@ -54618,9 +55109,8 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
54618
55109
  selected = CODEX_PROVIDER_STORE.for_session(sess, include_key=True) if sess is not None else None
54619
55110
  revision = selected["credential_id"] if selected else None
54620
55111
  session_id = str((sess or {}).get("id") or "")
54621
- # Goal enablement is a process launch flag. Manager lookup therefore takes
54622
- # the same barrier as configuration replacement, including the fast path;
54623
- # no caller can retain/create the old generation halfway through a toggle.
55112
+ # Manager lookup shares the provider reconfiguration barrier, including the
55113
+ # fast path, so a sign-in reload cannot race a retained/created generation.
54624
55114
  async with CODEX_GOALS_CONFIG_LOCK:
54625
55115
  ensure_provider_manager_factory_admission(codex=True)
54626
55116
  manager = existing_codex_app_server_manager(sess) if sess is not None else None
@@ -57216,6 +57706,9 @@ def resolve_cursor_executable(*, require_compatible: bool = True) -> str | None:
57216
57706
 
57217
57707
 
57218
57708
  def runtime_executable(backend: str) -> str:
57709
+ if backend == BACKEND_GEMINI:
57710
+ from gemini_runtime import resolve_executable
57711
+ return resolve_executable(runner_env()) or "gemini"
57219
57712
  if backend == BACKEND_CLAUDE:
57220
57713
  return CLAUDE_BIN
57221
57714
  if backend == BACKEND_OPENCODE:
@@ -57234,6 +57727,8 @@ def runtime_executable(backend: str) -> str:
57234
57727
 
57235
57728
 
57236
57729
  def runtime_display_name(backend: str) -> str:
57730
+ if backend == BACKEND_GEMINI:
57731
+ return "Gemini CLI"
57237
57732
  if backend == BACKEND_CLAUDE:
57238
57733
  return "Claude Code"
57239
57734
  if backend == BACKEND_CURSOR:
@@ -57463,6 +57958,9 @@ def cursor_auth_failure_confirmed(executable: str) -> bool:
57463
57958
 
57464
57959
 
57465
57960
  def probe_runtime(backend: str) -> dict[str, Any]:
57961
+ if backend == BACKEND_GEMINI:
57962
+ from gemini_runtime import probe
57963
+ return probe(runner_env(), timeout=runtime_probe_timeout(8.0))
57466
57964
  if backend == BACKEND_OPENCODE:
57467
57965
  (
57468
57966
  resolved,
@@ -57686,7 +58184,14 @@ def probe_runtime(backend: str) -> dict[str, Any]:
57686
58184
 
57687
58185
  combined = f"{auth_result.stdout}\n{auth_result.stderr}"
57688
58186
  if auth_result.returncode == 0:
57689
- return runtime_diagnostic_payload(backend, "ready", installed=True, authenticated=True, version=version)
58187
+ result = runtime_diagnostic_payload(backend, "ready", installed=True, authenticated=True, version=version)
58188
+ if backend == BACKEND_CODEX:
58189
+ # Retain only the kind, never login-status output (which may show a
58190
+ # masked API key). No additional auth/refresh subprocess is needed.
58191
+ text = combined.lower()
58192
+ result["_codex_auth_mode"] = "apiKey" if "api key" in text else "chatgpt" if "chatgpt" in text else "unknown"
58193
+ result["_codex_auth_mode_checked_at_epoch"] = result["checked_at_epoch"]
58194
+ return result
57690
58195
  if auth_failure_text(combined):
57691
58196
  return runtime_diagnostic_payload(backend, "unauthenticated", installed=True, authenticated=False, version=version)
57692
58197
  return runtime_diagnostic_payload(backend, "error", installed=True, authenticated=None, version=version)
@@ -57709,10 +58214,14 @@ def store_runtime_diagnostic(diagnostic: dict[str, Any], *, preserve_last_error:
57709
58214
 
57710
58215
  def expire_codex_login_diagnostic() -> None:
57711
58216
  """A login hint requires new native evidence, never reuse a cached denial."""
58217
+ global CODEX_MODEL_MIGRATION_TARGET
57712
58218
  with RUNTIME_DIAGNOSTICS_LOCK:
58219
+ CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
57713
58220
  current = dict(RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or runtime_diagnostic_payload(
57714
58221
  BACKEND_CODEX, "unknown", installed=None, authenticated=None))
57715
58222
  current["checked_at_epoch"] = 0.0
58223
+ current.pop("_codex_auth_mode", None)
58224
+ current.pop("_codex_auth_mode_checked_at_epoch", None)
57716
58225
  store_runtime_diagnostic(current)
57717
58226
 
57718
58227
 
@@ -57721,6 +58230,8 @@ def runtime_diagnostic(backend: str, *, force: bool = False) -> dict[str, Any]:
57721
58230
  cached = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
57722
58231
  generation = RUNTIME_DIAGNOSTIC_GENERATIONS.get(backend, 0)
57723
58232
  checked_at = cached.get("_installation_checked_at_epoch", cached.get("checked_at_epoch"))
58233
+ if backend == BACKEND_CODEX and cached.get("_codex_auth_mode_checked_at_epoch") is not None:
58234
+ checked_at = min(checked_at or 0, cached["_codex_auth_mode_checked_at_epoch"])
57724
58235
  if not force and isinstance(checked_at, (int, float)) and time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS:
57725
58236
  return cached
57726
58237
  try:
@@ -57865,14 +58376,34 @@ def record_runtime_success(backend: str) -> None:
57865
58376
  version=previous.get("version"),
57866
58377
  executable=(
57867
58378
  str(previous.get("_executable") or "") or None
57868
- if backend in (BACKEND_CURSOR, BACKEND_OPENCODE)
58379
+ if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI)
57869
58380
  else None
57870
58381
  ),
57871
58382
  )
58383
+ if backend == BACKEND_CODEX and previous.get("_codex_auth_mode"):
58384
+ # A successful turn does not establish which login kind was used.
58385
+ # Preserve the existing observation without extending its lifetime.
58386
+ current["_codex_auth_mode"] = previous["_codex_auth_mode"]
58387
+ current["_codex_auth_mode_checked_at_epoch"] = previous.get(
58388
+ "_codex_auth_mode_checked_at_epoch", previous.get("checked_at_epoch", 0),
58389
+ )
57872
58390
  store_runtime_diagnostic(current, preserve_last_error=False)
57873
58391
 
57874
58392
 
57875
58393
  async def ensure_runtime_available(backend: str, *, session: dict[str, Any] | None = None) -> dict[str, Any]:
58394
+ if backend == BACKEND_GEMINI:
58395
+ from gemini_runtime import probe, API_ONLY_MESSAGE
58396
+ if (session or {}).get("provider_connection") != "custom":
58397
+ raise HTTPException(409, API_ONLY_MESSAGE)
58398
+ diagnostic = await asyncio.to_thread(probe, runner_env())
58399
+ store_runtime_diagnostic(diagnostic)
58400
+ if diagnostic.get("status") != "ready":
58401
+ raise HTTPException(503, diagnostic.get("message"))
58402
+ if (session or {}).get("provider_connection") == "custom":
58403
+ from gemini_api import default_model
58404
+ selected = PROVIDER_CONNECTION_STORE.for_session(session)
58405
+ provider_connections.require_model({**selected, "model": default_model(selected)})
58406
+ return diagnostic
57876
58407
  diagnostic = await asyncio.to_thread(runtime_diagnostic, backend)
57877
58408
  if (session or {}).get("provider_connection") == "custom":
57878
58409
  selected = PROVIDER_CONNECTION_STORE.for_session(session)
@@ -58037,6 +58568,26 @@ def codex_default_service_tier(model: str) -> str:
58037
58568
  return CODEX_FALLBACK_SERVICE_TIERS.get(str(model or "").strip(), "")
58038
58569
 
58039
58570
 
58571
+ def codex_retired_model_replacements() -> dict[str, str]:
58572
+ """Scope retirement to native ChatGPT auth, never API/custom endpoints."""
58573
+ with RUNTIME_DIAGNOSTICS_LOCK:
58574
+ diagnostic = RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or {}
58575
+ checked_at = diagnostic.get("_codex_auth_mode_checked_at_epoch", diagnostic.get("checked_at_epoch")) or 0
58576
+ observed = (str(diagnostic.get("_codex_auth_mode") or "")
58577
+ if time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS else "")
58578
+ replacement = CODEX_MODEL_MIGRATION_TARGET
58579
+ mode = codex_auth.native_model_auth_mode(
58580
+ codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD), observed_mode=observed,
58581
+ )
58582
+ if mode != "chatgpt":
58583
+ return {}
58584
+ return {model: replacement for model in sorted(codex_auth.CHATGPT_RETIRED_MODELS)}
58585
+
58586
+
58587
+ def codex_available_model(model: str) -> str:
58588
+ return codex_retired_model_replacements().get(model, model)
58589
+
58590
+
58040
58591
  # The Codex app-server's thread/start and turn-override JSON-RPC params only
58041
58592
  # accept a narrower serviceTier enum ("fast"/"flex") than the canonical
58042
58593
  # service_tier values used for `codex exec` CLI config overrides (see
@@ -58053,6 +58604,7 @@ def codex_app_server_service_tier(service_tier: str) -> str:
58053
58604
 
58054
58605
 
58055
58606
  def discover_codex_catalog() -> dict[str, Any]:
58607
+ global CODEX_MODEL_MIGRATION_TARGET
58056
58608
  models: list[dict[str, Any]] = []
58057
58609
  model_options: list[dict[str, Any]] = []
58058
58610
  effort_options: list[dict[str, Any]] = []
@@ -58075,16 +58627,29 @@ def discover_codex_catalog() -> dict[str, Any]:
58075
58627
  model_source = f"{model_source} failed"
58076
58628
  effort_source = f"{effort_source} failed"
58077
58629
 
58630
+ replacements = codex_retired_model_replacements()
58078
58631
  visible_models = [
58079
58632
  model for model in models
58080
58633
  if str(model.get("visibility") or "list") == "list" and model.get("supported_in_api", True) is not False
58634
+ and str(model.get("slug") or model.get("id") or "").strip() not in replacements
58081
58635
  ]
58082
58636
  visible_models.sort(key=runtime_priority)
58637
+ if replacements and visible_models:
58638
+ # Use an available configured default, then the native default/priority.
58639
+ # The fallback is only needed when the CLI cannot return a catalog.
58640
+ slugs = [str(entry.get("slug") or entry.get("id") or "").strip() for entry in visible_models]
58641
+ preferred = next((slug for slug in (configured_model, CODEX_DEFAULT_MODEL) if slug in slugs), None)
58642
+ if preferred is None:
58643
+ preferred = next((str(entry.get("slug") or entry.get("id")) for entry in visible_models if entry.get("is_default") is True or entry.get("isDefault") is True), slugs[0])
58644
+ if preferred:
58645
+ with RUNTIME_DIAGNOSTICS_LOCK:
58646
+ CODEX_MODEL_MIGRATION_TARGET = preferred
58647
+ replacements = {model: preferred for model in replacements}
58083
58648
  # The selectable "Server default" must describe the exact settings that
58084
58649
  # codex_runtime_settings() launches for a chat with no explicit override.
58085
58650
  # The CLI's discovered list can omit a configured/custom default (or a
58086
58651
  # legacy fallback), so the first discovered model is not a safe proxy.
58087
- default_model = configured_model or CODEX_DEFAULT_MODEL
58652
+ default_model = replacements.get(configured_model or CODEX_DEFAULT_MODEL, configured_model or CODEX_DEFAULT_MODEL)
58088
58653
  default_effort = clamp_codex_runtime_effort(
58089
58654
  default_model,
58090
58655
  configured_effort or CODEX_DEFAULT_EFFORT,
@@ -58131,6 +58696,8 @@ def discover_codex_catalog() -> dict[str, Any]:
58131
58696
 
58132
58697
  if not model_options:
58133
58698
  for slug, label in CODEX_FALLBACK_MODELS:
58699
+ if slug in replacements:
58700
+ continue
58134
58701
  fallback_efforts = [
58135
58702
  runtime_option(effort, title_effort_label(effort))
58136
58703
  for effort in CODEX_FALLBACK_MODEL_EFFORTS.get(slug, CODEX_FALLBACK_EFFORTS)
@@ -58140,7 +58707,7 @@ def discover_codex_catalog() -> dict[str, Any]:
58140
58707
  model_options.append(runtime_option(slug, label, efforts=fallback_efforts, service_tier=service_tier or None))
58141
58708
  if not effort_options:
58142
58709
  effort_options.extend(runtime_option(effort, title_effort_label(effort)) for effort in CODEX_FALLBACK_EFFORTS)
58143
- if configured_model and not any(option.get("value") == configured_model for option in model_options):
58710
+ if configured_model and configured_model not in replacements and not any(option.get("value") == configured_model for option in model_options):
58144
58711
  model_options.insert(0, runtime_option(configured_model, title_model_label(configured_model)))
58145
58712
  if configured_effort and not any(option.get("value") == configured_effort for option in effort_options):
58146
58713
  effort_options.append(runtime_option(configured_effort, title_effort_label(configured_effort)))
@@ -58163,6 +58730,7 @@ def discover_codex_catalog() -> dict[str, Any]:
58163
58730
  "default_model": default_model or None,
58164
58731
  "default_effort": default_effort or None,
58165
58732
  "default_service_tier": default_service_tier or None,
58733
+ "model_replacements": replacements,
58166
58734
  }
58167
58735
 
58168
58736
 
@@ -58658,6 +59226,13 @@ def discover_runtime_backend_catalog(backend: str, *, force_runtime_probe: bool
58658
59226
  diagnostic = runtime_diagnostic(backend, force=force_runtime_probe)
58659
59227
  ready = diagnostic.get("status") == "ready"
58660
59228
  executable = str(diagnostic.get("_executable") or "") or None
59229
+ if backend == BACKEND_GEMINI:
59230
+ return {"models": [], "default_model": None,
59231
+ "efforts": [], "default_effort": None, "effort_source": "none",
59232
+ "model_source": "Configure a Gemini API connection in My Agents",
59233
+ "available": False, "diagnostic": public_runtime_diagnostic(diagnostic),
59234
+ "native_credentials_present": False,
59235
+ "custom_provider": PROVIDER_CONNECTION_STORE.catalog(backend, installed=ready)}
58661
59236
  if backend == BACKEND_CLAUDE:
58662
59237
  catalog = parse_claude_help_catalog()
58663
59238
  elif backend == BACKEND_CODEX:
@@ -58750,7 +59325,7 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
58750
59325
  catalog = CODEX_PROVIDER_STORE.cached_catalog(selected)
58751
59326
  return model, codex_provider.runtime_effort(selected, catalog, sess.get("effort")), ""
58752
59327
  configured_model, configured_effort, configured_service_tier = codex_user_config_defaults()
58753
- model = str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
59328
+ model = codex_available_model(str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip())
58754
59329
  effort = clamp_codex_runtime_effort(
58755
59330
  model,
58756
59331
  sess.get("effort") or configured_effort or CODEX_DEFAULT_EFFORT,
@@ -58759,6 +59334,39 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
58759
59334
  return model, effort, service_tier
58760
59335
 
58761
59336
 
59337
+ async def migrate_retired_codex_selection(
59338
+ session_id: str, sess: dict[str, Any], *, standalone: bool = False,
59339
+ previous_model: str | None = None,
59340
+ ) -> dict[str, Any]:
59341
+ """Migrate future work under the existing admission lock; retain history.
59342
+
59343
+ No native transcript edits, forks, auth changes, or retries. Native
59344
+ compaction/recovery remains Codex-owned and can still fail independently.
59345
+ """
59346
+ if sess.get("backend") != BACKEND_CODEX or sess.get("codex_provider") == "custom":
59347
+ return sess
59348
+ configured_model, _effort, _tier = codex_user_config_defaults()
59349
+ previous = str(previous_model or sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
59350
+ replacement = codex_available_model(previous)
59351
+ if previous == replacement:
59352
+ return sess
59353
+ patch = {"model": replacement, "effort": clamp_codex_runtime_effort(replacement, sess.get("effort") or _effort or CODEX_DEFAULT_EFFORT)}
59354
+ if standalone:
59355
+ migrated = {**sess, **patch}
59356
+ elif all(sess.get(key) == value for key, value in patch.items()):
59357
+ migrated = sess
59358
+ else:
59359
+ migrated = await STORE.update(session_id, patch)
59360
+ await append_event(session_id, "runtime_model_migrated", {
59361
+ "backend": BACKEND_CODEX, "previous_model": previous, "model": replacement,
59362
+ "standalone": standalone,
59363
+ "message": (f"{previous} is no longer available with ChatGPT sign-in. "
59364
+ f"This {'scheduled run' if standalone else 'chat'} will use {replacement}. "
59365
+ "Conversation history is unchanged."),
59366
+ })
59367
+ return migrated
59368
+
59369
+
58762
59370
  def codex_goal_time_budget_remaining(sess: dict[str, Any]) -> float | None:
58763
59371
  goal = sess.get("codex_goal")
58764
59372
  limit = sess.get("codex_goal_time_budget_seconds")
@@ -60026,7 +60634,7 @@ async def discover_session_provider_commands(
60026
60634
 
60027
60635
  backend = str(session.get("backend") or DEFAULT_BACKEND).strip().lower()
60028
60636
  requested_cwd = str(session.get("cwd") or DEFAULT_CWD)
60029
- if backend == BACKEND_OPENCODE:
60637
+ if backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
60030
60638
  try:
60031
60639
  cwd = validated_opencode_cwd(requested_cwd)
60032
60640
  except HTTPException:
@@ -60040,7 +60648,7 @@ async def discover_session_provider_commands(
60040
60648
  support = provider_command_support(
60041
60649
  False,
60042
60650
  "unavailable",
60043
- "OpenCode local skills require the chat working directory to be available.",
60651
+ f"{'Gemini' if backend == BACKEND_GEMINI else 'OpenCode'} local skills require the chat working directory to be available.",
60044
60652
  )
60045
60653
  return provider_command_snapshot(fallback_empty, support), fallback_empty
60046
60654
  else:
@@ -60080,7 +60688,7 @@ async def discover_session_provider_commands(
60080
60688
  provider_command_snapshot(fallback_empty, support),
60081
60689
  fallback_empty,
60082
60690
  )
60083
- elif backend == BACKEND_OPENCODE:
60691
+ elif backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
60084
60692
  pass
60085
60693
  else:
60086
60694
  support = provider_command_support(
@@ -60174,6 +60782,26 @@ async def discover_session_provider_commands(
60174
60782
  f"Showing the first {MAX_PROVIDER_COMMANDS} provider commands."
60175
60783
  )
60176
60784
  return provider_command_snapshot(inventory, support), inventory
60785
+ if backend == BACKEND_GEMINI:
60786
+ from gemini_runtime import build_environment, list_native_skills, resolve_executable, GeminiConfigurationError
60787
+ def discover_gemini():
60788
+ connection = PROVIDER_CONNECTION_STORE.for_session(session)
60789
+ profile = PROVIDER_CONNECTION_STORE.root / ("gemini-runtime-" + connection["credential_id"]) if connection else None
60790
+ env = build_environment(runner_env(), cwd=cwd, api_home=profile,
60791
+ api_key=connection.get("api_key") if connection else None,
60792
+ base_url=connection.get("base_url") if connection else None,
60793
+ native_history=bool(session.get("gemini_native_history")))
60794
+ executable = resolve_executable(env)
60795
+ if not executable:
60796
+ raise GeminiConfigurationError("Install Gemini CLI to discover its skills.")
60797
+ return gemini_provider_skill_inventory(list_native_skills(executable, env, cwd),
60798
+ cwd=cwd, selector_secret=identity, binding_context=session_id)
60799
+ try:
60800
+ inventory = await asyncio.to_thread(discover_gemini)
60801
+ except (GeminiConfigurationError, ProviderCommandDiscoveryError, HTTPException, OSError, ValueError):
60802
+ return provider_command_snapshot(empty, provider_command_support(False, "unavailable",
60803
+ "Gemini skills could not be read. Check gemini skills list --all in this workspace and refresh.")), empty
60804
+ return provider_command_snapshot(inventory, provider_command_support(True, "native_activation_request")), inventory
60177
60805
  if backend == BACKEND_OPENCODE:
60178
60806
  try:
60179
60807
  env = runner_env()
@@ -60251,6 +60879,8 @@ async def resolve_provider_command_selection(
60251
60879
  if backend == BACKEND_CLAUDE
60252
60880
  else OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY
60253
60881
  if backend == BACKEND_OPENCODE
60882
+ else GEMINI_CLIENT_CAPABILITY
60883
+ if backend == BACKEND_GEMINI
60254
60884
  else None
60255
60885
  )
60256
60886
  if required_capability is None:
@@ -60688,7 +61318,9 @@ async def update_reasoning_summary_stream(
60688
61318
  phase: str = "summary",
60689
61319
  ) -> None:
60690
61320
  """Keep provider-exposed summary and plaintext reasoning channels distinct."""
60691
- if phase not in {"summary", "reasoning"}:
61321
+ if phase not in {"summary", "reasoning", "assistant"}:
61322
+ return
61323
+ if phase == "assistant" and (metadata or {}).get("backend") != "gemini":
60692
61324
  return
60693
61325
  index = params.get("contentIndex" if phase == "reasoning" else "summaryIndex", 0)
60694
61326
  delta = params.get("delta", "")
@@ -60708,7 +61340,7 @@ async def update_reasoning_summary_stream(
60708
61340
  after_seq = await asyncio.to_thread(last_event_seq_from_file, events_path(session_id))
60709
61341
  state = {"parts": {}, "item": {
60710
61342
  **dict(metadata or {}), "run_id": run_id, "item_id": item_id,
60711
- "backend": BACKEND_CODEX, "phase": phase, "text": "",
61343
+ "backend": (metadata or {}).get("backend", BACKEND_CODEX), "phase": phase, "text": "",
60712
61344
  "ts": now_iso(), "after_seq": after_seq,
60713
61345
  }}
60714
61346
  streams[(run_id, item_id, phase)] = state
@@ -68907,6 +69539,35 @@ async def retain_codex_goal_run_owner(
68907
69539
  return True
68908
69540
 
68909
69541
 
69542
+ def codex_native_archive_rejection(exc: BaseException, thread_id: str) -> bool:
69543
+ """Only the owning native resume RPC can report this state, not chat text."""
69544
+ return bool(
69545
+ thread_id and isinstance(exc, CodexAppServerRequestError)
69546
+ and exc.method == "thread/resume" and isinstance(exc.error, dict)
69547
+ and exc.error.get("message") == (
69548
+ f"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
69549
+ )
69550
+ )
69551
+
69552
+
69553
+ async def archive_rejected_codex_session(session_id: str, thread_id: str, run_id: str) -> None:
69554
+ """Mirror a confirmed native archive through the normal local archive path.
69555
+
69556
+ This never archives/unarchives/deletes the native thread. It retires local
69557
+ routes and jobs exactly like the user's Archive action, preserving history.
69558
+ Identity is stable while this run owns the chat; ignore stale/deleted runs.
69559
+ """
69560
+ current = STORE.sessions.get(session_id)
69561
+ active = ACTIVE.get(session_id) or {}
69562
+ if (not current or current.get("archived") or session_id in DELETING_SESSIONS
69563
+ or current.get("backend") != BACKEND_CODEX
69564
+ or str(session_provider_id(current) or "") != thread_id
69565
+ or active.get("run_id") != run_id):
69566
+ return
69567
+ await _update_session(session_id, UpdateSessionRequest(archived=True),
69568
+ native_archive_owner=(thread_id, run_id))
69569
+
69570
+
68910
69571
  async def run_codex_app_server(
68911
69572
  session_id: str,
68912
69573
  run_id: str,
@@ -71330,6 +71991,7 @@ async def run_codex_app_server(
71330
71991
  thread_pinned = False
71331
71992
  can_fallback = (
71332
71993
  allow_exec_fallback
71994
+ and not codex_native_archive_rejection(exc, str(session_provider_id(sess) or ""))
71333
71995
  and codex_provider.session_choice(sess.get("codex_provider")) == "default"
71334
71996
  and provider_command is None
71335
71997
  and not stop_requested
@@ -71394,6 +72056,15 @@ async def run_codex_app_server(
71394
72056
  **current_metadata(),
71395
72057
  })
71396
72058
  error_emitted = True
72059
+ if codex_native_archive_rejection(exc, str(session_provider_id(sess) or "")):
72060
+ try:
72061
+ await archive_rejected_codex_session(
72062
+ session_id, str(session_provider_id(sess)), current_run_id,
72063
+ )
72064
+ except Exception:
72065
+ # Preserve the actual provider error if local retirement
72066
+ # fails. Never pretend the native thread was unarchived.
72067
+ logger.exception("Could not mirror native Codex archive session=%s", session_id)
71397
72068
  finally:
71398
72069
  async def settle_transport_finalizer() -> None:
71399
72070
  """Retire every owner before cancellation can escape the runner."""
@@ -71789,7 +72460,7 @@ async def start_turn(
71789
72460
  scheduled_job_revision: str | None = None,
71790
72461
  scheduled_job_manual_run: bool = False,
71791
72462
  ) -> dict[str, Any]:
71792
- await wait_for_queue_recovery_admission()
72463
+ await wait_for_queue_recovery_admission(session_id)
71793
72464
  if queue_if_busy and provider_context_mode == "chat":
71794
72465
  await reconcile_idle_queue_session(
71795
72466
  session_id,
@@ -71862,7 +72533,7 @@ async def _start_turn_locked(
71862
72533
  # Internal delivery paths can enter with the lifecycle lock already held
71863
72534
  # and intentionally bypass ``start_turn``. They still must not start or
71864
72535
  # queue ahead of an undiscovered durable startup row.
71865
- await wait_for_queue_recovery_admission()
72536
+ await wait_for_queue_recovery_admission(session_id)
71866
72537
  if (
71867
72538
  req.purpose == SECURE_PEER_DELIVERY_PURPOSE
71868
72539
  and not SECURE_PEER_AGENT_RELAY_ENABLED
@@ -71884,6 +72555,8 @@ async def _start_turn_locked(
71884
72555
  raise HTTPException(status_code=404, detail="session not found")
71885
72556
  if sess.get("archived"):
71886
72557
  raise HTTPException(status_code=409, detail="archived chats cannot start turns")
72558
+ if sess.get("gemini_cleanup_pending_run_id"):
72559
+ raise HTTPException(status_code=409, detail="Gemini runtime cleanup is unconfirmed. Retry Stop; after a server restart, server-side runtime verification is required before this chat can continue.")
71887
72560
  if req.purpose == "chat_mailbox_wake" or mailbox_wake_claim is not None:
71888
72561
  if (req.purpose != "chat_mailbox_wake" or mailbox_wake_claim is None
71889
72562
  or mailbox_wake_claim.get("target_session_id") != session_id
@@ -72173,6 +72846,9 @@ async def _start_turn_locked(
72173
72846
  ),
72174
72847
  runtime_validation_patch,
72175
72848
  )
72849
+ if runtime_preview_session.get("backend") == BACKEND_GEMINI:
72850
+ if GEMINI_CLIENT_CAPABILITY not in req.client_capabilities:
72851
+ raise HTTPException(409, "Update AgentsDock to a Gemini-capable client before sending messages.")
72176
72852
  if str(runtime_preview_session.get("backend") or DEFAULT_BACKEND).strip().lower() == BACKEND_CODEX:
72177
72853
  await prepare_codex_login_turn(runtime_preview_session)
72178
72854
  opencode_cwd: str | None = None
@@ -72219,6 +72895,10 @@ async def _start_turn_locked(
72219
72895
  session_id,
72220
72896
  list(item.get("file_ids") or []),
72221
72897
  )
72898
+ if runtime_preview_session.get("backend") == BACKEND_GEMINI:
72899
+ from gemini_content import attachments as gemini_attachments
72900
+ await asyncio.to_thread(gemini_attachments, sys.modules[__name__], session_id,
72901
+ list(normalized_lineage[-1].get("file_ids") or []) if len(normalized_lineage) >= 2 else list(req.file_ids))
72222
72902
  # OpenCode's native --file path has no provider-side size ceiling and can
72223
72903
  # eagerly ingest local data. Resolve and bound the exact current message
72224
72904
  # before either reserving a slot or durably accepting it into the queue.
@@ -72435,6 +73115,9 @@ async def _start_turn_locked(
72435
73115
  if team_mail_grant_mutation:
72436
73116
  team_mail_route_snapshot = team_mail_grants.admission_snapshot(team_mail_grant_mutation, sess)
72437
73117
  fields_set = runtime_fields_set
73118
+ migration_previous_model = str(
73119
+ req.model if "model" in fields_set and req.model else sess.get("model") or ""
73120
+ ).strip() or None
72438
73121
  runtime_patch: dict[str, Any] = {}
72439
73122
  if req.backend:
72440
73123
  runtime_patch["backend"] = req.backend
@@ -72463,14 +73146,20 @@ async def _start_turn_locked(
72463
73146
 
72464
73147
  backend = sess.get("backend") or DEFAULT_BACKEND
72465
73148
  runtime_status = await ensure_runtime_available(backend, session=sess)
73149
+ if backend == BACKEND_CODEX:
73150
+ sess = await migrate_retired_codex_selection(
73151
+ session_id, sess, standalone=provider_context_mode == "standalone",
73152
+ previous_model=migration_previous_model,
73153
+ )
72466
73154
  if provider_context_mode == "chat":
72467
73155
  sess = await STORE.mark_backend_started(session_id, str(backend))
72468
- if backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
73156
+ if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
72469
73157
  # Keep the exact compatibility-probed executable pinned through
72470
73158
  # admission; mark_backend_started returns a fresh durable snapshot.
72471
73159
  sess = dict(sess)
72472
73160
  executable_key = (
72473
- "_opencode_executable" if backend == BACKEND_OPENCODE
73161
+ "_gemini_executable" if backend == BACKEND_GEMINI
73162
+ else "_opencode_executable" if backend == BACKEND_OPENCODE
72474
73163
  else "_cursor_executable"
72475
73164
  )
72476
73165
  sess[executable_key] = runtime_status.get("_executable")
@@ -72784,7 +73473,7 @@ async def _start_turn_locked(
72784
73473
  else None
72785
73474
  ),
72786
73475
  )
72787
- if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
73476
+ if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
72788
73477
  provider_authority_context += (
72789
73478
  "\n\n[AgentsDock final-result handoff]\n"
72790
73479
  "Your successful non-empty final answer will be delivered once to the explicitly referenced chat. "
@@ -72814,7 +73503,7 @@ async def _start_turn_locked(
72814
73503
  prompt,
72815
73504
  )
72816
73505
  provider_prompt = provider_turn_payload.user_prompt
72817
- if backend == BACKEND_CURSOR or (
73506
+ if backend in {BACKEND_CURSOR, BACKEND_GEMINI} or (
72818
73507
  backend == BACKEND_OPENCODE and resolved_provider_command is None
72819
73508
  ):
72820
73509
  # These print-mode CLIs have no separate system/application-context
@@ -72884,6 +73573,8 @@ async def _start_turn_locked(
72884
73573
  else:
72885
73574
  started_payload["model"] = sess.get("model")
72886
73575
  started_payload["effort"] = sess.get("effort")
73576
+ if getattr(req, "client_request_id", None):
73577
+ started_payload["client_request_id"] = req.client_request_id
72887
73578
  if queued_id:
72888
73579
  started_payload["queued_id"] = queued_id
72889
73580
  run_metadata = {
@@ -72963,6 +73654,7 @@ async def _start_turn_locked(
72963
73654
  or mailbox_wake_claim is not None
72964
73655
  or getattr(req, "shared_chat_id", None) is not None
72965
73656
  or queued_id is not None
73657
+ or getattr(req, "client_request_id", None) is not None
72966
73658
  )
72967
73659
  else append_event(session_id, "turn_started", started_payload)
72968
73660
  )
@@ -73136,6 +73828,21 @@ async def _start_turn_locked(
73136
73828
  else {}
73137
73829
  ),
73138
73830
  )
73831
+ elif backend == BACKEND_GEMINI:
73832
+ from gemini_runner import run as run_gemini
73833
+ task = run_gemini(sys.modules[__name__], session_id, run_id, provider_prompt,
73834
+ dict(sess), manifest_path,
73835
+ file_ids=prompt_file_ids,
73836
+ communication_required=bool(
73837
+ any(reference.action in {"route", "direct_message", "instruction", "request_reply"}
73838
+ for reference in req.chat_references)
73839
+ or req.team_references or team_mail_command
73840
+ or mailbox_wake_claim is not None
73841
+ or req.purpose in {"cross_chat_handoff_delivery", "secure_peer_handoff_delivery"}
73842
+ ),
73843
+ provider_command=resolved_provider_command,
73844
+ provider_runtime_env=provider_turn_payload.runtime_env,
73845
+ standalone_provider_context=provider_context_mode == "standalone")
73139
73846
  elif backend == BACKEND_OPENCODE:
73140
73847
  task = run_opencode(
73141
73848
  session_id,
@@ -76325,7 +77032,7 @@ def ensure_managed_update_service_cgroup_clear(
76325
77032
  *,
76326
77033
  service_cgroup: str | None = None,
76327
77034
  ) -> dict[str, Any]:
76328
- """Fail closed unless only this server remains in its service cgroup."""
77035
+ """Require no children, or prove this worker's stop policy preserves them."""
76329
77036
 
76330
77037
  state = managed_update_service_cgroup_state(
76331
77038
  service_cgroup=service_cgroup,
@@ -77426,6 +78133,7 @@ async def lifespan(app: FastAPI):
77426
78133
  cleanup_abandoned_fork_provider_threads(startup_abandoned_fork_threads)
77427
78134
  )
77428
78135
  host_monitor_task = asyncio.create_task(host_monitor_loop())
78136
+ queue_checkpoint_task = asyncio.create_task(queue_checkpoint_loop())
77429
78137
  history_search_task = asyncio.create_task(history_search_index_loop())
77430
78138
  runtime_probe_task = asyncio.create_task(asyncio.to_thread(refresh_runtime_diagnostics, force=True))
77431
78139
  if CONFIG_ENV_APPLIED:
@@ -77503,6 +78211,10 @@ async def lifespan(app: FastAPI):
77503
78211
  cross_chat_expiry_task.cancel()
77504
78212
  digest_recovery_task.cancel()
77505
78213
  queue_recovery_task.cancel()
78214
+ queue_checkpoint_task.cancel()
78215
+ history_tasks = (*HISTORY_MAINTENANCE_TASKS.values(), *(item[1] for item in TURN_RECEIPT_TASKS.values()))
78216
+ for task in history_tasks:
78217
+ task.cancel()
77506
78218
  server_update_pending_waiter_task.cancel()
77507
78219
  await bounded_shutdown_phase(
77508
78220
  "startup-reconciliation",
@@ -77511,6 +78223,8 @@ async def lifespan(app: FastAPI):
77511
78223
  cross_chat_expiry_task,
77512
78224
  digest_recovery_task,
77513
78225
  queue_recovery_task,
78226
+ queue_checkpoint_task,
78227
+ *history_tasks,
77514
78228
  server_update_pending_waiter_task,
77515
78229
  *(
77516
78230
  (health_queue_reconcile_task,)
@@ -78295,6 +79009,7 @@ async def require_agent_token(request: Request, call_next):
78295
79009
  codex_goals_admin_route = request.url.path in {
78296
79010
  "/api/admin/codex/goals", "/api/admin/codex/subagents",
78297
79011
  "/api/admin/codex/auth", "/api/admin/codex/auth/api-key",
79012
+ "/api/admin/codex/auth/reload",
78298
79013
  "/api/admin/codex/provider", "/api/admin/codex/provider/test",
78299
79014
  "/api/admin/codex/provider/models",
78300
79015
  "/api/admin/provider-connections/claude", "/api/admin/provider-connections/claude/check",
@@ -78483,7 +79198,7 @@ async def require_agent_token(request: Request, call_next):
78483
79198
  if body_error is not None:
78484
79199
  status_code, detail = body_error
78485
79200
  return JSONResponse({"detail": detail}, status_code=status_code)
78486
- elif request.url.path == "/api/admin/codex/auth/api-key" and request.method.upper() == "POST":
79201
+ elif request.url.path in {"/api/admin/codex/auth/api-key", "/api/admin/codex/auth/reload"} and request.method.upper() == "POST":
78487
79202
  declared_size, transport_error = privileged_native_json_transport(
78488
79203
  request, max_body_bytes=codex_auth.MAX_BODY_BYTES,
78489
79204
  label="Codex authentication", require_content_length=True,
@@ -80811,6 +81526,22 @@ async def codex_provider_mcp(request: Request) -> Response:
80811
81526
  })
80812
81527
 
80813
81528
 
81529
+ @app.get("/api/connection-addresses")
81530
+ async def connection_addresses(request: Request) -> dict[str, Any]:
81531
+ require_native_admin_control(request)
81532
+ # Discover only on explicit QR sharing, never on the frequently polled health path.
81533
+ from server_instances import candidate_addresses, connection_choices, tailscale_status
81534
+
81535
+ def discover() -> list[str]:
81536
+ choices = connection_choices(
81537
+ SERVER_BIND_ADDRESS, SERVER_PORT,
81538
+ candidate_addresses(SERVER_BIND_ADDRESS, SERVER_PORT), tailscale_status(),
81539
+ )
81540
+ return list(dict.fromkeys(filter(None, [choices["tailscale"], *choices["lan"]])))[:6]
81541
+
81542
+ return {"version": 1, "server_identity": server_identity(), "urls": await asyncio.to_thread(discover)}
81543
+
81544
+
80814
81545
  @app.get("/api/health")
80815
81546
  async def health() -> dict[str, Any]:
80816
81547
  await reconcile_idle_queued_turns_from_health_poll()
@@ -80829,6 +81560,7 @@ async def health() -> dict[str, Any]:
80829
81560
  team_hub_capability = await team_hub_capability_for_health()
80830
81561
  return {
80831
81562
  "ok": True,
81563
+ "chat_readiness": queue_recovery_readiness(),
80832
81564
  "server_version": SERVER_VERSION,
80833
81565
  "api_contract_version": API_CONTRACT_VERSION,
80834
81566
  "server_identity": server_identity(),
@@ -80850,7 +81582,7 @@ async def health() -> dict[str, Any]:
80850
81582
  "side_questions": side_questions.capability(),
80851
81583
  "codex_auth_v1": codex_auth.capability(available=bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC),
80852
81584
  "codex_provider_v1": {"available": bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC, "wire_api": "responses", "per_chat": True, "per_chat_models": True, "model_discovery": True, "model_compatibility": True},
80853
- "provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR]},
81585
+ "provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR, BACKEND_GEMINI]},
80854
81586
  "team_mail_hints_v1": SECURE_PEER_RUNTIME.team_mail_hint_capability(),
80855
81587
  "team_mail_hints_v2": SECURE_PEER_RUNTIME.team_notification_hint_capability(),
80856
81588
  "websocket_auth_v1": {
@@ -80865,6 +81597,7 @@ async def health() -> dict[str, Any]:
80865
81597
  "event_protocol": EVENTS_WEBSOCKET_PROTOCOL,
80866
81598
  "terminal_protocol": TERMINAL_WEBSOCKET_PROTOCOL,
80867
81599
  },
81600
+ "event_stream_liveness_v1": {"available": True, "version": 1},
80868
81601
  "team_hub_v1": team_hub_capability,
80869
81602
  "team_hub_host_control_v1": team_hub_host_control_capability(),
80870
81603
  "agent_team_mail_routes_v1": {
@@ -80971,16 +81704,19 @@ async def health() -> dict[str, Any]:
80971
81704
  BACKEND_CODEX,
80972
81705
  BACKEND_CLAUDE,
80973
81706
  BACKEND_OPENCODE,
81707
+ BACKEND_GEMINI,
80974
81708
  ],
80975
81709
  "client_capability_by_backend": {
80976
81710
  BACKEND_CODEX: CODEX_INTERACTIVE_CLIENT_CAPABILITY,
80977
81711
  BACKEND_CLAUDE: CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY,
80978
81712
  BACKEND_OPENCODE: OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY,
81713
+ BACKEND_GEMINI: GEMINI_CLIENT_CAPABILITY,
80979
81714
  },
80980
81715
  "supported_kinds_by_backend": {
80981
81716
  BACKEND_CODEX: ["skill"],
80982
81717
  BACKEND_CLAUDE: ["command"],
80983
81718
  BACKEND_OPENCODE: ["skill"],
81719
+ BACKEND_GEMINI: ["skill"],
80984
81720
  },
80985
81721
  "opencode_scope": {
80986
81722
  "discovery": "bounded_documented_default_skill_roots",
@@ -81051,6 +81787,25 @@ async def health() -> dict[str, Any]:
81051
81787
  "max_client_frame_bytes": PORT_TUNNEL_MAX_CLIENT_FRAME_BYTES,
81052
81788
  **port_tunnel_status,
81053
81789
  },
81790
+ "gemini_backend": {
81791
+ # Server support is always advertised; CLI installation and
81792
+ # API connection readiness are checked separately at runtime.
81793
+ "available": True, "required": False, "version": 1, "transport": "acp",
81794
+ "permission_modes": ["default", "autoEdit", "yolo"],
81795
+ "default_permission_mode": "default",
81796
+ "message": "Gemini requires a compatible Gemini CLI and a configured API connection in My Agents.", "action": None,
81797
+ "client_capability": GEMINI_CLIENT_CAPABILITY,
81798
+ "history_mode": "initial_text_snapshot",
81799
+ "features": {"resume": True, "external_import": True, "fork": False,
81800
+ "native_import_api": True,
81801
+ "history_reconciliation": False,
81802
+ "approvals": True, "skills_picker": True, "custom_api": True,
81803
+ "attachments": True, "live_text": True, "token_usage": True,
81804
+ "scheduled_jobs": True, "interactive_shares": True,
81805
+ "native_goals": False, "native_side_questions": False,
81806
+ "managed_subagents": False, "native_compaction_control": False,
81807
+ "permission_mode_control": True},
81808
+ },
81054
81809
  "opencode_backend": {
81055
81810
  # ``available`` advertises the server contract. Runtime
81056
81811
  # readiness is reported independently by
@@ -81206,6 +81961,7 @@ async def health() -> dict[str, Any]:
81206
81961
  "max_active_alerts": EMERGENCY_ACTIVE_ALERT_LIMIT,
81207
81962
  "stream_path": "/api/emergency-alerts/events",
81208
81963
  "stream_protocol": EMERGENCY_WEBSOCKET_PROTOCOL,
81964
+ "acknowledge_through_alert_id": True,
81209
81965
  },
81210
81966
  "cross_chat_handoffs_v1": cross_chat_handoffs_capability(),
81211
81967
  "codex_controls": {
@@ -81302,15 +82058,11 @@ def codex_goals_admin_status() -> dict[str, Any]:
81302
82058
  enabled = bool(CODEX_GOALS_ENABLED)
81303
82059
  return {
81304
82060
  "enabled": enabled,
81305
- "configurable": True,
82061
+ "configurable": False,
81306
82062
  "default_enabled": bool(CODEX_GOALS_DEFAULT_ENABLED),
81307
82063
  "scope": "server",
81308
82064
  "reconfiguring": bool(CODEX_GOALS_RECONFIGURING),
81309
- "message": (
81310
- "Persistent Codex goals are enabled for this server."
81311
- if enabled
81312
- else "Persistent Codex goals are disabled for this server."
81313
- ),
82065
+ "message": "Persistent Codex goals are always available. Manage each goal in its chat.",
81314
82066
  }
81315
82067
 
81316
82068
 
@@ -82134,36 +82886,14 @@ async def get_codex_goals_admin() -> dict[str, Any]:
82134
82886
  async def put_codex_goals_admin(
82135
82887
  req: CodexGoalsAdminRequest,
82136
82888
  ) -> dict[str, Any]:
82137
- global CODEX_GOALS_ENABLED
82138
- async with CODEX_GOALS_CONFIG_LOCK:
82139
- requested = bool(req.enabled)
82140
- if requested == CODEX_GOALS_ENABLED:
82141
- return codex_goals_admin_status()
82142
- settings = read_codex_admin_settings()
82143
- await reserve_codex_goals_reconfiguration()
82144
- transition: dict[str, int] = {
82145
- "paused_goal_count": 0,
82146
- "fenced_goal_count": 0,
82147
- }
82148
- try:
82149
- if not requested:
82150
- transition = await pause_idle_codex_goals_before_disable()
82151
- # The feature flag is a process-level Codex setting. Replacing the
82152
- # shared, idle manager is the only way to make it authoritative for
82153
- # every loaded and future thread.
82154
- await close_codex_app_server_manager()
82155
- atomic_update_json(
82156
- CODEX_SETTINGS_FILE,
82157
- {
82158
- **settings,
82159
- "goals_enabled": requested,
82160
- "updated_at": update_utc_now(),
82161
- },
82162
- )
82163
- CODEX_GOALS_ENABLED = requested
82164
- finally:
82165
- await release_codex_goals_reconfiguration()
82166
- return {**codex_goals_admin_status(), **transition}
82889
+ # Preserve the authenticated legacy route without allowing an older client
82890
+ # to disable goals for every chat or restart the shared provider manager.
82891
+ if not req.enabled:
82892
+ raise HTTPException(
82893
+ status_code=409,
82894
+ detail="Persistent Codex goals cannot be disabled server-wide. Pause individual goals in their chats.",
82895
+ )
82896
+ return codex_goals_admin_status()
82167
82897
 
82168
82898
 
82169
82899
  def require_native_admin_control(request: Request) -> None:
@@ -82187,7 +82917,7 @@ def require_native_admin_control(request: Request) -> None:
82187
82917
  async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
82188
82918
  """Fence native authentication changes with the existing Codex admission barrier."""
82189
82919
  if not mutate:
82190
- await refresh_codex_app_server_login()
82920
+ await refresh_codex_app_server_login(source="account_status")
82191
82921
  async with CODEX_AUTH_LOCK:
82192
82922
  reserved = False
82193
82923
  try:
@@ -82224,10 +82954,20 @@ async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
82224
82954
  await release_codex_goals_reconfiguration()
82225
82955
 
82226
82956
 
82957
+ async def reload_codex_login() -> bool:
82958
+ logger.info("Codex login reload requested source=manual_login_reload")
82959
+ if not await refresh_codex_app_server_login(request_handoff=True, source="manual_login_reload"):
82960
+ logger.info("Codex login reload deferred source=manual_login_reload reason=unavailable_or_busy")
82961
+ raise HTTPException(409, "Codex sign-in cannot be reloaded now. Its credential store is unsupported or maintenance is active.")
82962
+ return any(getattr(manager, "_agentsdock_login_superseded", False) is True
82963
+ for manager in codex_app_server_managers())
82964
+
82965
+
82227
82966
  app.include_router(codex_auth.create_router(
82228
82967
  authorize=require_native_admin_control,
82229
82968
  operation=codex_auth_operation,
82230
82969
  available=lambda: CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC,
82970
+ reload_login=reload_codex_login,
82231
82971
  ))
82232
82972
 
82233
82973
 
@@ -82509,6 +83249,9 @@ app.include_router(provider_connections.create_router(
82509
83249
  ),
82510
83250
  ))
82511
83251
 
83252
+ from gemini_runner import router as gemini_router
83253
+ app.include_router(gemini_router(sys.modules[__name__]))
83254
+
82512
83255
  app.include_router(codex_provider.create_router(
82513
83256
  authorize=require_native_admin_control,
82514
83257
  store=CODEX_PROVIDER_STORE,
@@ -82929,6 +83672,7 @@ async def submit_interactive_chat_prompt(
82929
83672
  result = await post_turn(session_id, TurnRequest(
82930
83673
  prompt=prompt, file_ids=file_ids, shared_chat_id=share_id,
82931
83674
  shared_chat_request_id=request_id,
83675
+ client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
82932
83676
  ))
82933
83677
  if not isinstance(result, dict) or type(result.get("queued")) is not bool:
82934
83678
  raise HTTPException(503, "Message acceptance could not be confirmed")
@@ -83035,13 +83779,21 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
83035
83779
  projected[key] = strip_agentsdock_generated_user_text(projected[key], expected_session_id=session_id)
83036
83780
  queue.append(projected)
83037
83781
  model, effort = str(session.get("model") or ""), str(session.get("effort") or "")
83782
+ gemini_fields = {}
83783
+ if backend == "gemini":
83784
+ from gemini_runner import PERMISSIONS, owns
83785
+ gemini_fields["gemini_runtime"] = {"available": True, "pending_interactions": [
83786
+ shared_native_value(row["public"]) for row in PERMISSIONS.values()
83787
+ if row["public"]["session_id"] == session_id and owns(sys.modules[__name__], session_id, row["run_id"])]}
83038
83788
  snapshot = shared_native_value({
83039
83789
  "session": public, "events": page["events"], "queue": queue, "active": active,
83040
83790
  "hasMoreEvents": page["has_more"], "nextTimelineBefore": page.get("next_before"),
83041
83791
  "eventsTotal": page.get("semantic_total"), "goal": goal,
83042
83792
  "jobs": (await list_session_jobs(session_id))["jobs"],
83043
83793
  "codex_runtime": codex_runtime, "claude_runtime": claude_runtime,
83794
+ **gemini_fields,
83044
83795
  "health": {"capabilities": {
83796
+ **({"gemini_backend": {"available": True, "version": 1}} if backend == "gemini" else {}),
83045
83797
  "codex_controls": {"available": codex_available},
83046
83798
  "claude_controls": {"available": claude_available,
83047
83799
  "interactive_client_capability": CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY},
@@ -83072,6 +83824,7 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
83072
83824
  async def steer_interactive_chat_prompt(session_id: str, prompt: str, *, share_id: str, request_id: str) -> dict[str, Any]:
83073
83825
  receipt = await post_turn(session_id, TurnRequest(
83074
83826
  prompt=prompt, shared_chat_id=share_id, shared_chat_request_id=request_id,
83827
+ client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
83075
83828
  ))
83076
83829
  if not isinstance(receipt, dict) or type(receipt.get("queued")) is not bool:
83077
83830
  raise HTTPException(503, "Message acceptance could not be confirmed")
@@ -83168,6 +83921,9 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
83168
83921
  INTERACTIVE_CHAT_CATALOG = (time.monotonic(), shared_native_value(await runtime_catalog()))
83169
83922
  value = INTERACTIVE_CHAT_CATALOG[1]
83170
83923
  return shared_native_value(value)
83924
+ async def shared_gemini_approval(sid, interaction_id, response):
83925
+ from gemini_runner import resolve_permission
83926
+ return await resolve_permission(sys.modules[__name__], sid, interaction_id, response)
83171
83927
  callbacks = {
83172
83928
  "turn.stop": stop_turn_endpoint, "turn.steer": steer_interactive_chat_prompt,
83173
83929
  "queue.run_now": post_run_queued_turn_now, "queue.edit": patch_queued_turn,
@@ -83177,6 +83933,7 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
83177
83933
  "job.create": create_session_job, "job.update": update_session_job,
83178
83934
  "job.toggle": update_session_job, "job.delete": delete_session_job, "job.run": run_interactive_chat_job,
83179
83935
  "approval.codex": post_codex_interaction_response, "approval.claude": post_claude_interaction_response,
83936
+ "approval.gemini": shared_gemini_approval,
83180
83937
  }
83181
83938
  models = {model.__name__: model for model in (
83182
83939
  UpdateSessionRequest, CodexGoalRequest, CreateScopedJobRequest, UpdateJobRequest,
@@ -85787,7 +86544,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
85787
86544
  async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
85788
86545
  if refresh:
85789
86546
  await refresh_codex_app_server_binary(force=True)
85790
- await refresh_codex_app_server_login()
86547
+ await refresh_codex_app_server_login(source="catalog_refresh")
85791
86548
  return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
85792
86549
 
85793
86550
 
@@ -85908,12 +86665,13 @@ async def tail_session_process_log(session_id: str, path: str, lines: int = 200)
85908
86665
 
85909
86666
 
85910
86667
  @app.get("/api/sessions")
85911
- async def list_sessions(summary: bool = False) -> dict[str, Any]:
86668
+ async def list_sessions(summary: bool = False, placeholder_client_id: str = "") -> dict[str, Any]:
85912
86669
  await STORE.ensure_sort_orders()
85913
86670
  sessions = [
85914
86671
  public_session(session, summary=summary)
85915
86672
  for session in sorted_sessions(list(STORE.sessions.values()))
85916
86673
  if not session.get("_fork_initializing")
86674
+ and not empty_chat_dismissed_for(session, placeholder_client_id)
85917
86675
  ]
85918
86676
  return {"sessions": sessions}
85919
86677
 
@@ -85968,6 +86726,7 @@ async def create_session_with_history(req: CreateSessionRequest) -> dict[str, An
85968
86726
  async def get_local_sessions(
85969
86727
  limit: int = Query(default=200, ge=1, le=MAX_LOCAL_SESSION_LIST_ITEMS),
85970
86728
  include_cursor: bool = False,
86729
+ include_gemini: bool = False,
85971
86730
  ) -> dict[str, Any]:
85972
86731
  """List main conversations unused by any installed same-user local instance."""
85973
86732
 
@@ -85978,6 +86737,8 @@ async def get_local_sessions(
85978
86737
  for key in local_session_ownership.provider_session_keys(session, DEFAULT_BACKEND)
85979
86738
  }
85980
86739
  options = {"include_cursor": True} if include_cursor else {}
86740
+ if include_gemini:
86741
+ options["include_gemini"] = True
85981
86742
  sessions = await asyncio.to_thread(local_session_candidates, limit, known_provider_keys, **options)
85982
86743
  return {"sessions": sessions}
85983
86744
 
@@ -86051,6 +86812,7 @@ async def bulk_import_sessions_guarded(
86051
86812
  MAX_LOCAL_SESSION_LIST_ITEMS,
86052
86813
  set(),
86053
86814
  **({"include_cursor": True} if any(item.backend == BACKEND_CURSOR for item in req.items) else {}),
86815
+ **({"include_gemini": True} if any(item.backend == BACKEND_GEMINI for item in req.items) else {}),
86054
86816
  )
86055
86817
  candidate_by_key = {
86056
86818
  (str(candidate["backend"]), str(candidate["provider_session_id"])): candidate
@@ -86060,6 +86822,10 @@ async def bulk_import_sessions_guarded(
86060
86822
  results: list[dict[str, Any]] = []
86061
86823
  for item in req.items:
86062
86824
  key = (item.backend, item.provider_session_id)
86825
+ if item.provider_connection and item.backend != BACKEND_GEMINI:
86826
+ results.append(bulk_import_result(item, ok=False, code="invalid_connection",
86827
+ error="API binding on import is only supported for Gemini."))
86828
+ continue
86063
86829
  if key in requested_keys:
86064
86830
  results.append(bulk_import_result(
86065
86831
  item,
@@ -86100,7 +86866,11 @@ async def bulk_import_sessions_guarded(
86100
86866
 
86101
86867
  staged_session: dict[str, Any] | None = None
86102
86868
  try:
86103
- if item.backend == BACKEND_CURSOR:
86869
+ if item.backend == BACKEND_GEMINI:
86870
+ from gemini_import import initial_history
86871
+ source_path, history_items = await asyncio.to_thread(initial_history,
86872
+ sys.modules[__name__], runner_env(), item.provider_session_id, candidate_cwd or "")
86873
+ elif item.backend == BACKEND_CURSOR:
86104
86874
  source_path, history_items = await asyncio.to_thread(
86105
86875
  cursor_initial_history, item.provider_session_id, candidate_cwd or "",
86106
86876
  )
@@ -86142,6 +86912,7 @@ async def bulk_import_sessions_guarded(
86142
86912
  staged_session = await STORE.create(
86143
86913
  CreateSessionRequest(
86144
86914
  backend=item.backend,
86915
+ provider_connection=item.provider_connection,
86145
86916
  provider_session_id=item.provider_session_id,
86146
86917
  cwd=candidate_cwd or DEFAULT_CWD,
86147
86918
  title=(item.title or str(candidate.get("label") or "")).strip()
@@ -86418,8 +87189,6 @@ async def get_session(
86418
87189
  normalized_page_mode = str(page_mode or "").strip().lower()
86419
87190
  if normalized_page_mode not in {"", "semantic"}:
86420
87191
  raise HTTPException(status_code=400, detail="page_mode must be semantic")
86421
- if normalized_page_mode != "semantic":
86422
- await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
86423
87192
  page_tail = tail and after <= 0
86424
87193
  semantic_page: dict[str, Any] | None = None
86425
87194
  if normalized_page_mode == "semantic":
@@ -86485,6 +87254,23 @@ async def get_session(
86485
87254
  return response
86486
87255
 
86487
87256
 
87257
+ @app.post("/api/sessions/{session_id}/history/maintenance", status_code=202)
87258
+ async def retry_history_maintenance(session_id: str) -> dict[str, Any]:
87259
+ """Explicit retry clears backoff, never provenance or lifecycle guards."""
87260
+ ensure_session_not_initializing(session_id)
87261
+ ensure_session_not_deleting(session_id)
87262
+ sess = STORE.sessions.get(session_id)
87263
+ if not sess:
87264
+ raise HTTPException(status_code=404, detail="session not found")
87265
+ if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
87266
+ raise HTTPException(status_code=409, detail="history maintenance waits for this chat to become idle")
87267
+ if session_id not in HISTORY_SYNC_SCHEDULED:
87268
+ history_maintenance_state(session_id).clear()
87269
+ schedule_provider_history_sync(sess)
87270
+ return {"scheduled": session_id in HISTORY_SYNC_SCHEDULED,
87271
+ "history_maintenance": dict(history_maintenance_state(session_id))}
87272
+
87273
+
86488
87274
  @app.post("/api/sessions/{session_id}/import-history")
86489
87275
  async def import_history(session_id: str, req: ImportHistoryRequest) -> dict[str, Any]:
86490
87276
  async with session_lifecycle_lock(session_id):
@@ -86780,6 +87566,68 @@ async def ensure_backend_update_allowed(
86780
87566
 
86781
87567
  @app.patch("/api/sessions/{session_id}")
86782
87568
  async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str, Any]:
87569
+ if req.gemini_connect_api:
87570
+ if set(req.model_dump(exclude_unset=True)) != {"gemini_connect_api"}:
87571
+ raise HTTPException(400, "Connect the Gemini API separately from other chat edits.")
87572
+ return await connect_gemini_import_api(session_id)
87573
+ return await _update_session(session_id, req)
87574
+
87575
+
87576
+ async def connect_gemini_import_api(session_id: str) -> dict[str, Any]:
87577
+ """Explicit one-way binding of a native import, not general account switching."""
87578
+ from gemini_import import initial_history
87579
+ from gemini_history import native_snapshot, snapshot_replay
87580
+ from gemini_acp_client import GeminiProtocolError
87581
+ ensure_session_not_initializing(session_id)
87582
+ async with session_lifecycle_lock(session_id):
87583
+ current = STORE.sessions.get(session_id)
87584
+ if not current:
87585
+ raise HTTPException(404, "session not found")
87586
+ if current.get("backend") != BACKEND_GEMINI or not session_provider_id(current):
87587
+ raise HTTPException(400, "Only an imported Gemini conversation can use this action.")
87588
+ if current.get("archived") or session_id in DELETING_SESSIONS:
87589
+ raise HTTPException(409, "Unarchive this chat before connecting its API.")
87590
+ if (session_id in BUSY_SESSIONS or session_id in ACTIVE or QUEUED_TURNS.get(session_id)
87591
+ or current.get("gemini_cleanup_pending_run_id")
87592
+ or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
87593
+ raise HTTPException(409, "Stop active work and clear queued messages before connecting this API.")
87594
+ if current.get("provider_connection") == "custom":
87595
+ PROVIDER_CONNECTION_STORE.for_session(current) # No silent rebind on Forget.
87596
+ return {"session": public_session(current)}
87597
+ provider_id, cwd = session_provider_id(current), str(current.get("cwd") or "")
87598
+ def validate():
87599
+ initial_history(sys.modules[__name__], runner_env(), provider_id, cwd)
87600
+ snapshot_replay(native_snapshot(runner_env(), cwd, provider_id))
87601
+ try:
87602
+ await asyncio.to_thread(validate)
87603
+ except (GeminiProtocolError, OSError, ValueError):
87604
+ raise HTTPException(409, "The original Gemini history is unavailable or changed; refresh Import Chat.") from None
87605
+ async with STORE._lock:
87606
+ if (STORE.sessions.get(session_id) is not current or session_id in DELETING_SESSIONS
87607
+ or current.get("archived") or session_id in BUSY_SESSIONS or session_id in ACTIVE
87608
+ or QUEUED_TURNS.get(session_id) or current.get("gemini_cleanup_pending_run_id")
87609
+ or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
87610
+ raise HTTPException(409, "This chat changed while connecting; wait for active work to finish and retry.")
87611
+ previous = dict(current)
87612
+ selected = PROVIDER_CONNECTION_STORE.bind({"backend": BACKEND_GEMINI, "provider_connection": "custom"})
87613
+ current.update(provider_connection="custom", provider_connection_revision=selected["credential_id"],
87614
+ gemini_native_history=True, gemini_session_cwd=cwd, model=selected.get("model"), updated_at=now_iso())
87615
+ try:
87616
+ await STORE.save(durable=True)
87617
+ except BaseException:
87618
+ current.clear(); current.update(previous)
87619
+ raise
87620
+ return {"session": public_session(current)}
87621
+
87622
+
87623
+ async def _update_session(
87624
+ session_id: str, req: UpdateSessionRequest, *,
87625
+ native_archive_owner: tuple[str, str] | None = None,
87626
+ ) -> dict[str, Any]:
87627
+ # Internal provenance is not an HTTP parameter. Recheck after acquiring
87628
+ # the same lock as Stop, replacement and new-turn admission.
87629
+ if native_archive_owner is not None and req.archived is not True:
87630
+ raise ValueError("native archive ownership only applies to archive")
86783
87631
  ensure_session_not_initializing(session_id)
86784
87632
  patch = req.model_dump(exclude_unset=True)
86785
87633
  if SESSION_LIFECYCLE_UPDATE_FIELDS.intersection(patch):
@@ -86787,6 +87635,12 @@ async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str
86787
87635
  current = STORE.sessions.get(session_id)
86788
87636
  if not current:
86789
87637
  raise HTTPException(status_code=404, detail="session not found")
87638
+ if native_archive_owner is not None:
87639
+ thread_id, run_id = native_archive_owner
87640
+ if (session_id in DELETING_SESSIONS or current.get("backend") != BACKEND_CODEX
87641
+ or str(session_provider_id(current) or "") != thread_id
87642
+ or (ACTIVE.get(session_id) or {}).get("run_id") != run_id):
87643
+ return {"session": public_session(current)}
86790
87644
  if req.archived is False and current.get("archived"):
86791
87645
  try:
86792
87646
  # An earlier archive may have failed to save jobs.json
@@ -88648,7 +89502,7 @@ async def _put_codex_goal_locked(
88648
89502
  if req.status == "active":
88649
89503
  if (STORE.sessions.get(session_id) or {}).get("archived"):
88650
89504
  raise HTTPException(status_code=409, detail="archived chats cannot resume goals")
88651
- await wait_for_queue_recovery_admission()
89505
+ await wait_for_queue_recovery_admission(session_id)
88652
89506
  manager, thread_id, control_session = await acquire_codex_control_thread(
88653
89507
  session_id,
88654
89508
  reserve_session=req.status == "active",
@@ -89435,6 +90289,26 @@ async def acknowledge_session_emergency(
89435
90289
  sess = STORE.sessions.get(session_id)
89436
90290
  if not sess:
89437
90291
  raise HTTPException(status_code=404, detail="session not found")
90292
+ if req.through_alert_id is not None:
90293
+ boundary, alert_ids = await asyncio.to_thread(
90294
+ emergency_alert_batch_through, session_id, req.through_alert_id,
90295
+ )
90296
+ if boundary is None:
90297
+ raise HTTPException(status_code=409, detail="that emergency alert boundary was not found")
90298
+ if alert_ids:
90299
+ events = await append_durable_event_batch(session_id, [
90300
+ ("emergency_alert_acknowledged", {
90301
+ "emergency_alert_id": alert_id,
90302
+ "message": "Emergency acknowledged by the user.",
90303
+ })
90304
+ for alert_id in alert_ids
90305
+ ])
90306
+ committed = events[-1]
90307
+ else:
90308
+ committed = boundary
90309
+ sess = await project_committed_emergency_event(session_id, committed)
90310
+ return {"session": public_session(sess), "acknowledged": bool(alert_ids),
90311
+ "acknowledged_count": len(alert_ids)}
89438
90312
  alert_id = req.expected_alert_id
89439
90313
  acknowledged_event = await asyncio.to_thread(
89440
90314
  find_emergency_acknowledgement_event,
@@ -89520,6 +90394,74 @@ async def ensure_discardable_empty_session(session_id: str, expected_updated_at:
89520
90394
  raise HTTPException(status_code=409, detail="Chat has content or resources; it was preserved.")
89521
90395
 
89522
90396
 
90397
+ def empty_chat_revision(session: dict[str, Any]) -> str:
90398
+ # Use durable content/settings, not public runtime projections, read cursors
90399
+ # or sort order. Reconnecting, reading or normalizing runtime metadata on
90400
+ # startup must not resurrect an unchanged blank row.
90401
+ revision = {key: session.get(key) for key in (
90402
+ "created_at", "updated_at", "title", "folder", "cwd", "backend",
90403
+ "model", "effort", "system_prompt", "_title_source",
90404
+ "codex_provider", "provider_connection", "parent_id",
90405
+ "codex_goal", "claude_goal",
90406
+ )}
90407
+ revision["latest_event_seq"] = int(session.get("latest_event_seq") or 0)
90408
+ for key in ("backend_locked", "pinned", "archived", "manual_unread"):
90409
+ revision[key] = bool(session.get(key))
90410
+ return hashlib.sha256(json.dumps(revision, sort_keys=True,
90411
+ separators=(",", ":")).encode()).hexdigest()
90412
+
90413
+
90414
+ def empty_chat_dismissed_for(session: dict[str, Any], client_id: str) -> bool:
90415
+ if not client_id or session_backend_locked(session):
90416
+ return False
90417
+ state = session.get("_empty_chat_cleanup") or {}
90418
+ return (isinstance(state, dict) and state.get("version") == 1
90419
+ and isinstance(state.get("dismissed_by"), dict)
90420
+ and state["dismissed_by"].get(client_id) == empty_chat_revision(session))
90421
+
90422
+
90423
+ @app.post("/api/sessions/{session_id}/dismiss-empty")
90424
+ async def dismiss_empty_session(session_id: str, expected_updated_at: str,
90425
+ placeholder_client_id: str) -> dict[str, Any]:
90426
+ """Remove an unused row from one desktop's list without deleting its identity.
90427
+
90428
+ Another/offline desktop may own an unsent draft. Its list is unaffected;
90429
+ subsequent real changes also invalidate this desktop's dismissal. No draft
90430
+ content leaves a desktop, and no native history/process is touched.
90431
+ """
90432
+ if not re.fullmatch(r"[a-zA-Z0-9_-]{16,80}", placeholder_client_id):
90433
+ raise HTTPException(400, "Invalid placeholder client identity")
90434
+ await wait_for_queue_recovery_admission()
90435
+ async with session_lifecycle_lock(session_id):
90436
+ ensure_session_not_initializing(session_id)
90437
+ if session_id in DELETING_SESSIONS or session_id in DELETED_SESSION_TOMBSTONES:
90438
+ raise HTTPException(409, "Chat deletion is already in progress")
90439
+ # Existing admission, uploads, jobs and terminal routes use this fence.
90440
+ DELETING_SESSIONS.add(session_id)
90441
+ try:
90442
+ await ensure_discardable_empty_session(session_id, expected_updated_at)
90443
+ async with STORE._lock:
90444
+ session = STORE.sessions[session_id]
90445
+ previous = session.get("_empty_chat_cleanup")
90446
+ state = previous if isinstance(previous, dict) else {}
90447
+ dismissed = dict(state.get("dismissed_by") or {})
90448
+ if placeholder_client_id not in dismissed and len(dismissed) >= 256:
90449
+ raise HTTPException(409, "Too many desktop cleanup identities; chat preserved")
90450
+ dismissed[placeholder_client_id] = empty_chat_revision(session)
90451
+ session["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": dismissed}
90452
+ try:
90453
+ await STORE.save()
90454
+ except BaseException:
90455
+ if previous is None:
90456
+ session.pop("_empty_chat_cleanup", None)
90457
+ else:
90458
+ session["_empty_chat_cleanup"] = previous
90459
+ raise
90460
+ return {"ok": True, "dismissed": True}
90461
+ finally:
90462
+ DELETING_SESSIONS.discard(session_id)
90463
+
90464
+
89523
90465
  @app.post("/api/sessions/{session_id}/discard-empty")
89524
90466
  async def discard_empty_session(session_id: str, expected_updated_at: str) -> dict[str, Any]:
89525
90467
  # A separate route makes old servers fail safely with 404, rather than
@@ -89532,6 +90474,13 @@ async def discard_empty_session(session_id: str, expected_updated_at: str) -> di
89532
90474
  async def delete_session(session_id: str, discard_updated_at: str | None = None) -> dict[str, Any]:
89533
90475
  async with session_lifecycle_lock(session_id):
89534
90476
  ensure_session_not_initializing(session_id)
90477
+ pending_gemini_cleanup = (STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
90478
+ live_gemini_owner = ACTIVE.get(session_id) or {}
90479
+ if pending_gemini_cleanup and (
90480
+ live_gemini_owner.get("run_id") != pending_gemini_cleanup
90481
+ or not callable(live_gemini_owner.get("gemini_cleanup"))
90482
+ ):
90483
+ raise HTTPException(409, "Gemini runtime cleanup requires server-side verification after restart; the chat and native history were not deleted.")
89535
90484
  if session_id in DELETED_SESSION_TOMBSTONES:
89536
90485
  # A previous delete can have committed before its request was
89537
90486
  # canceled. Retry any tracked bridge retirement on idempotent
@@ -89699,6 +90648,16 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
89699
90648
  "shortly."
89700
90649
  ),
89701
90650
  )
90651
+ elif active.get("backend") == BACKEND_GEMINI:
90652
+ cleanup = active.get("gemini_cleanup")
90653
+ try:
90654
+ if not callable(cleanup):
90655
+ raise RuntimeError("Missing Gemini cleanup owner")
90656
+ await cleanup()
90657
+ except Exception:
90658
+ raise HTTPException(status_code=409, detail=(
90659
+ "Gemini runtime cleanup could not be verified; the chat was not deleted. Retry Stop."
90660
+ )) from None
89702
90661
  elif proc:
89703
90662
  await terminate_process_tree(proc)
89704
90663
 
@@ -89797,6 +90756,17 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
89797
90756
  # initial session record but before its task acknowledges stop.
89798
90757
  # The task registry is now drained, so this reread is stable.
89799
90758
  late_session = STORE.sessions.get(session_id) or {}
90759
+ gemini_active = ACTIVE.get(session_id) or {}
90760
+ if gemini_active.get("backend") == BACKEND_GEMINI and gemini_active.get("gemini_cleanup_failed"):
90761
+ # A completed consumer task does not prove its owned runtime
90762
+ # was reaped. Retry only this chat's cleanup, before deletion.
90763
+ try:
90764
+ await gemini_active["gemini_cleanup"]()
90765
+ from gemini_runner import clear_cleanup_fence
90766
+ await clear_cleanup_fence(sys.modules[__name__], session_id, str(gemini_active.get("run_id") or ""))
90767
+ except Exception:
90768
+ raise HTTPException(409, "Gemini runtime cleanup is incomplete; the chat was not deleted. Retry after stopping its runtime.") from None
90769
+ gemini_active["gemini_cleanup_failed"] = False
89800
90770
  late_provider_thread_id = str(
89801
90771
  session_provider_id(late_session) or ""
89802
90772
  )
@@ -90024,19 +90994,20 @@ def claude_imported_fork_boundary(
90024
90994
  digest = hashlib.sha256()
90025
90995
  last_message: dict[str, Any] = {}
90026
90996
  with path.open("rb") as stream:
90027
- for _line in range(MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
90028
- if not remaining:
90029
- break
90030
- raw = stream.readline(min(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1, remaining))
90031
- if not raw or not raw.endswith(b"\n") or len(raw) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
90032
- raise ValueError("imported history checkpoint has an incomplete transcript record")
90033
- remaining -= len(raw)
90034
- digest.update(raw)
90035
- if not raw.strip():
90997
+ original = os.fstat(stream.fileno())
90998
+ for record, offset, raw in jsonl_records(stream, end=remaining,
90999
+ max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
91000
+ raw.update_hash(digest)
91001
+ remaining = int(cursor["source_offset"]) - offset
91002
+ if raw.blank:
90036
91003
  continue
90037
- record = json.loads(raw)
90038
- if isinstance(record, dict) and record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
91004
+ if record is None:
91005
+ raise ValueError("imported history checkpoint has a malformed transcript record")
91006
+ if record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
90039
91007
  last_message = record
91008
+ current = os.fstat(stream.fileno())
91009
+ if (original.st_dev, original.st_ino, original.st_size, original.st_mtime_ns) != (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns):
91010
+ raise ValueError("imported history checkpoint changed during reading")
90040
91011
  if remaining or not hmac.compare_digest(digest.hexdigest(), cursor["source_digest"]):
90041
91012
  raise ValueError("imported history checkpoint no longer matches its native prefix")
90042
91013
  message = last_message.get("message")
@@ -90091,7 +91062,15 @@ def claude_completed_fork_boundary(
90091
91062
  # final projected message can identify the inclusive native cutoff.
90092
91063
  expected_text = projected_text if projected_text and final_text.endswith(projected_text) else final_text
90093
91064
  recorded_uuid = str(final_projection.get("provider_message_id") or "") if expected_text == projected_text else ""
90094
- cutoff_time = parse_job_timestamp(str(terminal.get("ts") or ""))
91065
+ cutoff_ts = str(terminal.get("ts") or "")
91066
+ cutoff_time = parse_job_timestamp(cutoff_ts)
91067
+ # now_iso() truncates server events to whole seconds; native Claude
91068
+ # records retain milliseconds. Only an exact completed-message UUID can
91069
+ # disambiguate another turn in that same second. Keep legacy text-only
91070
+ # matching and explicitly fractional timestamps on the strict cutoff.
91071
+ cutoff_second_is_truncated = bool(recorded_uuid and re.fullmatch(
91072
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", cutoff_ts,
91073
+ ))
90095
91074
  started = next((
90096
91075
  event for event in reversed(events[:-1])
90097
91076
  if event.get("type") == "turn_started" and str(event.get("run_id") or "") == run_id
@@ -90112,7 +91091,11 @@ def claude_completed_fork_boundary(
90112
91091
  if not record_uuid or (recorded_uuid and record_uuid != recorded_uuid):
90113
91092
  continue
90114
91093
  record_time = parse_job_timestamp(str(record.get("timestamp") or ""))
90115
- if cutoff_time is None or record_time is None or record_time > cutoff_time:
91094
+ if cutoff_time is None or record_time is None:
91095
+ continue
91096
+ if record_time > cutoff_time and not (
91097
+ cutoff_second_is_truncated and record_time < cutoff_time + 1
91098
+ ):
90116
91099
  continue
90117
91100
  if not recorded_uuid and (start_time is None or record_time < start_time):
90118
91101
  continue
@@ -90145,6 +91128,8 @@ async def fork_session(session_id: str, req: ForkSessionRequest) -> dict[str, An
90145
91128
  async with session_lifecycle_lock(session_id):
90146
91129
  try:
90147
91130
  ensure_session_not_deleting(session_id)
91131
+ if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_GEMINI:
91132
+ raise HTTPException(409, "Gemini native fork is not available; create a new chat instead.")
90148
91133
  if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_OPENCODE:
90149
91134
  raise HTTPException(status_code=409, detail="OpenCode native chat fork is unavailable; no transcript-copy fallback is enabled.")
90150
91135
  async with ACTIVE_LOCK:
@@ -90557,8 +91542,123 @@ async def _fork_session_locked(
90557
91542
  return {"session": public_session(child), "sessions": [public_session(sess) for sess in ordered_sessions]}
90558
91543
 
90559
91544
 
91545
+ TURN_RECEIPT_TASKS: dict[tuple[str, str], tuple[str, asyncio.Task]] = {}
91546
+
91547
+
91548
+ def turn_receipt_path(session_id: str, request_id: str) -> Path:
91549
+ return events_path(session_id).parent / "turn-receipts" / f"{request_id}.json"
91550
+
91551
+
91552
+ def write_turn_receipt(path: Path, value: dict) -> None:
91553
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
91554
+ temporary = path.with_suffix(".tmp")
91555
+ with temporary.open("w") as stream:
91556
+ json.dump(value, stream, separators=(",", ":"))
91557
+ stream.flush()
91558
+ os.fsync(stream.fileno())
91559
+ temporary.replace(path)
91560
+ descriptor = os.open(path.parent, os.O_RDONLY)
91561
+ try:
91562
+ os.fsync(descriptor)
91563
+ finally:
91564
+ os.close(descriptor)
91565
+
91566
+
91567
+ def recover_turn_receipt(session_id: str, value: dict) -> dict | None:
91568
+ path = events_path(session_id)
91569
+ stamp = history_file_stamp(path)
91570
+ before = value.get("journal")
91571
+ offset = before[2] if before else 0
91572
+ if stamp is None:
91573
+ if before is not None:
91574
+ raise ValueError("send receipt journal disappeared")
91575
+ return None
91576
+ if before and (stamp[:2] != tuple(before[:2]) or stamp[2] < offset):
91577
+ raise ValueError("send receipt journal was replaced")
91578
+ # An ambiguous old receipt must not cause an unbounded scan or a resend.
91579
+ if stamp[2] - offset > 32 * 1024 * 1024:
91580
+ raise ValueError("send receipt recovery requires a larger journal scan")
91581
+ with path.open("rb") as stream:
91582
+ stream.seek(offset)
91583
+ while stream.tell() < stamp[2]:
91584
+ line = stream.readline(stamp[2] - stream.tell())
91585
+ if not line.endswith(b"\n"):
91586
+ raise ValueError("send receipt journal has an incomplete record")
91587
+ if b'"client_request_id"' not in line:
91588
+ continue
91589
+ event = json.loads(line)
91590
+ if event.get("client_request_id") != value["request_id"]:
91591
+ continue
91592
+ if event.get("type") == "turn_started":
91593
+ return {"run_id": event["run_id"], "queued": False}
91594
+ if event.get("type") == "turn_queued":
91595
+ return {"queued_id": event["queued_id"], "queued": True,
91596
+ "position": event.get("position", 1)}
91597
+ return None
91598
+
91599
+
91600
+ async def execute_receipted_turn(session_id: str, req: TurnRequest, fingerprint: str) -> dict:
91601
+ path = turn_receipt_path(session_id, req.client_request_id)
91602
+ try:
91603
+ value = await asyncio.to_thread(lambda: json.loads(path.read_text()))
91604
+ except FileNotFoundError:
91605
+ value = {"request_id": req.client_request_id, "fingerprint": fingerprint,
91606
+ "journal": history_file_stamp(events_path(session_id)), "status": "pending"}
91607
+ await asyncio.to_thread(write_turn_receipt, path, value)
91608
+ else:
91609
+ if value.get("fingerprint") != fingerprint:
91610
+ raise HTTPException(status_code=409, detail="request ID was already used for a different message")
91611
+ if value.get("status") == "accepted":
91612
+ return {**value["response"], "session": public_session(STORE.sessions[session_id])}
91613
+ try:
91614
+ recovered = await asyncio.to_thread(recover_turn_receipt, session_id, value)
91615
+ except (ValueError, OSError) as exc:
91616
+ raise HTTPException(status_code=409, detail="message acceptance is uncertain; retain this request ID") from exc
91617
+ if recovered is not None:
91618
+ recovered["session"] = public_session(STORE.sessions[session_id])
91619
+ value.update(status="accepted", response=recovered)
91620
+ await asyncio.to_thread(write_turn_receipt, path, value)
91621
+ return recovered
91622
+ response = await post_turn_without_receipt(session_id, req)
91623
+ value.update(status="accepted", response=response)
91624
+ await asyncio.to_thread(write_turn_receipt, path, value)
91625
+ return response
91626
+
91627
+
91628
+ async def post_turn_with_receipt(session_id: str, req: TurnRequest) -> Any:
91629
+ if session_id not in STORE.sessions or session_id in DELETED_SESSION_TOMBSTONES:
91630
+ raise HTTPException(status_code=404, detail="session not found")
91631
+ fingerprint = hashlib.sha256(json.dumps(req.model_dump(mode="json"), sort_keys=True,
91632
+ separators=(",", ":")).encode()).hexdigest()
91633
+ key = (session_id, req.client_request_id)
91634
+ existing = TURN_RECEIPT_TASKS.get(key)
91635
+ if existing is not None and existing[0] != fingerprint:
91636
+ raise HTTPException(status_code=409, detail="request ID was already used for a different message")
91637
+ if existing is None:
91638
+ task = asyncio.create_task(execute_receipted_turn(session_id, req, fingerprint))
91639
+ TURN_RECEIPT_TASKS[key] = (fingerprint, task)
91640
+ def completed(done):
91641
+ TURN_RECEIPT_TASKS.pop(key, None)
91642
+ if not done.cancelled():
91643
+ done.exception()
91644
+ task.add_done_callback(completed)
91645
+ else:
91646
+ task = existing[1]
91647
+ try:
91648
+ return await asyncio.wait_for(asyncio.shield(task), timeout=10)
91649
+ except asyncio.TimeoutError:
91650
+ return JSONResponse(status_code=202, content={"request_id": req.client_request_id,
91651
+ "acceptance": "pending", "retry_same_request": True})
91652
+
91653
+
90560
91654
  @app.post("/api/sessions/{session_id}/turns")
90561
- async def post_turn(session_id: str, req: TurnRequest) -> dict[str, Any]:
91655
+ async def post_turn(session_id: str, req: TurnRequest) -> Any:
91656
+ if req.client_request_id is not None:
91657
+ return await post_turn_with_receipt(session_id, req)
91658
+ return await post_turn_without_receipt(session_id, req)
91659
+
91660
+
91661
+ async def post_turn_without_receipt(session_id: str, req: TurnRequest) -> dict[str, Any]:
90562
91662
  while True:
90563
91663
  try:
90564
91664
  return await start_turn(session_id, req)
@@ -94028,6 +95128,12 @@ async def stop_turn(
94028
95128
  "superseded": True,
94029
95129
  "message": "The requested run no longer owns this chat.",
94030
95130
  }
95131
+ if ((STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
95132
+ and (not active or not callable(active.get("gemini_cleanup")))):
95133
+ if _admission_ready is not None:
95134
+ _admission_ready.set()
95135
+ return {"ok": False, "stopped": False, "pending": True,
95136
+ "message": "Gemini runtime cleanup requires server-side verification after restart; this chat remains fenced."}
94031
95137
  if preserve_active_goal and codex_goal_followup_requires_native(
94032
95138
  STORE.sessions.get(session_id) or {}, active or {}, current_turn,
94033
95139
  ):
@@ -94750,7 +95856,20 @@ async def stop_turn(
94750
95856
  # The app-server process may serve multiple Codex chats and must never be
94751
95857
  # terminated as a per-chat Stop action.
94752
95858
  if proc and active.get("transport") != CODEX_TRANSPORT_APP_SERVER:
94753
- await terminate_process_tree(proc)
95859
+ if active.get("backend") == BACKEND_GEMINI:
95860
+ # Gemini owns a verified-cleanup guard. Generic tree termination
95861
+ # can kill it before startup or during verification, destroying the
95862
+ # cleanup evidence and leaving the chat permanently fenced.
95863
+ cleanup = active.get("gemini_cleanup")
95864
+ try:
95865
+ if not callable(cleanup):
95866
+ raise RuntimeError("Missing Gemini cleanup owner")
95867
+ await cleanup()
95868
+ except Exception:
95869
+ return {"ok": False, "stopped": False, "pending": True,
95870
+ "message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
95871
+ else:
95872
+ await terminate_process_tree(proc)
94754
95873
  if transition_ready is not None:
94755
95874
  try:
94756
95875
  await asyncio.wait_for(
@@ -94818,6 +95937,18 @@ async def stop_turn(
94818
95937
  and not owner_task.done()
94819
95938
  ):
94820
95939
  dynamic_tasks.add(owner_task)
95940
+ if (current is active and active.get("backend") == BACKEND_GEMINI
95941
+ and active.get("gemini_cleanup_failed") and not dynamic_tasks):
95942
+ try:
95943
+ await active["gemini_cleanup"]()
95944
+ from gemini_runner import clear_cleanup_fence
95945
+ await clear_cleanup_fence(sys.modules[__name__], session_id, str(active.get("run_id") or ""))
95946
+ except Exception:
95947
+ return {"ok": False, "stopped": False, "pending": True,
95948
+ "message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
95949
+ active["gemini_cleanup_failed"] = False
95950
+ await reconcile_provider_task_exit(session_id, str(active.get("run_id") or ""), BACKEND_GEMINI)
95951
+ return {"ok": True, "stopped": True, "pending": False}
94821
95952
  if current is not active:
94822
95953
  if not goal_pause_ok:
94823
95954
  return await terminal_goal_pause_failure_result(native_interrupt)
@@ -95663,6 +96794,17 @@ async def session_terminal(
95663
96794
  await ws.close()
95664
96795
 
95665
96796
 
96797
+ EVENT_STREAM_HEARTBEAT_SECONDS = 15.0
96798
+
96799
+
96800
+ async def send_event_stream_status(ws: WebSocket, session_id: str, phase: str) -> None:
96801
+ # Ephemeral, opt-in control frames: never persisted or assigned a seq.
96802
+ await asyncio.wait_for(ws.send_json({
96803
+ "type": "event_stream_status", "version": 1,
96804
+ "session_id": session_id, "phase": phase,
96805
+ }), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
96806
+
96807
+
95666
96808
  @app.websocket("/api/sessions/{session_id}/events")
95667
96809
  async def session_events(
95668
96810
  session_id: str,
@@ -95671,6 +96813,8 @@ async def session_events(
95671
96813
  visible: bool | None = None,
95672
96814
  reasoning_stream: bool = False,
95673
96815
  reasoning_text: bool = False,
96816
+ liveness: bool = False,
96817
+ history_projection: bool = False,
95674
96818
  ) -> None:
95675
96819
  selected_subprotocol = websocket_endpoint_subprotocol(
95676
96820
  ws,
@@ -95685,6 +96829,7 @@ async def session_events(
95685
96829
  await ws.close(code=4404)
95686
96830
  return
95687
96831
  await ws.accept(subprotocol=selected_subprotocol)
96832
+ schedule_provider_history_sync(STORE.sessions[session_id])
95688
96833
  try:
95689
96834
  if not await HUB.reserve(session_id, ws):
95690
96835
  await close_event_websocket_over_capacity(ws)
@@ -95700,7 +96845,6 @@ async def session_events(
95700
96845
  # is ready. Reading its fsynced rows earlier can replay raw scheduled
95701
96846
  # inputs before this socket is registered for the repaired broadcast.
95702
96847
  async with event_delivery_lock(session_id):
95703
- await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
95704
96848
  boundary = await asyncio.to_thread(
95705
96849
  last_event_seq_from_file,
95706
96850
  events_path(session_id),
@@ -95751,12 +96895,28 @@ async def session_events(
95751
96895
  return
95752
96896
  if reasoning_snapshot is not None:
95753
96897
  await asyncio.wait_for(ws.send_json(reasoning_snapshot), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
96898
+ if liveness:
96899
+ await send_event_stream_status(ws, session_id, "ready")
96900
+ if history_projection:
96901
+ # A proof may finish between the HTTP page and subscription.
96902
+ # The revision closes that gap without replaying old rows.
96903
+ await asyncio.wait_for(ws.send_json(history_projection_notice(session_id)), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
95754
96904
  break
95755
96905
  boundary = gap_boundary
95756
96906
  while True:
95757
- await ws.receive_text()
96907
+ if not liveness:
96908
+ await ws.receive_text()
96909
+ continue
96910
+ try:
96911
+ await asyncio.wait_for(ws.receive_text(), timeout=EVENT_STREAM_HEARTBEAT_SECONDS)
96912
+ except asyncio.TimeoutError:
96913
+ # No history scan, provider call, or sequence acknowledgement.
96914
+ # This confirms that the application event path is still alive.
96915
+ await send_event_stream_status(ws, session_id, "heartbeat")
95758
96916
  except WebSocketDisconnect:
95759
96917
  pass
96918
+ except (asyncio.TimeoutError, ConnectionError):
96919
+ await close_event_websocket_after_delivery_failure(ws)
95760
96920
  finally:
95761
96921
  await HUB.unsubscribe(session_id, ws)
95762
96922