@researai/deepscientist 1.5.15 → 1.5.17

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 (202) hide show
  1. package/README.md +385 -104
  2. package/bin/ds.js +1241 -110
  3. package/docs/en/00_QUICK_START.md +100 -19
  4. package/docs/en/01_SETTINGS_REFERENCE.md +34 -1
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  6. package/docs/en/05_TUI_GUIDE.md +6 -0
  7. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  8. package/docs/en/09_DOCTOR.md +25 -8
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  10. package/docs/en/15_CODEX_PROVIDER_SETUP.md +37 -11
  11. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  12. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  13. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  14. package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +283 -0
  15. package/docs/en/91_DEVELOPMENT.md +237 -0
  16. package/docs/en/README.md +24 -2
  17. package/docs/zh/00_QUICK_START.md +89 -19
  18. package/docs/zh/01_SETTINGS_REFERENCE.md +34 -1
  19. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  20. package/docs/zh/05_TUI_GUIDE.md +6 -0
  21. package/docs/zh/09_DOCTOR.md +26 -9
  22. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  23. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +37 -11
  24. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  25. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  26. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  27. package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +281 -0
  28. package/docs/zh/README.md +24 -2
  29. package/install.sh +46 -4
  30. package/package.json +2 -1
  31. package/pyproject.toml +1 -1
  32. package/src/deepscientist/__init__.py +1 -1
  33. package/src/deepscientist/acp/envelope.py +6 -0
  34. package/src/deepscientist/artifact/service.py +647 -22
  35. package/src/deepscientist/bash_exec/service.py +234 -9
  36. package/src/deepscientist/bridges/connectors.py +8 -2
  37. package/src/deepscientist/cli.py +115 -19
  38. package/src/deepscientist/codex_cli_compat.py +367 -22
  39. package/src/deepscientist/config/models.py +2 -1
  40. package/src/deepscientist/config/service.py +183 -13
  41. package/src/deepscientist/daemon/api/handlers.py +255 -31
  42. package/src/deepscientist/daemon/api/router.py +9 -0
  43. package/src/deepscientist/daemon/app.py +1146 -105
  44. package/src/deepscientist/diagnostics/__init__.py +6 -0
  45. package/src/deepscientist/diagnostics/runner_failures.py +130 -0
  46. package/src/deepscientist/doctor.py +207 -3
  47. package/src/deepscientist/gitops/__init__.py +10 -1
  48. package/src/deepscientist/gitops/diff.py +129 -0
  49. package/src/deepscientist/gitops/service.py +4 -1
  50. package/src/deepscientist/mcp/server.py +39 -0
  51. package/src/deepscientist/prompts/builder.py +275 -34
  52. package/src/deepscientist/quest/layout.py +15 -2
  53. package/src/deepscientist/quest/service.py +707 -55
  54. package/src/deepscientist/quest/stage_views.py +6 -1
  55. package/src/deepscientist/runners/codex.py +143 -43
  56. package/src/deepscientist/shared.py +19 -0
  57. package/src/deepscientist/skills/__init__.py +2 -2
  58. package/src/deepscientist/skills/installer.py +196 -5
  59. package/src/deepscientist/skills/registry.py +66 -0
  60. package/src/prompts/connectors/qq.md +18 -8
  61. package/src/prompts/connectors/weixin.md +16 -6
  62. package/src/prompts/contracts/shared_interaction.md +14 -2
  63. package/src/prompts/system.md +23 -5
  64. package/src/prompts/system_copilot.md +56 -0
  65. package/src/skills/analysis-campaign/SKILL.md +1 -0
  66. package/src/skills/baseline/SKILL.md +8 -0
  67. package/src/skills/decision/SKILL.md +8 -0
  68. package/src/skills/experiment/SKILL.md +8 -0
  69. package/src/skills/figure-polish/SKILL.md +1 -0
  70. package/src/skills/finalize/SKILL.md +1 -0
  71. package/src/skills/idea/SKILL.md +1 -0
  72. package/src/skills/intake-audit/SKILL.md +8 -0
  73. package/src/skills/mentor/SKILL.md +217 -0
  74. package/src/skills/mentor/references/correction-rules.md +210 -0
  75. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  76. package/src/skills/mentor/references/persona-profile.md +138 -0
  77. package/src/skills/mentor/references/taste-profile.md +128 -0
  78. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  79. package/src/skills/mentor/references/work-profile.md +289 -0
  80. package/src/skills/mentor/references/workflow-profile.md +240 -0
  81. package/src/skills/optimize/SKILL.md +1 -0
  82. package/src/skills/rebuttal/SKILL.md +1 -0
  83. package/src/skills/review/SKILL.md +1 -0
  84. package/src/skills/scout/SKILL.md +8 -0
  85. package/src/skills/write/SKILL.md +1 -0
  86. package/src/tui/dist/app/AppContainer.js +19 -11
  87. package/src/tui/dist/index.js +4 -1
  88. package/src/tui/dist/lib/api.js +33 -3
  89. package/src/tui/package.json +1 -1
  90. package/src/ui/dist/assets/AiManusChatView-Bv-Z8YpU.js +204 -0
  91. package/src/ui/dist/assets/AnalysisPlugin-BCKAfjba.js +1 -0
  92. package/src/ui/dist/assets/CliPlugin-BCKcpc35.js +109 -0
  93. package/src/ui/dist/assets/CodeEditorPlugin-DbOfSJ8K.js +2 -0
  94. package/src/ui/dist/assets/CodeViewerPlugin-CbaFRrUU.js +270 -0
  95. package/src/ui/dist/assets/DocViewerPlugin-DAjLVeQD.js +7 -0
  96. package/src/ui/dist/assets/GitCommitViewerPlugin-CIUqbUDO.js +1 -0
  97. package/src/ui/dist/assets/GitDiffViewerPlugin-CQACjoAA.js +6 -0
  98. package/src/ui/dist/assets/GitSnapshotViewer-0r4nLPke.js +30 -0
  99. package/src/ui/dist/assets/ImageViewerPlugin-nBOmI2v_.js +26 -0
  100. package/src/ui/dist/assets/LabCopilotPanel-BHxOxF4z.js +14 -0
  101. package/src/ui/dist/assets/LabPlugin-BKoZGs95.js +22 -0
  102. package/src/ui/dist/assets/LatexPlugin-ZwtV8pIp.js +25 -0
  103. package/src/ui/dist/assets/MarkdownViewerPlugin-DKqVfKyW.js +128 -0
  104. package/src/ui/dist/assets/MarketplacePlugin-BwxStZ9D.js +13 -0
  105. package/src/ui/dist/assets/NotebookEditor-BEQhaQbt.js +81 -0
  106. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  107. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  108. package/src/ui/dist/assets/NotebookEditor-DB9N_T9q.js +361 -0
  109. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  110. package/src/ui/dist/assets/PdfLoader-eWBONbQP.js +16 -0
  111. package/src/ui/dist/assets/PdfMarkdownPlugin-D22YOZL3.js +1 -0
  112. package/src/ui/dist/assets/PdfViewerPlugin-c-RK9DLM.js +17 -0
  113. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  114. package/src/ui/dist/assets/SearchPlugin-CxF9ytAx.js +16 -0
  115. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  116. package/src/ui/dist/assets/TextViewerPlugin-C5xqeeUH.js +54 -0
  117. package/src/ui/dist/assets/VNCViewer-BoLGLnHz.js +11 -0
  118. package/src/ui/dist/assets/bot-DREQOxzP.js +6 -0
  119. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  120. package/src/ui/dist/assets/chevron-up-C9Qpx4DE.js +6 -0
  121. package/src/ui/dist/assets/code-WlFHE7z_.js +6 -0
  122. package/src/ui/dist/assets/file-content-BZMz3RYp.js +1 -0
  123. package/src/ui/dist/assets/file-diff-panel-CQhw0jS2.js +1 -0
  124. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  125. package/src/ui/dist/assets/file-socket-CfQPKQKj.js +1 -0
  126. package/src/ui/dist/assets/git-commit-horizontal-DxZ8DCZh.js +6 -0
  127. package/src/ui/dist/assets/image-Bgl4VIyx.js +6 -0
  128. package/src/ui/dist/assets/index-BpV6lusQ.css +33 -0
  129. package/src/ui/dist/assets/index-CBNVuWcP.js +2496 -0
  130. package/src/ui/dist/assets/index-CwNu1aH4.js +11 -0
  131. package/src/ui/dist/assets/index-DrUnlf6K.js +1 -0
  132. package/src/ui/dist/assets/index-NW-h8VzN.js +1 -0
  133. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  134. package/src/ui/dist/assets/pdf-effect-queue-J8OnM0jE.js +6 -0
  135. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  136. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  137. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  138. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  139. package/src/ui/dist/assets/popover-CLc0pPP8.js +1 -0
  140. package/src/ui/dist/assets/project-sync-C9IdzdZW.js +1 -0
  141. package/src/ui/dist/assets/select-Cs2PmzwL.js +11 -0
  142. package/src/ui/dist/assets/sigma-ClKcHAXm.js +6 -0
  143. package/src/ui/dist/assets/trash-DwpbFr3w.js +11 -0
  144. package/src/ui/dist/assets/useCliAccess-NQ8m0Let.js +1 -0
  145. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  146. package/src/ui/dist/assets/wrap-text-BC-Hltpd.js +11 -0
  147. package/src/ui/dist/assets/zoom-out-E_gaeAxL.js +11 -0
  148. package/src/ui/dist/index.html +5 -2
  149. package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
  150. package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
  151. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
  152. package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
  153. package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
  154. package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
  155. package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
  156. package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
  157. package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
  158. package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
  159. package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
  160. package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
  161. package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
  162. package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
  163. package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
  164. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  165. package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
  166. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  167. package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
  168. package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
  169. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  170. package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
  171. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  172. package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
  173. package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
  174. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  175. package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
  176. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  177. package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
  178. package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
  179. package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
  180. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  181. package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
  182. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  183. package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
  184. package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
  185. package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
  186. package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
  187. package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
  188. package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
  189. package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
  190. package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
  191. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  192. package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
  193. package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
  194. package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
  195. package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
  196. package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
  197. package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
  198. package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
  199. package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
  200. package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
  201. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  202. package/src/ui/dist/assets/zoom-out-BgVMmOW4.js +0 -34
@@ -2,12 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  from collections import deque
5
+ from dataclasses import dataclass
5
6
  import faulthandler
6
7
  import hashlib
8
+ import hmac
7
9
  import json
8
10
  import mimetypes
9
11
  import os
10
12
  import re
13
+ import secrets
11
14
  import signal
12
15
  import shutil
13
16
  import subprocess
@@ -16,6 +19,7 @@ import threading
16
19
  import time
17
20
  import traceback
18
21
  from datetime import UTC, datetime, timedelta
22
+ from http.cookies import SimpleCookie
19
23
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
24
  from pathlib import Path
21
25
  from typing import Any
@@ -24,9 +28,11 @@ from urllib.request import Request
24
28
 
25
29
  from .. import __version__
26
30
  from ..annotations import AnnotationService
31
+ from ..acp import build_session_update
27
32
  from ..artifact import ArtifactService
28
33
  from ..bash_exec import BashExecService
29
34
  from ..bash_exec.models import TerminalClient
35
+ from ..bash_exec.service import DEFAULT_TERMINAL_SESSION_ID
30
36
  from ..bridges import register_builtin_connector_bridges
31
37
  from ..bridges.connectors import QQConnectorBridge
32
38
  from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
@@ -49,6 +55,7 @@ from ..connector.connector_profiles import (
49
55
  from ..connector_runtime import conversation_identity_key, format_conversation_id, normalize_conversation_id, parse_conversation_id
50
56
  from ..config import ConfigManager
51
57
  from ..config.models import SYSTEM_CONNECTOR_NAMES
58
+ from ..diagnostics import FailureDiagnosis, diagnose_runner_failure
52
59
  from ..home import repo_root
53
60
  from ..memory import MemoryService
54
61
  from ..network import urlopen_with_proxy as urlopen
@@ -69,7 +76,7 @@ from ..connector.lingzhu_support import (
69
76
  lingzhu_verify_auth_header,
70
77
  )
71
78
  from ..prompts import PromptBuilder
72
- from ..prompts.builder import STANDARD_SKILLS, classify_turn_intent
79
+ from ..prompts.builder import classify_turn_intent, current_standard_skills
73
80
  from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
74
81
  from ..quest import QuestService
75
82
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
@@ -96,7 +103,11 @@ from websockets.sync.server import Server as WebSocketServer
96
103
  from websockets.sync.server import ServerConnection, serve as websocket_serve
97
104
 
98
105
  TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
99
- _AUTO_CONTINUE_DELAY_SECONDS = 0.2
106
+ _AUTO_CONTINUE_DELAY_SECONDS = 240.0
107
+ _AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS = 0.2
108
+ _TERMINAL_PREWARM_DEBOUNCE_SECONDS = 20.0
109
+ _STALLED_RUNNING_TURN_INACTIVITY_SECONDS = 30 * 60
110
+ _STALLED_RUNNING_TURN_INTERRUPT_TIMEOUT_SECONDS = 5.0
100
111
  CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
101
112
  CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
102
113
  CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
@@ -144,6 +155,20 @@ _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
144
155
  _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT = 5
145
156
  _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT = 2.0
146
157
  _LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
158
+ _BROWSER_AUTH_COOKIE_NAME = "ds_local_auth"
159
+ _BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
160
+ _BROWSER_AUTH_QUERY_PARAM = "token"
161
+ _BROWSER_AUTH_STORAGE_KEY = "ds_local_auth_token"
162
+ _BROWSER_AUTH_PUBLIC_ROUTE_NAMES = {"root", "spa_root", "ui_asset", "asset", "auth_login"}
163
+ _BROWSER_AUTH_EXEMPT_ROUTE_NAMES = {"lingzhu_health", "lingzhu_sse"}
164
+ _BROWSER_AUTH_REALM = "DeepScientist"
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class BrowserAuthState:
169
+ authenticated: bool
170
+ token_source: str | None = None
171
+ response_cookie: str | None = None
147
172
 
148
173
 
149
174
  def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
@@ -155,7 +180,14 @@ def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
155
180
  class DaemonApp:
156
181
  _MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
157
182
 
158
- def __init__(self, home: Path) -> None:
183
+ def __init__(
184
+ self,
185
+ home: Path,
186
+ *,
187
+ browser_auth_enabled: bool | None = None,
188
+ browser_auth_token: str | None = None,
189
+ prompt_version_selection: str | None = None,
190
+ ) -> None:
159
191
  self.home = home.resolve()
160
192
  self.daemon_id = str(os.environ.get("DS_DAEMON_ID") or "").strip() or generate_id("daemon")
161
193
  self.daemon_managed_by = str(os.environ.get("DS_DAEMON_MANAGED_BY") or "manual").strip() or "manual"
@@ -191,7 +223,11 @@ class DaemonApp:
191
223
  abandoned_run_id=item.get("abandoned_run_id"),
192
224
  status=item.get("status"),
193
225
  )
194
- self.prompt_builder = PromptBuilder(self.repo_root, home)
226
+ self.prompt_builder = PromptBuilder(
227
+ self.repo_root,
228
+ home,
229
+ prompt_version_selection=prompt_version_selection,
230
+ )
195
231
  self.codex_runner = CodexRunner(
196
232
  home=home,
197
233
  repo_root=self.repo_root,
@@ -211,6 +247,8 @@ class DaemonApp:
211
247
  self._canonicalize_lingzhu_binding_state()
212
248
  self._turn_lock = threading.Lock()
213
249
  self._turn_state: dict[str, dict[str, object]] = {}
250
+ self._terminal_prewarm_lock = threading.Lock()
251
+ self._terminal_prewarm_recent: dict[str, float] = {}
214
252
  self._server: ThreadingHTTPServer | None = None
215
253
  self._terminal_attach_server: WebSocketServer | None = None
216
254
  self._terminal_attach_thread: threading.Thread | None = None
@@ -230,8 +268,190 @@ class DaemonApp:
230
268
  self._process_hooks_installed = False
231
269
  self._faulthandler_stream = None
232
270
  self._recovered_quest_ids: set[str] = set()
271
+ ui_config = config.get("ui") if isinstance(config.get("ui"), dict) else {}
272
+ configured_browser_auth_enabled = self._parse_browser_auth_bool(ui_config.get("auth_enabled"))
273
+ env_browser_auth_enabled = self._parse_browser_auth_bool(os.environ.get("DS_UI_AUTH_ENABLED"))
274
+ explicit_browser_auth_enabled = self._parse_browser_auth_bool(browser_auth_enabled)
275
+ if explicit_browser_auth_enabled is not None:
276
+ self.browser_auth_enabled = explicit_browser_auth_enabled
277
+ elif env_browser_auth_enabled is not None:
278
+ self.browser_auth_enabled = env_browser_auth_enabled
279
+ elif configured_browser_auth_enabled is not None:
280
+ self.browser_auth_enabled = configured_browser_auth_enabled
281
+ else:
282
+ self.browser_auth_enabled = False
283
+ explicit_browser_auth_token = self._normalize_browser_auth_token(browser_auth_token)
284
+ env_browser_auth_token = self._normalize_browser_auth_token(os.environ.get("DS_UI_AUTH_TOKEN"))
285
+ if self.browser_auth_enabled:
286
+ self.browser_auth_token = explicit_browser_auth_token or env_browser_auth_token or self.generate_browser_auth_token()
287
+ else:
288
+ self.browser_auth_token = None
233
289
  self.handlers = ApiHandlers(self)
234
290
 
291
+ @staticmethod
292
+ def _parse_browser_auth_bool(value: object) -> bool | None:
293
+ if isinstance(value, bool):
294
+ return value
295
+ normalized = str(value or "").strip().lower()
296
+ if not normalized:
297
+ return None
298
+ if normalized in {"1", "true", "yes", "on"}:
299
+ return True
300
+ if normalized in {"0", "false", "no", "off"}:
301
+ return False
302
+ return None
303
+
304
+ @staticmethod
305
+ def _normalize_browser_auth_token(value: object) -> str | None:
306
+ token = str(value or "").strip()
307
+ return token or None
308
+
309
+ @staticmethod
310
+ def generate_browser_auth_token() -> str:
311
+ return secrets.token_hex(8)
312
+
313
+ def masked_browser_auth_token(self) -> str | None:
314
+ token = self.browser_auth_token
315
+ if not token:
316
+ return None
317
+ if len(token) <= 6:
318
+ return "*" * len(token)
319
+ return f"{token[:3]}{'*' * (len(token) - 6)}{token[-3:]}"
320
+
321
+ @staticmethod
322
+ def _header_value(headers: dict[str, str] | None, name: str) -> str:
323
+ if not isinstance(headers, dict):
324
+ return ""
325
+ target = name.strip().lower()
326
+ for key, value in headers.items():
327
+ if str(key).strip().lower() == target:
328
+ return str(value or "")
329
+ return ""
330
+
331
+ @staticmethod
332
+ def _parse_bearer_token(header_value: str) -> str | None:
333
+ normalized = str(header_value or "").strip()
334
+ prefix = "bearer "
335
+ if not normalized or normalized[: len(prefix)].lower() != prefix:
336
+ return None
337
+ token = normalized[len(prefix) :].strip()
338
+ return token or None
339
+
340
+ def _request_cookie_token(self, headers: dict[str, str] | None) -> str | None:
341
+ raw_cookie = self._header_value(headers, "Cookie")
342
+ if not raw_cookie:
343
+ return None
344
+ try:
345
+ cookie = SimpleCookie()
346
+ cookie.load(raw_cookie)
347
+ except Exception:
348
+ return None
349
+ morsel = cookie.get(_BROWSER_AUTH_COOKIE_NAME)
350
+ if morsel is None:
351
+ return None
352
+ token = str(getattr(morsel, "value", "") or "").strip()
353
+ return token or None
354
+
355
+ @staticmethod
356
+ def _request_query_token(path: str) -> str | None:
357
+ query = parse_qs(urlparse(path).query, keep_blank_values=True)
358
+ token = str((query.get(_BROWSER_AUTH_QUERY_PARAM) or [""])[0] or "").strip()
359
+ return token or None
360
+
361
+ def _browser_auth_cookie_header(self, token: str | None = None) -> str:
362
+ cookie = SimpleCookie()
363
+ cookie[_BROWSER_AUTH_COOKIE_NAME] = token or (self.browser_auth_token or "")
364
+ morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
365
+ morsel["path"] = "/"
366
+ morsel["httponly"] = True
367
+ morsel["samesite"] = "Strict"
368
+ morsel["max-age"] = str(_BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS)
369
+ return morsel.OutputString()
370
+
371
+ @staticmethod
372
+ def _browser_auth_clear_cookie_header() -> str:
373
+ cookie = SimpleCookie()
374
+ cookie[_BROWSER_AUTH_COOKIE_NAME] = ""
375
+ morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
376
+ morsel["path"] = "/"
377
+ morsel["httponly"] = True
378
+ morsel["samesite"] = "Strict"
379
+ morsel["max-age"] = "0"
380
+ morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
381
+ return morsel.OutputString()
382
+
383
+ def browser_auth_matches(self, token: str | None) -> bool:
384
+ expected = self.browser_auth_token
385
+ candidate = self._normalize_browser_auth_token(token)
386
+ return bool(expected and candidate and hmac.compare_digest(candidate, expected))
387
+
388
+ def rotate_browser_auth_token(self) -> str:
389
+ if not self.browser_auth_enabled:
390
+ raise RuntimeError("Browser authentication is disabled.")
391
+ rotated = self.generate_browser_auth_token()
392
+ self.browser_auth_token = rotated
393
+ return rotated
394
+
395
+ def browser_auth_state_for_request(self, path: str, headers: dict[str, str] | None = None) -> BrowserAuthState:
396
+ if not self.browser_auth_enabled:
397
+ return BrowserAuthState(authenticated=True)
398
+ expected = self.browser_auth_token
399
+ if not expected:
400
+ return BrowserAuthState(authenticated=False)
401
+
402
+ candidates = (
403
+ ("authorization", self._parse_bearer_token(self._header_value(headers, "Authorization"))),
404
+ ("query", self._request_query_token(path)),
405
+ ("cookie", self._request_cookie_token(headers)),
406
+ )
407
+ for source, candidate in candidates:
408
+ if candidate and hmac.compare_digest(candidate, expected):
409
+ response_cookie = self._browser_auth_cookie_header(expected) if source in {"authorization", "query"} else None
410
+ return BrowserAuthState(authenticated=True, token_source=source, response_cookie=response_cookie)
411
+ return BrowserAuthState(authenticated=False, response_cookie=self._browser_auth_clear_cookie_header())
412
+
413
+ @staticmethod
414
+ def _auth_response_headers(auth_state: BrowserAuthState | None) -> dict[str, str]:
415
+ if auth_state is None or not auth_state.response_cookie:
416
+ return {}
417
+ return {"Set-Cookie": auth_state.response_cookie}
418
+
419
+ @staticmethod
420
+ def _merge_response_headers(
421
+ base: dict[str, str] | None = None,
422
+ extra: dict[str, str] | None = None,
423
+ ) -> dict[str, str]:
424
+ merged: dict[str, str] = {}
425
+ if isinstance(extra, dict):
426
+ merged.update(extra)
427
+ if isinstance(base, dict):
428
+ merged.update(base)
429
+ return merged
430
+
431
+ def _route_requires_browser_auth(self, route_name: str | None) -> bool:
432
+ if not self.browser_auth_enabled or not route_name:
433
+ return False
434
+ if route_name in _BROWSER_AUTH_PUBLIC_ROUTE_NAMES:
435
+ return False
436
+ if route_name in _BROWSER_AUTH_EXEMPT_ROUTE_NAMES:
437
+ return False
438
+ return True
439
+
440
+ def browser_auth_runtime_payload(self) -> dict[str, object]:
441
+ return {
442
+ "enabled": self.browser_auth_enabled,
443
+ "tokenQueryParam": _BROWSER_AUTH_QUERY_PARAM,
444
+ "storageKey": _BROWSER_AUTH_STORAGE_KEY,
445
+ }
446
+
447
+ def browser_auth_tokenized_url(self, url: str) -> str:
448
+ if not self.browser_auth_enabled or not self.browser_auth_token:
449
+ return url
450
+ parsed = urlparse(url)
451
+ query = parse_qs(parsed.query, keep_blank_values=True)
452
+ query[_BROWSER_AUTH_QUERY_PARAM] = [self.browser_auth_token]
453
+ return parsed._replace(query=urlencode(query, doseq=True)).geturl()
454
+
235
455
  def list_connector_statuses(self) -> list[dict[str, object]]:
236
456
  title_by_quest = self._quest_titles_by_id()
237
457
  items = [
@@ -538,7 +758,23 @@ class DaemonApp:
538
758
  if int(snapshot.get("pending_user_message_count") or 0) > 0
539
759
  else "auto_continue"
540
760
  )
541
- scheduled = self.schedule_turn(quest_id, reason=reason)
761
+ retry_delay_seconds = self._recovery_retry_delay_seconds(snapshot) if reason == "auto_continue" else None
762
+ if retry_delay_seconds is not None and retry_delay_seconds > 0:
763
+ self._schedule_turn_later(
764
+ quest_id,
765
+ reason=reason,
766
+ delay_seconds=retry_delay_seconds,
767
+ )
768
+ scheduled = {
769
+ "scheduled": True,
770
+ "started": False,
771
+ "queued": True,
772
+ "reason": reason,
773
+ "delayed": True,
774
+ "delay_seconds": retry_delay_seconds,
775
+ }
776
+ else:
777
+ scheduled = self.schedule_turn(quest_id, reason=reason)
542
778
  event = {
543
779
  "event_id": generate_id("evt"),
544
780
  "type": "quest.runtime_auto_resumed",
@@ -550,6 +786,8 @@ class DaemonApp:
550
786
  "scheduled": bool(scheduled.get("scheduled")),
551
787
  "started": bool(scheduled.get("started")),
552
788
  "queued": bool(scheduled.get("queued")),
789
+ "delayed": bool(scheduled.get("delayed")),
790
+ "delay_seconds": scheduled.get("delay_seconds"),
553
791
  "created_at": utc_now(),
554
792
  }
555
793
  append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", event)
@@ -564,6 +802,8 @@ class DaemonApp:
564
802
  scheduled=bool(scheduled.get("scheduled")),
565
803
  started=bool(scheduled.get("started")),
566
804
  queued=bool(scheduled.get("queued")),
805
+ delayed=bool(scheduled.get("delayed")),
806
+ delay_seconds=scheduled.get("delay_seconds"),
567
807
  )
