@agentsdock/server 1.0.7-beta.5 → 1.0.8

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 (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. package/server/uv.lock +3 -1
@@ -1,22 +1,27 @@
1
- """Transient side questions, receipts and native conversation ownership.
1
+ """Native side questions, durable side chats and request ownership.
2
2
 
3
3
  Nothing starts on import. This module has no access to the main turn, queue,
4
- goal, provider authority, provider history import, or persistence callbacks.
4
+ goal, provider authority, or provider history import. Side-chat persistence
5
+ is separate from the main conversation's event history.
5
6
  """
6
7
  from __future__ import annotations
7
8
 
8
9
  import asyncio
9
- from contextlib import suppress
10
+ from contextlib import suppress, closing
10
11
  from dataclasses import dataclass
12
+ from datetime import datetime, timezone
11
13
  import hashlib
12
14
  import json
13
15
  import os
14
16
  from pathlib import Path
15
17
  import re
16
18
  import signal
19
+ import sqlite3
17
20
  import stat
18
21
  import tempfile
19
22
  import time
23
+ import uuid
24
+ from copy import deepcopy
20
25
 
21
26
  from fastapi import APIRouter, HTTPException, Request
22
27
  from fastapi.responses import JSONResponse
@@ -29,7 +34,6 @@ MAX_REQUEST_BYTES = 512 * 1024
29
34
  MAX_CONTEXT_CHARS = 60000
30
35
  MAX_LOG_BYTES = 4 * 1024 * 1024
31
36
  MAX_OUTPUT_BYTES = 1024 * 1024
32
- REQUEST_TIMEOUT_SECONDS = 150
33
37
  RECEIPT_TTL_SECONDS = 600
34
38
  IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}\Z")
35
39
  SYSTEM_PROMPT = (
@@ -53,7 +57,7 @@ class SideQuestionError(Exception):
53
57
 
54
58
  def capability():
55
59
  return {"available": True, "version": 2, "native_context": True, "backends": ["codex", "claude"],
56
- "max_question_chars": MAX_QUESTION_CHARS}
60
+ "max_question_chars": MAX_QUESTION_CHARS, "sync": True}
57
61
 
58
62
 
59
63
  def validate_history(value) -> tuple[tuple[str, str], ...]:
@@ -215,7 +219,7 @@ async def terminate_isolated_process(proc, *, force_group: bool = False):
215
219
 
216
220
 
217
221
  async def run_isolated_command(command, *, prompt: str, cwd: str, env: dict,
218
- timeout: float = REQUEST_TIMEOUT_SECONDS) -> str:
222
+ timeout: float | None = None) -> str:
219
223
  """Own only this fresh process group, including cancellation during spawn."""
220
224
  spawn = asyncio.create_task(asyncio.create_subprocess_exec(
221
225
  *command, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
@@ -341,12 +345,25 @@ NATIVE_IDLE_SECONDS = 30 * 60
341
345
 
342
346
 
343
347
  class SideQuestions:
344
- def __init__(self, answer=None, *, native_factory=None):
348
+ def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None):
345
349
  self.answer = answer
346
350
  self.native_factory = native_factory
351
+ self.admission_check = admission_check
347
352
  self.receipts: dict[tuple[str, str, str], _Receipt] = {}
348
353
  self.conversations: dict[tuple[str, str, str], _NativeConversation] = {}
349
354
  self.cleanup_tasks: set[asyncio.Task] = set()
355
+ self.synced = (SyncedSideChats(storage_path, native_factory=native_factory, notify=notify, admission_check=admission_check)
356
+ if storage_path is not None else None)
357
+
358
+ def active_session_ids(self):
359
+ sessions = {key[1] for key, receipt in self.receipts.items()
360
+ if receipt.task is not None and not receipt.task.done()}
361
+ if self.synced is not None:
362
+ sessions.update(key[1] for key, task in self.synced.tasks.items() if not task.done())
363
+ return sessions
364
+
365
+ def active_work_labels(self):
366
+ return [f"Side chat in {session_id}" for session_id in sorted(self.active_session_ids())]
350
367
 
351
368
  async def _close_conversation(self, conversation):
352
369
  conversation.closed = True
@@ -434,6 +451,8 @@ class SideQuestions:
434
451
 
435
452
  def submit(self, owner: str, session_id: str, request_id: str, question: str, *, history: list[dict] | None = None,
436
453
  side_chat_id: str | None = None, after_request_id: str | None = None):
454
+ if self.admission_check is not None:
455
+ self.admission_check()
437
456
  frozen_history = validate_history([] if history is None else history)
438
457
  self._prune()
439
458
  key = (owner, session_id, request_id)
@@ -456,9 +475,10 @@ class SideQuestions:
456
475
  if self.native_factory is not None else
457
476
  self.answer(session_id, question, history=history_messages(frozen_history))
458
477
  if frozen_history else self.answer(session_id, question))
459
- return await asyncio.wait_for(answer, REQUEST_TIMEOUT_SECONDS)
460
- except asyncio.TimeoutError:
461
- raise SideQuestionError(504, "Side question timed out") from None
478
+ # A live side answer may be thinking, using tools or waiting
479
+ # for approval. Only explicit cancellation or provider failure
480
+ # ends it; elapsed time does not close its native conversation.
481
+ return await answer
462
482
  finally:
463
483
  receipt.expires_at = time.monotonic() + RECEIPT_TTL_SECONDS
464
484
 
@@ -485,7 +505,27 @@ class SideQuestions:
485
505
  await asyncio.gather(receipt.task, return_exceptions=True)
486
506
  return status
487
507
 
508
+ async def close_session(self, session_id):
509
+ for key in tuple(self.receipts):
510
+ if key[1] == session_id:
511
+ await self.cancel(*key)
512
+ for key, conversation in tuple(self.conversations.items()):
513
+ if key[1] == session_id:
514
+ await self._close_conversation(conversation)
515
+ self.conversations.pop(key, None)
516
+ if self.synced is not None:
517
+ await self.synced.close_session(session_id)
518
+
519
+ def delete_history(self, session_id):
520
+ if self.synced is not None:
521
+ self.synced.delete_history(session_id)
522
+
523
+ def provider_thread_ids(self):
524
+ return self.synced.store.provider_thread_ids() if self.synced is not None else set()
525
+
488
526
  async def close(self):
527
+ if self.synced is not None:
528
+ await self.synced.close()
489
529
  tasks = [receipt.task for receipt in self.receipts.values()
490
530
  if receipt.task is not None and not receipt.task.done()]
491
531
  for task in tasks:
@@ -497,6 +537,387 @@ class SideQuestions:
497
537
  await asyncio.gather(*self.cleanup_tasks, return_exceptions=True)
498
538
 
499
539
 
540
+ def side_chat_timestamp():
541
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
542
+
543
+
544
+ class SideChatStore:
545
+ """Private, atomic side-chat state; never part of the main event log.
546
+
547
+ A request receipt is retained after Clear so a delayed/retried submission
548
+ cannot re-run a previously accepted tool-using request. Opening a database
549
+ after process restart marks unfinished answers interrupted, never resends.
550
+ """
551
+
552
+ def __init__(self, path):
553
+ self.path = Path(path)
554
+ self.database = None
555
+
556
+ def _db(self):
557
+ if self.database is None:
558
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
559
+ descriptor = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o600)
560
+ os.close(descriptor)
561
+ database = sqlite3.connect(self.path)
562
+ database.execute("CREATE TABLE IF NOT EXISTS chats (owner TEXT, session TEXT, document TEXT NOT NULL, PRIMARY KEY(owner, session))")
563
+ database.execute("CREATE TABLE IF NOT EXISTS receipts (owner TEXT, session TEXT, request TEXT, side_chat TEXT NOT NULL, question TEXT NOT NULL, PRIMARY KEY(owner, session, request))")
564
+ database.execute("CREATE TABLE IF NOT EXISTS provider_threads (thread TEXT PRIMARY KEY)")
565
+ with database:
566
+ if database.execute("PRAGMA user_version").fetchone()[0] == 0:
567
+ # Early development databases stored receipt question
568
+ # text. Only a digest is needed for duplicate detection.
569
+ for owner, session, request, question in database.execute("SELECT owner, session, request, question FROM receipts").fetchall():
570
+ database.execute("UPDATE receipts SET question=? WHERE owner=? AND session=? AND request=?",
571
+ (hashlib.sha256(question.encode()).hexdigest(), owner, session, request))
572
+ database.execute("PRAGMA user_version=1")
573
+ for owner, session, raw in database.execute("SELECT owner, session, document FROM chats").fetchall():
574
+ document = json.loads(raw)
575
+ thread = ((document.get("_provider_state") or {}).get("codex") or {}).get("thread_id")
576
+ if isinstance(thread, str) and thread:
577
+ database.execute("INSERT OR IGNORE INTO provider_threads VALUES(?)", (thread,))
578
+ changed = False
579
+ for exchange in document["exchanges"]:
580
+ if exchange["status"] == "running":
581
+ exchange.update(status="interrupted", error="side_question_interrupted", updated_at=side_chat_timestamp())
582
+ document["last_request_id"] = exchange["request_id"]
583
+ changed = True
584
+ if changed:
585
+ document["revision"] += 1
586
+ database.execute("UPDATE chats SET document=? WHERE owner=? AND session=?",
587
+ (json.dumps(document), owner, session))
588
+ self.database = database
589
+ return self.database
590
+
591
+ def load(self, owner, session):
592
+ row = self._db().execute("SELECT document FROM chats WHERE owner=? AND session=?", (owner, session)).fetchone()
593
+ if row:
594
+ return json.loads(row[0])
595
+ document = {"session_id": session, "side_chat_id": uuid.uuid4().hex, "revision": 0,
596
+ "exchanges": [], "last_request_id": None, "_provider_state": None}
597
+ self.save(owner, session, document)
598
+ return document
599
+
600
+ def save(self, owner, session, document, *, receipt=None):
601
+ database = self._db()
602
+ with database:
603
+ if receipt is not None:
604
+ database.execute("INSERT INTO receipts VALUES(?,?,?,?,?)",
605
+ (owner, session, receipt["request_id"], document["side_chat_id"],
606
+ hashlib.sha256(receipt["question"].encode()).hexdigest()))
607
+ provider_state = document.get("_provider_state") or {}
608
+ thread_id = (provider_state.get("codex") or {}).get("thread_id")
609
+ if isinstance(thread_id, str) and thread_id:
610
+ database.execute("INSERT OR IGNORE INTO provider_threads VALUES(?)", (thread_id,))
611
+ database.execute("INSERT OR REPLACE INTO chats VALUES(?,?,?)",
612
+ (owner, session, json.dumps(document, ensure_ascii=False)))
613
+
614
+ def receipt(self, owner, session, request_id):
615
+ return self._db().execute("SELECT side_chat, question FROM receipts WHERE owner=? AND session=? AND request=?",
616
+ (owner, session, request_id)).fetchone()
617
+
618
+ def close(self):
619
+ if self.database is not None:
620
+ self.database.close()
621
+ self.database = None
622
+
623
+ def delete_session(self, session):
624
+ if self.database is None and not self.path.exists():
625
+ return
626
+ database = self._db()
627
+ with database:
628
+ database.execute("DELETE FROM chats WHERE session=?", (session,))
629
+ database.execute("DELETE FROM receipts WHERE session=?", (session,))
630
+
631
+ def provider_thread_ids(self):
632
+ # Local history discovery runs in a worker thread. Use a separate
633
+ # read-only connection instead of sharing the event-loop writer. The
634
+ # opaque registry survives Clear/deletion so old side forks never
635
+ # reappear as importable main chats.
636
+ if not self.path.exists():
637
+ return set()
638
+ with closing(sqlite3.connect(self.path.resolve().as_uri() + "?mode=ro", uri=True)) as database:
639
+ tables = {row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")}
640
+ if "provider_threads" in tables:
641
+ return {row[0] for row in database.execute("SELECT thread FROM provider_threads")}
642
+ if "chats" in tables:
643
+ threads = set()
644
+ for (raw,) in database.execute("SELECT document FROM chats"):
645
+ state = json.loads(raw).get("_provider_state") or {}
646
+ thread = (state.get("codex") or {}).get("thread_id")
647
+ if isinstance(thread, str) and thread:
648
+ threads.add(thread)
649
+ return threads
650
+ return set()
651
+
652
+
653
+ class SyncedSideChats:
654
+ """Own detached requests and persisted native provider continuations.
655
+
656
+ The single server event loop serializes admission and state changes. The
657
+ SQLite commit precedes task creation, so accepted requests remain visible
658
+ after an app disconnect or an unexpected process exit.
659
+ """
660
+
661
+ def __init__(self, path, *, native_factory, notify=None, admission_check=None):
662
+ self.store = SideChatStore(path)
663
+ self.native_factory = native_factory
664
+ self.notify = notify
665
+ self.admission_check = admission_check
666
+ self.locks = {}
667
+ self.tasks = {}
668
+ self.handles = {}
669
+ self.timers = {}
670
+ self.cleanup_tasks = set()
671
+ self.pending_writes = {}
672
+ self.stopping = False
673
+
674
+ def _lock(self, key):
675
+ return self.locks.setdefault(key, asyncio.Lock())
676
+
677
+ def _load(self, key):
678
+ pending = self.pending_writes.get(key)
679
+ if pending is not None:
680
+ # A disk failure after an answer must not strand a permanently
681
+ # running receipt. The next client read retries only this write,
682
+ # never the provider request or its tools.
683
+ self.store.save(*key, pending)
684
+ self.pending_writes.pop(key, None)
685
+ return self.store.load(*key)
686
+
687
+ @staticmethod
688
+ def public(document):
689
+ return deepcopy({key: value for key, value in document.items() if not key.startswith("_")})
690
+
691
+ async def _changed(self, document):
692
+ if self.notify is not None:
693
+ # Notification is invalidation only. Loss of a socket never rolls
694
+ # back accepted work; reconnect always reads authoritative state.
695
+ with suppress(Exception):
696
+ await self.notify(document["session_id"], {
697
+ "type": "side_chat_updated", "session_id": document["session_id"],
698
+ "revision": document["revision"],
699
+ })
700
+
701
+ async def snapshot(self, owner, session):
702
+ 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):
706
+ key = (owner, session)
707
+ async with self._lock(key):
708
+ if self.stopping:
709
+ raise SideQuestionError(503, "Server is shutting down")
710
+ if self.admission_check is not None:
711
+ self.admission_check()
712
+ document = self._load(key)
713
+ previous = self.store.receipt(*key, request_id)
714
+ if previous is not None:
715
+ if previous != (side_chat_id, hashlib.sha256(question.encode()).hexdigest()) or document["side_chat_id"] != side_chat_id:
716
+ raise SideQuestionError(409, "Request ID belongs to a different or cleared side chat")
717
+ return self.public(document)
718
+ if document["side_chat_id"] != side_chat_id:
719
+ raise SideQuestionError(409, "Side chat was cleared; refresh before sending")
720
+ if any(item["status"] == "running" for item in document["exchanges"]):
721
+ raise SideQuestionError(409, "A side question is already running in this conversation")
722
+ active = self.tasks.get(key)
723
+ if active is not None and not active.done():
724
+ raise SideQuestionError(409, "The previous side question is stopping; retry after it stops")
725
+ if document["last_request_id"] != after_request_id:
726
+ raise SideQuestionError(409, "Side chat changed; refresh before sending")
727
+ exchange = {"request_id": request_id, "question": question, "status": "running",
728
+ "created_at": side_chat_timestamp(), "updated_at": side_chat_timestamp()}
729
+ document["exchanges"].append(exchange)
730
+ document["revision"] += 1
731
+ self.store.save(*key, document, receipt=exchange)
732
+ timer = self.timers.pop(key, None)
733
+ if timer is not None:
734
+ timer.cancel()
735
+ task = asyncio.create_task(self._answer(key, side_chat_id, request_id, question), name="synced-side-question")
736
+ self.tasks[key] = task
737
+ def done(completed):
738
+ if self.tasks.get(key) is completed:
739
+ self.tasks.pop(key, None)
740
+ if not completed.cancelled():
741
+ completed.exception()
742
+ task.add_done_callback(done)
743
+ snapshot = self.public(document)
744
+ await self._changed(document)
745
+ return snapshot
746
+
747
+ async def _provider_state(self, key, side_chat_id, value):
748
+ async with self._lock(key):
749
+ document = self._load(key)
750
+ if document["side_chat_id"] != side_chat_id:
751
+ raise SideQuestionError(409, "Side chat was cleared")
752
+ document["_provider_state"] = value
753
+ self.store.save(*key, document)
754
+
755
+ async def _answer(self, key, side_chat_id, request_id, question):
756
+ handle = None
757
+ failed = False
758
+ try:
759
+ document = self._load(key)
760
+ if document["side_chat_id"] != side_chat_id:
761
+ return
762
+ handle = self.handles.get(key)
763
+ if handle is None:
764
+ 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)
766
+ self.handles[key] = handle
767
+ history = [{"question": item["question"], "response": item["answer"]}
768
+ for item in document["exchanges"] if item["status"] == "completed"][-20:]
769
+ result = await handle.ask(question, history=history)
770
+ await self._finish(key, side_chat_id, request_id, "completed", **result)
771
+ except asyncio.CancelledError:
772
+ failed = True
773
+ await self._finish(key, side_chat_id, request_id,
774
+ "interrupted" if self.stopping else "cancelled",
775
+ **({"error": "side_question_interrupted"} if self.stopping else {}))
776
+ raise
777
+ except Exception as exc:
778
+ failed = True
779
+ await self._finish(key, side_chat_id, request_id, "failed",
780
+ error=f"side_question_http_{exc.status_code}" if isinstance(exc, SideQuestionError) else "side_question_failed")
781
+ finally:
782
+ if failed and handle is not None:
783
+ if self.handles.get(key) is handle:
784
+ self.handles.pop(key, None)
785
+ cleanup = asyncio.create_task(handle.close(), name="synced-side-chat-close")
786
+ self.cleanup_tasks.add(cleanup)
787
+ try:
788
+ # Stop, Clear and shutdown can race. Once a native process
789
+ # is ours, repeated cancellation must still join its close.
790
+ while not cleanup.done():
791
+ try:
792
+ await asyncio.shield(cleanup)
793
+ except asyncio.CancelledError:
794
+ continue
795
+ cleanup.result()
796
+ finally:
797
+ self.cleanup_tasks.discard(cleanup)
798
+ if self.tasks.get(key) is asyncio.current_task():
799
+ self.tasks.pop(key, None)
800
+ if not self.stopping:
801
+ self.timers[key] = asyncio.get_running_loop().call_later(NATIVE_IDLE_SECONDS, self._expire, key)
802
+
803
+ async def _finish(self, key, side_chat_id, request_id, status, **result):
804
+ async with self._lock(key):
805
+ document = self._load(key)
806
+ if document["side_chat_id"] != side_chat_id:
807
+ return
808
+ exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
809
+ if exchange is None or exchange["status"] != "running":
810
+ return
811
+ exchange.update(status=status, updated_at=side_chat_timestamp(), **result)
812
+ document["last_request_id"] = request_id
813
+ document["revision"] += 1
814
+ try:
815
+ self.store.save(*key, document)
816
+ except (OSError, sqlite3.Error):
817
+ self.pending_writes[key] = document
818
+ await self._changed(document)
819
+
820
+ def _expire(self, key):
821
+ self.timers.pop(key, None)
822
+ if key in self.tasks:
823
+ return
824
+ handle = self.handles.pop(key, None)
825
+ if handle is not None:
826
+ task = asyncio.create_task(handle.close(), name="synced-side-chat-idle-close")
827
+ self.cleanup_tasks.add(task)
828
+ task.add_done_callback(lambda done: (self.cleanup_tasks.discard(done), None if done.cancelled() else done.exception()))
829
+
830
+ async def cancel(self, owner, session, request_id):
831
+ key = (owner, session)
832
+ async with self._lock(key):
833
+ document = self._load(key)
834
+ exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
835
+ if exchange is None:
836
+ # Stop can overtake submission in transit. Preserve an opaque
837
+ # receipt so the late POST cannot start provider/tool work.
838
+ if self.store.receipt(*key, request_id) is None:
839
+ self.store.save(*key, document, receipt={"request_id": request_id, "question": ""})
840
+ return self.public(document)
841
+ if exchange["status"] != "running":
842
+ return self.public(document)
843
+ exchange.update(status="cancelled", updated_at=side_chat_timestamp())
844
+ document["last_request_id"] = request_id
845
+ document["revision"] += 1
846
+ try:
847
+ self.store.save(*key, document)
848
+ except (OSError, sqlite3.Error):
849
+ self.pending_writes[key] = document
850
+ task = self.tasks.get(key)
851
+ if task is not None:
852
+ task.cancel()
853
+ if task is not None:
854
+ await asyncio.shield(asyncio.gather(task, return_exceptions=True))
855
+ await self._changed(document)
856
+ return await self.snapshot(*key)
857
+
858
+ async def clear(self, owner, session, side_chat_id):
859
+ key = (owner, session)
860
+ async with self._lock(key):
861
+ document = self._load(key)
862
+ if document["side_chat_id"] != side_chat_id:
863
+ # A repeated Clear is harmless. A stale device cannot clear a
864
+ # new conversation it has not seen.
865
+ return self.public(document)
866
+ document.update(side_chat_id=uuid.uuid4().hex, exchanges=[], last_request_id=None, _provider_state=None)
867
+ document["revision"] += 1
868
+ try:
869
+ self.store.save(*key, document)
870
+ except (OSError, sqlite3.Error):
871
+ self.pending_writes[key] = document
872
+ task = self.tasks.get(key)
873
+ if task is not None:
874
+ task.cancel()
875
+ handle = self.handles.pop(key, None)
876
+ if task is not None:
877
+ await asyncio.shield(asyncio.gather(task, return_exceptions=True))
878
+ if handle is not None:
879
+ await handle.close()
880
+ await self._changed(document)
881
+ return await self.snapshot(*key)
882
+
883
+ async def close(self):
884
+ self.stopping = True
885
+ for timer in self.timers.values():
886
+ timer.cancel()
887
+ self.timers.clear()
888
+ tasks = list(self.tasks.values())
889
+ for task in tasks:
890
+ task.cancel()
891
+ await asyncio.gather(*tasks, return_exceptions=True)
892
+ handles, self.handles = list(self.handles.values()), {}
893
+ await asyncio.gather(*(handle.close() for handle in handles), return_exceptions=True)
894
+ await asyncio.gather(*self.cleanup_tasks, return_exceptions=True)
895
+ for key in tuple(self.pending_writes):
896
+ with suppress(OSError, sqlite3.Error):
897
+ self._load(key)
898
+ self.store.close()
899
+ # Managed-update retirement can be cancelled before process restart.
900
+ self.stopping = False
901
+
902
+ async def close_session(self, session):
903
+ tasks = [task for key, task in self.tasks.items() if key[1] == session]
904
+ for task in tasks:
905
+ task.cancel()
906
+ await asyncio.gather(*tasks, return_exceptions=True)
907
+ for key, timer in tuple(self.timers.items()):
908
+ if key[1] == session:
909
+ timer.cancel()
910
+ self.timers.pop(key, None)
911
+ handles = [self.handles.pop(key) for key in tuple(self.handles) if key[1] == session]
912
+ await asyncio.gather(*(handle.close() for handle in handles))
913
+
914
+ def delete_history(self, session):
915
+ self.store.delete_session(session)
916
+ for key in tuple(self.pending_writes):
917
+ if key[1] == session:
918
+ self.pending_writes.pop(key, None)
919
+
920
+
500
921
  def create_side_question_router(*, authorize, session_exists, runtime: SideQuestions):
501
922
  router = APIRouter()
502
923
 
@@ -591,4 +1012,66 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
591
1012
  await runtime.close_conversation(owner, session_id, side_chat_id)
592
1013
  return JSONResponse({"side_chat_id": side_chat_id, "status": "closed"}, headers={"Cache-Control": "no-store"})
593
1014
 
1015
+ def synced_runtime():
1016
+ if runtime.synced is None:
1017
+ raise HTTPException(503, "Side chat storage is unavailable")
1018
+ return runtime.synced
1019
+
1020
+ async def synced_response(operation, *, status_code=200):
1021
+ try:
1022
+ return JSONResponse(await operation, status_code=status_code, headers={"Cache-Control": "no-store"})
1023
+ except SideQuestionError as exc:
1024
+ raise HTTPException(exc.status_code, str(exc)) from None
1025
+ except (OSError, sqlite3.Error):
1026
+ raise HTTPException(503, "Side chat could not be saved; check server storage and retry") from None
1027
+
1028
+ @router.get("/api/sessions/{session_id}/side-chat")
1029
+ async def get_synced(session_id: str, request: Request):
1030
+ owner = guard(request, session_id)
1031
+ return await synced_response(synced_runtime().snapshot(owner, session_id))
1032
+
1033
+ @router.post("/api/sessions/{session_id}/side-chat")
1034
+ async def post_synced(session_id: str, request: Request):
1035
+ owner = guard(request, session_id)
1036
+ async def body():
1037
+ data = bytearray()
1038
+ async for chunk in request.stream():
1039
+ if len(data) + len(chunk) > MAX_REQUEST_BYTES:
1040
+ raise HTTPException(413, "Side question request is too large")
1041
+ data.extend(chunk)
1042
+ return json.loads(data)
1043
+ try:
1044
+ value = await asyncio.wait_for(body(), 10)
1045
+ except (ValueError, UnicodeError, RecursionError):
1046
+ raise HTTPException(400, "Invalid side question request") from None
1047
+ except asyncio.TimeoutError:
1048
+ raise HTTPException(408, "Side question request was not received") from None
1049
+ 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"}):
1051
+ raise HTTPException(400, "Invalid side question fields")
1052
+ for field in ("request_id", "side_chat_id", "after_request_id"):
1053
+ if field == "after_request_id" and value.get(field) is None:
1054
+ continue
1055
+ if not isinstance(value.get(field), str) or not IDENTIFIER.fullmatch(value[field]):
1056
+ raise HTTPException(400, "Invalid side conversation ID")
1057
+ question = value["question"]
1058
+ if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
1059
+ raise HTTPException(400, "Question must contain 1 to 8000 characters")
1060
+ try:
1061
+ question.encode("utf-8")
1062
+ except UnicodeEncodeError:
1063
+ raise HTTPException(400, "Question must contain valid Unicode text") from None
1064
+ 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)
1066
+
1067
+ @router.delete("/api/sessions/{session_id}/side-chat/requests/{request_id}")
1068
+ async def cancel_synced(session_id: str, request_id: str, request: Request):
1069
+ owner = guard(request, session_id, request_id)
1070
+ return await synced_response(synced_runtime().cancel(owner, session_id, request_id))
1071
+
1072
+ @router.delete("/api/sessions/{session_id}/side-chat/{side_chat_id}")
1073
+ async def clear_synced(session_id: str, side_chat_id: str, request: Request):
1074
+ owner = guard(request, session_id, side_chat_id)
1075
+ return await synced_response(synced_runtime().clear(owner, session_id, side_chat_id))
1076
+
594
1077
  return router
@@ -242,6 +242,9 @@ def cursor_profile(root: Path, original_env: dict) -> tuple[Path, dict]:
242
242
  'NODE_EXTRA_CA_CERTS', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
243
243
  'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy', 'CURSOR_API_KEY', 'CURSOR_API_ENDPOINT'}
244
244
  env = {k: v for k, v in original_env.items() if k in allowed}
245
+ if original_env.get('AGENT_CLI_CREDENTIAL_STORE') == 'memory' and env.get('CURSOR_API_KEY'):
246
+ from cursor_api_key import overrides
247
+ env.update(overrides(env['CURSOR_API_KEY']))
245
248
  env.update(HOME=str(home), USERPROFILE=str(home), CURSOR_CONFIG_DIR=str(config),
246
249
  CURSOR_DATA_DIR=str(root / 'data'), XDG_CONFIG_HOME=str(home / '.config'),
247
250
  XDG_DATA_HOME=str(root / 'data'), XDG_CACHE_HOME=str(root / 'cache'),
@@ -298,7 +301,7 @@ async def cursor_keychain_snapshot(env: dict, isolated: dict, workspace: Path) -
298
301
 
299
302
 
300
303
  async def generate_cursor_title(prompt: str, *, executable: str, model: str | None, env: dict) -> str | None:
301
- model = model or cursor_default_model(env)
304
+ model = model or ('auto' if env.get('AGENT_CLI_CREDENTIAL_STORE') == 'memory' else cursor_default_model(env))
302
305
  with tempfile.TemporaryDirectory(prefix='agentsdock-title-cursor-') as directory:
303
306
  workspace, isolated = cursor_profile(Path(directory), env)
304
307
  await cursor_keychain_snapshot(env, isolated, workspace)