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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/NOTICE +7 -0
  2. package/package.json +1 -1
  3. package/server/NOTICE +7 -0
  4. package/server/VERSION +1 -1
  5. package/server/agent_server.py +1474 -318
  6. package/server/agentsdock_jobs.py +2 -2
  7. package/server/codex_auth.py +48 -0
  8. package/server/codex_history_repair.py +180 -15
  9. package/server/cursor_process_guard.py +67 -4
  10. package/server/cursor_provider_mcp.py +2 -0
  11. package/server/execution_durability.py +87 -11
  12. package/server/execution_install.py +5 -1
  13. package/server/gemini_acp_client.py +432 -0
  14. package/server/gemini_api.py +128 -0
  15. package/server/gemini_content.py +102 -0
  16. package/server/gemini_history.py +293 -0
  17. package/server/gemini_import.py +126 -0
  18. package/server/gemini_permissions.py +47 -0
  19. package/server/gemini_runner.py +493 -0
  20. package/server/gemini_runtime.py +322 -0
  21. package/server/install.sh +25 -2
  22. package/server/interactive_chat_controls.py +9 -3
  23. package/server/interactive_chat_native.py +1 -1
  24. package/server/interactive_chat_share_routes.py +2 -2
  25. package/server/interactive_chat_share_web.py +2 -2
  26. package/server/local_session_ownership.py +1 -0
  27. package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
  28. package/server/npm/agentsdock/pair.cjs +123 -0
  29. package/server/npm/agentsdock/qr-renderer.cjs +15 -0
  30. package/server/provider_commands.py +65 -0
  31. package/server/provider_connections.py +39 -10
  32. package/server/public_chat_share_routes.py +8 -6
  33. package/server/public_chat_shares.py +17 -7
  34. package/server/server_instances.py +195 -15
  35. package/server/shared_chat_analytics_web.py +5 -0
  36. package/server/title_generation.py +60 -1
  37. package/server/transcript_reader.py +20 -8
  38. package/server/update_runner.py +4 -2
@@ -0,0 +1,47 @@
1
+ """Gemini native approval modes, not Codex sandbox or authorization grants."""
2
+ from gemini_acp_client import GeminiProtocolError
3
+
4
+ MODES = ("default", "autoEdit", "yolo", "plan")
5
+ # Plan is offered by the UI only after this workspace's native handshake has
6
+ # advertised it. Every turn revalidates the native list before any prompt.
7
+ INITIAL_MODES = MODES[:3]
8
+
9
+
10
+ def effective_mode(session: dict) -> str:
11
+ value = session.get("gemini_permission_mode") or "default"
12
+ if value not in MODES:
13
+ raise GeminiProtocolError("Invalid Gemini permission mode. Choose a native Gemini mode.")
14
+ return value
15
+
16
+
17
+ def startup_mode(mode: str) -> str:
18
+ # Explicit argv prevents inheriting a user's default YOLO setting. Starting
19
+ # YOLO through the CLI also enforces native disableYoloMode/secureModeEnabled;
20
+ # pinned ACP set_mode alone does not check those administrator restrictions.
21
+ # Plan is selected over ACP: CLI --approval-mode plan silently falls back to
22
+ # default when Plan is disabled, which must never masquerade as read-only.
23
+ return {"default": "default", "autoEdit": "auto_edit", "yolo": "yolo", "plan": "default"}[mode]
24
+
25
+
26
+ def available_modes(opened: dict) -> list[str]:
27
+ modes = opened.get("modes")
28
+ options = modes.get("availableModes") if isinstance(modes, dict) else None
29
+ if not isinstance(options, list) or len(options) > 32:
30
+ raise GeminiProtocolError("Gemini did not return its native permission modes. Check the supported CLI version.")
31
+ ids = []
32
+ for option in options:
33
+ if not isinstance(option, dict) or not isinstance(option.get("id"), str):
34
+ raise GeminiProtocolError("Gemini returned an invalid permission mode.")
35
+ if option["id"] in MODES and option["id"] not in ids:
36
+ ids.append(option["id"])
37
+ if "default" not in ids:
38
+ raise GeminiProtocolError("Gemini did not advertise normal tool approvals.")
39
+ return ids
40
+
41
+
42
+ async def apply_mode(client, opened: dict, mode: str) -> list[str]:
43
+ modes = available_modes(opened)
44
+ if mode not in modes:
45
+ raise GeminiProtocolError(f"Gemini permission mode '{mode}' is not enabled in this workspace. Choose an available mode; no prompt was sent.")
46
+ await client.request("session/set_mode", {"sessionId": client.session_id, "modeId": mode})
47
+ return modes
@@ -0,0 +1,493 @@
1
+ """Gemini turns and run-owned approvals using AgentsServer's durable lifecycle."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from contextlib import suppress
6
+ import json
7
+ from pathlib import Path
8
+ import secrets
9
+ import sys
10
+ import time
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+
14
+ from gemini_acp_client import GeminiACPClient, GeminiProtocolError, identifier, session_models
15
+ from gemini_runtime import build_environment, GeminiConfigurationError, resolve_executable
16
+ from gemini_permissions import effective_mode, startup_mode, apply_mode, available_modes, INITIAL_MODES
17
+
18
+ PERMISSIONS: dict[str, dict] = {}
19
+ MODEL_CATALOG: dict[str, dict] = {}
20
+
21
+
22
+ def communication_unavailable_message(cwd: str) -> str:
23
+ # Point at the actual project, not the terminal's default HOME or the
24
+ # credential-isolated API profile. Never change native trust on retry.
25
+ return ("No cross-chat message was sent by this turn. Gemini did not load the AgentsDock communication tool. "
26
+ "This is not a sign-in error. "
27
+ f"Workspace: {json.dumps(cwd, ensure_ascii=False)}. "
28
+ "On the server, open Gemini in that directory and review folder trust and MCP settings (/permissions and /mcp). "
29
+ "Trust only projects you recognize, then retry in AgentsDock. "
30
+ "AgentsDock does not grant folder trust automatically.")
31
+
32
+
33
+ async def clear_cleanup_fence(api, session_id: str, run_id: str):
34
+ session = api.STORE.sessions.get(session_id) or {}
35
+ if session.get("gemini_cleanup_pending_run_id") != run_id:
36
+ return
37
+ session.pop("gemini_cleanup_pending_run_id")
38
+ try:
39
+ await api.STORE.save(durable=True)
40
+ except BaseException:
41
+ session["gemini_cleanup_pending_run_id"] = run_id
42
+ raise
43
+
44
+
45
+ def owns(api, session_id, run_id):
46
+ active = api.ACTIVE.get(session_id) or {}
47
+ return (session_id in api.STORE.sessions and session_id not in api.DELETING_SESSIONS
48
+ and run_id not in api.STOPPED_RUNS and active.get("run_id") == run_id
49
+ and active.get("backend") == "gemini" and not active.get("stop_requested"))
50
+
51
+
52
+ async def ask_permission(api, session_id: str, run_id: str, params: dict) -> dict:
53
+ cancelled = {"outcome": {"outcome": "cancelled"}}
54
+ if not owns(api, session_id, run_id) or len(PERMISSIONS) >= 128:
55
+ return cancelled
56
+ options = params.get("options")
57
+ if not isinstance(options, list) or len(options) > 16:
58
+ return cancelled
59
+ allowed = {item["kind"]: item["optionId"] for item in options
60
+ if isinstance(item, dict) and item.get("kind") in {"allow_once", "reject_once"}
61
+ and isinstance(item.get("optionId"), str) and len(item["optionId"]) <= 240}
62
+ tool = params.get("toolCall") or {}
63
+ key = "geminireq_" + secrets.token_hex(16)
64
+ public = {"id": key, "session_id": session_id, "thread_id": params.get("sessionId"),
65
+ "turn_id": run_id, "item_id": identifier(tool.get("toolCallId")),
66
+ "method": "item/tool/requestApproval", "created_at": api.now_iso(),
67
+ "params": {"toolName": str(tool.get("title") or "Gemini tool")[:512],
68
+ "availableDecisions": [name for name, kind in [("accept", "allow_once"), ("decline", "reject_once")] if kind in allowed],
69
+ "toolInput": {"content": api.bounded_codex_interaction_value(tool.get("content") or [])}}}
70
+ future = asyncio.get_running_loop().create_future()
71
+ PERMISSIONS[key] = {"public": public, "future": future, "options": allowed, "run_id": run_id}
72
+ try:
73
+ await api.append_event(session_id, "gemini_interaction_requested", {"interaction": public, "run_id": run_id})
74
+ choice = await asyncio.wait_for(future, 1800)
75
+ if choice not in allowed or not owns(api, session_id, run_id):
76
+ return cancelled
77
+ return {"outcome": {"outcome": "selected", "optionId": allowed[choice]}}
78
+ finally:
79
+ PERMISSIONS.pop(key, None)
80
+ if session_id in api.STORE.sessions:
81
+ await api.append_event(session_id, "gemini_interaction_resolved", {"interaction": public, "run_id": run_id})
82
+
83
+
84
+ async def resolve_permission(api, session_id: str, interaction_id: str, response: dict) -> dict:
85
+ """Exact run/chat ownership shared by native and authenticated share controls."""
86
+ if (not isinstance(response, dict) or set(response) != {"decision"}
87
+ or not isinstance(response["decision"], str)
88
+ or response["decision"] not in {"accept", "decline", "cancel"}):
89
+ raise HTTPException(400, "Invalid approval decision.")
90
+ async with api.session_lifecycle_lock(session_id):
91
+ api.ensure_session_not_deleting(session_id)
92
+ row = PERMISSIONS.get(interaction_id)
93
+ if (not row or row["public"]["session_id"] != session_id or row["future"].done()
94
+ or not owns(api, session_id, row["run_id"])):
95
+ raise HTTPException(409, "This Gemini approval is no longer active.")
96
+ choice = {"accept": "allow_once", "decline": "reject_once", "cancel": "cancelled"}[response["decision"]]
97
+ if choice != "cancelled" and choice not in row["options"]:
98
+ raise HTTPException(400, "This decision is unavailable for the tool.")
99
+ row["future"].set_result(choice)
100
+ return {"interaction": row["public"]}
101
+
102
+
103
+ def router(api) -> APIRouter:
104
+ routes = APIRouter()
105
+
106
+ @routes.get("/api/sessions/{session_id}/gemini/runtime")
107
+ async def runtime(session_id: str, request: Request):
108
+ api.require_native_admin_control(request)
109
+ sess = api.STORE.sessions.get(session_id)
110
+ if not sess or sess.get("backend") != "gemini":
111
+ raise HTTPException(404, "Gemini chat not found.")
112
+ return {"pending_interactions": [row["public"] for row in PERMISSIONS.values()
113
+ if row["public"]["session_id"] == session_id and owns(api, session_id, row["run_id"])],
114
+ "available": True,
115
+ "permission_modes": sess.get("gemini_permission_modes") or list(INITIAL_MODES),
116
+ "permission_mode": effective_mode(sess),
117
+ "active_permission_mode": (api.ACTIVE.get(session_id) or {}).get("gemini_permission_mode")}
118
+
119
+ @routes.post("/api/sessions/{session_id}/gemini/interactions/{interaction_id}/resolve")
120
+ async def respond(session_id: str, interaction_id: str, request: Request):
121
+ api.require_native_admin_control(request)
122
+ raw = bytearray()
123
+ async def read():
124
+ async for chunk in request.stream():
125
+ raw.extend(chunk)
126
+ if len(raw) > 4096:
127
+ raise HTTPException(413, "Approval response is too large.")
128
+ try:
129
+ await asyncio.wait_for(read(), 10)
130
+ payload = json.loads(raw)
131
+ except asyncio.TimeoutError:
132
+ raise HTTPException(408, "Approval response timed out.") from None
133
+ except (ValueError, RecursionError):
134
+ raise HTTPException(400, "Invalid approval response.") from None
135
+ if not isinstance(payload, dict) or set(payload) != {"response"} or not isinstance(payload["response"], dict):
136
+ raise HTTPException(400, "Provide an approval response.")
137
+ return await resolve_permission(api, session_id, interaction_id, payload["response"])
138
+
139
+ return routes
140
+
141
+
142
+ async def run(api, session_id: str, run_id: str, prompt: str, sess: dict,
143
+ manifest_path: Path, *, standalone_provider_context=False,
144
+ communication_required=False, **_kwargs):
145
+ """A new owned process per turn, resuming the same durable native UUID."""
146
+ from cursor_provider_mcp import CursorToolBroker
147
+
148
+ text_parts: list[str] = []
149
+ block: list[str] = []
150
+ block_kind = ""
151
+ block_index = 0
152
+ char_count = 0
153
+ tool_calls: dict[str, dict] = {}
154
+ finished_tools: set[str] = set()
155
+ broker = None
156
+ error = None
157
+ stopped = False
158
+ owner = secrets.token_urlsafe(48)
159
+ completed_reasoning = set()
160
+ env = None
161
+ provider_id = None
162
+ token_usage = {}
163
+ last_plan = None
164
+ context_usage = None
165
+ identity_bound = False
166
+ runtime_env = api.validate_provider_runtime_env(_kwargs.get("provider_runtime_env"))
167
+ helper_redactor = api.opencode_helper_output_redactor(session_id, run_id, runtime_env)
168
+ private_values = [owner]
169
+
170
+ def redact(value):
171
+ if isinstance(value, dict): return {key: redact(item) for key, item in value.items()}
172
+ if isinstance(value, list): return [redact(item) for item in value]
173
+ if not isinstance(value, str): return value
174
+ text = helper_redactor(value)
175
+ for private in private_values:
176
+ if private: text = text.replace(private, "<provider-private>")
177
+ return text
178
+
179
+ async def bind_visible_identity():
180
+ # Persist BEFORE exposing output or approving a tool. A server crash
181
+ # must never turn an already-observed first turn into a fresh chat.
182
+ # A merely allocated UUID on an auth/model failure is still retryable.
183
+ nonlocal identity_bound
184
+ if not identity_bound and client.session_id:
185
+ await api.persist_run_provider_session(session_id, run_id, "gemini", client.session_id,
186
+ cwd=cwd, standalone_provider_context=standalone_provider_context)
187
+ identity_bound = True
188
+
189
+ async def flush():
190
+ nonlocal block, block_kind, block_index
191
+ if not block or not await api.turn_slot_is_owned(session_id, run_id) or session_id not in api.STORE.sessions:
192
+ return
193
+ text = redact("".join(block))
194
+ block = []
195
+ item_id = f"gemini-{run_id}-{block_index}"
196
+ block_index += 1
197
+ payload = {"run_id": run_id, "backend": "gemini", "text": text, "item_id": item_id,
198
+ **api.run_event_metadata(run_id)}
199
+ if block_kind == "text":
200
+ text_parts.append(text)
201
+ async def commit():
202
+ await api.append_event(session_id, "assistant_text", payload)
203
+ await api.clear_reasoning_summary_stream(session_id, run_id, item_id, "assistant")
204
+ await api.join_task_despite_caller_cancellation(asyncio.create_task(commit()))
205
+ else:
206
+ await api.persist_reasoning_summary(session_id, {**payload, "phase": "summary"}, completed_reasoning)
207
+
208
+ async def update(event):
209
+ nonlocal block_kind, char_count, last_plan, context_usage
210
+ if not owns(api, session_id, run_id):
211
+ return
212
+ kind = event["type"]
213
+ if kind == "permission_mode":
214
+ # Native plan tools can change the current mode. Report it without
215
+ # changing the saved next-turn preference or another run's state.
216
+ api.ACTIVE[session_id]["gemini_permission_mode"] = event["mode"]
217
+ await api.append_event(session_id, "gemini_permission_mode", {
218
+ "run_id": run_id, "backend": "gemini", "mode": event["mode"]})
219
+ if kind in {"text", "reasoning", "tool", "plan"}:
220
+ await bind_visible_identity()
221
+ if kind in {"text", "reasoning"}:
222
+ if kind != block_kind:
223
+ await flush()
224
+ block_kind = kind
225
+ text = redact(event.get("text") or "")
226
+ char_count += len(text)
227
+ if char_count > 2 * 1024 * 1024:
228
+ raise GeminiProtocolError("Gemini output exceeded the per-turn limit.")
229
+ block.append(text)
230
+ await api.update_reasoning_summary_stream(session_id, run_id,
231
+ f"gemini-{run_id}-{block_index}", {"delta": text},
232
+ {**api.run_event_metadata(run_id), "backend": "gemini"},
233
+ phase="assistant" if kind == "text" else "summary")
234
+ elif kind == "tool":
235
+ await flush()
236
+ call_id = event["call_id"]
237
+ if call_id in finished_tools:
238
+ return
239
+ if call_id not in tool_calls:
240
+ if len(tool_calls) + len(finished_tools) >= 4096:
241
+ raise GeminiProtocolError("Gemini exceeded the per-turn tool limit.")
242
+ tool_calls[call_id] = {"id": call_id, "name": redact(event.get("title") or event.get("kind") or "Tool")[:512],
243
+ # ACP does not always expose raw tool arguments. Preserve
244
+ # its actual diff/location content without inventing args.
245
+ "input": redact(api.bounded_codex_interaction_value(event.get("input") if event.get("input") is not None else {
246
+ "acp_content": event.get("content") or [], "locations": event.get("locations") or []}))}
247
+ await api.append_event(session_id, "tool_started", {"run_id": run_id, "tool": tool_calls[call_id]})
248
+ if event.get("status") in {"completed", "failed"}:
249
+ content = event.get("output")
250
+ if content is None:
251
+ from gemini_content import tool_output
252
+ content = tool_output(api.bounded_codex_interaction_value(event.get("content") or []))
253
+ await api.append_event(session_id, "tool_finished", {"run_id": run_id,
254
+ "tool": {**tool_calls.pop(call_id), "output": redact(str(content))[:24000],
255
+ "is_error": event.get("status") == "failed"}})
256
+ finished_tools.add(call_id)
257
+ elif kind == "plan":
258
+ from gemini_content import plan_entries
259
+ entries = plan_entries(event.get("entries"))
260
+ if entries != last_plan:
261
+ await flush()
262
+ last_plan = entries
263
+ await api.append_event(session_id, "reasoning_summary", {
264
+ "run_id": run_id, "backend": "gemini", "phase": "plan",
265
+ "text": "\n".join(f"[{entry['status']}] {redact(entry['content'])}" for entry in entries),
266
+ **api.run_event_metadata(run_id)})
267
+ elif kind == "usage":
268
+ value = event.get("usage") or {}
269
+ if all(type(value.get(key)) is int and 0 <= value[key] <= 2**53 - 1 for key in ("used", "size")):
270
+ context_usage = {"used": value["used"], "size": value["size"]}
271
+
272
+ async def permission(params):
273
+ await bind_visible_identity()
274
+ return await ask_permission(api, session_id, run_id, redact(params))
275
+
276
+ client = GeminiACPClient(on_update=update, on_permission=permission)
277
+
278
+ async def cleanup():
279
+ try:
280
+ await client.close()
281
+ finally:
282
+ # Even a surviving process must lose its helper transport.
283
+ if broker:
284
+ await broker.close()
285
+ try:
286
+ cwd = str(Path(str(sess.get("cwd") or api.DEFAULT_CWD)).expanduser().resolve(strict=True))
287
+ if not Path(cwd).is_dir():
288
+ raise GeminiConfigurationError("The Gemini workspace must be an existing directory.")
289
+ provider_id = None if standalone_provider_context else sess.get("gemini_session_id") or sess.get("session_id")
290
+ if provider_id and sess.get("gemini_session_cwd") != cwd:
291
+ raise GeminiConfigurationError("Resume Gemini in the original workspace; create a new chat to change its directory.")
292
+ connection = api.PROVIDER_CONNECTION_STORE.for_session(sess)
293
+ if connection: private_values.append(connection["api_key"])
294
+ profile = api.PROVIDER_CONNECTION_STORE.root / ("gemini-runtime-" + connection["credential_id"]) if connection else None
295
+ env = build_environment(api.runner_env(), cwd=cwd, api_home=profile,
296
+ api_key=connection.get("api_key") if connection else None,
297
+ base_url=connection.get("base_url") if connection else None,
298
+ native_history=bool(sess.get("gemini_native_history")) and not standalone_provider_context)
299
+ selected_skill = _kwargs.get("provider_command")
300
+ if selected_skill is not None:
301
+ # Recheck immediately before launching, including after queueing.
302
+ snapshot, inventory = await api.discover_session_provider_commands(session_id, sess)
303
+ current = inventory.resolve(selected_skill.public["id"])
304
+ if snapshot["support"].get("available") is not True or current != selected_skill:
305
+ raise GeminiConfigurationError("The selected Gemini skill changed. Refresh skills and select it again.")
306
+ invocation = selected_skill.public["invocation"]
307
+ task = prompt[len(invocation):].lstrip() if api.provider_command_prompt_matches(prompt, invocation) else prompt
308
+ prompt = ("The user explicitly selected the installed Gemini skill "
309
+ + json.dumps(selected_skill.native["name"]) + ". Use the native activate_skill tool to activate it, "
310
+ "then follow its instructions for the task below. If activation is unavailable or denied, "
311
+ "report that instead of pretending the skill ran. This selection grants no additional tool or chat permissions.\n\n"
312
+ + task)
313
+ executable = sess.get("_gemini_executable") or resolve_executable(env)
314
+ if not executable:
315
+ raise GeminiConfigurationError("Gemini CLI is not installed on the server.")
316
+ mcp = []
317
+ permission_mode = effective_mode(sess)
318
+ command = [str(executable), "--acp", "--approval-mode", startup_mode(permission_mode)]
319
+ async with api.CROSS_CHAT_CAPABILITY_LOCK:
320
+ capabilities = [c for c in api.CROSS_CHAT_CAPABILITIES.values()
321
+ if c.get("source_session_id") == session_id and c.get("source_run_id") == run_id]
322
+ has_authority = bool(capabilities)
323
+ if has_authority:
324
+ async def execute(value, key):
325
+ try:
326
+ return await api.execute_provider_tool_once(session_id, run_id, value,
327
+ replay_key="gemini:" + key, backend="gemini", cursor_owner_token=owner)
328
+ except api.ProviderToolError as exc:
329
+ return str(exc), True
330
+ broker = CursorToolBroker(api.codex_provider_mcp_tool_definition(), execute)
331
+ tool_env = await broker.start()
332
+ from cursor_provider_mcp import ENV_SECRET
333
+ private_values.append(tool_env[ENV_SECRET])
334
+ name = "agentsdock_" + secrets.token_hex(24)
335
+ mcp = [{"name": name, "command": sys.executable,
336
+ "args": [str(Path(__file__).with_name("cursor_provider_mcp.py")), "--mcp"],
337
+ "env": [{"name": key, "value": value} for key, value in tool_env.items()]}]
338
+ # Preserve Gemini's native policy hierarchy. In particular, a CLI
339
+ # --policy allow could outrank the user's existing deny rule.
340
+ # Native permission requests go through the explicit approval UI;
341
+ # the broker separately enforces AgentsDock's run/task authority.
342
+ # No shell authority-file paths or credentials belong in the model
343
+ # prompt/native transcript. The broker binds those out of band.
344
+ instructions = api.PROVIDER_AUTHORITY_USAGE_INSTRUCTIONS.split("- Print-only provider fallback:", 1)[0]
345
+ prompt += ("\n\n[AgentsDock provider authority]\n" + instructions
346
+ + api.CROSS_CHAT_DELIVERY_INSTRUCTIONS
347
+ + f"\nUse the run-bound AgentsDock MCP tool from {name} for authorized helper operations. "
348
+ "There is no shell-helper fallback for this integration."
349
+ "\n[End AgentsDock provider authority]")
350
+ model = sess.get("model")
351
+ if connection:
352
+ from gemini_api import default_model
353
+ from provider_connections import require_model
354
+ # Re-resolve the immutable binding at execution, including queued
355
+ # runs. Never ignore its default or inherit a native-account model.
356
+ model = require_model({**connection, "model": default_model(connection)})
357
+ if model:
358
+ command += ["--model", str(model)]
359
+
360
+ async def started(proc):
361
+ bound, stop = await api.bind_active_turn(session_id, run_id, {
362
+ "proc": proc, "run_id": run_id, "backend": "gemini", "transport": "exec",
363
+ "pid": proc.pid, "pgid": proc.pid, "cwd": cwd, "argv": [str(executable), "--acp"],
364
+ "started_at": time.time(), "started_at_iso": api.now_iso(),
365
+ "gemini_mcp_owner_token": owner, "provider_turn_ready": False,
366
+ "provider_tools_ready": asyncio.Event(),
367
+ "gemini_cleanup": cleanup,
368
+ })
369
+ if not bound or stop:
370
+ raise asyncio.CancelledError()
371
+
372
+ await client.start(command, cwd=cwd, env=env, on_started=started)
373
+ from gemini_history import native_snapshot, snapshot_replay, verify_resume_snapshot
374
+ snapshot = await asyncio.to_thread(native_snapshot, env, cwd, provider_id) if provider_id else None
375
+ replay = snapshot_replay(snapshot) if snapshot is not None else None
376
+ opened = await client.open_session(cwd=cwd, session_id=provider_id, mcp_servers=mcp, replay=replay)
377
+ modes = available_modes(opened)
378
+ if not owns(api, session_id, run_id):
379
+ raise asyncio.CancelledError()
380
+ # Save only native capability evidence, never overwrite a newer user's
381
+ # next-turn mode choice with this run's immutable snapshot.
382
+ api.STORE.sessions[session_id]["gemini_permission_modes"] = modes
383
+ await api.STORE.save(durable=True)
384
+ await apply_mode(client, opened, permission_mode)
385
+ if not owns(api, session_id, run_id):
386
+ raise asyncio.CancelledError()
387
+ if api.ACTIVE[session_id].get("gemini_permission_mode") != permission_mode:
388
+ api.ACTIVE[session_id]["gemini_permission_mode"] = permission_mode
389
+ await api.append_event(session_id, "gemini_permission_mode", {
390
+ "run_id": run_id, "backend": "gemini", "mode": permission_mode})
391
+ if broker and not broker.definition_requested.is_set():
392
+ # Native Gemini refuses MCP discovery in an untrusted workspace.
393
+ # Never grant folder trust or weaken an existing MCP policy here.
394
+ # A durable route permits communication; it does not require every
395
+ # later ordinary question to use it. Admission supplies this bit
396
+ # from this turn's validated references/internal delivery purpose.
397
+ # The broker's authorization and native tool policies are unchanged.
398
+ if communication_required:
399
+ raise GeminiConfigurationError(communication_unavailable_message(cwd))
400
+ prompt += "\nThe native AgentsDock MCP tool was not loaded. Do not use shell helpers as a substitute; report unavailable helper operations instead."
401
+ if snapshot is not None:
402
+ verify_resume_snapshot(snapshot, await asyncio.to_thread(native_snapshot, env, cwd, provider_id))
403
+ if not connection:
404
+ MODEL_CATALOG["default"] = session_models(opened)
405
+ # session/new allocates a UUID before any resumable history exists.
406
+ # Commit new identities only after the native recorder has flushed.
407
+ # Existing identities must never be cleared/replaced after a failure.
408
+ if provider_id:
409
+ await api.persist_run_provider_session(session_id, run_id, "gemini", client.session_id,
410
+ cwd=cwd, standalone_provider_context=standalone_provider_context)
411
+ await api.mark_provider_turn_ready(session_id, run_id, client.session_id)
412
+ async with api.ACTIVE_LOCK:
413
+ active = api.ACTIVE.get(session_id) or {}
414
+ if active.get("run_id") == run_id:
415
+ active["provider_tools_ready"].set()
416
+ if not owns(api, session_id, run_id):
417
+ raise asyncio.CancelledError()
418
+ from gemini_content import attachments, usage
419
+ content = await asyncio.to_thread(attachments, api, session_id, list(_kwargs.get("file_ids") or []))
420
+ result = await client.prompt([{"type": "text", "text": prompt + api.session_prompt_addendum(sess)}, *content])
421
+ token_usage = usage(result.get("usage"))
422
+ stopped = result.get("stopReason") == "cancelled"
423
+ if result.get("stopReason") not in {"end_turn", "cancelled"}:
424
+ raise GeminiProtocolError("Gemini stopped before completing the turn (limit or refusal).")
425
+ if not stopped:
426
+ api.record_runtime_success("gemini")
427
+ except asyncio.CancelledError:
428
+ stopped = True
429
+ except (GeminiConfigurationError, GeminiProtocolError) as exc:
430
+ error = str(exc)
431
+ except Exception:
432
+ error = "Gemini could not complete this request. Check the server's Gemini installation and selected connection."
433
+ finally:
434
+ stopped = stopped or run_id in api.STOPPED_RUNS
435
+ # Flush exposed partial output even on interruption before retiring its
436
+ # owner; do not publish raw stderr or protocol payloads.
437
+ with suppress(Exception):
438
+ await flush()
439
+ try:
440
+ await api.join_task_despite_caller_cancellation(asyncio.create_task(cleanup()))
441
+ except Exception:
442
+ # Do not release the slot or report a successful terminal event if
443
+ # the owned provider process could not be reaped.
444
+ if await api.turn_slot_is_owned(session_id, run_id):
445
+ async with api.ACTIVE_LOCK:
446
+ active = api.ACTIVE.get(session_id) or {}
447
+ if active.get("run_id") == run_id:
448
+ active["gemini_cleanup_failed"] = True
449
+ # A restart must not erase an unconfirmed cleanup and
450
+ # allow new workspace edits. Without the live owned
451
+ # cleanup closure, recovery needs operator verification.
452
+ api.STORE.sessions[session_id]["gemini_cleanup_pending_run_id"] = run_id
453
+ with suppress(Exception):
454
+ await api.STORE.save(durable=True)
455
+ await api.append_event(session_id, "error", {"run_id": run_id, "backend": "gemini",
456
+ "message": "Gemini cleanup did not complete. Stop this chat before retrying."})
457
+ return
458
+ if not provider_id and client.session_id and env is not None:
459
+ # Never silently replace a conversation that exposed output or
460
+ # executed a tool, even if the recorder subsequently fails. Keeping
461
+ # its UUID makes the next attempt fail closed on missing history.
462
+ preserve_identity = bool(char_count or tool_calls or finished_tools or last_plan)
463
+ try:
464
+ from gemini_history import native_snapshot
465
+ history = await asyncio.to_thread(native_snapshot, env, cwd, client.session_id, missing_ok=True)
466
+ # The pinned CLI prunes sessions containing only user/context
467
+ # records on its next startup. An allocated file alone is not
468
+ # proof that this UUID can be resumed after a model failure.
469
+ if history is not None and any(row.get("type") == "gemini" for row in history["messages"]):
470
+ preserve_identity = True
471
+ elif not error and not stopped:
472
+ error = "Gemini did not save resumable history for this turn. Check its native history before retrying."
473
+ except Exception:
474
+ preserve_identity = True # Unreadable is not evidence of empty history.
475
+ error = "Gemini history could not be verified after this turn. Check the server's Gemini history before retrying."
476
+ if preserve_identity:
477
+ await api.persist_run_provider_session(session_id, run_id, "gemini", client.session_id,
478
+ cwd=cwd, standalone_provider_context=standalone_provider_context)
479
+ await api.clear_reasoning_summary_stream(session_id, run_id)
480
+ for call_id, tool in tool_calls.items():
481
+ if session_id in api.STORE.sessions and await api.turn_slot_is_owned(session_id, run_id):
482
+ await api.append_event(session_id, "tool_finished", {"run_id": run_id,
483
+ "tool": {**tool, "output": "Gemini tool did not complete.", "is_error": True}})
484
+ if error and not stopped and await api.turn_slot_is_owned(session_id, run_id):
485
+ await api.append_event(session_id, "error", {"run_id": run_id, "backend": "gemini", "message": error})
486
+ await api.clear_active_process(session_id, expected_run_id=run_id)
487
+ await api.finalize_owned_turn_finished(session_id, run_id, stopped=stopped, payload={
488
+ "run_id": run_id, "backend": "gemini", "exit_code": 1 if error else 0,
489
+ "is_error": bool(error and not stopped), "stopped": stopped,
490
+ "result_text": "\n\n".join(text_parts), **api.run_event_metadata(run_id),
491
+ **token_usage,
492
+ **({"gemini_context_usage": context_usage} if context_usage is not None else {}),
493
+ })