568
808
  self._recovered_quest_ids.add(quest_id)
569
809
  resumed.append(
@@ -617,6 +857,63 @@ class DaemonApp:
617
857
  count += 1
618
858
  return count
619
859
 
860
+ def _recovery_retry_delay_seconds(self, snapshot: dict[str, Any]) -> float | None:
861
+ retry_state = snapshot.get("retry_state") if isinstance(snapshot.get("retry_state"), dict) else None
862
+ if not retry_state:
863
+ return None
864
+ next_retry_at = str(retry_state.get("next_retry_at") or "").strip()
865
+ if not next_retry_at:
866
+ return None
867
+ parsed = self._parse_event_timestamp(next_retry_at)
868
+ if parsed is None:
869
+ return None
870
+ return max((parsed - datetime.now(UTC)).total_seconds(), 0.0)
871
+
872
+ def _resume_retry_state(
873
+ self,
874
+ snapshot: dict[str, Any],
875
+ *,
876
+ max_attempts: int,
877
+ ) -> tuple[int, str | None, dict[str, Any] | None]:
878
+ retry_state = snapshot.get("retry_state") if isinstance(snapshot.get("retry_state"), dict) else None
879
+ resume_source = str(snapshot.get("last_resume_source") or "").strip()
880
+ if not retry_state or not resume_source.startswith("auto:daemon-recovery"):
881
+ return 1, None, None
882
+
883
+ try:
884
+ recorded_attempt = int(retry_state.get("attempt_index") or 0)
885
+ except (TypeError, ValueError):
886
+ recorded_attempt = 0
887
+ if recorded_attempt <= 0:
888
+ return 1, None, None
889
+
890
+ next_retry_at = str(retry_state.get("next_retry_at") or "").strip()
891
+ start_attempt = recorded_attempt + 1 if next_retry_at else recorded_attempt
892
+ if start_attempt > max_attempts:
893
+ start_attempt = max_attempts
894
+ if start_attempt <= 1:
895
+ return 1, None, None
896
+
897
+ turn_id = str(retry_state.get("turn_id") or "").strip() or None
898
+ previous_run_id = str(retry_state.get("last_run_id") or "").strip() or None
899
+ failure_summary = str(retry_state.get("last_error") or "").strip() or None
900
+ retry_context = {
901
+ "turn_id": turn_id,
902
+ "attempt_index": recorded_attempt,
903
+ "max_attempts": max_attempts,
904
+ "previous_run_id": previous_run_id,
905
+ "failure_kind": "daemon_recovery",
906
+ "failure_summary": failure_summary or "Recovered retry state after daemon restart.",
907
+ "previous_exit_code": None,
908
+ "previous_output_text": "",
909
+ "stderr_tail": "",
910
+ "recent_messages": [],
911
+ "tool_progress": [],
912
+ "workspace_summary": {},
913
+ "recent_artifacts": [],
914
+ }
915
+ return start_attempt, turn_id, retry_context
916
+
620
917
  def _record_auto_resume_suppressed(
621
918
  self,
622
919
  *,
@@ -1326,7 +1623,13 @@ class DaemonApp:
1326
1623
  )
1327
1624
  turn_state = self._refresh_turn_worker_state(quest_id)
1328
1625
  has_live_turn = bool(turn_state.get("running"))
1329
- if runtime_status == "running" and has_live_turn:
1626
+ stalled_details = self._stalled_running_turn_details(
1627
+ quest_id,
1628
+ snapshot=snapshot,
1629
+ turn_state=turn_state,
1630
+ turn_reason="user_message",
1631
+ )
1632
+ if runtime_status == "running" and has_live_turn and stalled_details is None:
1330
1633
  scheduled = {
1331
1634
  "scheduled": True,
1332
1635
  "started": False,
@@ -1495,18 +1798,30 @@ class DaemonApp:
1495
1798
  return snapshot
1496
1799
 
1497
1800
  def schedule_turn(self, quest_id: str, *, reason: str = "user_message") -> dict:
1801
+ snapshot = self.quest_service.snapshot(quest_id)
1802
+ snapshot = self._reconcile_stale_active_turn(quest_id, snapshot=snapshot)
1803
+ recovery = self._recover_stalled_running_turn(quest_id, snapshot=snapshot, turn_reason=reason)
1804
+ snapshot = dict(recovery.get("snapshot") or snapshot)
1805
+ if recovery.get("blocked"):
1806
+ return {
1807
+ "scheduled": True,
1808
+ "started": False,
1809
+ "queued": True,
1810
+ "reason": "stalled_turn_recovery_pending",
1811
+ }
1498
1812
  self._refresh_turn_worker_state(quest_id)
1499
1813
  with self._turn_lock:
1500
1814
  state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1501
1815
  state["pending"] = True
1502
1816
  state["stop_requested"] = False
1817
+ state.pop("recovery_pending", None)
1503
1818
  state["reason"] = reason
1504
1819
  if state.get("running"):
1505
1820
  return {
1506
1821
  "scheduled": True,
1507
1822
  "started": False,
1508
1823
  "queued": True,
1509
- "reason": reason,
1824
+ "reason": "queued_for_artifact_interact" if reason == "user_message" else reason,
1510
1825
  }
1511
1826
  state["running"] = True
1512
1827
  worker = threading.Thread(
@@ -1528,6 +1843,57 @@ class DaemonApp:
1528
1843
  def _turn_worker_is_alive(worker: object) -> bool:
1529
1844
  return isinstance(worker, threading.Thread) and worker.is_alive()
1530
1845
 
1846
+ def schedule_latest_quest_terminal_prewarm(
1847
+ self,
1848
+ quest_id: str,
1849
+ *,
1850
+ source: str = "quest_session_prewarm",
1851
+ ) -> None:
1852
+ normalized_quest_id = str(quest_id or "").strip()
1853
+ if not normalized_quest_id or os.name == "nt":
1854
+ return
1855
+ try:
1856
+ quests = self.quest_service.list_quests()
1857
+ except Exception:
1858
+ return
1859
+ latest_quest_id = str((quests[0].get("quest_id") if quests else "") or "").strip()
1860
+ if latest_quest_id != normalized_quest_id:
1861
+ return
1862
+ now = time.monotonic()
1863
+ with self._terminal_prewarm_lock:
1864
+ last_attempt = float(self._terminal_prewarm_recent.get(normalized_quest_id) or 0.0)
1865
+ if now - last_attempt < _TERMINAL_PREWARM_DEBOUNCE_SECONDS:
1866
+ return
1867
+ self._terminal_prewarm_recent[normalized_quest_id] = now
1868
+ threading.Thread(
1869
+ target=self._prewarm_terminal_for_quest,
1870
+ args=(normalized_quest_id, source),
1871
+ daemon=True,
1872
+ name=f"deepscientist-terminal-prewarm-{normalized_quest_id}",
1873
+ ).start()
1874
+
1875
+ def _prewarm_terminal_for_quest(self, quest_id: str, source: str) -> None:
1876
+ try:
1877
+ quest_root = self.quest_service._quest_root(quest_id)
1878
+ workspace_root = self.quest_service.active_workspace_root(quest_root)
1879
+ self.bash_exec_service.ensure_terminal_session(
1880
+ quest_root,
1881
+ quest_id=quest_id,
1882
+ bash_id=DEFAULT_TERMINAL_SESSION_ID,
1883
+ cwd=workspace_root,
1884
+ source=source,
1885
+ )
1886
+ except Exception as exc:
1887
+ with self._terminal_prewarm_lock:
1888
+ self._terminal_prewarm_recent.pop(quest_id, None)
1889
+ self.logger.log(
1890
+ "warning",
1891
+ "terminal.prewarm_failed",
1892
+ quest_id=quest_id,
1893
+ source=source,
1894
+ error=str(exc),
1895
+ )
1896
+
1531
1897
  def _refresh_turn_worker_state(self, quest_id: str) -> dict[str, object]:
1532
1898
  with self._turn_lock:
1533
1899
  state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
@@ -1536,6 +1902,110 @@ class DaemonApp:
1536
1902
  state.pop("worker", None)
1537
1903
  return dict(state)
1538
1904
 
1905
+ def _wait_for_turn_worker_exit(self, quest_id: str, *, timeout_seconds: float) -> dict[str, object]:
1906
+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
1907
+ state = self._refresh_turn_worker_state(quest_id)
1908
+ while state.get("running") and time.monotonic() < deadline:
1909
+ time.sleep(0.05)
1910
+ state = self._refresh_turn_worker_state(quest_id)
1911
+ return state
1912
+
1913
+ def _ensure_recovery_resume_watch(self, quest_id: str, *, turn_reason: str) -> None:
1914
+ with self._turn_lock:
1915
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1916
+ if state.get("recovery_watch_active"):
1917
+ return
1918
+ state["recovery_watch_active"] = True
1919
+ watcher = threading.Thread(
1920
+ target=self._wait_and_resume_recovered_turn,
1921
+ args=(quest_id, turn_reason),
1922
+ daemon=True,
1923
+ name=f"deepscientist-recovery-watch-{quest_id}",
1924
+ )
1925
+ watcher.start()
1926
+
1927
+ def _wait_and_resume_recovered_turn(self, quest_id: str, turn_reason: str) -> None:
1928
+ try:
1929
+ while True:
1930
+ state = self._refresh_turn_worker_state(quest_id)
1931
+ if not state.get("recovery_pending"):
1932
+ return
1933
+ if not state.get("running"):
1934
+ break
1935
+ time.sleep(0.1)
1936
+
1937
+ snapshot = self.quest_service.snapshot(quest_id)
1938
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
1939
+ if runtime_status in {"paused", "stopped", "completed", "error"}:
1940
+ with self._turn_lock:
1941
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1942
+ state.pop("recovery_pending", None)
1943
+ state["stop_requested"] = runtime_status in {"paused", "stopped"}
1944
+ return
1945
+ pending_user_count = int(snapshot.get("pending_user_message_count") or 0)
1946
+ if pending_user_count > 0:
1947
+ with self._turn_lock:
1948
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1949
+ state.pop("recovery_pending", None)
1950
+ self.schedule_turn(quest_id, reason=turn_reason)
1951
+ return
1952
+
1953
+ with self._turn_lock:
1954
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1955
+ state.pop("recovery_pending", None)
1956
+ state["stop_requested"] = False
1957
+ except Exception as exc:
1958
+ self.logger.log(
1959
+ "warning",
1960
+ "quest.turn_state_recovery_watch_failed",
1961
+ quest_id=quest_id,
1962
+ reason=turn_reason,
1963
+ error=str(exc),
1964
+ )
1965
+ finally:
1966
+ with self._turn_lock:
1967
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1968
+ state.pop("recovery_watch_active", None)
1969
+
1970
+ def _stalled_running_turn_details(
1971
+ self,
1972
+ quest_id: str,
1973
+ *,
1974
+ snapshot: dict | None = None,
1975
+ turn_state: dict[str, object] | None = None,
1976
+ turn_reason: str,
1977
+ ) -> dict[str, int] | None:
1978
+ if str(turn_reason or "").strip() not in {"user_message", "queued_user_messages"}:
1979
+ return None
1980
+ snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
1981
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
1982
+ active_run_id = str(snapshot.get("active_run_id") or "").strip()
1983
+ if runtime_status != "running" or not active_run_id:
1984
+ return None
1985
+ state = dict(turn_state or self._refresh_turn_worker_state(quest_id))
1986
+ if not state.get("running"):
1987
+ return None
1988
+ pending_user_count = int(snapshot.get("pending_user_message_count") or 0)
1989
+ if pending_user_count <= 0:
1990
+ return None
1991
+ counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
1992
+ if int(counts.get("bash_running_count") or 0) > 0:
1993
+ return None
1994
+ silent_seconds = snapshot.get("seconds_since_last_tool_activity")
1995
+ if silent_seconds is None:
1996
+ watchdog = snapshot.get("interaction_watchdog") if isinstance(snapshot.get("interaction_watchdog"), dict) else {}
1997
+ silent_seconds = watchdog.get("seconds_since_last_tool_activity")
1998
+ try:
1999
+ silent_seconds_int = int(silent_seconds or 0)
2000
+ except (TypeError, ValueError):
2001
+ return None
2002
+ if silent_seconds_int < _STALLED_RUNNING_TURN_INACTIVITY_SECONDS:
2003
+ return None
2004
+ return {
2005
+ "pending_user_count": pending_user_count,
2006
+ "silent_seconds": silent_seconds_int,
2007
+ }
2008
+
1539
2009
  def _reconcile_stale_active_turn(self, quest_id: str, *, snapshot: dict | None = None) -> dict:
1540
2010
  snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
1541
2011
  active_run_id = str(snapshot.get("active_run_id") or "").strip()
@@ -1587,6 +2057,139 @@ class DaemonApp:
1587
2057
  )
1588
2058
  return self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
1589
2059
 
2060
+ def _recover_stalled_running_turn(
2061
+ self,
2062
+ quest_id: str,
2063
+ *,
2064
+ snapshot: dict | None = None,
2065
+ turn_reason: str,
2066
+ ) -> dict[str, object]:
2067
+ snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
2068
+ turn_state = self._refresh_turn_worker_state(quest_id)
2069
+ with self._turn_lock:
2070
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
2071
+ if state.get("recovery_pending"):
2072
+ return {
2073
+ "snapshot": snapshot,
2074
+ "blocked": True,
2075
+ }
2076
+ details = self._stalled_running_turn_details(
2077
+ quest_id,
2078
+ snapshot=snapshot,
2079
+ turn_state=turn_state,
2080
+ turn_reason=turn_reason,
2081
+ )
2082
+ if details is None:
2083
+ return {
2084
+ "snapshot": snapshot,
2085
+ "blocked": False,
2086
+ }
2087
+
2088
+ active_run_id = str(snapshot.get("active_run_id") or "").strip()
2089
+ runner_name = self._runner_name_for(snapshot)
2090
+ with self._turn_lock:
2091
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
2092
+ if state.get("recovery_pending"):
2093
+ return {
2094
+ "snapshot": snapshot,
2095
+ "blocked": True,
2096
+ }
2097
+ state["pending"] = False
2098
+ state["stop_requested"] = True
2099
+ state["recovery_pending"] = True
2100
+ interrupted = False
2101
+ try:
2102
+ try:
2103
+ runner = self.get_runner(runner_name)
2104
+ except KeyError:
2105
+ runner = None
2106
+ if runner is not None and hasattr(runner, "interrupt"):
2107
+ interrupted = bool(getattr(runner, "interrupt")(quest_id))
2108
+ stopped_bash_session_ids = self._stop_active_bash_exec_sessions(
2109
+ quest_id,
2110
+ run_id=active_run_id or None,
2111
+ reason="stalled_turn_recovery",
2112
+ user_id="auto:stalled-turn-recovery",
2113
+ )
2114
+ turn_state = self._wait_for_turn_worker_exit(
2115
+ quest_id,
2116
+ timeout_seconds=_STALLED_RUNNING_TURN_INTERRUPT_TIMEOUT_SECONDS,
2117
+ )
2118
+ if turn_state.get("running"):
2119
+ self._ensure_recovery_resume_watch(quest_id, turn_reason="queued_user_messages")
2120
+ self.logger.log(
2121
+ "warning",
2122
+ "quest.turn_state_recovery_pending",
2123
+ quest_id=quest_id,
2124
+ abandoned_run_id=active_run_id or None,
2125
+ reason=turn_reason,
2126
+ silent_seconds=int(details.get("silent_seconds") or 0),
2127
+ pending_user_message_count=int(details.get("pending_user_count") or 0),
2128
+ interrupted=interrupted,
2129
+ )
2130
+ return {
2131
+ "snapshot": snapshot,
2132
+ "blocked": True,
2133
+ }
2134
+
2135
+ previous_status = (
2136
+ str(snapshot.get("runtime_status") or snapshot.get("status") or snapshot.get("display_status") or "running").strip()
2137
+ or "running"
2138
+ )
2139
+ normalized_status = "active" if previous_status == "running" else previous_status
2140
+ summary = (
2141
+ f"Recovered stalled running turn `{active_run_id}` after "
2142
+ f"{int(details.get('silent_seconds') or 0)} seconds without tool activity while "
2143
+ f"{int(details.get('pending_user_count') or 0)} queued user message(s) were waiting."
2144
+ )
2145
+ if interrupted:
2146
+ summary = f"{summary} The active runner process was interrupted."
2147
+ if stopped_bash_session_ids:
2148
+ summary = f"{summary} Stopped {len(stopped_bash_session_ids)} bash_exec session(s)."
2149
+ quest_root = self.quest_service._quest_root(quest_id)
2150
+ append_jsonl(
2151
+ quest_root / ".ds" / "events.jsonl",
2152
+ {
2153
+ "event_id": generate_id("evt"),
2154
+ "type": "quest.turn_state_reconciled",
2155
+ "quest_id": quest_id,
2156
+ "abandoned_run_id": active_run_id or None,
2157
+ "previous_status": previous_status,
2158
+ "status": normalized_status,
2159
+ "completed_at": None,
2160
+ "exit_code": None,
2161
+ "summary": summary,
2162
+ "recovery_kind": "stalled_live_turn",
2163
+ "interrupted": interrupted,
2164
+ "stopped_bash_session_ids": stopped_bash_session_ids,
2165
+ "created_at": utc_now(),
2166
+ },
2167
+ )
2168
+ self.logger.log(
2169
+ "warning",
2170
+ "quest.turn_state_reconciled",
2171
+ quest_id=quest_id,
2172
+ abandoned_run_id=active_run_id or None,
2173
+ previous_status=previous_status,
2174
+ status=normalized_status,
2175
+ recovery_kind="stalled_live_turn",
2176
+ interrupted=interrupted,
2177
+ stopped_bash_session_count=len(stopped_bash_session_ids),
2178
+ )
2179
+ snapshot = self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
2180
+ with self._turn_lock:
2181
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
2182
+ state.pop("recovery_pending", None)
2183
+ return {
2184
+ "snapshot": snapshot,
2185
+ "blocked": False,
2186
+ }
2187
+ except Exception:
2188
+ with self._turn_lock:
2189
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
2190
+ state.pop("recovery_pending", None)
2191
+ raise
2192
+
1590
2193
  def control_quest(self, quest_id: str, *, action: str, source: str = "local") -> dict:
1591
2194
  normalized_action = str(action or "").strip().lower()
1592
2195
  if normalized_action == "pause":
@@ -1882,52 +2485,50 @@ class DaemonApp:
1882
2485
  cancelled_pending_user_message_count: int,
1883
2486
  previous_snapshot: dict | None = None,
1884
2487
  ) -> str:
1885
- branch = str(snapshot.get("branch") or "unknown").strip() or "unknown"
1886
- workspace_root = str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or "").strip()
1887
2488
  if action == "resume":
