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

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.
@@ -126,6 +126,7 @@ from provider_commands import (
126
126
  claude_provider_command_inventory,
127
127
  codex_provider_command_inventory,
128
128
  empty_provider_command_inventory,
129
+ gemini_provider_skill_inventory,
129
130
  opencode_provider_skill_inventory,
130
131
  validate_opencode_provider_skill_record,
131
132
  )
@@ -208,7 +209,8 @@ BACKEND_CLAUDE = "claude"
208
209
  BACKEND_CODEX = "codex"
209
210
  BACKEND_CURSOR = "cursor"
210
211
  BACKEND_OPENCODE = "opencode"
211
- 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}
212
214
  CODEX_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
213
215
  CODEX_EFFORT_ALIASES = {
214
216
  "extra high": "xhigh",
@@ -662,6 +664,7 @@ def codex_effective_thread_config(sess: dict[str, Any]) -> dict[str, Any]:
662
664
  CODEX_GOALS_ENABLED = read_codex_goals_enabled()
663
665
 
664
666
  CODEX_FALLBACK_MODELS = (
667
+ ("gpt-6-luna", "GPT-6-Luna"),
665
668
  ("gpt-5.6-sol", "GPT-5.6-Sol"),
666
669
  ("gpt-5.6-terra", "GPT-5.6-Terra"),
667
670
  ("gpt-5.6-luna", "GPT-5.6-Luna"),
@@ -672,6 +675,7 @@ CODEX_FALLBACK_MODELS = (
672
675
  CODEX_FALLBACK_EFFORTS = ("low", "medium", "high", "xhigh", "max", "ultra")
673
676
  CODEX_EFFORT_ORDER = ("none", "minimal", *CODEX_FALLBACK_EFFORTS)
674
677
  CODEX_FALLBACK_MODEL_EFFORTS = {
678
+ "gpt-6-luna": ("low", "medium", "high", "xhigh", "max"),
675
679
  "gpt-5.6-sol": ("low", "medium", "high", "xhigh", "max", "ultra"),
676
680
  "gpt-5.6-terra": ("low", "medium", "high", "xhigh", "max", "ultra"),
677
681
  "gpt-5.6-luna": ("low", "medium", "high", "xhigh", "max"),
@@ -1301,6 +1305,7 @@ MAX_LOCAL_SESSION_SCAN_FILES = 10_000
1301
1305
  # Retained text/metadata budget. Opaque native media/tool bytes are streamed.
1302
1306
  # Private timeline/index readers still use this as their raw record bound.
1303
1307
  MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 32 * 1024 * 1024
1308
+ HISTORY_SYNC_BATCH_BYTES = 16 * 1024 * 1024
1304
1309
  MAX_LOCAL_TRANSCRIPT_SCAN_LINES = 250_000
1305
1310
  MAX_CODEX_SESSION_INDEX_BYTES = 16 * 1024 * 1024
1306
1311
  MAX_CODEX_SESSION_INDEX_LINES = 100_000
@@ -6317,6 +6322,7 @@ class CreateSessionRequest(BaseModel):
6317
6322
  # Same three names as Cursor, but "default" means OpenCode's own
6318
6323
  # permissions, which do allow shell.
6319
6324
  opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
6325
+ gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
6320
6326
 
6321
6327
 
6322
6328
  MAX_BULK_IMPORT_ITEMS = 25
@@ -6328,9 +6334,10 @@ PROVIDER_SESSION_IDENTIFIER_RE = re.compile(
6328
6334
 
6329
6335
  class BulkImportSessionItem(BaseModel):
6330
6336
  provider_session_id: str = Field(min_length=1, max_length=256)
6331
- backend: Literal["claude", "codex", "cursor"]
6337
+ backend: Literal["claude", "codex", "cursor", "gemini"]
6332
6338
  cwd: str | None = Field(default=None, max_length=MAX_WORKSPACE_PATH_CHARS)
6333
6339
  title: str | None = Field(default=None, max_length=MAX_LOCAL_SESSION_LABEL_CHARS)
6340
+ provider_connection: Literal["custom"] | None = None
6334
6341
 
6335
6342
  @field_validator("provider_session_id")
6336
6343
  @classmethod
@@ -6354,6 +6361,7 @@ class BulkImportSessionsRequest(BaseModel):
6354
6361
 
6355
6362
 
6356
6363
  class UpdateSessionRequest(BaseModel):
6364
+ gemini_connect_api: bool | None = Field(default=None, strict=True)
6357
6365
  title: str | None = None
6358
6366
  auto_title_enabled: bool | None = None
6359
6367
  folder: str | None = None
@@ -6382,6 +6390,7 @@ class UpdateSessionRequest(BaseModel):
6382
6390
  provider_jobs_access: Literal["full", "read_only", "blocked"] | None = None
6383
6391
  cursor_permission_mode: Literal["default", "full_access", "plan"] | None = None
6384
6392
  opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
6393
+ gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
6385
6394
 
6386
6395
 
6387
6396
  SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
@@ -6400,6 +6409,7 @@ SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
6400
6409
  "provider_jobs_access",
6401
6410
  "cursor_permission_mode",
6402
6411
  "opencode_permission_mode",
6412
+ "gemini_permission_mode",
6403
6413
  "archived",
6404
6414
  })
6405
6415
 
@@ -6549,12 +6559,25 @@ class EmergencyAlertRequest(BaseModel):
6549
6559
  class AcknowledgeEmergencyRequest(BaseModel):
6550
6560
  model_config = {"extra": "forbid"}
6551
6561
 
6552
- 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,
6553
6570
  min_length=8,
6554
6571
  max_length=80,
6555
6572
  pattern=r"^emergency_[0-9a-f]{32}$",
6556
6573
  )
6557
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
+
6558
6581
 
6559
6582
  class ChatReference(BaseModel):
6560
6583
  model_config = {"extra": "forbid"}
@@ -6723,6 +6746,7 @@ class ProviderCommandSelectionUnavailable(HTTPException):
6723
6746
 
6724
6747
  class TurnRequest(BaseModel):
6725
6748
  prompt: str
6749
+ client_request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,128}$")
6726
6750
  # Server-derived attribution only; the guest router accepts neither field
6727
6751
  # from its JSON body and never accepts arbitrary TurnRequest controls.
6728
6752
  shared_chat_id: str | None = Field(default=None, pattern=r"^interactive_[a-f0-9]{32}$")
@@ -7035,6 +7059,10 @@ def normalize_runtime_effort(backend: str, effort: Any, *, strict: bool = False)
7035
7059
  clean = str(effort or "").strip().lower()
7036
7060
  if not clean:
7037
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
7038
7066
  if str(backend or "").strip().lower() != BACKEND_CODEX:
7039
7067
  return clean
7040
7068
 
@@ -7180,7 +7208,7 @@ def preview_session_runtime_update(
7180
7208
 
7181
7209
  current_connection = sess.get("provider_connection") or "default"
7182
7210
  connection = (patch.get("provider_connection") or "default") if "provider_connection" in patch else "default" if backend_changed else current_connection
7183
- 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}:
7184
7212
  raise HTTPException(400, "This backend does not support that API connection.")
7185
7213
  connection_changed = current_connection != connection
7186
7214
  if connection_changed and session_backend_locked(sess):
@@ -7192,11 +7220,21 @@ def preview_session_runtime_update(
7192
7220
  if "model" in patch
7193
7221
  else None if backend_changed or provider_changed else sess.get("model")
7194
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))
7195
7226
  prospective_effort = (
7196
7227
  patch.get("effort")
7197
7228
  if "effort" in patch
7198
7229
  else None if backend_changed or provider_changed else sess.get("effort")
7199
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
+ )
7200
7238
  normalized_effort = (
7201
7239
  normalize_runtime_effort(prospective_backend, prospective_effort, strict="effort" in patch)
7202
7240
  if prospective_provider == "custom" else
@@ -7215,7 +7253,7 @@ def preview_session_runtime_update(
7215
7253
  preview["model"] = prospective_model
7216
7254
  preview["effort"] = normalized_effort
7217
7255
  preview["provider_connection"] = connection
7218
- if connection_changed or connection == "default":
7256
+ if backend_changed or connection_changed or connection == "default":
7219
7257
  preview.pop("provider_connection_revision", None)
7220
7258
  if connection == "custom":
7221
7259
  selected_connection = PROVIDER_CONNECTION_STORE.bind(preview)
@@ -7237,6 +7275,7 @@ def preview_session_runtime_update(
7237
7275
  BACKEND_CODEX: "codex_thread_id",
7238
7276
  BACKEND_CURSOR: "cursor_session_id",
7239
7277
  BACKEND_OPENCODE: "opencode_session_id",
7278
+ BACKEND_GEMINI: "gemini_session_id",
7240
7279
  }[prospective_backend]
7241
7280
  preview["session_id"] = sess.get(provider_id_field)
7242
7281
  return preview
@@ -9628,35 +9667,66 @@ def normalized_emergency_alert(value: Any) -> dict[str, Any] | None:
9628
9667
  }
9629
9668
 
9630
9669
 
9631
- def reversed_jsonl_events(path: Path) -> Iterator[dict[str, Any]]:
9632
- """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
+ """
9633
9679
 
9634
9680
  if not path.exists():
9635
9681
  return
9636
9682
  chunk_size = 256 * 1024
9637
9683
  with path.open("rb") as source:
9638
9684
  source.seek(0, os.SEEK_END)
9639
- position = source.tell()
9640
- 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
+
9641
9709
  while position > 0:
9642
9710
  read_size = min(chunk_size, position)
9643
9711
  position -= read_size
9644
9712
  source.seek(position)
9645
- block = source.read(read_size) + remainder
9646
- lines = block.split(b"\n")
9647
- remainder = lines[0]
9648
- for raw_line in reversed(lines[1:]):
9649
- if not raw_line.strip():
9650
- continue
9651
- with suppress(Exception):
9652
- event = json.loads(raw_line.decode("utf-8", "replace"))
9653
- if isinstance(event, dict):
9654
- yield event
9655
- if remainder.strip():
9656
- with suppress(Exception):
9657
- event = json.loads(remainder.decode("utf-8", "replace"))
9658
- 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:
9659
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
+ })
9660
9730
 
9661
9731
 
9662
9732
  def emergency_lifecycle_events_after(
@@ -9667,7 +9737,9 @@ def emergency_lifecycle_events_after(
9667
9737
 
9668
9738
  newest_seq = max(0, int(after_seq or 0))
9669
9739
  events: list[dict[str, Any]] = []
9670
- 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
+ ):
9671
9743
  seq = int(event.get("seq") or 0)
9672
9744
  newest_seq = max(newest_seq, seq)
9673
9745
  if seq <= after_seq:
@@ -10158,6 +10230,9 @@ class SessionStore:
10158
10230
  if reconcile_session_emergency_alerts(session_id, sess):
10159
10231
  runtime_changed = True
10160
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
10161
10236
  claude_permission_mode = effective_claude_permission_mode(sess)
10162
10237
  if sess.get("claude_permission_mode") != claude_permission_mode:
10163
10238
  sess["claude_permission_mode"] = claude_permission_mode
@@ -10522,13 +10597,25 @@ class SessionStore:
10522
10597
  if initializing_fork and parent_id and req.provider_connection == "custom":
10523
10598
  parent = self.sessions.get(parent_id) or {}
10524
10599
  runtime_source.update({name: parent.get(name) for name in ("provider_connection", "provider_connection_revision")})
10525
- 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)):
10526
10601
  raise HTTPException(409, "Import native conversations with their native login. Create a new chat for a custom API.")
10527
10602
  runtime = preview_session_runtime_update(runtime_source, {
10528
10603
  "codex_provider": req.codex_provider, "provider_connection": req.provider_connection, "model": req.model, "effort": req.effort,
10529
10604
  })
10530
10605
  model, effort = runtime["model"], runtime["effort"]
10531
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())
10532
10619
  if backend == BACKEND_OPENCODE:
10533
10620
  session_cwd = validated_opencode_cwd(
10534
10621
  session_cwd,
@@ -10565,6 +10652,7 @@ class SessionStore:
10565
10652
  claude_session_id if backend == BACKEND_CLAUDE
10566
10653
  else codex_thread_id if backend == BACKEND_CODEX
10567
10654
  else cursor_session_id if backend == BACKEND_CURSOR
10655
+ else provider_id if backend == BACKEND_GEMINI
10568
10656
  else opencode_session_id
10569
10657
  )
10570
10658
  # A Claude chat can park an inactive Codex identity for later backend
@@ -10631,6 +10719,9 @@ class SessionStore:
10631
10719
  "claude_session_id": claude_session_id,
10632
10720
  "codex_thread_id": codex_thread_id,
10633
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,
10634
10725
  "opencode_session_id": opencode_session_id,
10635
10726
  "opencode_session_cwd": (
10636
10727
  session_cwd
@@ -10650,6 +10741,7 @@ class SessionStore:
10650
10741
  "opencode_permission_mode": (
10651
10742
  req.opencode_permission_mode or OPENCODE_DEFAULT_PERMISSION_MODE
10652
10743
  ),
10744
+ "gemini_permission_mode": req.gemini_permission_mode or "default",
10653
10745
  "codex_approval_policy": (
10654
10746
  req.codex_approval_policy or CODEX_DEFAULT_APPROVAL_POLICY
10655
10747
  ),
@@ -10846,6 +10938,7 @@ class SessionStore:
10846
10938
  if patch.get("auto_title_enabled") is not None:
10847
10939
  sess["auto_title_enabled"] = bool(patch["auto_title_enabled"])
10848
10940
  for key, default, allowed in (
10941
+ ("gemini_permission_mode", "default", {"default", "autoEdit", "yolo", "plan"}),
10849
10942
  (
10850
10943
  "claude_permission_mode",
10851
10944
  CLAUDE_DEFAULT_PERMISSION_MODE,
@@ -10921,14 +11014,15 @@ class SessionStore:
10921
11014
  sess["subagent_limit"] = patch["subagent_limit"]
10922
11015
  if "system_prompt" in patch:
10923
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)
10924
11019
  for key in ("model", "effort"):
10925
11020
  if key in patch:
10926
11021
  value = patch[key]
10927
11022
  if key == "effort":
10928
11023
  sess[key] = normalized_prospective_effort
10929
11024
  else:
10930
- clean = str(value).strip() if value is not None else ""
10931
- sess[key] = clean or None
11025
+ sess[key] = runtime_preview["model"]
10932
11026
  if reset_implicit_incompatible_effort:
10933
11027
  # Older clients update the model and effort in separate
10934
11028
  # requests. Let that model change succeed while clearing a
@@ -11048,7 +11142,8 @@ class SessionStore:
11048
11142
  }
11049
11143
  sess.update(title=title, _title_source=source, _title_auto_value=title)
11050
11144
  if source == "prompt" and isinstance(prompt_seed, str):
11051
- 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])
11052
11147
  elif source in {"provider", "generated"}:
11053
11148
  sess.pop("_title_seed", None)
11054
11149
  sess["updated_at"] = now_iso()
@@ -11185,9 +11280,18 @@ class SessionStore:
11185
11280
  sid: str,
11186
11281
  event: dict[str, Any],
11187
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
+ )
11188
11292
  async with self._lock:
11189
11293
  sess = self.sessions.get(sid)
11190
- if not sess:
11294
+ if not sess or sess is not snapshot:
11191
11295
  raise HTTPException(status_code=404, detail="session not found")
11192
11296
  previous_present = "_emergency_alerts" in sess
11193
11297
  previous = sess.get("_emergency_alerts")
@@ -11197,11 +11301,10 @@ class SessionStore:
11197
11301
  0,
11198
11302
  int(sess.get("_emergency_reconciled_through_seq") or 0),
11199
11303
  )
11200
- lifecycle_events, reconciled_through = await asyncio.to_thread(
11201
- emergency_lifecycle_events_after,
11202
- sid,
11203
- previous_reconciled_through,
11204
- )
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]
11205
11308
  event_seq = int(event.get("seq") or 0)
11206
11309
  if (
11207
11310
  event_seq > previous_reconciled_through
@@ -11372,6 +11475,9 @@ class SessionStore:
11372
11475
  sess.pop("cursor_instruction_version", None)
11373
11476
  elif backend == BACKEND_OPENCODE:
11374
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
11375
11481
  if backend == BACKEND_CLAUDE and cwd:
11376
11482
  sess["claude_session_cwd"] = cwd
11377
11483
  if backend == BACKEND_OPENCODE and cwd:
@@ -13064,6 +13170,9 @@ class JobStore:
13064
13170
  client_capabilities=[
13065
13171
  ASYNC_ROUTE_V1_CLIENT_CAPABILITY,
13066
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 []),
13067
13176
  ],
13068
13177
  chat_references=chat_references,
13069
13178
  team_references=team_references,
@@ -14083,6 +14192,8 @@ RUN_NOW_TURNS: dict[str, dict[str, Any]] = {}
14083
14192
  # that the scan has not discovered yet. A completed failed task intentionally
14084
14193
  # remains installed and keeps admission fail-closed.
14085
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()
14086
14197
  QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS = 2.0
14087
14198
  STEERING_SESSIONS: set[str] = set()
14088
14199
  # A non-native Force Send keeps the steering fence while the interrupted
@@ -16543,6 +16654,7 @@ HANDOFF_DIGEST_FINALIZING: set[str] = set()
16543
16654
  RUNTIME_DIAGNOSTICS: dict[str, dict[str, Any]] = {}
16544
16655
  RUNTIME_DIAGNOSTICS_LOCK = threading.RLock()
16545
16656
  RUNTIME_DIAGNOSTIC_GENERATIONS: dict[str, int] = {}
16657
+ CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
16546
16658
  ABANDONED_FORK_PROVIDER_THREADS: set[str] = set()
16547
16659
  ABANDONED_FORK_PROVIDER_THREADS_LOCK = asyncio.Lock()
16548
16660
 
@@ -17347,6 +17459,7 @@ def should_bump_session_updated_at(event_type: str, event: dict[str, Any]) -> bo
17347
17459
 
17348
17460
 
17349
17461
  async def update_session_event_metadata(session_id: str, event: dict[str, Any]) -> None:
17462
+ QUEUE_CHECKPOINT_DIRTY.add(session_id)
17350
17463
  sess = STORE.sessions.get(session_id)
17351
17464
  if not sess:
17352
17465
  return
@@ -17474,7 +17587,22 @@ async def update_session_event_metadata(session_id: str, event: dict[str, Any])
17474
17587
  await STORE.save(flush=False)
17475
17588
 
17476
17589
 
17477
- 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:
17478
17606
  """Wait briefly for the one startup queue scan, then fail retryably.
17479
17607
 
17480
17608
  The shared recovery task is shielded because disconnecting one request
@@ -17483,42 +17611,34 @@ async def wait_for_queue_recovery_admission() -> None:
17483
17611
  ordering hole with only a partial in-memory queue.
17484
17612
  """
17485
17613
 
17614
+ if session_id is not None and session_id in QUEUE_RECOVERY_READY:
17615
+ return
17486
17616
  recovery = QUEUE_RECOVERY_TASK
17487
17617
  if recovery is None or recovery is asyncio.current_task():
17488
17618
  return
17489
- try:
17490
- await asyncio.wait_for(
17491
- asyncio.shield(recovery),
17492
- timeout=max(0.0, QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS),
17493
- )
17494
- 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:
17495
17625
  raise TransientAdmissionWait(
17496
17626
  status_code=503,
17497
- detail=(
17498
- "queued turn recovery is still in progress; retry this "
17499
- "request shortly"
17500
- ),
17501
- ) from exc
17627
+ detail="queued turn recovery is still in progress; retry this request shortly",
17628
+ )
17629
+ try:
17630
+ recovery.result()
17502
17631
  except asyncio.CancelledError:
17503
- # Caller cancellation propagates normally. If the shared task itself
17504
- # was cancelled outside shutdown, convert that failed recovery into a
17505
- # retryable fail-closed admission result.
17506
- if recovery.cancelled() and not SERVER_SHUTTING_DOWN:
17507
- raise TransientAdmissionWait(
17508
- status_code=503,
17509
- detail=(
17510
- "queued turn recovery did not complete; restart or retry "
17511
- "the managed server"
17512
- ),
17513
- ) from None
17514
- 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
17515
17638
  except Exception as exc:
