@researai/deepscientist 1.5.13 → 1.5.15
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 +8 -0
- package/assets/branding/logo-raster.png +0 -0
- package/bin/ds.js +134 -49
- package/docs/en/00_QUICK_START.md +2 -2
- package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/en/05_TUI_GUIDE.md +466 -96
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- package/docs/en/README.md +8 -0
- package/docs/zh/00_QUICK_START.md +2 -2
- package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
- package/docs/zh/05_TUI_GUIDE.md +465 -82
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
- package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
- package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
- package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
- package/docs/zh/README.md +8 -0
- package/install.sh +2 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/artifact/charts.py +567 -0
- package/src/deepscientist/artifact/guidance.py +50 -10
- package/src/deepscientist/artifact/metrics.py +228 -5
- package/src/deepscientist/artifact/schemas.py +3 -0
- package/src/deepscientist/artifact/service.py +4004 -538
- package/src/deepscientist/bash_exec/models.py +23 -0
- package/src/deepscientist/bash_exec/monitor.py +147 -67
- package/src/deepscientist/bash_exec/runtime.py +218 -156
- package/src/deepscientist/bash_exec/service.py +79 -64
- package/src/deepscientist/bash_exec/shells.py +87 -0
- package/src/deepscientist/bridges/connectors.py +51 -2
- package/src/deepscientist/config/models.py +6 -3
- package/src/deepscientist/config/service.py +7 -2
- package/src/deepscientist/connector/lingzhu_support.py +23 -4
- package/src/deepscientist/connector/weixin_support.py +122 -1
- package/src/deepscientist/daemon/api/handlers.py +75 -4
- package/src/deepscientist/daemon/api/router.py +1 -0
- package/src/deepscientist/daemon/app.py +869 -236
- package/src/deepscientist/doctor.py +51 -0
- package/src/deepscientist/file_lock.py +48 -0
- package/src/deepscientist/gitops/diff.py +167 -1
- package/src/deepscientist/mcp/server.py +331 -21
- package/src/deepscientist/process_control.py +161 -0
- package/src/deepscientist/prompts/builder.py +275 -491
- package/src/deepscientist/quest/service.py +2336 -145
- package/src/deepscientist/quest/stage_views.py +305 -29
- package/src/deepscientist/runners/base.py +2 -0
- package/src/deepscientist/runners/codex.py +88 -5
- package/src/deepscientist/runners/runtime_overrides.py +17 -1
- package/src/deepscientist/shared.py +6 -1
- package/src/prompts/contracts/shared_interaction.md +13 -4
- package/src/prompts/system.md +984 -1985
- package/src/skills/analysis-campaign/SKILL.md +31 -2
- package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
- package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
- package/src/skills/baseline/SKILL.md +267 -994
- package/src/skills/baseline/references/baseline-checklist-template.md +21 -32
- package/src/skills/baseline/references/baseline-plan-template.md +41 -57
- package/src/skills/decision/SKILL.md +19 -2
- package/src/skills/experiment/SKILL.md +8 -2
- package/src/skills/finalize/SKILL.md +18 -0
- package/src/skills/idea/SKILL.md +78 -0
- package/src/skills/idea/references/idea-generation-playbook.md +100 -0
- package/src/skills/idea/references/outline-seeding-example.md +60 -0
- package/src/skills/intake-audit/SKILL.md +1 -1
- package/src/skills/optimize/SKILL.md +1644 -0
- package/src/skills/rebuttal/SKILL.md +2 -1
- package/src/skills/review/SKILL.md +2 -1
- package/src/skills/write/SKILL.md +80 -12
- package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
- package/src/tui/dist/app/AppContainer.js +1445 -52
- package/src/tui/dist/components/Composer.js +1 -1
- package/src/tui/dist/components/ConfigScreen.js +190 -36
- package/src/tui/dist/components/GradientStatusText.js +1 -20
- package/src/tui/dist/components/InputPrompt.js +41 -32
- package/src/tui/dist/components/LoadingIndicator.js +1 -1
- package/src/tui/dist/components/Logo.js +61 -38
- package/src/tui/dist/components/MainContent.js +10 -3
- package/src/tui/dist/components/WelcomePanel.js +4 -12
- package/src/tui/dist/components/messages/AssistantMessage.js +1 -1
- package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -3
- package/src/tui/dist/components/messages/OperationMessage.js +1 -1
- package/src/tui/dist/index.js +28 -1
- package/src/tui/dist/layouts/DefaultAppLayout.js +3 -3
- package/src/tui/dist/lib/api.js +17 -0
- package/src/tui/dist/lib/connectors.js +261 -0
- package/src/tui/dist/semantic-colors.js +29 -19
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-CnJcXynW.js → AiManusChatView-DDjbFnbt.js} +12 -12
- package/src/ui/dist/assets/{AnalysisPlugin-DeyzPEhV.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
- package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
- package/src/ui/dist/assets/{CodeEditorPlugin-B-xicq1e.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-DT54ysXa.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-DQtKT-VD.js → DocViewerPlugin-CLChbllo.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-hqHbCfnv.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
- package/src/ui/dist/assets/{ImageViewerPlugin-OcVo33jV.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-DdGwhEUV.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-Ciz1gDaX.js → LabPlugin-DQPg-NrB.js} +2 -2
- package/src/ui/dist/assets/{LatexPlugin-BhmjNQRC.js → LatexPlugin-CI05XAV9.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-BzdVH9Bx.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-DmyHspXt.js → MarketplacePlugin-DolE58Q2.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-BTVYRGkm.js → NotebookEditor-7Qm2rSWD.js} +11 -11
- package/src/ui/dist/assets/{NotebookEditor-BMXKrDRk.js → NotebookEditor-C1kWaxKi.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-CvcjJHXv.js → PdfLoader-BfOHw8Zw.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-DW2ej8Vk.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
- package/src/ui/dist/assets/{PdfViewerPlugin-CmlDxbhU.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-DAjQZPSv.js → SearchPlugin-CjpaiJ3A.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-C-nVAZb_.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
- package/src/ui/dist/assets/{VNCViewer-D7-dIYon.js → VNCViewer-HAg9mF7M.js} +10 -10
- package/src/ui/dist/assets/{bot-C_G4WtNI.js → bot-0DYntytV.js} +1 -1
- package/src/ui/dist/assets/{code-Cd7WfiWq.js → code-B20Slj_w.js} +1 -1
- package/src/ui/dist/assets/{file-content-B57zsL9y.js → file-content-DT24KFma.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DVoheLFq.js → file-diff-panel-DK13YPql.js} +1 -1
- package/src/ui/dist/assets/{file-socket-B5kXFxZP.js → file-socket-B4T2o4nR.js} +1 -1
- package/src/ui/dist/assets/{image-LLOjkMHF.js → image-DSeR_sDS.js} +1 -1
- package/src/ui/dist/assets/{index-hOUOWbW2.js → index-BrFje2Uk.js} +2 -2
- package/src/ui/dist/assets/{index-Dxa2eYMY.js → index-BwRJaoTl.js} +1 -1
- package/src/ui/dist/assets/{index-CLQauncb.js → index-D_E4281X.js} +5418 -28620
- package/src/ui/dist/assets/{index-C3r2iGrp.js → index-DnYB3xb1.js} +12 -12
- package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
- package/src/ui/dist/assets/{monaco-BGGAEii3.js → monaco-LExaAN3Y.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-DlEr1_y5.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
- package/src/ui/dist/assets/{popover-CWJbJuYY.js → popover-D3Gg_FoV.js} +1 -1
- package/src/ui/dist/assets/{project-sync-CRJiucYO.js → project-sync-C_ygLlVU.js} +1 -1
- package/src/ui/dist/assets/{select-CoHB7pvH.js → select-CpAK6uWm.js} +2 -2
- package/src/ui/dist/assets/{sigma-D5aJWR8J.js → sigma-DEccaSgk.js} +1 -1
- package/src/ui/dist/assets/{square-check-big-DUK_mnkS.js → square-check-big-uUfyVsbD.js} +1 -1
- package/src/ui/dist/assets/{trash-ChU3SEE3.js → trash-CXvwwSe8.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-BrJBV3tY.js → useCliAccess-Bnop4mgR.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-C2OQaVWc.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-C7Qqh-om.js → wrap-text-9vbOBpkW.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-rtX0FKya.js → zoom-out-BgVMmOW4.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/uv.lock +1 -1
- package/src/ui/dist/assets/CliPlugin-CB1YODQn.js +0 -5905
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
3
4
|
import codecs
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
|
-
import
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
import select
|
|
8
|
-
import shlex
|
|
9
|
-
import signal
|
|
10
9
|
import struct
|
|
11
10
|
import subprocess
|
|
12
11
|
import tempfile
|
|
13
|
-
import termios
|
|
14
12
|
import threading
|
|
15
13
|
import time
|
|
16
14
|
from collections import deque
|
|
17
15
|
from dataclasses import dataclass
|
|
18
|
-
from
|
|
19
|
-
from typing import Any
|
|
16
|
+
from typing import Any, BinaryIO
|
|
20
17
|
|
|
21
|
-
|
|
18
|
+
if os.name != "nt": # pragma: no cover - exercised on POSIX
|
|
19
|
+
import pty
|
|
20
|
+
import termios
|
|
21
|
+
else: # pragma: no cover - exercised on Windows
|
|
22
|
+
pty = None
|
|
23
|
+
termios = None
|
|
24
|
+
|
|
25
|
+
from ..process_control import process_session_popen_kwargs, terminate_subprocess
|
|
26
|
+
from ..shared import ensure_dir, generate_id, read_json, utc_now
|
|
27
|
+
from .models import AttachToken, TerminalClient
|
|
22
28
|
|
|
23
29
|
BASH_STATUS_MARKER_PREFIX = "__DS_BASH_STATUS__"
|
|
24
30
|
BASH_CARRIAGE_RETURN_PREFIX = "__DS_BASH_CR__"
|
|
@@ -76,14 +82,11 @@ def _parse_terminal_prompt_marker(line: str) -> dict[str, str] | None:
|
|
|
76
82
|
if not raw:
|
|
77
83
|
return None
|
|
78
84
|
payload: dict[str, str] = {}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
payload[key.strip()] = value
|
|
85
|
-
except ValueError:
|
|
86
|
-
return None
|
|
85
|
+
for token in raw.split():
|
|
86
|
+
if "=" not in token:
|
|
87
|
+
continue
|
|
88
|
+
key, value = token.split("=", 1)
|
|
89
|
+
payload[key.strip()] = value
|
|
87
90
|
return payload or None
|
|
88
91
|
|
|
89
92
|
|
|
@@ -108,26 +111,6 @@ def _append_jsonl(path: Path, payload: dict[str, Any]) -> None:
|
|
|
108
111
|
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
109
112
|
|
|
110
113
|
|
|
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
114
|
def _drain_buffer(
|
|
132
115
|
buffer: str,
|
|
133
116
|
append_line,
|
|
@@ -162,20 +145,12 @@ def _drain_buffer(
|
|
|
162
145
|
|
|
163
146
|
|
|
164
147
|
@dataclass(slots=True)
|
|
165
|
-
class
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
148
|
+
class _LaunchDescriptor:
|
|
149
|
+
transport: str
|
|
150
|
+
process: subprocess.Popen[bytes]
|
|
151
|
+
process_group_id: int | None
|
|
152
|
+
master_fd: int | None = None
|
|
153
|
+
output_stream: BinaryIO | None = None
|
|
179
154
|
|
|
180
155
|
|
|
181
156
|
class TerminalRuntime:
|
|
@@ -190,7 +165,9 @@ class TerminalRuntime:
|
|
|
190
165
|
prompt_events_path: Path,
|
|
191
166
|
env_payload: dict[str, str],
|
|
192
167
|
command: str,
|
|
168
|
+
launch_argv: list[str] | None,
|
|
193
169
|
cwd: Path,
|
|
170
|
+
transport_preference: str | None,
|
|
194
171
|
on_finished,
|
|
195
172
|
) -> None:
|
|
196
173
|
self.quest_root = quest_root
|
|
@@ -201,7 +178,9 @@ class TerminalRuntime:
|
|
|
201
178
|
self.prompt_events_path = prompt_events_path
|
|
202
179
|
self.env_payload = dict(env_payload)
|
|
203
180
|
self.command = command
|
|
181
|
+
self.launch_argv = [str(item) for item in (launch_argv or []) if str(item).strip()]
|
|
204
182
|
self.cwd = cwd
|
|
183
|
+
self.transport_preference = _normalize_string(transport_preference).lower() or None
|
|
205
184
|
self._on_finished = on_finished
|
|
206
185
|
self._clients: dict[str, TerminalClient] = {}
|
|
207
186
|
self._clients_lock = threading.Lock()
|
|
@@ -213,6 +192,8 @@ class TerminalRuntime:
|
|
|
213
192
|
self._process: subprocess.Popen[bytes] | None = None
|
|
214
193
|
self._process_group_id: int | None = None
|
|
215
194
|
self._master_fd: int | None = None
|
|
195
|
+
self._output_stream: BinaryIO | None = None
|
|
196
|
+
self._transport = "pipe"
|
|
216
197
|
self._replay_chunks: deque[bytes] = deque()
|
|
217
198
|
self._replay_bytes = 0
|
|
218
199
|
self._prompt_offset = 0
|
|
@@ -224,32 +205,26 @@ class TerminalRuntime:
|
|
|
224
205
|
self.terminal_log_path.touch()
|
|
225
206
|
self.log_path.touch()
|
|
226
207
|
self.prompt_events_path.touch()
|
|
227
|
-
|
|
208
|
+
|
|
228
209
|
env_payload = os.environ.copy()
|
|
229
210
|
env_payload.update(self.env_payload)
|
|
230
211
|
env_payload.setdefault("PYTHONUNBUFFERED", "1")
|
|
231
212
|
env_payload.setdefault("TERM", "xterm-256color")
|
|
232
213
|
env_payload.setdefault("COLORTERM", "truecolor")
|
|
233
|
-
|
|
234
|
-
|
|
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)
|
|
214
|
+
launch = self._start_process(env_payload)
|
|
215
|
+
|
|
245
216
|
with self._state_lock:
|
|
246
|
-
self.
|
|
247
|
-
self.
|
|
248
|
-
self.
|
|
217
|
+
self._transport = launch.transport
|
|
218
|
+
self._master_fd = launch.master_fd
|
|
219
|
+
self._output_stream = launch.output_stream
|
|
220
|
+
self._process = launch.process
|
|
221
|
+
self._process_group_id = launch.process_group_id
|
|
222
|
+
|
|
249
223
|
meta = read_json(self.meta_path, {}) or {}
|
|
250
224
|
meta["monitor_pid"] = None
|
|
251
|
-
meta["process_pid"] = process.pid
|
|
252
|
-
meta["process_group_id"] = process_group_id
|
|
225
|
+
meta["process_pid"] = launch.process.pid
|
|
226
|
+
meta["process_group_id"] = launch.process_group_id
|
|
227
|
+
meta["transport"] = launch.transport
|
|
253
228
|
meta["status"] = "running"
|
|
254
229
|
meta["updated_at"] = utc_now()
|
|
255
230
|
_atomic_write_json(self.meta_path, meta)
|
|
@@ -262,9 +237,55 @@ class TerminalRuntime:
|
|
|
262
237
|
self._reader_thread.start()
|
|
263
238
|
return meta
|
|
264
239
|
|
|
240
|
+
def _start_process(self, env_payload: dict[str, str]) -> _LaunchDescriptor:
|
|
241
|
+
argv = self.launch_argv or ["bash", "-lc", self.command]
|
|
242
|
+
popen_kwargs = {
|
|
243
|
+
"cwd": str(self.cwd),
|
|
244
|
+
"env": env_payload,
|
|
245
|
+
**process_session_popen_kwargs(hide_window=True),
|
|
246
|
+
}
|
|
247
|
+
allow_pty = (
|
|
248
|
+
os.name != "nt"
|
|
249
|
+
and pty is not None
|
|
250
|
+
and termios is not None
|
|
251
|
+
and self.transport_preference != "pipe"
|
|
252
|
+
)
|
|
253
|
+
if allow_pty:
|
|
254
|
+
master_fd, slave_fd = pty.openpty()
|
|
255
|
+
process = subprocess.Popen(
|
|
256
|
+
argv,
|
|
257
|
+
stdin=slave_fd,
|
|
258
|
+
stdout=slave_fd,
|
|
259
|
+
stderr=slave_fd,
|
|
260
|
+
**popen_kwargs,
|
|
261
|
+
)
|
|
262
|
+
os.close(slave_fd)
|
|
263
|
+
os.set_blocking(master_fd, False)
|
|
264
|
+
return _LaunchDescriptor(
|
|
265
|
+
transport="pty",
|
|
266
|
+
process=process,
|
|
267
|
+
process_group_id=os.getpgid(process.pid),
|
|
268
|
+
master_fd=master_fd,
|
|
269
|
+
)
|
|
270
|
+
process = subprocess.Popen(
|
|
271
|
+
argv,
|
|
272
|
+
stdin=subprocess.PIPE,
|
|
273
|
+
stdout=subprocess.PIPE,
|
|
274
|
+
stderr=subprocess.STDOUT,
|
|
275
|
+
**popen_kwargs,
|
|
276
|
+
)
|
|
277
|
+
if process.stdout is None:
|
|
278
|
+
raise RuntimeError("terminal_runtime_missing_stdout_pipe")
|
|
279
|
+
return _LaunchDescriptor(
|
|
280
|
+
transport="pipe",
|
|
281
|
+
process=process,
|
|
282
|
+
process_group_id=process.pid if os.name == "nt" else os.getpgid(process.pid),
|
|
283
|
+
output_stream=process.stdout,
|
|
284
|
+
)
|
|
285
|
+
|
|
265
286
|
def is_alive(self) -> bool:
|
|
266
287
|
process = self._process
|
|
267
|
-
return process is not None and process.poll() is None
|
|
288
|
+
return process is not None and process.poll() is None
|
|
268
289
|
|
|
269
290
|
def snapshot_replay(self) -> list[bytes]:
|
|
270
291
|
with self._replay_lock:
|
|
@@ -281,46 +302,48 @@ class TerminalRuntime:
|
|
|
281
302
|
def write_input(self, data: str) -> None:
|
|
282
303
|
if not data:
|
|
283
304
|
return
|
|
284
|
-
|
|
285
|
-
self.write_binary_input(payload)
|
|
305
|
+
self.write_binary_input(data.encode("utf-8"))
|
|
286
306
|
|
|
287
307
|
def write_binary_input(self, data: bytes) -> None:
|
|
288
308
|
if not data:
|
|
289
309
|
return
|
|
290
|
-
|
|
291
|
-
if
|
|
310
|
+
process = self._process
|
|
311
|
+
if process is None or process.poll() is not None:
|
|
292
312
|
raise RuntimeError("terminal_runtime_inactive")
|
|
293
313
|
with self._write_lock:
|
|
294
|
-
|
|
314
|
+
if self._transport == "pty" and self._master_fd is not None:
|
|
315
|
+
os.write(self._master_fd, data)
|
|
316
|
+
return
|
|
317
|
+
if process.stdin is None:
|
|
318
|
+
raise RuntimeError("terminal_runtime_missing_stdin")
|
|
319
|
+
process.stdin.write(data)
|
|
320
|
+
process.stdin.flush()
|
|
295
321
|
|
|
296
322
|
def resize(self, cols: int, rows: int) -> None:
|
|
297
|
-
|
|
298
|
-
if master_fd is None or cols <= 0 or rows <= 0:
|
|
323
|
+
if self._transport != "pty" or self._master_fd is None or cols <= 0 or rows <= 0 or termios is None:
|
|
299
324
|
return
|
|
300
325
|
winsz = struct.pack("HHHH", rows, cols, 0, 0)
|
|
301
326
|
with self._write_lock:
|
|
302
|
-
termios.tcsetwinsize(
|
|
327
|
+
termios.tcsetwinsize(self._master_fd, (rows, cols))
|
|
303
328
|
try:
|
|
304
|
-
import fcntl
|
|
329
|
+
import fcntl # pragma: no cover - exercised on POSIX
|
|
305
330
|
|
|
306
|
-
fcntl.ioctl(
|
|
331
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsz)
|
|
307
332
|
except Exception:
|
|
308
333
|
return
|
|
309
334
|
|
|
310
335
|
def stop(self, *, reason: str = "runtime_shutdown", force: bool = False) -> None:
|
|
311
336
|
self._stop_event.set()
|
|
312
337
|
process = self._process
|
|
313
|
-
|
|
314
|
-
if force:
|
|
315
|
-
_kill_process_group_force(process_group_id, process)
|
|
338
|
+
if process is None:
|
|
316
339
|
return
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
340
|
+
terminate_subprocess(
|
|
341
|
+
process,
|
|
342
|
+
process_group_id=self._process_group_id,
|
|
343
|
+
force=force,
|
|
344
|
+
prefer_ctrl_break=True,
|
|
345
|
+
grace_seconds=TERMINAL_STOP_GRACE_SECONDS,
|
|
346
|
+
)
|
|
324
347
|
|
|
325
348
|
def _append_replay(self, payload: bytes) -> None:
|
|
326
349
|
if not payload:
|
|
@@ -418,83 +441,47 @@ class TerminalRuntime:
|
|
|
418
441
|
if not marker:
|
|
419
442
|
continue
|
|
420
443
|
meta = read_json(self.meta_path, {}) or {}
|
|
421
|
-
|
|
444
|
+
prompt_cwd_raw = str(marker.get("cwd_b64") or marker.get("cwd") or meta.get("cwd") or self.cwd)
|
|
445
|
+
if marker.get("cwd_b64"):
|
|
446
|
+
try:
|
|
447
|
+
prompt_cwd = base64.b64decode(prompt_cwd_raw.encode("ascii")).decode("utf-8")
|
|
448
|
+
except Exception:
|
|
449
|
+
prompt_cwd = prompt_cwd_raw
|
|
450
|
+
else:
|
|
451
|
+
prompt_cwd = prompt_cwd_raw
|
|
452
|
+
meta["cwd"] = prompt_cwd
|
|
422
453
|
meta["last_prompt_at"] = str(marker.get("ts") or utc_now())
|
|
423
454
|
meta["updated_at"] = utc_now()
|
|
424
455
|
_atomic_write_json(self.meta_path, meta)
|
|
425
456
|
|
|
457
|
+
def _consume_text(self, text: str, log_buffer: str) -> str:
|
|
458
|
+
if not text:
|
|
459
|
+
return log_buffer
|
|
460
|
+
encoded = text.encode("utf-8", errors="replace")
|
|
461
|
+
self._append_replay(encoded)
|
|
462
|
+
self._append_terminal_display(text)
|
|
463
|
+
self._broadcast_output(encoded)
|
|
464
|
+
log_buffer += text
|
|
465
|
+
return _drain_buffer(
|
|
466
|
+
log_buffer,
|
|
467
|
+
self._append_log_entry,
|
|
468
|
+
flush_partial=True,
|
|
469
|
+
carriage_mode="stream",
|
|
470
|
+
)
|
|
471
|
+
|
|
426
472
|
def _reader_loop(self) -> None:
|
|
427
473
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
428
474
|
log_buffer = ""
|
|
429
|
-
exit_code = None
|
|
430
|
-
final_status = "failed"
|
|
431
|
-
stop_reason = None
|
|
432
475
|
process = self._process
|
|
433
|
-
master_fd = self._master_fd
|
|
434
476
|
try:
|
|
435
|
-
if process is None
|
|
477
|
+
if process is None:
|
|
436
478
|
return
|
|
437
|
-
|
|
438
|
-
self.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
ready, _unused_w, _unused_x = select.select([master_fd], [], [], TERMINAL_RUNTIME_POLL_SECONDS)
|
|
442
|
-
if ready:
|
|
443
|
-
try:
|
|
444
|
-
chunk = os.read(master_fd, 4096)
|
|
445
|
-
except OSError:
|
|
446
|
-
chunk = b""
|
|
447
|
-
if chunk:
|
|
448
|
-
text = decoder.decode(chunk)
|
|
449
|
-
if text:
|
|
450
|
-
encoded = text.encode("utf-8", errors="replace")
|
|
451
|
-
self._append_replay(encoded)
|
|
452
|
-
self._append_terminal_display(text)
|
|
453
|
-
self._broadcast_output(encoded)
|
|
454
|
-
log_buffer += text
|
|
455
|
-
log_buffer = _drain_buffer(
|
|
456
|
-
log_buffer,
|
|
457
|
-
self._append_log_entry,
|
|
458
|
-
flush_partial=True,
|
|
459
|
-
carriage_mode="stream",
|
|
460
|
-
)
|
|
461
|
-
if process.poll() is not None:
|
|
462
|
-
break
|
|
463
|
-
|
|
464
|
-
while True:
|
|
465
|
-
try:
|
|
466
|
-
chunk = os.read(master_fd, 4096)
|
|
467
|
-
except OSError:
|
|
468
|
-
chunk = b""
|
|
469
|
-
if not chunk:
|
|
470
|
-
break
|
|
471
|
-
text = decoder.decode(chunk)
|
|
472
|
-
if text:
|
|
473
|
-
encoded = text.encode("utf-8", errors="replace")
|
|
474
|
-
self._append_replay(encoded)
|
|
475
|
-
self._append_terminal_display(text)
|
|
476
|
-
self._broadcast_output(encoded)
|
|
477
|
-
log_buffer += text
|
|
478
|
-
log_buffer = _drain_buffer(
|
|
479
|
-
log_buffer,
|
|
480
|
-
self._append_log_entry,
|
|
481
|
-
flush_partial=True,
|
|
482
|
-
carriage_mode="stream",
|
|
483
|
-
)
|
|
484
|
-
|
|
479
|
+
if self._transport == "pty":
|
|
480
|
+
log_buffer = self._reader_loop_pty(process, decoder, log_buffer)
|
|
481
|
+
else:
|
|
482
|
+
log_buffer = self._reader_loop_pipe(process, decoder, log_buffer)
|
|
485
483
|
tail = decoder.decode(b"", final=True)
|
|
486
|
-
|
|
487
|
-
encoded = tail.encode("utf-8", errors="replace")
|
|
488
|
-
self._append_replay(encoded)
|
|
489
|
-
self._append_terminal_display(tail)
|
|
490
|
-
self._broadcast_output(encoded)
|
|
491
|
-
log_buffer += tail
|
|
492
|
-
log_buffer = _drain_buffer(
|
|
493
|
-
log_buffer,
|
|
494
|
-
self._append_log_entry,
|
|
495
|
-
flush_partial=True,
|
|
496
|
-
carriage_mode="stream",
|
|
497
|
-
)
|
|
484
|
+
log_buffer = self._consume_text(tail, log_buffer)
|
|
498
485
|
if log_buffer:
|
|
499
486
|
self._append_log_entry(log_buffer, stream="partial")
|
|
500
487
|
exit_code = process.wait()
|
|
@@ -534,13 +521,84 @@ class TerminalRuntime:
|
|
|
534
521
|
except OSError:
|
|
535
522
|
pass
|
|
536
523
|
self._master_fd = None
|
|
537
|
-
if self.
|
|
524
|
+
if self._output_stream is not None:
|
|
525
|
+
try:
|
|
526
|
+
self._output_stream.close()
|
|
527
|
+
except OSError:
|
|
528
|
+
pass
|
|
529
|
+
self._output_stream = None
|
|
530
|
+
if self._process is not None and self._process.stdin is not None:
|
|
538
531
|
try:
|
|
539
|
-
self._process.
|
|
532
|
+
self._process.stdin.close()
|
|
540
533
|
except OSError:
|
|
541
534
|
pass
|
|
542
535
|
self._on_finished(self.quest_root, self.bash_id)
|
|
543
536
|
|
|
537
|
+
def _reader_loop_pty(
|
|
538
|
+
self,
|
|
539
|
+
process: subprocess.Popen[bytes],
|
|
540
|
+
decoder,
|
|
541
|
+
log_buffer: str,
|
|
542
|
+
) -> str:
|
|
543
|
+
master_fd = self._master_fd
|
|
544
|
+
if master_fd is None:
|
|
545
|
+
return log_buffer
|
|
546
|
+
while True:
|
|
547
|
+
self._poll_prompt_events()
|
|
548
|
+
if self._stop_event.is_set() and process.poll() is None:
|
|
549
|
+
terminate_subprocess(
|
|
550
|
+
process,
|
|
551
|
+
process_group_id=self._process_group_id,
|
|
552
|
+
prefer_ctrl_break=True,
|
|
553
|
+
grace_seconds=TERMINAL_STOP_GRACE_SECONDS,
|
|
554
|
+
)
|
|
555
|
+
ready, _unused_w, _unused_x = select.select([master_fd], [], [], TERMINAL_RUNTIME_POLL_SECONDS)
|
|
556
|
+
if ready:
|
|
557
|
+
try:
|
|
558
|
+
chunk = os.read(master_fd, 4096)
|
|
559
|
+
except OSError:
|
|
560
|
+
chunk = b""
|
|
561
|
+
if chunk:
|
|
562
|
+
log_buffer = self._consume_text(decoder.decode(chunk), log_buffer)
|
|
563
|
+
if process.poll() is not None:
|
|
564
|
+
break
|
|
565
|
+
while True:
|
|
566
|
+
try:
|
|
567
|
+
chunk = os.read(master_fd, 4096)
|
|
568
|
+
except OSError:
|
|
569
|
+
chunk = b""
|
|
570
|
+
if not chunk:
|
|
571
|
+
break
|
|
572
|
+
log_buffer = self._consume_text(decoder.decode(chunk), log_buffer)
|
|
573
|
+
return log_buffer
|
|
574
|
+
|
|
575
|
+
def _reader_loop_pipe(
|
|
576
|
+
self,
|
|
577
|
+
process: subprocess.Popen[bytes],
|
|
578
|
+
decoder,
|
|
579
|
+
log_buffer: str,
|
|
580
|
+
) -> str:
|
|
581
|
+
output_stream = self._output_stream
|
|
582
|
+
if output_stream is None:
|
|
583
|
+
return log_buffer
|
|
584
|
+
while True:
|
|
585
|
+
if self._stop_event.is_set() and process.poll() is None:
|
|
586
|
+
terminate_subprocess(
|
|
587
|
+
process,
|
|
588
|
+
process_group_id=self._process_group_id,
|
|
589
|
+
prefer_ctrl_break=True,
|
|
590
|
+
grace_seconds=TERMINAL_STOP_GRACE_SECONDS,
|
|
591
|
+
)
|
|
592
|
+
chunk = output_stream.read(4096)
|
|
593
|
+
self._poll_prompt_events()
|
|
594
|
+
if chunk:
|
|
595
|
+
log_buffer = self._consume_text(decoder.decode(chunk), log_buffer)
|
|
596
|
+
continue
|
|
597
|
+
if process.poll() is not None:
|
|
598
|
+
break
|
|
599
|
+
time.sleep(TERMINAL_RUNTIME_POLL_SECONDS)
|
|
600
|
+
return log_buffer
|
|
601
|
+
|
|
544
602
|
|
|
545
603
|
class TerminalRuntimeManager:
|
|
546
604
|
def __init__(self, home: Path) -> None:
|
|
@@ -575,7 +633,9 @@ class TerminalRuntimeManager:
|
|
|
575
633
|
prompt_events_path: Path,
|
|
576
634
|
env_payload: dict[str, str],
|
|
577
635
|
command: str,
|
|
636
|
+
launch_argv: list[str] | None,
|
|
578
637
|
cwd: Path,
|
|
638
|
+
transport_preference: str | None = None,
|
|
579
639
|
) -> dict[str, Any]:
|
|
580
640
|
runtime = self.get_runtime(quest_root, bash_id)
|
|
581
641
|
if runtime is not None:
|
|
@@ -589,7 +649,9 @@ class TerminalRuntimeManager:
|
|
|
589
649
|
prompt_events_path=prompt_events_path,
|
|
590
650
|
env_payload=env_payload,
|
|
591
651
|
command=command,
|
|
652
|
+
launch_argv=launch_argv,
|
|
592
653
|
cwd=cwd,
|
|
654
|
+
transport_preference=transport_preference,
|
|
593
655
|
on_finished=self._handle_runtime_finished,
|
|
594
656
|
)
|
|
595
657
|
meta = created.start()
|