@agentsdock/server 1.0.8 → 1.0.10-beta.1

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/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 codex_response_stream.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:
@@ -3639,11 +3751,12 @@ validate_staged_release_runtime() (
3639
3751
  "$STAGE_DIR/claude_background_reconciliation.py" \
3640
3752
  "$STAGE_DIR/codex_app_server.py" \
3641
3753
  "$STAGE_DIR/codex_auth.py" \
3642
- "$STAGE_DIR/codex_provider.py" \
3754
+ "$STAGE_DIR/codex_provider.py" "$STAGE_DIR/codex_response_stream.py" \
3643
3755
  "$STAGE_DIR/provider_connections.py" "$STAGE_DIR/cursor_api_key.py" \
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, codex_response_stream, 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):