@researai/deepscientist 1.5.15 → 1.5.16

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 (193) hide show
  1. package/README.md +336 -98
  2. package/bin/ds.js +691 -91
  3. package/docs/en/00_QUICK_START.md +36 -15
  4. package/docs/en/01_SETTINGS_REFERENCE.md +33 -0
  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 +11 -5
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  10. package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
  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/README.md +18 -0
  15. package/docs/zh/00_QUICK_START.md +36 -15
  16. package/docs/zh/01_SETTINGS_REFERENCE.md +33 -0
  17. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  18. package/docs/zh/05_TUI_GUIDE.md +6 -0
  19. package/docs/zh/09_DOCTOR.md +11 -5
  20. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +63 -13
  21. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
  22. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  23. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  24. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  25. package/docs/zh/README.md +18 -0
  26. package/package.json +1 -1
  27. package/pyproject.toml +1 -1
  28. package/src/deepscientist/__init__.py +1 -1
  29. package/src/deepscientist/acp/envelope.py +6 -0
  30. package/src/deepscientist/artifact/service.py +647 -22
  31. package/src/deepscientist/bash_exec/service.py +234 -9
  32. package/src/deepscientist/cli.py +115 -19
  33. package/src/deepscientist/codex_cli_compat.py +232 -0
  34. package/src/deepscientist/config/models.py +2 -1
  35. package/src/deepscientist/config/service.py +31 -9
  36. package/src/deepscientist/daemon/api/handlers.py +125 -6
  37. package/src/deepscientist/daemon/api/router.py +4 -0
  38. package/src/deepscientist/daemon/app.py +715 -98
  39. package/src/deepscientist/gitops/__init__.py +10 -1
  40. package/src/deepscientist/gitops/diff.py +129 -0
  41. package/src/deepscientist/gitops/service.py +4 -1
  42. package/src/deepscientist/mcp/server.py +39 -0
  43. package/src/deepscientist/prompts/builder.py +255 -32
  44. package/src/deepscientist/quest/layout.py +15 -2
  45. package/src/deepscientist/quest/service.py +295 -43
  46. package/src/deepscientist/quest/stage_views.py +6 -1
  47. package/src/deepscientist/runners/codex.py +86 -31
  48. package/src/deepscientist/skills/__init__.py +2 -2
  49. package/src/deepscientist/skills/installer.py +196 -5
  50. package/src/deepscientist/skills/registry.py +66 -0
  51. package/src/prompts/connectors/qq.md +18 -8
  52. package/src/prompts/connectors/weixin.md +16 -6
  53. package/src/prompts/contracts/shared_interaction.md +12 -1
  54. package/src/prompts/system.md +10 -5
  55. package/src/prompts/system_copilot.md +43 -0
  56. package/src/skills/analysis-campaign/SKILL.md +1 -0
  57. package/src/skills/baseline/SKILL.md +8 -0
  58. package/src/skills/decision/SKILL.md +8 -0
  59. package/src/skills/experiment/SKILL.md +8 -0
  60. package/src/skills/figure-polish/SKILL.md +1 -0
  61. package/src/skills/finalize/SKILL.md +1 -0
  62. package/src/skills/idea/SKILL.md +1 -0
  63. package/src/skills/intake-audit/SKILL.md +8 -0
  64. package/src/skills/mentor/SKILL.md +217 -0
  65. package/src/skills/mentor/references/correction-rules.md +210 -0
  66. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  67. package/src/skills/mentor/references/persona-profile.md +138 -0
  68. package/src/skills/mentor/references/taste-profile.md +128 -0
  69. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  70. package/src/skills/mentor/references/work-profile.md +289 -0
  71. package/src/skills/mentor/references/workflow-profile.md +240 -0
  72. package/src/skills/optimize/SKILL.md +1 -0
  73. package/src/skills/rebuttal/SKILL.md +1 -0
  74. package/src/skills/review/SKILL.md +1 -0
  75. package/src/skills/scout/SKILL.md +8 -0
  76. package/src/skills/write/SKILL.md +1 -0
  77. package/src/tui/dist/app/AppContainer.js +19 -11
  78. package/src/tui/dist/index.js +4 -1
  79. package/src/tui/dist/lib/api.js +33 -3
  80. package/src/tui/package.json +1 -1
  81. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
  82. package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
  83. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
  84. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
  85. package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
  86. package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
  87. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
  88. package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
  89. package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
  90. package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
  91. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
  92. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
  93. package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
  94. package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
  95. package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
  96. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  97. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  98. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
  99. package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
  100. package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
  101. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  102. package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
  103. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
  104. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  105. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
  106. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  107. package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
  108. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
  109. package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
  110. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  111. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
  112. package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
  113. package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
  114. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
  115. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  116. package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
  117. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
  118. package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
  119. package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
  120. package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
  121. package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
  122. package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
  123. package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
  124. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  125. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
  126. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  127. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  128. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  129. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  130. package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
  131. package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
  132. package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
  133. package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
  134. package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
  135. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
  136. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  137. package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
  138. package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
  139. package/src/ui/dist/index.html +5 -2
  140. package/src/ui/dist/assets/AiManusChatView-DDjbFnbt.js +0 -26597
  141. package/src/ui/dist/assets/AnalysisPlugin-Yb5IdmaU.js +0 -123
  142. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +0 -31037
  143. package/src/ui/dist/assets/CodeEditorPlugin-C4D2TIkU.js +0 -427
  144. package/src/ui/dist/assets/CodeViewerPlugin-BVoNZIvC.js +0 -905
  145. package/src/ui/dist/assets/DocViewerPlugin-CLChbllo.js +0 -278
  146. package/src/ui/dist/assets/GitDiffViewerPlugin-C4xeFyFQ.js +0 -2661
  147. package/src/ui/dist/assets/ImageViewerPlugin-OiMUAcLi.js +0 -500
  148. package/src/ui/dist/assets/LabCopilotPanel-BjD2ThQF.js +0 -4104
  149. package/src/ui/dist/assets/LabPlugin-DQPg-NrB.js +0 -2677
  150. package/src/ui/dist/assets/LatexPlugin-CI05XAV9.js +0 -1792
  151. package/src/ui/dist/assets/MarkdownViewerPlugin-DpeBLYZf.js +0 -308
  152. package/src/ui/dist/assets/MarketplacePlugin-DolE58Q2.js +0 -413
  153. package/src/ui/dist/assets/NotebookEditor-7Qm2rSWD.js +0 -4214
  154. package/src/ui/dist/assets/NotebookEditor-C1kWaxKi.js +0 -84873
  155. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  156. package/src/ui/dist/assets/PdfLoader-BfOHw8Zw.js +0 -25468
  157. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  158. package/src/ui/dist/assets/PdfMarkdownPlugin-BulDREv1.js +0 -409
  159. package/src/ui/dist/assets/PdfViewerPlugin-C-daaOaL.js +0 -3095
  160. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  161. package/src/ui/dist/assets/SearchPlugin-CjpaiJ3A.js +0 -741
  162. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  163. package/src/ui/dist/assets/TextViewerPlugin-BxIyqPQC.js +0 -472
  164. package/src/ui/dist/assets/VNCViewer-HAg9mF7M.js +0 -18821
  165. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  166. package/src/ui/dist/assets/bot-0DYntytV.js +0 -21
  167. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  168. package/src/ui/dist/assets/code-B20Slj_w.js +0 -17
  169. package/src/ui/dist/assets/file-content-DT24KFma.js +0 -377
  170. package/src/ui/dist/assets/file-diff-panel-DK13YPql.js +0 -92
  171. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  172. package/src/ui/dist/assets/file-socket-B4T2o4nR.js +0 -58
  173. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  174. package/src/ui/dist/assets/image-DSeR_sDS.js +0 -18
  175. package/src/ui/dist/assets/index-BrFje2Uk.js +0 -120
  176. package/src/ui/dist/assets/index-BwRJaoTl.js +0 -25
  177. package/src/ui/dist/assets/index-D_E4281X.js +0 -221322
  178. package/src/ui/dist/assets/index-DnYB3xb1.js +0 -159
  179. package/src/ui/dist/assets/index-G7AcWcMu.css +0 -12594
  180. package/src/ui/dist/assets/monaco-LExaAN3Y.js +0 -623
  181. package/src/ui/dist/assets/pdf-effect-queue-BJk5okWJ.js +0 -47
  182. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  183. package/src/ui/dist/assets/popover-D3Gg_FoV.js +0 -476
  184. package/src/ui/dist/assets/project-sync-C_ygLlVU.js +0 -297
  185. package/src/ui/dist/assets/select-CpAK6uWm.js +0 -1690
  186. package/src/ui/dist/assets/sigma-DEccaSgk.js +0 -22
  187. package/src/ui/dist/assets/square-check-big-uUfyVsbD.js +0 -17
  188. package/src/ui/dist/assets/trash-CXvwwSe8.js +0 -32
  189. package/src/ui/dist/assets/useCliAccess-Bnop4mgR.js +0 -957
  190. package/src/ui/dist/assets/useFileDiffOverlay-B8eUAX0I.js +0 -53
  191. package/src/ui/dist/assets/wrap-text-9vbOBpkW.js +0 -35
  192. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  193. 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
