@researai/deepscientist 1.5.14 → 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 (225) hide show
  1. package/README.md +336 -90
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +816 -131
  4. package/docs/en/00_QUICK_START.md +36 -15
  5. package/docs/en/01_SETTINGS_REFERENCE.md +53 -4
  6. package/docs/en/02_START_RESEARCH_GUIDE.md +7 -0
  7. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  8. package/docs/en/05_TUI_GUIDE.md +6 -0
  9. package/docs/en/06_RUNTIME_AND_CANVAS.md +4 -3
  10. package/docs/en/09_DOCTOR.md +11 -5
  11. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  12. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
  13. package/docs/en/15_CODEX_PROVIDER_SETUP.md +25 -8
  14. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  15. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  16. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  17. package/docs/en/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  18. package/docs/en/19_LOCAL_BROWSER_AUTH.md +70 -0
  19. package/docs/en/20_WORKSPACE_MODES_GUIDE.md +250 -0
  20. package/docs/en/README.md +24 -0
  21. package/docs/zh/00_QUICK_START.md +36 -15
  22. package/docs/zh/01_SETTINGS_REFERENCE.md +53 -4
  23. package/docs/zh/02_START_RESEARCH_GUIDE.md +7 -0
  24. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  25. package/docs/zh/05_TUI_GUIDE.md +6 -0
  26. package/docs/zh/09_DOCTOR.md +11 -5
  27. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  28. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +65 -13
  29. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +25 -8
  30. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  31. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  32. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  33. package/docs/zh/19_EXTERNAL_CONTROLLER_GUIDE.md +226 -0
  34. package/docs/zh/19_LOCAL_BROWSER_AUTH.md +68 -0
  35. package/docs/zh/20_WORKSPACE_MODES_GUIDE.md +251 -0
  36. package/docs/zh/README.md +24 -0
  37. package/install.sh +2 -0
  38. package/package.json +1 -1
  39. package/pyproject.toml +1 -1
  40. package/src/deepscientist/__init__.py +1 -1
  41. package/src/deepscientist/acp/envelope.py +6 -0
  42. package/src/deepscientist/artifact/charts.py +567 -0
  43. package/src/deepscientist/artifact/guidance.py +50 -10
  44. package/src/deepscientist/artifact/metrics.py +228 -5
  45. package/src/deepscientist/artifact/schemas.py +3 -0
  46. package/src/deepscientist/artifact/service.py +4276 -308
  47. package/src/deepscientist/bash_exec/models.py +23 -0
  48. package/src/deepscientist/bash_exec/monitor.py +147 -67
  49. package/src/deepscientist/bash_exec/runtime.py +218 -156
  50. package/src/deepscientist/bash_exec/service.py +309 -69
  51. package/src/deepscientist/bash_exec/shells.py +87 -0
  52. package/src/deepscientist/bridges/connectors.py +51 -2
  53. package/src/deepscientist/cli.py +115 -19
  54. package/src/deepscientist/codex_cli_compat.py +232 -0
  55. package/src/deepscientist/config/models.py +8 -4
  56. package/src/deepscientist/config/service.py +38 -11
  57. package/src/deepscientist/connector/weixin_support.py +122 -1
  58. package/src/deepscientist/daemon/api/handlers.py +199 -9
  59. package/src/deepscientist/daemon/api/router.py +5 -0
  60. package/src/deepscientist/daemon/app.py +1458 -289
  61. package/src/deepscientist/doctor.py +51 -0
  62. package/src/deepscientist/file_lock.py +48 -0
  63. package/src/deepscientist/gitops/__init__.py +10 -1
  64. package/src/deepscientist/gitops/diff.py +296 -1
  65. package/src/deepscientist/gitops/service.py +4 -1
  66. package/src/deepscientist/mcp/server.py +212 -5
  67. package/src/deepscientist/process_control.py +161 -0
  68. package/src/deepscientist/prompts/builder.py +501 -453
  69. package/src/deepscientist/quest/layout.py +15 -2
  70. package/src/deepscientist/quest/service.py +2539 -195
  71. package/src/deepscientist/quest/stage_views.py +177 -1
  72. package/src/deepscientist/runners/base.py +2 -0
  73. package/src/deepscientist/runners/codex.py +169 -31
  74. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  75. package/src/deepscientist/skills/__init__.py +2 -2
  76. package/src/deepscientist/skills/installer.py +196 -5
  77. package/src/deepscientist/skills/registry.py +66 -0
  78. package/src/prompts/connectors/qq.md +18 -8
  79. package/src/prompts/connectors/weixin.md +16 -6
  80. package/src/prompts/contracts/shared_interaction.md +24 -4
  81. package/src/prompts/system.md +921 -72
  82. package/src/prompts/system_copilot.md +43 -0
  83. package/src/skills/analysis-campaign/SKILL.md +32 -2
  84. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  85. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  86. package/src/skills/baseline/SKILL.md +10 -0
  87. package/src/skills/decision/SKILL.md +27 -2
  88. package/src/skills/experiment/SKILL.md +16 -2
  89. package/src/skills/figure-polish/SKILL.md +1 -0
  90. package/src/skills/finalize/SKILL.md +19 -0
  91. package/src/skills/idea/SKILL.md +79 -0
  92. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  93. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  94. package/src/skills/intake-audit/SKILL.md +9 -1
  95. package/src/skills/mentor/SKILL.md +217 -0
  96. package/src/skills/mentor/references/correction-rules.md +210 -0
  97. package/src/skills/mentor/references/knowledge-profile.md +91 -0
  98. package/src/skills/mentor/references/persona-profile.md +138 -0
  99. package/src/skills/mentor/references/taste-profile.md +128 -0
  100. package/src/skills/mentor/references/thought-style-profile.md +138 -0
  101. package/src/skills/mentor/references/work-profile.md +289 -0
  102. package/src/skills/mentor/references/workflow-profile.md +240 -0
  103. package/src/skills/optimize/SKILL.md +1645 -0
  104. package/src/skills/rebuttal/SKILL.md +3 -1
  105. package/src/skills/review/SKILL.md +3 -1
  106. package/src/skills/scout/SKILL.md +8 -0
  107. package/src/skills/write/SKILL.md +81 -12
  108. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  109. package/src/tui/dist/app/AppContainer.js +22 -11
  110. package/src/tui/dist/index.js +4 -1
  111. package/src/tui/dist/lib/api.js +33 -3
  112. package/src/tui/package.json +1 -1
  113. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +204 -0
  114. package/src/ui/dist/assets/AnalysisPlugin-DnSm0GZn.js +1 -0
  115. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +109 -0
  116. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +2 -0
  117. package/src/ui/dist/assets/CodeViewerPlugin-itb0tltR.js +270 -0
  118. package/src/ui/dist/assets/DocViewerPlugin-DqKkiCI6.js +7 -0
  119. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +1 -0
  120. package/src/ui/dist/assets/GitDiffViewerPlugin-DxL2ezFG.js +6 -0
  121. package/src/ui/dist/assets/GitSnapshotViewer-B_RQm1YZ.js +30 -0
  122. package/src/ui/dist/assets/ImageViewerPlugin-tHqlXY3n.js +26 -0
  123. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +14 -0
  124. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +22 -0
  125. package/src/ui/dist/assets/LatexPlugin-B495DTXC.js +25 -0
  126. package/src/ui/dist/assets/MarkdownViewerPlugin-DG28-61B.js +128 -0
  127. package/src/ui/dist/assets/MarketplacePlugin-BiOGT-Kj.js +13 -0
  128. package/src/ui/dist/assets/{NotebookEditor-CccQYZjX.css → NotebookEditor-BHH8rdGj.css} +1 -1
  129. package/src/ui/dist/assets/NotebookEditor-BOr3x3Ej.css +1 -0
  130. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +81 -0
  131. package/src/ui/dist/assets/NotebookEditor-CVsj8h_T.js +361 -0
  132. package/src/ui/dist/assets/PdfLoader-CASDQmxJ.js +16 -0
  133. package/src/ui/dist/assets/PdfLoader-Cy5jtWrr.css +1 -0
  134. package/src/ui/dist/assets/PdfMarkdownPlugin-BFhwoKsY.js +1 -0
  135. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +17 -0
  136. package/src/ui/dist/assets/PdfViewerPlugin-nwwE-fjJ.css +1 -0
  137. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +16 -0
  138. package/src/ui/dist/assets/SearchPlugin-DA4en4hK.css +1 -0
  139. package/src/ui/dist/assets/TextViewerPlugin-CB4DYfWO.js +54 -0
  140. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +11 -0
  141. package/src/ui/dist/assets/bot-CFkZY-JP.js +6 -0
  142. package/src/ui/dist/assets/browser-CTB2jwNe.js +8 -0
  143. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +6 -0
  144. package/src/ui/dist/assets/code-DLC6G24T.js +6 -0
  145. package/src/ui/dist/assets/file-content-Dv4LoZec.js +1 -0
  146. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +1 -0
  147. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +1 -0
  148. package/src/ui/dist/assets/file-socket-Cu4Qln7Y.js +1 -0
  149. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +6 -0
  150. package/src/ui/dist/assets/image-B9HUUddG.js +6 -0
  151. package/src/ui/dist/assets/index-B2B1sg-M.js +1 -0
  152. package/src/ui/dist/assets/index-Cgla8biy.css +33 -0
  153. package/src/ui/dist/assets/index-DRyx7vAc.js +1 -0
  154. package/src/ui/dist/assets/index-Gbl53BNp.js +2496 -0
  155. package/src/ui/dist/assets/index-wQ7RIIRd.js +11 -0
  156. package/src/ui/dist/assets/monaco-CiHMMNH_.js +1 -0
  157. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +6 -0
  158. package/src/ui/dist/assets/plugin-monaco-C8UgLomw.js +19 -0
  159. package/src/ui/dist/assets/plugin-notebook-HbW2K-1c.js +169 -0
  160. package/src/ui/dist/assets/plugin-pdf-CR8hgQBV.js +357 -0
  161. package/src/ui/dist/assets/plugin-terminal-MXFIPun8.js +227 -0
  162. package/src/ui/dist/assets/popover-DL6h35vr.js +1 -0
  163. package/src/ui/dist/assets/project-sync-CsX08Qno.js +1 -0
  164. package/src/ui/dist/assets/select-DvmXt1yY.js +11 -0
  165. package/src/ui/dist/assets/sigma-7jpXazui.js +6 -0
  166. package/src/ui/dist/assets/trash-xA7kFt8i.js +11 -0
  167. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +1 -0
  168. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +1 -0
  169. package/src/ui/dist/assets/wrap-text-CwMn-iqb.js +11 -0
  170. package/src/ui/dist/assets/zoom-out-R-GWEhzS.js +11 -0
  171. package/src/ui/dist/index.html +5 -2
  172. package/src/ui/dist/assets/AiManusChatView-DaF9Nge_.js +0 -26597
  173. package/src/ui/dist/assets/AnalysisPlugin-BSVx6dXE.js +0 -123
  174. package/src/ui/dist/assets/CliPlugin-C9gzJX41.js +0 -5905
  175. package/src/ui/dist/assets/CodeEditorPlugin-DU9G0Tox.js +0 -427
  176. package/src/ui/dist/assets/CodeViewerPlugin-DoX_fI9l.js +0 -905
  177. package/src/ui/dist/assets/DocViewerPlugin-C4FWIXuU.js +0 -278
  178. package/src/ui/dist/assets/GitDiffViewerPlugin-BgfFMgtf.js +0 -2661
  179. package/src/ui/dist/assets/ImageViewerPlugin-tcPkfY_x.js +0 -500
  180. package/src/ui/dist/assets/LabCopilotPanel-_dKV60Bf.js +0 -4104
  181. package/src/ui/dist/assets/LabPlugin-Bje0ayoC.js +0 -2677
  182. package/src/ui/dist/assets/LatexPlugin-CVsBzAln.js +0 -1792
  183. package/src/ui/dist/assets/MarkdownViewerPlugin-xjmrqv_8.js +0 -308
  184. package/src/ui/dist/assets/MarketplacePlugin-mMM2A8wP.js +0 -413
  185. package/src/ui/dist/assets/NotebookEditor-3kVDSOBo.js +0 -4214
  186. package/src/ui/dist/assets/NotebookEditor-C3VQ7ylN.css +0 -1405
  187. package/src/ui/dist/assets/NotebookEditor-SoJ8X-MO.js +0 -84873
  188. package/src/ui/dist/assets/PdfLoader-C-Y707R3.css +0 -49
  189. package/src/ui/dist/assets/PdfLoader-DElVuHl9.js +0 -25468
  190. package/src/ui/dist/assets/PdfMarkdownPlugin-Bq88XT4G.js +0 -409
  191. package/src/ui/dist/assets/PdfViewerPlugin-CsCXMo9S.js +0 -3095
  192. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +0 -3627
  193. package/src/ui/dist/assets/SearchPlugin-DDMrGDkh.css +0 -379
  194. package/src/ui/dist/assets/SearchPlugin-oUPvy19k.js +0 -741
  195. package/src/ui/dist/assets/TextViewerPlugin-CRkT9yNy.js +0 -472
  196. package/src/ui/dist/assets/VNCViewer-BgbuvWhR.js +0 -18821
  197. package/src/ui/dist/assets/awareness-C0NPR2Dj.js +0 -292
  198. package/src/ui/dist/assets/bot-v_RASACv.js +0 -21
  199. package/src/ui/dist/assets/browser-BAcuE0Xj.js +0 -2895
  200. package/src/ui/dist/assets/code-5hC9d0VH.js +0 -17
  201. package/src/ui/dist/assets/file-content-D1PxfOrp.js +0 -377
  202. package/src/ui/dist/assets/file-diff-panel-DG1oT_Hj.js +0 -92
  203. package/src/ui/dist/assets/file-jump-queue-r5XKgJEV.js +0 -16
  204. package/src/ui/dist/assets/file-socket-BmdFYQlk.js +0 -58
  205. package/src/ui/dist/assets/function-B5QZkkHC.js +0 -1895
  206. package/src/ui/dist/assets/image-Dqe2X2tW.js +0 -18
  207. package/src/ui/dist/assets/index-BQG-1s2o.css +0 -12553
  208. package/src/ui/dist/assets/index-DVsMKK_y.js +0 -25
  209. package/src/ui/dist/assets/index-Duvz8Ip0.js +0 -159
  210. package/src/ui/dist/assets/index-Nt9hS4ck.js +0 -244829
  211. package/src/ui/dist/assets/index-RDlNXXx1.js +0 -120
  212. package/src/ui/dist/assets/monaco-DIXge1CP.js +0 -623
  213. package/src/ui/dist/assets/pdf-effect-queue-BBTTQaO-.js +0 -47
  214. package/src/ui/dist/assets/pdf_viewer-e0g1is2C.js +0 -8206
  215. package/src/ui/dist/assets/popover-BWlolyxo.js +0 -476
  216. package/src/ui/dist/assets/project-sync-BM5PkFH4.js +0 -297
  217. package/src/ui/dist/assets/select-D4dAtrA8.js +0 -1690
  218. package/src/ui/dist/assets/sigma-CKbE5jJT.js +0 -22
  219. package/src/ui/dist/assets/square-check-big-CZNGMgiB.js +0 -17
  220. package/src/ui/dist/assets/trash-DaB37xAz.js +0 -32
  221. package/src/ui/dist/assets/useCliAccess-C2OmAcWe.js +0 -957
  222. package/src/ui/dist/assets/useFileDiffOverlay-Dowd1Ij4.js +0 -53
  223. package/src/ui/dist/assets/wrap-text-BGjAhAUq.js +0 -35
  224. package/src/ui/dist/assets/yjs-DncrqiZ8.js +0 -11243
  225. package/src/ui/dist/assets/zoom-out-dMZQMXzc.js +0 -34
@@ -2,11 +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
7
+ import hashlib
8
+ import hmac
6
9
  import json
7
10
  import mimetypes
8
11
  import os
9
12
  import re
13
+ import secrets
10
14
  import signal
11
15
  import shutil
12
16
  import subprocess
@@ -15,6 +19,7 @@ import threading
15
19
  import time
16
20
  import traceback
17
21
  from datetime import UTC, datetime, timedelta
22
+ from http.cookies import SimpleCookie
18
23
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
24
  from pathlib import Path
20
25
  from typing import Any
@@ -23,9 +28,11 @@ from urllib.request import Request
23
28
 
24
29
  from .. import __version__
25
30
  from ..annotations import AnnotationService
31
+ from ..acp import build_session_update
26
32
  from ..artifact import ArtifactService
27
33
  from ..bash_exec import BashExecService
28
- from ..bash_exec.runtime import TerminalClient
34
+ from ..bash_exec.models import TerminalClient
35
+ from ..bash_exec.service import DEFAULT_TERMINAL_SESSION_ID
29
36
  from ..bridges import register_builtin_connector_bridges
30
37
  from ..bridges.connectors import QQConnectorBridge
31
38
  from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
@@ -68,7 +75,7 @@ from ..connector.lingzhu_support import (
68
75
  lingzhu_verify_auth_header,
69
76
  )
70
77
  from ..prompts import PromptBuilder
71
- from ..prompts.builder import STANDARD_SKILLS
78
+ from ..prompts.builder import classify_turn_intent, current_standard_skills
72
79
  from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
73
80
  from ..quest import QuestService
74
81
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
@@ -79,9 +86,11 @@ from ..team import SingleTeamService
79
86
  from ..connector.weixin_support import (
80
87
  DEFAULT_WEIXIN_BOT_TYPE,
81
88
  fetch_weixin_qrcode,
89
+ get_weixin_replay_cursor,
82
90
  normalize_weixin_base_url,
83
91
  normalize_weixin_cdn_base_url,
84
92
  poll_weixin_qrcode_status,
93
+ update_weixin_replay_cursor,
85
94
  )
86
95
  from .api import ApiHandlers, match_route
87
96
  from .sessions import SessionStore
@@ -93,7 +102,9 @@ from websockets.sync.server import Server as WebSocketServer
93
102
  from websockets.sync.server import ServerConnection, serve as websocket_serve
94
103
 
95
104
  TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
96
- _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
97
108
  CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
98
109
  CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
99
110
  CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
@@ -138,13 +149,42 @@ _LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
138
149
  "恢复": "resume",
139
150
  }
140
151
  _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新", "最新的"}
152
+ _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT = 5
153
+ _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT = 2.0
141
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
169
+
170
+
171
+ def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
172
+ if os.name == "nt" and hasattr(subprocess, "CREATE_NO_WINDOW"):
173
+ return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW")}
174
+ return {}
142
175
 
143
176
 
144
177
  class DaemonApp:
145
178
  _MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
146
179
 
147
- 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:
148
188
  self.home = home.resolve()
149
189
  self.daemon_id = str(os.environ.get("DS_DAEMON_ID") or "").strip() or generate_id("daemon")
150
190
  self.daemon_managed_by = str(os.environ.get("DS_DAEMON_MANAGED_BY") or "manual").strip() or "manual"
@@ -180,7 +220,11 @@ class DaemonApp:
180
220
  abandoned_run_id=item.get("abandoned_run_id"),
181
221
  status=item.get("status"),
182
222
  )
183
- 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
+ )
184
228
  self.codex_runner = CodexRunner(
185
229
  home=home,
186
230
  repo_root=self.repo_root,
@@ -200,6 +244,8 @@ class DaemonApp:
200
244
  self._canonicalize_lingzhu_binding_state()
201
245
  self._turn_lock = threading.Lock()
202
246
  self._turn_state: dict[str, dict[str, object]] = {}
247
+ self._terminal_prewarm_lock = threading.Lock()
248
+ self._terminal_prewarm_recent: dict[str, float] = {}
203
249
  self._server: ThreadingHTTPServer | None = None
204
250
  self._terminal_attach_server: WebSocketServer | None = None
205
251
  self._terminal_attach_thread: threading.Thread | None = None
@@ -219,8 +265,190 @@ class DaemonApp:
219
265
  self._process_hooks_installed = False
220
266
  self._faulthandler_stream = None
221
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
222
286
  self.handlers = ApiHandlers(self)
223
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
+
224
452
  def list_connector_statuses(self) -> list[dict[str, object]]:
225
453
  title_by_quest = self._quest_titles_by_id()
226
454
  items = [
@@ -450,6 +678,14 @@ class DaemonApp:
450
678
  ensure_dir(faulthandler_path.parent)
451
679
  self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
452
680
  faulthandler.enable(file=self._faulthandler_stream)
681
+ dump_signal = getattr(signal, "SIGUSR1", None)
682
+ if dump_signal is not None:
683
+ faulthandler.register(
684
+ dump_signal,
685
+ file=self._faulthandler_stream,
686
+ all_threads=True,
687
+ chain=False,
688
+ )
453
689
  except Exception as exc:
454
690
  self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
455
691
 
@@ -716,6 +952,7 @@ class DaemonApp:
716
952
  timeout=8,
717
953
  check=False,
718
954
  env=os.environ.copy(),
955
+ **_windows_hidden_subprocess_kwargs(),
719
956
  )
720
957
  except subprocess.TimeoutExpired as exc:
721
958
  raise RuntimeError("DeepScientist update check timed out.") from exc
@@ -763,6 +1000,7 @@ class DaemonApp:
763
1000
  timeout=8,
764
1001
  check=False,
765
1002
  env=os.environ.copy(),
1003
+ **_windows_hidden_subprocess_kwargs(),
766
1004
  )
767
1005
  except subprocess.TimeoutExpired as exc:
768
1006
  raise RuntimeError("DeepScientist update request timed out.") from exc
@@ -1290,6 +1528,7 @@ class DaemonApp:
1290
1528
  client_message_id=client_message_id,
1291
1529
  )
1292
1530
  snapshot = self.quest_service.snapshot(quest_id)
1531
+ snapshot = self._reconcile_stale_active_turn(quest_id, snapshot=snapshot)
1293
1532
  runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip()
1294
1533
  auto_resumed = previous_status in {"stopped", "paused", "completed"} and runtime_status not in {"stopped", "paused", "completed"}
1295
1534
  if auto_resumed:
@@ -1302,9 +1541,8 @@ class DaemonApp:
1302
1541
  summary=f"Quest {quest_id} automatically resumed after a new user message.",
1303
1542
  automated=True,
1304
1543
  )
1305
- with self._turn_lock:
1306
- turn_state = dict(self._turn_state.get(quest_id) or {})
1307
- has_live_turn = bool(turn_state.get("running")) or bool(snapshot.get("active_run_id"))
1544
+ turn_state = self._refresh_turn_worker_state(quest_id)
1545
+ has_live_turn = bool(turn_state.get("running"))
1308
1546
  if runtime_status == "running" and has_live_turn:
1309
1547
  scheduled = {
1310
1548
  "scheduled": True,
@@ -1474,6 +1712,7 @@ class DaemonApp:
1474
1712
  return snapshot
1475
1713
 
1476
1714
  def schedule_turn(self, quest_id: str, *, reason: str = "user_message") -> dict:
1715
+ self._refresh_turn_worker_state(quest_id)
1477
1716
  with self._turn_lock:
1478
1717
  state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1479
1718
  state["pending"] = True
@@ -1502,6 +1741,120 @@ class DaemonApp:
1502
1741
  "reason": reason,
1503
1742
  }
1504
1743
 
1744
+ @staticmethod
1745
+ def _turn_worker_is_alive(worker: object) -> bool:
1746
+ return isinstance(worker, threading.Thread) and worker.is_alive()
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
+
1799
+ def _refresh_turn_worker_state(self, quest_id: str) -> dict[str, object]:
1800
+ with self._turn_lock:
1801
+ state = self._turn_state.setdefault(quest_id, {"running": False, "pending": False})
1802
+ if bool(state.get("running")) and not self._turn_worker_is_alive(state.get("worker")):
1803
+ state["running"] = False
1804
+ state.pop("worker", None)
1805
+ return dict(state)
1806
+
1807
+ def _reconcile_stale_active_turn(self, quest_id: str, *, snapshot: dict | None = None) -> dict:
1808
+ snapshot = dict(snapshot or self.quest_service.snapshot(quest_id))
1809
+ active_run_id = str(snapshot.get("active_run_id") or "").strip()
1810
+ if not active_run_id:
1811
+ self._refresh_turn_worker_state(quest_id)
1812
+ return snapshot
1813
+ turn_state = self._refresh_turn_worker_state(quest_id)
1814
+ if turn_state.get("running"):
1815
+ return snapshot
1816
+
1817
+ quest_root = self.quest_service._quest_root(quest_id)
1818
+ result_payload = read_json(quest_root / ".ds" / "runs" / active_run_id / "result.json", {})
1819
+ completed_at = str(result_payload.get("completed_at") or "").strip() if isinstance(result_payload, dict) else ""
1820
+ exit_code = result_payload.get("exit_code") if isinstance(result_payload, dict) else None
1821
+ previous_status = (
1822
+ str(snapshot.get("runtime_status") or snapshot.get("status") or snapshot.get("display_status") or "running").strip()
1823
+ or "running"
1824
+ )
1825
+ normalized_status = "active" if previous_status == "running" else previous_status
1826
+ summary = (
1827
+ f"Cleared stale active turn state for run `{active_run_id}` after no live worker was found."
1828
+ if not completed_at
1829
+ else f"Cleared stale active turn state for completed run `{active_run_id}`."
1830
+ )
1831
+ append_jsonl(
1832
+ quest_root / ".ds" / "events.jsonl",
1833
+ {
1834
+ "event_id": generate_id("evt"),
1835
+ "type": "quest.turn_state_reconciled",
1836
+ "quest_id": quest_id,
1837
+ "abandoned_run_id": active_run_id,
1838
+ "previous_status": previous_status,
1839
+ "status": normalized_status,
1840
+ "completed_at": completed_at or None,
1841
+ "exit_code": exit_code if isinstance(exit_code, int) else None,
1842
+ "summary": summary,
1843
+ "created_at": utc_now(),
1844
+ },
1845
+ )
1846
+ self.logger.log(
1847
+ "warning",
1848
+ "quest.turn_state_reconciled",
1849
+ quest_id=quest_id,
1850
+ abandoned_run_id=active_run_id,
1851
+ previous_status=previous_status,
1852
+ status=normalized_status,
1853
+ completed_at=completed_at or None,
1854
+ exit_code=exit_code if isinstance(exit_code, int) else None,
1855
+ )
1856
+ return self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
1857
+
1505
1858
  def control_quest(self, quest_id: str, *, action: str, source: str = "local") -> dict:
1506
1859
  normalized_action = str(action or "").strip().lower()
1507
1860
  if normalized_action == "pause":
@@ -1544,7 +1897,7 @@ class DaemonApp:
1544
1897
  reason=f"quest_{action}",
1545
1898
  user_id=source,
1546
1899
  )
1547
- if action == "stop":
1900
+ if action == "stop" and source == "local-admin":
1548
1901
  cancel_reason = "cancelled_by_daemon_shutdown" if source == "local-admin" else "cancelled_by_stop"
1549
1902
  cancelled_pending = self.quest_service.cancel_pending_user_messages(
1550
1903
  quest_id,
@@ -1640,6 +1993,23 @@ class DaemonApp:
1640
1993
  snapshot = self.quest_service.snapshot(quest_id)
1641
1994
  next_status = "running" if snapshot.get("status") == "running" else "active"
1642
1995
  snapshot = self.quest_service.set_status(quest_id, next_status)
1996
+ recovery_abandoned_run_id = None
1997
+ recovery_summary = None
1998
+ if source.startswith("auto:daemon-recovery"):
1999
+ recent_events = self.quest_service.events(quest_id)["events"]
2000
+ for item in reversed(recent_events[-20:]):
2001
+ if str(item.get("type") or "").strip() != "quest.runtime_reconciled":
2002
+ continue
2003
+ recovery_abandoned_run_id = str(item.get("abandoned_run_id") or "").strip() or None
2004
+ recovery_summary = str(item.get("summary") or "").strip() or None
2005
+ break
2006
+ self.quest_service.update_runtime_state(
2007
+ quest_root=self.quest_service._quest_root(quest_id),
2008
+ last_resume_source=source,
2009
+ last_resume_at=utc_now(),
2010
+ last_recovery_abandoned_run_id=recovery_abandoned_run_id,
2011
+ last_recovery_summary=recovery_summary,
2012
+ )
1643
2013
  summary = f"Quest {quest_id} resumed."
1644
2014
  event = self._append_control_event(
1645
2015
  quest_id,
@@ -1780,52 +2150,50 @@ class DaemonApp:
1780
2150
  cancelled_pending_user_message_count: int,
1781
2151
  previous_snapshot: dict | None = None,
1782
2152
  ) -> str:
1783
- branch = str(snapshot.get("branch") or "unknown").strip() or "unknown"
1784
- workspace_root = str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or "").strip()
1785
2153
  if action == "resume":
1786
2154
  lines = [
1787
2155
  self._polite_copy(
1788
- zh="DeepScientist 已恢复运行。",
1789
- en="DeepScientist has resumed.",
2156
+ zh=f"我回来继续干活啦,Quest `{quest_id}` 已恢复。",
2157
+ en=f"I’m back on it. Quest `{quest_id}` has resumed.",
1790
2158
  ),
1791
2159
  self._polite_copy(
1792
- zh="当前 Git 分支与 worktree 已保留,系统会沿用现有研究上下文继续。",
1793
- 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.",
1794
2162
  ),
1795
2163
  ]
1796
2164
  if source.startswith("auto:daemon-recovery"):
1797
2165
  lines.append(
1798
2166
  self._polite_copy(
1799
- zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
1800
- 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. 🔧",
1801
2169
  )
1802
2170
  )
1803
2171
  elif action == "pause":
1804
2172
  lines = [
1805
2173
  self._polite_copy(
1806
- zh="DeepScientist 已从运行状态转为暂停状态。",
1807
- 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. ⏸️",
1808
2176
  ),
1809
2177
  self._polite_copy(
1810
- zh="当前 Git 分支与 worktree 已保留。如需继续,请直接在当前聊天或 connector 中发送任意新指令,或使用 /resume;系统会沿用当前 quest 上下文继续。",
1811
- 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.",
1812
2180
  ),
1813
2181
  ]
1814
2182
  else:
1815
2183
  lines = [
1816
2184
  self._polite_copy(
1817
- zh="DeepScientist 已从运行状态转为停止状态。",
1818
- 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. 📌",
1819
2187
  ),
1820
2188
  self._polite_copy(
1821
- zh="当前 Git 分支与 worktree 已保留。如需继续,请直接在当前聊天或 connector 中发送任意新指令,或使用 /resume;系统会沿用当前 quest 上下文继续。",
1822
- 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.",
1823
2191
  ),
1824
2192
  ]
1825
2193
  if interrupted:
1826
2194
  lines.append(
1827
2195
  self._polite_copy(
1828
- zh="当前活跃 runner 已被中断。",
2196
+ zh="刚才正在跑的任务已经被打断了。",
1829
2197
  en="The active runner was interrupted.",
1830
2198
  )
1831
2199
  )
@@ -1833,8 +2201,8 @@ class DaemonApp:
1833
2201
  if cancelled_count > 0:
1834
2202
  lines.append(
1835
2203
  self._polite_copy(
1836
- zh=f"已取消 {cancelled_count} 条排队中的用户消息。",
1837
- 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.",
1838
2206
  )
1839
2207
  )
1840
2208
  previous_status = str(
@@ -1845,17 +2213,10 @@ class DaemonApp:
1845
2213
  if previous_status and action == "resume":
1846
2214
  lines.append(
1847
2215
  self._polite_copy(
1848
- zh=f"此前状态:`{previous_status}`。",
2216
+ zh=f"恢复前的状态是:`{previous_status}`。",
1849
2217
  en=f"Previous status: `{previous_status}`.",
1850
2218
  )
1851
2219
  )
1852
- lines.extend(
1853
- [
1854
- f"- Quest: `{quest_id}`",
1855
- f"- Branch: `{branch}`",
1856
- f"- Workspace: `{workspace_root or snapshot.get('quest_root')}`",
1857
- ]
1858
- )
1859
2220
  return "\n".join(lines)
1860
2221
 
1861
2222
  def _drain_turns(self, quest_id: str) -> None:
@@ -1867,7 +2228,16 @@ class DaemonApp:
1867
2228
  state.pop("worker", None)
1868
2229
  return
1869
2230
  state["pending"] = False
1870
- self._run_quest_turn(quest_id)
2231
+ try:
2232
+ self._run_quest_turn(quest_id)
2233
+ except Exception as exc:
2234
+ self.logger.log(
2235
+ "error",
2236
+ "daemon.turn_worker_crashed",
2237
+ quest_id=quest_id,
2238
+ error=str(exc),
2239
+ traceback=traceback.format_exc(),
2240
+ )
1871
2241
 
1872
2242
  def _run_quest_turn(self, quest_id: str) -> None:
1873
2243
  with self._turn_lock:
@@ -1885,7 +2255,9 @@ class DaemonApp:
1885
2255
 
1886
2256
  runner_name = self._runner_name_for(snapshot)
1887
2257
  runner_cfg = self.runners_config.get(runner_name, {})
1888
- skill_id = self._turn_skill_for(snapshot, latest_user_message, turn_reason=turn_reason)
2258
+ turn_intent = self._turn_intent_for(latest_user_message, turn_reason=turn_reason)
2259
+ turn_mode = self._turn_mode_for(snapshot, latest_user_message, turn_reason=turn_reason)
2260
+ skill_id = self._turn_skill_for(snapshot, latest_user_message, turn_reason=turn_reason, turn_mode=turn_mode)
1889
2261
  run_id = generate_id("run")
1890
2262
  model = str(runner_cfg.get("model", "gpt-5.4"))
1891
2263
  run_message = ""
@@ -1982,6 +2354,8 @@ class DaemonApp:
1982
2354
  approval_policy=str(runner_cfg.get("approval_policy", "on-request")),
1983
2355
  sandbox_mode=str(runner_cfg.get("sandbox_mode", "workspace-write")),
1984
2356
  turn_reason=turn_reason,
2357
+ turn_intent=turn_intent,
2358
+ turn_mode=turn_mode,
1985
2359
  reasoning_effort=reasoning_effort,
1986
2360
  turn_id=turn_id,
1987
2361
  attempt_index=attempt_index,
@@ -2002,24 +2376,172 @@ class DaemonApp:
2002
2376
  "next_retry_at": None,
2003
2377
  },
2004
2378
  )
2005
-
2006
2379
  try:
2007
- result = runner.run(request)
2008
- except Exception as exc: # pragma: no cover - exercised via integration behavior
2380
+ try:
2381
+ result = runner.run(request)
2382
+ except Exception as exc: # pragma: no cover - exercised via integration behavior
2383
+ if self._turn_stop_requested(quest_id):
2384
+ return
2385
+ failure_summary = f"Runner `{runner_name}` failed on attempt {attempt_index}/{max_attempts}: {exc}"
2386
+ retry_context = self._build_retry_context(
2387
+ quest_id=quest_id,
2388
+ failed_run_id=current_run_id,
2389
+ turn_id=turn_id,
2390
+ attempt_index=attempt_index,
2391
+ max_attempts=max_attempts,
2392
+ failure_kind="exception",
2393
+ failure_summary=failure_summary,
2394
+ previous_exit_code=None,
2395
+ previous_output_text="",
2396
+ stderr_text=str(exc),
2397
+ )
2398
+ if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
2399
+ delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
2400
+ next_retry_at = self._retry_next_timestamp(delay_seconds)
2401
+ self.quest_service.update_runtime_state(
2402
+ quest_root=quest_root,
2403
+ status="running",
2404
+ display_status="retrying",
2405
+ active_run_id=None,
2406
+ retry_state={
2407
+ "turn_id": turn_id,
2408
+ "attempt_index": attempt_index,
2409
+ "max_attempts": max_attempts,
2410
+ "last_run_id": current_run_id,
2411
+ "last_error": failure_summary,
2412
+ "next_retry_at": next_retry_at,
2413
+ },
2414
+ )
2415
+ self._append_retry_event(
2416
+ quest_id,
2417
+ event_type="runner.turn_retry_scheduled",
2418
+ runner_name=runner_name,
2419
+ run_id=current_run_id,
2420
+ turn_id=turn_id,
2421
+ skill_id=skill_id,
2422
+ model=model,
2423
+ attempt_index=attempt_index,
2424
+ max_attempts=max_attempts,
2425
+ summary=f"Attempt {attempt_index}/{max_attempts} failed. Retrying in {delay_seconds:.1f}s.",
2426
+ failure_summary=failure_summary,
2427
+ backoff_seconds=delay_seconds,
2428
+ next_attempt_index=attempt_index + 1,
2429
+ )
2430
+ if self._wait_for_retry_delay(quest_id, delay_seconds):
2431
+ continue
2432
+ self._append_retry_event(
2433
+ quest_id,
2434
+ event_type="runner.turn_retry_aborted",
2435
+ runner_name=runner_name,
2436
+ run_id=current_run_id,
2437
+ turn_id=turn_id,
2438
+ skill_id=skill_id,
2439
+ model=model,
2440
+ attempt_index=attempt_index,
2441
+ max_attempts=max_attempts,
2442
+ summary="Retry sequence aborted because the quest was stopped or paused.",
2443
+ failure_summary=failure_summary,
2444
+ )
2445
+ return
2446
+ exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
2447
+ self._append_retry_event(
2448
+ quest_id,
2449
+ event_type="runner.turn_retry_exhausted",
2450
+ runner_name=runner_name,
2451
+ run_id=current_run_id,
2452
+ turn_id=turn_id,
2453
+ skill_id=skill_id,
2454
+ model=model,
2455
+ attempt_index=attempt_index,
2456
+ max_attempts=max_attempts,
2457
+ summary=exhausted_summary,
2458
+ failure_summary=failure_summary,
2459
+ )
2460
+ self._record_turn_error(
2461
+ quest_id=quest_id,
2462
+ runner_name=runner_name,
2463
+ run_id=current_run_id,
2464
+ skill_id=skill_id,
2465
+ model=model,
2466
+ summary=exhausted_summary,
2467
+ retry_state=None,
2468
+ )
2469
+ return
2470
+
2009
2471
  if self._turn_stop_requested(quest_id):
2010
2472
  return
2011
- failure_summary = f"Runner `{runner_name}` failed on attempt {attempt_index}/{max_attempts}: {exc}"
2473
+
2474
+ if result.ok:
2475
+ self.quest_service.update_runtime_state(quest_root=quest_root, retry_state=None)
2476
+ if result.output_text:
2477
+ result_attachment = [
2478
+ {
2479
+ "kind": "runner_result",
2480
+ "run_id": result.run_id,
2481
+ "skill_id": skill_id,
2482
+ "runner": runner_name,
2483
+ "model": result.model,
2484
+ "exit_code": result.exit_code,
2485
+ "history_root": str(result.history_root),
2486
+ "run_root": str(result.run_root),
2487
+ }
2488
+ ]
2489
+ try:
2490
+ self.quest_service.append_message(
2491
+ quest_id,
2492
+ role="assistant",
2493
+ content=result.output_text,
2494
+ source=runner_name,
2495
+ run_id=result.run_id,
2496
+ skill_id=skill_id,
2497
+ )
2498
+ except Exception as exc:
2499
+ self._record_turn_postprocess_warning(
2500
+ quest_id=quest_id,
2501
+ runner_name=runner_name,
2502
+ run_id=result.run_id,
2503
+ skill_id=skill_id,
2504
+ model=result.model,
2505
+ stage="append_message",
2506
+ error=exc,
2507
+ )
2508
+ try:
2509
+ self._relay_quest_message_to_bound_connectors(
2510
+ quest_id,
2511
+ message=result.output_text,
2512
+ kind="assistant",
2513
+ response_phase="final",
2514
+ importance="normal",
2515
+ attachments=result_attachment,
2516
+ )
2517
+ except Exception as exc:
2518
+ self._record_turn_postprocess_warning(
2519
+ quest_id=quest_id,
2520
+ runner_name=runner_name,
2521
+ run_id=result.run_id,
2522
+ skill_id=skill_id,
2523
+ model=result.model,
2524
+ stage="connector_relay",
2525
+ error=exc,
2526
+ )
2527
+ self._normalize_status_after_turn(quest_id, turn_reason=turn_reason)
2528
+ return
2529
+
2530
+ failure_summary = f"Runner `{runner_name}` exited with code {result.exit_code} on attempt {attempt_index}/{max_attempts}."
2531
+ stderr_excerpt = self._trim_text(result.stderr_text, limit=240)
2532
+ if stderr_excerpt:
2533
+ failure_summary = f"{failure_summary} stderr: {stderr_excerpt}"
2012
2534
  retry_context = self._build_retry_context(
2013
2535
  quest_id=quest_id,
2014
- failed_run_id=current_run_id,
2536
+ failed_run_id=result.run_id,
2015
2537
  turn_id=turn_id,
2016
2538
  attempt_index=attempt_index,
2017
2539
  max_attempts=max_attempts,
2018
- failure_kind="exception",
2540
+ failure_kind="exit_code",
2019
2541
  failure_summary=failure_summary,
2020
- previous_exit_code=None,
2021
- previous_output_text="",
2022
- stderr_text=str(exc),
2542
+ previous_exit_code=result.exit_code,
2543
+ previous_output_text=result.output_text,
2544
+ stderr_text=result.stderr_text,
2023
2545
  )
2024
2546
  if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
2025
2547
  delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
@@ -2033,7 +2555,7 @@ class DaemonApp:
2033
2555
  "turn_id": turn_id,
2034
2556
  "attempt_index": attempt_index,
2035
2557
  "max_attempts": max_attempts,
2036
- "last_run_id": current_run_id,
2558
+ "last_run_id": result.run_id,
2037
2559
  "last_error": failure_summary,
2038
2560
  "next_retry_at": next_retry_at,
2039
2561
  },
@@ -2042,7 +2564,7 @@ class DaemonApp:
2042
2564
  quest_id,
2043
2565
  event_type="runner.turn_retry_scheduled",
2044
2566
  runner_name=runner_name,
2045
- run_id=current_run_id,
2567
+ run_id=result.run_id,
2046
2568
  turn_id=turn_id,
2047
2569
  skill_id=skill_id,
2048
2570
  model=model,
@@ -2059,7 +2581,7 @@ class DaemonApp:
2059
2581
  quest_id,
2060
2582
  event_type="runner.turn_retry_aborted",
2061
2583
  runner_name=runner_name,
2062
- run_id=current_run_id,
2584
+ run_id=result.run_id,
2063
2585
  turn_id=turn_id,
2064
2586
  skill_id=skill_id,
2065
2587
  model=model,
@@ -2069,12 +2591,13 @@ class DaemonApp:
2069
2591
  failure_summary=failure_summary,
2070
2592
  )
2071
2593
  return
2594
+
2072
2595
  exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
2073
2596
  self._append_retry_event(
2074
2597
  quest_id,
2075
2598
  event_type="runner.turn_retry_exhausted",
2076
2599
  runner_name=runner_name,
2077
- run_id=current_run_id,
2600
+ run_id=result.run_id,
2078
2601
  turn_id=turn_id,
2079
2602
  skill_id=skill_id,
2080
2603
  model=model,
@@ -2086,150 +2609,155 @@ class DaemonApp:
2086
2609
  self._record_turn_error(
2087
2610
  quest_id=quest_id,
2088
2611
  runner_name=runner_name,
2089
- run_id=current_run_id,
2612
+ run_id=result.run_id,
2090
2613
  skill_id=skill_id,
2091
2614
  model=model,
2092
2615
  summary=exhausted_summary,
2093
2616
  retry_state=None,
2094
2617
  )
2095
2618
  return
2096
-
2097
- if self._turn_stop_requested(quest_id):
2098
- return
2099
-
2100
- if result.ok:
2101
- self.quest_service.update_runtime_state(quest_root=quest_root, retry_state=None)
2102
- if result.output_text:
2103
- self.quest_service.append_message(
2104
- quest_id,
2105
- role="assistant",
2106
- content=result.output_text,
2107
- source=runner_name,
2108
- run_id=result.run_id,
2109
- skill_id=skill_id,
2110
- )
2111
- self._relay_quest_message_to_bound_connectors(
2112
- quest_id,
2113
- message=result.output_text,
2114
- kind="assistant",
2115
- response_phase="final",
2116
- importance="normal",
2117
- attachments=[
2118
- {
2119
- "kind": "runner_result",
2120
- "run_id": result.run_id,
2121
- "skill_id": skill_id,
2122
- "runner": runner_name,
2123
- "model": result.model,
2124
- "exit_code": result.exit_code,
2125
- "history_root": str(result.history_root),
2126
- "run_root": str(result.run_root),
2127
- }
2128
- ],
2129
- )
2130
- self._normalize_status_after_turn(quest_id)
2131
- return
2132
-
2133
- failure_summary = f"Runner `{runner_name}` exited with code {result.exit_code} on attempt {attempt_index}/{max_attempts}."
2134
- stderr_excerpt = self._trim_text(result.stderr_text, limit=240)
2135
- if stderr_excerpt:
2136
- failure_summary = f"{failure_summary} stderr: {stderr_excerpt}"
2137
- retry_context = self._build_retry_context(
2138
- quest_id=quest_id,
2139
- failed_run_id=result.run_id,
2140
- turn_id=turn_id,
2141
- attempt_index=attempt_index,
2142
- max_attempts=max_attempts,
2143
- failure_kind="exit_code",
2144
- failure_summary=failure_summary,
2145
- previous_exit_code=result.exit_code,
2146
- previous_output_text=result.output_text,
2147
- stderr_text=result.stderr_text,
2148
- )
2149
- if bool(retry_policy.get("enabled")) and attempt_index < max_attempts:
2150
- delay_seconds = self._retry_delay_seconds(retry_policy, attempt_index=attempt_index + 1)
2151
- next_retry_at = self._retry_next_timestamp(delay_seconds)
2152
- self.quest_service.update_runtime_state(
2153
- quest_root=quest_root,
2154
- status="running",
2155
- display_status="retrying",
2156
- active_run_id=None,
2157
- retry_state={
2158
- "turn_id": turn_id,
2159
- "attempt_index": attempt_index,
2160
- "max_attempts": max_attempts,
2161
- "last_run_id": result.run_id,
2162
- "last_error": failure_summary,
2163
- "next_retry_at": next_retry_at,
2164
- },
2165
- )
2166
- self._append_retry_event(
2167
- quest_id,
2168
- event_type="runner.turn_retry_scheduled",
2169
- runner_name=runner_name,
2170
- run_id=result.run_id,
2171
- turn_id=turn_id,
2172
- skill_id=skill_id,
2173
- model=model,
2174
- attempt_index=attempt_index,
2175
- max_attempts=max_attempts,
2176
- summary=f"Attempt {attempt_index}/{max_attempts} failed. Retrying in {delay_seconds:.1f}s.",
2177
- failure_summary=failure_summary,
2178
- backoff_seconds=delay_seconds,
2179
- next_attempt_index=attempt_index + 1,
2180
- )
2181
- if self._wait_for_retry_delay(quest_id, delay_seconds):
2182
- continue
2183
- self._append_retry_event(
2619
+ finally:
2620
+ self._ensure_turn_cleanup(
2184
2621
  quest_id,
2185
- event_type="runner.turn_retry_aborted",
2186
- runner_name=runner_name,
2187
- run_id=result.run_id,
2188
- turn_id=turn_id,
2189
- skill_id=skill_id,
2190
- model=model,
2191
- attempt_index=attempt_index,
2192
- max_attempts=max_attempts,
2193
- summary="Retry sequence aborted because the quest was stopped or paused.",
2194
- failure_summary=failure_summary,
2622
+ run_id=current_run_id,
2623
+ turn_reason=turn_reason,
2195
2624
  )
2196
- return
2197
-
2198
- exhausted_summary = f"{failure_summary} Retry budget exhausted after {attempt_index} attempt(s)."
2199
- self._append_retry_event(
2200
- quest_id,
2201
- event_type="runner.turn_retry_exhausted",
2202
- runner_name=runner_name,
2203
- run_id=result.run_id,
2204
- turn_id=turn_id,
2205
- skill_id=skill_id,
2206
- model=model,
2207
- attempt_index=attempt_index,
2208
- max_attempts=max_attempts,
2209
- summary=exhausted_summary,
2210
- failure_summary=failure_summary,
2211
- )
2212
- self._record_turn_error(
2213
- quest_id=quest_id,
2214
- runner_name=runner_name,
2215
- run_id=result.run_id,
2216
- skill_id=skill_id,
2217
- model=model,
2218
- summary=exhausted_summary,
2219
- retry_state=None,
2220
- )
2221
- return
2222
2625
 
2223
2626
  def _runner_name_for(self, snapshot: dict) -> str:
2224
2627
  configured = self.config_manager.load_named("config")
2225
2628
  return str(snapshot.get("runner") or configured.get("default_runner", "codex")).strip().lower()
2226
2629
 
2227
2630
  @staticmethod
2228
- def _turn_skill_for(snapshot: dict, latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
2631
+ def _stage_state_fingerprint(snapshot: dict) -> str:
2632
+ paper_health = (
2633
+ dict(snapshot.get("paper_contract_health") or {})
2634
+ if isinstance(snapshot.get("paper_contract_health"), dict)
2635
+ else {}
2636
+ )
2637
+ payload = {
2638
+ "active_anchor": str(snapshot.get("active_anchor") or "").strip() or None,
2639
+ "active_run_id": str(snapshot.get("active_run_id") or "").strip() or None,
2640
+ "active_analysis_campaign_id": str(snapshot.get("active_analysis_campaign_id") or "").strip() or None,
2641
+ "next_pending_slice_id": str(snapshot.get("next_pending_slice_id") or "").strip() or None,
2642
+ "current_workspace_branch": str(snapshot.get("current_workspace_branch") or "").strip() or None,
2643
+ "continuation_policy": str(snapshot.get("continuation_policy") or "").strip() or None,
2644
+ "paper": {
2645
+ "closure_state": str(paper_health.get("closure_state") or "").strip() or None,
2646
+ "delivery_state": str(paper_health.get("delivery_state") or "").strip() or None,
2647
+ "recommended_next_stage": str(paper_health.get("recommended_next_stage") or "").strip() or None,
2648
+ "recommended_action": str(paper_health.get("recommended_action") or "").strip() or None,
2649
+ "blocking_reasons": list(paper_health.get("blocking_reasons") or []),
2650
+ "keep_bundle_fixed_by_default": bool(paper_health.get("keep_bundle_fixed_by_default")),
2651
+ },
2652
+ }
2653
+ return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
2654
+
2655
+ @staticmethod
2656
+ def _turn_intent_for(latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
2229
2657
  if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
2658
+ return "continue_stage"
2659
+ return classify_turn_intent(str(latest_user_message.get("content") or "").strip())
2660
+
2661
+ @staticmethod
2662
+ def _turn_mode_for(snapshot: dict, latest_user_message: dict | None, *, turn_reason: str = "user_message") -> str:
2663
+ normalized_reason = str(turn_reason or "").strip() or "user_message"
2664
+ if normalized_reason == "auto_continue":
2665
+ resume_source = str(snapshot.get("last_resume_source") or "").strip()
2666
+ if resume_source.startswith("auto:daemon-recovery"):
2667
+ return "recovering"
2668
+ continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
2669
+ if continuation_policy == "when_external_progress":
2670
+ return "monitoring"
2671
+ if continuation_policy in {"wait_for_user_or_resume", "none"}:
2672
+ return "parked"
2673
+ return "stage_execution"
2674
+ turn_intent = DaemonApp._turn_intent_for(latest_user_message, turn_reason=turn_reason)
2675
+ if turn_intent == "answer_user_question_first":
2676
+ return "answering"
2677
+ if turn_intent == "execute_user_command_first":
2678
+ return "command_execution"
2679
+ return "stage_execution"
2680
+
2681
+ @staticmethod
2682
+ def _continuation_anchor_for(snapshot: dict) -> str:
2683
+ available_stage_skills = current_standard_skills(repo_root())
2684
+ continuation_anchor = str(snapshot.get("continuation_anchor") or "").strip()
2685
+ if continuation_anchor in available_stage_skills:
2686
+ return continuation_anchor
2687
+ active_anchor = str(snapshot.get("active_anchor") or "").strip()
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
2718
+
2719
+ @staticmethod
2720
+ def _turn_skill_stage_gate(snapshot: dict, candidate_skill: str) -> str:
2721
+ skill = str(candidate_skill or "").strip()
2722
+ baseline_gate = str(snapshot.get("baseline_gate") or "pending").strip().lower() or "pending"
2723
+ startup_contract = snapshot.get("startup_contract") if isinstance(snapshot.get("startup_contract"), dict) else {}
2724
+ raw_need_research_paper = startup_contract.get("need_research_paper")
2725
+ need_research_paper = raw_need_research_paper if isinstance(raw_need_research_paper, bool) else True
2726
+ active_idea_id = str(snapshot.get("active_idea_id") or "").strip()
2727
+
2728
+ if (
2729
+ baseline_gate == "pending"
2730
+ and skill in {"idea", "optimize", "experiment", "analysis-campaign", "write", "review", "rebuttal", "finalize"}
2731
+ ):
2732
+ return "baseline"
2733
+
2734
+ if skill == "experiment" and not active_idea_id:
2735
+ return "idea" if need_research_paper else "optimize"
2736
+
2737
+ return skill
2738
+
2739
+ @staticmethod
2740
+ def _turn_skill_for(
2741
+ snapshot: dict,
2742
+ latest_user_message: dict | None,
2743
+ *,
2744
+ turn_reason: str = "user_message",
2745
+ turn_mode: str = "stage_execution",
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:
2230
2751
  active_anchor = str(snapshot.get("active_anchor") or "").strip()
2231
- return active_anchor if active_anchor in STANDARD_SKILLS else "decision"
2232
- reply_target = str(latest_user_message.get("reply_to_interaction_id") 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
+
2760
+ reply_target = str((latest_user_message or {}).get("reply_to_interaction_id") or "").strip()
2233
2761
  if reply_target:
2234
2762
  for item in (snapshot.get("active_interactions") or []):
2235
2763
  candidate_ids = {
@@ -2247,13 +2775,42 @@ class DaemonApp:
2247
2775
  str(item.get("interaction_id") or "").strip(),
2248
2776
  str(item.get("artifact_id") or "").strip(),
2249
2777
  }
2250
- if reply_target in candidate_ids and (
2778
+ if reply_target not in candidate_ids:
2779
+ continue
2780
+ if (
2251
2781
  str(item.get("reply_mode") or "") == "blocking"
2252
2782
  or str(item.get("kind") or "") == "decision_request"
2253
2783
  ):
2254
2784
  return "decision"
2785
+ if str(item.get("reply_mode") or "") == "threaded":
2786
+ if workspace_mode == "copilot":
2787
+ return copilot_default_skill()
2788
+ return DaemonApp._turn_skill_stage_gate(
2789
+ snapshot,
2790
+ DaemonApp._continuation_anchor_for(snapshot),
2791
+ )
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"}:
2797
+ return "decision"
2798
+ if str(turn_reason or "").strip() == "auto_continue" or latest_user_message is None:
2799
+ return DaemonApp._turn_skill_stage_gate(
2800
+ snapshot,
2801
+ DaemonApp._continuation_anchor_for(snapshot),
2802
+ )
2803
+ continuation_policy = str(snapshot.get("continuation_policy") or "auto").strip().lower() or "auto"
2804
+ if continuation_policy == "wait_for_user_or_resume":
2805
+ return DaemonApp._turn_skill_stage_gate(
2806
+ snapshot,
2807
+ DaemonApp._continuation_anchor_for(snapshot),
2808
+ )
2255
2809
  active_anchor = str(snapshot.get("active_anchor") or "").strip()
2256
- return active_anchor if active_anchor in STANDARD_SKILLS else "decision"
2810
+ return DaemonApp._turn_skill_stage_gate(
2811
+ snapshot,
2812
+ active_anchor if active_anchor in available_stage_skills else "decision",
2813
+ )
2257
2814
 
2258
2815
  def _latest_user_message(self, quest_id: str) -> dict | None:
2259
2816
  for item in reversed(self.quest_service.history(quest_id, limit=200)):
@@ -2601,7 +3158,80 @@ class DaemonApp:
2601
3158
  ],
2602
3159
  )
2603
3160
 
2604
- def _normalize_status_after_turn(self, quest_id: str) -> None:
3161
+ def _record_turn_postprocess_warning(
3162
+ self,
3163
+ *,
3164
+ quest_id: str,
3165
+ runner_name: str,
3166
+ run_id: str,
3167
+ skill_id: str,
3168
+ model: str,
3169
+ stage: str,
3170
+ error: Exception,
3171
+ ) -> None:
3172
+ quest_root = self.home / "quests" / quest_id
3173
+ summary = f"Runner post-run stage `{stage}` failed for run `{run_id}`: {error}"
3174
+ append_jsonl(
3175
+ quest_root / ".ds" / "events.jsonl",
3176
+ {
3177
+ "event_id": generate_id("evt"),
3178
+ "type": "runner.turn_postprocess_warning",
3179
+ "quest_id": quest_id,
3180
+ "run_id": run_id,
3181
+ "source": runner_name,
3182
+ "skill_id": skill_id,
3183
+ "model": model,
3184
+ "stage": stage,
3185
+ "summary": summary,
3186
+ "created_at": utc_now(),
3187
+ },
3188
+ )
3189
+ self.logger.log(
3190
+ "error",
3191
+ "runner.turn_postprocess_warning",
3192
+ quest_id=quest_id,
3193
+ run_id=run_id,
3194
+ runner=runner_name,
3195
+ skill_id=skill_id,
3196
+ model=model,
3197
+ stage=stage,
3198
+ error=str(error),
3199
+ )
3200
+
3201
+ def _ensure_turn_cleanup(self, quest_id: str, *, run_id: str, turn_reason: str) -> None:
3202
+ snapshot = self.quest_service.snapshot(quest_id)
3203
+ if str(snapshot.get("active_run_id") or "").strip() != str(run_id or "").strip():
3204
+ return
3205
+ try:
3206
+ self._normalize_status_after_turn(quest_id, turn_reason=turn_reason)
3207
+ return
3208
+ except Exception as exc:
3209
+ current_status = str(snapshot.get("status") or snapshot.get("display_status") or "active").strip() or "active"
3210
+ normalized_status = "active" if current_status == "running" else current_status
3211
+ self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
3212
+ quest_root = self.quest_service._quest_root(quest_id)
3213
+ append_jsonl(
3214
+ quest_root / ".ds" / "events.jsonl",
3215
+ {
3216
+ "event_id": generate_id("evt"),
3217
+ "type": "runner.turn_cleanup_recovered",
3218
+ "quest_id": quest_id,
3219
+ "run_id": run_id,
3220
+ "status": normalized_status,
3221
+ "summary": f"Recovered turn cleanup after `_normalize_status_after_turn` failed: {exc}",
3222
+ "created_at": utc_now(),
3223
+ },
3224
+ )
3225
+ self.logger.log(
3226
+ "error",
3227
+ "runner.turn_cleanup_recovered",
3228
+ quest_id=quest_id,
3229
+ run_id=run_id,
3230
+ status=normalized_status,
3231
+ error=str(exc),
3232
+ )
3233
+
3234
+ def _normalize_status_after_turn(self, quest_id: str, *, turn_reason: str = "user_message") -> None:
2605
3235
  with self._turn_lock:
2606
3236
  if bool((self._turn_state.get(quest_id) or {}).get("stop_requested")):
2607
3237
  return
@@ -2609,6 +3239,46 @@ class DaemonApp:
2609
3239
  current_status = str(snapshot.get("status") or snapshot.get("display_status") or "active").strip() or "active"
2610
3240
  normalized_status = "active" if current_status == "running" else current_status
2611
3241
  snapshot = self.quest_service.mark_turn_finished(quest_id, status=normalized_status)
3242
+ runtime_updates: dict[str, Any] = {}
3243
+ current_fingerprint = self._stage_state_fingerprint(snapshot)
3244
+ previous_fingerprint = str(snapshot.get("last_stage_fingerprint") or "").strip() or None
3245
+ same_fingerprint_count = int(snapshot.get("same_fingerprint_auto_turn_count") or 0)
3246
+ if str(turn_reason or "").strip() == "auto_continue":
3247
+ same_fingerprint_count = same_fingerprint_count + 1 if previous_fingerprint == current_fingerprint else 1
3248
+ else:
3249
+ same_fingerprint_count = 0
3250
+ runtime_updates.update(
3251
+ {
3252
+ "last_stage_fingerprint": current_fingerprint,
3253
+ "last_stage_fingerprint_at": utc_now(),
3254
+ "same_fingerprint_auto_turn_count": same_fingerprint_count,
3255
+ }
3256
+ )
3257
+ if (
3258
+ str(turn_reason or "").strip() == "auto_continue"
3259
+ and str(snapshot.get("active_anchor") or "").strip() == "finalize"
3260
+ and same_fingerprint_count >= 2
3261
+ and int(snapshot.get("pending_user_message_count") or 0) == 0
3262
+ ):
3263
+ runtime_updates.update(
3264
+ {
3265
+ "continuation_policy": "wait_for_user_or_resume",
3266
+ "continuation_anchor": "decision",
3267
+ "continuation_reason": "unchanged_finalize_state",
3268
+ "continuation_updated_at": utc_now(),
3269
+ }
3270
+ )
3271
+ self.quest_service.update_runtime_state(
3272
+ quest_root=self.quest_service._quest_root(quest_id),
3273
+ **runtime_updates,
3274
+ )
3275
+ snapshot = self.quest_service.snapshot(quest_id)
3276
+ else:
3277
+ self.quest_service.update_runtime_state(
3278
+ quest_root=self.quest_service._quest_root(quest_id),
3279
+ **runtime_updates,
3280
+ )
3281
+ snapshot = self.quest_service.snapshot(quest_id)
2612
3282
  status = str(snapshot.get("status") or "")
2613
3283
  if status in {"stopped", "paused", "completed", "error"}:
2614
3284
  return
@@ -2618,12 +3288,68 @@ class DaemonApp:
2618
3288
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2619
3289
  self.schedule_turn(quest_id, reason="queued_user_messages")
2620
3290
  else:
2621
- self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
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)
3304
+ if continuation_policy not in {"wait_for_user_or_resume", "none"}:
3305
+ self._schedule_turn_later(
3306
+ quest_id,
3307
+ reason="auto_continue",
3308
+ delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
3309
+ )
2622
3310
  return
2623
3311
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2624
3312
  self.schedule_turn(quest_id, reason="queued_user_messages")
2625
3313
  return
2626
- self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
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)
3327
+ if continuation_policy == "none":
3328
+ return
3329
+ if continuation_policy == "wait_for_user_or_resume":
3330
+ return
3331
+ if continuation_policy == "when_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
+ )
3347
+ return
3348
+ self._schedule_turn_later(
3349
+ quest_id,
3350
+ reason="auto_continue",
3351
+ delay_seconds=self._auto_continue_delay_for_policy(continuation_policy),
3352
+ )
2627
3353
 
2628
3354
  def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
2629
3355
  def _delayed() -> None:
@@ -2634,6 +3360,32 @@ class DaemonApp:
2634
3360
  status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
2635
3361
  if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
2636
3362
  return
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)
3376
+ if continuation_policy in {"none", "wait_for_user_or_resume"}:
3377
+ return
3378
+ if continuation_policy == "when_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
+ )
3388
+ return
2637
3389
  self.schedule_turn(quest_id, reason=reason)
2638
3390
 
2639
3391
  threading.Thread(
@@ -2642,6 +3394,23 @@ class DaemonApp:
2642
3394
  name=f"deepscientist-turn-delay-{quest_id}",
2643
3395
  ).start()
2644
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
+
2645
3414
  def _relay_quest_message_to_bound_connectors(
2646
3415
  self,
2647
3416
  quest_id: str,
@@ -3492,6 +4261,13 @@ class DaemonApp:
3492
4261
  **normalized,
3493
4262
  "_qq_main_chat_binding": qq_binding,
3494
4263
  }
4264
+ if connector_name == "weixin":
4265
+ replay = self._maybe_replay_weixin_pending_outbox(normalized)
4266
+ if replay is not None:
4267
+ normalized = {
4268
+ **normalized,
4269
+ "_weixin_replay": replay,
4270
+ }
3495
4271
  reply = self._route_connector_message(connector_name, normalized)
3496
4272
  return {
3497
4273
  "ok": True,
@@ -3627,8 +4403,8 @@ class DaemonApp:
3627
4403
  "quest_id": target_quest,
3628
4404
  "kind": "ack",
3629
4405
  "message": self._polite_copy(
3630
- zh=f"老师,已将当前 {connector_label} 会话绑定到 {target_quest},我会继续推进并同步计划。",
3631
- 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.",
3632
4408
  ),
3633
4409
  }
3634
4410
  )
@@ -4719,13 +5495,13 @@ class DaemonApp:
4719
5495
  channel = self._channel_with_bindings(old_connector)
4720
5496
  if mode == "disconnect":
4721
5497
  message = self._polite_copy(
4722
- zh=f"当前已退出 Quest `{quest_id}`,项目已切换为仅本地。",
4723
- 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. 📌",
4724
5500
  )
4725
5501
  else:
4726
5502
  message = self._polite_copy(
4727
- zh=f"当前已退出 Quest `{quest_id}`,后续请在 {current_label} 查看进展。",
4728
- 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}. 🔁",
4729
5505
  )
4730
5506
  channel.send(
4731
5507
  {
@@ -4742,13 +5518,13 @@ class DaemonApp:
4742
5518
  channel = self._channel_with_bindings(new_connector)
4743
5519
  if mode == "bind":
4744
5520
  message = self._polite_copy(
4745
- zh=f"当前已绑定 Quest `{quest_id}`。",
4746
- 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. ✨",
4747
5523
  )
4748
5524
  elif mode == "switch":
4749
5525
  message = self._polite_copy(
4750
- zh=f"当前已绑定 Quest `{quest_id}`,并已从 {previous_label} 切换到当前会话。",
4751
- 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. 🔄",
4752
5528
  )
4753
5529
  else:
4754
5530
  message = ""
@@ -4982,6 +5758,20 @@ class DaemonApp:
4982
5758
  )
4983
5759
  return f"{notice}\n\n{base}"
4984
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
+
4985
5775
  def _quest_created_connector_message(
4986
5776
  self,
4987
5777
  connector_name: str,
@@ -4990,29 +5780,29 @@ class DaemonApp:
4990
5780
  goal: str,
4991
5781
  previous_quest_id: str | None = None,
4992
5782
  ) -> str:
4993
- normalized_goal = str(goal or "").strip() or "(未提供具体任务)"
4994
5783
  previous = str(previous_quest_id or "").strip()
5784
+ goal_preview = self._connector_goal_preview(goal)
4995
5785
  restore_zh = (
4996
- f"\n如果需要恢复到原先绑定的 quest,请发送:`/use {previous}`。"
5786
+ f"\n如果想切回原先的 Quest `{previous}`,给我发 `/use {previous}` 就行。"
4997
5787
  if previous and previous != quest_id
4998
5788
  else ""
4999
5789
  )
5000
5790
  restore_en = (
5001
- 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}`. 🔁"
5002
5792
  if previous and previous != quest_id
5003
5793
  else ""
5004
5794
  )
5005
5795
  return self._polite_copy(
5006
5796
  zh=(
5007
- f"老师,已顺利创建新的 quest `{quest_id}`。\n"
5008
- f"我即将为您完成以下任务:{normalized_goal}\n"
5009
- f"当前 {self._connector_label(connector_name)} 会话接下来会自动使用这个新 quest 保持连接。\n"
5797
+ f"开工啦!新的 Quest `{quest_id}` 已经建好啦。\n"
5798
+ f"这轮我先做这件事:{goal_preview}\n"
5799
+ f"后面的进展我都会直接在这里同步给您。"
5010
5800
  )
5011
5801
  + restore_zh,
5012
5802
  en=(
5013
- f"Created a new quest `{quest_id}` successfully.\n"
5014
- f"I am about to work on: {normalized_goal}\n"
5015
- 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."
5016
5806
  )
5017
5807
  + restore_en,
5018
5808
  )
@@ -5290,6 +6080,121 @@ class DaemonApp:
5290
6080
  resolved = dict(config) if isinstance(config, dict) else {}
5291
6081
  return lingzhu_health_payload(resolved, chat_completions_enabled=True)
5292
6082
 
6083
+ @staticmethod
6084
+ def _weixin_replay_limit(config: dict[str, Any]) -> int:
6085
+ try:
6086
+ limit = int(config.get("stale_replay_latest_limit") or _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT)
6087
+ except (TypeError, ValueError):
6088
+ limit = _WEIXIN_STALE_REPLAY_LIMIT_DEFAULT
6089
+ return max(0, min(limit, 20))
6090
+
6091
+ @staticmethod
6092
+ def _weixin_replay_interval_seconds(config: dict[str, Any]) -> float:
6093
+ try:
6094
+ interval = float(
6095
+ config.get("stale_replay_interval_seconds") or _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT
6096
+ )
6097
+ except (TypeError, ValueError):
6098
+ interval = _WEIXIN_STALE_REPLAY_INTERVAL_SECONDS_DEFAULT
6099
+ return max(0.0, min(interval, 30.0))
6100
+
6101
+ def _weixin_connector_root(self) -> Path:
6102
+ return self.home / "logs" / "connectors" / "weixin"
6103
+
6104
+ def _weixin_queued_outbox_records(self, conversation_id: str) -> list[dict[str, Any]]:
6105
+ outbox_path = self._weixin_connector_root() / "outbox.jsonl"
6106
+ target_key = conversation_identity_key(conversation_id)
6107
+ items: list[dict[str, Any]] = []
6108
+ for record in read_jsonl(outbox_path):
6109
+ if not isinstance(record, dict):
6110
+ continue
6111
+ current_conversation_id = str(record.get("conversation_id") or "").strip()
6112
+ if not current_conversation_id:
6113
+ continue
6114
+ if conversation_identity_key(current_conversation_id) != target_key:
6115
+ continue
6116
+ delivery = record.get("delivery") if isinstance(record.get("delivery"), dict) else {}
6117
+ if not bool(delivery.get("queued", False)):
6118
+ continue
6119
+ attachments = [dict(item) for item in (record.get("attachments") or []) if isinstance(item, dict)]
6120
+ if not str(record.get("text") or "").strip() and not attachments:
6121
+ continue
6122
+ items.append(
6123
+ {
6124
+ **dict(record),
6125
+ "attachments": attachments,
6126
+ }
6127
+ )
6128
+ return items
6129
+
6130
+ def _weixin_pending_outbox_records(self, conversation_id: str, *, user_id: str) -> tuple[list[dict[str, Any]], int]:
6131
+ records = self._weixin_queued_outbox_records(conversation_id)
6132
+ baseline = get_weixin_replay_cursor(self._weixin_connector_root(), user_id)
6133
+ applied_baseline = max(0, min(int(baseline), len(records)))
6134
+ return records[applied_baseline:], len(records)
6135
+
6136
+ @staticmethod
6137
+ def _weixin_replay_payload(record: dict[str, Any]) -> dict[str, Any]:
6138
+ attachments = [dict(item) for item in (record.get("attachments") or []) if isinstance(item, dict)]
6139
+ surface_actions = [dict(item) for item in (record.get("surface_actions") or []) if isinstance(item, dict)]
6140
+ connector_hints = dict(record.get("connector_hints")) if isinstance(record.get("connector_hints"), dict) else {}
6141
+ return {
6142
+ "conversation_id": record.get("conversation_id"),
6143
+ "reply_to_message_id": record.get("reply_to_message_id"),
6144
+ "kind": record.get("kind"),
6145
+ "message": str(record.get("text") or ""),
6146
+ "attachments": attachments,
6147
+ "surface_actions": surface_actions,
6148
+ "connector_hints": connector_hints,
6149
+ "quest_id": record.get("quest_id"),
6150
+ "quest_root": record.get("quest_root"),
6151
+ "importance": record.get("importance"),
6152
+ "response_phase": record.get("response_phase"),
6153
+ }
6154
+
6155
+ def _maybe_replay_weixin_pending_outbox(self, message: dict[str, Any]) -> dict[str, Any] | None:
6156
+ conversation_id = str(message.get("conversation_id") or "").strip()
6157
+ sender_id = str(message.get("sender_id") or message.get("direct_id") or "").strip()
6158
+ if not conversation_id or not sender_id:
6159
+ return None
6160
+ config = self.connectors_config.get("weixin", {})
6161
+ resolved = dict(config) if isinstance(config, dict) else {}
6162
+ limit = self._weixin_replay_limit(resolved)
6163
+ if limit <= 0:
6164
+ return {"replayed_count": 0, "dropped_count": 0, "total_pending": 0}
6165
+ pending_records, total_count = self._weixin_pending_outbox_records(conversation_id, user_id=sender_id)
6166
+ if not pending_records:
6167
+ return {"replayed_count": 0, "dropped_count": 0, "total_pending": 0}
6168
+ selected_records = pending_records[-limit:]
6169
+ dropped_count = max(0, len(pending_records) - len(selected_records))
6170
+ update_weixin_replay_cursor(
6171
+ self._weixin_connector_root(),
6172
+ user_id=sender_id,
6173
+ queued_replay_cursor=total_count,
6174
+ last_replay_trigger_message_id=str(message.get("message_id") or "").strip() or None,
6175
+ last_replayed_count=len(selected_records),
6176
+ last_replay_dropped_count=dropped_count,
6177
+ )
6178
+ channel = self._channel_with_bindings("weixin")
6179
+ interval_seconds = self._weixin_replay_interval_seconds(resolved)
6180
+ for index, record in enumerate(selected_records):
6181
+ channel.send(self._weixin_replay_payload(record))
6182
+ if index + 1 < len(selected_records) and interval_seconds > 0:
6183
+ time.sleep(interval_seconds)
6184
+ self.logger.log(
6185
+ "info",
6186
+ "connector.weixin_replay",
6187
+ conversation_id=conversation_id,
6188
+ replayed_count=len(selected_records),
6189
+ dropped_count=dropped_count,
6190
+ trigger_message_id=str(message.get("message_id") or "").strip() or None,
6191
+ )
6192
+ return {
6193
+ "replayed_count": len(selected_records),
6194
+ "dropped_count": dropped_count,
6195
+ "total_pending": len(pending_records),
6196
+ }
6197
+
5293
6198
  def _lingzhu_state_path(self) -> Path:
5294
6199
  return self.home / "logs" / "connectors" / "lingzhu" / "metis_state.json"
5295
6200
 
@@ -5449,6 +6354,13 @@ class DaemonApp:
5449
6354
  return emitted
5450
6355
 
5451
6356
  def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
6357
+ normalized_quest_id = str(quest_id or "").strip()
6358
+ if normalized_quest_id:
6359
+ snapshot = self.quest_service.snapshot_fast(normalized_quest_id)
6360
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
6361
+ if runtime_status in {"running", "active"}:
6362
+ return "进行中"
6363
+ return self._lingzhu_status_hint_text(normalized_quest_id)
5452
6364
  return self._lingzhu_status_hint_text(quest_id)
5453
6365
 
5454
6366
  @staticmethod
@@ -5717,6 +6629,33 @@ class DaemonApp:
5717
6629
  handler.wfile.write(b"\n")
5718
6630
  handler.wfile.flush()
5719
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
+
5720
6659
  @staticmethod
5721
6660
  def _parse_bash_log_jsonl_line(raw_line: bytes) -> dict[str, Any] | None:
5722
6661
  stripped = raw_line.strip()
@@ -5730,6 +6669,19 @@ class DaemonApp:
5730
6669
  return None
5731
6670
  return payload
5732
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
+
5733
6685
  @classmethod
5734
6686
  def _read_bash_log_delta(
5735
6687
  cls,
@@ -5773,6 +6725,42 @@ class DaemonApp:
5773
6725
 
5774
6726
  return fresh_entries, next_offset, remainder
5775
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
+
5776
6764
  def stream_quest_events(
5777
6765
  self,
5778
6766
  handler: BaseHTTPRequestHandler,
@@ -5780,6 +6768,7 @@ class DaemonApp:
5780
6768
  quest_id: str,
5781
6769
  path: str,
5782
6770
  headers: dict[str, str] | None = None,
6771
+ extra_headers: dict[str, str] | None = None,
5783
6772
  ) -> None:
5784
6773
  query = self.handlers.parse_query(path)
5785
6774
  after = int((query.get("after") or ["0"])[0] or "0")
@@ -5789,46 +6778,132 @@ class DaemonApp:
5789
6778
  last_event_id = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
5790
6779
  current_cursor = max(after, int(last_event_id)) if last_event_id.isdigit() else after
5791
6780
  heartbeat_at = time.monotonic()
5792
- idle_sleep_seconds = 0.35
6781
+ idle_sleep_seconds = 0.08
6782
+ force_fetch = True
6783
+ event_path = self.quest_service._quest_root(quest_id) / ".ds" / "events.jsonl"
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""
5793
6790
 
5794
6791
  handler.send_response(200)
5795
6792
  handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
5796
6793
  handler.send_header("Cache-Control", "no-cache, no-transform")
5797
6794
  handler.send_header("Connection", "keep-alive")
5798
6795
  handler.send_header("X-Accel-Buffering", "no")
6796
+ for key, value in (extra_headers or {}).items():
6797
+ handler.send_header(key, value)
5799
6798
  handler.end_headers()
5800
6799
  handler.wfile.write(b"retry: 1000\n\n")
5801
6800
  handler.wfile.flush()
5802
6801
 
5803
6802
  try:
5804
6803
  while True:
5805
- stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
5806
- payload = self.handlers.quest_events(quest_id, path=stream_path)
5807
- updates = payload.get("acp_updates") or []
5808
- if updates:
5809
- for update in updates:
5810
- update_cursor = str(((update.get("params") or {}).get("update") or {}).get("cursor") or "")
6804
+ current_event_state = self.quest_service._path_state(event_path)
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
6862
+ stream_path = f"/api/quests/{quest_id}/events?{urlencode({'after': current_cursor, 'limit': limit, 'format': format_name, 'session_id': session_id})}"
6863
+ payload = self.handlers.quest_events(quest_id, path=stream_path)
6864
+ previous_event_state = current_event_state
6865
+ updates = payload.get("acp_updates") or []
6866
+ if updates:
6867
+ for update in updates:
6868
+ update_cursor = str(((update.get("params") or {}).get("update") or {}).get("cursor") or "")
6869
+ self._write_sse_event(
6870
+ handler,
6871
+ event="acp_update",
6872
+ data=update,
6873
+ event_id=update_cursor or None,
6874
+ )
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
5811
6881
  self._write_sse_event(
5812
6882
  handler,
5813
- event="acp_update",
5814
- data=update,
5815
- event_id=update_cursor or None,
6883
+ event="cursor",
6884
+ data={"cursor": current_cursor, "quest_id": quest_id},
5816
6885
  )
5817
- current_cursor = int(payload.get("cursor") or current_cursor)
5818
- self._write_sse_event(
5819
- handler,
5820
- event="cursor",
5821
- data={"cursor": current_cursor, "quest_id": quest_id},
5822
- )
5823
- heartbeat_at = time.monotonic()
5824
- idle_sleep_seconds = 0.2
6886
+ heartbeat_at = time.monotonic()
6887
+ force_fetch = bool(payload.get("has_more"))
6888
+ idle_sleep_seconds = 0.03 if force_fetch else 0.08
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
6893
+ force_fetch = False
6894
+ now = time.monotonic()
6895
+ if now - heartbeat_at >= 10:
6896
+ handler.wfile.write(b": keep-alive\n\n")
6897
+ handler.wfile.flush()
6898
+ heartbeat_at = now
6899
+ idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
5825
6900
  else:
5826
6901
  now = time.monotonic()
5827
6902
  if now - heartbeat_at >= 10:
5828
6903
  handler.wfile.write(b": keep-alive\n\n")
5829
6904
  handler.wfile.flush()
5830
6905
  heartbeat_at = now
5831
- idle_sleep_seconds = min(1.5, idle_sleep_seconds * 1.35)
6906
+ idle_sleep_seconds = min(0.9, idle_sleep_seconds * 1.25)
5832
6907
  time.sleep(idle_sleep_seconds)
5833
6908
  except (BrokenPipeError, ConnectionResetError, TimeoutError):
5834
6909
  return
@@ -5839,6 +6914,7 @@ class DaemonApp:
5839
6914
  *,
5840
6915
  quest_id: str,
5841
6916
  path: str,
6917
+ extra_headers: dict[str, str] | None = None,
5842
6918
  ) -> None:
5843
6919
  quest_root = self.quest_service._quest_root(quest_id)
5844
6920
  query = self.handlers.parse_query(path)
@@ -5875,55 +6951,83 @@ class DaemonApp:
5875
6951
  handler.send_header("Cache-Control", "no-cache, no-transform")
5876
6952
  handler.send_header("Connection", "keep-alive")
5877
6953
  handler.send_header("X-Accel-Buffering", "no")
6954
+ for key, value in (extra_headers or {}).items():
6955
+ handler.send_header(key, value)
5878
6956
  handler.end_headers()
5879
6957
  handler.wfile.write(b"retry: 1000\n\n")
5880
6958
  handler.wfile.flush()
5881
6959
 
5882
6960
  previous_snapshot: dict[str, dict[str, object]] = {}
5883
6961
  heartbeat_at = time.monotonic()
6962
+ summary_path = self.bash_exec_service.summary_path(quest_root)
6963
+ index_path = self.bash_exec_service.index_path(quest_root)
6964
+ previous_summary_state = None
6965
+ previous_index_state = None
6966
+ has_active_sessions = False
6967
+ last_full_refresh_at = 0.0
5884
6968
  try:
5885
6969
  while True:
5886
- sessions = list_payload()
5887
- current_snapshot = {
5888
- str(item.get("bash_id") or ""): item
5889
- for item in sessions
5890
- if item.get("bash_id")
5891
- }
5892
- if not previous_snapshot:
5893
- self._write_sse_event(
5894
- handler,
5895
- event="snapshot",
5896
- data={"sessions": sessions},
6970
+ current_summary_state = self.quest_service._path_state(summary_path)
6971
+ current_index_state = self.quest_service._path_state(index_path)
6972
+ should_refresh = (
6973
+ not previous_snapshot
6974
+ or current_summary_state != previous_summary_state
6975
+ or current_index_state != previous_index_state
6976
+ or (has_active_sessions and time.monotonic() - last_full_refresh_at >= 3.0)
6977
+ )
6978
+ if should_refresh:
6979
+ sessions = list_payload()
6980
+ current_snapshot = {
6981
+ str(item.get("bash_id") or ""): item
6982
+ for item in sessions
6983
+ if item.get("bash_id")
6984
+ }
6985
+ has_active_sessions = any(
6986
+ str(item.get("status") or "").strip().lower() in {"running", "terminating"}
6987
+ for item in current_snapshot.values()
5897
6988
  )
5898
- previous_snapshot = current_snapshot
5899
- heartbeat_at = time.monotonic()
5900
- else:
5901
- changed = [
5902
- session
5903
- for bash_id, session in current_snapshot.items()
5904
- if previous_snapshot.get(bash_id) != session
5905
- ]
5906
- removed = set(previous_snapshot) - set(current_snapshot)
5907
- for session in changed:
5908
- self._write_sse_event(
5909
- handler,
5910
- event="session",
5911
- data={"session": session},
5912
- )
5913
- for bash_id in removed:
6989
+ previous_summary_state = self.quest_service._path_state(summary_path)
6990
+ previous_index_state = self.quest_service._path_state(index_path)
6991
+ last_full_refresh_at = time.monotonic()
6992
+ if not previous_snapshot:
5914
6993
  self._write_sse_event(
5915
6994
  handler,
5916
- event="session",
5917
- data={"session": {"bash_id": bash_id, "status": "terminated"}},
6995
+ event="snapshot",
6996
+ data={"sessions": sessions},
5918
6997
  )
5919
- if changed or removed:
5920
6998
  previous_snapshot = current_snapshot
5921
6999
  heartbeat_at = time.monotonic()
5922
- elif time.monotonic() - heartbeat_at >= 10:
5923
- handler.wfile.write(b": keep-alive\n\n")
5924
- handler.wfile.flush()
5925
- heartbeat_at = time.monotonic()
5926
- time.sleep(0.4)
7000
+ else:
7001
+ changed = [
7002
+ session
7003
+ for bash_id, session in current_snapshot.items()
7004
+ if previous_snapshot.get(bash_id) != session
7005
+ ]
7006
+ removed = set(previous_snapshot) - set(current_snapshot)
7007
+ for session in changed:
7008
+ self._write_sse_event(
7009
+ handler,
7010
+ event="session",
7011
+ data={"session": session},
7012
+ )
7013
+ for bash_id in removed:
7014
+ self._write_sse_event(
7015
+ handler,
7016
+ event="session",
7017
+ data={"session": {"bash_id": bash_id, "status": "terminated"}},
7018
+ )
7019
+ if changed or removed:
7020
+ previous_snapshot = current_snapshot
7021
+ heartbeat_at = time.monotonic()
7022
+ elif time.monotonic() - heartbeat_at >= 10:
7023
+ handler.wfile.write(b": keep-alive\n\n")
7024
+ handler.wfile.flush()
7025
+ heartbeat_at = time.monotonic()
7026
+ elif time.monotonic() - heartbeat_at >= 10:
7027
+ handler.wfile.write(b": keep-alive\n\n")
7028
+ handler.wfile.flush()
7029
+ heartbeat_at = time.monotonic()
7030
+ time.sleep(0.5 if has_active_sessions else 2.0)
5927
7031
  except (BrokenPipeError, ConnectionResetError, TimeoutError):
5928
7032
  return
5929
7033
 
@@ -5934,6 +7038,7 @@ class DaemonApp:
5934
7038
  quest_id: str,
5935
7039
  bash_id: str,
5936
7040
  headers: dict[str, str] | None = None,
7041
+ extra_headers: dict[str, str] | None = None,
5937
7042
  ) -> None:
5938
7043
  quest_root = self.quest_service._quest_root(quest_id)
5939
7044
  last_event_raw = str((headers or {}).get("Last-Event-ID") or (headers or {}).get("last-event-id") or "").strip()
@@ -5944,6 +7049,8 @@ class DaemonApp:
5944
7049
  handler.send_header("Cache-Control", "no-cache, no-transform")
5945
7050
  handler.send_header("Connection", "keep-alive")
5946
7051
  handler.send_header("X-Accel-Buffering", "no")
7052
+ for key, value in (extra_headers or {}).items():
7053
+ handler.send_header(key, value)
5947
7054
  handler.end_headers()
5948
7055
  handler.wfile.write(b"retry: 1000\n\n")
5949
7056
  handler.wfile.flush()
@@ -6144,35 +7251,84 @@ class DaemonApp:
6144
7251
  if route_name is None:
6145
7252
  self._write_json(404, {"ok": False, "message": "Not Found"})
6146
7253
  return
6147
- 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):
6148
7274
  try:
6149
- 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)
6150
7276
  except Exception as exc:
6151
- 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
6152
7284
  return
6153
7285
  if route_name == "bash_sessions_stream":
6154
7286
  try:
6155
- app.stream_bash_sessions(self, **params, path=self.path)
7287
+ app.stream_bash_sessions(self, **params, path=self.path, extra_headers=auth_headers)
6156
7288
  except Exception as exc:
6157
- 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
6158
7296
  return
6159
7297
  if route_name == "bash_log_stream":
6160
7298
  try:
6161
- 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)
6162
7300
  except Exception as exc:
6163
- 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
6164
7308
  return
6165
7309
  if route_name == "terminal_stream":
6166
7310
  try:
6167
- 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
+ )
6168
7318
  except Exception as exc:
6169
- 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
6170
7326
  return
6171
7327
  if route_name == "lingzhu_sse":
6172
7328
  content_length = int(self.headers.get("Content-Length", "0"))
6173
7329
  raw_body = self.rfile.read(content_length) if content_length else b""
6174
7330
  try:
6175
- 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)
6176
7332
  except Exception as exc:
6177
7333
  self._write_json(500, {"ok": False, "message": str(exc)})
6178
7334
  return
@@ -6187,11 +7343,12 @@ class DaemonApp:
6187
7343
  result = getattr(app.handlers, route_name)
6188
7344
  if route_name == "asset":
6189
7345
  status, headers, content = result(**params)
6190
- self.send_response(status)
6191
- for key, value in headers.items():
6192
- self.send_header(key, value)
6193
- self.end_headers()
6194
- 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
+ )
6195
7352
  return
6196
7353
  if route_name in {
6197
7354
  "quest_events",
@@ -6218,7 +7375,7 @@ class DaemonApp:
6218
7375
  payload = result(**params, path=self.path)
6219
7376
  elif method == "GET":
6220
7377
  payload = result(**params) if params else result()
6221
- 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"}:
6222
7379
  payload = result(**params, body=body)
6223
7380
  elif route_name == "config_validate":
6224
7381
  payload = result(body)
@@ -6231,33 +7388,43 @@ class DaemonApp:
6231
7388
  else:
6232
7389
  payload = result(**params) if params else result()
6233
7390
  except Exception as exc:
6234
- self._write_json(500, {"ok": False, "message": str(exc)})
7391
+ self._write_json(500, {"ok": False, "message": str(exc)}, extra_headers=auth_headers)
6235
7392
  return
6236
7393
 
6237
7394
  if isinstance(payload, tuple) and len(payload) == 2:
6238
7395
  status, body = payload
6239
- self._write_json(status, body)
7396
+ self._write_json(status, body, extra_headers=auth_headers)
6240
7397
  return
6241
7398
  if isinstance(payload, tuple) and len(payload) == 3:
6242
7399
  status, headers, content = payload
6243
- self.send_response(status)
6244
- for key, value in headers.items():
6245
- self.send_header(key, value)
6246
- self.end_headers()
6247
7400
  if isinstance(content, str):
6248
- self.wfile.write(content.encode("utf-8"))
7401
+ encoded = content.encode("utf-8")
6249
7402
  else:
6250
- 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
+ )
6251
7410
  return
6252
- self._write_json(200, payload)
6253
-
6254
- 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:
6255
7420
  encoded = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
6256
- self.send_response(code)
6257
- self.send_header("Content-Type", "application/json; charset=utf-8")
6258
- self.send_header("Content-Length", str(len(encoded)))
6259
- self.end_headers()
6260
- 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
+ )
6261
7428
 
6262
7429
  server = ThreadingHTTPServer((host, port), RequestHandler)
6263
7430
  server.daemon_threads = True
@@ -6269,6 +7436,8 @@ class DaemonApp:
6269
7436
  self._start_background_connectors()
6270
7437
  self._resume_reconciled_quests()
6271
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}")
6272
7441
  try:
6273
7442
  server.serve_forever()
6274
7443
  except KeyboardInterrupt: