@researai/deepscientist 1.5.0 → 1.5.1
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/AGENTS.md +26 -0
- package/README.md +19 -179
- package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
- package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
- package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
- package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
- package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
- package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
- package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
- package/bin/ds.js +233 -53
- package/docs/en/00_QUICK_START.md +134 -0
- package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
- package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
- package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
- package/docs/en/05_TUI_GUIDE.md +141 -0
- package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
- package/docs/en/07_MEMORY_AND_MCP.md +253 -0
- package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
- package/docs/en/09_DOCTOR.md +108 -0
- package/docs/en/90_ARCHITECTURE.md +245 -0
- package/docs/en/91_DEVELOPMENT.md +195 -0
- package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
- package/docs/zh/00_QUICK_START.md +134 -0
- package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
- package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
- package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
- package/docs/zh/05_TUI_GUIDE.md +128 -0
- package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
- package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
- package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
- package/docs/zh/09_DOCTOR.md +112 -0
- package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
- package/install.sh +32 -8
- package/package.json +4 -2
- package/pyproject.toml +1 -1
- package/src/deepscientist/artifact/guidance.py +9 -2
- package/src/deepscientist/artifact/service.py +482 -22
- package/src/deepscientist/bash_exec/monitor.py +27 -5
- package/src/deepscientist/bash_exec/runtime.py +639 -0
- package/src/deepscientist/bash_exec/service.py +99 -16
- package/src/deepscientist/bridges/base.py +3 -0
- package/src/deepscientist/bridges/connectors.py +292 -13
- package/src/deepscientist/channels/qq.py +19 -2
- package/src/deepscientist/channels/relay.py +1 -0
- package/src/deepscientist/cli.py +32 -25
- package/src/deepscientist/config/models.py +28 -2
- package/src/deepscientist/config/service.py +201 -6
- package/src/deepscientist/connector_runtime.py +2 -0
- package/src/deepscientist/daemon/api/handlers.py +50 -5
- package/src/deepscientist/daemon/api/router.py +1 -0
- package/src/deepscientist/daemon/app.py +442 -15
- package/src/deepscientist/doctor.py +444 -0
- package/src/deepscientist/home.py +1 -0
- package/src/deepscientist/latex_runtime.py +17 -4
- package/src/deepscientist/lingzhu_support.py +182 -0
- package/src/deepscientist/mcp/server.py +49 -2
- package/src/deepscientist/prompts/builder.py +181 -58
- package/src/deepscientist/quest/layout.py +1 -0
- package/src/deepscientist/quest/service.py +63 -2
- package/src/deepscientist/quest/stage_views.py +19 -1
- package/src/deepscientist/runtime_tools/__init__.py +16 -0
- package/src/deepscientist/runtime_tools/builtins.py +19 -0
- package/src/deepscientist/runtime_tools/models.py +29 -0
- package/src/deepscientist/runtime_tools/registry.py +40 -0
- package/src/deepscientist/runtime_tools/service.py +59 -0
- package/src/deepscientist/runtime_tools/tinytex.py +25 -0
- package/src/deepscientist/tinytex.py +276 -0
- package/src/prompts/connectors/lingzhu.md +12 -0
- package/src/prompts/connectors/qq.md +121 -0
- package/src/prompts/system.md +177 -33
- package/src/skills/analysis-campaign/SKILL.md +22 -6
- package/src/skills/baseline/SKILL.md +5 -4
- package/src/skills/decision/SKILL.md +4 -3
- package/src/skills/experiment/SKILL.md +5 -4
- package/src/skills/finalize/SKILL.md +5 -4
- package/src/skills/idea/SKILL.md +5 -4
- package/src/skills/intake-audit/SKILL.md +277 -0
- package/src/skills/intake-audit/references/state-audit-template.md +41 -0
- package/src/skills/rebuttal/SKILL.md +407 -0
- package/src/skills/rebuttal/references/action-plan-template.md +63 -0
- package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
- package/src/skills/rebuttal/references/response-letter-template.md +113 -0
- package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
- package/src/skills/review/SKILL.md +293 -0
- package/src/skills/review/references/experiment-todo-template.md +29 -0
- package/src/skills/review/references/review-report-template.md +83 -0
- package/src/skills/review/references/revision-log-template.md +40 -0
- package/src/skills/scout/SKILL.md +5 -4
- package/src/skills/write/SKILL.md +7 -3
- package/src/tui/dist/components/WelcomePanel.js +17 -43
- package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-w5lF2Ttt.js} +109 -575
- package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-DJOED79I.js} +1 -1
- package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-DaG61Y0M.js} +63 -8
- package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CV4LqUB_.js} +43 -609
- package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DylfAea4.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-F7saY0LM.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-COP0c7jf.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-CAS05pT9.js} +1 -1
- package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-Bco1CN_w.js} +5 -6
- package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-CvMlCD99.js} +12 -15
- package/src/ui/dist/assets/LabPlugin-BYankkE4.js +2676 -0
- package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +2698 -0
- package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-LDSMR-t-.js} +16 -16
- package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-B7o80jgm.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-CM6ZOcpC.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-Dc61cXmK.js} +3 -3
- package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-DWowuQwx.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-BsJM1q_a.js} +3 -3
- package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-DB2eEEFQ.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-CraThSvt.js} +1 -1
- package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-CgocRTPq.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-B1JGhKtd.js} +4 -4
- package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-CclFC7FM.js} +9 -10
- package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-D3IKsMl7.js} +1 -1
- package/src/ui/dist/assets/{code-BnBeNxBc.js → code-BP37Xx0p.js} +1 -1
- package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-BAJSu-9r.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-DUGeCTuy.js} +1 -1
- package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CXc1Ojf7.js} +1 -1
- package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-2J21jt7M.js} +1 -1
- package/src/ui/dist/assets/{image-Boe6ffhu.js → image-CMMmgvcn.js} +1 -1
- package/src/ui/dist/assets/{index-BlplpvE1.js → index-BaVumsQT.js} +2 -2
- package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-CWgMgpow.js} +60 -2154
- package/src/ui/dist/assets/{index-DO43pFZP.js → index-DmwmJmbW.js} +6372 -8434
- package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-KGt-z-dD.css} +225 -2920
- package/src/ui/dist/assets/{index-2Zf65FZt.js → index-s7aHnNQ4.js} +1 -1
- package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-CQRfX0Am.js} +1 -1
- package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-B4TbdsrF.js} +1 -1
- package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-B8Rokodk.js} +1 -1
- package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-D_i96KH4.js} +2 -8
- package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-D12PnzCN.js} +1 -1
- package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-B6YrI4aJ.js} +1 -1
- package/src/ui/dist/assets/trash-Bc8jGp0V.js +32 -0
- package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-mXVCYSZ-.js} +12 -42
- package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-Bg6b9H9K.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Drh5GEnL.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-CJj9DZLn.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/assets/fonts/Inter-Variable.ttf +0 -0
- package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
- package/assets/fonts/NunitoSans-Variable.ttf +0 -0
- package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
- package/assets/fonts/SourceSans3-Variable.ttf +0 -0
- package/assets/fonts/ds-fonts.css +0 -83
- package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
- package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
- package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
- package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
- package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
- package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
- package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
- package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import codecs
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pty
|
|
7
|
+
import select
|
|
8
|
+
import shlex
|
|
9
|
+
import signal
|
|
10
|
+
import struct
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import termios
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections import deque
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..shared import append_jsonl, ensure_dir, generate_id, read_json, utc_now
|
|
22
|
+
|
|
23
|
+
BASH_STATUS_MARKER_PREFIX = "__DS_BASH_STATUS__"
|
|
24
|
+
BASH_CARRIAGE_RETURN_PREFIX = "__DS_BASH_CR__"
|
|
25
|
+
BASH_PROGRESS_PREFIX = "__DS_PROGRESS__"
|
|
26
|
+
BASH_TERMINAL_PROMPT_PREFIX = "__DS_TERMINAL_PROMPT__"
|
|
27
|
+
TERMINAL_FINAL_STATUSES = {"completed", "failed", "terminated"}
|
|
28
|
+
TERMINAL_REPLAY_LIMIT_BYTES = 1_500_000
|
|
29
|
+
TERMINAL_RUNTIME_POLL_SECONDS = 0.02
|
|
30
|
+
TERMINAL_STOP_GRACE_SECONDS = 5.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _normalize_string(value: object) -> str:
|
|
34
|
+
return str(value or "").strip()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _coerce_session_status(value: object) -> str:
|
|
38
|
+
normalized = _normalize_string(value).lower()
|
|
39
|
+
if normalized in TERMINAL_FINAL_STATUSES | {"running", "terminating"}:
|
|
40
|
+
return normalized
|
|
41
|
+
return "failed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_progress_marker(line: str) -> dict[str, Any] | None:
|
|
45
|
+
if not line.startswith(BASH_PROGRESS_PREFIX):
|
|
46
|
+
return None
|
|
47
|
+
raw = line[len(BASH_PROGRESS_PREFIX) :].strip()
|
|
48
|
+
if not raw:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
payload = json.loads(raw)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
return None
|
|
54
|
+
return payload if isinstance(payload, dict) else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _safe_reason(reason: str | None) -> str:
|
|
58
|
+
if not reason:
|
|
59
|
+
return "none"
|
|
60
|
+
return reason.replace('"', '\\"').replace("\n", "\\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _status_marker(meta: dict[str, Any], *, status: str, exit_code: int | None, reason: str | None) -> str:
|
|
64
|
+
return (
|
|
65
|
+
f"{BASH_STATUS_MARKER_PREFIX} status={status} bash_id={meta.get('bash_id')} ts={utc_now()} "
|
|
66
|
+
f"user_id={meta.get('started_by_user_id') or 'agent'} session_id={meta.get('session_id') or 'none'} "
|
|
67
|
+
f"agent_id={meta.get('agent_id') or 'none'} agent_instance_id={meta.get('agent_instance_id') or 'none'} "
|
|
68
|
+
f"exit_code={exit_code if exit_code is not None else 'none'} reason=\"{_safe_reason(reason)}\""
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_terminal_prompt_marker(line: str) -> dict[str, str] | None:
|
|
73
|
+
if not line.startswith(BASH_TERMINAL_PROMPT_PREFIX):
|
|
74
|
+
return None
|
|
75
|
+
raw = line[len(BASH_TERMINAL_PROMPT_PREFIX) :].strip()
|
|
76
|
+
if not raw:
|
|
77
|
+
return None
|
|
78
|
+
payload: dict[str, str] = {}
|
|
79
|
+
try:
|
|
80
|
+
for token in shlex.split(raw):
|
|
81
|
+
if "=" not in token:
|
|
82
|
+
continue
|
|
83
|
+
key, value = token.split("=", 1)
|
|
84
|
+
payload[key.strip()] = value
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
87
|
+
return payload or None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _atomic_write_json(path: Path, payload: Any) -> None:
|
|
91
|
+
ensure_dir(path.parent)
|
|
92
|
+
with tempfile.NamedTemporaryFile(
|
|
93
|
+
"w",
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
dir=path.parent,
|
|
96
|
+
prefix=f"{path.name}.",
|
|
97
|
+
suffix=".tmp",
|
|
98
|
+
delete=False,
|
|
99
|
+
) as handle:
|
|
100
|
+
handle.write(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False) + "\n")
|
|
101
|
+
temp_path = Path(handle.name)
|
|
102
|
+
temp_path.replace(path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _append_jsonl(path: Path, payload: dict[str, Any]) -> None:
|
|
106
|
+
ensure_dir(path.parent)
|
|
107
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
108
|
+
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _kill_process_group(process_group_id: int | None, process: subprocess.Popen[bytes] | None) -> None:
|
|
112
|
+
if isinstance(process_group_id, int) and process_group_id > 0:
|
|
113
|
+
try:
|
|
114
|
+
os.killpg(process_group_id, signal.SIGTERM)
|
|
115
|
+
except ProcessLookupError:
|
|
116
|
+
return
|
|
117
|
+
elif process is not None and process.poll() is None:
|
|
118
|
+
process.terminate()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _kill_process_group_force(process_group_id: int | None, process: subprocess.Popen[bytes] | None) -> None:
|
|
122
|
+
if isinstance(process_group_id, int) and process_group_id > 0:
|
|
123
|
+
try:
|
|
124
|
+
os.killpg(process_group_id, signal.SIGKILL)
|
|
125
|
+
except ProcessLookupError:
|
|
126
|
+
return
|
|
127
|
+
elif process is not None and process.poll() is None:
|
|
128
|
+
process.kill()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _drain_buffer(
|
|
132
|
+
buffer: str,
|
|
133
|
+
append_line,
|
|
134
|
+
*,
|
|
135
|
+
flush_partial: bool = False,
|
|
136
|
+
carriage_mode: str = "stream",
|
|
137
|
+
) -> str:
|
|
138
|
+
while True:
|
|
139
|
+
index_r = buffer.find("\r")
|
|
140
|
+
index_n = buffer.find("\n")
|
|
141
|
+
if index_r == -1 and index_n == -1:
|
|
142
|
+
break
|
|
143
|
+
if index_r != -1 and (index_n == -1 or index_r < index_n):
|
|
144
|
+
segment = buffer[:index_r]
|
|
145
|
+
if index_r + 1 < len(buffer) and buffer[index_r + 1] == "\n":
|
|
146
|
+
buffer = buffer[index_r + 2 :]
|
|
147
|
+
append_line(segment)
|
|
148
|
+
else:
|
|
149
|
+
buffer = buffer[index_r + 1 :]
|
|
150
|
+
if carriage_mode == "stream":
|
|
151
|
+
append_line(segment, stream="carriage")
|
|
152
|
+
else:
|
|
153
|
+
append_line(f"{BASH_CARRIAGE_RETURN_PREFIX}{segment}")
|
|
154
|
+
continue
|
|
155
|
+
segment = buffer[:index_n]
|
|
156
|
+
buffer = buffer[index_n + 1 :]
|
|
157
|
+
append_line(segment)
|
|
158
|
+
if flush_partial and buffer:
|
|
159
|
+
append_line(buffer, stream="partial")
|
|
160
|
+
return ""
|
|
161
|
+
return buffer
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(slots=True)
|
|
165
|
+
class AttachToken:
|
|
166
|
+
token: str
|
|
167
|
+
quest_root: Path
|
|
168
|
+
bash_id: str
|
|
169
|
+
expires_at: float
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(slots=True)
|
|
173
|
+
class TerminalClient:
|
|
174
|
+
client_id: str
|
|
175
|
+
send_text: Any
|
|
176
|
+
send_binary: Any
|
|
177
|
+
close: Any
|
|
178
|
+
send_lock: threading.Lock
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TerminalRuntime:
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
quest_root: Path,
|
|
186
|
+
bash_id: str,
|
|
187
|
+
meta_path: Path,
|
|
188
|
+
log_path: Path,
|
|
189
|
+
terminal_log_path: Path,
|
|
190
|
+
prompt_events_path: Path,
|
|
191
|
+
env_payload: dict[str, str],
|
|
192
|
+
command: str,
|
|
193
|
+
cwd: Path,
|
|
194
|
+
on_finished,
|
|
195
|
+
) -> None:
|
|
196
|
+
self.quest_root = quest_root
|
|
197
|
+
self.bash_id = bash_id
|
|
198
|
+
self.meta_path = meta_path
|
|
199
|
+
self.log_path = log_path
|
|
200
|
+
self.terminal_log_path = terminal_log_path
|
|
201
|
+
self.prompt_events_path = prompt_events_path
|
|
202
|
+
self.env_payload = dict(env_payload)
|
|
203
|
+
self.command = command
|
|
204
|
+
self.cwd = cwd
|
|
205
|
+
self._on_finished = on_finished
|
|
206
|
+
self._clients: dict[str, TerminalClient] = {}
|
|
207
|
+
self._clients_lock = threading.Lock()
|
|
208
|
+
self._write_lock = threading.Lock()
|
|
209
|
+
self._replay_lock = threading.Lock()
|
|
210
|
+
self._state_lock = threading.Lock()
|
|
211
|
+
self._stop_event = threading.Event()
|
|
212
|
+
self._reader_thread: threading.Thread | None = None
|
|
213
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
214
|
+
self._process_group_id: int | None = None
|
|
215
|
+
self._master_fd: int | None = None
|
|
216
|
+
self._replay_chunks: deque[bytes] = deque()
|
|
217
|
+
self._replay_bytes = 0
|
|
218
|
+
self._prompt_offset = 0
|
|
219
|
+
self._prompt_remainder = b""
|
|
220
|
+
|
|
221
|
+
def start(self) -> dict[str, Any]:
|
|
222
|
+
ensure_dir(self.meta_path.parent)
|
|
223
|
+
ensure_dir(self.log_path.parent)
|
|
224
|
+
self.terminal_log_path.touch()
|
|
225
|
+
self.log_path.touch()
|
|
226
|
+
self.prompt_events_path.touch()
|
|
227
|
+
master_fd, slave_fd = pty.openpty()
|
|
228
|
+
env_payload = os.environ.copy()
|
|
229
|
+
env_payload.update(self.env_payload)
|
|
230
|
+
env_payload.setdefault("PYTHONUNBUFFERED", "1")
|
|
231
|
+
env_payload.setdefault("TERM", "xterm-256color")
|
|
232
|
+
env_payload.setdefault("COLORTERM", "truecolor")
|
|
233
|
+
process = subprocess.Popen(
|
|
234
|
+
["bash", "-lc", self.command],
|
|
235
|
+
cwd=str(self.cwd),
|
|
236
|
+
env=env_payload,
|
|
237
|
+
stdin=slave_fd,
|
|
238
|
+
stdout=slave_fd,
|
|
239
|
+
stderr=slave_fd,
|
|
240
|
+
start_new_session=True,
|
|
241
|
+
)
|
|
242
|
+
os.close(slave_fd)
|
|
243
|
+
os.set_blocking(master_fd, False)
|
|
244
|
+
process_group_id = os.getpgid(process.pid)
|
|
245
|
+
with self._state_lock:
|
|
246
|
+
self._master_fd = master_fd
|
|
247
|
+
self._process = process
|
|
248
|
+
self._process_group_id = process_group_id
|
|
249
|
+
meta = read_json(self.meta_path, {}) or {}
|
|
250
|
+
meta["monitor_pid"] = None
|
|
251
|
+
meta["process_pid"] = process.pid
|
|
252
|
+
meta["process_group_id"] = process_group_id
|
|
253
|
+
meta["status"] = "running"
|
|
254
|
+
meta["updated_at"] = utc_now()
|
|
255
|
+
_atomic_write_json(self.meta_path, meta)
|
|
256
|
+
self._append_log_entry(_status_marker(meta, status="running", exit_code=None, reason="none"), stream="system")
|
|
257
|
+
self._reader_thread = threading.Thread(
|
|
258
|
+
target=self._reader_loop,
|
|
259
|
+
daemon=True,
|
|
260
|
+
name=f"terminal-runtime-{self.bash_id}",
|
|
261
|
+
)
|
|
262
|
+
self._reader_thread.start()
|
|
263
|
+
return meta
|
|
264
|
+
|
|
265
|
+
def is_alive(self) -> bool:
|
|
266
|
+
process = self._process
|
|
267
|
+
return process is not None and process.poll() is None and self._master_fd is not None
|
|
268
|
+
|
|
269
|
+
def snapshot_replay(self) -> list[bytes]:
|
|
270
|
+
with self._replay_lock:
|
|
271
|
+
return list(self._replay_chunks)
|
|
272
|
+
|
|
273
|
+
def attach_client(self, client: TerminalClient) -> None:
|
|
274
|
+
with self._clients_lock:
|
|
275
|
+
self._clients[client.client_id] = client
|
|
276
|
+
|
|
277
|
+
def detach_client(self, client_id: str) -> None:
|
|
278
|
+
with self._clients_lock:
|
|
279
|
+
self._clients.pop(client_id, None)
|
|
280
|
+
|
|
281
|
+
def write_input(self, data: str) -> None:
|
|
282
|
+
if not data:
|
|
283
|
+
return
|
|
284
|
+
payload = data.encode("utf-8")
|
|
285
|
+
self.write_binary_input(payload)
|
|
286
|
+
|
|
287
|
+
def write_binary_input(self, data: bytes) -> None:
|
|
288
|
+
if not data:
|
|
289
|
+
return
|
|
290
|
+
master_fd = self._master_fd
|
|
291
|
+
if master_fd is None or not self.is_alive():
|
|
292
|
+
raise RuntimeError("terminal_runtime_inactive")
|
|
293
|
+
with self._write_lock:
|
|
294
|
+
os.write(master_fd, data)
|
|
295
|
+
|
|
296
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
297
|
+
master_fd = self._master_fd
|
|
298
|
+
if master_fd is None or cols <= 0 or rows <= 0:
|
|
299
|
+
return
|
|
300
|
+
winsz = struct.pack("HHHH", rows, cols, 0, 0)
|
|
301
|
+
with self._write_lock:
|
|
302
|
+
termios.tcsetwinsize(master_fd, (rows, cols))
|
|
303
|
+
try:
|
|
304
|
+
import fcntl
|
|
305
|
+
|
|
306
|
+
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz)
|
|
307
|
+
except Exception:
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
def stop(self, *, reason: str = "runtime_shutdown", force: bool = False) -> None:
|
|
311
|
+
self._stop_event.set()
|
|
312
|
+
process = self._process
|
|
313
|
+
process_group_id = self._process_group_id
|
|
314
|
+
if force:
|
|
315
|
+
_kill_process_group_force(process_group_id, process)
|
|
316
|
+
return
|
|
317
|
+
_kill_process_group(process_group_id, process)
|
|
318
|
+
deadline = time.monotonic() + TERMINAL_STOP_GRACE_SECONDS
|
|
319
|
+
while time.monotonic() < deadline:
|
|
320
|
+
if process is None or process.poll() is not None:
|
|
321
|
+
return
|
|
322
|
+
time.sleep(0.05)
|
|
323
|
+
_kill_process_group_force(process_group_id, process)
|
|
324
|
+
|
|
325
|
+
def _append_replay(self, payload: bytes) -> None:
|
|
326
|
+
if not payload:
|
|
327
|
+
return
|
|
328
|
+
with self._replay_lock:
|
|
329
|
+
self._replay_chunks.append(payload)
|
|
330
|
+
self._replay_bytes += len(payload)
|
|
331
|
+
while self._replay_bytes > TERMINAL_REPLAY_LIMIT_BYTES and self._replay_chunks:
|
|
332
|
+
removed = self._replay_chunks.popleft()
|
|
333
|
+
self._replay_bytes -= len(removed)
|
|
334
|
+
|
|
335
|
+
def _broadcast_output(self, payload: bytes) -> None:
|
|
336
|
+
if not payload:
|
|
337
|
+
return
|
|
338
|
+
with self._clients_lock:
|
|
339
|
+
clients = list(self._clients.values())
|
|
340
|
+
stale: list[str] = []
|
|
341
|
+
for client in clients:
|
|
342
|
+
try:
|
|
343
|
+
with client.send_lock:
|
|
344
|
+
client.send_binary(payload)
|
|
345
|
+
except Exception:
|
|
346
|
+
stale.append(client.client_id)
|
|
347
|
+
if stale:
|
|
348
|
+
with self._clients_lock:
|
|
349
|
+
for client_id in stale:
|
|
350
|
+
self._clients.pop(client_id, None)
|
|
351
|
+
|
|
352
|
+
def _broadcast_control(self, payload: dict[str, Any]) -> None:
|
|
353
|
+
encoded = json.dumps(payload, ensure_ascii=False)
|
|
354
|
+
with self._clients_lock:
|
|
355
|
+
clients = list(self._clients.values())
|
|
356
|
+
stale: list[str] = []
|
|
357
|
+
for client in clients:
|
|
358
|
+
try:
|
|
359
|
+
with client.send_lock:
|
|
360
|
+
client.send_text(encoded)
|
|
361
|
+
except Exception:
|
|
362
|
+
stale.append(client.client_id)
|
|
363
|
+
if stale:
|
|
364
|
+
with self._clients_lock:
|
|
365
|
+
for client_id in stale:
|
|
366
|
+
self._clients.pop(client_id, None)
|
|
367
|
+
|
|
368
|
+
def _append_terminal_display(self, text: str) -> None:
|
|
369
|
+
if not text:
|
|
370
|
+
return
|
|
371
|
+
ensure_dir(self.terminal_log_path.parent)
|
|
372
|
+
with self.terminal_log_path.open("ab") as handle:
|
|
373
|
+
handle.write(text.encode("utf-8", errors="replace"))
|
|
374
|
+
|
|
375
|
+
def _append_log_entry(self, line: str, *, stream: str = "stdout") -> None:
|
|
376
|
+
meta = read_json(self.meta_path, {}) or {}
|
|
377
|
+
seq = int(meta.get("latest_seq") or 0) + 1
|
|
378
|
+
timestamp = utc_now()
|
|
379
|
+
_append_jsonl(
|
|
380
|
+
self.log_path,
|
|
381
|
+
{
|
|
382
|
+
"seq": seq,
|
|
383
|
+
"stream": stream,
|
|
384
|
+
"line": line,
|
|
385
|
+
"timestamp": timestamp,
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
progress = _parse_progress_marker(line)
|
|
389
|
+
if progress is not None:
|
|
390
|
+
progress.setdefault("ts", timestamp)
|
|
391
|
+
_atomic_write_json(self.meta_path.parent / "progress.json", progress)
|
|
392
|
+
meta["last_progress"] = progress
|
|
393
|
+
meta["latest_seq"] = seq
|
|
394
|
+
meta["updated_at"] = timestamp
|
|
395
|
+
_atomic_write_json(self.meta_path, meta)
|
|
396
|
+
|
|
397
|
+
def _poll_prompt_events(self) -> None:
|
|
398
|
+
if not self.prompt_events_path.exists():
|
|
399
|
+
return
|
|
400
|
+
with self.prompt_events_path.open("rb") as handle:
|
|
401
|
+
handle.seek(self._prompt_offset)
|
|
402
|
+
chunk = handle.read()
|
|
403
|
+
self._prompt_offset = handle.tell()
|
|
404
|
+
if not chunk:
|
|
405
|
+
return
|
|
406
|
+
payload = self._prompt_remainder + chunk
|
|
407
|
+
parts = payload.split(b"\n")
|
|
408
|
+
if payload and not payload.endswith(b"\n"):
|
|
409
|
+
self._prompt_remainder = parts.pop()
|
|
410
|
+
else:
|
|
411
|
+
self._prompt_remainder = b""
|
|
412
|
+
for raw_line in parts:
|
|
413
|
+
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
414
|
+
marker = _parse_terminal_prompt_marker(line)
|
|
415
|
+
if not marker:
|
|
416
|
+
continue
|
|
417
|
+
meta = read_json(self.meta_path, {}) or {}
|
|
418
|
+
meta["cwd"] = str(marker.get("cwd") or meta.get("cwd") or self.cwd)
|
|
419
|
+
meta["last_prompt_at"] = str(marker.get("ts") or utc_now())
|
|
420
|
+
meta["updated_at"] = utc_now()
|
|
421
|
+
_atomic_write_json(self.meta_path, meta)
|
|
422
|
+
|
|
423
|
+
def _reader_loop(self) -> None:
|
|
424
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
425
|
+
log_buffer = ""
|
|
426
|
+
exit_code = None
|
|
427
|
+
final_status = "failed"
|
|
428
|
+
stop_reason = None
|
|
429
|
+
process = self._process
|
|
430
|
+
master_fd = self._master_fd
|
|
431
|
+
try:
|
|
432
|
+
if process is None or master_fd is None:
|
|
433
|
+
return
|
|
434
|
+
while True:
|
|
435
|
+
self._poll_prompt_events()
|
|
436
|
+
if self._stop_event.is_set() and process.poll() is None:
|
|
437
|
+
_kill_process_group(self._process_group_id, process)
|
|
438
|
+
ready, _unused_w, _unused_x = select.select([master_fd], [], [], TERMINAL_RUNTIME_POLL_SECONDS)
|
|
439
|
+
if ready:
|
|
440
|
+
try:
|
|
441
|
+
chunk = os.read(master_fd, 4096)
|
|
442
|
+
except OSError:
|
|
443
|
+
chunk = b""
|
|
444
|
+
if chunk:
|
|
445
|
+
text = decoder.decode(chunk)
|
|
446
|
+
if text:
|
|
447
|
+
encoded = text.encode("utf-8", errors="replace")
|
|
448
|
+
self._append_replay(encoded)
|
|
449
|
+
self._append_terminal_display(text)
|
|
450
|
+
self._broadcast_output(encoded)
|
|
451
|
+
log_buffer += text
|
|
452
|
+
log_buffer = _drain_buffer(
|
|
453
|
+
log_buffer,
|
|
454
|
+
self._append_log_entry,
|
|
455
|
+
flush_partial=True,
|
|
456
|
+
carriage_mode="stream",
|
|
457
|
+
)
|
|
458
|
+
if process.poll() is not None:
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
while True:
|
|
462
|
+
try:
|
|
463
|
+
chunk = os.read(master_fd, 4096)
|
|
464
|
+
except OSError:
|
|
465
|
+
chunk = b""
|
|
466
|
+
if not chunk:
|
|
467
|
+
break
|
|
468
|
+
text = decoder.decode(chunk)
|
|
469
|
+
if text:
|
|
470
|
+
encoded = text.encode("utf-8", errors="replace")
|
|
471
|
+
self._append_replay(encoded)
|
|
472
|
+
self._append_terminal_display(text)
|
|
473
|
+
self._broadcast_output(encoded)
|
|
474
|
+
log_buffer += text
|
|
475
|
+
log_buffer = _drain_buffer(
|
|
476
|
+
log_buffer,
|
|
477
|
+
self._append_log_entry,
|
|
478
|
+
flush_partial=True,
|
|
479
|
+
carriage_mode="stream",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
tail = decoder.decode(b"", final=True)
|
|
483
|
+
if tail:
|
|
484
|
+
encoded = tail.encode("utf-8", errors="replace")
|
|
485
|
+
self._append_replay(encoded)
|
|
486
|
+
self._append_terminal_display(tail)
|
|
487
|
+
self._broadcast_output(encoded)
|
|
488
|
+
log_buffer += tail
|
|
489
|
+
log_buffer = _drain_buffer(
|
|
490
|
+
log_buffer,
|
|
491
|
+
self._append_log_entry,
|
|
492
|
+
flush_partial=True,
|
|
493
|
+
carriage_mode="stream",
|
|
494
|
+
)
|
|
495
|
+
if log_buffer:
|
|
496
|
+
self._append_log_entry(log_buffer, stream="partial")
|
|
497
|
+
exit_code = process.wait()
|
|
498
|
+
meta = read_json(self.meta_path, {}) or {}
|
|
499
|
+
stop_request = read_json(self.meta_path.parent / "stop_request.json", {}) or {}
|
|
500
|
+
stop_reason = (
|
|
501
|
+
_normalize_string(meta.get("stop_reason"))
|
|
502
|
+
or _normalize_string(stop_request.get("reason"))
|
|
503
|
+
or None
|
|
504
|
+
)
|
|
505
|
+
final_status = "terminated" if stop_reason else ("completed" if exit_code == 0 else "failed")
|
|
506
|
+
self._append_log_entry(
|
|
507
|
+
_status_marker(meta, status=final_status, exit_code=exit_code, reason=stop_reason),
|
|
508
|
+
stream="system",
|
|
509
|
+
)
|
|
510
|
+
meta["status"] = final_status
|
|
511
|
+
meta["exit_code"] = exit_code
|
|
512
|
+
meta["finished_at"] = utc_now()
|
|
513
|
+
meta["updated_at"] = utc_now()
|
|
514
|
+
meta["stop_reason"] = stop_reason
|
|
515
|
+
_atomic_write_json(self.meta_path, meta)
|
|
516
|
+
self._broadcast_control(
|
|
517
|
+
{
|
|
518
|
+
"type": "exit",
|
|
519
|
+
"bash_id": self.bash_id,
|
|
520
|
+
"status": final_status,
|
|
521
|
+
"exit_code": exit_code,
|
|
522
|
+
"stop_reason": stop_reason,
|
|
523
|
+
"finished_at": meta["finished_at"],
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
finally:
|
|
527
|
+
self._stop_event.set()
|
|
528
|
+
if self._master_fd is not None:
|
|
529
|
+
try:
|
|
530
|
+
os.close(self._master_fd)
|
|
531
|
+
except OSError:
|
|
532
|
+
pass
|
|
533
|
+
self._master_fd = None
|
|
534
|
+
if self._process is not None and self._process.stdout is not None:
|
|
535
|
+
try:
|
|
536
|
+
self._process.stdout.close()
|
|
537
|
+
except OSError:
|
|
538
|
+
pass
|
|
539
|
+
self._on_finished(self.quest_root, self.bash_id)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class TerminalRuntimeManager:
|
|
543
|
+
def __init__(self, home: Path) -> None:
|
|
544
|
+
self.home = home
|
|
545
|
+
self._lock = threading.Lock()
|
|
546
|
+
self._runtimes: dict[tuple[str, str], TerminalRuntime] = {}
|
|
547
|
+
self._tokens: dict[str, AttachToken] = {}
|
|
548
|
+
|
|
549
|
+
@staticmethod
|
|
550
|
+
def _key(quest_root: Path, bash_id: str) -> tuple[str, str]:
|
|
551
|
+
return (str(quest_root.resolve()), bash_id)
|
|
552
|
+
|
|
553
|
+
def _handle_runtime_finished(self, quest_root: Path, bash_id: str) -> None:
|
|
554
|
+
with self._lock:
|
|
555
|
+
self._runtimes.pop(self._key(quest_root, bash_id), None)
|
|
556
|
+
|
|
557
|
+
def get_runtime(self, quest_root: Path, bash_id: str) -> TerminalRuntime | None:
|
|
558
|
+
with self._lock:
|
|
559
|
+
runtime = self._runtimes.get(self._key(quest_root, bash_id))
|
|
560
|
+
if runtime is not None and runtime.is_alive():
|
|
561
|
+
return runtime
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
def ensure_runtime(
|
|
565
|
+
self,
|
|
566
|
+
*,
|
|
567
|
+
quest_root: Path,
|
|
568
|
+
bash_id: str,
|
|
569
|
+
meta_path: Path,
|
|
570
|
+
log_path: Path,
|
|
571
|
+
terminal_log_path: Path,
|
|
572
|
+
prompt_events_path: Path,
|
|
573
|
+
env_payload: dict[str, str],
|
|
574
|
+
command: str,
|
|
575
|
+
cwd: Path,
|
|
576
|
+
) -> dict[str, Any]:
|
|
577
|
+
runtime = self.get_runtime(quest_root, bash_id)
|
|
578
|
+
if runtime is not None:
|
|
579
|
+
return read_json(meta_path, {}) or {}
|
|
580
|
+
created = TerminalRuntime(
|
|
581
|
+
quest_root=quest_root,
|
|
582
|
+
bash_id=bash_id,
|
|
583
|
+
meta_path=meta_path,
|
|
584
|
+
log_path=log_path,
|
|
585
|
+
terminal_log_path=terminal_log_path,
|
|
586
|
+
prompt_events_path=prompt_events_path,
|
|
587
|
+
env_payload=env_payload,
|
|
588
|
+
command=command,
|
|
589
|
+
cwd=cwd,
|
|
590
|
+
on_finished=self._handle_runtime_finished,
|
|
591
|
+
)
|
|
592
|
+
meta = created.start()
|
|
593
|
+
with self._lock:
|
|
594
|
+
self._runtimes[self._key(quest_root, bash_id)] = created
|
|
595
|
+
return meta
|
|
596
|
+
|
|
597
|
+
def issue_attach_token(self, quest_root: Path, bash_id: str, *, ttl_seconds: int = 60) -> AttachToken:
|
|
598
|
+
self._cleanup_tokens()
|
|
599
|
+
token = AttachToken(
|
|
600
|
+
token=generate_id("tattach"),
|
|
601
|
+
quest_root=quest_root.resolve(),
|
|
602
|
+
bash_id=bash_id,
|
|
603
|
+
expires_at=time.time() + max(5, ttl_seconds),
|
|
604
|
+
)
|
|
605
|
+
with self._lock:
|
|
606
|
+
self._tokens[token.token] = token
|
|
607
|
+
return token
|
|
608
|
+
|
|
609
|
+
def resolve_attach_token(self, token_value: str) -> tuple[AttachToken | None, TerminalRuntime | None]:
|
|
610
|
+
self._cleanup_tokens()
|
|
611
|
+
with self._lock:
|
|
612
|
+
token = self._tokens.get(token_value)
|
|
613
|
+
if token is None or token.expires_at < time.time():
|
|
614
|
+
return None, None
|
|
615
|
+
runtime = self.get_runtime(token.quest_root, token.bash_id)
|
|
616
|
+
return token, runtime
|
|
617
|
+
|
|
618
|
+
def consume_attach_token(self, token_value: str) -> tuple[AttachToken | None, TerminalRuntime | None]:
|
|
619
|
+
token, runtime = self.resolve_attach_token(token_value)
|
|
620
|
+
if token is None:
|
|
621
|
+
return None, None
|
|
622
|
+
with self._lock:
|
|
623
|
+
self._tokens.pop(token_value, None)
|
|
624
|
+
return token, runtime
|
|
625
|
+
|
|
626
|
+
def shutdown(self) -> None:
|
|
627
|
+
with self._lock:
|
|
628
|
+
runtimes = list(self._runtimes.values())
|
|
629
|
+
self._runtimes.clear()
|
|
630
|
+
self._tokens.clear()
|
|
631
|
+
for runtime in runtimes:
|
|
632
|
+
runtime.stop(reason="daemon_shutdown", force=False)
|
|
633
|
+
|
|
634
|
+
def _cleanup_tokens(self) -> None:
|
|
635
|
+
now = time.time()
|
|
636
|
+
with self._lock:
|
|
637
|
+
expired = [token for token, payload in self._tokens.items() if payload.expires_at < now]
|
|
638
|
+
for token in expired:
|
|
639
|
+
self._tokens.pop(token, None)
|