17516
17639
  raise TransientAdmissionWait(
17517
17640
  status_code=503,
17518
- detail=(
17519
- "queued turn recovery did not complete; restart or retry the "
17520
- "managed server"
17521
- ),
17641
+ detail="queued turn recovery did not complete; restart the managed server; if it repeats, inspect server logs",
17522
17642
  ) from exc
17523
17643
 
17524
17644
 
@@ -17531,7 +17651,7 @@ async def enqueue_turn(
17531
17651
  secure_peer_route_snapshots: list[dict[str, Any]] | None = None,
17532
17652
  reciprocal_route_grant: dict[str, Any] | None = None,
17533
17653
  ) -> dict[str, Any]:
17534
- await wait_for_queue_recovery_admission()
17654
+ await wait_for_queue_recovery_admission(session_id)
17535
17655
  if not routed_references_match_visible_prompt(
17536
17656
  req.prompt,
17537
17657
  req.display_prompt,
@@ -17803,6 +17923,7 @@ async def enqueue_turn(
17803
17923
  ),
17804
17924
  "prompt": display_prompt,
17805
17925
  "request_prompt": req.prompt,
17926
+ "client_request_id": getattr(req, "client_request_id", None),
17806
17927
  "display_prompt": req.display_prompt,
17807
17928
  "file_ids": list(req.file_ids),
17808
17929
  "position": position,
@@ -18068,7 +18189,7 @@ async def enqueue_turn(
18068
18189
 
18069
18190
 
18070
18191
  async def unqueue_turn(session_id: str, queued_id: str) -> dict[str, Any]:
18071
- await wait_for_queue_recovery_admission()
18192
+ await wait_for_queue_recovery_admission(session_id)
18072
18193
  if session_id not in STORE.sessions:
18073
18194
  raise HTTPException(status_code=404, detail="session not found")
18074
18195
 
@@ -20732,7 +20853,7 @@ def provider_tool_active_matches(
20732
20853
  expected_transport = (
20733
20854
  CODEX_TRANSPORT_APP_SERVER
20734
20855
  if backend == BACKEND_CODEX
20735
- else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE}
20856
+ else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
20736
20857
  else CLAUDE_TRANSPORT_AGENT_SDK
20737
20858
  )
20738
20859
  matches = bool(
@@ -20757,7 +20878,7 @@ def provider_tool_active_matches(
20757
20878
  and str(active.get("provider_turn_id") or "") == provider_turn_id
20758
20879
  and active.get("provider_turn_ready") is True
20759
20880
  )
20760
- elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE}:
20881
+ elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
20761
20882
  proc = active.get("proc")
20762
20883
  matches = bool(
20763
20884
  matches and cursor_owner_token
@@ -20830,7 +20951,7 @@ async def provider_tool_capability_snapshot(
20830
20951
  and codex_native_mailbox_owner_matches(session_id, run_id, provider_thread_id, provider_turn_id))
20831
20952
  ):
20832
20953
  raise ProviderToolError("provider tool turn is stale")
20833
- elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE}:
20954
+ elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
20834
20955
  raise ProviderToolError("provider tool backend is unsupported")
20835
20956
 
20836
20957
  async with ACTIVE_LOCK:
@@ -22494,7 +22615,7 @@ def public_queued_turn(
22494
22615
 
22495
22616
 
22496
22617
  async def queued_turns_snapshot(session_id: str) -> list[dict[str, Any]]:
22497
- await wait_for_queue_recovery_admission()
22618
+ await wait_for_queue_recovery_admission(session_id)
22498
22619
  async with QUEUE_LOCK:
22499
22620
  queue = list(QUEUED_TURNS.get(session_id) or [])
22500
22621
  run_now = RUN_NOW_TURNS.get(session_id)
@@ -22554,7 +22675,7 @@ def reject_promoted_queue_mutation(session_id: str, queued_id: str) -> None:
22554
22675
 
22555
22676
 
22556
22677
  async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedTurnRequest) -> dict[str, Any]:
22557
- await wait_for_queue_recovery_admission()
22678
+ await wait_for_queue_recovery_admission(session_id)
22558
22679
  if session_id not in STORE.sessions:
22559
22680
  raise HTTPException(status_code=404, detail="session not found")
22560
22681
  async_result = await update_async_queued_message(session_id, queued_id, req)
@@ -23001,7 +23122,7 @@ async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedT
23001
23122
 
23002
23123
 
23003
23124
  async def move_queued_turn(session_id: str, queued_id: str, req: MoveQueuedTurnRequest) -> dict[str, Any]:
23004
- await wait_for_queue_recovery_admission()
23125
+ await wait_for_queue_recovery_admission(session_id)
23005
23126
  if session_id not in STORE.sessions:
23006
23127
  raise HTTPException(status_code=404, detail="session not found")
23007
23128
  direction = req.direction.strip().lower()
@@ -23516,7 +23637,7 @@ async def reconcile_idle_queued_turns(*, reason: str) -> int:
23516
23637
 
23517
23638
  async def run_queued_turn_now(session_id: str, queued_id: str) -> dict[str, Any]:
23518
23639
  """Coalesce duplicate Force Send requests without replaying a steer."""
23519
- await wait_for_queue_recovery_admission()
23640
+ await wait_for_queue_recovery_admission(session_id)
23520
23641
  ensure_session_not_deleting(session_id)
23521
23642
  await reconcile_idle_queue_session(
23522
23643
  session_id,
@@ -25161,7 +25282,7 @@ def schedule_steered_turn_slot_waiter(
25161
25282
 
25162
25283
 
25163
25284
  async def start_next_queued_turn(session_id: str) -> None:
25164
- await wait_for_queue_recovery_admission()
25285
+ await wait_for_queue_recovery_admission(session_id)
25165
25286
  await reconcile_idle_queue_session(
25166
25287
  session_id,
25167
25288
  schedule=False,
@@ -25803,14 +25924,78 @@ def queued_turn_from_event(event: dict[str, Any], sess: dict[str, Any], position
25803
25924
  }
25804
25925
 
25805
25926
 
25806
- 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]:
25807
25929
  with path.open("rb") as source:
25808
- 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()
25809
25943
  if (b'"queued_id"' in raw_line or b'"turn_queue_paused"' in raw_line
25810
25944
  or (include_envelopes and b'"cross_chat_envelope_id"' in raw_line)):
25811
25945
  yield raw_line
25812
25946
 
25813
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
+
25814
25999
  def record_mailbox_migration_evidence(evidence: dict[str, dict[str, Any]], event: dict[str, Any]) -> None:
25815
26000
  item = evidence.get(str(event.get("cross_chat_envelope_id") or ""))
25816
26001
  if item is None:
@@ -25844,11 +26029,13 @@ def scan_queued_turns_from_events(
25844
26029
  value["complete"] = False
25845
26030
  if not path.exists():
25846
26031
  continue
25847
- proof_stamp = path.stat() if evidence else None
26032
+ scan_stamp = path.stat()
26033
+ proof_stamp = scan_stamp if evidence else None
25848
26034
  proof_complete = True
25849
- pending: dict[str, dict[str, Any]] = {}
25850
- order: list[str] = []
25851
- 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):
25852
26039
  try:
25853
26040
  event = json.loads(raw_line.decode("utf-8", "replace"))
25854
26041
  except Exception:
@@ -26008,7 +26195,9 @@ def scan_queued_turns_from_events(
26008
26195
  unchanged = ((proof_stamp.st_dev, proof_stamp.st_ino, proof_stamp.st_size, proof_stamp.st_mtime_ns)
26009
26196
  == (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns))
26010
26197
  for value in evidence.values():
26011
- 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"]
26012
26201
  items = [
26013
26202
  pending[qid]
26014
26203
  for qid in order
@@ -26019,6 +26208,9 @@ def scan_queued_turns_from_events(
26019
26208
  ]
26020
26209
  if items:
26021
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])
26022
26214
  return recovered
26023
26215
 
26024
26216
 
@@ -26182,13 +26374,48 @@ async def migrate_unstarted_chat_mailbox_backlog(
26182
26374
  return migrated
26183
26375
 
26184
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
+
26185
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]:
26186
26418
  started = time.monotonic()
26187
- session_items = [
26188
- (session_id, dict(sess))
26189
- for session_id, sess in STORE.sessions.items()
26190
- ]
26191
- migration_candidates = await CROSS_CHAT.pending_mailbox_migration_candidates()
26192
26419
  mailbox_evidence = {str(record["id"]): {"target_session_id": record["target_session_id"]}
26193
26420
  for record in migration_candidates if is_async_route_message(record)}
26194
26421
  recovered = await asyncio.to_thread(scan_queued_turns_from_events, session_items,
@@ -27577,6 +27804,40 @@ def linux_process_still_exists(
27577
27804
  return True
27578
27805
 
27579
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
+
27580
27841
  def managed_update_service_cgroup_state(
27581
27842
  *,
27582
27843
  service_cgroup: str | None = None,
@@ -27648,10 +27909,11 @@ def managed_update_service_cgroup_state(
27648
27909
  })
27649
27910
  return state
27650
27911
  unknown = tuple(sorted(service_pids - {os.getpid()}))
27912
+ preserved = bool(unknown) and systemd_worker_preserves_descendants(selected_cgroup)
27651
27913
  state.update({
27652
- "safe": not unknown,
27914
+ "safe": not unknown or preserved,
27653
27915
  "unknown_descendant_count": len(unknown),
27654
- "inspection": "verified",
27916
+ "inspection": "verified-preserved" if preserved else "verified",
27655
27917
  "_unknown_descendant_pids": unknown,
27656
27918
  })
27657
27919
  return state
@@ -29963,6 +30225,14 @@ async def reconcile_provider_task_exit(
29963
30225
  prevents delayed cleanup from touching a replacement turn.
29964
30226
  """
29965
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
29966
30236
  persisted_active = (STORE.sessions.get(session_id) or {}).get("active_run")
29967
30237
  terminal_missing = bool(
29968
30238
  isinstance(persisted_active, dict)
@@ -32373,14 +32643,14 @@ CODEX_GOAL_HISTORY_REPAIR_CACHE = CodexGoalHistoryRepairCache()
32373
32643
  CODEX_NATIVE_HISTORY_REPAIR_CACHE = CodexNativeHistoryRepairCache()
32374
32644
 
32375
32645
 
32376
- def prepare_provider_history_metadata_repair(session_id: str) -> None:
32377
- """Prepare bounded source proofs only at explicit history-read boundaries."""
32646
+ def prepare_provider_history_metadata_repair(session_id: str) -> bool | None:
32647
+ """Prepare bounded source proofs in background history maintenance."""
32378
32648
  prepare_claude_history_metadata_repair(session_id)
32379
32649
  prepare_codex_goal_history_repair(session_id)
32380
- prepare_codex_native_history_repair(session_id)
32650
+ return prepare_codex_native_history_repair(session_id)
32381
32651
 
32382
32652
 
32383
- def prepare_codex_native_history_repair(session_id: str) -> None:
32653
+ def prepare_codex_native_history_repair(session_id: str) -> bool | None:
32384
32654
  session = STORE.sessions.get(session_id)
32385
32655
  if not isinstance(session, dict) or session.get("backend") != BACKEND_CODEX or session.get("_deleting") is True:
32386
32656
  CODEX_NATIVE_HISTORY_REPAIR_CACHE.forget(session_id)
@@ -32408,11 +32678,13 @@ def prepare_codex_native_history_repair(session_id: str) -> None:
32408
32678
  session_id, provider_id, events_path(session_id), source, CODEX_SESSIONS_ROOT,
32409
32679
  lambda event: codex_history_event_item(event, expected_session_id=session_id),
32410
32680
  )
32411
- except CodexNativeHistoryProofUnavailable:
32681
+ except CodexNativeHistoryProofUnavailable as exc:
32682
+ if getattr(exc, "deterministic", False):
32683
+ raise
32412
32684
  # Incomplete evidence is retryable at a later explicit read boundary,
32413
32685
  # not a successful empty proof and not a reason to break chat opening.
32414
32686
  logger.debug("native history repair deferred session=%s", session_id)
32415
- return
32687
+ return False
32416
32688
  if changed:
32417
32689
  HISTORY_SEARCH_REPAIR_DIRTY.add(session_id)
32418
32690
  HISTORY_SEARCH_DIRTY.add(session_id)
@@ -32975,7 +33247,6 @@ def build_timeline_index(session_id: str) -> dict[str, Any]:
32975
33247
 
32976
33248
 
32977
33249
  def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
32978
- prepare_provider_history_metadata_repair(session_id)
32979
33250
  claude_metadata_signature = CLAUDE_METADATA_REPAIR_CACHE.signature(session_id)
32980
33251
  codex_goal_history_signature = (
32981
33252
  CODEX_GOAL_HISTORY_REPAIR_CACHE.signature(session_id),
@@ -33063,6 +33334,15 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
33063
33334
  if can_append
33064
33335
  else []
33065
33336
  )
33337
+ # Keep provisional turn lookup linear in the journal size. Scheduled-job
33338
+ # streams can have hundreds of thousands of events with no ordinary turn;
33339
+ # scanning every preceding landmark for each one makes cold opens quadratic.
33340
+ turn_record_keys_by_run: dict[str, list[str]] = {}
33341
+ for existing in records:
33342
+ existing_key = str(existing.get("key") or "")
33343
+ if existing_key.startswith("turn:"):
33344
+ existing_run = existing_key[5:].split(":start-", 1)[0]
33345
+ turn_record_keys_by_run.setdefault(existing_run, []).append(existing_key)
33066
33346
  dirty_record_keys: set[str] = set()
33067
33347
  active_turn_key: str | None = cached.get("active_turn_key") if can_append else None
33068
33348
  current_turn_by_run: dict[str, str] = (
@@ -33383,6 +33663,8 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
33383
33663
  }
33384
33664
  by_key[key] = record
33385
33665
  records.append(record)
33666
+ if key.startswith("turn:"):
33667
+ turn_record_keys_by_run.setdefault(key[5:].split(":start-", 1)[0], []).append(key)
33386
33668
  if advance_timeline:
33387
33669
  latest_timeline_landmark_key = key
33388
33670
  record["start_seq"] = min(int(record["start_seq"]), seq)
@@ -33849,16 +34131,10 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
33849
34131
  job_by_run[run_id] = job_id
33850
34132
  prior_key = current_turn_by_run.pop(run_id, None) if run_id else None
33851
34133
  if not prior_key and run_id:
33852
- base_turn_key = f"turn:{run_id}"
33853
- prior_key = next(
33854
- (
33855
- str(candidate.get("key") or "")
33856
- for candidate in reversed(records)
33857
- if str(candidate.get("key") or "") == base_turn_key
33858
- or str(candidate.get("key") or "").startswith(f"{base_turn_key}:start-")
33859
- ),
33860
- None,
33861
- )
34134
+ prior_keys = turn_record_keys_by_run.get(run_id, [])
34135
+ while prior_keys and prior_keys[-1] not in by_key:
34136
+ prior_keys.pop()
34137
+ prior_key = prior_keys[-1] if prior_keys else None
33862
34138
  prior = by_key.get(prior_key or "")
33863
34139
  group_key, group_created = assign_job_timeline_group(
33864
34140
  event,
@@ -38383,7 +38659,7 @@ def cross_chat_supported_target_backends() -> list[str]:
38383
38659
  # Only cached diagnostics/private metadata are read here; health must not
38384
38660
  # launch CLIs or authenticate remotely. Each target's actual turn admission
38385
38661
  # still checks its own binding, CLI compatibility and selected login mode.
38386
- for backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
38662
+ for backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
38387
38663
  with RUNTIME_DIAGNOSTICS_LOCK:
38388
38664
  diagnostic = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
38389
38665
  ready = diagnostic.get("status") == "ready"
@@ -38509,15 +38785,21 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
38509
38785
  BACKEND_CLAUDE: CLAUDE_TRANSPORT_AGENT_SDK,
38510
38786
  BACKEND_CURSOR: "headless-stream-json",
38511
38787
  BACKEND_OPENCODE: "headless-stream-json",
38788
+ BACKEND_GEMINI: "acp",
38512
38789
  },
38513
38790
  }
38514
38791
 
38515
38792
 
38516
38793
  OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY = "opencode_cross_chat_v1"
38794
+ GEMINI_CLIENT_CAPABILITY = "gemini_acp_v1"
38517
38795
 
38518
38796
 
38519
38797
  def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]:
38520
38798
  backend = str(target.get("backend") or DEFAULT_BACKEND).strip().lower()
38799
+ if backend == BACKEND_GEMINI:
38800
+ if not cross_chat_target_backend_supported(backend):
38801
+ raise HTTPException(409, "The target Gemini CLI is unavailable.")
38802
+ return [GEMINI_CLIENT_CAPABILITY]
38521
38803
  if backend == BACKEND_CODEX:
38522
38804
  if not cross_chat_target_backend_supported(backend):
38523
38805
  raise HTTPException(
@@ -38560,6 +38842,7 @@ CROSS_CHAT_DELIVERY_CAPABILITIES_BY_BACKEND: dict[str, frozenset[str]] = {
38560
38842
  BACKEND_CLAUDE: frozenset({CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY}),
38561
38843
  BACKEND_CURSOR: frozenset(),
38562
38844
  BACKEND_OPENCODE: frozenset({OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY}),
38845
+ BACKEND_GEMINI: frozenset({GEMINI_CLIENT_CAPABILITY}),
38563
38846
  }
38564
38847
 
38565
38848
 
@@ -43293,7 +43576,9 @@ def find_emergency_request_event(
43293
43576
  request_id: str,
43294
43577
  source_run_id: str,
43295
43578
  ) -> dict[str, Any] | None:
43296
- for event in reversed_jsonl_events(events_path(session_id)):
43579
+ for event in reversed_jsonl_events(
43580
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43581
+ ):
43297
43582
  if (
43298
43583
  event.get("type") == "emergency_alert_raised"
43299
43584
  and event.get("emergency_request_id") == request_id
@@ -43308,7 +43593,9 @@ def emergency_alert_was_acknowledged(
43308
43593
  alert_id: str,
43309
43594
  raised_seq: int,
43310
43595
  ) -> bool:
43311
- for event in reversed_jsonl_events(events_path(session_id)):
43596
+ for event in reversed_jsonl_events(
43597
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43598
+ ):
43312
43599
  seq = int(event.get("seq") or 0)
43313
43600
  if seq <= raised_seq:
43314
43601
  return False
@@ -43324,7 +43611,9 @@ def find_emergency_acknowledgement_event(
43324
43611
  session_id: str,
43325
43612
  alert_id: str,
43326
43613
  ) -> dict[str, Any] | None:
43327
- for event in reversed_jsonl_events(events_path(session_id)):
43614
+ for event in reversed_jsonl_events(
43615
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43616
+ ):
43328
43617
  if (
43329
43618
  event.get("type") == "emergency_alert_acknowledged"
43330
43619
  and event.get("emergency_alert_id") == alert_id
@@ -43333,6 +43622,41 @@ def find_emergency_acknowledgement_event(
43333
43622
  return None
43334
43623
 
43335
43624
 
43625
+ def emergency_alert_batch_through(
43626
+ session_id: str,
43627
+ through_alert_id: str,
43628
+ ) -> tuple[dict[str, Any] | None, list[str]]:
43629
+ """Freeze active IDs at an exact durable raise boundary, including retries.
43630
+
43631
+ Read lifecycle facts rather than a potentially interrupted summary save.
43632
+ Reverse order lets each alert's newest fact determine whether it is active.
43633
+ A previously acknowledged boundary remains a valid, immutable cut-off.
43634
+ """
43635
+ settled: set[str] = set()
43636
+ active: list[tuple[int, str]] = []
43637
+ boundary = None
43638
+ for event in reversed_jsonl_events(
43639
+ events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
43640
+ ):
43641
+ if event.get("type") == "emergency_alert_acknowledged":
43642
+ settled.add(str(event.get("emergency_alert_id") or ""))
43643
+ elif event.get("type") == "emergency_alert_raised":
43644
+ alert = normalized_emergency_alert(event.get("emergency_alert"))
43645
+ seq = int(event.get("seq") or 0)
43646
+ if alert is None or seq <= 0:
43647
+ continue
43648
+ alert_id = alert["id"]
43649
+ if alert_id == through_alert_id:
43650
+ boundary = event
43651
+ if alert_id not in settled:
43652
+ active.append((seq, alert_id))
43653
+ settled.add(alert_id)
43654
+ if boundary is None:
43655
+ return None, []
43656
+ boundary_seq = int(boundary["seq"])
43657
+ return boundary, [alert_id for seq, alert_id in reversed(active) if seq <= boundary_seq]
43658
+
43659
+
43336
43660
  def emergency_alert_response(
43337
43661
  session_id: str,
43338
43662
  event: dict[str, Any],
@@ -44945,7 +45269,7 @@ def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Itera
44945
45269
 
44946
45270
  def bounded_jsonl_records_range(
44947
45271
  path: Path, start: int, end: int, *, expected_stat: dict[str, int],
44948
- require_newline: bool = True,
45272
+ require_newline: bool = True, allow_partial: bool = False,
44949
45273
  ) -> Iterator[tuple[dict[str, Any] | None, int]]:
44950
45274
  """Read every record and ending offset in one immutable JSONL range."""
44951
45275
  if start < 0 or end < start or end > expected_stat["st_size"]:
@@ -44955,10 +45279,12 @@ def bounded_jsonl_records_range(
44955
45279
  if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
44956
45280
  raise ValueError("transcript changed before cursor parsing")
44957
45281
  stream.seek(start)
44958
- for event, offset, _raw in jsonl_records(stream, end=end,
45282
+ for count, (event, offset, _raw) in enumerate(jsonl_records(stream, end=end,
44959
45283
  require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
44960
- max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
45284
+ max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES), 1):
44961
45285
  yield event, offset
45286
+ if allow_partial and count >= MAX_LOCAL_TRANSCRIPT_SCAN_LINES:
45287
+ break
44962
45288
  after = os.fstat(stream.fileno())
44963
45289
  if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
44964
45290
  raise ValueError("transcript changed during cursor parsing")
@@ -45252,20 +45578,7 @@ def find_codex_history(provider_id: str) -> Path | None:
45252
45578
  clean_provider_id = provider_session_identifier(provider_id)
45253
45579
  if clean_provider_id is None:
45254
45580
  return None
45255
- if not CODEX_SESSIONS_ROOT.exists():
45256
- return None
45257
- selected: Path | None = None
45258
- selected_mtime = -1.0
45259
- for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
45260
- transcript_id, _cwd = codex_transcript_meta(path)
45261
- if transcript_id != clean_provider_id:
45262
- continue
45263
- with suppress(OSError):
45264
- mtime = path.stat().st_mtime
45265
- if mtime > selected_mtime:
45266
- selected = path
45267
- selected_mtime = mtime
45268
- return selected
45581
+ return codex_rollout_path(clean_provider_id)
45269
45582
 
45270
45583
 
45271
45584
  def codex_compaction_count(sess: dict[str, Any]) -> int:
@@ -45307,7 +45620,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
45307
45620
  """Locate ``rollout-*-<thread_id>.jsonl`` under CODEX_SESSIONS_ROOT.
45308
45621
 
45309
45622
  Blocking filesystem work; call from a worker thread. Hits are cached per
45310
- thread id and re-validated with one stat. Misses are cached briefly so a
45623
+ thread id and re-validated with a bounded metadata read. Misses are cached briefly so a
45311
45624
  thread whose rollout has not been flushed yet does not trigger a directory
45312
45625
  walk on every runtime snapshot.
45313
45626
  """
@@ -45317,7 +45630,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
45317
45630
  return None
45318
45631
  cached = CODEX_ROLLOUT_PATH_CACHE.get(clean_id)
45319
45632
  if cached is not None:
45320
- if cached.is_file():
45633
+ if contained_jsonl_path(cached, CODEX_SESSIONS_ROOT) is not None and codex_transcript_meta(cached)[0] == clean_id:
45321
45634
  return cached
45322
45635
  CODEX_ROLLOUT_PATH_CACHE.pop(clean_id, None)
45323
45636
  missed_at = CODEX_ROLLOUT_PATH_MISSES.get(clean_id)
@@ -45330,17 +45643,29 @@ def codex_rollout_path(thread_id: str) -> Path | None:
45330
45643
  suffix = f"-{clean_id}.jsonl"
45331
45644
  selected: Path | None = None
45332
45645
  selected_mtime = -1.0
45646
+ legacy_paths: list[Path] = []
45333
45647
  for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
45334
45648
  name = path.name
45335
45649
  if not (name.startswith("rollout-") and name.endswith(suffix)):
45650
+ legacy_paths.append(path)
45336
45651
  continue
45337
- if not codex_rollout_head_matches(path, clean_id):
45652
+ if codex_transcript_meta(path)[0] != clean_id:
45338
45653
  continue
45339
45654
  with suppress(OSError):
45340
45655
  mtime = path.stat().st_mtime
45341
45656
  if mtime > selected_mtime:
45342
45657
  selected = path
45343
45658
  selected_mtime = mtime
45659
+ if selected is None:
45660
+ # Preserve discovery of renamed/legacy transcripts without reading
45661
+ # unrelated headers on the ordinary filename lookup path.
45662
+ for path in legacy_paths:
45663
+ if codex_transcript_meta(path)[0] != clean_id:
45664
+ continue
45665
+ with suppress(OSError):
45666
+ mtime = path.stat().st_mtime
45667
+ if mtime > selected_mtime:
45668
+ selected, selected_mtime = path, mtime
45344
45669
  if selected is None:
45345
45670
  CODEX_ROLLOUT_PATH_MISSES[clean_id] = now
45346
45671
  if len(CODEX_ROLLOUT_PATH_MISSES) > 1024:
@@ -46531,6 +46856,8 @@ def session_provider_id(sess: dict[str, Any]) -> str | None:
46531
46856
  return sess.get("cursor_session_id") or sess.get("session_id")
46532
46857
  if backend == BACKEND_OPENCODE:
46533
46858
  return sess.get("opencode_session_id") or sess.get("session_id")
46859
+ if backend == BACKEND_GEMINI:
46860
+ return sess.get("gemini_session_id") or sess.get("session_id")
46534
46861
  return sess.get("session_id")
46535
46862
 
46536
46863
 
@@ -46670,6 +46997,12 @@ def read_native_session_title(sess: dict[str, Any]) -> str | None:
46670
46997
  return native_session_title(codex_session_index_thread_names().get(provider_id))
46671
46998
  if backend == BACKEND_CURSOR:
46672
46999
  return cursor_native_session_title(provider_id, sess.get("cwd"))
47000
+ if backend == BACKEND_GEMINI:
47001
+ from gemini_history import native_snapshot
47002
+ from gemini_runtime import history_environment
47003
+ env = history_environment(runner_env(), sess, PROVIDER_CONNECTION_STORE.for_session(sess), PROVIDER_CONNECTION_STORE.root)
47004
+ record = native_snapshot(env, str(sess.get("cwd") or ""), provider_id)
47005
+ return native_session_title(record.get("summary"))
46673
47006
  if backend == "opencode":
46674
47007
  # Current native storage. A schema change, missing store, or busy DB
46675
47008
  # fails closed. mode=ro must never create or migrate a provider DB.
@@ -46754,7 +47087,8 @@ def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = Fa
46754
47087
  return bool(
46755
47088
  os.environ.get("AGENTSDOCK_AUTO_TITLES", "1").strip().lower() not in {"0", "false", "off"}
46756
47089
  and not SERVER_SHUTTING_DOWN
46757
- and sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
47090
+ and (sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
47091
+ or (sess.get("backend") == BACKEND_GEMINI and sess.get("provider_connection") == "custom"))
46758
47092
  and sess.get("auto_title_enabled", True) is not False
46759
47093
  and not sess.get("archived") and not sess.get("parent_id") and not sess.get("fork_from")
46760
47094
  and sess.get("id") not in DELETING_SESSIONS
@@ -46781,6 +47115,9 @@ async def close_generated_session_titles() -> None:
46781
47115
 
46782
47116
  async def generate_session_title(session_id: str, snapshot: dict, reply: str) -> None:
46783
47117
  async with GENERATED_TITLE_SLOTS:
47118
+ if snapshot["backend"] == BACKEND_GEMINI:
47119
+ # A native summary may arrive while this optional task waits.
47120
+ await refresh_native_session_title(session_id)
46784
47121
  # Persist the claim before spending any provider usage. A restart does
46785
47122
  # not retry an ambiguous request. This is one optional attempt per chat.
46786
47123
  async with STORE._lock:
@@ -46807,6 +47144,27 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
46807
47144
  options["model"] = codex_runtime_settings(snapshot)[0]
46808
47145
  options["executable"] = CODEX_BIN
46809
47146
  options["provider_selection"] = CODEX_PROVIDER_STORE.for_session(snapshot, include_key=True)
47147
+ elif snapshot["backend"] == BACKEND_GEMINI:
47148
+ from gemini_api import default_model
47149
+ from gemini_history import native_snapshot
47150
+ connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
47151
+ model = default_model(connection)
47152
+ if model == "auto":
47153
+ from gemini_runtime import history_environment
47154
+ env = history_environment(runner_env(), snapshot, connection, PROVIDER_CONNECTION_STORE.root)
47155
+ native = await asyncio.wait_for(asyncio.to_thread(native_snapshot, env,
47156
+ str(snapshot.get("cwd") or ""), session_provider_id(snapshot)), timeout=1.0)
47157
+ model = next((row.get("model") for row in reversed(native["messages"])
47158
+ if row.get("type") == "gemini" and row.get("model") and row.get("model") != "auto"), None)
47159
+ # Recheck revocation after the awaited metadata read and before
47160
+ # sending anything. Never take credentials from the current default.
47161
+ connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
47162
+ current = STORE.sessions.get(session_id)
47163
+ if (not current or not generated_title_eligible(current, allow_attempted=True)
47164
+ or title_runtime_key(current) != title_runtime_key(snapshot)
47165
+ or current.get("title") != snapshot.get("title")):
47166
+ return
47167
+ options = {"model": model, "provider_selection": connection}
46810
47168
  else:
46811
47169
  options["executable"] = resolve_cursor_executable()
46812
47170
  if not options["executable"]:
@@ -46818,6 +47176,9 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
46818
47176
  snapshot["backend"], snapshot["_title_seed"], reply, **options,
46819
47177
  )
46820
47178
  if result:
47179
+ if snapshot["backend"] == BACKEND_GEMINI:
47180
+ await refresh_native_session_title(session_id)
47181
+ PROVIDER_CONNECTION_STORE.for_session(snapshot) # Forget also fences a pending result.
46821
47182
  changed = await STORE.adopt_auto_title(
46822
47183
  session_id, result, source="generated", backend=snapshot["backend"],
46823
47184
  provider_id=session_provider_id(snapshot), expected_title=snapshot.get("title"),
@@ -46968,7 +47329,7 @@ def codex_transcript_meta(
46968
47329
  ) -> tuple[str | None, str | None]:
46969
47330
  """Return (session_id, cwd) from a Codex transcript's session_meta record."""
46970
47331
  with suppress(OSError, ValueError):
46971
- for index, event in enumerate(bounded_jsonl_events(path)):
47332
+ for index, event in enumerate(codex_transcript_metadata_records(path)):
46972
47333
  if index >= CODEX_TRANSCRIPT_SCAN_LINES:
46973
47334
  break
46974
47335
  if event.get("type") != "session_meta":
@@ -46984,6 +47345,23 @@ def codex_transcript_meta(
46984
47345
  return None, None
46985
47346
 
46986
47347
 
47348
+ def codex_transcript_metadata_records(path: Path) -> Iterator[dict[str, Any]]:
47349
+ """Discovery reads a bounded header, independent of total rollout size."""
47350
+ remaining = 1024 * 1024
47351
+ with path.open("rb") as stream:
47352
+ for _ in range(CODEX_TRANSCRIPT_SCAN_LINES):
47353
+ line = stream.readline(min(remaining, MAX_LOCAL_TRANSCRIPT_LINE_BYTES) + 1)
47354
+ if not line or len(line) > remaining or not line.endswith(b"\n"):
47355
+ return
47356
+ remaining -= len(line)
47357
+ try:
47358
+ record = json.loads(line)
47359
+ except (ValueError, UnicodeDecodeError):
47360
+ continue
47361
+ if isinstance(record, dict):
47362
+ yield record
47363
+
47364
+
46987
47365
  def codex_transcript_preview(path: Path) -> str | None:
46988
47366
  """Return a short snippet of the first user message, for a local-session label."""
46989
47367
  with suppress(OSError, ValueError):
@@ -47216,6 +47594,7 @@ def local_session_candidates(
47216
47594
  known_provider_keys: set[tuple[str, str]] | None = None,
47217
47595
  *,
47218
47596
  include_cursor: bool = False,
47597
+ include_gemini: bool = False,
47219
47598
  ) -> list[dict[str, Any]]:
47220
47599
  """Enumerate sessions not already used by this or another local instance."""
47221
47600
 
@@ -47244,6 +47623,11 @@ def local_session_candidates(
47244
47623
  candidates.extend(local_cursor_session_candidates({
47245
47624
  provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_CURSOR
47246
47625
  }))
47626
+ if include_gemini:
47627
+ from gemini_import import candidates as gemini_candidates
47628
+ candidates.extend(gemini_candidates(sys.modules[__name__], runner_env(), {
47629
+ provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_GEMINI
47630
+ }))
47247
47631
  candidates.sort(key=lambda c: c["updated_at"], reverse=True)
47248
47632
  return candidates[:max(1, min(limit, MAX_LOCAL_SESSION_LIST_ITEMS))]
47249
47633
 
@@ -47291,6 +47675,8 @@ def standalone_provider_session(sess: dict[str, Any]) -> dict[str, Any]:
47291
47675
  isolated["codex_thread_id"] = None
47292
47676
  isolated["cursor_session_id"] = None
47293
47677
  isolated["opencode_session_id"] = None
47678
+ isolated["gemini_session_id"] = None
47679
+ isolated.pop("gemini_session_cwd", None)
47294
47680
  isolated["backend_locked"] = False
47295
47681
  isolated.pop("cursor_instruction_hash", None)
47296
47682
  isolated.pop("cursor_instruction_version", None)
@@ -47330,6 +47716,12 @@ def provider_history_path(sess: dict[str, Any]) -> Path | None:
47330
47716
  if backend == BACKEND_CLAUDE:
47331
47717
  return find_claude_history(provider_id)
47332
47718
  if backend == BACKEND_CODEX:
47719
+ cursor = normalized_history_sync_cursor(sess)
47720
+ if cursor and cursor.get("provider_session_id") == provider_id:
47721
+ path = contained_jsonl_path(Path(cursor["source_path"]), CODEX_SESSIONS_ROOT)
47722
+ if path is not None and codex_transcript_meta(path)[0] == provider_id:
47723
+ CODEX_ROLLOUT_PATH_CACHE[provider_id] = path
47724
+ return path
47333
47725
  return find_codex_history(provider_id)
47334
47726
  return None
47335
47727
 
@@ -47452,6 +47844,8 @@ def normalized_history_sync_cursor(
47452
47844
  raw.get("timeline_pending_through_seq") or 0
47453
47845
  ),
47454
47846
  "timeline_pending_active": timeline_pending_active,
47847
+ **({"bootstrap_timeline_seq": raw["bootstrap_timeline_seq"]}
47848
+ if type(raw.get("bootstrap_timeline_seq")) is int and raw["bootstrap_timeline_seq"] >= 0 else {}),
47455
47849
  "checkpoint_seq": int(raw.get("checkpoint_seq", raw["timeline_seq"])),
47456
47850
  }
47457
47851
  # Distinguish a pre-feature cursor from an initialized conservative empty
@@ -47633,7 +48027,12 @@ def provider_history_source_snapshot(
47633
48027
  int(opened.st_mtime_ns),
47634
48028
  ) != expected_identity:
47635
48029
  raise ValueError("transcript changed before cursor snapshot")
47636
- remaining = int(initial.st_size)
48030
+ # Verify the existing prefix, then prepare at most one bounded suffix.
48031
+ # Total file size is not an import limit. Prefix verification remains
48032
+ # necessary: provider transcripts can be replaced or rewritten.
48033
+ snapshot_limit = min(int(initial.st_size), (verify_offset or 0) + HISTORY_SYNC_BATCH_BYTES)
48034
+ remaining = snapshot_limit
48035
+ consumed = 0
47637
48036
  while remaining > 0:
47638
48037
  chunk = stream.read(min(1024 * 1024, remaining))
47639
48038
  if not chunk:
@@ -47648,10 +48047,15 @@ def provider_history_source_snapshot(
47648
48047
  if newline >= 0:
47649
48048
  running_digest.update(chunk[:newline + 1])
47650
48049
  complete_digest = running_digest.copy()
47651
- complete_size = int(initial.st_size) - remaining - len(chunk) + newline + 1
48050
+ complete_size = consumed + newline + 1
47652
48051
  running_digest.update(chunk[newline + 1:])
47653
48052
  else:
47654
48053
  running_digest.update(chunk)
48054
+ consumed += len(chunk)
48055
+ # A single streamed record may exceed a batch. Extend only until
48056
+ # at least one new complete record exists, under the scan deadline.
48057
+ if remaining == 0 and complete_size <= (verify_offset or 0) and consumed < int(initial.st_size):
48058
+ remaining = min(1024 * 1024, int(initial.st_size) - consumed)
47655
48059
  finished = os.fstat(stream.fileno())
47656
48060
  if (
47657
48061
  int(finished.st_dev),
@@ -47804,6 +48208,7 @@ def parse_provider_history_delta(
47804
48208
  start,
47805
48209
  end,
47806
48210
  expected_stat=expected_stat,
48211
+ allow_partial=True,
47807
48212
  ):
47808
48213
  if blocked_on_unseen_message:
47809
48214
  # Exhaust the fixed range so its final fstat validation still
@@ -47934,51 +48339,27 @@ def load_provider_history_with_cursor(
47934
48339
  if "claude_interruption_context" in previous
47935
48340
  else seed_claude_interruption_context(path, start, expected_stat=snapshot["expected_stat"], provider_session_id=provider_id)
47936
48341
  )
47937
- if continued and previous:
47938
- (
47939
- items,
47940
- cursor_offset,
47941
- last_item_digest,
47942
- delta_overflow,
47943
- ) = parse_provider_history_delta(
47944
- path,
47945
- backend,
47946
- start,
47947
- end,
47948
- limit=limit,
47949
- expected_stat=snapshot["expected_stat"],
47950
- previous_last_item_digest=str(
47951
- previous.get("last_item_digest") or ""
47952
- ),
47953
- expected_session_id=str(sess.get("id") or "") or None,
47954
- interruption_context=interruption_context,
47955
- codex_phase_context=codex_phase_context,
47956
- source_text_context=source_text_context,
47957
- )
47958
- caught_up = not delta_overflow and cursor_offset == end
47959
- else:
47960
- events = bounded_jsonl_events_range(
47961
- path,
47962
- 0,
47963
- end,
47964
- expected_stat=snapshot["expected_stat"],
47965
- preserve_invalid=backend == BACKEND_CLAUDE,
47966
- )
47967
- if backend == BACKEND_CLAUDE:
47968
- items = parse_claude_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None, interruption_context=interruption_context)
47969
- elif backend == BACKEND_CODEX:
47970
- items = parse_codex_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None)
47971
- else:
47972
- return None, [], None, False
47973
- cursor_offset = end
47974
- last_item_digest = (
47975
- history_item_cursor_digest(items[-1]) if items else ""
47976
- )
47977
- if items and items[-1].get("source_text_sha256"):
47978
- source_text_context["sha256"] = items[-1]["source_text_sha256"]
47979
- if backend == BACKEND_CODEX and items and items[-1].get("phase") in ("commentary", "final_answer"):
47980
- codex_phase_context["phase"] = items[-1]["phase"]
47981
- caught_up = True
48342
+ (
48343
+ items,
48344
+ cursor_offset,
48345
+ last_item_digest,
48346
+ delta_overflow,
48347
+ ) = parse_provider_history_delta(
48348
+ path,
48349
+ backend,
48350
+ start,
48351
+ end,
48352
+ limit=limit,
48353
+ expected_stat=snapshot["expected_stat"],
48354
+ previous_last_item_digest=str(
48355
+ (previous or {}).get("last_item_digest") or ""
48356
+ ),
48357
+ expected_session_id=str(sess.get("id") or "") or None,
48358
+ interruption_context=interruption_context,
48359
+ codex_phase_context=codex_phase_context,
48360
+ source_text_context=source_text_context,
48361
+ )
48362
+ caught_up = not delta_overflow and cursor_offset == end and end == int(snapshot["source_size"])
47982
48363
  source_digest = (
47983
48364
  str(snapshot["source_digest"])
47984
48365
  if cursor_offset == end
@@ -48341,6 +48722,7 @@ def reconcile_cursor_history_items(
48341
48722
  *,
48342
48723
  timeline_after_seq: int,
48343
48724
  timeline_through_seq: int,
48725
+ bootstrap: bool = False,
48344
48726
  ) -> tuple[list[dict[str, str]], int]:
48345
48727
  """Subtract timeline-owned occurrences from one exact provider delta.
48346
48728
 
@@ -48364,14 +48746,15 @@ def reconcile_cursor_history_items(
48364
48746
  timeline_after_seq=after_seq,
48365
48747
  timeline_through_seq=through_seq,
48366
48748
  tail=False,
48367
- include_imported=False,
48749
+ include_imported=bootstrap,
48368
48750
  message_details=message_details,
48369
48751
  )
48370
48752
 
48371
48753
  fresh: list[dict[str, str]] = []
48372
48754
  timeline_index = 0
48373
48755
  consumed_seq = after_seq
48374
- for item in items:
48756
+ last_match_index = -1
48757
+ for item_index, item in enumerate(items):
48375
48758
  item_details = history_message_match_details(item.get("kind", ""), item.get("text", ""), item, provider_item=True)
48376
48759
  if (
48377
48760
  timeline_index < len(timeline_messages)
@@ -48381,6 +48764,7 @@ def reconcile_cursor_history_items(
48381
48764
  ):
48382
48765
  consumed_seq = timeline_messages[timeline_index][0]
48383
48766
  timeline_index += 1
48767
+ last_match_index = item_index
48384
48768
  else:
48385
48769
  fresh.append(item)
48386
48770
 
@@ -48395,6 +48779,11 @@ def reconcile_cursor_history_items(
48395
48779
  if timeline_window_truncated and timeline_messages
48396
48780
  else through_seq
48397
48781
  )
48782
+ if bootstrap:
48783
+ # Initial backfill uses the same conservative tail semantics as a
48784
+ # single full import. Old imported rows are credits too; keep only
48785
+ # source items after the entire captured preexisting timeline.
48786
+ fresh = items[last_match_index + 1:] if consumed_seq == through_seq and not timeline_window_truncated else []
48398
48787
  return fresh, consumed_seq
48399
48788
 
48400
48789
 
@@ -48516,10 +48905,48 @@ def unsynced_history_items(
48516
48905
  return items[last_matched + 1:]
48517
48906
 
48518
48907
 
48908
+ class HistoryPreparationStale(RuntimeError):
48909
+ """The read-only plan lost its ownership or immutable input generation."""
48910
+
48911
+
48912
+ def history_file_stamp(path: Path) -> tuple[int, int, int, int] | None:
48913
+ try:
48914
+ value = path.stat()
48915
+ return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns
48916
+ except FileNotFoundError:
48917
+ return None
48918
+
48919
+
48920
+ @asynccontextmanager
48921
+ async def history_commit_guard(sess, cursor, timeline_stamp, source_stamp, enabled):
48922
+ if not enabled:
48923
+ yield
48924
+ return
48925
+ session_id = str(sess["id"])
48926
+ async with session_lifecycle_lock(session_id):
48927
+ current = STORE.sessions.get(session_id)
48928
+ if (not current or session_id in DELETING_SESSIONS
48929
+ or session_id in DELETED_SESSION_TOMBSTONES
48930
+ or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
48931
+ or session_id in SERVER_MAINTENANCE_SESSIONS
48932
+ or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))
48933
+ or current.get("backend") != sess.get("backend")
48934
+ or session_provider_id(current) != session_provider_id(sess)
48935
+ or normalized_history_sync_cursor(current) != cursor
48936
+ or history_file_stamp(events_path(session_id)) != timeline_stamp):
48937
+ raise HistoryPreparationStale("chat changed during history preparation")
48938
+ if source_stamp is not None:
48939
+ path, size, mtime, dev, ino = source_stamp
48940
+ if history_file_stamp(Path(path)) != (dev, ino, size, mtime):
48941
+ raise HistoryPreparationStale("provider transcript changed during history preparation")
48942
+ yield
48943
+
48944
+
48519
48945
  async def sync_provider_history(
48520
48946
  sess: dict[str, Any],
48521
48947
  *,
48522
48948
  limit: int | None = None,
48949
+ background: bool = False,
48523
48950
  ) -> dict[str, Any]:
48524
48951
  """Append provider messages added outside this chat since the last sync.
48525
48952
 
@@ -48540,6 +48967,7 @@ async def sync_provider_history(
48540
48967
  }
48541
48968
  live_session = STORE.sessions.get(session_id) or {}
48542
48969
  previous_cursor = normalized_history_sync_cursor(live_session)
48970
+ timeline_stamp = history_file_stamp(events_path(session_id))
48543
48971
  # A durable cursor makes the unchanged fast path survive process restart;
48544
48972
  # all five stat/identity fields must still describe the fully-consumed
48545
48973
  # source before parsing can be skipped.
@@ -48562,6 +48990,10 @@ async def sync_provider_history(
48562
48990
  "source_path": None,
48563
48991
  "message": "No provider transcript found.",
48564
48992
  }
48993
+ if (previous_cursor is not None and next_cursor is not None
48994
+ and next_cursor["source_offset"] == previous_cursor["source_offset"]
48995
+ and not next_cursor.get("source_caught_up", True)):
48996
+ return {"imported": 0, "deferred": True, "reason": "incomplete_source_record"}
48565
48997
  if next_cursor is not None and previous_cursor is not None:
48566
48998
  recovered = await asyncio.to_thread(
48567
48999
  committed_history_sync_checkpoint,
@@ -48570,11 +49002,11 @@ async def sync_provider_history(
48570
49002
  )
48571
49003
  if recovered is not None:
48572
49004
  recovered_cursor, _terminal_seq = recovered
48573
- await persist_history_sync_cursor(
48574
- sess,
48575
- recovered_cursor,
48576
- timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
48577
- )
49005
+ async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
49006
+ await persist_history_sync_cursor(
49007
+ sess, recovered_cursor,
49008
+ timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
49009
+ )
48578
49010
  return {
48579
49011
  "imported": 0,
48580
49012
  "source_path": str(source_path),
@@ -48584,14 +49016,18 @@ async def sync_provider_history(
48584
49016
  last_event_seq_from_file,
48585
49017
  events_path(session_id),
48586
49018
  )
48587
- if continued and previous_cursor is not None:
48588
- timeline_scan_through_seq = timeline_latest_seq
49019
+ bootstrap_boundary = (previous_cursor or {}).get("bootstrap_timeline_seq") if continued else None
49020
+ if not continued and next_cursor is not None and not next_cursor.get("source_caught_up", True):
49021
+ bootstrap_boundary = timeline_latest_seq
49022
+ if (continued and previous_cursor is not None) or (next_cursor is not None and not next_cursor.get("source_caught_up", True)):
49023
+ timeline_scan_through_seq = timeline_latest_seq if bootstrap_boundary is None else bootstrap_boundary
48589
49024
  fresh, consumed_timeline_seq = await asyncio.to_thread(
48590
49025
  reconcile_cursor_history_items,
48591
49026
  session_id,
48592
49027
  items,
48593
- timeline_after_seq=int(previous_cursor.get("timeline_seq") or 0),
49028
+ timeline_after_seq=int((previous_cursor or {}).get("timeline_seq") or 0) if continued else 0,
48594
49029
  timeline_through_seq=timeline_scan_through_seq,
49030
+ **({"bootstrap": True} if bootstrap_boundary is not None else {}),
48595
49031
  )
48596
49032
  else:
48597
49033
  # First sync or invalidated/rotated source: retain the conservative
@@ -48612,11 +49048,11 @@ async def sync_provider_history(
48612
49048
  )
48613
49049
  if recovered is not None:
48614
49050
  recovered_cursor, _terminal_seq = recovered
48615
- await persist_history_sync_cursor(
48616
- sess,
48617
- recovered_cursor,
48618
- timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
48619
- )
49051
+ async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
49052
+ await persist_history_sync_cursor(
49053
+ sess, recovered_cursor,
49054
+ timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
49055
+ )
48620
49056
  return {
48621
49057
  "imported": 0,
48622
49058
  "source_path": str(source_path),
@@ -48627,6 +49063,8 @@ async def sync_provider_history(
48627
49063
  timeline_seq = timeline_latest_seq
48628
49064
  if next_cursor is not None:
48629
49065
  caught_up = bool(next_cursor.pop("source_caught_up", True))
49066
+ if bootstrap_boundary is not None and not caught_up:
49067
+ next_cursor["bootstrap_timeline_seq"] = bootstrap_boundary
48630
49068
  next_cursor["timeline_pending_through_seq"] = 0
48631
49069
  next_cursor["timeline_pending_active"] = False
48632
49070
  if not caught_up:
@@ -48642,50 +49080,59 @@ async def sync_provider_history(
48642
49080
  caught_up=caught_up,
48643
49081
  )
48644
49082
 
48645
- result: dict[str, Any]
48646
- if not fresh:
48647
- result = {
48648
- "imported": 0,
48649
- "source_path": str(source_path),
48650
- "message": "Already up to date with the provider transcript.",
48651
- }
48652
- else:
48653
- # The imported batch is fsynced atomically together with its exact next
48654
- # cursor. A cursor moves only after that authoritative commit; if the
48655
- # registry save then fails, retry recovers the embedded checkpoint
48656
- # without relying on ambiguous content alignment.
49083
+ if background and fresh:
48657
49084
  try:
48658
- result = await append_imported_history(
48659
- sess,
48660
- source_path,
48661
- fresh,
48662
- sync_checkpoint=checkpoint,
48663
- )
49085
+ fresh = await prepare_imported_history_items(sess, source_path, fresh, checkpoint)
48664
49086
  except CodexNativeHistoryProofUnavailable:
48665
- # Do not persist next_cursor: these source bytes have not yet been
48666
- # proven against native history. A later explicit sync can retry.
48667
- return {
48668
- "imported": 0, "source_path": str(source_path), "deferred": True,
48669
- "reason": "native_history_proof_unavailable",
48670
- "message": "History reconciliation deferred; no messages were imported.",
49087
+ return {"imported": 0, "deferred": True, "reason": "native_history_proof_unavailable"}
49088
+ async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
49089
+ result: dict[str, Any]
49090
+ if not fresh:
49091
+ result = {
49092
+ "imported": 0,
49093
+ "source_path": str(source_path),
49094
+ "message": "Already up to date with the provider transcript.",
48671
49095
  }
48672
- if next_cursor is not None:
48673
- if caught_up:
48674
- timeline_seq = max(
49096
+ else:
49097
+ # The imported batch is fsynced atomically together with its exact next
49098
+ # cursor. A cursor moves only after that authoritative commit; if the
49099
+ # registry save then fails, retry recovers the embedded checkpoint
49100
+ # without relying on ambiguous content alignment.
49101
+ try:
49102
+ result = await append_imported_history(
49103
+ sess,
49104
+ source_path,
49105
+ fresh,
49106
+ sync_checkpoint=checkpoint,
49107
+ _prepared=background,
49108
+ _expected_timeline_stamp=timeline_stamp,
49109
+ )
49110
+ except CodexNativeHistoryProofUnavailable:
49111
+ # Do not persist next_cursor: these source bytes have not yet been
49112
+ # proven against native history. A later explicit sync can retry.
49113
+ return {
49114
+ "imported": 0, "source_path": str(source_path), "deferred": True,
49115
+ "reason": "native_history_proof_unavailable",
49116
+ "message": "History reconciliation deferred; no messages were imported.",
49117
+ }
49118
+ if next_cursor is not None:
49119
+ if caught_up:
49120
+ timeline_seq = max(
49121
+ timeline_latest_seq,
49122
+ int(result.get("timeline_seq") or 0),
49123
+ )
49124
+ next_cursor["checkpoint_seq"] = max(
49125
+ int(next_cursor.get("checkpoint_seq") or 0),
48675
49126
  timeline_latest_seq,
48676
49127
  int(result.get("timeline_seq") or 0),
48677
49128
  )
48678
- next_cursor["checkpoint_seq"] = max(
48679
- int(next_cursor.get("checkpoint_seq") or 0),
48680
- timeline_latest_seq,
48681
- int(result.get("timeline_seq") or 0),
48682
- )
48683
- await persist_history_sync_cursor(
48684
- sess,
48685
- next_cursor,
48686
- timeline_seq=timeline_seq,
48687
- )
48688
- return result
49129
+ await persist_history_sync_cursor(
49130
+ sess,
49131
+ next_cursor,
49132
+ timeline_seq=timeline_seq,
49133
+ )
49134
+ result["pending"] = not caught_up
49135
+ return result
48689
49136
 
48690
49137
 
48691
49138
  def provider_history_source_stamp(sess: dict[str, Any]) -> list[Any] | None:
@@ -48767,73 +49214,115 @@ async def persist_history_sync_cursor(
48767
49214
  return True
48768
49215
 
48769
49216
 
48770
- async def run_provider_history_sync(session_id: str) -> None:
48771
- """Sync one chat in the background; never surface failure to the opener."""
49217
+ HISTORY_MAINTENANCE_SLOTS = asyncio.Semaphore(2)
49218
+ HISTORY_MAINTENANCE_STATE: dict[str, dict[str, Any]] = {}
49219
+ HISTORY_MAINTENANCE_TASKS: dict[str, asyncio.Task] = {}
49220
+ HISTORY_PROJECTION_REVISIONS: dict[str, int] = {}
49221
+
49222
+
49223
+ def history_projection_notice(session_id: str) -> dict[str, Any]:
49224
+ return {"type": "history_projection_changed", "session_id": session_id,
49225
+ "revision": f"{SERVER_INSTANCE_ID}:{HISTORY_PROJECTION_REVISIONS.get(session_id, 0)}"}
49226
+
48772
49227
 
49228
+ def history_maintenance_key(sess: dict[str, Any]) -> list[Any]:
49229
+ provider_id = session_provider_id(sess)
49230
+ cursor = normalized_history_sync_cursor(sess)
49231
+ source = (Path(cursor["source_path"]) if cursor else CODEX_ROLLOUT_PATH_CACHE.get(provider_id))
49232
+ return [2, sess.get("backend"), provider_id,
49233
+ list(history_file_stamp(events_path(str(sess["id"]))) or ()),
49234
+ str(source) if source else None,
49235
+ list(history_file_stamp(source) or ()) if source else None]
49236
+
49237
+
49238
+ def history_maintenance_state(session_id: str) -> dict[str, Any]:
49239
+ if session_id not in HISTORY_MAINTENANCE_STATE:
49240
+ try:
49241
+ raw = json.loads((events_path(session_id).parent / "history-maintenance.json").read_text())
49242
+ HISTORY_MAINTENANCE_STATE[session_id] = raw if isinstance(raw, dict) else {}
49243
+ except (OSError, ValueError):
49244
+ HISTORY_MAINTENANCE_STATE[session_id] = {}
49245
+ return HISTORY_MAINTENANCE_STATE[session_id]
49246
+
49247
+
49248
+ def save_history_maintenance_state(session_id: str, value: dict[str, Any]) -> None:
49249
+ path = events_path(session_id).parent / "history-maintenance.json"
49250
+ if not path.parent.is_dir():
49251
+ return
49252
+ temporary = path.with_suffix(".tmp")
49253
+ temporary.write_text(json.dumps(value, separators=(",", ":")))
49254
+ temporary.replace(path)
49255
+
49256
+
49257
+ async def run_provider_history_sync(session_id: str) -> None:
49258
+ """Prepare in the background; hold admission only for validated commit."""
49259
+ state = history_maintenance_state(session_id)
49260
+ key = None
49261
+ pending = False
48773
49262
  try:
48774
- # Turn admission, deletion, backend changes, and provider-native
48775
- # controls use this same lifecycle lock. Revalidate after acquiring it
48776
- # so a sync scheduled while idle cannot import a transcript that a new
48777
- # live turn is concurrently extending.
48778
- async with session_lifecycle_lock(session_id):
49263
+ async with HISTORY_MAINTENANCE_SLOTS:
48779
49264
  sess = STORE.sessions.get(session_id)
48780
- if (
48781
- not sess
48782
- or session_id in DELETING_SESSIONS
48783
- or session_id in DELETED_SESSION_TOMBSTONES
48784
- ):
49265
+ if (not sess or session_id in DELETING_SESSIONS
49266
+ or session_id in DELETED_SESSION_TOMBSTONES
49267
+ or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
49268
+ or session_id in SERVER_MAINTENANCE_SESSIONS):
48785
49269
  return
48786
- async with ACTIVE_LOCK:
48787
- if (
48788
- session_id in BUSY_SESSIONS
48789
- or ACTIVE.get(session_id) is not None
48790
- or session_id in SERVER_MAINTENANCE_SESSIONS
48791
- or any(
48792
- task is not asyncio.current_task() and not task.done()
48793
- for task in tuple(SESSION_TURN_TASKS.get(session_id) or ())
48794
- )
48795
- ):
48796
- return
49270
+ key = history_maintenance_key(sess)
49271
+ state.update(status="running", reason=None)
48797
49272
  await refresh_native_session_title(session_id)
48798
- result = await sync_provider_history(dict(sess))
48799
- if result.get("imported"):
48800
- logger.info(
48801
- "synced %d provider message(s) added outside session=%s",
48802
- result["imported"],
48803
- session_id,
48804
- )
49273
+ repair_caches = (CLAUDE_METADATA_REPAIR_CACHE, CODEX_GOAL_HISTORY_REPAIR_CACHE,
49274
+ CODEX_NATIVE_HISTORY_REPAIR_CACHE)
49275
+ before_repair = tuple(cache.signature(session_id) for cache in repair_caches)
49276
+ result = await sync_provider_history(dict(sess), background=True)
49277
+ pending = bool(result.get("pending")) and not result.get("deferred")
49278
+ # Forward imports have already proved their own batch. Repair of
49279
+ # older displayed rows must not starve the remaining source suffix.
49280
+ repaired = None if pending else await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
49281
+ if before_repair != tuple(cache.signature(session_id) for cache in repair_caches):
49282
+ HISTORY_PROJECTION_REVISIONS[session_id] = HISTORY_PROJECTION_REVISIONS.get(session_id, 0) + 1
49283
+ await HUB.broadcast(session_id, history_projection_notice(session_id))
49284
+ deferred = result.get("deferred") or repaired is False
49285
+ state.update(status="deferred" if deferred else "pending" if pending else "idle", deterministic=False,
49286
+ reason=result.get("reason") or ("native_history_proof_unavailable" if repaired is False else None))
49287
+ if result.get("imported"):
49288
+ logger.info("synced %d provider message(s) added outside session=%s", result["imported"], session_id)
48805
49289
  except asyncio.CancelledError:
49290
+ state.update(status="deferred", reason="cancelled")
48806
49291
  raise
48807
49292
  except Exception as exc:
48808
- # A read-only catch-up must never break opening a chat.
48809
- logger.warning(
48810
- "provider history sync failed session=%s error=%s",
48811
- session_id,
48812
- type(exc).__name__,
48813
- )
49293
+ state.update(status="deferred", reason=type(exc).__name__, deterministic=isinstance(exc, ValueError))
49294
+ logger.warning("provider history sync deferred session=%s error=%s", session_id, type(exc).__name__)
48814
49295
  finally:
49296
+ if key is not None:
49297
+ # A changing input retries on its next open; unchanged failures
49298
+ # survive reconnects and process restarts without a scan storm.
49299
+ current = STORE.sessions.get(session_id)
49300
+ state.update(key=history_maintenance_key(current) if current else key,
49301
+ retry_at=time.time() + (60 if state.get("status") == "deferred" else 1 if pending else 15))
49302
+ with suppress(OSError):
49303
+ await asyncio.to_thread(save_history_maintenance_state, session_id, dict(state))
48815
49304
  HISTORY_SYNC_SCHEDULED.discard(session_id)
49305
+ HISTORY_MAINTENANCE_TASKS.pop(session_id, None)
49306
+ if pending and state.get("status") == "pending" and not SERVER_SHUTTING_DOWN:
49307
+ def continue_history():
49308
+ current = STORE.sessions.get(session_id)
49309
+ if current is not None:
49310
+ schedule_provider_history_sync(current)
49311
+ asyncio.get_running_loop().call_later(1.1, continue_history)
48816
49312
 
48817
49313
 
48818
49314
  def schedule_provider_history_sync(sess: dict[str, Any]) -> None:
48819
- """Catch one chat's transcript up when opened, without blocking the open.
48820
-
48821
- Opening a chat must stay fast, and the client is already subscribed to
48822
- the event stream, so recovered messages arrive live instead of delaying
48823
- the response.
48824
- """
48825
-
48826
49315
  session_id = str(sess.get("id") or "")
48827
- if not session_id or session_id in HISTORY_SYNC_SCHEDULED:
49316
+ if not session_id or session_id in HISTORY_SYNC_SCHEDULED or not session_provider_id(sess):
48828
49317
  return
48829
- if not session_provider_id(sess):
48830
- return
48831
- # A turn in flight is already streaming the provider's own output and its
48832
- # transcript writes are still landing.
48833
49318
  if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
48834
49319
  return
49320
+ state = history_maintenance_state(session_id)
49321
+ if state.get("key") == history_maintenance_key(sess) and (
49322
+ state.get("deterministic") or time.time() < state.get("retry_at", 0)):
49323
+ return
48835
49324
  HISTORY_SYNC_SCHEDULED.add(session_id)
48836
- asyncio.create_task(run_provider_history_sync(session_id))
49325
+ HISTORY_MAINTENANCE_TASKS[session_id] = asyncio.create_task(run_provider_history_sync(session_id))
48837
49326
 
48838
49327
 
48839
49328
  async def filter_codex_history_for_import(
@@ -48861,13 +49350,10 @@ async def filter_codex_history_for_import(
48861
49350
  raise
48862
49351
 
48863
49352
 
48864
- async def append_imported_history(
48865
- sess: dict[str, Any],
48866
- source_path: Path,
48867
- items: list[dict[str, Any]],
48868
- *,
48869
- sync_checkpoint: dict[str, Any] | None = None,
48870
- ) -> dict[str, Any]:
49353
+ async def prepare_imported_history_items(
49354
+ sess: dict[str, Any], source_path: Path, items: list[dict[str, Any]],
49355
+ sync_checkpoint: dict[str, Any] | None,
49356
+ ) -> list[dict[str, Any]]:
48871
49357
  session_id = str(sess["id"])
48872
49358
  provider_id = str(session_provider_id(sess) or "")
48873
49359
  backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
@@ -48894,6 +49380,23 @@ async def append_imported_history(
48894
49380
  normalize_user=normalize_wake_user, normalize_full_user=normalize_full_wake_user,
48895
49381
  normalize_assistant=clean_assistant_text,
48896
49382
  )
49383
+ return items
49384
+
49385
+
49386
+ async def append_imported_history(
49387
+ sess: dict[str, Any],
49388
+ source_path: Path,
49389
+ items: list[dict[str, Any]],
49390
+ *,
49391
+ sync_checkpoint: dict[str, Any] | None = None,
49392
+ _prepared: bool = False,
49393
+ _expected_timeline_stamp: tuple | None = None,
49394
+ ) -> dict[str, Any]:
49395
+ session_id = str(sess["id"])
49396
+ provider_id = str(session_provider_id(sess) or "")
49397
+ backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
49398
+ if not _prepared:
49399
+ items = await prepare_imported_history_items(sess, source_path, items, sync_checkpoint)
48897
49400
  metadata_only = bool(items) and all(item.get("kind") == "interruption" or (
48898
49401
  item.get("provider_history_repair") in {"source_proven_import", "source_proven_native_replay", "source_proven_assistant_replay"}
48899
49402
  and item.get("text") == ""
@@ -48983,7 +49486,13 @@ async def append_imported_history(
48983
49486
  imported_events.append(imported_history_terminal_event(
48984
49487
  run_id, backend, metadata_only=metadata_only, source_timestamp=last_source_timestamp,
48985
49488
  ))
48986
- committed = await append_durable_event_batch(session_id, imported_events)
49489
+ if _prepared:
49490
+ async with event_delivery_lock(session_id):
49491
+ if history_file_stamp(events_path(session_id)) != _expected_timeline_stamp:
49492
+ raise HistoryPreparationStale("timeline changed before history publication")
49493
+ committed = await append_durable_event_batch_locked(session_id, imported_events)
49494
+ else:
49495
+ committed = await append_durable_event_batch(session_id, imported_events)
48987
49496
  if len(committed) != len(imported_events):
48988
49497
  raise RuntimeError("history event batch was not fully persisted")
48989
49498
  if backend == BACKEND_CODEX:
@@ -49121,6 +49630,12 @@ async def import_session_history(sess: dict[str, Any], *, force: bool = False, l
49121
49630
  backend = (sess.get("backend") or DEFAULT_BACKEND).lower()
49122
49631
  if not provider_id:
49123
49632
  return {"imported": 0, "source_path": None, "message": "No provider session ID set."}
49633
+ if backend == BACKEND_GEMINI:
49634
+ if await asyncio.to_thread(lambda: any(event.get("type") == "history_imported" for event in iter_session_events(session_id))):
49635
+ return {"imported": 0, "source_path": None, "message": "Gemini initial text history already imported; automatic reconciliation is not supported."}
49636
+ from gemini_import import initial_history
49637
+ source, items = await asyncio.to_thread(initial_history, sys.modules[__name__], runner_env(), provider_id, str(sess.get("cwd") or ""), limit)
49638
+ return await append_imported_history(sess, source, items)
49124
49639
  if backend == BACKEND_CURSOR:
49125
49640
  # Initial public-text snapshot only. The private native store, not these
49126
49641
  # display messages, supplies resume context. Cursor exports are rewritable
@@ -50158,9 +50673,10 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
50158
50673
  detail_fields = () if summary else (
50159
50674
  "auto_title_enabled",
50160
50675
  "system_prompt", "session_id", "claude_session_id", "codex_thread_id",
50161
- "cursor_session_id", "opencode_session_id",
50676
+ "cursor_session_id", "opencode_session_id", "gemini_session_id",
50162
50677
  "claude_permission_mode", "cursor_permission_mode",
50163
50678
  "opencode_permission_mode",
50679
+ "gemini_permission_mode", "gemini_permission_modes",
50164
50680
  "codex_approval_policy", "codex_sandbox_mode",
50165
50681
  "codex_permission_profile", "codex_approvals_reviewer",
50166
50682
  "provider_jobs_access",
@@ -50181,6 +50697,11 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
50181
50697
  "last_read_agent_event_seq", "last_read_agent_event_at", "manual_unread",
50182
50698
  )
50183
50699
  }
50700
+ maintenance = HISTORY_MAINTENANCE_STATE.get(str(sess.get("id") or ""), {})
50701
+ # Never-started maintenance adds no useful information to high-volume lists.
50702
+ # Once observed, retain idle/cleared fields so caches can retire old errors.
50703
+ if not summary or maintenance:
50704
+ public["history_maintenance"] = {key: maintenance.get(key) for key in ("status", "reason", "retry_at")}
50184
50705
  # Omit only never-configured defaults from high-volume lists. A stored null
50185
50706
  # is a clear tombstone and must survive summary/cache merges.
50186
50707
  if not summary or "subagent_limit" in sess:
@@ -57165,6 +57686,9 @@ def resolve_cursor_executable(*, require_compatible: bool = True) -> str | None:
57165
57686
 
57166
57687
 
57167
57688
  def runtime_executable(backend: str) -> str:
57689
+ if backend == BACKEND_GEMINI:
57690
+ from gemini_runtime import resolve_executable
57691
+ return resolve_executable(runner_env()) or "gemini"
57168
57692
  if backend == BACKEND_CLAUDE:
57169
57693
  return CLAUDE_BIN
57170
57694
  if backend == BACKEND_OPENCODE:
@@ -57183,6 +57707,8 @@ def runtime_executable(backend: str) -> str:
57183
57707
 
57184
57708
 
57185
57709
  def runtime_display_name(backend: str) -> str:
57710
+ if backend == BACKEND_GEMINI:
57711
+ return "Gemini CLI"
57186
57712
  if backend == BACKEND_CLAUDE:
57187
57713
  return "Claude Code"
57188
57714
  if backend == BACKEND_CURSOR:
@@ -57412,6 +57938,9 @@ def cursor_auth_failure_confirmed(executable: str) -> bool:
57412
57938
 
57413
57939
 
57414
57940
  def probe_runtime(backend: str) -> dict[str, Any]:
57941
+ if backend == BACKEND_GEMINI:
57942
+ from gemini_runtime import probe
57943
+ return probe(runner_env(), timeout=runtime_probe_timeout(8.0))
57415
57944
  if backend == BACKEND_OPENCODE:
57416
57945
  (
57417
57946
  resolved,
@@ -57635,7 +58164,14 @@ def probe_runtime(backend: str) -> dict[str, Any]:
57635
58164
 
57636
58165
  combined = f"{auth_result.stdout}\n{auth_result.stderr}"
57637
58166
  if auth_result.returncode == 0:
57638
- return runtime_diagnostic_payload(backend, "ready", installed=True, authenticated=True, version=version)
58167
+ result = runtime_diagnostic_payload(backend, "ready", installed=True, authenticated=True, version=version)
58168
+ if backend == BACKEND_CODEX:
58169
+ # Retain only the kind, never login-status output (which may show a
58170
+ # masked API key). No additional auth/refresh subprocess is needed.
58171
+ text = combined.lower()
58172
+ result["_codex_auth_mode"] = "apiKey" if "api key" in text else "chatgpt" if "chatgpt" in text else "unknown"
58173
+ result["_codex_auth_mode_checked_at_epoch"] = result["checked_at_epoch"]
58174
+ return result
57639
58175
  if auth_failure_text(combined):
57640
58176
  return runtime_diagnostic_payload(backend, "unauthenticated", installed=True, authenticated=False, version=version)
57641
58177
  return runtime_diagnostic_payload(backend, "error", installed=True, authenticated=None, version=version)
@@ -57658,10 +58194,14 @@ def store_runtime_diagnostic(diagnostic: dict[str, Any], *, preserve_last_error:
57658
58194
 
57659
58195
  def expire_codex_login_diagnostic() -> None:
57660
58196
  """A login hint requires new native evidence, never reuse a cached denial."""
58197
+ global CODEX_MODEL_MIGRATION_TARGET
57661
58198
  with RUNTIME_DIAGNOSTICS_LOCK:
58199
+ CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
57662
58200
  current = dict(RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or runtime_diagnostic_payload(
57663
58201
  BACKEND_CODEX, "unknown", installed=None, authenticated=None))
57664
58202
  current["checked_at_epoch"] = 0.0
58203
+ current.pop("_codex_auth_mode", None)
58204
+ current.pop("_codex_auth_mode_checked_at_epoch", None)
57665
58205
  store_runtime_diagnostic(current)
57666
58206
 
57667
58207
 
@@ -57670,6 +58210,8 @@ def runtime_diagnostic(backend: str, *, force: bool = False) -> dict[str, Any]:
57670
58210
  cached = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
57671
58211
  generation = RUNTIME_DIAGNOSTIC_GENERATIONS.get(backend, 0)
57672
58212
  checked_at = cached.get("_installation_checked_at_epoch", cached.get("checked_at_epoch"))
58213
+ if backend == BACKEND_CODEX and cached.get("_codex_auth_mode_checked_at_epoch") is not None:
58214
+ checked_at = min(checked_at or 0, cached["_codex_auth_mode_checked_at_epoch"])
57673
58215
  if not force and isinstance(checked_at, (int, float)) and time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS:
57674
58216
  return cached
57675
58217
  try:
@@ -57814,14 +58356,34 @@ def record_runtime_success(backend: str) -> None:
57814
58356
  version=previous.get("version"),
57815
58357
  executable=(
57816
58358
  str(previous.get("_executable") or "") or None
57817
- if backend in (BACKEND_CURSOR, BACKEND_OPENCODE)
58359
+ if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI)
57818
58360
  else None
57819
58361
  ),
57820
58362
  )
58363
+ if backend == BACKEND_CODEX and previous.get("_codex_auth_mode"):
58364
+ # A successful turn does not establish which login kind was used.
58365
+ # Preserve the existing observation without extending its lifetime.
58366
+ current["_codex_auth_mode"] = previous["_codex_auth_mode"]
58367
+ current["_codex_auth_mode_checked_at_epoch"] = previous.get(
58368
+ "_codex_auth_mode_checked_at_epoch", previous.get("checked_at_epoch", 0),
58369
+ )
57821
58370
  store_runtime_diagnostic(current, preserve_last_error=False)
57822
58371
 
57823
58372
 
57824
58373
  async def ensure_runtime_available(backend: str, *, session: dict[str, Any] | None = None) -> dict[str, Any]:
58374
+ if backend == BACKEND_GEMINI:
58375
+ from gemini_runtime import probe, API_ONLY_MESSAGE
58376
+ if (session or {}).get("provider_connection") != "custom":
58377
+ raise HTTPException(409, API_ONLY_MESSAGE)
58378
+ diagnostic = await asyncio.to_thread(probe, runner_env())
58379
+ store_runtime_diagnostic(diagnostic)
58380
+ if diagnostic.get("status") != "ready":
58381
+ raise HTTPException(503, diagnostic.get("message"))
58382
+ if (session or {}).get("provider_connection") == "custom":
58383
+ from gemini_api import default_model
58384
+ selected = PROVIDER_CONNECTION_STORE.for_session(session)
58385
+ provider_connections.require_model({**selected, "model": default_model(selected)})
58386
+ return diagnostic
57825
58387
  diagnostic = await asyncio.to_thread(runtime_diagnostic, backend)
57826
58388
  if (session or {}).get("provider_connection") == "custom":
57827
58389
  selected = PROVIDER_CONNECTION_STORE.for_session(session)
@@ -57986,6 +58548,26 @@ def codex_default_service_tier(model: str) -> str:
57986
58548
  return CODEX_FALLBACK_SERVICE_TIERS.get(str(model or "").strip(), "")
57987
58549
 
57988
58550
 
58551
+ def codex_retired_model_replacements() -> dict[str, str]:
58552
+ """Scope retirement to native ChatGPT auth, never API/custom endpoints."""
58553
+ with RUNTIME_DIAGNOSTICS_LOCK:
58554
+ diagnostic = RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or {}
58555
+ checked_at = diagnostic.get("_codex_auth_mode_checked_at_epoch", diagnostic.get("checked_at_epoch")) or 0
58556
+ observed = (str(diagnostic.get("_codex_auth_mode") or "")
58557
+ if time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS else "")
58558
+ replacement = CODEX_MODEL_MIGRATION_TARGET
58559
+ mode = codex_auth.native_model_auth_mode(
58560
+ codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD), observed_mode=observed,
58561
+ )
58562
+ if mode != "chatgpt":
58563
+ return {}
58564
+ return {model: replacement for model in sorted(codex_auth.CHATGPT_RETIRED_MODELS)}
58565
+
58566
+
58567
+ def codex_available_model(model: str) -> str:
58568
+ return codex_retired_model_replacements().get(model, model)
58569
+
58570
+
57989
58571
  # The Codex app-server's thread/start and turn-override JSON-RPC params only
57990
58572
  # accept a narrower serviceTier enum ("fast"/"flex") than the canonical
57991
58573
  # service_tier values used for `codex exec` CLI config overrides (see
@@ -58002,6 +58584,7 @@ def codex_app_server_service_tier(service_tier: str) -> str:
58002
58584
 
58003
58585
 
58004
58586
  def discover_codex_catalog() -> dict[str, Any]:
58587
+ global CODEX_MODEL_MIGRATION_TARGET
58005
58588
  models: list[dict[str, Any]] = []
58006
58589
  model_options: list[dict[str, Any]] = []
58007
58590
  effort_options: list[dict[str, Any]] = []
@@ -58024,16 +58607,29 @@ def discover_codex_catalog() -> dict[str, Any]:
58024
58607
  model_source = f"{model_source} failed"
58025
58608
  effort_source = f"{effort_source} failed"
58026
58609
 
58610
+ replacements = codex_retired_model_replacements()
58027
58611
  visible_models = [
58028
58612
  model for model in models
58029
58613
  if str(model.get("visibility") or "list") == "list" and model.get("supported_in_api", True) is not False
58614
+ and str(model.get("slug") or model.get("id") or "").strip() not in replacements
58030
58615
  ]
58031
58616
  visible_models.sort(key=runtime_priority)
58617
+ if replacements and visible_models:
58618
+ # Use an available configured default, then the native default/priority.
58619
+ # The fallback is only needed when the CLI cannot return a catalog.
58620
+ slugs = [str(entry.get("slug") or entry.get("id") or "").strip() for entry in visible_models]
58621
+ preferred = next((slug for slug in (configured_model, CODEX_DEFAULT_MODEL) if slug in slugs), None)
58622
+ if preferred is None:
58623
+ 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])
58624
+ if preferred:
58625
+ with RUNTIME_DIAGNOSTICS_LOCK:
58626
+ CODEX_MODEL_MIGRATION_TARGET = preferred
58627
+ replacements = {model: preferred for model in replacements}
58032
58628
  # The selectable "Server default" must describe the exact settings that
58033
58629
  # codex_runtime_settings() launches for a chat with no explicit override.
58034
58630
  # The CLI's discovered list can omit a configured/custom default (or a
58035
58631
  # legacy fallback), so the first discovered model is not a safe proxy.
58036
- default_model = configured_model or CODEX_DEFAULT_MODEL
58632
+ default_model = replacements.get(configured_model or CODEX_DEFAULT_MODEL, configured_model or CODEX_DEFAULT_MODEL)
58037
58633
  default_effort = clamp_codex_runtime_effort(
58038
58634
  default_model,
58039
58635
  configured_effort or CODEX_DEFAULT_EFFORT,
@@ -58080,6 +58676,8 @@ def discover_codex_catalog() -> dict[str, Any]:
58080
58676
 
58081
58677
  if not model_options:
58082
58678
  for slug, label in CODEX_FALLBACK_MODELS:
58679
+ if slug in replacements:
58680
+ continue
58083
58681
  fallback_efforts = [
58084
58682
  runtime_option(effort, title_effort_label(effort))
58085
58683
  for effort in CODEX_FALLBACK_MODEL_EFFORTS.get(slug, CODEX_FALLBACK_EFFORTS)
@@ -58089,7 +58687,7 @@ def discover_codex_catalog() -> dict[str, Any]:
58089
58687
  model_options.append(runtime_option(slug, label, efforts=fallback_efforts, service_tier=service_tier or None))
58090
58688
  if not effort_options:
58091
58689
  effort_options.extend(runtime_option(effort, title_effort_label(effort)) for effort in CODEX_FALLBACK_EFFORTS)
58092
- if configured_model and not any(option.get("value") == configured_model for option in model_options):
58690
+ if configured_model and configured_model not in replacements and not any(option.get("value") == configured_model for option in model_options):
58093
58691
  model_options.insert(0, runtime_option(configured_model, title_model_label(configured_model)))
58094
58692
  if configured_effort and not any(option.get("value") == configured_effort for option in effort_options):
58095
58693
  effort_options.append(runtime_option(configured_effort, title_effort_label(configured_effort)))
@@ -58112,6 +58710,7 @@ def discover_codex_catalog() -> dict[str, Any]:
58112
58710
  "default_model": default_model or None,
58113
58711
  "default_effort": default_effort or None,
58114
58712
  "default_service_tier": default_service_tier or None,
58713
+ "model_replacements": replacements,
58115
58714
  }
58116
58715
 
58117
58716
 
@@ -58607,6 +59206,13 @@ def discover_runtime_backend_catalog(backend: str, *, force_runtime_probe: bool
58607
59206
  diagnostic = runtime_diagnostic(backend, force=force_runtime_probe)
58608
59207
  ready = diagnostic.get("status") == "ready"
58609
59208
  executable = str(diagnostic.get("_executable") or "") or None
59209
+ if backend == BACKEND_GEMINI:
59210
+ return {"models": [], "default_model": None,
59211
+ "efforts": [], "default_effort": None, "effort_source": "none",
59212
+ "model_source": "Configure a Gemini API connection in My Agents",
59213
+ "available": False, "diagnostic": public_runtime_diagnostic(diagnostic),
59214
+ "native_credentials_present": False,
59215
+ "custom_provider": PROVIDER_CONNECTION_STORE.catalog(backend, installed=ready)}
58610
59216
  if backend == BACKEND_CLAUDE:
58611
59217
  catalog = parse_claude_help_catalog()
58612
59218
  elif backend == BACKEND_CODEX:
@@ -58699,7 +59305,7 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
58699
59305
  catalog = CODEX_PROVIDER_STORE.cached_catalog(selected)
58700
59306
  return model, codex_provider.runtime_effort(selected, catalog, sess.get("effort")), ""
58701
59307
  configured_model, configured_effort, configured_service_tier = codex_user_config_defaults()
58702
- model = str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
59308
+ model = codex_available_model(str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip())
58703
59309
  effort = clamp_codex_runtime_effort(
58704
59310
  model,
58705
59311
  sess.get("effort") or configured_effort or CODEX_DEFAULT_EFFORT,
@@ -58708,6 +59314,39 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
58708
59314
  return model, effort, service_tier
58709
59315
 
58710
59316
 
59317
+ async def migrate_retired_codex_selection(
59318
+ session_id: str, sess: dict[str, Any], *, standalone: bool = False,
59319
+ previous_model: str | None = None,
59320
+ ) -> dict[str, Any]:
59321
+ """Migrate future work under the existing admission lock; retain history.
59322
+
59323
+ No native transcript edits, forks, auth changes, or retries. Native
59324
+ compaction/recovery remains Codex-owned and can still fail independently.
59325
+ """
59326
+ if sess.get("backend") != BACKEND_CODEX or sess.get("codex_provider") == "custom":
59327
+ return sess
59328
+ configured_model, _effort, _tier = codex_user_config_defaults()
59329
+ previous = str(previous_model or sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
59330
+ replacement = codex_available_model(previous)
59331
+ if previous == replacement:
59332
+ return sess
59333
+ patch = {"model": replacement, "effort": clamp_codex_runtime_effort(replacement, sess.get("effort") or _effort or CODEX_DEFAULT_EFFORT)}
59334
+ if standalone:
59335
+ migrated = {**sess, **patch}
59336
+ elif all(sess.get(key) == value for key, value in patch.items()):
59337
+ migrated = sess
59338
+ else:
59339
+ migrated = await STORE.update(session_id, patch)
59340
+ await append_event(session_id, "runtime_model_migrated", {
59341
+ "backend": BACKEND_CODEX, "previous_model": previous, "model": replacement,
59342
+ "standalone": standalone,
59343
+ "message": (f"{previous} is no longer available with ChatGPT sign-in. "
59344
+ f"This {'scheduled run' if standalone else 'chat'} will use {replacement}. "
59345
+ "Conversation history is unchanged."),
59346
+ })
59347
+ return migrated
59348
+
59349
+
58711
59350
  def codex_goal_time_budget_remaining(sess: dict[str, Any]) -> float | None:
58712
59351
  goal = sess.get("codex_goal")
58713
59352
  limit = sess.get("codex_goal_time_budget_seconds")
@@ -59975,7 +60614,7 @@ async def discover_session_provider_commands(
59975
60614
 
59976
60615
  backend = str(session.get("backend") or DEFAULT_BACKEND).strip().lower()
59977
60616
  requested_cwd = str(session.get("cwd") or DEFAULT_CWD)
59978
- if backend == BACKEND_OPENCODE:
60617
+ if backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
59979
60618
  try:
59980
60619
  cwd = validated_opencode_cwd(requested_cwd)
59981
60620
  except HTTPException:
@@ -59989,7 +60628,7 @@ async def discover_session_provider_commands(
59989
60628
  support = provider_command_support(
59990
60629
  False,
59991
60630
  "unavailable",
59992
- "OpenCode local skills require the chat working directory to be available.",
60631
+ f"{'Gemini' if backend == BACKEND_GEMINI else 'OpenCode'} local skills require the chat working directory to be available.",
59993
60632
  )
59994
60633
  return provider_command_snapshot(fallback_empty, support), fallback_empty
59995
60634
  else:
@@ -60029,7 +60668,7 @@ async def discover_session_provider_commands(
60029
60668
  provider_command_snapshot(fallback_empty, support),
60030
60669
  fallback_empty,
60031
60670
  )
60032
- elif backend == BACKEND_OPENCODE:
60671
+ elif backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
60033
60672
  pass
60034
60673
  else:
60035
60674
  support = provider_command_support(
@@ -60123,6 +60762,26 @@ async def discover_session_provider_commands(
60123
60762
  f"Showing the first {MAX_PROVIDER_COMMANDS} provider commands."
60124
60763
  )
60125
60764
  return provider_command_snapshot(inventory, support), inventory
60765
+ if backend == BACKEND_GEMINI:
60766
+ from gemini_runtime import build_environment, list_native_skills, resolve_executable, GeminiConfigurationError
60767
+ def discover_gemini():
60768
+ connection = PROVIDER_CONNECTION_STORE.for_session(session)
60769
+ profile = PROVIDER_CONNECTION_STORE.root / ("gemini-runtime-" + connection["credential_id"]) if connection else None
60770
+ env = build_environment(runner_env(), cwd=cwd, api_home=profile,
60771
+ api_key=connection.get("api_key") if connection else None,
60772
+ base_url=connection.get("base_url") if connection else None,
60773
+ native_history=bool(session.get("gemini_native_history")))
60774
+ executable = resolve_executable(env)
60775
+ if not executable:
60776
+ raise GeminiConfigurationError("Install Gemini CLI to discover its skills.")
60777
+ return gemini_provider_skill_inventory(list_native_skills(executable, env, cwd),
60778
+ cwd=cwd, selector_secret=identity, binding_context=session_id)
60779
+ try:
60780
+ inventory = await asyncio.to_thread(discover_gemini)
60781
+ except (GeminiConfigurationError, ProviderCommandDiscoveryError, HTTPException, OSError, ValueError):
60782
+ return provider_command_snapshot(empty, provider_command_support(False, "unavailable",
60783
+ "Gemini skills could not be read. Check gemini skills list --all in this workspace and refresh.")), empty
60784
+ return provider_command_snapshot(inventory, provider_command_support(True, "native_activation_request")), inventory
60126
60785
  if backend == BACKEND_OPENCODE:
60127
60786
  try:
60128
60787
  env = runner_env()
@@ -60200,6 +60859,8 @@ async def resolve_provider_command_selection(
60200
60859
  if backend == BACKEND_CLAUDE
60201
60860
  else OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY
60202
60861
  if backend == BACKEND_OPENCODE
60862
+ else GEMINI_CLIENT_CAPABILITY
60863
+ if backend == BACKEND_GEMINI
60203
60864
  else None
60204
60865
  )
60205
60866
  if required_capability is None:
@@ -60637,7 +61298,9 @@ async def update_reasoning_summary_stream(
60637
61298
  phase: str = "summary",
60638
61299
  ) -> None:
60639
61300
  """Keep provider-exposed summary and plaintext reasoning channels distinct."""
60640
- if phase not in {"summary", "reasoning"}:
61301
+ if phase not in {"summary", "reasoning", "assistant"}:
61302
+ return
61303
+ if phase == "assistant" and (metadata or {}).get("backend") != "gemini":
60641
61304
  return
60642
61305
  index = params.get("contentIndex" if phase == "reasoning" else "summaryIndex", 0)
60643
61306
  delta = params.get("delta", "")
@@ -60657,7 +61320,7 @@ async def update_reasoning_summary_stream(
60657
61320
  after_seq = await asyncio.to_thread(last_event_seq_from_file, events_path(session_id))
60658
61321
  state = {"parts": {}, "item": {
60659
61322
  **dict(metadata or {}), "run_id": run_id, "item_id": item_id,
60660
- "backend": BACKEND_CODEX, "phase": phase, "text": "",
61323
+ "backend": (metadata or {}).get("backend", BACKEND_CODEX), "phase": phase, "text": "",
60661
61324
  "ts": now_iso(), "after_seq": after_seq,
60662
61325
  }}
60663
61326
  streams[(run_id, item_id, phase)] = state
@@ -68856,6 +69519,35 @@ async def retain_codex_goal_run_owner(
68856
69519
  return True
68857
69520
 
68858
69521
 
69522
+ def codex_native_archive_rejection(exc: BaseException, thread_id: str) -> bool:
69523
+ """Only the owning native resume RPC can report this state, not chat text."""
69524
+ return bool(
69525
+ thread_id and isinstance(exc, CodexAppServerRequestError)
69526
+ and exc.method == "thread/resume" and isinstance(exc.error, dict)
69527
+ and exc.error.get("message") == (
69528
+ f"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
69529
+ )
69530
+ )
69531
+
69532
+
69533
+ async def archive_rejected_codex_session(session_id: str, thread_id: str, run_id: str) -> None:
69534
+ """Mirror a confirmed native archive through the normal local archive path.
69535
+
69536
+ This never archives/unarchives/deletes the native thread. It retires local
69537
+ routes and jobs exactly like the user's Archive action, preserving history.
69538
+ Identity is stable while this run owns the chat; ignore stale/deleted runs.
69539
+ """
69540
+ current = STORE.sessions.get(session_id)
69541
+ active = ACTIVE.get(session_id) or {}
69542
+ if (not current or current.get("archived") or session_id in DELETING_SESSIONS
69543
+ or current.get("backend") != BACKEND_CODEX
69544
+ or str(session_provider_id(current) or "") != thread_id
69545
+ or active.get("run_id") != run_id):
69546
+ return
69547
+ await _update_session(session_id, UpdateSessionRequest(archived=True),
69548
+ native_archive_owner=(thread_id, run_id))
69549
+
69550
+
68859
69551
  async def run_codex_app_server(
68860
69552
  session_id: str,
68861
69553
  run_id: str,
@@ -71279,6 +71971,7 @@ async def run_codex_app_server(
71279
71971
  thread_pinned = False
71280
71972
  can_fallback = (
71281
71973
  allow_exec_fallback
71974
+ and not codex_native_archive_rejection(exc, str(session_provider_id(sess) or ""))
71282
71975
  and codex_provider.session_choice(sess.get("codex_provider")) == "default"
71283
71976
  and provider_command is None
71284
71977
  and not stop_requested
@@ -71343,6 +72036,15 @@ async def run_codex_app_server(
71343
72036
  **current_metadata(),
71344
72037
  })
71345
72038
  error_emitted = True
72039
+ if codex_native_archive_rejection(exc, str(session_provider_id(sess) or "")):
72040
+ try:
72041
+ await archive_rejected_codex_session(
72042
+ session_id, str(session_provider_id(sess)), current_run_id,
72043
+ )
72044
+ except Exception:
72045
+ # Preserve the actual provider error if local retirement
72046
+ # fails. Never pretend the native thread was unarchived.
72047
+ logger.exception("Could not mirror native Codex archive session=%s", session_id)
71346
72048
  finally:
71347
72049
  async def settle_transport_finalizer() -> None:
71348
72050
  """Retire every owner before cancellation can escape the runner."""
@@ -71738,7 +72440,7 @@ async def start_turn(
71738
72440
  scheduled_job_revision: str | None = None,
71739
72441
  scheduled_job_manual_run: bool = False,
71740
72442
  ) -> dict[str, Any]:
71741
- await wait_for_queue_recovery_admission()
72443
+ await wait_for_queue_recovery_admission(session_id)
71742
72444
  if queue_if_busy and provider_context_mode == "chat":
71743
72445
  await reconcile_idle_queue_session(
71744
72446
  session_id,
@@ -71811,7 +72513,7 @@ async def _start_turn_locked(
71811
72513
  # Internal delivery paths can enter with the lifecycle lock already held
71812
72514
  # and intentionally bypass ``start_turn``. They still must not start or
71813
72515
  # queue ahead of an undiscovered durable startup row.
71814
- await wait_for_queue_recovery_admission()
72516
+ await wait_for_queue_recovery_admission(session_id)
71815
72517
  if (
71816
72518
  req.purpose == SECURE_PEER_DELIVERY_PURPOSE
71817
72519
  and not SECURE_PEER_AGENT_RELAY_ENABLED
@@ -71833,6 +72535,8 @@ async def _start_turn_locked(
71833
72535
  raise HTTPException(status_code=404, detail="session not found")
71834
72536
  if sess.get("archived"):
71835
72537
  raise HTTPException(status_code=409, detail="archived chats cannot start turns")
72538
+ if sess.get("gemini_cleanup_pending_run_id"):
72539
+ 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.")
71836
72540
  if req.purpose == "chat_mailbox_wake" or mailbox_wake_claim is not None:
71837
72541
  if (req.purpose != "chat_mailbox_wake" or mailbox_wake_claim is None
71838
72542
  or mailbox_wake_claim.get("target_session_id") != session_id
@@ -72122,6 +72826,9 @@ async def _start_turn_locked(
72122
72826
  ),
72123
72827
  runtime_validation_patch,
72124
72828
  )
72829
+ if runtime_preview_session.get("backend") == BACKEND_GEMINI:
72830
+ if GEMINI_CLIENT_CAPABILITY not in req.client_capabilities:
72831
+ raise HTTPException(409, "Update AgentsDock to a Gemini-capable client before sending messages.")
72125
72832
  if str(runtime_preview_session.get("backend") or DEFAULT_BACKEND).strip().lower() == BACKEND_CODEX:
72126
72833
  await prepare_codex_login_turn(runtime_preview_session)
72127
72834
  opencode_cwd: str | None = None
@@ -72168,6 +72875,10 @@ async def _start_turn_locked(
72168
72875
  session_id,
72169
72876
  list(item.get("file_ids") or []),
72170
72877
  )
72878
+ if runtime_preview_session.get("backend") == BACKEND_GEMINI:
72879
+ from gemini_content import attachments as gemini_attachments
72880
+ await asyncio.to_thread(gemini_attachments, sys.modules[__name__], session_id,
72881
+ list(normalized_lineage[-1].get("file_ids") or []) if len(normalized_lineage) >= 2 else list(req.file_ids))
72171
72882
  # OpenCode's native --file path has no provider-side size ceiling and can
72172
72883
  # eagerly ingest local data. Resolve and bound the exact current message
72173
72884
  # before either reserving a slot or durably accepting it into the queue.
@@ -72384,6 +73095,9 @@ async def _start_turn_locked(
72384
73095
  if team_mail_grant_mutation:
72385
73096
  team_mail_route_snapshot = team_mail_grants.admission_snapshot(team_mail_grant_mutation, sess)
72386
73097
  fields_set = runtime_fields_set
73098
+ migration_previous_model = str(
73099
+ req.model if "model" in fields_set and req.model else sess.get("model") or ""
73100
+ ).strip() or None
72387
73101
  runtime_patch: dict[str, Any] = {}
72388
73102
  if req.backend:
72389
73103
  runtime_patch["backend"] = req.backend
@@ -72412,14 +73126,20 @@ async def _start_turn_locked(
72412
73126
 
72413
73127
  backend = sess.get("backend") or DEFAULT_BACKEND
72414
73128
  runtime_status = await ensure_runtime_available(backend, session=sess)
73129
+ if backend == BACKEND_CODEX:
73130
+ sess = await migrate_retired_codex_selection(
73131
+ session_id, sess, standalone=provider_context_mode == "standalone",
73132
+ previous_model=migration_previous_model,
73133
+ )
72415
73134
  if provider_context_mode == "chat":
72416
73135
  sess = await STORE.mark_backend_started(session_id, str(backend))
72417
- if backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
73136
+ if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
72418
73137
  # Keep the exact compatibility-probed executable pinned through
72419
73138
  # admission; mark_backend_started returns a fresh durable snapshot.
72420
73139
  sess = dict(sess)
72421
73140
  executable_key = (
72422
- "_opencode_executable" if backend == BACKEND_OPENCODE
73141
+ "_gemini_executable" if backend == BACKEND_GEMINI
73142
+ else "_opencode_executable" if backend == BACKEND_OPENCODE
72423
73143
  else "_cursor_executable"
72424
73144
  )
72425
73145
  sess[executable_key] = runtime_status.get("_executable")
@@ -72733,7 +73453,7 @@ async def _start_turn_locked(
72733
73453
  else None
72734
73454
  ),
72735
73455
  )
72736
- if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
73456
+ if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
72737
73457
  provider_authority_context += (
72738
73458
  "\n\n[AgentsDock final-result handoff]\n"
72739
73459
  "Your successful non-empty final answer will be delivered once to the explicitly referenced chat. "
@@ -72763,7 +73483,7 @@ async def _start_turn_locked(
72763
73483
  prompt,
72764
73484
  )
72765
73485
  provider_prompt = provider_turn_payload.user_prompt
72766
- if backend == BACKEND_CURSOR or (
73486
+ if backend in {BACKEND_CURSOR, BACKEND_GEMINI} or (
72767
73487
  backend == BACKEND_OPENCODE and resolved_provider_command is None
72768
73488
  ):
72769
73489
  # These print-mode CLIs have no separate system/application-context
@@ -72833,6 +73553,8 @@ async def _start_turn_locked(
72833
73553
  else:
72834
73554
  started_payload["model"] = sess.get("model")
72835
73555
  started_payload["effort"] = sess.get("effort")
73556
+ if getattr(req, "client_request_id", None):
73557
+ started_payload["client_request_id"] = req.client_request_id
72836
73558
  if queued_id:
72837
73559
  started_payload["queued_id"] = queued_id
72838
73560
  run_metadata = {
@@ -72912,6 +73634,7 @@ async def _start_turn_locked(
72912
73634
  or mailbox_wake_claim is not None
72913
73635
  or getattr(req, "shared_chat_id", None) is not None
72914
73636
  or queued_id is not None
73637
+ or getattr(req, "client_request_id", None) is not None
72915
73638
  )
72916
73639
  else append_event(session_id, "turn_started", started_payload)
72917
73640
  )
@@ -73085,6 +73808,21 @@ async def _start_turn_locked(
73085
73808
  else {}
73086
73809
  ),
73087
73810
  )
73811
+ elif backend == BACKEND_GEMINI:
73812
+ from gemini_runner import run as run_gemini
73813
+ task = run_gemini(sys.modules[__name__], session_id, run_id, provider_prompt,
73814
+ dict(sess), manifest_path,
73815
+ file_ids=prompt_file_ids,
73816
+ communication_required=bool(
73817
+ any(reference.action in {"route", "direct_message", "instruction", "request_reply"}
73818
+ for reference in req.chat_references)
73819
+ or req.team_references or team_mail_command
73820
+ or mailbox_wake_claim is not None
73821
+ or req.purpose in {"cross_chat_handoff_delivery", "secure_peer_handoff_delivery"}
73822
+ ),
73823
+ provider_command=resolved_provider_command,
73824
+ provider_runtime_env=provider_turn_payload.runtime_env,
73825
+ standalone_provider_context=provider_context_mode == "standalone")
73088
73826
  elif backend == BACKEND_OPENCODE:
73089
73827
  task = run_opencode(
73090
73828
  session_id,
@@ -76274,7 +77012,7 @@ def ensure_managed_update_service_cgroup_clear(
76274
77012
  *,
76275
77013
  service_cgroup: str | None = None,
76276
77014
  ) -> dict[str, Any]:
76277
- """Fail closed unless only this server remains in its service cgroup."""
77015
+ """Require no children, or prove this worker's stop policy preserves them."""
76278
77016
 
76279
77017
  state = managed_update_service_cgroup_state(
76280
77018
  service_cgroup=service_cgroup,
@@ -77375,6 +78113,7 @@ async def lifespan(app: FastAPI):
77375
78113
  cleanup_abandoned_fork_provider_threads(startup_abandoned_fork_threads)
77376
78114
  )
77377
78115
  host_monitor_task = asyncio.create_task(host_monitor_loop())
78116
+ queue_checkpoint_task = asyncio.create_task(queue_checkpoint_loop())
77378
78117
  history_search_task = asyncio.create_task(history_search_index_loop())
77379
78118
  runtime_probe_task = asyncio.create_task(asyncio.to_thread(refresh_runtime_diagnostics, force=True))
77380
78119
  if CONFIG_ENV_APPLIED:
@@ -77452,6 +78191,10 @@ async def lifespan(app: FastAPI):
77452
78191
  cross_chat_expiry_task.cancel()
77453
78192
  digest_recovery_task.cancel()
77454
78193
  queue_recovery_task.cancel()
78194
+ queue_checkpoint_task.cancel()
78195
+ history_tasks = (*HISTORY_MAINTENANCE_TASKS.values(), *(item[1] for item in TURN_RECEIPT_TASKS.values()))
78196
+ for task in history_tasks:
78197
+ task.cancel()
77455
78198
  server_update_pending_waiter_task.cancel()
77456
78199
  await bounded_shutdown_phase(
77457
78200
  "startup-reconciliation",
@@ -77460,6 +78203,8 @@ async def lifespan(app: FastAPI):
77460
78203
  cross_chat_expiry_task,
77461
78204
  digest_recovery_task,
77462
78205
  queue_recovery_task,
78206
+ queue_checkpoint_task,
78207
+ *history_tasks,
77463
78208
  server_update_pending_waiter_task,
77464
78209
  *(
77465
78210
  (health_queue_reconcile_task,)
@@ -80779,6 +81524,7 @@ async def health() -> dict[str, Any]:
80779
81524
  team_hub_capability = await team_hub_capability_for_health()
80780
81525
  return {
80781
81526
  "ok": True,
81527
+ "chat_readiness": queue_recovery_readiness(),
80782
81528
  "server_version": SERVER_VERSION,
80783
81529
  "api_contract_version": API_CONTRACT_VERSION,
80784
81530
  "server_identity": server_identity(),
@@ -80800,7 +81546,7 @@ async def health() -> dict[str, Any]:
80800
81546
  "side_questions": side_questions.capability(),
80801
81547
  "codex_auth_v1": codex_auth.capability(available=bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC),
80802
81548
  "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},
80803
- "provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR]},
81549
+ "provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR, BACKEND_GEMINI]},
80804
81550
  "team_mail_hints_v1": SECURE_PEER_RUNTIME.team_mail_hint_capability(),
80805
81551
  "team_mail_hints_v2": SECURE_PEER_RUNTIME.team_notification_hint_capability(),
80806
81552
  "websocket_auth_v1": {
@@ -80815,6 +81561,7 @@ async def health() -> dict[str, Any]:
80815
81561
  "event_protocol": EVENTS_WEBSOCKET_PROTOCOL,
80816
81562
  "terminal_protocol": TERMINAL_WEBSOCKET_PROTOCOL,
80817
81563
  },
81564
+ "event_stream_liveness_v1": {"available": True, "version": 1},
80818
81565
  "team_hub_v1": team_hub_capability,
80819
81566
  "team_hub_host_control_v1": team_hub_host_control_capability(),
80820
81567
  "agent_team_mail_routes_v1": {
@@ -80921,16 +81668,19 @@ async def health() -> dict[str, Any]:
80921
81668
  BACKEND_CODEX,
80922
81669
  BACKEND_CLAUDE,
80923
81670
  BACKEND_OPENCODE,
81671
+ BACKEND_GEMINI,
80924
81672
  ],
80925
81673
  "client_capability_by_backend": {
80926
81674
  BACKEND_CODEX: CODEX_INTERACTIVE_CLIENT_CAPABILITY,
80927
81675
  BACKEND_CLAUDE: CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY,
80928
81676
  BACKEND_OPENCODE: OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY,
81677
+ BACKEND_GEMINI: GEMINI_CLIENT_CAPABILITY,
80929
81678
  },
80930
81679
  "supported_kinds_by_backend": {
80931
81680
  BACKEND_CODEX: ["skill"],
80932
81681
  BACKEND_CLAUDE: ["command"],
80933
81682
  BACKEND_OPENCODE: ["skill"],
81683
+ BACKEND_GEMINI: ["skill"],
80934
81684
  },
80935
81685
  "opencode_scope": {
80936
81686
  "discovery": "bounded_documented_default_skill_roots",
@@ -81001,6 +81751,25 @@ async def health() -> dict[str, Any]:
81001
81751
  "max_client_frame_bytes": PORT_TUNNEL_MAX_CLIENT_FRAME_BYTES,
81002
81752
  **port_tunnel_status,
81003
81753
  },
81754
+ "gemini_backend": {
81755
+ # Server support is always advertised; CLI installation and
81756
+ # API connection readiness are checked separately at runtime.
81757
+ "available": True, "required": False, "version": 1, "transport": "acp",
81758
+ "permission_modes": ["default", "autoEdit", "yolo"],
81759
+ "default_permission_mode": "default",
81760
+ "message": "Gemini requires a compatible Gemini CLI and a configured API connection in My Agents.", "action": None,
81761
+ "client_capability": GEMINI_CLIENT_CAPABILITY,
81762
+ "history_mode": "initial_text_snapshot",
81763
+ "features": {"resume": True, "external_import": True, "fork": False,
81764
+ "native_import_api": True,
81765
+ "history_reconciliation": False,
81766
+ "approvals": True, "skills_picker": True, "custom_api": True,
81767
+ "attachments": True, "live_text": True, "token_usage": True,
81768
+ "scheduled_jobs": True, "interactive_shares": True,
81769
+ "native_goals": False, "native_side_questions": False,
81770
+ "managed_subagents": False, "native_compaction_control": False,
81771
+ "permission_mode_control": True},
81772
+ },
81004
81773
  "opencode_backend": {
81005
81774
  # ``available`` advertises the server contract. Runtime
81006
81775
  # readiness is reported independently by
@@ -81156,6 +81925,7 @@ async def health() -> dict[str, Any]:
81156
81925
  "max_active_alerts": EMERGENCY_ACTIVE_ALERT_LIMIT,
81157
81926
  "stream_path": "/api/emergency-alerts/events",
81158
81927
  "stream_protocol": EMERGENCY_WEBSOCKET_PROTOCOL,
81928
+ "acknowledge_through_alert_id": True,
81159
81929
  },
81160
81930
  "cross_chat_handoffs_v1": cross_chat_handoffs_capability(),
81161
81931
  "codex_controls": {
@@ -82443,6 +83213,9 @@ app.include_router(provider_connections.create_router(
82443
83213
  ),
82444
83214
  ))
82445
83215
 
83216
+ from gemini_runner import router as gemini_router
83217
+ app.include_router(gemini_router(sys.modules[__name__]))
83218
+
82446
83219
  app.include_router(codex_provider.create_router(
82447
83220
  authorize=require_native_admin_control,
82448
83221
  store=CODEX_PROVIDER_STORE,
@@ -82863,6 +83636,7 @@ async def submit_interactive_chat_prompt(
82863
83636
  result = await post_turn(session_id, TurnRequest(
82864
83637
  prompt=prompt, file_ids=file_ids, shared_chat_id=share_id,
82865
83638
  shared_chat_request_id=request_id,
83639
+ client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
82866
83640
  ))
82867
83641
  if not isinstance(result, dict) or type(result.get("queued")) is not bool:
82868
83642
  raise HTTPException(503, "Message acceptance could not be confirmed")
@@ -82969,13 +83743,21 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
82969
83743
  projected[key] = strip_agentsdock_generated_user_text(projected[key], expected_session_id=session_id)
82970
83744
  queue.append(projected)
82971
83745
  model, effort = str(session.get("model") or ""), str(session.get("effort") or "")
83746
+ gemini_fields = {}
83747
+ if backend == "gemini":
83748
+ from gemini_runner import PERMISSIONS, owns
83749
+ gemini_fields["gemini_runtime"] = {"available": True, "pending_interactions": [
83750
+ shared_native_value(row["public"]) for row in PERMISSIONS.values()
83751
+ if row["public"]["session_id"] == session_id and owns(sys.modules[__name__], session_id, row["run_id"])]}
82972
83752
  snapshot = shared_native_value({
82973
83753
  "session": public, "events": page["events"], "queue": queue, "active": active,
82974
83754
  "hasMoreEvents": page["has_more"], "nextTimelineBefore": page.get("next_before"),
82975
83755
  "eventsTotal": page.get("semantic_total"), "goal": goal,
82976
83756
  "jobs": (await list_session_jobs(session_id))["jobs"],
82977
83757
  "codex_runtime": codex_runtime, "claude_runtime": claude_runtime,
83758
+ **gemini_fields,
82978
83759
  "health": {"capabilities": {
83760
+ **({"gemini_backend": {"available": True, "version": 1}} if backend == "gemini" else {}),
82979
83761
  "codex_controls": {"available": codex_available},
82980
83762
  "claude_controls": {"available": claude_available,
82981
83763
  "interactive_client_capability": CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY},
@@ -83006,6 +83788,7 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
83006
83788
  async def steer_interactive_chat_prompt(session_id: str, prompt: str, *, share_id: str, request_id: str) -> dict[str, Any]:
83007
83789
  receipt = await post_turn(session_id, TurnRequest(
83008
83790
  prompt=prompt, shared_chat_id=share_id, shared_chat_request_id=request_id,
83791
+ client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
83009
83792
  ))
83010
83793
  if not isinstance(receipt, dict) or type(receipt.get("queued")) is not bool:
83011
83794
  raise HTTPException(503, "Message acceptance could not be confirmed")
@@ -83102,6 +83885,9 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
83102
83885
  INTERACTIVE_CHAT_CATALOG = (time.monotonic(), shared_native_value(await runtime_catalog()))
83103
83886
  value = INTERACTIVE_CHAT_CATALOG[1]
83104
83887
  return shared_native_value(value)
83888
+ async def shared_gemini_approval(sid, interaction_id, response):
83889
+ from gemini_runner import resolve_permission
83890
+ return await resolve_permission(sys.modules[__name__], sid, interaction_id, response)
83105
83891
  callbacks = {
83106
83892
  "turn.stop": stop_turn_endpoint, "turn.steer": steer_interactive_chat_prompt,
83107
83893
  "queue.run_now": post_run_queued_turn_now, "queue.edit": patch_queued_turn,
@@ -83111,6 +83897,7 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
83111
83897
  "job.create": create_session_job, "job.update": update_session_job,
83112
83898
  "job.toggle": update_session_job, "job.delete": delete_session_job, "job.run": run_interactive_chat_job,
83113
83899
  "approval.codex": post_codex_interaction_response, "approval.claude": post_claude_interaction_response,
83900
+ "approval.gemini": shared_gemini_approval,
83114
83901
  }
83115
83902
  models = {model.__name__: model for model in (
83116
83903
  UpdateSessionRequest, CodexGoalRequest, CreateScopedJobRequest, UpdateJobRequest,
@@ -85903,6 +86690,7 @@ async def create_session_with_history(req: CreateSessionRequest) -> dict[str, An
85903
86690
  async def get_local_sessions(
85904
86691
  limit: int = Query(default=200, ge=1, le=MAX_LOCAL_SESSION_LIST_ITEMS),
85905
86692
  include_cursor: bool = False,
86693
+ include_gemini: bool = False,
85906
86694
  ) -> dict[str, Any]:
85907
86695
  """List main conversations unused by any installed same-user local instance."""
85908
86696
 
@@ -85913,6 +86701,8 @@ async def get_local_sessions(
85913
86701
  for key in local_session_ownership.provider_session_keys(session, DEFAULT_BACKEND)
85914
86702
  }
85915
86703
  options = {"include_cursor": True} if include_cursor else {}
86704
+ if include_gemini:
86705
+ options["include_gemini"] = True
85916
86706
  sessions = await asyncio.to_thread(local_session_candidates, limit, known_provider_keys, **options)
85917
86707
  return {"sessions": sessions}
85918
86708
 
@@ -85986,6 +86776,7 @@ async def bulk_import_sessions_guarded(
85986
86776
  MAX_LOCAL_SESSION_LIST_ITEMS,
85987
86777
  set(),
85988
86778
  **({"include_cursor": True} if any(item.backend == BACKEND_CURSOR for item in req.items) else {}),
86779
+ **({"include_gemini": True} if any(item.backend == BACKEND_GEMINI for item in req.items) else {}),
85989
86780
  )
85990
86781
  candidate_by_key = {
85991
86782
  (str(candidate["backend"]), str(candidate["provider_session_id"])): candidate
@@ -85995,6 +86786,10 @@ async def bulk_import_sessions_guarded(
85995
86786
  results: list[dict[str, Any]] = []
85996
86787
  for item in req.items:
85997
86788
  key = (item.backend, item.provider_session_id)
86789
+ if item.provider_connection and item.backend != BACKEND_GEMINI:
86790
+ results.append(bulk_import_result(item, ok=False, code="invalid_connection",
86791
+ error="API binding on import is only supported for Gemini."))
86792
+ continue
85998
86793
  if key in requested_keys:
85999
86794
  results.append(bulk_import_result(
86000
86795
  item,
@@ -86035,7 +86830,11 @@ async def bulk_import_sessions_guarded(
86035
86830
 
86036
86831
  staged_session: dict[str, Any] | None = None
86037
86832
  try:
86038
- if item.backend == BACKEND_CURSOR:
86833
+ if item.backend == BACKEND_GEMINI:
86834
+ from gemini_import import initial_history
86835
+ source_path, history_items = await asyncio.to_thread(initial_history,
86836
+ sys.modules[__name__], runner_env(), item.provider_session_id, candidate_cwd or "")
86837
+ elif item.backend == BACKEND_CURSOR:
86039
86838
  source_path, history_items = await asyncio.to_thread(
86040
86839
  cursor_initial_history, item.provider_session_id, candidate_cwd or "",
86041
86840
  )
@@ -86077,6 +86876,7 @@ async def bulk_import_sessions_guarded(
86077
86876
  staged_session = await STORE.create(
86078
86877
  CreateSessionRequest(
86079
86878
  backend=item.backend,
86879
+ provider_connection=item.provider_connection,
86080
86880
  provider_session_id=item.provider_session_id,
86081
86881
  cwd=candidate_cwd or DEFAULT_CWD,
86082
86882
  title=(item.title or str(candidate.get("label") or "")).strip()
@@ -86353,8 +87153,6 @@ async def get_session(
86353
87153
  normalized_page_mode = str(page_mode or "").strip().lower()
86354
87154
  if normalized_page_mode not in {"", "semantic"}:
86355
87155
  raise HTTPException(status_code=400, detail="page_mode must be semantic")
86356
- if normalized_page_mode != "semantic":
86357
- await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
86358
87156
  page_tail = tail and after <= 0
86359
87157
  semantic_page: dict[str, Any] | None = None
86360
87158
  if normalized_page_mode == "semantic":
@@ -86420,6 +87218,23 @@ async def get_session(
86420
87218
  return response
86421
87219
 
86422
87220
 
87221
+ @app.post("/api/sessions/{session_id}/history/maintenance", status_code=202)
87222
+ async def retry_history_maintenance(session_id: str) -> dict[str, Any]:
87223
+ """Explicit retry clears backoff, never provenance or lifecycle guards."""
87224
+ ensure_session_not_initializing(session_id)
87225
+ ensure_session_not_deleting(session_id)
87226
+ sess = STORE.sessions.get(session_id)
87227
+ if not sess:
87228
+ raise HTTPException(status_code=404, detail="session not found")
87229
+ if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
87230
+ raise HTTPException(status_code=409, detail="history maintenance waits for this chat to become idle")
87231
+ if session_id not in HISTORY_SYNC_SCHEDULED:
87232
+ history_maintenance_state(session_id).clear()
87233
+ schedule_provider_history_sync(sess)
87234
+ return {"scheduled": session_id in HISTORY_SYNC_SCHEDULED,
87235
+ "history_maintenance": dict(history_maintenance_state(session_id))}
87236
+
87237
+
86423
87238
  @app.post("/api/sessions/{session_id}/import-history")
86424
87239
  async def import_history(session_id: str, req: ImportHistoryRequest) -> dict[str, Any]:
86425
87240
  async with session_lifecycle_lock(session_id):
@@ -86715,6 +87530,68 @@ async def ensure_backend_update_allowed(
86715
87530
 
86716
87531
  @app.patch("/api/sessions/{session_id}")
86717
87532
  async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str, Any]:
87533
+ if req.gemini_connect_api:
87534
+ if set(req.model_dump(exclude_unset=True)) != {"gemini_connect_api"}:
87535
+ raise HTTPException(400, "Connect the Gemini API separately from other chat edits.")
87536
+ return await connect_gemini_import_api(session_id)
87537
+ return await _update_session(session_id, req)
87538
+
87539
+
87540
+ async def connect_gemini_import_api(session_id: str) -> dict[str, Any]:
87541
+ """Explicit one-way binding of a native import, not general account switching."""
87542
+ from gemini_import import initial_history
87543
+ from gemini_history import native_snapshot, snapshot_replay
87544
+ from gemini_acp_client import GeminiProtocolError
87545
+ ensure_session_not_initializing(session_id)
87546
+ async with session_lifecycle_lock(session_id):
87547
+ current = STORE.sessions.get(session_id)
87548
+ if not current:
87549
+ raise HTTPException(404, "session not found")
87550
+ if current.get("backend") != BACKEND_GEMINI or not session_provider_id(current):
87551
+ raise HTTPException(400, "Only an imported Gemini conversation can use this action.")
87552
+ if current.get("archived") or session_id in DELETING_SESSIONS:
87553
+ raise HTTPException(409, "Unarchive this chat before connecting its API.")
87554
+ if (session_id in BUSY_SESSIONS or session_id in ACTIVE or QUEUED_TURNS.get(session_id)
87555
+ or current.get("gemini_cleanup_pending_run_id")
87556
+ or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
87557
+ raise HTTPException(409, "Stop active work and clear queued messages before connecting this API.")
87558
+ if current.get("provider_connection") == "custom":
87559
+ PROVIDER_CONNECTION_STORE.for_session(current) # No silent rebind on Forget.
87560
+ return {"session": public_session(current)}
87561
+ provider_id, cwd = session_provider_id(current), str(current.get("cwd") or "")
87562
+ def validate():
87563
+ initial_history(sys.modules[__name__], runner_env(), provider_id, cwd)
87564
+ snapshot_replay(native_snapshot(runner_env(), cwd, provider_id))
87565
+ try:
87566
+ await asyncio.to_thread(validate)
87567
+ except (GeminiProtocolError, OSError, ValueError):
87568
+ raise HTTPException(409, "The original Gemini history is unavailable or changed; refresh Import Chat.") from None
87569
+ async with STORE._lock:
87570
+ if (STORE.sessions.get(session_id) is not current or session_id in DELETING_SESSIONS
87571
+ or current.get("archived") or session_id in BUSY_SESSIONS or session_id in ACTIVE
87572
+ or QUEUED_TURNS.get(session_id) or current.get("gemini_cleanup_pending_run_id")
87573
+ or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
87574
+ raise HTTPException(409, "This chat changed while connecting; wait for active work to finish and retry.")
87575
+ previous = dict(current)
87576
+ selected = PROVIDER_CONNECTION_STORE.bind({"backend": BACKEND_GEMINI, "provider_connection": "custom"})
87577
+ current.update(provider_connection="custom", provider_connection_revision=selected["credential_id"],
87578
+ gemini_native_history=True, gemini_session_cwd=cwd, model=selected.get("model"), updated_at=now_iso())
87579
+ try:
87580
+ await STORE.save(durable=True)
87581
+ except BaseException:
87582
+ current.clear(); current.update(previous)
87583
+ raise
87584
+ return {"session": public_session(current)}
87585
+
87586
+
87587
+ async def _update_session(
87588
+ session_id: str, req: UpdateSessionRequest, *,
87589
+ native_archive_owner: tuple[str, str] | None = None,
87590
+ ) -> dict[str, Any]:
87591
+ # Internal provenance is not an HTTP parameter. Recheck after acquiring
87592
+ # the same lock as Stop, replacement and new-turn admission.
87593
+ if native_archive_owner is not None and req.archived is not True:
87594
+ raise ValueError("native archive ownership only applies to archive")
86718
87595
  ensure_session_not_initializing(session_id)
86719
87596
  patch = req.model_dump(exclude_unset=True)
86720
87597
  if SESSION_LIFECYCLE_UPDATE_FIELDS.intersection(patch):
@@ -86722,6 +87599,12 @@ async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str
86722
87599
  current = STORE.sessions.get(session_id)
86723
87600
  if not current:
86724
87601
  raise HTTPException(status_code=404, detail="session not found")
87602
+ if native_archive_owner is not None:
87603
+ thread_id, run_id = native_archive_owner
87604
+ if (session_id in DELETING_SESSIONS or current.get("backend") != BACKEND_CODEX
87605
+ or str(session_provider_id(current) or "") != thread_id
87606
+ or (ACTIVE.get(session_id) or {}).get("run_id") != run_id):
87607
+ return {"session": public_session(current)}
86725
87608
  if req.archived is False and current.get("archived"):
86726
87609
  try:
86727
87610
  # An earlier archive may have failed to save jobs.json
@@ -88583,7 +89466,7 @@ async def _put_codex_goal_locked(
88583
89466
  if req.status == "active":
88584
89467
  if (STORE.sessions.get(session_id) or {}).get("archived"):
88585
89468
  raise HTTPException(status_code=409, detail="archived chats cannot resume goals")
88586
- await wait_for_queue_recovery_admission()
89469
+ await wait_for_queue_recovery_admission(session_id)
88587
89470
  manager, thread_id, control_session = await acquire_codex_control_thread(
88588
89471
  session_id,
88589
89472
  reserve_session=req.status == "active",
@@ -89370,6 +90253,26 @@ async def acknowledge_session_emergency(
89370
90253
  sess = STORE.sessions.get(session_id)
89371
90254
  if not sess:
89372
90255
  raise HTTPException(status_code=404, detail="session not found")
90256
+ if req.through_alert_id is not None:
90257
+ boundary, alert_ids = await asyncio.to_thread(
90258
+ emergency_alert_batch_through, session_id, req.through_alert_id,
90259
+ )
90260
+ if boundary is None:
90261
+ raise HTTPException(status_code=409, detail="that emergency alert boundary was not found")
90262
+ if alert_ids:
90263
+ events = await append_durable_event_batch(session_id, [
90264
+ ("emergency_alert_acknowledged", {
90265
+ "emergency_alert_id": alert_id,
90266
+ "message": "Emergency acknowledged by the user.",
90267
+ })
90268
+ for alert_id in alert_ids
90269
+ ])
90270
+ committed = events[-1]
90271
+ else:
90272
+ committed = boundary
90273
+ sess = await project_committed_emergency_event(session_id, committed)
90274
+ return {"session": public_session(sess), "acknowledged": bool(alert_ids),
90275
+ "acknowledged_count": len(alert_ids)}
89373
90276
  alert_id = req.expected_alert_id
89374
90277
  acknowledged_event = await asyncio.to_thread(
89375
90278
  find_emergency_acknowledgement_event,
@@ -89535,6 +90438,13 @@ async def discard_empty_session(session_id: str, expected_updated_at: str) -> di
89535
90438
  async def delete_session(session_id: str, discard_updated_at: str | None = None) -> dict[str, Any]:
89536
90439
  async with session_lifecycle_lock(session_id):
89537
90440
  ensure_session_not_initializing(session_id)
90441
+ pending_gemini_cleanup = (STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
90442
+ live_gemini_owner = ACTIVE.get(session_id) or {}
90443
+ if pending_gemini_cleanup and (
90444
+ live_gemini_owner.get("run_id") != pending_gemini_cleanup
90445
+ or not callable(live_gemini_owner.get("gemini_cleanup"))
90446
+ ):
90447
+ raise HTTPException(409, "Gemini runtime cleanup requires server-side verification after restart; the chat and native history were not deleted.")
89538
90448
  if session_id in DELETED_SESSION_TOMBSTONES:
89539
90449
  # A previous delete can have committed before its request was
89540
90450
  # canceled. Retry any tracked bridge retirement on idempotent
@@ -89702,6 +90612,16 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
89702
90612
  "shortly."
89703
90613
  ),
89704
90614
  )
90615
+ elif active.get("backend") == BACKEND_GEMINI:
90616
+ cleanup = active.get("gemini_cleanup")
90617
+ try:
90618
+ if not callable(cleanup):
90619
+ raise RuntimeError("Missing Gemini cleanup owner")
90620
+ await cleanup()
90621
+ except Exception:
90622
+ raise HTTPException(status_code=409, detail=(
90623
+ "Gemini runtime cleanup could not be verified; the chat was not deleted. Retry Stop."
90624
+ )) from None
89705
90625
  elif proc:
89706
90626
  await terminate_process_tree(proc)
89707
90627
 
@@ -89800,6 +90720,17 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
89800
90720
  # initial session record but before its task acknowledges stop.
89801
90721
  # The task registry is now drained, so this reread is stable.
89802
90722
  late_session = STORE.sessions.get(session_id) or {}
90723
+ gemini_active = ACTIVE.get(session_id) or {}
90724
+ if gemini_active.get("backend") == BACKEND_GEMINI and gemini_active.get("gemini_cleanup_failed"):
90725
+ # A completed consumer task does not prove its owned runtime
90726
+ # was reaped. Retry only this chat's cleanup, before deletion.
90727
+ try:
90728
+ await gemini_active["gemini_cleanup"]()
90729
+ from gemini_runner import clear_cleanup_fence
90730
+ await clear_cleanup_fence(sys.modules[__name__], session_id, str(gemini_active.get("run_id") or ""))
90731
+ except Exception:
90732
+ raise HTTPException(409, "Gemini runtime cleanup is incomplete; the chat was not deleted. Retry after stopping its runtime.") from None
90733
+ gemini_active["gemini_cleanup_failed"] = False
89803
90734
  late_provider_thread_id = str(
89804
90735
  session_provider_id(late_session) or ""
89805
90736
  )
@@ -90149,6 +91080,8 @@ async def fork_session(session_id: str, req: ForkSessionRequest) -> dict[str, An
90149
91080
  async with session_lifecycle_lock(session_id):
90150
91081
  try:
90151
91082
  ensure_session_not_deleting(session_id)
91083
+ if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_GEMINI:
91084
+ raise HTTPException(409, "Gemini native fork is not available; create a new chat instead.")
90152
91085
  if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_OPENCODE:
90153
91086
  raise HTTPException(status_code=409, detail="OpenCode native chat fork is unavailable; no transcript-copy fallback is enabled.")
90154
91087
  async with ACTIVE_LOCK:
@@ -90561,8 +91494,123 @@ async def _fork_session_locked(
90561
91494
  return {"session": public_session(child), "sessions": [public_session(sess) for sess in ordered_sessions]}
90562
91495
 
90563
91496
 
91497
+ TURN_RECEIPT_TASKS: dict[tuple[str, str], tuple[str, asyncio.Task]] = {}
91498
+
91499
+
91500
+ def turn_receipt_path(session_id: str, request_id: str) -> Path:
91501
+ return events_path(session_id).parent / "turn-receipts" / f"{request_id}.json"
91502
+
91503
+
91504
+ def write_turn_receipt(path: Path, value: dict) -> None:
91505
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
91506
+ temporary = path.with_suffix(".tmp")
91507
+ with temporary.open("w") as stream:
91508
+ json.dump(value, stream, separators=(",", ":"))
91509
+ stream.flush()
91510
+ os.fsync(stream.fileno())
91511
+ temporary.replace(path)
91512
+ descriptor = os.open(path.parent, os.O_RDONLY)
91513
+ try:
91514
+ os.fsync(descriptor)
91515
+ finally:
91516
+ os.close(descriptor)
91517
+
91518
+
91519
+ def recover_turn_receipt(session_id: str, value: dict) -> dict | None:
91520
+ path = events_path(session_id)
91521
+ stamp = history_file_stamp(path)
91522
+ before = value.get("journal")
91523
+ offset = before[2] if before else 0
91524
+ if stamp is None:
91525
+ if before is not None:
91526
+ raise ValueError("send receipt journal disappeared")
91527
+ return None
91528
+ if before and (stamp[:2] != tuple(before[:2]) or stamp[2] < offset):
91529
+ raise ValueError("send receipt journal was replaced")
91530
+ # An ambiguous old receipt must not cause an unbounded scan or a resend.
91531
+ if stamp[2] - offset > 32 * 1024 * 1024:
91532
+ raise ValueError("send receipt recovery requires a larger journal scan")
91533
+ with path.open("rb") as stream:
91534
+ stream.seek(offset)
91535
+ while stream.tell() < stamp[2]:
91536
+ line = stream.readline(stamp[2] - stream.tell())
91537
+ if not line.endswith(b"\n"):
91538
+ raise ValueError("send receipt journal has an incomplete record")
91539
+ if b'"client_request_id"' not in line:
91540
+ continue
91541
+ event = json.loads(line)
91542
+ if event.get("client_request_id") != value["request_id"]:
91543
+ continue
91544
+ if event.get("type") == "turn_started":
91545
+ return {"run_id": event["run_id"], "queued": False}
91546
+ if event.get("type") == "turn_queued":
91547
+ return {"queued_id": event["queued_id"], "queued": True,
91548
+ "position": event.get("position", 1)}
91549
+ return None
91550
+
91551
+
91552
+ async def execute_receipted_turn(session_id: str, req: TurnRequest, fingerprint: str) -> dict:
91553
+ path = turn_receipt_path(session_id, req.client_request_id)
91554
+ try:
91555
+ value = await asyncio.to_thread(lambda: json.loads(path.read_text()))
91556
+ except FileNotFoundError:
91557
+ value = {"request_id": req.client_request_id, "fingerprint": fingerprint,
91558
+ "journal": history_file_stamp(events_path(session_id)), "status": "pending"}
91559
+ await asyncio.to_thread(write_turn_receipt, path, value)
91560
+ else:
91561
+ if value.get("fingerprint") != fingerprint:
91562
+ raise HTTPException(status_code=409, detail="request ID was already used for a different message")
91563
+ if value.get("status") == "accepted":
91564
+ return {**value["response"], "session": public_session(STORE.sessions[session_id])}
91565
+ try:
91566
+ recovered = await asyncio.to_thread(recover_turn_receipt, session_id, value)
91567
+ except (ValueError, OSError) as exc:
91568
+ raise HTTPException(status_code=409, detail="message acceptance is uncertain; retain this request ID") from exc
91569
+ if recovered is not None:
91570
+ recovered["session"] = public_session(STORE.sessions[session_id])
91571
+ value.update(status="accepted", response=recovered)
91572
+ await asyncio.to_thread(write_turn_receipt, path, value)
91573
+ return recovered
91574
+ response = await post_turn_without_receipt(session_id, req)
91575
+ value.update(status="accepted", response=response)
91576
+ await asyncio.to_thread(write_turn_receipt, path, value)
91577
+ return response
91578
+
91579
+
91580
+ async def post_turn_with_receipt(session_id: str, req: TurnRequest) -> Any:
91581
+ if session_id not in STORE.sessions or session_id in DELETED_SESSION_TOMBSTONES:
91582
+ raise HTTPException(status_code=404, detail="session not found")
91583
+ fingerprint = hashlib.sha256(json.dumps(req.model_dump(mode="json"), sort_keys=True,
91584
+ separators=(",", ":")).encode()).hexdigest()
91585
+ key = (session_id, req.client_request_id)
91586
+ existing = TURN_RECEIPT_TASKS.get(key)
91587
+ if existing is not None and existing[0] != fingerprint:
91588
+ raise HTTPException(status_code=409, detail="request ID was already used for a different message")
91589
+ if existing is None:
91590
+ task = asyncio.create_task(execute_receipted_turn(session_id, req, fingerprint))
91591
+ TURN_RECEIPT_TASKS[key] = (fingerprint, task)
91592
+ def completed(done):
91593
+ TURN_RECEIPT_TASKS.pop(key, None)
91594
+ if not done.cancelled():
91595
+ done.exception()
91596
+ task.add_done_callback(completed)
91597
+ else:
91598
+ task = existing[1]
91599
+ try:
91600
+ return await asyncio.wait_for(asyncio.shield(task), timeout=10)
91601
+ except asyncio.TimeoutError:
91602
+ return JSONResponse(status_code=202, content={"request_id": req.client_request_id,
91603
+ "acceptance": "pending", "retry_same_request": True})
91604
+
91605
+
90564
91606
  @app.post("/api/sessions/{session_id}/turns")
90565
- async def post_turn(session_id: str, req: TurnRequest) -> dict[str, Any]:
91607
+ async def post_turn(session_id: str, req: TurnRequest) -> Any:
91608
+ if req.client_request_id is not None:
91609
+ return await post_turn_with_receipt(session_id, req)
91610
+ return await post_turn_without_receipt(session_id, req)
91611
+
91612
+
91613
+ async def post_turn_without_receipt(session_id: str, req: TurnRequest) -> dict[str, Any]:
90566
91614
  while True:
90567
91615
  try:
90568
91616
  return await start_turn(session_id, req)
@@ -94032,6 +95080,12 @@ async def stop_turn(
94032
95080
  "superseded": True,
94033
95081
  "message": "The requested run no longer owns this chat.",
94034
95082
  }
95083
+ if ((STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
95084
+ and (not active or not callable(active.get("gemini_cleanup")))):
95085
+ if _admission_ready is not None:
95086
+ _admission_ready.set()
95087
+ return {"ok": False, "stopped": False, "pending": True,
95088
+ "message": "Gemini runtime cleanup requires server-side verification after restart; this chat remains fenced."}
94035
95089
  if preserve_active_goal and codex_goal_followup_requires_native(
94036
95090
  STORE.sessions.get(session_id) or {}, active or {}, current_turn,
94037
95091
  ):
@@ -94754,7 +95808,20 @@ async def stop_turn(
94754
95808
  # The app-server process may serve multiple Codex chats and must never be
94755
95809
  # terminated as a per-chat Stop action.
94756
95810
  if proc and active.get("transport") != CODEX_TRANSPORT_APP_SERVER:
94757
- await terminate_process_tree(proc)
95811
+ if active.get("backend") == BACKEND_GEMINI:
95812
+ # Gemini owns a verified-cleanup guard. Generic tree termination
95813
+ # can kill it before startup or during verification, destroying the
95814
+ # cleanup evidence and leaving the chat permanently fenced.
95815
+ cleanup = active.get("gemini_cleanup")
95816
+ try:
95817
+ if not callable(cleanup):
95818
+ raise RuntimeError("Missing Gemini cleanup owner")
95819
+ await cleanup()
95820
+ except Exception:
95821
+ return {"ok": False, "stopped": False, "pending": True,
95822
+ "message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
95823
+ else:
95824
+ await terminate_process_tree(proc)
94758
95825
  if transition_ready is not None:
94759
95826
  try:
94760
95827
  await asyncio.wait_for(
@@ -94822,6 +95889,18 @@ async def stop_turn(
94822
95889
  and not owner_task.done()
94823
95890
  ):
94824
95891
  dynamic_tasks.add(owner_task)
95892
+ if (current is active and active.get("backend") == BACKEND_GEMINI
95893
+ and active.get("gemini_cleanup_failed") and not dynamic_tasks):
95894
+ try:
95895
+ await active["gemini_cleanup"]()
95896
+ from gemini_runner import clear_cleanup_fence
95897
+ await clear_cleanup_fence(sys.modules[__name__], session_id, str(active.get("run_id") or ""))
95898
+ except Exception:
95899
+ return {"ok": False, "stopped": False, "pending": True,
95900
+ "message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
95901
+ active["gemini_cleanup_failed"] = False
95902
+ await reconcile_provider_task_exit(session_id, str(active.get("run_id") or ""), BACKEND_GEMINI)
95903
+ return {"ok": True, "stopped": True, "pending": False}
94825
95904
  if current is not active:
94826
95905
  if not goal_pause_ok:
94827
95906
  return await terminal_goal_pause_failure_result(native_interrupt)
@@ -95667,6 +96746,17 @@ async def session_terminal(
95667
96746
  await ws.close()
95668
96747
 
95669
96748
 
96749
+ EVENT_STREAM_HEARTBEAT_SECONDS = 15.0
96750
+
96751
+
96752
+ async def send_event_stream_status(ws: WebSocket, session_id: str, phase: str) -> None:
96753
+ # Ephemeral, opt-in control frames: never persisted or assigned a seq.
96754
+ await asyncio.wait_for(ws.send_json({
96755
+ "type": "event_stream_status", "version": 1,
96756
+ "session_id": session_id, "phase": phase,
96757
+ }), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
96758
+
96759
+
95670
96760
  @app.websocket("/api/sessions/{session_id}/events")
95671
96761
  async def session_events(
95672
96762
  session_id: str,
@@ -95675,6 +96765,8 @@ async def session_events(
95675
96765
  visible: bool | None = None,
95676
96766
  reasoning_stream: bool = False,
95677
96767
  reasoning_text: bool = False,
96768
+ liveness: bool = False,
96769
+ history_projection: bool = False,
95678
96770
  ) -> None:
95679
96771
  selected_subprotocol = websocket_endpoint_subprotocol(
95680
96772
  ws,
@@ -95689,6 +96781,7 @@ async def session_events(
95689
96781
  await ws.close(code=4404)
95690
96782
  return
95691
96783
  await ws.accept(subprotocol=selected_subprotocol)
96784
+ schedule_provider_history_sync(STORE.sessions[session_id])
95692
96785
  try:
95693
96786
  if not await HUB.reserve(session_id, ws):
95694
96787
  await close_event_websocket_over_capacity(ws)
@@ -95704,7 +96797,6 @@ async def session_events(
95704
96797
  # is ready. Reading its fsynced rows earlier can replay raw scheduled
95705
96798
  # inputs before this socket is registered for the repaired broadcast.
95706
96799
  async with event_delivery_lock(session_id):
95707
- await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
95708
96800
  boundary = await asyncio.to_thread(
95709
96801
  last_event_seq_from_file,
95710
96802
  events_path(session_id),
@@ -95755,12 +96847,28 @@ async def session_events(
95755
96847
  return
95756
96848
  if reasoning_snapshot is not None:
95757
96849
  await asyncio.wait_for(ws.send_json(reasoning_snapshot), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
96850
+ if liveness:
96851
+ await send_event_stream_status(ws, session_id, "ready")
96852
+ if history_projection:
96853
+ # A proof may finish between the HTTP page and subscription.
96854
+ # The revision closes that gap without replaying old rows.
96855
+ await asyncio.wait_for(ws.send_json(history_projection_notice(session_id)), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
95758
96856
  break
95759
96857
  boundary = gap_boundary
95760
96858
  while True:
95761
- await ws.receive_text()
96859
+ if not liveness:
96860
+ await ws.receive_text()
96861
+ continue
96862
+ try:
96863
+ await asyncio.wait_for(ws.receive_text(), timeout=EVENT_STREAM_HEARTBEAT_SECONDS)
96864
+ except asyncio.TimeoutError:
96865
+ # No history scan, provider call, or sequence acknowledgement.
96866
+ # This confirms that the application event path is still alive.
96867
+ await send_event_stream_status(ws, session_id, "heartbeat")
95762
96868
  except WebSocketDisconnect:
95763
96869
  pass
96870
+ except (asyncio.TimeoutError, ConnectionError):
96871
+ await close_event_websocket_after_delivery_failure(ws)
95764
96872
  finally:
95765
96873
  await HUB.unsubscribe(session_id, ws)
95766
96874