- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
241 lines
8.3 KiB
Python
241 lines
8.3 KiB
Python
# modules/persistent_terminal.py - 持久化终端实例(修复版)
|
|
|
|
import asyncio
|
|
import subprocess
|
|
import os
|
|
import sys
|
|
import time
|
|
import signal
|
|
from pathlib import Path
|
|
from typing import Optional, Callable, Dict, List, Tuple
|
|
from datetime import datetime
|
|
import threading
|
|
import queue
|
|
from collections import deque
|
|
import shutil
|
|
import uuid
|
|
import codecs
|
|
from modules.host_sandbox_runner import (
|
|
HostSandboxError,
|
|
build_host_sandbox_shell_plan,
|
|
host_sandbox_enabled,
|
|
)
|
|
try:
|
|
from config import (
|
|
OUTPUT_FORMATS,
|
|
TERMINAL_OUTPUT_WAIT,
|
|
TERMINAL_INPUT_MAX_CHARS,
|
|
TERMINAL_SANDBOX_MODE,
|
|
TERMINAL_SANDBOX_IMAGE,
|
|
TERMINAL_SANDBOX_MOUNT_PATH,
|
|
TERMINAL_SANDBOX_SHELL,
|
|
TERMINAL_SANDBOX_NETWORK,
|
|
TERMINAL_SANDBOX_CPUS,
|
|
TERMINAL_SANDBOX_MEMORY,
|
|
TERMINAL_SANDBOX_BINDS,
|
|
TERMINAL_SANDBOX_BIN,
|
|
TERMINAL_SANDBOX_NAME_PREFIX,
|
|
TERMINAL_SANDBOX_ENV,
|
|
TERMINAL_SANDBOX_REQUIRE,
|
|
)
|
|
except ImportError:
|
|
import sys
|
|
from pathlib import Path
|
|
project_root = Path(__file__).resolve().parents[1]
|
|
if str(project_root) not in sys.path:
|
|
sys.path.insert(0, str(project_root))
|
|
from config import (
|
|
OUTPUT_FORMATS,
|
|
TERMINAL_OUTPUT_WAIT,
|
|
TERMINAL_INPUT_MAX_CHARS,
|
|
TERMINAL_SANDBOX_MODE,
|
|
TERMINAL_SANDBOX_IMAGE,
|
|
TERMINAL_SANDBOX_MOUNT_PATH,
|
|
TERMINAL_SANDBOX_SHELL,
|
|
TERMINAL_SANDBOX_NETWORK,
|
|
TERMINAL_SANDBOX_CPUS,
|
|
TERMINAL_SANDBOX_MEMORY,
|
|
TERMINAL_SANDBOX_BINDS,
|
|
TERMINAL_SANDBOX_BIN,
|
|
TERMINAL_SANDBOX_NAME_PREFIX,
|
|
TERMINAL_SANDBOX_ENV,
|
|
TERMINAL_SANDBOX_REQUIRE,
|
|
)
|
|
|
|
|
|
class IoMixin:
|
|
"""PersistentTerminal io 能力 mixin。"""
|
|
|
|
def _read_output(self):
|
|
"""后台线程:持续读取输出(修复版,正确处理编码)"""
|
|
if self._decoder is None:
|
|
encoding = 'gbk' if self.is_windows else 'utf-8'
|
|
self._decoder = codecs.getincrementaldecoder(encoding)(errors='replace')
|
|
while self.is_reading and self.process:
|
|
try:
|
|
# 读取任意字节块,不依赖换行符
|
|
chunk = self.process.stdout.read(1024)
|
|
if chunk:
|
|
text = self._decoder.decode(chunk)
|
|
if text:
|
|
self.output_queue.put(text)
|
|
self._process_output(text)
|
|
elif self.process.poll() is not None:
|
|
# 进程已结束
|
|
self.is_running = False
|
|
break
|
|
else:
|
|
# 没有输出,短暂休眠
|
|
time.sleep(0.01)
|
|
|
|
except Exception as e:
|
|
# 不要因为单个错误而停止
|
|
print(f"[Terminal] 读取输出警告: {e}")
|
|
time.sleep(0.01)
|
|
continue
|
|
|
|
def _decode_output(self, data):
|
|
"""安全地解码输出"""
|
|
# 如果已经是字符串,直接返回
|
|
if isinstance(data, str):
|
|
return data
|
|
|
|
# 如果是字节,尝试解码
|
|
if isinstance(data, bytes):
|
|
# Windows系统尝试的编码顺序
|
|
if self.is_windows:
|
|
encodings = ['utf-8', 'gbk', 'gb2312', 'cp936', 'latin-1']
|
|
else:
|
|
encodings = ['utf-8', 'latin-1']
|
|
|
|
for encoding in encodings:
|
|
try:
|
|
return data.decode(encoding)
|
|
except (UnicodeDecodeError, AttributeError):
|
|
continue
|
|
|
|
# 如果所有编码都失败,使用替换模式
|
|
return data.decode('utf-8', errors='replace')
|
|
|
|
# 其他类型,转换为字符串
|
|
return str(data)
|
|
|
|
def _process_output(self, output: str):
|
|
"""处理输出行"""
|
|
now = time.time()
|
|
noisy_markers = (
|
|
"bash: cannot set terminal process group",
|
|
"bash: no job control in this shell",
|
|
)
|
|
for line in output.splitlines(keepends=True):
|
|
if any(noise in line for noise in noisy_markers):
|
|
continue
|
|
self.output_buffer.append(line)
|
|
self.total_output_size += len(line)
|
|
now = time.time()
|
|
self.last_output_time = now
|
|
|
|
# 记录输出事件
|
|
self._output_event_counter += 1
|
|
self.output_history.append((self._output_event_counter, now, line))
|
|
self._append_io_event('output', line, timestamp=now)
|
|
|
|
# 控制输出历史长度
|
|
if len(self.output_history) > 2000:
|
|
self.output_history.popleft()
|
|
|
|
# 检查是否需要截断
|
|
if self.total_output_size > self.max_buffer_size:
|
|
self._truncate_buffer()
|
|
|
|
# 更新活动时间
|
|
self.last_activity = now
|
|
|
|
# 检测命令回显死循环
|
|
cleaned_output = output.replace('\r', '').strip()
|
|
cleaned_input = self.last_input_text.strip() if self.last_input_text else ""
|
|
if cleaned_output and cleaned_input and cleaned_output == cleaned_input:
|
|
self._consecutive_echo_matches += 1
|
|
else:
|
|
self._consecutive_echo_matches = 0
|
|
if cleaned_output:
|
|
self.echo_loop_detected = False
|
|
if self._consecutive_echo_matches >= 1 and self.last_input_time:
|
|
if now - self.last_input_time <= 2:
|
|
self.echo_loop_detected = True
|
|
|
|
# 检测交互式提示
|
|
self._detect_interactive_prompt(output)
|
|
|
|
# 广播输出
|
|
if self.broadcast:
|
|
self.broadcast('terminal_output', {
|
|
'session': self.session_name,
|
|
'data': output,
|
|
'timestamp': time.time()
|
|
})
|
|
|
|
def _truncate_buffer(self):
|
|
"""截断缓冲区以保持在限制内"""
|
|
# 保留最后的N个字符
|
|
while self.total_output_size > self.max_buffer_size and self.output_buffer:
|
|
removed = self.output_buffer.pop(0)
|
|
self.total_output_size -= len(removed)
|
|
self.truncated_lines += 1
|
|
if self.output_history:
|
|
self.output_history.popleft()
|
|
|
|
def _detect_interactive_prompt(self, output: str):
|
|
"""检测是否在等待交互输入"""
|
|
self.is_interactive = False
|
|
# 常见的交互提示模式
|
|
interactive_patterns = [
|
|
"? ", # 问题提示
|
|
": ", # 输入提示
|
|
"> ", # 命令提示
|
|
"$ ", # shell提示
|
|
"# ", # root提示
|
|
">>> ", # Python提示
|
|
"... ", # Python续行
|
|
"(y/n)", # 确认提示
|
|
"[Y/n]", # 确认提示
|
|
"Password:", # 密码提示
|
|
"password:", # 密码提示
|
|
"Enter", # 输入提示
|
|
"选择", # 中文选择
|
|
"请输入", # 中文输入
|
|
]
|
|
|
|
output_lower = output.lower().strip()
|
|
for pattern in interactive_patterns:
|
|
if pattern.lower() in output_lower:
|
|
self.is_interactive = True
|
|
return
|
|
|
|
# 如果输出以常见提示符结尾且没有换行,也认为是交互式
|
|
if output and not output.endswith('\n'):
|
|
last_chars = output.strip()[-3:]
|
|
if last_chars in ['> ', '$ ', '# ', ': ']:
|
|
self.is_interactive = True
|
|
|
|
def _capture_history_marker(self) -> int:
|
|
return self._output_event_counter
|
|
|
|
def _get_output_since_marker(self, marker: int) -> str:
|
|
if marker is None:
|
|
return ''.join(item[2] for item in self.output_history)
|
|
return ''.join(item[2] for item in self.output_history if item[0] > marker)
|
|
|
|
def _append_io_event(self, event_type: str, data: str, timestamp: Optional[float] = None):
|
|
"""记录终端输入输出事件"""
|
|
if timestamp is None:
|
|
timestamp = time.time()
|
|
self.io_history.append((event_type, timestamp, data))
|
|
while len(self.io_history) > self._io_history_max:
|
|
self.io_history.popleft()
|
|
|
|
def _seconds_since_last_output(self) -> Optional[float]:
|
|
if not self.last_output_time:
|
|
return None
|
|
return round(time.time() - self.last_output_time, 3)
|