1888
2489
  lines = [
1889
2490
  self._polite_copy(
1890
- zh="DeepScientist 已恢复运行。",
1891
- en="DeepScientist has resumed.",
2491
+ zh=f"我回来继续干活啦,Quest `{quest_id}` 已恢复。",
2492
+ en=f"I’m back on it. Quest `{quest_id}` has resumed.",
1892
2493
  ),
1893
2494
  self._polite_copy(
1894
- zh="当前 Git 分支与 worktree 已保留,系统会沿用现有研究上下文继续。",
1895
- en="The current Git branch and worktree were kept intact, and the quest will continue from the existing research context.",
2495
+ zh="刚才的进度都还在,我会直接接着往下推。",
2496
+ en="The current progress is still here, and I’ll pick up right where I left off.",
1896
2497
  ),
1897
2498
  ]
1898
2499
  if source.startswith("auto:daemon-recovery"):
1899
2500
  lines.append(
1900
2501
  self._polite_copy(
1901
- zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
1902
- en="The daemon exited unexpectedly before; this quest has now been recovered automatically and will continue.",
2502
+ zh="刚才是 daemon 意外断开了,不过现在已经自动接回来了。",
2503
+ en="The daemon dropped unexpectedly, but it has been recovered automatically. 🔧",
1903
2504
  )
1904
2505
  )
1905
2506
  elif action == "pause":
1906
2507
  lines = [
1907
2508
  self._polite_copy(
1908
- zh="DeepScientist 已从运行状态转为暂停状态。",
1909
- en="DeepScientist has moved from running to paused.",
2509
+ zh=f"我先帮您把 Quest `{quest_id}` 稳稳停在这里啦。",
2510
+ en=f"I’ve paused Quest `{quest_id}` right here for now. ⏸️",
1910
2511
  ),
1911
2512
  self._polite_copy(
1912
- zh="当前 Git 分支与 worktree 已保留。如需继续,请直接在当前聊天或 connector 中发送任意新指令,或使用 /resume;系统会沿用当前 quest 上下文继续。",
1913
- en="The current Git branch and worktree were kept intact. To continue, send any new instruction in this chat or connector, or use /resume; the quest will resume from the current context.",
2513
+ zh="当前进度我都保留好了,您发新消息或者执行 `/resume`,我就会继续。",
2514
+ en="I kept the current progress safe. Send a new message or use `/resume`, and I’ll continue.",
1914
2515
  ),
1915
2516
  ]
1916
2517
  else:
1917
2518
  lines = [
1918
2519
  self._polite_copy(
1919
- zh="DeepScientist 已从运行状态转为停止状态。",
1920
- en="DeepScientist has moved from running to stopped.",
2520
+ zh=f"这轮我先收住啦,Quest `{quest_id}` 已停止运行。",
2521
+ en=f"I’m wrapping this round here. Quest `{quest_id}` has stopped. 📌",
1921
2522
  ),
1922
2523
  self._polite_copy(
1923
- zh="当前 Git 分支与 worktree 已保留。如需继续,请直接在当前聊天或 connector 中发送任意新指令,或使用 /resume;系统会沿用当前 quest 上下文继续。",
1924
- en="The current Git branch and worktree were kept intact. To continue, send any new instruction in this chat or connector, or use /resume; the quest will resume from the current context.",
2524
+ zh="不过别担心,当前进度我都保留好了;您发新消息或者执行 `/resume`,我就能接着干。",
2525
+ en="Don’t worry, the current progress is still preserved. Send a new message or use `/resume`, and I’ll keep going.",
1925
2526
  ),
1926
2527
  ]
1927
2528
  if interrupted:
1928
2529
  lines.append(
1929
2530
  self._polite_copy(
1930
- zh="当前活跃 runner 已被中断。",
2531
+ zh="刚才正在跑的任务已经被打断了。",
1931
2532
  en="The active runner was interrupted.",
1932
2533
  )
1933
2534
  )
@@ -1935,8 +2536,8 @@ class DaemonApp:
1935
2536
  if cancelled_count > 0:
1936
2537
  lines.append(
1937
2538
  self._polite_copy(
1938
- zh=f"已取消 {cancelled_count} 条排队中的用户消息。",
1939
- en=f"Cancelled {cancelled_count} queued user message(s).",
2539
+ zh=f"另外我还顺手清掉了 {cancelled_count} 条排队消息,避免旧指令继续堆着。",
2540
+ en=f"I also cleared {cancelled_count} queued message(s) so stale instructions do not pile up.",
1940
2541
  )
1941
2542
  )
1942
2543
  previous_status = str(
@@ -1947,17 +2548,10 @@ class DaemonApp:
1947
2548
  if previous_status and action == "resume":
1948
2549
  lines.append(
1949
2550
  self._polite_copy(
1950
- zh=f"此前状态:`{previous_status}`。",
2551
+ zh=f"恢复前的状态是:`{previous_status}`。",
1951
2552
  en=f"Previous status: `{previous_status}`.",
1952
2553
  )
1953
2554
  )
1954
- lines.extend(
1955
- [
1956
- f"- Quest: `{quest_id}`",
1957
- f"- Branch: `{branch}`",
1958
- f"- Workspace: `{workspace_root or snapshot.get('quest_root')}`",
1959
- ]
1960
- )
1961
2555
  return "\n".join(lines)
1962
2556
 
1963
2557
  def _drain_turns(self, quest_id: str) -> None:
@@ -2062,12 +2656,15 @@ class DaemonApp:
2062
2656
  )
2063
2657
  retry_policy = self._runner_retry_policy(runner_name, runner_cfg if isinstance(runner_cfg, dict) else {})
2064
2658
  max_attempts = int(retry_policy.get("max_attempts") or 1)
2065
- turn_id = generate_id("turn")
2066
- retry_context: dict[str, Any] | None = None
2659
+ resumed_start_attempt, resumed_turn_id, retry_context = self._resume_retry_state(
2660
+ snapshot,
2661
+ max_attempts=max_attempts,
2662
+ )
2663
+ turn_id = resumed_turn_id or generate_id("turn")
2067
2664
  quest_root = Path(snapshot["quest_root"])
2068
2665
  worktree_root = Path(str(snapshot["current_workspace_root"])) if snapshot.get("current_workspace_root") else None
2069
2666
 
2070
- for attempt_index in range(1, max_attempts + 1):
2667
+ for attempt_index in range(resumed_start_attempt, max_attempts + 1):
2071
2668
  current_run_id = run_id if attempt_index == 1 else generate_id("run")
2072
2669
  if attempt_index > 1:
2073
2670
  self._append_retry_event(
@@ -2136,6 +2733,31 @@ class DaemonApp:
2136
2733
  previous_output_text="",
2137
2734
  stderr_text=str(exc),
2138
2735
  )
2736
+ diagnosis = self._non_retryable_failure_diagnosis(
2737
+ runner_name=runner_name,
2738
+ summary=failure_summary,
2739
+ stderr_text=str(exc),
2740
+ output_text="",
2741
+ )
2742
+ if diagnosis is not None:
2743
+ self.quest_service.update_runtime_state(
2744
+ quest_root=quest_root,
2745
+ continuation_policy="wait_for_user_or_resume",
2746
+ continuation_reason="non_retryable_runner_error",
2747
+ continuation_updated_at=utc_now(),
2748
+ )
2749
+ self._record_turn_error(
2750
+ quest_id=quest_id,
2751
+ runner_name=runner_name,
2752
+ run_id=current_run_id,
2753
+ skill_id=skill_id,
2754
+ model=model,
2755
+ summary=f"{diagnosis.problem} {failure_summary}".strip(),
2756
+ retry_state=None,
2757
+ diagnosis_code=diagnosis.code,
2758
+ guidance=list(diagnosis.guidance),
2759
+ )
2760
+ return
2139
2761
  if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
2140
2762
  delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
2141
2763
  next_retry_at = self._retry_next_timestamp(delay_seconds)
@@ -2284,6 +2906,31 @@ class DaemonApp:
2284
2906
  previous_output_text=result.output_text,
2285
2907
  stderr_text=result.stderr_text,
2286
2908
  )
2909
+ diagnosis = self._non_retryable_failure_diagnosis(
2910
+ runner_name=runner_name,
2911
+ summary=failure_summary,
2912
+ stderr_text=result.stderr_text,
2913
+ output_text=result.output_text,
2914
+ )
2915
+ if diagnosis is not None:
2916
+ self.quest_service.update_runtime_state(
2917
+ quest_root=quest_root,
2918
+ continuation_policy="wait_for_user_or_resume",
2919
+ continuation_reason="non_retryable_runner_error",
2920
+ continuation_updated_at=utc_now(),
2921
+ )
2922
+ self._record_turn_error(
2923
+ quest_id=quest_id,
2924
+ runner_name=runner_name,
2925
+ run_id=result.run_id,
2926
+ skill_id=skill_id,
2927
+ model=model,
2928
+ summary=f"{diagnosis.problem} {failure_summary}".strip(),
2929
+ retry_state=None,
2930
+ diagnosis_code=diagnosis.code,
2931
+ guidance=list(diagnosis.guidance),
2932
+ )
2933
+ return
2287
2934
  if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
2288
2935
  delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
2289
2936
  next_retry_at = self._retry_next_timestamp(delay_seconds)
@@ -2421,11 +3068,41 @@ class DaemonApp:
2421
3068
 
2422
3069
  @staticmethod
2423
3070
  def _continuation_anchor_for(snapshot: dict) -> str:
3071
+ available_stage_skills = current_standard_skills(repo_root())
2424
3072
  continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
2425
- if continuation_anchor in STANDARD_SKILLS:
3073
+ if continuation_anchor in available_stage_skills:
2426
3074
  return continuation_anchor
2427
3075
  active_anchor = str(snapshot.get("active_anchor") or "").strip()
2428
- return active_anchor if active_anchor in STANDARD_SKILLS else "decision"
3076
+ return active_anchor if active_anchor in available_stage_skills else "decision"
3077
+
3078
+ @staticmethod
3079
+ def _workspace_mode_for(snapshot: dict) -> str:
3080
+ value = str(snapshot.get("workspace_mode") or "").strip().lower()
3081
+ if value in {"copilot", "autonomous"}:
3082
+ return value
3083
+ startup_contract = snapshot.get("startup_contract")
3084
+ if isinstance(startup_contract, dict):
3085
+ value = str(startup_contract.get("workspace_mode") or "").strip().lower()
3086
+ if value in {"copilot", "autonomous"}:
3087
+ return value
3088
+ return "autonomous"
3089
+
3090
+ def _resolve_continuation_policy(self, snapshot: dict, *, current_policy: str) -> tuple[str, str]:
3091
+ normalized = str(current_policy or "auto").strip().lower() or "auto"
3092
+ if normalized != "auto":
3093
+ return normalized, str(snapshot.get("continuation_reason") or "").strip() or "explicit_continuation_policy"
3094
+ if self._workspace_mode_for(snapshot) == "copilot":
3095
+ return "wait_for_user_or_resume", "copilot_mode"
3096
+ if self._has_external_progress(snapshot):
3097
+ return "when_external_progress", "background_external_progress_active"
3098
+ return "auto", "autonomous_prepare_or_launch_long_run"
3099
+
3100
+ @staticmethod
3101
+ def _auto_continue_delay_for_policy(policy: str) -> float:
3102
+ normalized = str(policy or "").strip().lower() or "auto"
3103
+ if normalized == "when_external_progress":
3104
+ return _AUTO_CONTINUE_DELAY_SECONDS
3105
+ return _AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS
2429
3106
 
2430
3107
  @staticmethod
2431
3108
  def _turn_skill_stage_gate(snapshot: dict, candidate_skill: str) -> str:
@@ -2447,6 +3124,18 @@ class DaemonApp:
2447
3124
 
2448
3125
  return skill
2449
3126
 
3127
+ @staticmethod
3128
+ def _direct_user_turn_skill(snapshot: dict) -> str:
3129
+ available_stage_skills = current_standard_skills(repo_root())
3130
+ for candidate in (
3131
+ str(snapshot.get("active_anchor") or "").strip(),
3132
+ str(snapshot.get("continuation_anchor") or "").strip(),
3133
+ ):
3134
+ if candidate in available_stage_skills and candidate != "decision":
3135
+ return DaemonApp._turn_skill_stage_gate(snapshot, candidate)
3136
+ fallback = "baseline" if "baseline" in available_stage_skills else "scout"
3137
+ return DaemonApp._turn_skill_stage_gate(snapshot, fallback)
3138
+
2450
3139
  @staticmethod
2451
3140
  def _turn_skill_for(
2452
3141
  snapshot: dict,
@@ -2455,6 +3144,9 @@ class DaemonApp:
2455
3144
  turn_reason: str = "user_message",
2456
3145
  turn_mode: str = "stage_execution",
2457
3146
  ) -> str:
3147
+ available_stage_skills = current_standard_skills(repo_root())
3148
+ workspace_mode = DaemonApp._workspace_mode_for(snapshot)
3149
+
2458
3150
  reply_target = str((latest_user_message or {}).get("reply_to_interaction_id") or "").strip()
2459
3151
  if reply_target:
2460
3152
  for item in (snapshot.get("active_interactions") or []):
@@ -2481,12 +3173,18 @@ class DaemonApp:
2481
3173
  ):
2482
3174
  return "decision"
2483
3175
  if str(item.get("reply_mode") or "") == "threaded":
3176
+ if workspace_mode == "copilot" or turn_mode in {"answering", "command_execution"}:
3177
+ return DaemonApp._direct_user_turn_skill(snapshot)
2484
3178
  return DaemonApp._turn_skill_stage_gate(
2485
3179
  snapshot,
2486
3180
  DaemonApp._continuation_anchor_for(snapshot),
2487
3181
  )
2488
- if turn_mode in {"answering", "command_execution", "recovering"}:
3182
+ if turn_mode == "recovering":
2489
3183
  return "decision"
3184
+ if workspace_mode == "copilot" and latest_user_message is not None:
3185
+ return DaemonApp._direct_user_turn_skill(snapshot)
3186
+ if turn_mode in {"answering", "command_execution"}:
3187
+ return DaemonApp._direct_user_turn_skill(snapshot)
2490
3188
  if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
2491
3189
  return DaemonApp._turn_skill_stage_gate(
2492
3190
  snapshot,
@@ -2501,7 +3199,7 @@ class DaemonApp:
2501
3199
  active_anchor = str(snapshot.get("active_anchor") or "").strip()
2502
3200
  return DaemonApp._turn_skill_stage_gate(
2503
3201
  snapshot,
2504
- active_anchor if active_anchor in STANDARD_SKILLS else "decision",
3202
+ active_anchor if active_anchor in available_stage_skills else "decision",
2505
3203
  )
2506
3204
 
2507
3205
  def _latest_user_message(self, quest_id: str) -> dict | None:
@@ -2800,8 +3498,11 @@ class DaemonApp:
2800
3498
  summary: str,
2801
3499
  display_status: str = "error",
2802
3500
  retry_state: dict[str, Any] | None = None,
3501
+ diagnosis_code: str | None = None,
3502
+ guidance: list[str] | None = None,
2803
3503
  ) -> None:
2804
3504
  quest_root = self.home / "quests" / quest_id
3505
+ normalized_guidance = [str(line) for line in (guidance or []) if str(line).strip()]
2805
3506
  append_jsonl(
2806
3507
  quest_root / ".ds" / "events.jsonl",
2807
3508
  {
@@ -2813,6 +3514,8 @@ class DaemonApp:
2813
3514
  "skill_id": skill_id,
2814
3515
  "model": model,
2815
3516
  "summary": summary,
3517
+ "diagnosis_code": str(diagnosis_code or "").strip() or None,
3518
+ "guidance": normalized_guidance,
2816
3519
  "created_at": utc_now(),
2817
3520
  },
2818
3521
  )
@@ -2823,6 +3526,16 @@ class DaemonApp:
2823
3526
  active_run_id=None,
2824
3527
  retry_state=retry_state,
2825
3528
  )
3529
+ notice_message = summary
3530
+ if normalized_guidance:
3531
+ notice_message = "\n".join(
3532
+ [
3533
+ summary,
3534
+ "",
3535
+ "Suggested fix:",
3536
+ *[f"- {line}" for line in normalized_guidance[:3]],
3537
+ ]
3538
+ ).strip()
2826
3539
  self.logger.log(
2827
3540
  "error",
2828
3541
  "runner.turn_error",
@@ -2835,7 +3548,7 @@ class DaemonApp:
2835
3548
  )
2836
3549
  self._relay_quest_message_to_bound_connectors(
2837
3550
  quest_id,
2838
- message=summary,
3551
+ message=notice_message,
2839
3552
  kind="error",
2840
3553
  response_phase="final",
2841
3554
  importance="warning",
@@ -2846,10 +3559,29 @@ class DaemonApp:
2846
3559
  "skill_id": skill_id,
2847
3560
  "runner": runner_name,
2848
3561
  "model": model,
3562
+ "diagnosis_code": str(diagnosis_code or "").strip() or None,
2849
3563
  }
2850
3564
  ],
2851
3565
  )
2852
3566
 
3567
+ @staticmethod
3568
+ def _non_retryable_failure_diagnosis(
3569
+ *,
3570
+ runner_name: str,
3571
+ summary: str,
3572
+ stderr_text: str,
3573
+ output_text: str,
3574
+ ) -> FailureDiagnosis | None:
3575
+ diagnosis = diagnose_runner_failure(
3576
+ runner_name=runner_name,
3577
+ summary=summary,
3578
+ stderr_text=stderr_text,
3579
+ output_text=output_text,
3580
+ )
3581
+ if diagnosis is None or diagnosis.retriable:
3582
+ return None
3583
+ return diagnosis
3584
+
2853
3585
  def _record_turn_postprocess_warning(
2854
3586
  self,
2855
3587
  *,
@@ -2981,23 +3713,67 @@ class DaemonApp:
2981
3713
  self.schedule_turn(quest_id, reason="queued_user_messages")
2982
3714
  else:
2983
3715
  continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
3716
+ if continuation_policy == "auto":
3717
+ continuation_policy, continuation_reason = self._resolve_continuation_policy(
3718
+ snapshot,
3719
+ current_policy=continuation_policy,
3720
+ )
3721
+ self.quest_service.update_runtime_state(
3722
+ quest_root=self.quest_service._quest_root(quest_id),
3723
+ continuation_policy=continuation_policy,
3724
+ continuation_reason=continuation_reason,
3725
+ continuation_updated_at=utc_now(),
3726
+ )
3727
+ snapshot = self.quest_service.snapshot(quest_id)
2984
3728
  if continuation_policy not in {"wait_for_user_or_resume", "none"}:
2985
- self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
3729
+ self._schedule_turn_later(
3730
+ quest_id,
3731
+ reason="auto_continue",
3732
+ delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
3733
+ )
2986
3734
  return
2987
3735
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2988
3736
  self.schedule_turn(quest_id, reason="queued_user_messages")
2989
3737
  return
2990
3738
  continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
3739
+ if continuation_policy == "auto":
3740
+ continuation_policy, continuation_reason = self._resolve_continuation_policy(
3741
+ snapshot,
3742
+ current_policy=continuation_policy,
3743
+ )
3744
+ self.quest_service.update_runtime_state(
3745
+ quest_root=self.quest_service._quest_root(quest_id),
3746
+ continuation_policy=continuation_policy,
3747
+ continuation_reason=continuation_reason,
3748
+ continuation_updated_at=utc_now(),
3749
+ )
3750
+ snapshot = self.quest_service.snapshot(quest_id)
2991
3751
  if continuation_policy == "none":
2992
3752
  return
2993
3753
  if continuation_policy == "wait_for_user_or_resume":
2994
3754
  return
2995
3755
  if continuation_policy == "when_external_progress":
2996
- counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
2997
- has_external_progress = bool(snapshot.get("active_run_id")) or int(counts.get("bash_running_count") or 0) > 0
2998
- if not has_external_progress:
3756
+ if not self._has_external_progress(snapshot):
3757
+ next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
3758
+ next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
3759
+ self.quest_service.update_runtime_state(
3760
+ quest_root=self.quest_service._quest_root(quest_id),
3761
+ continuation_policy=next_policy,
3762
+ continuation_reason=next_reason,
3763
+ continuation_updated_at=utc_now(),
3764
+ )
3765
+ if next_policy != "wait_for_user_or_resume":
3766
+ self._schedule_turn_later(
3767
+ quest_id,
3768
+ reason="auto_continue",
3769
+ delay_seconds=self._auto_continue_delay_for_policy(next_policy),
3770
+ )
2999
3771
  return
3000
- self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
3772
+ self._schedule_turn_later(
3773
+ quest_id,
3774
+ reason="auto_continue",
3775
+ delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
3776
+ )
3001
3777
 
3002
3778
  def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
3003
3779
  def _delayed() -> None:
@@ -3009,12 +3785,30 @@ class DaemonApp:
3009
3785
  if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
3010
3786
  return
3011
3787
  continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
3788
+ if continuation_policy == "auto":
3789
+ continuation_policy, continuation_reason = self._resolve_continuation_policy(
3790
+ snapshot,
3791
+ current_policy=continuation_policy,
3792
+ )
3793
+ self.quest_service.update_runtime_state(
3794
+ quest_root=self.quest_service._quest_root(quest_id),
3795
+ continuation_policy=continuation_policy,
3796
+ continuation_reason=continuation_reason,
3797
+ continuation_updated_at=utc_now(),
3798
+ )
3799
+ snapshot = self.quest_service.snapshot(quest_id)
3012
3800
  if continuation_policy in {"none", "wait_for_user_or_resume"}:
3013
3801
  return
3014
3802
  if continuation_policy == "when_external_progress":
3015
- counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
3016
- has_external_progress = bool(snapshot.get("active_run_id")) or int(counts.get("bash_running_count") or 0) > 0
3017
- if not has_external_progress:
3803
+ if not self._has_external_progress(snapshot):
3804
+ next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
3805
+ next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
3806
+ self.quest_service.update_runtime_state(
3807
+ quest_root=self.quest_service._quest_root(quest_id),
3808
+ continuation_policy=next_policy,
3809
+ continuation_reason=next_reason,
3810
+ continuation_updated_at=utc_now(),
3811
+ )
3018
3812
  return
3019
3813
  self.schedule_turn(quest_id, reason=reason)
3020
3814
 
@@ -3024,6 +3818,23 @@ class DaemonApp:
3024
3818
  name=f"deepscientist-turn-delay-{quest_id}",
3025
3819
  ).start()
3026
3820
 
3821
+ def _has_external_progress(self, snapshot: dict) -> bool:
3822
+ if bool(snapshot.get("active_run_id")):
3823
+ return True
3824
+ quest_id = str(snapshot.get("quest_id") or "").strip()
3825
+ if not quest_id:
3826
+ return False
3827
+ try:
3828
+ quest_root = self.quest_service._quest_root(quest_id)
3829
+ except FileNotFoundError:
3830
+ return False
3831
+ try:
3832
+ sessions = self.bash_exec_service.list_sessions(quest_root, limit=200)
3833
+ return any(str(item.get("status") or "").strip().lower() == "running" for item in sessions if isinstance(item, dict))
3834
+ except Exception:
3835
+ counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
3836
+ return int(counts.get("bash_running_count") or 0) > 0
3837
+
3027
3838
  def _relay_quest_message_to_bound_connectors(
3028
3839
  self,
3029
3840
  quest_id: str,
@@ -4016,8 +4827,8 @@ class DaemonApp:
4016
4827
  "quest_id": target_quest,
4017
4828
  "kind": "ack",
4018
4829
  "message": self._polite_copy(
4019
- zh=f"老师,已将当前 {connector_label} 会话绑定到 {target_quest},我会继续推进并同步计划。",
4020
- en=f"Received. I’ve bound this {connector_label} conversation to {target_quest} and will keep the plan moving.",
4830
+ zh=f"收到啦!这里已经切到 Quest `{target_quest}` 了,接下来我会直接在这个 {connector_label} 里继续同步进展。",
4831
+ en=f"Got it. This {connector_label} is now on Quest `{target_quest}`, and I’ll keep the next updates here.",
4021
4832
  ),
4022
4833
  }
4023
4834
  )
@@ -5108,13 +5919,13 @@ class DaemonApp:
5108
5919
  channel = self._channel_with_bindings(old_connector)
5109
5920
  if mode == "disconnect":
5110
5921
  message = self._polite_copy(
5111
- zh=f"当前已退出 Quest `{quest_id}`,项目已切换为仅本地。",
5112
- en=f"This conversation is no longer bound to Quest `{quest_id}`. The project is now local only.",
5922
+ zh=f"Quest `{quest_id}` 已经从这里解绑啦,后面会只在本地继续推进。",
5923
+ en=f"Quest `{quest_id}` is no longer bound here. It will continue locally only. 📌",
5113
5924
  )
5114
5925
  else:
5115
5926
  message = self._polite_copy(
5116
- zh=f"当前已退出 Quest `{quest_id}`,后续请在 {current_label} 查看进展。",
5117
- en=f"This conversation is no longer bound to Quest `{quest_id}`. Continue from {current_label}.",
5927
+ zh=f"Quest `{quest_id}` 已经从这里切走啦,后面的进展请在 {current_label} 查看。",
5928
+ en=f"Quest `{quest_id}` has moved away from this conversation. Continue from {current_label}. 🔁",
5118
5929
  )
5119
5930
  channel.send(
5120
5931
  {
@@ -5131,13 +5942,13 @@ class DaemonApp:
5131
5942
  channel = self._channel_with_bindings(new_connector)
5132
5943
  if mode == "bind":
5133
5944
  message = self._polite_copy(
5134
- zh=f"当前已绑定 Quest `{quest_id}`。",
5135
- en=f"This conversation is now bound to Quest `{quest_id}`.",
5945
+ zh=f"收到!Quest `{quest_id}` 已经接上啦,后面的进展我都会直接在这里同步给您。",
5946
+ en=f"Quest `{quest_id}` is now connected here, and I’ll keep the next updates in this conversation. ✨",
5136
5947
  )
5137
5948
  elif mode == "switch":
5138
5949
  message = self._polite_copy(
5139
- zh=f"当前已绑定 Quest `{quest_id}`,并已从 {previous_label} 切换到当前会话。",
5140
- en=f"This conversation is now bound to Quest `{quest_id}`, replacing {previous_label}.",
5950
+ zh=f"收到!Quest `{quest_id}` 已经切到这里啦,后面的进展我都会直接在这里同步给您。",
5951
+ en=f"Quest `{quest_id}` has switched over here, and I’ll keep the next updates in this conversation. 🔄",
5141
5952
  )
5142
5953
  else:
5143
5954
  message = ""
@@ -5371,6 +6182,20 @@ class DaemonApp:
5371
6182
  )
5372
6183
  return f"{notice}\n\n{base}"
5373
6184
 
6185
+ def _connector_goal_preview(self, goal: str, *, limit: int = 88) -> str:
6186
+ for raw_line in str(goal or "").replace("\r", "\n").split("\n"):
6187
+ line = re.sub(r"^[#>*\-\d\.)\s]+", "", raw_line).strip()
6188
+ if not line:
6189
+ continue
6190
+ normalized = re.sub(r"\s+", " ", line).strip()
6191
+ if len(normalized) <= limit:
6192
+ return normalized
6193
+ return normalized[: max(0, limit - 3)].rstrip() + "..."
6194
+ return self._polite_copy(
6195
+ zh="我会先把当前任务整理清楚,再继续推进。",
6196
+ en="I will clarify the current task first, then keep moving. ✨",
6197
+ )
6198
+
5374
6199
  def _quest_created_connector_message(
5375
6200
  self,
5376
6201
  connector_name: str,
@@ -5379,29 +6204,29 @@ class DaemonApp:
5379
6204
  goal: str,
5380
6205
  previous_quest_id: str | None = None,
5381
6206
  ) -> str:
5382
- normalized_goal = str(goal or "").strip() or "(未提供具体任务)"
5383
6207
  previous = str(previous_quest_id or "").strip()
6208
+ goal_preview = self._connector_goal_preview(goal)
5384
6209
  restore_zh = (
5385
- f"\n如果需要恢复到原先绑定的 quest,请发送:`/use {previous}`。"
6210
+ f"\n如果想切回原先的 Quest `{previous}`,给我发 `/use {previous}` 就行。"
5386
6211
  if previous and previous != quest_id
5387
6212
  else ""
5388
6213
  )
5389
6214
  restore_en = (
5390
- f"\nIf you need to switch back to the previously bound quest, send: `/use {previous}`."
6215
+ f"\nIf you want to switch back to Quest `{previous}`, send `/use {previous}`. 🔁"
5391
6216
  if previous and previous != quest_id
5392
6217
  else ""
5393
6218
  )
5394
6219
  return self._polite_copy(
5395
6220
  zh=(
5396
- f"老师,已顺利创建新的 quest `{quest_id}`。\n"
5397
- f"我即将为您完成以下任务:{normalized_goal}\n"
5398
- f"当前 {self._connector_label(connector_name)} 会话接下来会自动使用这个新 quest 保持连接。\n"
6221
+ f"开工啦!新的 Quest `{quest_id}` 已经建好啦。\n"
6222
+ f"这轮我先做这件事:{goal_preview}\n"
6223
+ f"后面的进展我都会直接在这里同步给您。"
5399
6224
  )
5400
6225
  + restore_zh,
5401
6226
  en=(
5402
- f"Created a new quest `{quest_id}` successfully.\n"
5403
- f"I am about to work on: {normalized_goal}\n"
5404
- f"This {self._connector_label(connector_name)} conversation will now stay attached to the new quest automatically.\n"
6227
+ f"Quest `{quest_id}` is ready, and I’m starting now. 🚀\n"
6228
+ f"Current focus: {goal_preview}\n"
6229
+ f"I’ll keep the next updates right here."
5405
6230
  )
5406
6231
  + restore_en,
5407
6232
  )
@@ -6228,6 +7053,33 @@ class DaemonApp:
6228
7053
  handler.wfile.write(b"\n")
6229
7054
  handler.wfile.flush()
6230
7055
 
7056
+ @staticmethod
7057
+ def _write_handler_response(
7058
+ handler: BaseHTTPRequestHandler,
7059
+ *,
7060
+ code: int,
7061
+ content: bytes,
7062
+ content_type: str | None = None,
7063
+ extra_headers: dict[str, str] | None = None,
7064
+ ) -> bool:
7065
+ try:
7066
+ handler.send_response(code)
7067
+ if content_type:
7068
+ handler.send_header("Content-Type", content_type)
7069
+ handler.send_header("Content-Length", str(len(content)))
7070
+ for key, value in (extra_headers or {}).items():
7071
+ handler.send_header(key, value)
7072
+ handler.end_headers()
7073
+ if content:
7074
+ handler.wfile.write(content)
7075
+ return True
7076
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
7077
+ try:
7078
+ handler.close_connection = True
7079
+ except Exception:
7080
+ pass
7081
+ return False
7082
+
6231
7083
  @staticmethod
6232
7084
  def _parse_bash_log_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
6233
7085
  stripped = raw_line.strip()
@@ -6241,6 +7093,19 @@ class DaemonApp:
6241
7093
  return None
6242
7094
  return payload
6243
7095
 
7096
+ @staticmethod
7097
+ def _parse_quest_event_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
7098
+ stripped = raw_line.strip()
7099
+ if not stripped:
7100
+ return None
7101
+ try:
7102
+ payload = json.loads(stripped.decode("utf-8", errors="replace"))
7103
+ except json.JSONDecodeError:
7104
+ return None
7105
+ if not isinstance(payload, dict):
7106
+ return None
7107
+ return payload
7108
+
6244
7109
  @classmethod
6245
7110
  def _read_bash_log_delta(
6246
7111
  cls,
@@ -6284,6 +7149,42 @@ class DaemonApp:
6284
7149
 
6285
7150
  return fresh_entries, next_offset, remainder
6286
7151
 
7152
+ @classmethod
7153
+ def _read_quest_event_delta(
7154
+ cls,
7155
+ event_path: Path,
7156
+ *,
7157
+ offset: int,
7158
+ pending: bytes,
7159
+ ) -> tuple[list[dict[str, Any]], int, bytes]:
7160
+ if not event_path.exists():
7161
+ return [], 0, pending
7162
+
7163
+ current_size = event_path.stat().st_size
7164
+ safe_offset = max(0, min(offset, current_size))
7165
+ with event_path.open("rb") as handle:
7166
+ handle.seek(safe_offset)
7167
+ chunk = handle.read()
7168
+ next_offset = handle.tell()
7169
+
7170
+ if not chunk:
7171
+ return [], next_offset, pending
7172
+
7173
+ payload = pending + chunk
7174
+ lines = payload.split(b"\n")
7175
+ remainder = b""
7176
+ if payload and not payload.endswith(b"\n"):
7177
+ remainder = lines.pop()
7178
+
7179
+ fresh_entries: list[dict[str, Any]] = []
7180
+ for raw_line in lines:
7181
+ entry = cls._parse_quest_event_jsonl_line(raw_line.rstrip(b"\r"))
7182
+ if not entry:
7183
+ continue
7184
+ fresh_entries.append(entry)
7185
+
7186
+ return fresh_entries, next_offset, remainder
7187
+
6287
7188
  def stream_quest_events(
6288
7189
  self,
6289
7190
  handler: BaseHTTPRequestHandler,
@@ -6291,6 +7192,7 @@ class DaemonApp:
6291
7192
  quest_id: str,
6292
7193
  path: str,
6293
7194
  headers: dict[str, str] | None = None,
7195
+ extra_headers: dict[str, str] | None = None,
6294
7196
  ) -> None:
6295
7197
  query = self.handlers.parse_query(path)
6296
7198
  after = int((query.get("after") or ["0"])[0] or "0")
@@ -6300,16 +7202,23 @@ class DaemonApp:
6300
7202
  last_event_id = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
6301
7203
  current_cursor = max(after, int(last_event_id)) if last_event_id.isdigit() else after
6302
7204
  heartbeat_at = time.monotonic()
6303
- idle_sleep_seconds = 0.35
7205
+ idle_sleep_seconds = 0.08
6304
7206
  force_fetch = True
6305
7207
  event_path = self.quest_service._quest_root(quest_id) / ".ds" / "events.jsonl"
6306
7208
  previous_event_state = None
7209
+ cached_tail = self.quest_service.jsonl_tail_cache_entry(event_path) or {}
7210
+ cached_tail_state = cached_tail.get("state") if isinstance(cached_tail.get("state"), (list, tuple)) else None
7211
+ cached_tail_total = int(cached_tail.get("total") or 0) if isinstance(cached_tail, dict) else 0
7212
+ event_offset = int(cached_tail_state[2]) if cached_tail_state and cached_tail_total == current_cursor else 0
7213
+ pending_bytes = b""
6307
7214
 
6308
7215
  handler.send_response(200)
6309
7216
  handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
6310
7217
  handler.send_header("Cache-Control", "no-cache, no-transform")
6311
7218
  handler.send_header("Connection", "keep-alive")
6312
7219
  handler.send_header("X-Accel-Buffering", "no")
7220
+ for key, value in (extra_headers or {}).items():
7221
+ handler.send_header(key, value)
6313
7222
  handler.end_headers()
6314
7223
  handler.wfile.write(b"retry: 1000\n\n")
6315
7224
  handler.wfile.flush()
@@ -6318,6 +7227,62 @@ class DaemonApp:
6318
7227
  while True:
6319
7228
  current_event_state = self.quest_service._path_state(event_path)
6320
7229
  if force_fetch or current_event_state != previous_event_state:
7230
+ used_incremental_delta = False
7231
+ delta_base_state = previous_event_state or cached_tail_state
7232
+ can_read_incremental = (
7233
+ current_cursor > 0
7234
+ and event_offset > 0
7235
+ and current_event_state is not None
7236
+ and delta_base_state is not None
7237
+ and tuple(delta_base_state)[0] == current_event_state[0]
7238
+ and current_event_state[2] >= int(tuple(delta_base_state)[2])
7239
+ )
7240
+ if can_read_incremental:
7241
+ fresh_events, event_offset, pending_bytes = self._read_quest_event_delta(
7242
+ event_path,
7243
+ offset=event_offset,
7244
+ pending=pending_bytes,
7245
+ )
7246
+ previous_event_state = current_event_state
7247
+ if fresh_events:
7248
+ for event in fresh_events:
7249
+ current_cursor += 1
7250
+ enriched_event = {
7251
+ "cursor": current_cursor,
7252
+ "event_id": event.get("event_id") or f"evt-{quest_id}-{current_cursor}",
7253
+ **event,
7254
+ }
7255
+ update = build_session_update(
7256
+ enriched_event,
7257
+ quest_id=quest_id,
7258
+ cursor=current_cursor,
7259
+ session_id=session_id,
7260
+ )
7261
+ self._write_sse_event(
7262
+ handler,
7263
+ event="acp_update",
7264
+ data=update,
7265
+ event_id=str(current_cursor),
7266
+ )
7267
+ self._write_sse_event(
7268
+ handler,
7269
+ event="cursor",
7270
+ data={"cursor": current_cursor, "quest_id": quest_id},
7271
+ )
7272
+ heartbeat_at = time.monotonic()
7273
+ used_incremental_delta = True
7274
+ force_fetch = False
7275
+ idle_sleep_seconds = 0.03
7276
+ else:
7277
+ force_fetch = False
7278
+ now = time.monotonic()
7279
+ if now - heartbeat_at >= 10:
7280
+ handler.wfile.write(b": keep-alive\n\n")
7281
+ handler.wfile.flush()
7282
+ heartbeat_at = now
7283
+ if used_incremental_delta:
7284
+ time.sleep(idle_sleep_seconds)
7285
+ continue
6321
7286
  stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
6322
7287
  payload = self.handlers.quest_events(quest_id, path=stream_path)
6323
7288
  previous_event_state = current_event_state
@@ -6332,6 +7297,11 @@ class DaemonApp:
6332
7297
  event_id=update_cursor or None,
6333
7298
  )
6334
7299
  current_cursor = int(payload.get("cursor") or current_cursor)
7300
+ if current_event_state is not None and not payload.get("has_more"):
7301
+ event_offset = int(current_event_state[2])
7302
+ pending_bytes = b""
7303
+ cached_tail_state = current_event_state
7304
+ cached_tail_total = current_cursor
6335
7305
  self._write_sse_event(
6336
7306
  handler,
6337
7307
  event="cursor",
@@ -6339,22 +7309,25 @@ class DaemonApp:
6339
7309
  )
6340
7310
  heartbeat_at = time.monotonic()
6341
7311
  force_fetch = bool(payload.get("has_more"))
6342
- idle_sleep_seconds = 0.05 if force_fetch else 0.2
7312
+ idle_sleep_seconds = 0.03 if force_fetch else 0.08
6343
7313
  else:
7314
+ if current_event_state is not None:
7315
+ event_offset = int(current_event_state[2])
7316
+ cached_tail_state = current_event_state
6344
7317
  force_fetch = False
6345
7318
  now = time.monotonic()
6346
7319
  if now - heartbeat_at >= 10:
6347
7320
  handler.wfile.write(b": keep-alive\n\n")
6348
7321
  handler.wfile.flush()
6349
7322
  heartbeat_at = now
6350
- idle_sleep_seconds = min(1.5, idle_sleep_seconds * 1.35)
7323
+ idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
6351
7324
  else:
6352
7325
  now = time.monotonic()
6353
7326
  if now - heartbeat_at >= 10:
6354
7327
  handler.wfile.write(b": keep-alive\n\n")
6355
7328
  handler.wfile.flush()
6356
7329
  heartbeat_at = now
6357
- idle_sleep_seconds = min(1.5, idle_sleep_seconds * 1.35)
7330
+ idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
6358
7331
  time.sleep(idle_sleep_seconds)
6359
7332
  except (BrokenPipeError, ConnectionResetError, TimeoutError):
6360
7333
  return
@@ -6365,6 +7338,7 @@ class DaemonApp:
6365
7338
  *,
6366
7339
  quest_id: str,
6367
7340
  path: str,
7341
+ extra_headers: dict[str, str] | None = None,
6368
7342
  ) -> None:
6369
7343
  quest_root = self.quest_service._quest_root(quest_id)
6370
7344
  query = self.handlers.parse_query(path)
@@ -6401,6 +7375,8 @@ class DaemonApp:
6401
7375
  handler.send_header("Cache-Control", "no-cache, no-transform")
6402
7376
  handler.send_header("Connection", "keep-alive")
6403
7377
  handler.send_header("X-Accel-Buffering", "no")
7378
+ for key, value in (extra_headers or {}).items():
7379
+ handler.send_header(key, value)
6404
7380
  handler.end_headers()
6405
7381
  handler.wfile.write(b"retry: 1000\n\n")
6406
7382
  handler.wfile.flush()
@@ -6486,6 +7462,7 @@ class DaemonApp:
6486
7462
  quest_id: str,
6487
7463
  bash_id: str,
6488
7464
  headers: dict[str, str] | None = None,
7465
+ extra_headers: dict[str, str] | None = None,
6489
7466
  ) -> None:
6490
7467
  quest_root = self.quest_service._quest_root(quest_id)
6491
7468
  last_event_raw = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
@@ -6496,6 +7473,8 @@ class DaemonApp:
6496
7473
  handler.send_header("Cache-Control", "no-cache, no-transform")
6497
7474
  handler.send_header("Connection", "keep-alive")
6498
7475
  handler.send_header("X-Accel-Buffering", "no")
7476
+ for key, value in (extra_headers or {}).items():
7477
+ handler.send_header(key, value)
6499
7478
  handler.end_headers()
6500
7479
  handler.wfile.write(b"retry: 1000\n\n")
6501
7480
  handler.wfile.flush()
@@ -6696,35 +7675,84 @@ class DaemonApp:
6696
7675
  if route_name is None:
6697
7676
  self._write_json(404, {"ok": False, "message": "Not Found"})
6698
7677
  return
6699
- if route_name == "quest_events" and app._wants_event_stream(self.path, dict(self.headers.items())):
7678
+ request_headers = dict(self.headers.items())
7679
+ auth_state = app.browser_auth_state_for_request(self.path, request_headers)
7680
+ auth_headers = app._auth_response_headers(auth_state)
7681
+ if app._route_requires_browser_auth(route_name) and not auth_state.authenticated:
7682
+ self._write_json(
7683
+ 401,
7684
+ {
7685
+ "ok": False,
7686
+ "message": "Authentication required.",
7687
+ "auth_required": True,
7688
+ "auth_enabled": True,
7689
+ },
7690
+ extra_headers={
7691
+ **auth_headers,
7692
+ "WWW-Authenticate": f'Bearer realm="{_BROWSER_AUTH_REALM}"',
7693
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
7694
+ },
7695
+ )
7696
+ return
7697
+ if route_name == "quest_events" and app._wants_event_stream(self.path, request_headers):
6700
7698
  try:
6701
- app.stream_quest_events(self, **params, path=self.path, headers=dict(self.headers.items()))
7699
+ app.stream_quest_events(self, **params, path=self.path, headers=request_headers, extra_headers=auth_headers)
6702
7700
  except Exception as exc:
6703
- self._write_json(500, {"ok": False, "message": str(exc)})
7701
+ app.logger.log(
7702
+ "error",
7703
+ "http.stream_quest_events_failed",
7704
+ path=self.path,
7705
+ error=str(exc),
7706
+ )
7707
+ self.close_connection = True
6704
7708
  return
6705
7709
  if route_name == "bash_sessions_stream":
6706
7710
  try:
6707
- app.stream_bash_sessions(self, **params, path=self.path)
7711
+ app.stream_bash_sessions(self, **params, path=self.path, extra_headers=auth_headers)
6708
7712
  except Exception as exc:
6709
- self._write_json(500, {"ok": False, "message": str(exc)})
7713
+ app.logger.log(
7714
+ "error",
7715
+ "http.stream_bash_sessions_failed",
7716
+ path=self.path,
7717
+ error=str(exc),
7718
+ )
7719
+ self.close_connection = True
6710
7720
  return
6711
7721
  if route_name == "bash_log_stream":
6712
7722
  try:
6713
- app.stream_bash_logs(self, **params, headers=dict(self.headers.items()))
7723
+ app.stream_bash_logs(self, **params, headers=request_headers, extra_headers=auth_headers)
6714
7724
  except Exception as exc:
6715
- self._write_json(500, {"ok": False, "message": str(exc)})
7725
+ app.logger.log(
7726
+ "error",
7727
+ "http.stream_bash_logs_failed",
7728
+ path=self.path,
7729
+ error=str(exc),
7730
+ )
7731
+ self.close_connection = True
6716
7732
  return
6717
7733
  if route_name == "terminal_stream":
6718
7734
  try:
6719
- app.stream_bash_logs(self, quest_id=params["quest_id"], bash_id=params["session_id"], headers=dict(self.headers.items()))
7735
+ app.stream_bash_logs(
7736
+ self,
7737
+ quest_id=params["quest_id"],
7738
+ bash_id=params["session_id"],
7739
+ headers=request_headers,
7740
+ extra_headers=auth_headers,
7741
+ )
6720
7742
  except Exception as exc:
6721
- self._write_json(500, {"ok": False, "message": str(exc)})
7743
+ app.logger.log(
7744
+ "error",
7745
+ "http.stream_terminal_logs_failed",
7746
+ path=self.path,
7747
+ error=str(exc),
7748
+ )
7749
+ self.close_connection = True
6722
7750
  return
6723
7751
  if route_name == "lingzhu_sse":
6724
7752
  content_length = int(self.headers.get("Content-Length", "0"))
6725
7753
  raw_body = self.rfile.read(content_length) if content_length else b""
6726
7754
  try:
6727
- app.stream_lingzhu_sse(self, raw_body=raw_body, headers=dict(self.headers.items()))
7755
+ app.stream_lingzhu_sse(self, raw_body=raw_body, headers=request_headers)
6728
7756
  except Exception as exc:
6729
7757
  self._write_json(500, {"ok": False, "message": str(exc)})
6730
7758
  return
@@ -6739,11 +7767,12 @@ class DaemonApp:
6739
7767
  result = getattr(app.handlers, route_name)
6740
7768
  if route_name == "asset":
6741
7769
  status, headers, content = result(**params)
6742
- self.send_response(status)
6743
- for key, value in headers.items():
6744
- self.send_header(key, value)
6745
- self.end_headers()
6746
- self.wfile.write(content)
7770
+ app._write_handler_response(
7771
+ self,
7772
+ code=status,
7773
+ content=content,
7774
+ extra_headers=app._merge_response_headers(headers, auth_headers),
7775
+ )
6747
7776
  return
6748
7777
  if route_name in {
6749
7778
  "quest_events",
@@ -6770,7 +7799,7 @@ class DaemonApp:
6770
7799
  payload = result(**params, path=self.path)
6771
7800
  elif method == "GET":
6772
7801
  payload = result(**params) if params else result()
6773
- elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create"}:
7802
+ elif route_name in {"document_open", "document_asset_upload", "quest_file_create_folder", "quest_file_upload", "quest_file_rename", "quest_file_move", "quest_file_delete", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create", "auth_login", "auth_rotate"}:
6774
7803
  payload = result(**params, body=body)
6775
7804
  elif route_name == "config_validate":
6776
7805
  payload = result(body)
@@ -6783,33 +7812,43 @@ class DaemonApp:
6783
7812
  else:
6784
7813
  payload = result(**params) if params else result()
6785
7814
  except Exception as exc:
6786
- self._write_json(500, {"ok": False, "message": str(exc)})
7815
+ self._write_json(500, {"ok": False, "message": str(exc)}, extra_headers=auth_headers)
6787
7816
  return
6788
7817
 
6789
7818
  if isinstance(payload, tuple) and len(payload) == 2:
6790
7819
  status, body = payload
6791
- self._write_json(status, body)
7820
+ self._write_json(status, body, extra_headers=auth_headers)
6792
7821
  return
6793
7822
  if isinstance(payload, tuple) and len(payload) == 3:
6794
7823
  status, headers, content = payload
6795
- self.send_response(status)
6796
- for key, value in headers.items():
6797
- self.send_header(key, value)
6798
- self.end_headers()
6799
7824
  if isinstance(content, str):
6800
- self.wfile.write(content.encode("utf-8"))
7825
+ encoded = content.encode("utf-8")
6801
7826
  else:
6802
- self.wfile.write(content)
7827
+ encoded = content
7828
+ app._write_handler_response(
7829
+ self,
7830
+ code=status,
7831
+ content=encoded,
7832
+ extra_headers=app._merge_response_headers(headers, auth_headers),
7833
+ )
6803
7834
  return
6804
- self._write_json(200, payload)
6805
-
6806
- def _write_json(self, code: int, payload: dict | list) -> None:
7835
+ self._write_json(200, payload, extra_headers=auth_headers)
7836
+
7837
+ def _write_json(
7838
+ self,
7839
+ code: int,
7840
+ payload: dict | list,
7841
+ *,
7842
+ extra_headers: dict[str, str] | None = None,
7843
+ ) -> None:
6807
7844
  encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
6808
- self.send_response(code)
6809
- self.send_header("Content-Type", "application/json; charset=utf-8")
6810
- self.send_header("Content-Length", str(len(encoded)))
6811
- self.end_headers()
6812
- self.wfile.write(encoded)
7845
+ app._write_handler_response(
7846
+ self,
7847
+ code=code,
7848
+ content=encoded,
7849
+ content_type="application/json; charset=utf-8",
7850
+ extra_headers=extra_headers,
7851
+ )
6813
7852
 
6814
7853
  server = ThreadingHTTPServer((host, port), RequestHandler)
6815
7854
  server.daemon_threads = True
@@ -6821,6 +7860,8 @@ class DaemonApp:
6821
7860
  self._start_background_connectors()
6822
7861
  self._resume_reconciled_quests()
6823
7862
  print(f"DeepScientist daemon listening on http://{host}:{port}")
7863
+ if self.browser_auth_enabled and self.browser_auth_token:
7864
+ print(f"DeepScientist auth token: {self.browser_auth_token}")
6824
7865
  try:
6825
7866
  server.serve_forever()
6826
7867
  except KeyboardInterrupt: