@agentsdock/server 1.0.8 → 1.0.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentsdock/server",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "AgentsDock's self-hosted Python server and explicit installation CLI",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/server/VERSION CHANGED
@@ -1 +1 @@
1
- 1.0.8
1
+ 1.0.9
@@ -83265,7 +83265,20 @@ def public_chat_share_session_exists(session_id: str) -> bool:
83265
83265
  )
83266
83266
 
83267
83267
 
83268
- async def create_native_side_chat(session_id: str, *, persisted_state=None, persist_state=None, durable=False):
83268
+ def side_chat_runtime_settings(session_id: str):
83269
+ session = STORE.sessions.get(session_id)
83270
+ if not session or str(session.get("backend") or DEFAULT_BACKEND).lower() != BACKEND_CODEX:
83271
+ return {}
83272
+ try:
83273
+ model, effort, _service_tier = codex_runtime_settings(session)
83274
+ except HTTPException:
83275
+ # Optional settings do not gate reading retained side-chat history.
83276
+ return {}
83277
+ return {"model": model, "effort": effort}
83278
+
83279
+
83280
+ async def create_native_side_chat(session_id: str, *, persisted_state=None, persist_state=None, durable=False,
83281
+ initial_runtime_settings=None):
83269
83282
  """Bind a side conversation to the provider's actual parent.
83270
83283
 
83271
83284
  No visible-message projection, main turn, queue or goal mutation belongs
@@ -83296,6 +83309,15 @@ async def create_native_side_chat(session_id: str, *, persisted_state=None, pers
83296
83309
  class NativeSideChat:
83297
83310
  codex = None
83298
83311
 
83312
+ def __init__(self):
83313
+ self.runtime_settings = dict((persisted_state or {}).get("runtime_settings") or initial_runtime_settings or {})
83314
+ self.codex_state = (persisted_state or {}).get("codex")
83315
+
83316
+ async def save_state(self):
83317
+ if persist_state is not None:
83318
+ await persist_state({**binding, **({"runtime_settings": self.runtime_settings} if self.runtime_settings else {}),
83319
+ **({"codex": self.codex_state} if self.codex_state else {})})
83320
+
83299
83321
  def current(self):
83300
83322
  if SERVER_SHUTTING_DOWN:
83301
83323
  raise side_questions.SideQuestionError(503, "Server is shutting down")
@@ -83313,9 +83335,12 @@ async def create_native_side_chat(session_id: str, *, persisted_state=None, pers
83313
83335
  CODEX_PROVIDER_STORE.require_thread(parent_id, selected)
83314
83336
  return current
83315
83337
 
83316
- async def ask(self, question, *, history):
83338
+ async def ask(self, question, *, history, runtime_settings=None, on_progress=None):
83317
83339
  current = self.current()
83340
+ requested = side_questions.validate_runtime_settings(runtime_settings or {})
83318
83341
  if backend == BACKEND_CLAUDE:
83342
+ if requested:
83343
+ raise side_questions.SideQuestionError(400, "Claude native side questions use the main conversation settings")
83319
83344
  from claude_sdk_client import (ClaudeSDKGenerationChanged, ClaudeSDKConfigurationConflict,
83320
83345
  ClaudeSDKRunActive, ClaudeSDKSupervisorError)
83321
83346
  try:
@@ -83333,9 +83358,24 @@ async def create_native_side_chat(session_id: str, *, persisted_state=None, pers
83333
83358
  raise side_questions.SideQuestionError(503, "Claude native side questions are unavailable; retry after reconnecting") from None
83334
83359
  else:
83335
83360
  from codex_side_question import NativeCodexSideChat
83361
+ # A side conversation owns its settings after first use. Only
83362
+ # explicit side choices change them; never update the parent.
83363
+ side_session = {**current, **self.runtime_settings, **requested}
83364
+ model, effort, service_tier = codex_runtime_settings(side_session)
83365
+ provider_selection = CODEX_PROVIDER_STORE.for_session(side_session, include_key=True)
83366
+ summary = None
83367
+ if provider_selection:
83368
+ model = provider_selection["model"]
83369
+ effort = codex_provider.runtime_effort(provider_selection,
83370
+ CODEX_PROVIDER_STORE.cached_catalog(provider_selection), side_session.get("effort"))
83371
+ summary = codex_provider.runtime_summary(provider_selection,
83372
+ CODEX_PROVIDER_STORE.cached_catalog(provider_selection))
83373
+ provider_selection["effort"] = effort
83374
+ provider_selection["reasoning_summary"] = summary
83375
+ self.runtime_settings = {"model": model, "effort": effort}
83376
+ await self.save_state()
83336
83377
  if self.codex is None:
83337
83378
  cwd = existing_cwd(str(current.get("cwd") or DEFAULT_CWD))
83338
- model, effort, service_tier = codex_runtime_settings(current)
83339
83379
  approval_policy = current.get("codex_approval_policy")
83340
83380
  if approval_policy not in CODEX_APPROVAL_POLICIES:
83341
83381
  approval_policy = CODEX_DEFAULT_APPROVAL_POLICY
@@ -83383,28 +83423,25 @@ async def create_native_side_chat(session_id: str, *, persisted_state=None, pers
83383
83423
  side_session_id=session_id, side_owner_is_current=is_current,
83384
83424
  side_approval_policy=approval_policy)
83385
83425
 
83386
- provider_selection = CODEX_PROVIDER_STORE.for_session(current, include_key=True)
83387
- if provider_selection:
83388
- provider_selection["effort"] = codex_provider.runtime_effort(
83389
- provider_selection, CODEX_PROVIDER_STORE.cached_catalog(provider_selection), current.get("effort"),
83390
- )
83391
- provider_selection["reasoning_summary"] = codex_provider.runtime_summary(
83392
- provider_selection, CODEX_PROVIDER_STORE.cached_catalog(provider_selection))
83393
83426
  async def save_codex_state(value):
83394
- if persist_state is not None:
83395
- await persist_state({**binding, "codex": value})
83427
+ self.codex_state = value
83428
+ await self.save_state()
83396
83429
  self.codex = NativeCodexSideChat(parent_id, executable=CODEX_BIN,
83397
83430
  model=model if isinstance(model, str) and model.strip() else None,
83398
83431
  cwd=cwd, fork_overrides=fork_overrides, turn_overrides=turn_overrides,
83399
83432
  server_request_handler=side_server_request,
83400
83433
  env=side_questions.isolated_environment(runner_env()),
83401
83434
  provider_selection=provider_selection, durable=durable,
83402
- resume_state=(persisted_state or {}).get("codex"), persist_state=save_codex_state)
83403
- result = {"answer": await self.codex.ask(question),
83435
+ resume_state=self.codex_state, persist_state=save_codex_state)
83436
+ result = {**self.runtime_settings, "answer": await self.codex.ask(question, on_progress=on_progress,
83437
+ runtime_settings={**self.runtime_settings, **({"reasoning_summary": summary} if summary else {})}),
83404
83438
  "context_note": "Native Codex context from when Side chat started, including tool results. Clear Side chat to use the latest main context."}
83405
83439
  self.current()
83406
83440
  return {"backend": backend, **result}
83407
83441
 
83442
+ def snapshot_progress(self, task):
83443
+ return self.codex.snapshot_progress(task) if self.codex is not None else None
83444
+
83408
83445
  async def close(self):
83409
83446
  if self.codex is not None:
83410
83447
  await self.codex.close()
@@ -83423,7 +83460,7 @@ def require_side_question_admission():
83423
83460
 
83424
83461
  SIDE_QUESTIONS = side_questions.SideQuestions(native_factory=create_native_side_chat,
83425
83462
  storage_path=STATE_DIR / "side_chats.sqlite3", notify=lambda session_id, event: HUB.broadcast(session_id, event),
83426
- admission_check=require_side_question_admission)
83463
+ admission_check=require_side_question_admission, runtime_settings_for_session=side_chat_runtime_settings)
83427
83464
  app.include_router(side_questions.create_side_question_router(
83428
83465
  authorize=require_native_admin_control,
83429
83466
  session_exists=public_chat_share_session_exists,
@@ -0,0 +1,110 @@
1
+ """User-visible native Codex side-thread activity; never project parent/control input."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from copy import deepcopy
6
+
7
+
8
+ class CodexSideChatProgress:
9
+ def __init__(self, publish, *, thread_id):
10
+ self.publish = publish
11
+ self.thread_id = thread_id
12
+ self.items = {}
13
+ self.parts = {}
14
+ self.completed = set()
15
+ self.pending = None
16
+ self.dirty = False
17
+
18
+ def _item(self, identifier, kind):
19
+ key = f"{identifier}:{kind}"
20
+ if key not in self.items:
21
+ self.items[key] = {"id": key, "kind": kind, "text": "", "status": "running"}
22
+ return self.items[key]
23
+
24
+ @staticmethod
25
+ def _text(parts):
26
+ if not isinstance(parts, list):
27
+ return ""
28
+ return "\n\n".join(part if isinstance(part, str) else str(part.get("text") or part.get("summary_text") or "")
29
+ for part in parts if isinstance(part, (str, dict)))
30
+
31
+ def receive(self, packet):
32
+ method, data = packet.get("method"), packet.get("params") or {}
33
+ # The transport normally scopes notifications; enforce it here too so
34
+ # descendant activity cannot become the side assistant's own answer.
35
+ if data.get("threadId") not in (None, self.thread_id):
36
+ return
37
+ item = data.get("item") or {}
38
+ identifier = str(data.get("itemId") or item.get("id") or "")
39
+ if not identifier or identifier in self.completed:
40
+ return
41
+ changed = False
42
+ if method in {"item/reasoning/summaryTextDelta", "item/reasoning/textDelta", "item/reasoning/summaryPartAdded"}:
43
+ kind = "reasoning" if method == "item/reasoning/textDelta" else "reasoning_summary"
44
+ field = "contentIndex" if kind == "reasoning" else "summaryIndex"
45
+ index = data.get(field, 0)
46
+ if not isinstance(index, int) or index < 0:
47
+ return
48
+ parts = self.parts.setdefault((identifier, kind), {})
49
+ parts[index] = parts.get(index, "") + str(data.get("delta") or "")
50
+ self._item(identifier, kind)["text"] = "\n\n".join(parts[i] for i in sorted(parts))
51
+ changed = True
52
+ elif method == "item/agentMessage/delta":
53
+ # item/started carries the phase. Older runtimes may emit deltas
54
+ # first; item/completed supplies the authoritative final phase.
55
+ kind = "commentary" if f"{identifier}:commentary" in self.items else "answer"
56
+ self._item(identifier, kind)["text"] += str(data.get("delta") or "")
57
+ changed = True
58
+ elif method in {"item/started", "item/completed"}:
59
+ item_type = item.get("type")
60
+ if item_type == "reasoning":
61
+ for kind, field in (("reasoning_summary", "summary"), ("reasoning", "content")):
62
+ text = self._text(item.get(field))
63
+ if text or (identifier, kind) in self.parts:
64
+ projected = self._item(identifier, kind)
65
+ if text:
66
+ projected["text"] = text
67
+ changed = True
68
+ elif item_type == "agentMessage":
69
+ kind = "commentary" if item.get("phase") == "commentary" else "answer"
70
+ other = f"{identifier}:{'answer' if kind == 'commentary' else 'commentary'}"
71
+ if other in self.items:
72
+ # Keep its chronological slot when an older runtime omits phase at start.
73
+ self.items = {f"{identifier}:{kind}" if key == other else key:
74
+ {**value, "id": f"{identifier}:{kind}", "kind": kind} if key == other else value
75
+ for key, value in self.items.items()}
76
+ projected = self._item(identifier, kind)
77
+ if isinstance(item.get("text"), str) and (item["text"] or method == "item/completed"):
78
+ projected["text"] = item["text"]
79
+ changed = True
80
+ elif item_type in {"commandExecution", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall",
81
+ "collabToolCall", "webSearch", "fileChange", "imageView", "imageGeneration"}:
82
+ projected = self._item(identifier, "tool")
83
+ projected["tool"] = item_type
84
+ text = item.get("command") if item_type == "commandExecution" else item.get("tool") or item.get("query") or item.get("path")
85
+ if not text and item_type == "fileChange":
86
+ text = ", ".join(str(change.get("path") or "") for change in item.get("changes", []) if isinstance(change, dict))
87
+ if text:
88
+ projected["text"] = str(text)
89
+ changed = True
90
+ if method == "item/completed":
91
+ self.completed.add(identifier)
92
+ for key, value in self.items.items():
93
+ if key.startswith(identifier + ":"):
94
+ value["status"] = "completed"
95
+ if changed:
96
+ self.dirty = True
97
+ if self.pending is None:
98
+ self.pending = asyncio.create_task(self._publish_later())
99
+
100
+ async def _publish_later(self):
101
+ # Coalesce token bursts; no per-token disk writes or client history reads.
102
+ await asyncio.sleep(0.1)
103
+ while self.dirty:
104
+ self.dirty = False
105
+ await self.publish(deepcopy(list(self.items.values())))
106
+ self.pending = None
107
+
108
+ async def flush(self):
109
+ if self.pending is not None:
110
+ await asyncio.shield(self.pending)
@@ -179,6 +179,7 @@ class NativeCodexSideChat:
179
179
  self._cleaning: asyncio.Task | None = None