@@ -69,7 +75,7 @@ from ..connector.lingzhu_support import (
69
75
  lingzhu_verify_auth_header,
70
76
  )
71
77
  from ..prompts import PromptBuilder
72
- from ..prompts.builder import STANDARD_SKILLS, classify_turn_intent
78
+ from ..prompts.builder import classify_turn_intent, current_standard_skills
73
79
  from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
74
80
  from ..quest import QuestService
75
81
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
@@ -96,7 +102,9 @@ from websockets.sync.server import Server as WebSocketServer
96
102
  from websockets.sync.server import ServerConnection, serve as websocket_serve
97
103
 
98
104
  TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
99
- _AUTO_CONTINUE_DELAY_SECONDS = 0.2
105
+ _AUTO_CONTINUE_DELAY_SECONDS = 240.0
106
+ _AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS = 0.2
107
+ _TERMINAL_PREWARM_DEBOUNCE_SECONDS = 20.0
100
108
  CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
101
109
  CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
102
110
  CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
@@ -144,6 +152,20 @@ _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
144
152
  _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT = 5
145
153
  _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT = 2.0
146
154
  _LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
155
+ _BROWSER_AUTH_COOKIE_NAME = "ds_local_auth"
156
+ _BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365
157
+ _BROWSER_AUTH_QUERY_PARAM = "token"
158
+ _BROWSER_AUTH_STORAGE_KEY = "ds_local_auth_token"
159
+ _BROWSER_AUTH_PUBLIC_ROUTE_NAMES = {"root", "spa_root", "ui_asset", "asset", "auth_login"}
160
+ _BROWSER_AUTH_EXEMPT_ROUTE_NAMES = {"lingzhu_health", "lingzhu_sse"}
161
+ _BROWSER_AUTH_REALM = "DeepScientist"
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class BrowserAuthState:
166
+ authenticated: bool
167
+ token_source: str | None = None
168
+ response_cookie: str | None = None
147
169
 
148
170
 
149
171
  def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
@@ -155,7 +177,14 @@ def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
155
177
  class DaemonApp:
156
178
  _MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
157
179
 
158
- def __init__(self, home: Path) -> None:
180
+ def __init__(
181
+ self,
182
+ home: Path,
183
+ *,
184
+ browser_auth_enabled: bool | None = None,
185
+ browser_auth_token: str | None = None,
186
+ prompt_version_selection: str | None = None,
187
+ ) -> None:
159
188
  self.home = home.resolve()
160
189
  self.daemon_id = str(os.environ.get("DS_DAEMON_ID") or "").strip() or generate_id("daemon")
161
190
  self.daemon_managed_by = str(os.environ.get("DS_DAEMON_MANAGED_BY") or "manual").strip() or "manual"
@@ -191,7 +220,11 @@ class DaemonApp:
191
220
  abandoned_run_id=item.get("abandoned_run_id"),
192
221
  status=item.get("status"),
193
222
  )
