@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.
Files changed (163) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +19 -179
  3. package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
  4. package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
  5. package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
  6. package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
  7. package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
  8. package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
  9. package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
  10. package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
  11. package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
  12. package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
  13. package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
  14. package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
  15. package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
  16. package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
  17. package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
  18. package/bin/ds.js +233 -53
  19. package/docs/en/00_QUICK_START.md +134 -0
  20. package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
  21. package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
  22. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
  23. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
  24. package/docs/en/05_TUI_GUIDE.md +141 -0
  25. package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
  26. package/docs/en/07_MEMORY_AND_MCP.md +253 -0
  27. package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
  28. package/docs/en/09_DOCTOR.md +108 -0
  29. package/docs/en/90_ARCHITECTURE.md +245 -0
  30. package/docs/en/91_DEVELOPMENT.md +195 -0
  31. package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
  32. package/docs/zh/00_QUICK_START.md +134 -0
  33. package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
  34. package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
  35. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
  36. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
  37. package/docs/zh/05_TUI_GUIDE.md +128 -0
  38. package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
  39. package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
  40. package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
  41. package/docs/zh/09_DOCTOR.md +112 -0
  42. package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
  43. package/install.sh +32 -8
  44. package/package.json +4 -2
  45. package/pyproject.toml +1 -1
  46. package/src/deepscientist/artifact/guidance.py +9 -2
  47. package/src/deepscientist/artifact/service.py +482 -22
  48. package/src/deepscientist/bash_exec/monitor.py +27 -5
  49. package/src/deepscientist/bash_exec/runtime.py +639 -0
  50. package/src/deepscientist/bash_exec/service.py +99 -16
  51. package/src/deepscientist/bridges/base.py +3 -0
  52. package/src/deepscientist/bridges/connectors.py +292 -13
  53. package/src/deepscientist/channels/qq.py +19 -2
  54. package/src/deepscientist/channels/relay.py +1 -0
  55. package/src/deepscientist/cli.py +32 -25
  56. package/src/deepscientist/config/models.py +28 -2
  57. package/src/deepscientist/config/service.py +201 -6
  58. package/src/deepscientist/connector_runtime.py +2 -0
  59. package/src/deepscientist/daemon/api/handlers.py +50 -5
  60. package/src/deepscientist/daemon/api/router.py +1 -0
  61. package/src/deepscientist/daemon/app.py +442 -15
  62. package/src/deepscientist/doctor.py +444 -0
  63. package/src/deepscientist/home.py +1 -0
  64. package/src/deepscientist/latex_runtime.py +17 -4
  65. package/src/deepscientist/lingzhu_support.py +182 -0
  66. package/src/deepscientist/mcp/server.py +49 -2
  67. package/src/deepscientist/prompts/builder.py +181 -58
  68. package/src/deepscientist/quest/layout.py +1 -0
  69. package/src/deepscientist/quest/service.py +63 -2
  70. package/src/deepscientist/quest/stage_views.py +19 -1
  71. package/src/deepscientist/runtime_tools/__init__.py +16 -0
  72. package/src/deepscientist/runtime_tools/builtins.py +19 -0
  73. package/src/deepscientist/runtime_tools/models.py +29 -0
  74. package/src/deepscientist/runtime_tools/registry.py +40 -0
  75. package/src/deepscientist/runtime_tools/service.py +59 -0
  76. package/src/deepscientist/runtime_tools/tinytex.py +25 -0
  77. package/src/deepscientist/tinytex.py +276 -0
  78. package/src/prompts/connectors/lingzhu.md +12 -0
  79. package/src/prompts/connectors/qq.md +121 -0
  80. package/src/prompts/system.md +177 -33
  81. package/src/skills/analysis-campaign/SKILL.md +22 -6
  82. package/src/skills/baseline/SKILL.md +5 -4
  83. package/src/skills/decision/SKILL.md +4 -3
  84. package/src/skills/experiment/SKILL.md +5 -4
  85. package/src/skills/finalize/SKILL.md +5 -4
  86. package/src/skills/idea/SKILL.md +5 -4
  87. package/src/skills/intake-audit/SKILL.md +277 -0
  88. package/src/skills/intake-audit/references/state-audit-template.md +41 -0
  89. package/src/skills/rebuttal/SKILL.md +407 -0
  90. package/src/skills/rebuttal/references/action-plan-template.md +63 -0
  91. package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
  92. package/src/skills/rebuttal/references/response-letter-template.md +113 -0
  93. package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
  94. package/src/skills/review/SKILL.md +293 -0
  95. package/src/skills/review/references/experiment-todo-template.md +29 -0
  96. package/src/skills/review/references/review-report-template.md +83 -0
  97. package/src/skills/review/references/revision-log-template.md +40 -0
  98. package/src/skills/scout/SKILL.md +5 -4
  99. package/src/skills/write/SKILL.md +7 -3
  100. package/src/tui/dist/components/WelcomePanel.js +17 -43
  101. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
  102. package/src/tui/package.json +1 -1
  103. package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-w5lF2Ttt.js} +109 -575
  104. package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-DJOED79I.js} +1 -1
  105. package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-DaG61Y0M.js} +63 -8
  106. package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CV4LqUB_.js} +43 -609
  107. package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DylfAea4.js} +8 -8
  108. package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-F7saY0LM.js} +5 -5
  109. package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-COP0c7jf.js} +3 -3
  110. package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-CAS05pT9.js} +1 -1
  111. package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-Bco1CN_w.js} +5 -6
  112. package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-CvMlCD99.js} +12 -15
  113. package/src/ui/dist/assets/LabPlugin-BYankkE4.js +2676 -0
  114. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +2698 -0
  115. package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-LDSMR-t-.js} +16 -16
  116. package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-B7o80jgm.js} +4 -4
  117. package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-CM6ZOcpC.js} +3 -3
  118. package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-Dc61cXmK.js} +3 -3
  119. package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-DWowuQwx.js} +1 -1
  120. package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-BsJM1q_a.js} +3 -3
  121. package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-DB2eEEFQ.js} +10 -10
  122. package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-CraThSvt.js} +1 -1
  123. package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-CgocRTPq.js} +1 -1
  124. package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-B1JGhKtd.js} +4 -4
  125. package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-CclFC7FM.js} +9 -10
  126. package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-D3IKsMl7.js} +1 -1
  127. package/src/ui/dist/assets/{code-BnBeNxBc.js → code-BP37Xx0p.js} +1 -1
  128. package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-BAJSu-9r.js} +1 -1
  129. package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-DUGeCTuy.js} +1 -1
  130. package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CXc1Ojf7.js} +1 -1
  131. package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-2J21jt7M.js} +1 -1
  132. package/src/ui/dist/assets/{image-Boe6ffhu.js → image-CMMmgvcn.js} +1 -1
  133. package/src/ui/dist/assets/{index-BlplpvE1.js → index-BaVumsQT.js} +2 -2
  134. package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-CWgMgpow.js} +60 -2154
  135. package/src/ui/dist/assets/{index-DO43pFZP.js → index-DmwmJmbW.js} +6372 -8434
  136. package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-KGt-z-dD.css} +225 -2920
  137. package/src/ui/dist/assets/{index-2Zf65FZt.js → index-s7aHnNQ4.js} +1 -1
  138. package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-CQRfX0Am.js} +1 -1
  139. package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-B4TbdsrF.js} +1 -1
  140. package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-B8Rokodk.js} +1 -1
  141. package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-D_i96KH4.js} +2 -8
  142. package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-D12PnzCN.js} +1 -1
  143. package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-B6YrI4aJ.js} +1 -1
  144. package/src/ui/dist/assets/trash-Bc8jGp0V.js +32 -0
  145. package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-mXVCYSZ-.js} +12 -42
  146. package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-Bg6b9H9K.js} +1 -1
  147. package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Drh5GEnL.js} +1 -1
  148. package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-CJj9DZLn.js} +1 -1
  149. package/src/ui/dist/index.html +2 -2
  150. package/assets/fonts/Inter-Variable.ttf +0 -0
  151. package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
  152. package/assets/fonts/NunitoSans-Variable.ttf +0 -0
  153. package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
  154. package/assets/fonts/SourceSans3-Variable.ttf +0 -0
  155. package/assets/fonts/ds-fonts.css +0 -83
  156. package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
  157. package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
  158. package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
  159. package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
  160. package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
  161. package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
  162. package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
  163. 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)