180
180
  self._process_started = asyncio.Event()
181
181
  self._active: asyncio.Task | None = None
182
+ self._progress = None
182
183
  self._lock = asyncio.Lock()
183
184
  self._closed = False
184
185
 
@@ -186,6 +187,12 @@ class NativeCodexSideChat:
186
187
  def closed(self) -> bool:
187
188
  return self._closed
188
189
 
190
+ def snapshot_progress(self, task):
191
+ """Copy exposed native items at Stop without flushing or awaiting IO."""
192
+ if task is None or task.done() or self._active is not task or self._progress is None:
193
+ return None
194
+ return deepcopy(list(self._progress.items.values()))
195
+
189
196
  async def _handle_server_request(self, request_id, method, params):
190
197
  # The callback can present approvals through the existing UI, but only
191
198
  # for this exact child. Never route a parent's inherited tool request.
@@ -284,7 +291,7 @@ class NativeCodexSideChat:
284
291
  elif metadata.get("ephemeral") is not True or metadata.get("path") is not None:
285
292
  raise SideQuestionError(503, "Codex did not confirm an ephemeral side chat")
286
293
 
287
- async def ask(self, question: str) -> str:
294
+ async def ask(self, question: str, *, runtime_settings: dict | None = None, on_progress=None) -> str:
288
295
  if self._closed:
289
296
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
290
297
  if self._lock.locked():
@@ -292,6 +299,7 @@ class NativeCodexSideChat:
292
299
  async with self._lock:
293
300
  self._active = asyncio.current_task()
294
301
  turn = None
302
+ progress = None
295
303
  try:
296
304
  if self._opening is None:
297
305
  self._opening = asyncio.create_task(self._open())
@@ -300,14 +308,37 @@ class NativeCodexSideChat:
300
308
  await asyncio.shield(self._opening)
301
309
  if self._closed:
302
310
  raise SideQuestionError(409, "Side chat was closed; open a new side chat")
311
+ overrides = {**self.turn_overrides, **self.provider_turn_overrides}
312
+ if runtime_settings is not None:
313
+ model = runtime_settings["model"]
314
+ effort = runtime_settings.get("effort") or ""
315
+ # Explicit default must clear a previous turn's effort;
316
+ # null effort alone would inherit it from the thread.
317
+ overrides.pop("effort", None)
318
+ overrides.update({"model": model, "collaborationMode": {"mode": "default", "settings": {
319
+ "model": model, "reasoning_effort": effort or None, "developer_instructions": None}}})
320
+ if effort:
321
+ overrides["effort"] = effort
322
+ if self.provider_config:
323
+ from codex_provider import turn_overrides
324
+ overrides.update(turn_overrides(model, effort,
325
+ summary=runtime_settings.get("reasoning_summary") or "none"))
303
326
  turn = await self._client.start_turn(
304
327
  self.thread_id, [{"type": "text", "text": question}],
305
- overrides={**self.turn_overrides, **self.provider_turn_overrides},
328
+ overrides=overrides,
306
329
  )
330
+ if on_progress is not None:
331
+ from codex_side_chat_progress import CodexSideChatProgress
332
+ progress = CodexSideChatProgress(on_progress, thread_id=self.thread_id)
333
+ self._progress = progress
307
334
  answers: dict[str, str] = {}
308
335
  while True:
309
336
  packet = await turn.next_notification()
310
337
  method, data = packet.get("method"), packet.get("params", {})
338
+ if data.get("threadId") not in (None, self.thread_id):
339
+ continue
340
+ if progress is not None:
341
+ progress.receive(packet)
311
342
  if method == "item/completed":
312
343
  item = data.get("item", {})
313
344
  if item.get("type") == "agentMessage" and item.get("phase") in (None, "", "final_answer"):
@@ -323,6 +354,8 @@ class NativeCodexSideChat:
323
354
  answer = "\n\n".join(answers.values()).strip()
324
355
  if not answer:
325
356
  raise SideQuestionError(502, "Codex did not return a side question answer")
357
+ if progress is not None:
358
+ await progress.flush()
326
359
  return answer
327
360
  except CodexAppServerError:
328
361
  await self.close()
@@ -331,9 +364,16 @@ class NativeCodexSideChat:
331
364
  await self.close()
332
365
  raise
333
366
  finally:
334
- if turn is not None:
335
- await turn.close()
336
- self._active = None
367
+ try:
368
+ if progress is not None:
369
+ await progress.flush()
370
+ finally:
371
+ try:
372
+ if turn is not None:
373
+ await turn.close()
374
+ finally:
375
+ self._progress = None
376
+ self._active = None
337
377
 
338
378
  async def close(self):
339
379
  self._closed = True
package/server/install.sh CHANGED
@@ -1839,7 +1839,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
1839
1839
  fi
1840
1840
  fi
1841
1841
 
1842
- RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
1842
+ RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
1843
1843
  RELEASE_DIRECTORIES=(agentsdock_team_hub)
1844
1844
  TEAM_HUB_RELEASE_FILES=(
1845
1845
  __init__.py
@@ -3055,21 +3055,37 @@ install_lock_control() {
3055
3055
  local action="$1"
3056
3056
  local expected_device="${2:-0}"
3057
3057
  local expected_inode="${3:-0}"
3058
+ local wait_for_prepared="false"
3059
+ # Old managed preparers do not pass a managed-update ID. The existing pinned
3060
+ # preparation/activation modes may serialize briefly; this grants no update
3061
+ # authority and leaves ordinary installation and recovery fail-fast.
3062
+ if [[ "$action" == "acquire" && "${RECOVER_ONLY:-false}" != "true" \
3063
+ && "${RECOVER_UNARMED_ONLY:-false}" != "true" \
3064
+ && ( "${PREPARE_ONLY:-false}" == "true" || -n "${ACTIVATE_PREPARED:-}" ) \
3065
+ && "${PREPARED_ARCHIVE_SHA256:-}" =~ ^[0-9a-f]{64}$ \
3066
+ && "${EXPECTED_API_CONTRACT:-}" =~ ^[1-9][0-9]*$ \
3067
+ && -n "${RELEASE_VERSION:-}" ]]; then
3068
+ wait_for_prepared="true"
3069
+ fi
3058
3070
  local python_path=""
3059
3071
  python_path="$(install_lock_python)" || return 1
3060
3072
  "$python_path" - \
3061
- "$action" "$INSTALL_ROOT" "$$" "$expected_device" "$expected_inode" <<'PY'
3073
+ "$action" "$INSTALL_ROOT" "$$" "$expected_device" "$expected_inode" "$wait_for_prepared" <<'PY'
3062
3074
  import errno
3063
3075
  import os
3064
3076
  from pathlib import Path
3065
3077
  import secrets
3078
+ import signal
3066
3079
  import stat
3067
3080
  import sys
3081
+ import time
3068
3082
 
3069
- action, raw_root, raw_pid, raw_device, raw_inode = sys.argv[1:]
3083
+ action, raw_root, raw_pid, raw_device, raw_inode, raw_wait = sys.argv[1:]
3070
3084
  root = Path(os.path.abspath(raw_root))
3071
3085
  pid = int(raw_pid)
3072
3086
  lock = root / ".install-lock"
3087
+ PREPARED_LOCK_WAIT_SECONDS = 30.0
3088
+ wait_for_prepared = action == "acquire" and raw_wait == "true"
3073
3089
 
3074
3090
  root_info = root.lstat()
3075
3091
  if (
@@ -3145,7 +3161,56 @@ def read_owner(directory_descriptor: int) -> tuple[int, tuple[int, int]]:
3145
3161
  return int(value), (int(info.st_dev), int(info.st_ino))
3146
3162
 
3147
3163
 
3164
+ def check_wait_root() -> None:
3165
+ current = root.lstat()
3166
+ if ((current.st_dev, current.st_ino) != (root_info.st_dev, root_info.st_ino)
3167
+ or not stat.S_ISDIR(current.st_mode) or current.st_uid != os.geteuid()
3168
+ or stat.S_IMODE(current.st_mode) & 0o022):
3169
+ raise PermissionError("AgentsServer install root changed while waiting")
3170
+
3171
+
3172
+ def same_wait_owner(directory_descriptor, directory_info, owner_pid, owner_identity) -> bool:
3173
+ check_wait_root()
3174
+ current = lock.lstat()
3175
+ if ((current.st_dev, current.st_ino) != (directory_info.st_dev, directory_info.st_ino)):
3176
+ return False
3177
+ if (not stat.S_ISDIR(current.st_mode) or current.st_uid != os.geteuid()
3178
+ or stat.S_IMODE(current.st_mode) & 0o077):
3179
+ raise PermissionError("AgentsServer install lock directory is unsafe")
3180
+ return read_owner(directory_descriptor) == (owner_pid, owner_identity)
3181
+
3182
+
3183
+ def cleanup_wait_candidate(path, expected) -> None:
3184
+ # Cancellation while waiting may remove only our still-unpublished draft.
3185
+ # An acquired-but-unreported lock is left for ordinary dead-owner reaping.
3186
+ try:
3187
+ check_wait_root()
3188
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
3189
+ try:
3190
+ current = os.fstat(descriptor)
3191
+ linked = path.lstat()
3192
+ identity = (expected.st_dev, expected.st_ino)
3193
+ if ((current.st_dev, current.st_ino) != identity
3194
+ or (linked.st_dev, linked.st_ino) != identity):
3195
+ return
3196
+ owner, owner_identity = read_owner(descriptor)
3197
+ if owner != pid or read_owner(descriptor) != (owner, owner_identity):
3198
+ return
3199
+ os.unlink("pid", dir_fd=descriptor)
3200
+ os.fsync(descriptor)
3201
+ linked = path.lstat()
3202
+ if (linked.st_dev, linked.st_ino) == identity:
3203
+ path.rmdir()
3204
+ finally:
3205
+ os.close(descriptor)
3206
+ except (OSError, RuntimeError):
3207
+ pass # Preserve the original cancellation/failure; never guess ownership.
3208
+
3209
+
3148
3210
  if action == "acquire":
3211
+ # One budget includes owner/namespace changes. Waiting only observes an
3212
+ # existing safe lock; all authority checks still follow atomic acquisition.
3213
+ deadline = time.monotonic() + PREPARED_LOCK_WAIT_SECONDS
3149
3214
  candidate = root / f".install-lock.{pid}.{secrets.token_hex(12)}.tmp"
3150
3215
  os.mkdir(candidate, 0o700)
3151
3216
  os.chmod(candidate, 0o700)
@@ -3165,21 +3230,44 @@ if action == "acquire":
3165
3230
  finally:
3166
3231
  os.close(descriptor)
3167
3232
  fsync_directory(candidate)
3168
- for _attempt in range(32):
3233
+ if wait_for_prepared:
3234
+ def cancelled(signum, _frame):
3235
+ raise SystemExit(128 + signum)
3236
+ for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
3237
+ signal.signal(signum, cancelled)
3238
+ namespace_attempts = 0
3239
+ while namespace_attempts < 32:
3240
+ if wait_for_prepared:
3241
+ check_wait_root()
3242
+ if time.monotonic() >= deadline:
3243
+ raise RuntimeError("AgentsServer prepared update timed out waiting for the installation lock")
3169
3244
  try:
3170
3245
  os.rename(candidate, lock)
3171
3246
  except OSError as exc:
3172
3247
  if exc.errno not in {errno.EEXIST, errno.ENOTEMPTY}:
3173
3248
  raise
3174
- directory_descriptor, directory_info = open_lock_directory()
3175
3249
  try:
3250
+ directory_descriptor, directory_info = open_lock_directory()
3251
+ except FileNotFoundError:
3252
+ if not wait_for_prepared:
3253
+ raise
3254
+ # The incumbent retired after rename observed its lock.
3255
+ namespace_attempts += 1
3256
+ continue
3257
+ try:
3258
+ if wait_for_prepared and any(
3259
+ name != "pid" for name in os.listdir(directory_descriptor)
3260
+ ):
3261
+ raise PermissionError("AgentsServer install lock contains unknown files")
3176
3262
  try:
3177
3263
  owner_pid, owner_identity = read_owner(directory_descriptor)
3178
3264
  except FileNotFoundError:
3179
3265
  # An old installer may have crashed in mkdir->pid. The
3180
3266
  # next atomic directory rename replaces only that
3181
3267
  # exact empty namespace; a populated replacement wins.
3268
+ namespace_attempts += 1
3182
3269
  continue
3270
+ alive = False
3183
3271
  try:
3184
3272
  os.kill(owner_pid, 0)
3185
3273
  except ProcessLookupError:
@@ -3189,6 +3277,22 @@ if action == "acquire":
3189
3277
  f"another AgentsServer installation is active ({owner_pid})"
3190
3278
  )
3191
3279
  else:
3280
+ alive = True
3281
+ if alive:
3282
+ if wait_for_prepared:
3283
+ try:
3284
+ unchanged = same_wait_owner(directory_descriptor, directory_info,
3285
+ owner_pid, owner_identity)
3286
+ except FileNotFoundError:
3287
+ unchanged = False
3288
+ if not unchanged:
3289
+ namespace_attempts += 1
3290
+ continue
3291
+ remaining = deadline - time.monotonic()
3292
+ if remaining <= 0:
3293
+ raise RuntimeError("AgentsServer prepared update timed out waiting for the installation lock")
3294
+ time.sleep(min(0.1, remaining))
3295
+ continue
3192
3296
  raise RuntimeError(
3193
3297
  f"another AgentsServer installation is active ({owner_pid})"
3194
3298
  )
@@ -3200,19 +3304,25 @@ if action == "acquire":
3200
3304
  directory_descriptor
3201
3305
  )
3202
3306
  except FileNotFoundError:
3307
+ namespace_attempts += 1
3203
3308
  continue
3204
3309
  if current_owner != owner_pid or current_identity != owner_identity:
3310
+ namespace_attempts += 1
3205
3311
  continue
3206
3312
  try:
3207
3313
  os.unlink("pid", dir_fd=directory_descriptor)
3208
3314
  except FileNotFoundError:
3315
+ namespace_attempts += 1
3209
3316
  continue
3210
3317
  os.fsync(directory_descriptor)
3211
3318
  finally:
3212
3319
  os.close(directory_descriptor)
3320
+ namespace_attempts += 1
3213
3321
  continue
3214
3322
  else:
3215
3323
  owned = True
3324
+ if wait_for_prepared:
3325
+ check_wait_root()
3216
3326
  for _ in range(3):
3217
3327
  try:
3218
3328
  fsync_directory(root)
@@ -3232,7 +3342,9 @@ if action == "acquire":
3232
3342
  raise SystemExit(0)
3233
3343
  raise RuntimeError("AgentsServer install lock ownership kept changing")
3234
3344
  finally:
3235
- if not owned:
3345
+ if not owned and wait_for_prepared:
3346
+ cleanup_wait_candidate(candidate, candidate_info)
3347
+ elif not owned:
3236
3348
  try:
3237
3349
  (candidate / "pid").unlink()
3238
3350
  except FileNotFoundError:
@@ -3644,6 +3756,7 @@ validate_staged_release_runtime() (
3644
3756
  "$STAGE_DIR/side_questions.py" \
3645
3757
  "$STAGE_DIR/workspace_git.py" \
3646
3758
  "$STAGE_DIR/codex_side_question.py" \
3759
+ "$STAGE_DIR/codex_side_chat_progress.py" \
3647
3760
  "$STAGE_DIR/title_generation.py" \
3648
3761
  "$STAGE_DIR/claude_side_question.py" \
3649
3762
  "$STAGE_DIR/cursor_agent_client.py" \
@@ -3668,7 +3781,7 @@ validate_staged_release_runtime() (
3668
3781
  "$STAGE_DIR/update_runner.py"
3669
3782
  "$STAGE_DIR/.venv/bin/python" -m py_compile "$STAGE_DIR/server_instances.py"
3670
3783
  "$STAGE_DIR/.venv/bin/python" -m compileall -q "$STAGE_DIR/agentsdock_team_hub"
3671
- PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
3784
+ PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
3672
3785
  )
3673
3786
 
3674
3787
  abort_unclaimed_team_hub_reactivation() {
File without changes
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  from contextlib import suppress, closing
11
- from dataclasses import dataclass
11
+ from dataclasses import dataclass, field
12
12
  from datetime import datetime, timezone
13
13
  import hashlib
14
14
  import json
@@ -57,7 +57,36 @@ class SideQuestionError(Exception):
57
57
 
58
58
  def capability():
59
59
  return {"available": True, "version": 2, "native_context": True, "backends": ["codex", "claude"],
60
- "max_question_chars": MAX_QUESTION_CHARS, "sync": True}
60
+ "max_question_chars": MAX_QUESTION_CHARS, "sync": True, "runtime_settings": True}
61
+
62
+
63
+ def validate_runtime_settings(value):
64
+ """Only explicit per-turn choices; omission preserves the side runtime."""
65
+ result = {}
66
+ for key in ("model", "effort"):
67
+ if key not in value:
68
+ continue
69
+ setting = value[key]
70
+ if not isinstance(setting, str) or len(setting) > 256 or any(ord(c) < 32 for c in setting):
71
+ raise SideQuestionError(400, "Invalid side chat runtime setting")
72
+ try:
73
+ setting.encode("utf-8")
74
+ except UnicodeEncodeError:
75
+ raise SideQuestionError(400, "Invalid side chat runtime setting") from None
76
+ setting = setting.strip()
77
+ if key == "model" and not setting:
78
+ raise SideQuestionError(400, "Side chat model must not be empty")
79
+ if key == "effort" and setting not in {"", "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
80
+ raise SideQuestionError(400, "Invalid side chat reasoning effort")
81
+ result[key] = setting
82
+ return result
83
+
84
+
85
+ def request_digest(question, runtime_settings=None):
86
+ # Retain old receipts exactly when the optional settings were absent.
87
+ payload = (json.dumps([question, runtime_settings], sort_keys=True, ensure_ascii=False)
88
+ if runtime_settings else question)
89
+ return hashlib.sha256(payload.encode()).hexdigest()
61
90
 
62
91
 
63
92
  def validate_history(value) -> tuple[tuple[str, str], ...]:
@@ -328,6 +357,7 @@ class _Receipt:
328
357
  waiters: int = 0
329
358
  side_chat_id: str | None = None
330
359
  after_request_id: str | None = None
360
+ runtime_settings: dict = field(default_factory=dict)
331
361
 
332
362
 
333
363
  @dataclass
@@ -345,14 +375,15 @@ NATIVE_IDLE_SECONDS = 30 * 60
345
375
 
346
376
 
347
377
  class SideQuestions:
348
- def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None):
378
+ def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None, runtime_settings_for_session=None):
349
379
  self.answer = answer
350
380
  self.native_factory = native_factory
351
381
  self.admission_check = admission_check
352
382
  self.receipts: dict[tuple[str, str, str], _Receipt] = {}
353
383
  self.conversations: dict[tuple[str, str, str], _NativeConversation] = {}
354
384
  self.cleanup_tasks: set[asyncio.Task] = set()
355
- self.synced = (SyncedSideChats(storage_path, native_factory=native_factory, notify=notify, admission_check=admission_check)
385
+ self.synced = (SyncedSideChats(storage_path, native_factory=native_factory, notify=notify, admission_check=admission_check,
386
+ runtime_settings_for_session=runtime_settings_for_session)
356
387
  if storage_path is not None else None)
357
388
 
358
389
  def active_session_ids(self):
@@ -395,7 +426,7 @@ class SideQuestions:
395
426
  task.exception()
396
427
  task.add_done_callback(finished)
397
428
 
398
- async def _native_answer(self, owner, session_id, request_id, question, side_chat_id, after_request_id):
429
+ async def _native_answer(self, owner, session_id, request_id, question, side_chat_id, after_request_id, runtime_settings=None):
399
430
  key = (owner, session_id, side_chat_id)
400
431
  conversation = self.conversations.get(key)
401
432
  if conversation is None:
@@ -416,7 +447,8 @@ class SideQuestions:
416
447
  conversation.handle = await self.native_factory(session_id)
417
448
  if conversation.closed:
418
449
  raise SideQuestionError(410, "Side chat ended")
419
- result = await conversation.handle.ask(question, history=list(conversation.history))
450
+ result = await conversation.handle.ask(question, history=list(conversation.history),
451
+ **({"runtime_settings": runtime_settings} if runtime_settings else {}))
420
452
  if conversation.closed:
421
453
  raise SideQuestionError(410, "Side chat ended")
422
454
  # Claude /btw replays the last twenty side exchanges natively.
@@ -450,9 +482,10 @@ class SideQuestions:
450
482
  self.receipts.pop(key, None)
451
483
 
452
484
  def submit(self, owner: str, session_id: str, request_id: str, question: str, *, history: list[dict] | None = None,
453
- side_chat_id: str | None = None, after_request_id: str | None = None):
485
+ side_chat_id: str | None = None, after_request_id: str | None = None, runtime_settings=None):
454
486
  if self.admission_check is not None:
455
487
  self.admission_check()
488
+ runtime_settings = validate_runtime_settings(runtime_settings or {})
456
489
  frozen_history = validate_history([] if history is None else history)
457
490
  self._prune()
458
491
  key = (owner, session_id, request_id)
@@ -461,17 +494,19 @@ class SideQuestions:
461
494
  if receipt.cancelled:
462
495
  raise SideQuestionError(409, "Side question was cancelled; submit with a new request ID")
463
496
  if (receipt.question != question or receipt.history != frozen_history
464
- or receipt.side_chat_id != side_chat_id or receipt.after_request_id != after_request_id):
497
+ or receipt.side_chat_id != side_chat_id or receipt.after_request_id != after_request_id
498
+ or receipt.runtime_settings != runtime_settings):
465
499
  raise SideQuestionError(409, "Request ID already belongs to a different side question")
466
500
  return receipt
467
501
  if self.native_factory is not None and (not side_chat_id or frozen_history):
468
502
  raise SideQuestionError(409, "Update the app to use native Side chat; copied conversation history is no longer accepted.")
469
- receipt = _Receipt(question, history=frozen_history, side_chat_id=side_chat_id, after_request_id=after_request_id)
503
+ receipt = _Receipt(question, history=frozen_history, side_chat_id=side_chat_id, after_request_id=after_request_id,
504
+ runtime_settings=runtime_settings)
470
505
  self.receipts[key] = receipt
471
506
 
472
507
  async def run():
473
508
  try:
474
- answer = (self._native_answer(owner, session_id, request_id, question, side_chat_id, after_request_id)
509
+ answer = (self._native_answer(owner, session_id, request_id, question, side_chat_id, after_request_id, runtime_settings)
475
510
  if self.native_factory is not None else
476
511
  self.answer(session_id, question, history=history_messages(frozen_history))
477
512
  if frozen_history else self.answer(session_id, question))
@@ -603,7 +638,7 @@ class SideChatStore:
603
638
  if receipt is not None:
604
639
  database.execute("INSERT INTO receipts VALUES(?,?,?,?,?)",
605
640
  (owner, session, receipt["request_id"], document["side_chat_id"],
606
- hashlib.sha256(receipt["question"].encode()).hexdigest()))
641
+ request_digest(receipt["question"], receipt.get("runtime_settings"))))
607
642
  provider_state = document.get("_provider_state") or {}
608
643
  thread_id = (provider_state.get("codex") or {}).get("thread_id")
609
644
  if isinstance(thread_id, str) and thread_id:
@@ -658,7 +693,8 @@ class SyncedSideChats:
658
693
  after an app disconnect or an unexpected process exit.
659
694
  """
660
695
 
661
- def __init__(self, path, *, native_factory, notify=None, admission_check=None):
696
+ def __init__(self, path, *, native_factory, notify=None, admission_check=None, runtime_settings_for_session=None):
697
+ self.runtime_settings_for_session = runtime_settings_for_session
662
698
  self.store = SideChatStore(path)
663
699
  self.native_factory = native_factory
664
700
  self.notify = notify
@@ -700,9 +736,21 @@ class SyncedSideChats:
700
736
 
701
737
  async def snapshot(self, owner, session):
702
738
  async with self._lock((owner, session)):
703
- return self.public(self._load((owner, session)))
704
-
705
- async def submit(self, owner, session, request_id, question, side_chat_id, after_request_id=None):
739
+ document = self._load((owner, session))
740
+ if "model" not in document and self.runtime_settings_for_session is not None:
741
+ # Retained history stays readable when provider credentials,
742
+ # endpoint configuration or model discovery are unavailable.
743
+ settings = {}
744
+ with suppress(Exception):
745
+ settings = self.runtime_settings_for_session(session)
746
+ if settings:
747
+ document.update(settings)
748
+ document["revision"] += 1
749
+ self.store.save(owner, session, document)
750
+ return self.public(document)
751
+
752
+ async def submit(self, owner, session, request_id, question, side_chat_id, after_request_id=None, runtime_settings=None):
753
+ runtime_settings = validate_runtime_settings(runtime_settings or {})
706
754
  key = (owner, session)
707
755
  async with self._lock(key):
708
756
  if self.stopping:
@@ -712,7 +760,7 @@ class SyncedSideChats:
712
760
  document = self._load(key)
713
761
  previous = self.store.receipt(*key, request_id)
714
762
  if previous is not None:
715
- if previous != (side_chat_id, hashlib.sha256(question.encode()).hexdigest()) or document["side_chat_id"] != side_chat_id:
763
+ if previous != (side_chat_id, request_digest(question, runtime_settings)) or document["side_chat_id"] != side_chat_id:
716
764
  raise SideQuestionError(409, "Request ID belongs to a different or cleared side chat")
717
765
  return self.public(document)
718
766
  if document["side_chat_id"] != side_chat_id:
@@ -725,14 +773,15 @@ class SyncedSideChats:
725
773
  if document["last_request_id"] != after_request_id:
726
774
  raise SideQuestionError(409, "Side chat changed; refresh before sending")
727
775
  exchange = {"request_id": request_id, "question": question, "status": "running",
728
- "created_at": side_chat_timestamp(), "updated_at": side_chat_timestamp()}
776
+ "created_at": side_chat_timestamp(), "updated_at": side_chat_timestamp(),
777
+ **({"runtime_settings": runtime_settings} if runtime_settings else {})}
729
778
  document["exchanges"].append(exchange)
730
779
  document["revision"] += 1
731
780
  self.store.save(*key, document, receipt=exchange)
732
781
  timer = self.timers.pop(key, None)
733
782
  if timer is not None:
734
783
  timer.cancel()
735
- task = asyncio.create_task(self._answer(key, side_chat_id, request_id, question), name="synced-side-question")
784
+ task = asyncio.create_task(self._answer(key, side_chat_id, request_id, question, runtime_settings), name="synced-side-question")
736
785
  self.tasks[key] = task
737
786
  def done(completed):
738
787
  if self.tasks.get(key) is completed:
@@ -750,9 +799,33 @@ class SyncedSideChats:
750
799
  if document["side_chat_id"] != side_chat_id:
751
800
  raise SideQuestionError(409, "Side chat was cleared")
752
801
  document["_provider_state"] = value
802
+ settings = value.get("runtime_settings") or {}
803
+ changed = any(document.get(name) != setting for name, setting in settings.items())
804
+ document.update(settings)
805
+ if changed:
806
+ document["revision"] += 1
753
807
  self.store.save(*key, document)
808
+ if changed:
809
+ await self._changed(document)
810
+
811
+ async def _progress(self, key, side_chat_id, request_id, activity):
812
+ async with self._lock(key):
813
+ document = self.pending_writes.get(key) or self._load(key)
814
+ if document["side_chat_id"] != side_chat_id:
815
+ return
816
+ exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
817
+ if exchange is None or exchange["status"] != "running":
818
+ return
819
+ exchange.update(activity=activity, updated_at=side_chat_timestamp())
820
+ document["revision"] += 1
821
+ try:
822
+ self.store.save(*key, document)
823
+ self.pending_writes.pop(key, None)
824
+ except (OSError, sqlite3.Error):
825
+ self.pending_writes[key] = document
826
+ await self._changed(document)
754
827
 
755
- async def _answer(self, key, side_chat_id, request_id, question):
828
+ async def _answer(self, key, side_chat_id, request_id, question, runtime_settings=None):
756
829
  handle = None
757
830
  failed = False
758
831
  try:
@@ -762,11 +835,15 @@ class SyncedSideChats:
762
835
  handle = self.handles.get(key)
763
836
  if handle is None:
764
837
  handle = await self.native_factory(key[1], persisted_state=document.get("_provider_state"),
765
- persist_state=lambda value: self._provider_state(key, side_chat_id, value), durable=True)
838
+ persist_state=lambda value: self._provider_state(key, side_chat_id, value), durable=True,
839
+ **({"initial_runtime_settings": {name: document[name] for name in ("model", "effort") if name in document}}
840
+ if "model" in document else {}))
766
841
  self.handles[key] = handle
767
842
  history = [{"question": item["question"], "response": item["answer"]}
768
843
  for item in document["exchanges"] if item["status"] == "completed"][-20:]
769
- result = await handle.ask(question, history=history)
844
+ result = await handle.ask(question, history=history,
845
+ on_progress=lambda activity: self._progress(key, side_chat_id, request_id, activity),
846
+ **({"runtime_settings": runtime_settings} if runtime_settings else {}))
770
847
  await self._finish(key, side_chat_id, request_id, "completed", **result)
771
848
  except asyncio.CancelledError:
772
849
  failed = True
@@ -802,17 +879,19 @@ class SyncedSideChats:
802
879
 
803
880
  async def _finish(self, key, side_chat_id, request_id, status, **result):
804
881
  async with self._lock(key):
805
- document = self._load(key)
882
+ document = self.pending_writes.get(key) or self._load(key)
806
883
  if document["side_chat_id"] != side_chat_id:
807
884
  return
808
885
  exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
809
886
  if exchange is None or exchange["status"] != "running":
810
887
  return
811
888
  exchange.update(status=status, updated_at=side_chat_timestamp(), **result)
889
+ document.update({key: result[key] for key in ("model", "effort") if key in result})
812
890
  document["last_request_id"] = request_id
813
891
  document["revision"] += 1
814
892
  try:
815
893
  self.store.save(*key, document)
894
+ self.pending_writes.pop(key, None)
816
895
  except (OSError, sqlite3.Error):
817
896
  self.pending_writes[key] = document
818
897
  await self._changed(document)
@@ -830,7 +909,9 @@ class SyncedSideChats:
830
909
  async def cancel(self, owner, session, request_id):
831
910
  key = (owner, session)
832
911
  async with self._lock(key):
833
- document = self._load(key)
912
+ # Pending progress may be unsaved while provider work is active.
913
+ # Storage recovery must not gate the operator's Stop intent.
914
+ document = self.pending_writes.get(key) or self._load(key)
834
915
  exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
835
916
  if exchange is None:
836
917
  # Stop can overtake submission in transit. Preserve an opaque
@@ -839,15 +920,24 @@ class SyncedSideChats:
839
920
  self.store.save(*key, document, receipt={"request_id": request_id, "question": ""})
840
921
  return self.public(document)
841
922
  if exchange["status"] != "running":
842
- return self.public(document)
923
+ return self.public(self._load(key))
924
+ task = self.tasks.get(key)
925
+ snapshot_progress = getattr(self.handles.get(key), "snapshot_progress", None)
926
+ if task is not None and callable(snapshot_progress):
927
+ # Capture only native items received by this exact request
928
+ # before Stop. Later coalesced callbacks stay fenced below.
929
+ with suppress(Exception):
930
+ activity = snapshot_progress(task)
931
+ if activity is not None:
932
+ exchange["activity"] = activity
843
933
  exchange.update(status="cancelled", updated_at=side_chat_timestamp())
844
934
  document["last_request_id"] = request_id
845
935
  document["revision"] += 1
846
936
  try:
847
937
  self.store.save(*key, document)
938
+ self.pending_writes.pop(key, None)
848
939
  except (OSError, sqlite3.Error):
849
940
  self.pending_writes[key] = document
850
- task = self.tasks.get(key)
851
941
  if task is not None:
852
942
  task.cancel()
853
943
  if task is not None:
@@ -858,15 +948,20 @@ class SyncedSideChats:
858
948
  async def clear(self, owner, session, side_chat_id):
859
949
  key = (owner, session)
860
950
  async with self._lock(key):
861
- document = self._load(key)
951
+ # Preserve the new conversation fence even if progress storage
952
+ # is unavailable; still retire the old provider immediately.
953
+ document = self.pending_writes.get(key) or self._load(key)
862
954
  if document["side_chat_id"] != side_chat_id:
863
955
  # A repeated Clear is harmless. A stale device cannot clear a
864
956
  # new conversation it has not seen.
865
- return self.public(document)
957
+ return self.public(self._load(key))
866
958
  document.update(side_chat_id=uuid.uuid4().hex, exchanges=[], last_request_id=None, _provider_state=None)
959
+ document.pop("model", None)
960
+ document.pop("effort", None)
867
961
  document["revision"] += 1
868
962
  try:
869
963
  self.store.save(*key, document)
964
+ self.pending_writes.pop(key, None)
870
965
  except (OSError, sqlite3.Error):
871
966
  self.pending_writes[key] = document
872
967
  task = self.tasks.get(key)
@@ -947,7 +1042,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
947
1042
  except asyncio.TimeoutError:
948
1043
  raise HTTPException(408, "Side question request was not received") from None
949
1044
  if (not isinstance(value, dict) or not {"request_id", "question"} <= set(value)
950
- or set(value) - {"request_id", "question", "history", "side_chat_id", "after_request_id"}):
1045
+ or set(value) - {"request_id", "question", "history", "side_chat_id", "after_request_id", "model", "effort"}):
951
1046
  raise HTTPException(400, "Invalid side question fields")
952
1047
  for field in ("side_chat_id", "after_request_id"):
953
1048
  if field in value and (not isinstance(value[field], str) or not IDENTIFIER.fullmatch(value[field])):
@@ -977,7 +1072,8 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
977
1072
  receipt = None
978
1073
  try:
979
1074
  receipt = runtime.submit(owner, session_id, request_id, question, history=history,
980
- side_chat_id=value.get("side_chat_id"), after_request_id=value.get("after_request_id"))
1075
+ side_chat_id=value.get("side_chat_id"), after_request_id=value.get("after_request_id"),
1076
+ runtime_settings=validate_runtime_settings(value))
981
1077
  receipt.waiters += 1
982
1078
  watcher = asyncio.create_task(disconnected())
983
1079
  done, _ = await asyncio.wait({receipt.task, watcher}, return_when=asyncio.FIRST_COMPLETED)
@@ -1047,7 +1143,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
1047
1143
  except asyncio.TimeoutError:
1048
1144
  raise HTTPException(408, "Side question request was not received") from None
1049
1145
  if (not isinstance(value, dict) or not {"request_id", "question", "side_chat_id"} <= set(value)
1050
- or set(value) - {"request_id", "question", "side_chat_id", "after_request_id"}):
1146
+ or set(value) - {"request_id", "question", "side_chat_id", "after_request_id", "model", "effort"}):
1051
1147
  raise HTTPException(400, "Invalid side question fields")
1052
1148
  for field in ("request_id", "side_chat_id", "after_request_id"):
1053
1149
  if field == "after_request_id" and value.get(field) is None:
@@ -1062,7 +1158,8 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
1062
1158
  except UnicodeEncodeError:
1063
1159
  raise HTTPException(400, "Question must contain valid Unicode text") from None
1064
1160
  return await synced_response(synced_runtime().submit(owner, session_id, value["request_id"], question,
1065
- value["side_chat_id"], value.get("after_request_id")), status_code=202)
1161
+ value["side_chat_id"], value.get("after_request_id"),
1162
+ runtime_settings=value), status_code=202)
1066
1163
 
1067
1164
  @router.delete("/api/sessions/{session_id}/side-chat/requests/{request_id}")
1068
1165
  async def cancel_synced(session_id: str, request_id: str, request: Request):