194
- self.prompt_builder = PromptBuilder(self.repo_root, home)
223
+ self.prompt_builder = PromptBuilder(
224
+ self.repo_root,
225
+ home,
226
+ prompt_version_selection=prompt_version_selection,
227
+ )
195
228
  self.codex_runner = CodexRunner(
196
229
  home=home,
197
230
  repo_root=self.repo_root,
@@ -211,6 +244,8 @@ class DaemonApp:
211
244
  self._canonicalize_lingzhu_binding_state()
212
245
  self._turn_lock = threading.Lock()
213
246
  self._turn_state: dict[str, dict[str, object]] = {}
247
+ self._terminal_prewarm_lock = threading.Lock()
248
+ self._terminal_prewarm_recent: dict[str, float] = {}
214
249
  self._server: ThreadingHTTPServer | None = None
215
250
  self._terminal_attach_server: WebSocketServer | None = None
216
251
  self._terminal_attach_thread: threading.Thread | None = None
@@ -230,8 +265,190 @@ class DaemonApp:
230
265
  self._process_hooks_installed = False
231
266
  self._faulthandler_stream = None
232
267
  self._recovered_quest_ids: set[str] = set()
268
+ ui_config = config.get("ui") if isinstance(config.get("ui"), dict) else {}
269
+ configured_browser_auth_enabled = self._parse_browser_auth_bool(ui_config.get("auth_enabled"))
270
+ env_browser_auth_enabled = self._parse_browser_auth_bool(os.environ.get("DS_UI_AUTH_ENABLED"))
271
+ explicit_browser_auth_enabled = self._parse_browser_auth_bool(browser_auth_enabled)
272
+ if explicit_browser_auth_enabled is not None:
273
+ self.browser_auth_enabled = explicit_browser_auth_enabled
274
+ elif env_browser_auth_enabled is not None:
275
+ self.browser_auth_enabled = env_browser_auth_enabled
276
+ elif configured_browser_auth_enabled is not None:
277
+ self.browser_auth_enabled = configured_browser_auth_enabled
278
+ else:
279
+ self.browser_auth_enabled = False
280
+ explicit_browser_auth_token = self._normalize_browser_auth_token(browser_auth_token)
281
+ env_browser_auth_token = self._normalize_browser_auth_token(os.environ.get("DS_UI_AUTH_TOKEN"))
282
+ if self.browser_auth_enabled:
283
+ self.browser_auth_token = explicit_browser_auth_token or env_browser_auth_token or self.generate_browser_auth_token()
284
+ else:
285
+ self.browser_auth_token = None
233
286
  self.handlers = ApiHandlers(self)
234
287
 
288
+ @staticmethod
289
+ def _parse_browser_auth_bool(value: object) -> bool | None:
290
+ if isinstance(value, bool):
291
+ return value
292
+ normalized = str(value or "").strip().lower()
293
+ if not normalized:
294
+ return None
295
+ if normalized in {"1", "true", "yes", "on"}:
296
+ return True
297
+ if normalized in {"0", "false", "no", "off"}:
298
+ return False
299
+ return None
300
+
301
+ @staticmethod
302
+ def _normalize_browser_auth_token(value: object) -> str | None:
303
+ token = str(value or "").strip()
304
+ return token or None
305
+
306
+ @staticmethod
307
+ def generate_browser_auth_token() -> str:
308
+ return secrets.token_hex(8)
309
+
310
+ def masked_browser_auth_token(self) -> str | None:
311
+ token = self.browser_auth_token
312
+ if not token:
313
+ return None
314
+ if len(token) <= 6:
315
+ return "*" * len(token)
316
+ return f"{token[:3]}{'*' * (len(token) - 6)}{token[-3:]}"
317
+
318
+ @staticmethod
319
+ def _header_value(headers: dict[str, str] | None, name: str) -> str:
320
+ if not isinstance(headers, dict):
321
+ return ""
322
+ target = name.strip().lower()
323
+ for key, value in headers.items():
324
+ if str(key).strip().lower() == target:
325
+ return str(value or "")
326
+ return ""
327
+
328
+ @staticmethod
329
+ def _parse_bearer_token(header_value: str) -> str | None:
330
+ normalized = str(header_value or "").strip()
331
+ prefix = "bearer "
332
+ if not normalized or normalized[: len(prefix)].lower() != prefix:
333
+ return None
334
+ token = normalized[len(prefix) :].strip()
335
+ return token or None
336
+
337
+ def _request_cookie_token(self, headers: dict[str, str] | None) -> str | None:
338
+ raw_cookie = self._header_value(headers, "Cookie")
339
+ if not raw_cookie:
340
+ return None
341
+ try:
342
+ cookie = SimpleCookie()
343
+ cookie.load(raw_cookie)
344
+ except Exception:
345
+ return None
346
+ morsel = cookie.get(_BROWSER_AUTH_COOKIE_NAME)
347
+ if morsel is None:
348
+ return None
349
+ token = str(getattr(morsel, "value", "") or "").strip()
350
+ return token or None
351
+
352
+ @staticmethod
353
+ def _request_query_token(path: str) -> str | None:
354
+ query = parse_qs(urlparse(path).query, keep_blank_values=True)
355
+ token = str((query.get(_BROWSER_AUTH_QUERY_PARAM) or [""])[0] or "").strip()
356
+ return token or None
357
+
358
+ def _browser_auth_cookie_header(self, token: str | None = None) -> str:
359
+ cookie = SimpleCookie()
360
+ cookie[_BROWSER_AUTH_COOKIE_NAME] = token or (self.browser_auth_token or "")
361
+ morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
362
+ morsel["path"] = "/"
363
+ morsel["httponly"] = True
364
+ morsel["samesite"] = "Strict"
365
+ morsel["max-age"] = str(_BROWSER_AUTH_COOKIE_MAX_AGE_SECONDS)
366
+ return morsel.OutputString()
367
+
368
+ @staticmethod
369
+ def _browser_auth_clear_cookie_header() -> str:
370
+ cookie = SimpleCookie()
371
+ cookie[_BROWSER_AUTH_COOKIE_NAME] = ""
372
+ morsel = cookie[_BROWSER_AUTH_COOKIE_NAME]
373
+ morsel["path"] = "/"
374
+ morsel["httponly"] = True
375
+ morsel["samesite"] = "Strict"
376
+ morsel["max-age"] = "0"
377
+ morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT"
378
+ return morsel.OutputString()
379
+
380
+ def browser_auth_matches(self, token: str | None) -> bool:
381
+ expected = self.browser_auth_token
382
+ candidate = self._normalize_browser_auth_token(token)
383
+ return bool(expected and candidate and hmac.compare_digest(candidate, expected))
384
+
385
+ def rotate_browser_auth_token(self) -> str:
386
+ if not self.browser_auth_enabled:
387
+ raise RuntimeError("Browser authentication is disabled.")
388
+ rotated = self.generate_browser_auth_token()
389
+ self.browser_auth_token = rotated
390
+ return rotated
391
+
392
+ def browser_auth_state_for_request(self, path: str, headers: dict[str, str] | None = None) -> BrowserAuthState:
393
+ if not self.browser_auth_enabled:
394
+ return BrowserAuthState(authenticated=True)
395
+ expected = self.browser_auth_token
396
+ if not expected:
397
+ return BrowserAuthState(authenticated=False)
398
+
399
+ candidates = (
400
+ ("authorization", self._parse_bearer_token(self._header_value(headers, "Authorization"))),
401
+ ("query", self._request_query_token(path)),
402
+ ("cookie", self._request_cookie_token(headers)),
403
+ )
404
+ for source, candidate in candidates:
405
+ if candidate and hmac.compare_digest(candidate, expected):
406
+ response_cookie = self._browser_auth_cookie_header(expected) if source in {"authorization", "query"} else None
407
+ return BrowserAuthState(authenticated=True, token_source=source, response_cookie=response_cookie)
408
+ return BrowserAuthState(authenticated=False, response_cookie=self._browser_auth_clear_cookie_header())
409
+
410
+ @staticmethod
411
+ def _auth_response_headers(auth_state: BrowserAuthState | None) -> dict[str, str]:
412
+ if auth_state is None or not auth_state.response_cookie:
413
+ return {}
414
+ return {"Set-Cookie": auth_state.response_cookie}
415
+
416
+ @staticmethod
417
+ def _merge_response_headers(
418
+ base: dict[str, str] | None = None,
419
+ extra: dict[str, str] | None = None,
420
+ ) -> dict[str, str]:
421
+ merged: dict[str, str] = {}
422
+ if isinstance(extra, dict):
423
+ merged.update(extra)
424
+ if isinstance(base, dict):
425
+ merged.update(base)
426
+ return merged
427
+
428
+ def _route_requires_browser_auth(self, route_name: str | None) -> bool:
429
+ if not self.browser_auth_enabled or not route_name:
430
+ return False
431
+ if route_name in _BROWSER_AUTH_PUBLIC_ROUTE_NAMES:
432
+ return False
433
+ if route_name in _BROWSER_AUTH_EXEMPT_ROUTE_NAMES:
434
+ return False
435
+ return True
436
+
437
+ def browser_auth_runtime_payload(self) -> dict[str, object]:
438
+ return {
439
+ "enabled": self.browser_auth_enabled,
440
+ "tokenQueryParam": _BROWSER_AUTH_QUERY_PARAM,
441
+ "storageKey": _BROWSER_AUTH_STORAGE_KEY,
442
+ }
443
+
444
+ def browser_auth_tokenized_url(self, url: str) -> str:
445
+ if not self.browser_auth_enabled or not self.browser_auth_token:
446
+ return url
447
+ parsed = urlparse(url)
448
+ query = parse_qs(parsed.query, keep_blank_values=True)
449
+ query[_BROWSER_AUTH_QUERY_PARAM] = [self.browser_auth_token]
450
+ return parsed._replace(query=urlencode(query, doseq=True)).geturl()
451
+
235
452
  def list_connector_statuses(self) -> list[dict[str, object]]:
236
453
  title_by_quest = self._quest_titles_by_id()
237
454
  items = [
@@ -1528,6 +1745,57 @@ class DaemonApp:
1528
1745
  def _turn_worker_is_alive(worker: object) -> bool:
1529
1746
  return isinstance(worker, threading.Thread) and worker.is_alive()
1530
1747
 
1748
+ def schedule_latest_quest_terminal_prewarm(
1749
+ self,
1750
+ quest_id: str,
1751
+ *,
1752
+ source: str = "quest_session_prewarm",
1753
+ ) -> None:
1754
+ normalized_quest_id = str(quest_id or "").strip()
1755
+ if not normalized_quest_id or os.name == "nt":
1756
+ return
1757
+ try:
1758
+ quests = self.quest_service.list_quests()
1759
+ except Exception:
1760
+ return
1761
+ latest_quest_id = str((quests[0].get("quest_id") if quests else "") or "").strip()
1762
+ if latest_quest_id != normalized_quest_id:
1763
+ return
1764
+ now = time.monotonic()
1765
+ with self._terminal_prewarm_lock:
1766
+ last_attempt = float(self._terminal_prewarm_recent.get(normalized_quest_id) or 0.0)
1767
+ if now - last_attempt < _TERMINAL_PREWARM_DEBOUNCE_SECONDS:
1768
+ return
1769
+ self._terminal_prewarm_recent[normalized_quest_id] = now
1770
+ threading.Thread(
1771
+ target=self._prewarm_terminal_for_quest,
1772
+ args=(normalized_quest_id, source),
1773
+ daemon=True,
1774
+ name=f"deepscientist-terminal-prewarm-{normalized_quest_id}",
1775
+ ).start()
1776
+
1777
+ def _prewarm_terminal_for_quest(self, quest_id: str, source: str) -> None:
1778
+ try:
1779
+ quest_root = self.quest_service._quest_root(quest_id)
1780
+ workspace_root = self.quest_service.active_workspace_root(quest_root)
1781
+ self.bash_exec_service.ensure_terminal_session(
1782
+ quest_root,
1783
+ quest_id=quest_id,
1784
+ bash_id=DEFAULT_TERMINAL_SESSION_ID,
1785
+ cwd=workspace_root,
1786
+ source=source,
1787
+ )
1788
+ except Exception as exc:
1789
+ with self._terminal_prewarm_lock:
1790
+ self._terminal_prewarm_recent.pop(quest_id, None)
1791
+ self.logger.log(
1792
+ "warning",
1793
+ "terminal.prewarm_failed",
1794
+ quest_id=quest_id,
1795
+ source=source,
1796
+ error=str(exc),
1797
+ )
1798
+
1531
1799
  def _refresh_turn_worker_state(self, quest_id: str) -> dict[str, object]:
1532
1800
  with self._turn_lock:
1533
1801
  state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
@@ -1882,52 +2150,50 @@ class DaemonApp:
1882
2150
  cancelled_pending_user_message_count: int,
1883
2151
  previous_snapshot: dict | None = None,
1884
2152
  ) -> 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
2153
  if action == "resume":
1888
2154
  lines = [
1889
2155
  self._polite_copy(
1890
- zh="DeepScientist 已恢复运行。",
1891
- en="DeepScientist has resumed.",
2156
+ zh=f"我回来继续干活啦,Quest `{quest_id}` 已恢复。",
2157
+ en=f"I’m back on it. Quest `{quest_id}` has resumed.",
1892
2158
  ),
1893
2159
  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.",
2160
+ zh="刚才的进度都还在,我会直接接着往下推。",
2161
+ en="The current progress is still here, and I’ll pick up right where I left off.",
1896
2162
  ),
1897
2163
  ]
1898
2164
  if source.startswith("auto:daemon-recovery"):
1899
2165
  lines.append(
1900
2166
  self._polite_copy(
1901
- zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
1902
- en="The daemon exited unexpectedly before; this quest has now been recovered automatically and will continue.",
2167
+ zh="刚才是 daemon 意外断开了,不过现在已经自动接回来了。",
2168
+ en="The daemon dropped unexpectedly, but it has been recovered automatically. 🔧",
1903
2169
  )
1904
2170
  )
1905
2171
  elif action == "pause":
1906
2172
  lines = [
1907
2173
  self._polite_copy(
1908
- zh="DeepScientist 已从运行状态转为暂停状态。",
1909
- en="DeepScientist has moved from running to paused.",
2174
+ zh=f"我先帮您把 Quest `{quest_id}` 稳稳停在这里啦。",
2175
+ en=f"I’ve paused Quest `{quest_id}` right here for now. ⏸️",
1910
2176
  ),
1911
2177
  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.",
2178
+ zh="当前进度我都保留好了,您发新消息或者执行 `/resume`,我就会继续。",
2179
+ en="I kept the current progress safe. Send a new message or use `/resume`, and I’ll continue.",
1914
2180
  ),
1915
2181
  ]
1916
2182
  else:
1917
2183
  lines = [
1918
2184
  self._polite_copy(
1919
- zh="DeepScientist 已从运行状态转为停止状态。",
1920
- en="DeepScientist has moved from running to stopped.",
2185
+ zh=f"这轮我先收住啦,Quest `{quest_id}` 已停止运行。",
2186
+ en=f"I’m wrapping this round here. Quest `{quest_id}` has stopped. 📌",
1921
2187
  ),
1922
2188
  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.",
2189
+ zh="不过别担心,当前进度我都保留好了;您发新消息或者执行 `/resume`,我就能接着干。",
2190
+ en="Don’t worry, the current progress is still preserved. Send a new message or use `/resume`, and I’ll keep going.",
1925
2191
  ),
1926
2192
  ]
1927
2193
  if interrupted:
1928
2194
  lines.append(
1929
2195
  self._polite_copy(
1930
- zh="当前活跃 runner 已被中断。",
2196
+ zh="刚才正在跑的任务已经被打断了。",
1931
2197
  en="The active runner was interrupted.",
1932
2198
  )
1933
2199
  )
@@ -1935,8 +2201,8 @@ class DaemonApp:
1935
2201
  if cancelled_count > 0:
1936
2202
  lines.append(
1937
2203
  self._polite_copy(
1938
- zh=f"已取消 {cancelled_count} 条排队中的用户消息。",
1939
- en=f"Cancelled {cancelled_count} queued user message(s).",
2204
+ zh=f"另外我还顺手清掉了 {cancelled_count} 条排队消息,避免旧指令继续堆着。",
2205
+ en=f"I also cleared {cancelled_count} queued message(s) so stale instructions do not pile up.",
1940
2206
  )
1941
2207
  )
1942
2208
  previous_status = str(
@@ -1947,17 +2213,10 @@ class DaemonApp:
1947
2213
  if previous_status and action == "resume":
1948
2214
  lines.append(
1949
2215
  self._polite_copy(
1950
- zh=f"此前状态:`{previous_status}`。",
2216
+ zh=f"恢复前的状态是:`{previous_status}`。",
1951
2217
  en=f"Previous status: `{previous_status}`.",
1952
2218
  )
1953
2219
  )
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
2220
  return "\n".join(lines)
1962
2221
 
1963
2222
  def _drain_turns(self, quest_id: str) -> None:
@@ -2421,11 +2680,41 @@ class DaemonApp:
2421
2680
 
2422
2681
  @staticmethod
2423
2682
  def _continuation_anchor_for(snapshot: dict) -> str:
2683
+ available_stage_skills = current_standard_skills(repo_root())
2424
2684
  continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
2425
- if continuation_anchor in STANDARD_SKILLS:
2685
+ if continuation_anchor in available_stage_skills:
2426
2686
  return continuation_anchor
2427
2687
  active_anchor = str(snapshot.get("active_anchor") or "").strip()
2428
- return active_anchor if active_anchor in STANDARD_SKILLS else "decision"
2688
+ return active_anchor if active_anchor in available_stage_skills else "decision"
2689
+
2690
+ @staticmethod
2691
+ def _workspace_mode_for(snapshot: dict) -> str:
2692
+ value = str(snapshot.get("workspace_mode") or "").strip().lower()
2693
+ if value in {"copilot", "autonomous"}:
2694
+ return value
2695
+ startup_contract = snapshot.get("startup_contract")
2696
+ if isinstance(startup_contract, dict):
2697
+ value = str(startup_contract.get("workspace_mode") or "").strip().lower()
2698
+ if value in {"copilot", "autonomous"}:
2699
+ return value
2700
+ return "autonomous"
2701
+
2702
+ def _resolve_continuation_policy(self, snapshot: dict, *, current_policy: str) -> tuple[str, str]:
2703
+ normalized = str(current_policy or "auto").strip().lower() or "auto"
2704
+ if normalized != "auto":
2705
+ return normalized, str(snapshot.get("continuation_reason") or "").strip() or "explicit_continuation_policy"
2706
+ if self._workspace_mode_for(snapshot) == "copilot":
2707
+ return "wait_for_user_or_resume", "copilot_mode"
2708
+ if self._has_external_progress(snapshot):
2709
+ return "when_external_progress", "background_external_progress_active"
2710
+ return "auto", "autonomous_prepare_or_launch_long_run"
2711
+
2712
+ @staticmethod
2713
+ def _auto_continue_delay_for_policy(policy: str) -> float:
2714
+ normalized = str(policy or "").strip().lower() or "auto"
2715
+ if normalized == "when_external_progress":
2716
+ return _AUTO_CONTINUE_DELAY_SECONDS
2717
+ return _AUTO_CONTINUE_ACTIVE_WORK_DELAY_SECONDS
2429
2718
 
2430
2719
  @staticmethod
2431
2720
  def _turn_skill_stage_gate(snapshot: dict, candidate_skill: str) -> str:
@@ -2455,6 +2744,19 @@ class DaemonApp:
2455
2744
  turn_reason: str = "user_message",
2456
2745
  turn_mode: str = "stage_execution",
2457
2746
  ) -> str:
2747
+ available_stage_skills = current_standard_skills(repo_root())
2748
+ workspace_mode = DaemonApp._workspace_mode_for(snapshot)
2749
+
2750
+ def copilot_default_skill() -> str:
2751
+ active_anchor = str(snapshot.get("active_anchor") or "").strip()
2752
+ if active_anchor in available_stage_skills and active_anchor != "decision":
2753
+ return DaemonApp._turn_skill_stage_gate(snapshot, active_anchor)
2754
+ continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
2755
+ if continuation_anchor in available_stage_skills and continuation_anchor != "decision":
2756
+ return DaemonApp._turn_skill_stage_gate(snapshot, continuation_anchor)
2757
+ fallback = "baseline" if "baseline" in available_stage_skills else "scout"
2758
+ return DaemonApp._turn_skill_stage_gate(snapshot, fallback)
2759
+
2458
2760
  reply_target = str((latest_user_message or {}).get("reply_to_interaction_id") or "").strip()
2459
2761
  if reply_target:
2460
2762
  for item in (snapshot.get("active_interactions") or []):
@@ -2481,11 +2783,17 @@ class DaemonApp:
2481
2783
  ):
2482
2784
  return "decision"
2483
2785
  if str(item.get("reply_mode") or "") == "threaded":
2786
+ if workspace_mode == "copilot":
2787
+ return copilot_default_skill()
2484
2788
  return DaemonApp._turn_skill_stage_gate(
2485
2789
  snapshot,
2486
2790
  DaemonApp._continuation_anchor_for(snapshot),
2487
2791
  )
2488
- if turn_mode in {"answering", "command_execution", "recovering"}:
2792
+ if turn_mode == "recovering":
2793
+ return "decision"
2794
+ if workspace_mode == "copilot" and latest_user_message is not None:
2795
+ return copilot_default_skill()
2796
+ if turn_mode in {"answering", "command_execution"}:
2489
2797
  return "decision"
2490
2798
  if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
2491
2799
  return DaemonApp._turn_skill_stage_gate(
@@ -2501,7 +2809,7 @@ class DaemonApp:
2501
2809
  active_anchor = str(snapshot.get("active_anchor") or "").strip()
2502
2810
  return DaemonApp._turn_skill_stage_gate(
2503
2811
  snapshot,
2504
- active_anchor if active_anchor in STANDARD_SKILLS else "decision",
2812
+ active_anchor if active_anchor in available_stage_skills else "decision",
2505
2813
  )
2506
2814
 
2507
2815
  def _latest_user_message(self, quest_id: str) -> dict | None:
@@ -2981,23 +3289,67 @@ class DaemonApp:
2981
3289
  self.schedule_turn(quest_id, reason="queued_user_messages")
2982
3290
  else:
2983
3291
  continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
3292
+ if continuation_policy == "auto":
3293
+ continuation_policy, continuation_reason = self._resolve_continuation_policy(
3294
+ snapshot,
3295
+ current_policy=continuation_policy,
3296
+ )
3297
+ self.quest_service.update_runtime_state(
3298
+ quest_root=self.quest_service._quest_root(quest_id),
3299
+ continuation_policy=continuation_policy,
3300
+ continuation_reason=continuation_reason,
3301
+ continuation_updated_at=utc_now(),
3302
+ )
3303
+ snapshot = self.quest_service.snapshot(quest_id)
2984
3304
  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)
3305
+ self._schedule_turn_later(
3306
+ quest_id,
3307
+ reason="auto_continue",
3308
+ delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
3309
+ )
2986
3310
  return
2987
3311
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2988
3312
  self.schedule_turn(quest_id, reason="queued_user_messages")
2989
3313
  return
2990
3314
  continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
3315
+ if continuation_policy == "auto":
3316
+ continuation_policy, continuation_reason = self._resolve_continuation_policy(
3317
+ snapshot,
3318
+ current_policy=continuation_policy,
3319
+ )
3320
+ self.quest_service.update_runtime_state(
3321
+ quest_root=self.quest_service._quest_root(quest_id),
3322
+ continuation_policy=continuation_policy,
3323
+ continuation_reason=continuation_reason,
3324
+ continuation_updated_at=utc_now(),
3325
+ )
3326
+ snapshot = self.quest_service.snapshot(quest_id)
2991
3327
  if continuation_policy == "none":
2992
3328
  return
2993
3329
  if continuation_policy == "wait_for_user_or_resume":
2994
3330
  return
2995
3331
  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:
3332
+ if not self._has_external_progress(snapshot):
3333
+ next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
3334
+ next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
3335
+ self.quest_service.update_runtime_state(
3336
+ quest_root=self.quest_service._quest_root(quest_id),
3337
+ continuation_policy=next_policy,
3338
+ continuation_reason=next_reason,
3339
+ continuation_updated_at=utc_now(),
3340
+ )
3341
+ if next_policy != "wait_for_user_or_resume":
3342
+ self._schedule_turn_later(
3343
+ quest_id,
3344
+ reason="auto_continue",
3345
+ delay_seconds=self._auto_continue_delay_for_policy(next_policy),
3346
+ )
2999
3347
  return
3000
- self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
3348
+ self._schedule_turn_later(
3349
+ quest_id,
3350
+ reason="auto_continue",
3351
+ delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
3352
+ )
3001
3353
 
3002
3354
  def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
3003
3355
  def _delayed() -> None:
@@ -3009,12 +3361,30 @@ class DaemonApp:
3009
3361
  if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
3010
3362
  return
3011
3363
  continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
3364
+ if continuation_policy == "auto":
3365
+ continuation_policy, continuation_reason = self._resolve_continuation_policy(
3366
+ snapshot,
3367
+ current_policy=continuation_policy,
3368
+ )
3369
+ self.quest_service.update_runtime_state(
3370
+ quest_root=self.quest_service._quest_root(quest_id),
3371
+ continuation_policy=continuation_policy,
3372
+ continuation_reason=continuation_reason,
3373
+ continuation_updated_at=utc_now(),
3374
+ )
3375
+ snapshot = self.quest_service.snapshot(quest_id)
3012
3376
  if continuation_policy in {"none", "wait_for_user_or_resume"}:
3013
3377
  return
3014
3378
  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:
3379
+ if not self._has_external_progress(snapshot):
3380
+ next_policy = "wait_for_user_or_resume" if self._workspace_mode_for(snapshot) == "copilot" else "auto"
3381
+ next_reason = "external_progress_finished" if next_policy == "wait_for_user_or_resume" else "external_progress_finished_continue_autonomous"
3382
+ self.quest_service.update_runtime_state(
3383
+ quest_root=self.quest_service._quest_root(quest_id),
3384
+ continuation_policy=next_policy,
3385
+ continuation_reason=next_reason,
3386
+ continuation_updated_at=utc_now(),
3387
+ )
3018
3388
  return
3019
3389
  self.schedule_turn(quest_id, reason=reason)
3020
3390
 
@@ -3024,6 +3394,23 @@ class DaemonApp:
3024
3394
  name=f"deepscientist-turn-delay-{quest_id}",
3025
3395
  ).start()
3026
3396
 
3397
+ def _has_external_progress(self, snapshot: dict) -> bool:
3398
+ if bool(snapshot.get("active_run_id")):
3399
+ return True
3400
+ quest_id = str(snapshot.get("quest_id") or "").strip()
3401
+ if not quest_id:
3402
+ return False
3403
+ try:
3404
+ quest_root = self.quest_service._quest_root(quest_id)
3405
+ except FileNotFoundError:
3406
+ return False
3407
+ try:
3408
+ sessions = self.bash_exec_service.list_sessions(quest_root, limit=200)
3409
+ return any(str(item.get("status") or "").strip().lower() == "running" for item in sessions if isinstance(item, dict))
3410
+ except Exception:
3411
+ counts = snapshot.get("counts") if isinstance(snapshot.get("counts"), dict) else {}
3412
+ return int(counts.get("bash_running_count") or 0) > 0
3413
+
3027
3414
  def _relay_quest_message_to_bound_connectors(
3028
3415
  self,
3029
3416
  quest_id: str,
@@ -4016,8 +4403,8 @@ class DaemonApp:
4016
4403
  "quest_id": target_quest,
4017
4404
  "kind": "ack",
4018
4405
  "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.",
4406
+ zh=f"收到啦!这里已经切到 Quest `{target_quest}` 了,接下来我会直接在这个 {connector_label} 里继续同步进展。",
4407
+ en=f"Got it. This {connector_label} is now on Quest `{target_quest}`, and I’ll keep the next updates here.",
4021
4408
  ),
4022
4409
  }
4023
4410
  )
@@ -5108,13 +5495,13 @@ class DaemonApp:
5108
5495
  channel = self._channel_with_bindings(old_connector)
5109
5496
  if mode == "disconnect":
5110
5497
  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.",
5498
+ zh=f"Quest `{quest_id}` 已经从这里解绑啦,后面会只在本地继续推进。",
5499
+ en=f"Quest `{quest_id}` is no longer bound here. It will continue locally only. 📌",
5113
5500
  )
5114
5501
  else:
5115
5502
  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}.",
5503
+ zh=f"Quest `{quest_id}` 已经从这里切走啦,后面的进展请在 {current_label} 查看。",
5504
+ en=f"Quest `{quest_id}` has moved away from this conversation. Continue from {current_label}. 🔁",
5118
5505
  )
5119
5506
  channel.send(
5120
5507
  {
@@ -5131,13 +5518,13 @@ class DaemonApp:
5131
5518
  channel = self._channel_with_bindings(new_connector)
5132
5519
  if mode == "bind":
5133
5520
  message = self._polite_copy(
5134
- zh=f"当前已绑定 Quest `{quest_id}`。",
5135
- en=f"This conversation is now bound to Quest `{quest_id}`.",
5521
+ zh=f"收到!Quest `{quest_id}` 已经接上啦,后面的进展我都会直接在这里同步给您。",
5522
+ en=f"Quest `{quest_id}` is now connected here, and I’ll keep the next updates in this conversation. ✨",
5136
5523
  )
5137
5524
  elif mode == "switch":
5138
5525
  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}.",
5526
+ zh=f"收到!Quest `{quest_id}` 已经切到这里啦,后面的进展我都会直接在这里同步给您。",
5527
+ en=f"Quest `{quest_id}` has switched over here, and I’ll keep the next updates in this conversation. 🔄",
5141
5528
  )
5142
5529
  else:
5143
5530
  message = ""
@@ -5371,6 +5758,20 @@ class DaemonApp:
5371
5758
  )
5372
5759
  return f"{notice}\n\n{base}"
5373
5760
 
5761
+ def _connector_goal_preview(self, goal: str, *, limit: int = 88) -> str:
5762
+ for raw_line in str(goal or "").replace("\r", "\n").split("\n"):
5763
+ line = re.sub(r"^[#>*\-\d\.)\s]+", "", raw_line).strip()
5764
+ if not line:
5765
+ continue
5766
+ normalized = re.sub(r"\s+", " ", line).strip()
5767
+ if len(normalized) <= limit:
5768
+ return normalized
5769
+ return normalized[: max(0, limit - 3)].rstrip() + "..."
5770
+ return self._polite_copy(
5771
+ zh="我会先把当前任务整理清楚,再继续推进。",
5772
+ en="I will clarify the current task first, then keep moving. ✨",
5773
+ )
5774
+
5374
5775
  def _quest_created_connector_message(
5375
5776
  self,
5376
5777
  connector_name: str,
@@ -5379,29 +5780,29 @@ class DaemonApp:
5379
5780
  goal: str,
5380
5781
  previous_quest_id: str | None = None,
5381
5782
  ) -> str:
5382
- normalized_goal = str(goal or "").strip() or "(未提供具体任务)"
5383
5783
  previous = str(previous_quest_id or "").strip()
5784
+ goal_preview = self._connector_goal_preview(goal)
5384
5785
  restore_zh = (
5385
- f"\n如果需要恢复到原先绑定的 quest,请发送:`/use {previous}`。"
5786
+ f"\n如果想切回原先的 Quest `{previous}`,给我发 `/use {previous}` 就行。"
5386
5787
  if previous and previous != quest_id
5387
5788
  else ""
5388
5789
  )
5389
5790
  restore_en = (
5390
- f"\nIf you need to switch back to the previously bound quest, send: `/use {previous}`."
5791
+ f"\nIf you want to switch back to Quest `{previous}`, send `/use {previous}`. 🔁"
5391
5792
  if previous and previous != quest_id
5392
5793
  else ""
5393
5794
  )
5394
5795
  return self._polite_copy(
5395
5796
  zh=(
5396
- f"老师,已顺利创建新的 quest `{quest_id}`。\n"
5397
- f"我即将为您完成以下任务:{normalized_goal}\n"
5398
- f"当前 {self._connector_label(connector_name)} 会话接下来会自动使用这个新 quest 保持连接。\n"
5797
+ f"开工啦!新的 Quest `{quest_id}` 已经建好啦。\n"
5798
+ f"这轮我先做这件事:{goal_preview}\n"
5799
+ f"后面的进展我都会直接在这里同步给您。"
5399
5800
  )
5400
5801
  + restore_zh,
5401
5802
  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"
5803
+ f"Quest `{quest_id}` is ready, and I’m starting now. 🚀\n"
5804
+ f"Current focus: {goal_preview}\n"
5805
+ f"I’ll keep the next updates right here."
5405
5806
  )
5406
5807
  + restore_en,
5407
5808
  )
@@ -6228,6 +6629,33 @@ class DaemonApp:
6228
6629
  handler.wfile.write(b"\n")
6229
6630
  handler.wfile.flush()
6230
6631
 
6632
+ @staticmethod
6633
+ def _write_handler_response(
6634
+ handler: BaseHTTPRequestHandler,
6635
+ *,
6636
+ code: int,
6637
+ content: bytes,
6638
+ content_type: str | None = None,
6639
+ extra_headers: dict[str, str] | None = None,
6640
+ ) -> bool:
6641
+ try:
6642
+ handler.send_response(code)
6643
+ if content_type:
6644
+ handler.send_header("Content-Type", content_type)
6645
+ handler.send_header("Content-Length", str(len(content)))
6646
+ for key, value in (extra_headers or {}).items():
6647
+ handler.send_header(key, value)
6648
+ handler.end_headers()
6649
+ if content:
6650
+ handler.wfile.write(content)
6651
+ return True
6652
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
6653
+ try:
6654
+ handler.close_connection = True
6655
+ except Exception:
6656
+ pass
6657
+ return False
6658
+
6231
6659
  @staticmethod
6232
6660
  def _parse_bash_log_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
6233
6661
  stripped = raw_line.strip()
@@ -6241,6 +6669,19 @@ class DaemonApp:
6241
6669
  return None
6242
6670
  return payload
6243
6671
 
6672
+ @staticmethod
6673
+ def _parse_quest_event_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
6674
+ stripped = raw_line.strip()
6675
+ if not stripped:
6676
+ return None
6677
+ try:
6678
+ payload = json.loads(stripped.decode("utf-8", errors="replace"))
6679
+ except json.JSONDecodeError:
6680
+ return None
6681
+ if not isinstance(payload, dict):
6682
+ return None
6683
+ return payload
6684
+
6244
6685
  @classmethod
6245
6686
  def _read_bash_log_delta(
6246
6687
  cls,
@@ -6284,6 +6725,42 @@ class DaemonApp:
6284
6725
 
6285
6726
  return fresh_entries, next_offset, remainder
6286
6727
 
6728
+ @classmethod
6729
+ def _read_quest_event_delta(
6730
+ cls,
6731
+ event_path: Path,
6732
+ *,
6733
+ offset: int,
6734
+ pending: bytes,
6735
+ ) -> tuple[list[dict[str, Any]], int, bytes]:
6736
+ if not event_path.exists():
6737
+ return [], 0, pending
6738
+
6739
+ current_size = event_path.stat().st_size
6740
+ safe_offset = max(0, min(offset, current_size))
6741
+ with event_path.open("rb") as handle:
6742
+ handle.seek(safe_offset)
6743
+ chunk = handle.read()
6744
+ next_offset = handle.tell()
6745
+
6746
+ if not chunk:
6747
+ return [], next_offset, pending
6748
+
6749
+ payload = pending + chunk
6750
+ lines = payload.split(b"\n")
6751
+ remainder = b""
6752
+ if payload and not payload.endswith(b"\n"):
6753
+ remainder = lines.pop()
6754
+
6755
+ fresh_entries: list[dict[str, Any]] = []
6756
+ for raw_line in lines:
6757
+ entry = cls._parse_quest_event_jsonl_line(raw_line.rstrip(b"\r"))
6758
+ if not entry:
6759
+ continue
6760
+ fresh_entries.append(entry)
6761
+
6762
+ return fresh_entries, next_offset, remainder
6763
+
6287
6764
  def stream_quest_events(
6288
6765
  self,
6289
6766
  handler: BaseHTTPRequestHandler,
@@ -6291,6 +6768,7 @@ class DaemonApp:
6291
6768
  quest_id: str,
6292
6769
  path: str,
6293
6770
  headers: dict[str, str] | None = None,
6771
+ extra_headers: dict[str, str] | None = None,
6294
6772
  ) -> None:
6295
6773
  query = self.handlers.parse_query(path)
6296
6774
  after = int((query.get("after") or ["0"])[0] or "0")
@@ -6300,16 +6778,23 @@ class DaemonApp:
6300
6778
  last_event_id = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
6301
6779
  current_cursor = max(after, int(last_event_id)) if last_event_id.isdigit() else after
6302
6780
  heartbeat_at = time.monotonic()
6303
- idle_sleep_seconds = 0.35
6781
+ idle_sleep_seconds = 0.08
6304
6782
  force_fetch = True
6305
6783
  event_path = self.quest_service._quest_root(quest_id) / ".ds" / "events.jsonl"
6306
6784
  previous_event_state = None
6785
+ cached_tail = self.quest_service.jsonl_tail_cache_entry(event_path) or {}
6786
+ cached_tail_state = cached_tail.get("state") if isinstance(cached_tail.get("state"), (list, tuple)) else None
6787
+ cached_tail_total = int(cached_tail.get("total") or 0) if isinstance(cached_tail, dict) else 0
6788
+ event_offset = int(cached_tail_state[2]) if cached_tail_state and cached_tail_total == current_cursor else 0
6789
+ pending_bytes = b""
6307
6790
 
6308
6791
  handler.send_response(200)
6309
6792
  handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
6310
6793
  handler.send_header("Cache-Control", "no-cache, no-transform")
6311
6794
  handler.send_header("Connection", "keep-alive")
6312
6795
  handler.send_header("X-Accel-Buffering", "no")
6796
+ for key, value in (extra_headers or {}).items():
6797
+ handler.send_header(key, value)
6313
6798
  handler.end_headers()
6314
6799
  handler.wfile.write(b"retry: 1000\n\n")
6315
6800
  handler.wfile.flush()
@@ -6318,6 +6803,62 @@ class DaemonApp:
6318
6803
  while True:
6319
6804
  current_event_state = self.quest_service._path_state(event_path)
6320
6805
  if force_fetch or current_event_state != previous_event_state:
6806
+ used_incremental_delta = False
6807
+ delta_base_state = previous_event_state or cached_tail_state
6808
+ can_read_incremental = (
6809
+ current_cursor > 0
6810
+ and event_offset > 0
6811
+ and current_event_state is not None
6812
+ and delta_base_state is not None
6813
+ and tuple(delta_base_state)[0] == current_event_state[0]
6814
+ and current_event_state[2] >= int(tuple(delta_base_state)[2])
6815
+ )
6816
+ if can_read_incremental:
6817
+ fresh_events, event_offset, pending_bytes = self._read_quest_event_delta(
6818
+ event_path,
6819
+ offset=event_offset,
6820
+ pending=pending_bytes,
6821
+ )
6822
+ previous_event_state = current_event_state
6823
+ if fresh_events:
6824
+ for event in fresh_events:
6825
+ current_cursor += 1
6826
+ enriched_event = {
6827
+ "cursor": current_cursor,
6828
+ "event_id": event.get("event_id") or f"evt-{quest_id}-{current_cursor}",
6829
+ **event,
6830
+ }
6831
+ update = build_session_update(
6832
+ enriched_event,
6833
+ quest_id=quest_id,
6834
+ cursor=current_cursor,
6835
+ session_id=session_id,
6836
+ )
6837
+ self._write_sse_event(
6838
+ handler,
6839
+ event="acp_update",
6840
+ data=update,
6841
+ event_id=str(current_cursor),
6842
+ )
6843
+ self._write_sse_event(
6844
+ handler,
6845
+ event="cursor",
6846
+ data={"cursor": current_cursor, "quest_id": quest_id},
6847
+ )
6848
+ heartbeat_at = time.monotonic()
6849
+ used_incremental_delta = True
6850
+ force_fetch = False
6851
+ idle_sleep_seconds = 0.03
6852
+ else:
6853
+ force_fetch = False
6854
+ now = time.monotonic()
6855
+ if now - heartbeat_at >= 10:
6856
+ handler.wfile.write(b": keep-alive\n\n")
6857
+ handler.wfile.flush()
6858
+ heartbeat_at = now
6859
+ if used_incremental_delta:
6860
+ time.sleep(idle_sleep_seconds)
6861
+ continue
6321
6862
  stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
6322
6863
  payload = self.handlers.quest_events(quest_id, path=stream_path)
6323
6864
  previous_event_state = current_event_state
@@ -6332,6 +6873,11 @@ class DaemonApp:
6332
6873
  event_id=update_cursor or None,
6333
6874
  )
6334
6875
  current_cursor = int(payload.get("cursor") or current_cursor)
6876
+ if current_event_state is not None and not payload.get("has_more"):
6877
+ event_offset = int(current_event_state[2])
6878
+ pending_bytes = b""
6879
+ cached_tail_state = current_event_state
6880
+ cached_tail_total = current_cursor
6335
6881
  self._write_sse_event(
6336
6882
  handler,
6337
6883
  event="cursor",
@@ -6339,22 +6885,25 @@ class DaemonApp:
6339
6885
  )
6340
6886
  heartbeat_at = time.monotonic()
6341
6887
  force_fetch = bool(payload.get("has_more"))
6342
- idle_sleep_seconds = 0.05 if force_fetch else 0.2
6888
+ idle_sleep_seconds = 0.03 if force_fetch else 0.08
6343
6889
  else:
6890
+ if current_event_state is not None:
6891
+ event_offset = int(current_event_state[2])
6892
+ cached_tail_state = current_event_state
6344
6893
  force_fetch = False
6345
6894
  now = time.monotonic()
6346
6895
  if now - heartbeat_at >= 10:
6347
6896
  handler.wfile.write(b": keep-alive\n\n")
6348
6897
  handler.wfile.flush()
6349
6898
  heartbeat_at = now
6350
- idle_sleep_seconds = min(1.5, idle_sleep_seconds * 1.35)
6899
+ idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
6351
6900
  else:
6352
6901
  now = time.monotonic()
6353
6902
  if now - heartbeat_at >= 10:
6354
6903
  handler.wfile.write(b": keep-alive\n\n")
6355
6904
  handler.wfile.flush()
6356
6905
  heartbeat_at = now
6357
- idle_sleep_seconds = min(1.5, idle_sleep_seconds * 1.35)
6906
+ idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
6358
6907
  time.sleep(idle_sleep_seconds)
6359
6908
  except (BrokenPipeError, ConnectionResetError, TimeoutError):
6360
6909
  return
@@ -6365,6 +6914,7 @@ class DaemonApp:
6365
6914
  *,
6366
6915
  quest_id: str,
6367
6916
  path: str,
6917
+ extra_headers: dict[str, str] | None = None,
6368
6918
  ) -> None:
6369
6919
  quest_root = self.quest_service._quest_root(quest_id)
6370
6920
  query = self.handlers.parse_query(path)
@@ -6401,6 +6951,8 @@ class DaemonApp:
6401
6951
  handler.send_header("Cache-Control", "no-cache, no-transform")
6402
6952
  handler.send_header("Connection", "keep-alive")
6403
6953
  handler.send_header("X-Accel-Buffering", "no")
6954
+ for key, value in (extra_headers or {}).items():
6955
+ handler.send_header(key, value)
6404
6956
  handler.end_headers()
6405
6957
  handler.wfile.write(b"retry: 1000\n\n")
6406
6958
  handler.wfile.flush()
@@ -6486,6 +7038,7 @@ class DaemonApp:
6486
7038
  quest_id: str,
6487
7039
  bash_id: str,
6488
7040
  headers: dict[str, str] | None = None,
7041
+ extra_headers: dict[str, str] | None = None,
6489
7042
  ) -> None:
6490
7043
  quest_root = self.quest_service._quest_root(quest_id)
6491
7044
  last_event_raw = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
@@ -6496,6 +7049,8 @@ class DaemonApp:
6496
7049
  handler.send_header("Cache-Control", "no-cache, no-transform")
6497
7050
  handler.send_header("Connection", "keep-alive")
6498
7051
  handler.send_header("X-Accel-Buffering", "no")
7052
+ for key, value in (extra_headers or {}).items():
7053
+ handler.send_header(key, value)
6499
7054
  handler.end_headers()
6500
7055
  handler.wfile.write(b"retry: 1000\n\n")
6501
7056
  handler.wfile.flush()
@@ -6696,35 +7251,84 @@ class DaemonApp:
6696
7251
  if route_name is None:
6697
7252
  self._write_json(404, {"ok": False, "message": "Not Found"})
6698
7253
  return
6699
- if route_name == "quest_events" and app._wants_event_stream(self.path, dict(self.headers.items())):
7254
+ request_headers = dict(self.headers.items())
7255
+ auth_state = app.browser_auth_state_for_request(self.path, request_headers)
7256
+ auth_headers = app._auth_response_headers(auth_state)
7257
+ if app._route_requires_browser_auth(route_name) and not auth_state.authenticated:
7258
+ self._write_json(
7259
+ 401,
7260
+ {
7261
+ "ok": False,
7262
+ "message": "Authentication required.",
7263
+ "auth_required": True,
7264
+ "auth_enabled": True,
7265
+ },
7266
+ extra_headers={
7267
+ **auth_headers,
7268
+ "WWW-Authenticate": f'Bearer realm="{_BROWSER_AUTH_REALM}"',
7269
+ "Cache-Control": "no-store, max-age=0, must-revalidate",
7270
+ },
7271
+ )
7272
+ return
7273
+ if route_name == "quest_events" and app._wants_event_stream(self.path, request_headers):
6700
7274
  try:
6701
- app.stream_quest_events(self, **params, path=self.path, headers=dict(self.headers.items()))
7275
+ app.stream_quest_events(self, **params, path=self.path, headers=request_headers, extra_headers=auth_headers)
6702
7276
  except Exception as exc:
6703
- self._write_json(500, {"ok": False, "message": str(exc)})
7277
+ app.logger.log(
7278
+ "error",
7279
+ "http.stream_quest_events_failed",
7280
+ path=self.path,
7281
+ error=str(exc),
7282
+ )
7283
+ self.close_connection = True
6704
7284
  return
6705
7285
  if route_name == "bash_sessions_stream":
6706
7286
  try:
6707
- app.stream_bash_sessions(self, **params, path=self.path)
7287
+ app.stream_bash_sessions(self, **params, path=self.path, extra_headers=auth_headers)
6708
7288
  except Exception as exc:
6709
- self._write_json(500, {"ok": False, "message": str(exc)})
7289
+ app.logger.log(
7290
+ "error",
7291
+ "http.stream_bash_sessions_failed",
7292
+ path=self.path,
7293
+ error=str(exc),
7294
+ )
7295
+ self.close_connection = True
6710
7296
  return
6711
7297
  if route_name == "bash_log_stream":
6712
7298
  try:
6713
- app.stream_bash_logs(self, **params, headers=dict(self.headers.items()))
7299
+ app.stream_bash_logs(self, **params, headers=request_headers, extra_headers=auth_headers)
6714
7300
  except Exception as exc:
6715
- self._write_json(500, {"ok": False, "message": str(exc)})
7301
+ app.logger.log(
7302
+ "error",
7303
+ "http.stream_bash_logs_failed",
7304
+ path=self.path,
7305
+ error=str(exc),
7306
+ )
7307
+ self.close_connection = True
6716
7308
  return
6717
7309
  if route_name == "terminal_stream":
6718
7310
  try:
6719
- app.stream_bash_logs(self, quest_id=params["quest_id"], bash_id=params["session_id"], headers=dict(self.headers.items()))
7311
+ app.stream_bash_logs(
7312
+ self,
7313
+ quest_id=params["quest_id"],
7314
+ bash_id=params["session_id"],
7315
+ headers=request_headers,
7316
+ extra_headers=auth_headers,
7317
+ )
6720
7318
  except Exception as exc:
6721
- self._write_json(500, {"ok": False, "message": str(exc)})
7319
+ app.logger.log(
7320
+ "error",
7321
+ "http.stream_terminal_logs_failed",
7322
+ path=self.path,
7323
+ error=str(exc),
7324
+ )
7325
+ self.close_connection = True
6722
7326
  return
6723
7327
  if route_name == "lingzhu_sse":
6724
7328
  content_length = int(self.headers.get("Content-Length", "0"))
6725
7329
  raw_body = self.rfile.read(content_length) if content_length else b""
6726
7330
  try:
6727
- app.stream_lingzhu_sse(self, raw_body=raw_body, headers=dict(self.headers.items()))
7331
+ app.stream_lingzhu_sse(self, raw_body=raw_body, headers=request_headers)
6728
7332
  except Exception as exc:
6729
7333
  self._write_json(500, {"ok": False, "message": str(exc)})
6730
7334
  return
@@ -6739,11 +7343,12 @@ class DaemonApp:
6739
7343
  result = getattr(app.handlers, route_name)
6740
7344
  if route_name == "asset":
6741
7345
  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)
7346
+ app._write_handler_response(
7347
+ self,
7348
+ code=status,
7349
+ content=content,
7350
+ extra_headers=app._merge_response_headers(headers, auth_headers),
7351
+ )
6747
7352
  return
6748
7353
  if route_name in {
6749
7354
  "quest_events",
@@ -6770,7 +7375,7 @@ class DaemonApp:
6770
7375
  payload = result(**params, path=self.path)
6771
7376
  elif method == "GET":
6772
7377
  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"}:
7378
+ 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", "auth_login", "auth_rotate"}:
6774
7379
  payload = result(**params, body=body)
6775
7380
  elif route_name == "config_validate":
6776
7381
  payload = result(body)
@@ -6783,33 +7388,43 @@ class DaemonApp:
6783
7388
  else:
6784
7389
  payload = result(**params) if params else result()
6785
7390
  except Exception as exc:
6786
- self._write_json(500, {"ok": False, "message": str(exc)})
7391
+ self._write_json(500, {"ok": False, "message": str(exc)}, extra_headers=auth_headers)
6787
7392
  return
6788
7393
 
6789
7394
  if isinstance(payload, tuple) and len(payload) == 2:
6790
7395
  status, body = payload
6791
- self._write_json(status, body)
7396
+ self._write_json(status, body, extra_headers=auth_headers)
6792
7397
  return
6793
7398
  if isinstance(payload, tuple) and len(payload) == 3:
6794
7399
  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
7400
  if isinstance(content, str):
6800
- self.wfile.write(content.encode("utf-8"))
7401
+ encoded = content.encode("utf-8")
6801
7402
  else:
6802
- self.wfile.write(content)
7403
+ encoded = content
7404
+ app._write_handler_response(
7405
+ self,
7406
+ code=status,
7407
+ content=encoded,
7408
+ extra_headers=app._merge_response_headers(headers, auth_headers),
7409
+ )
6803
7410
  return
6804
- self._write_json(200, payload)
6805
-
6806
- def _write_json(self, code: int, payload: dict | list) -> None:
7411
+ self._write_json(200, payload, extra_headers=auth_headers)
7412
+
7413
+ def _write_json(
7414
+ self,
7415
+ code: int,
7416
+ payload: dict | list,
7417
+ *,
7418
+ extra_headers: dict[str, str] | None = None,
7419
+ ) -> None:
6807
7420
  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)
7421
+ app._write_handler_response(
7422
+ self,
7423
+ code=code,
7424
+ content=encoded,
7425
+ content_type="application/json; charset=utf-8",
7426
+ extra_headers=extra_headers,
7427
+ )
6813
7428
 
6814
7429
  server = ThreadingHTTPServer((host, port), RequestHandler)
6815
7430
  server.daemon_threads = True
@@ -6821,6 +7436,8 @@ class DaemonApp:
6821
7436
  self._start_background_connectors()
6822
7437
  self._resume_reconciled_quests()
6823
7438
  print(f"DeepScientist daemon listening on http://{host}:{port}")
7439
+ if self.browser_auth_enabled and self.browser_auth_token:
7440
+ print(f"DeepScientist auth token: {self.browser_auth_token}")
6824
7441
  try:
6825
7442
  server.serve_forever()
6826
7443
  except KeyboardInterrupt: