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