agent-Specialization/modules/persistent_terminal/io.py
JOJO f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- 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
2026-06-20 15:54:48 +08:00

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)