@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.
Files changed (142) hide show
  1. package/README.md +8 -0
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +134 -49
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  7. package/docs/en/05_TUI_GUIDE.md +466 -96
  8. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  10. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  11. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  12. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  13. package/docs/en/README.md +8 -0
  14. package/docs/zh/00_QUICK_START.md +2 -2
  15. package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
  16. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  17. package/docs/zh/05_TUI_GUIDE.md +465 -82
  18. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  19. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  20. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  21. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  22. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  23. package/docs/zh/README.md +8 -0
  24. package/install.sh +2 -0
  25. package/package.json +1 -1
  26. package/pyproject.toml +1 -1
  27. package/src/deepscientist/__init__.py +1 -1
  28. package/src/deepscientist/artifact/charts.py +567 -0
  29. package/src/deepscientist/artifact/guidance.py +50 -10
  30. package/src/deepscientist/artifact/metrics.py +228 -5
  31. package/src/deepscientist/artifact/schemas.py +3 -0
  32. package/src/deepscientist/artifact/service.py +4004 -538
  33. package/src/deepscientist/bash_exec/models.py +23 -0
  34. package/src/deepscientist/bash_exec/monitor.py +147 -67
  35. package/src/deepscientist/bash_exec/runtime.py +218 -156
  36. package/src/deepscientist/bash_exec/service.py +79 -64
  37. package/src/deepscientist/bash_exec/shells.py +87 -0
  38. package/src/deepscientist/bridges/connectors.py +51 -2
  39. package/src/deepscientist/config/models.py +6 -3
  40. package/src/deepscientist/config/service.py +7 -2
  41. package/src/deepscientist/connector/lingzhu_support.py +23 -4
  42. package/src/deepscientist/connector/weixin_support.py +122 -1
  43. package/src/deepscientist/daemon/api/handlers.py +75 -4
  44. package/src/deepscientist/daemon/api/router.py +1 -0
  45. package/src/deepscientist/daemon/app.py +869 -236
  46. package/src/deepscientist/doctor.py +51 -0
  47. package/src/deepscientist/file_lock.py +48 -0
  48. package/src/deepscientist/gitops/diff.py +167 -1
  49. package/src/deepscientist/mcp/server.py +331 -21
  50. package/src/deepscientist/process_control.py +161 -0
  51. package/src/deepscientist/prompts/builder.py +275 -491
  52. package/src/deepscientist/quest/service.py +2336 -145
  53. package/src/deepscientist/quest/stage_views.py +305 -29
  54. package/src/deepscientist/runners/base.py +2 -0
  55. package/src/deepscientist/runners/codex.py +88 -5
  56. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  57. package/src/deepscientist/shared.py +6 -1
  58. package/src/prompts/contracts/shared_interaction.md +13 -4
  59. package/src/prompts/system.md +984 -1985
  60. package/src/skills/analysis-campaign/SKILL.md +31 -2
  61. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  62. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  63. package/src/skills/baseline/SKILL.md +267 -994
  64. package/src/skills/baseline/references/baseline-checklist-template.md +21 -32
  65. package/src/skills/baseline/references/baseline-plan-template.md +41 -57
  66. package/src/skills/decision/SKILL.md +19 -2
  67. package/src/skills/experiment/SKILL.md +8 -2
  68. package/src/skills/finalize/SKILL.md +18 -0
  69. package/src/skills/idea/SKILL.md +78 -0
  70. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  71. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  72. package/src/skills/intake-audit/SKILL.md +1 -1
  73. package/src/skills/optimize/SKILL.md +1644 -0
  74. package/src/skills/rebuttal/SKILL.md +2 -1
  75. package/src/skills/review/SKILL.md +2 -1
  76. package/src/skills/write/SKILL.md +80 -12
  77. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  78. package/src/tui/dist/app/AppContainer.js +1445 -52
  79. package/src/tui/dist/components/Composer.js +1 -1
  80. package/src/tui/dist/components/ConfigScreen.js +190 -36
  81. package/src/tui/dist/components/GradientStatusText.js +1 -20
  82. package/src/tui/dist/components/InputPrompt.js +41 -32
  83. package/src/tui/dist/components/LoadingIndicator.js +1 -1
  84. package/src/tui/dist/components/Logo.js +61 -38
  85. package/src/tui/dist/components/MainContent.js +10 -3
  86. package/src/tui/dist/components/WelcomePanel.js +4 -12
  87. package/src/tui/dist/components/messages/AssistantMessage.js +1 -1
  88. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -3
  89. package/src/tui/dist/components/messages/OperationMessage.js +1 -1
  90. package/src/tui/dist/index.js +28 -1
  91. package/src/tui/dist/layouts/DefaultAppLayout.js +3 -3
  92. package/src/tui/dist/lib/api.js +17 -0
  93. package/src/tui/dist/lib/connectors.js +261 -0
  94. package/src/tui/dist/semantic-colors.js +29 -19
  95. package/src/tui/package.json +1 -1
  96. package/src/ui/dist/assets/{AiManusChatView-CnJcXynW.js → AiManusChatView-DDjbFnbt.js} +12 -12
  97. package/src/ui/dist/assets/{AnalysisPlugin-DeyzPEhV.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
  98. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
  99. package/src/ui/dist/assets/{CodeEditorPlugin-B-xicq1e.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
  100. package/src/ui/dist/assets/{CodeViewerPlugin-DT54ysXa.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
  101. package/src/ui/dist/assets/{DocViewerPlugin-DQtKT-VD.js → DocViewerPlugin-CLChbllo.js} +3 -3
  102. package/src/ui/dist/assets/{GitDiffViewerPlugin-hqHbCfnv.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
  103. package/src/ui/dist/assets/{ImageViewerPlugin-OcVo33jV.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
  104. package/src/ui/dist/assets/{LabCopilotPanel-DdGwhEUV.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
  105. package/src/ui/dist/assets/{LabPlugin-Ciz1gDaX.js → LabPlugin-DQPg-NrB.js} +2 -2
  106. package/src/ui/dist/assets/{LatexPlugin-BhmjNQRC.js → LatexPlugin-CI05XAV9.js} +7 -7
  107. package/src/ui/dist/assets/{MarkdownViewerPlugin-BzdVH9Bx.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
  108. package/src/ui/dist/assets/{MarketplacePlugin-DmyHspXt.js → MarketplacePlugin-DolE58Q2.js} +3 -3
  109. package/src/ui/dist/assets/{NotebookEditor-BTVYRGkm.js → NotebookEditor-7Qm2rSWD.js} +11 -11
  110. package/src/ui/dist/assets/{NotebookEditor-BMXKrDRk.js → NotebookEditor-C1kWaxKi.js} +1 -1
  111. package/src/ui/dist/assets/{PdfLoader-CvcjJHXv.js → PdfLoader-BfOHw8Zw.js} +1 -1
  112. package/src/ui/dist/assets/{PdfMarkdownPlugin-DW2ej8Vk.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
  113. package/src/ui/dist/assets/{PdfViewerPlugin-CmlDxbhU.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
  114. package/src/ui/dist/assets/{SearchPlugin-DAjQZPSv.js → SearchPlugin-CjpaiJ3A.js} +1 -1
  115. package/src/ui/dist/assets/{TextViewerPlugin-C-nVAZb_.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
  116. package/src/ui/dist/assets/{VNCViewer-D7-dIYon.js → VNCViewer-HAg9mF7M.js} +10 -10
  117. package/src/ui/dist/assets/{bot-C_G4WtNI.js → bot-0DYntytV.js} +1 -1
  118. package/src/ui/dist/assets/{code-Cd7WfiWq.js → code-B20Slj_w.js} +1 -1
  119. package/src/ui/dist/assets/{file-content-B57zsL9y.js → file-content-DT24KFma.js} +1 -1
  120. package/src/ui/dist/assets/{file-diff-panel-DVoheLFq.js → file-diff-panel-DK13YPql.js} +1 -1
  121. package/src/ui/dist/assets/{file-socket-B5kXFxZP.js → file-socket-B4T2o4nR.js} +1 -1
  122. package/src/ui/dist/assets/{image-LLOjkMHF.js → image-DSeR_sDS.js} +1 -1
  123. package/src/ui/dist/assets/{index-hOUOWbW2.js → index-BrFje2Uk.js} +2 -2
  124. package/src/ui/dist/assets/{index-Dxa2eYMY.js → index-BwRJaoTl.js} +1 -1
  125. package/src/ui/dist/assets/{index-CLQauncb.js → index-D_E4281X.js} +5418 -28620
  126. package/src/ui/dist/assets/{index-C3r2iGrp.js → index-DnYB3xb1.js} +12 -12
  127. package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
  128. package/src/ui/dist/assets/{monaco-BGGAEii3.js → monaco-LExaAN3Y.js} +1 -1
  129. package/src/ui/dist/assets/{pdf-effect-queue-DlEr1_y5.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
  130. package/src/ui/dist/assets/{popover-CWJbJuYY.js → popover-D3Gg_FoV.js} +1 -1
  131. package/src/ui/dist/assets/{project-sync-CRJiucYO.js → project-sync-C_ygLlVU.js} +1 -1
  132. package/src/ui/dist/assets/{select-CoHB7pvH.js → select-CpAK6uWm.js} +2 -2
  133. package/src/ui/dist/assets/{sigma-D5aJWR8J.js → sigma-DEccaSgk.js} +1 -1
  134. package/src/ui/dist/assets/{square-check-big-DUK_mnkS.js → square-check-big-uUfyVsbD.js} +1 -1
  135. package/src/ui/dist/assets/{trash-ChU3SEE3.js → trash-CXvwwSe8.js} +1 -1
  136. package/src/ui/dist/assets/{useCliAccess-BrJBV3tY.js → useCliAccess-Bnop4mgR.js} +1 -1
  137. package/src/ui/dist/assets/{useFileDiffOverlay-C2OQaVWc.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
  138. package/src/ui/dist/assets/{wrap-text-C7Qqh-om.js → wrap-text-9vbOBpkW.js} +1 -1
  139. package/src/ui/dist/assets/{zoom-out-rtX0FKya.js → zoom-out-BgVMmOW4.js} +1 -1
  140. package/src/ui/dist/index.html +2 -2
  141. package/uv.lock +1 -1
  142. 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 pty
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 pathlib import Path
19
- from typing import Any
16
+ from typing import Any, BinaryIO
20
17
 
21
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, utc_now
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
- 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
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 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
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
- master_fd, slave_fd = pty.openpty()
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
- 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)
214
+ launch = self._start_process(env_payload)
215
+
245
216
  with self._state_lock:
246
- self._master_fd = master_fd
247
- self._process = process
248
- self._process_group_id = process_group_id
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 and self._master_fd is not 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
- payload = data.encode("utf-8")
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
- master_fd = self._master_fd
291
- if master_fd is None or not self.is_alive():
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
- os.write(master_fd, data)
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
- master_fd = self._master_fd
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(master_fd, (rows, cols))
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(master_fd, termios.TIOCSWINSZ, winsz)
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
- process_group_id = self._process_group_id
314
- if force:
315
- _kill_process_group_force(process_group_id, process)
338
+ if process is None:
316
339
  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)
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
- meta["cwd"] = str(marker.get("cwd") or meta.get("cwd") or self.cwd)
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 or master_fd is None:
477
+ if process is None:
436
478
  return
437
- while True:
438
- self._poll_prompt_events()
439
- if self._stop_event.is_set() and process.poll() is None:
440
- _kill_process_group(self._process_group_id, process)
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
- if tail:
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._process is not None and self._process.stdout is not None:
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.stdout.close()
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()