agent-Specialization/modules/persistent_terminal/lifecycle.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

227 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 LifecycleMixin:
"""PersistentTerminal lifecycle 能力 mixin。"""
def get_snapshot(self, last_n_lines: Optional[int], max_chars: Optional[int]) -> Dict:
"""获取终端快照,包含按顺序排列的输入/输出"""
include_all_lines = last_n_lines is None or last_n_lines <= 0
if not include_all_lines:
last_n_lines = max(1, last_n_lines)
segments: List[str] = []
reset = "\x1b[0m"
command_color = "\x1b[1;32m"
for event_type, _, data in self.io_history:
if event_type == 'input':
# 显示输入命令并加上ANSI颜色保持与实时终端一致
input_text = data.rstrip('\n')
decorated = f"{command_color}{input_text}{reset}" if input_text.strip() else f"{command_color}{reset}"
if not decorated.endswith('\n'):
decorated += '\n'
segments.append(decorated)
else:
cleaned = data.replace('\r', '')
segments.append(cleaned)
full_text = ''.join(segments)
if full_text:
line_chunks = full_text.splitlines(keepends=True)
total_lines = len(line_chunks)
if include_all_lines:
selected_lines = line_chunks
lines_requested = total_lines
else:
count = min(last_n_lines, total_lines)
selected_lines = line_chunks[-count:] if count else []
lines_requested = last_n_lines
output_text = ''.join(selected_lines)
else:
output_text = ''
lines_requested = 0
total_lines = 0
truncated = False
if max_chars is not None and max_chars > 0 and len(output_text) > max_chars:
output_text = output_text[-max_chars:]
truncated = True
# 统计行数
if output_text:
lines_returned = output_text.count('\n') + (0 if output_text.endswith('\n') else 1)
else:
lines_returned = 0
if include_all_lines:
lines_requested = total_lines
return {
"success": True,
"session": self.session_name,
"output": output_text,
"lines_requested": lines_requested,
"lines_returned": lines_returned,
"truncated": truncated,
"is_interactive": self.is_interactive,
"echo_loop_detected": self.echo_loop_detected,
"seconds_since_last_output": self._seconds_since_last_output(),
"last_command": self.last_command,
"buffer_size": self.total_output_size,
"timestamp": datetime.now().isoformat()
}
def get_status(self) -> Dict:
"""获取终端状态"""
return {
"session_name": self.session_name,
"is_running": self.is_running,
"working_dir": str(self.working_dir),
"shell": self.shell_command,
"execution_mode": self.execution_mode,
"start_time": self.start_time.isoformat() if self.start_time else None,
"is_interactive": self.is_interactive,
"last_command": self.last_command,
"command_count": len(self.command_history),
"buffer_size": self.total_output_size,
"truncated_lines": self.truncated_lines,
"last_activity": datetime.fromtimestamp(self.last_activity).isoformat(),
"uptime_seconds": (datetime.now() - self.start_time).total_seconds() if self.start_time else 0,
"seconds_since_last_output": self._seconds_since_last_output(),
"echo_loop_detected": self.echo_loop_detected
}
def close(self) -> bool:
"""关闭终端"""
if not self.is_running:
return False
try:
# 停止读取线程
self.is_reading = False
# 发送退出命令
if self.process and self.process.poll() is None:
exit_cmd = "exit\n"
try:
self.process.stdin.write(exit_cmd.encode('utf-8'))
self.process.stdin.flush()
except:
pass
# 等待进程结束
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
# 强制终止
self.process.terminate()
time.sleep(0.5)
if self.process.poll() is None:
self.process.kill()
self.is_running = False
# 等待读取线程结束
if self.reader_thread and self.reader_thread.is_alive():
self.reader_thread.join(timeout=1)
# 广播终端关闭事件
if self.broadcast:
self.broadcast('terminal_closed', {
'session': self.session_name,
'time': datetime.now().isoformat()
})
if self.using_container:
self._stop_sandbox_container()
self.using_container = False
self.execution_mode = "host"
print(f"{OUTPUT_FORMATS['info']} 终端会话关闭: {self.session_name}")
return True
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 关闭终端失败: {e}")
return False
def _stop_sandbox_container(self, force: bool = False):
"""确保容器终端被停止"""
if not self._owns_container or not self.sandbox_container_name or not self._sandbox_bin_path:
return
try:
subprocess.run(
[self._sandbox_bin_path, "kill", self.sandbox_container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=3,
check=False
)
except Exception:
if force:
print(f"{OUTPUT_FORMATS['warning']} 强制终止容器 {self.sandbox_container_name} 失败,可能已退出。")
finally:
self.sandbox_container_name = None
self._owns_container = False