feat: unify terminal session controls

This commit is contained in:
JOJO 2026-03-04 23:49:10 +08:00
parent dc6d998afb
commit 4be61fe76e
14 changed files with 103 additions and 145 deletions

View File

@ -1317,7 +1317,7 @@ class MainTerminal:
"type": "function",
"function": {
"name": "sleep",
"description": "等待指定的秒数,用于短暂延迟/节奏控制(例如让终端产生更多输出、或在两次快照之间留出间隔)。命令是否完成必须用 terminal_snapshot 确认;需要控制命令最长运行时间请使用 run_command/terminal_input 的 timeout",
"description": "等待指定的秒数,用于短暂延迟/节奏控制(例如让终端产生更多输出、或在两次快照之间留出间隔)。命令是否完成必须用 terminal_snapshot 确认;需要强制超时终止请使用 run_command",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -1543,12 +1543,12 @@ class MainTerminal:
"properties": self._inject_intent({
"action": {
"type": "string",
"enum": ["open", "close", "list", "switch"],
"description": "操作类型open-打开新终端close-关闭终端list-列出所有终端,switch-切换活动终端"
"enum": ["open", "close", "list", "reset"],
"description": "操作类型open-打开新终端close-关闭终端list-列出所有终端,reset-重置终端"
},
"session_name": {
"type": "string",
"description": "终端会话名称open、close、switch时需要)"
"description": "终端会话名称open、close、reset时需要)"
},
"working_dir": {
"type": "string",
@ -1563,7 +1563,7 @@ class MainTerminal:
"type": "function",
"function": {
"name": "terminal_input",
"description": "活动终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。timeout 必填:\n1) 传入数字最大300会对命令进行硬超时封装。系统终端执行环境若存在 timeout/gtimeout会采用类似 timeout -k 2 {秒}s sh -c '命令; echo __CMD_DONE__...__' 的封装;若没有 timeout/gtimeout少见情况则退化为外层 sh -c 的 sleep/kill 包装例如sh -c '运行的指令 & CMD_PID=$!; (sleep 300 && kill -s INT $CMD_PID >/dev/null 2>&1 && sleep 2 && kill -s KILL $CMD_PID >/dev/null 2>&1) & WAITER=$!; wait $CMD_PID; CMD_STATUS=$?; kill $WAITER >/dev/null 2>&1; echo __CMD_DONE__1770826047975__; exit $CMD_STATUS'。超时后会先 INT 再 KILL进程会被不可恢复地打断可能留下半写文件、锁或残留子进程\n2) 传入 never 表示不封装、不杀进程命令原样进入终端并维护状态会在运行完成前持续占用终端窗口在确认完成前无法发送新的指令此时快照可能无法判断完成情况需要在其他终端或用run_command使用 curl/ps/ls 等主动验证。\n适合 timeout=never 的场景示例:启动常驻服务/开发服务器npm run dev、python web_server.py、uvicorn ...、开启后台进程后在另一个终端测试、在后台运行时间极长的任务同时做其他事情、Github大项目克隆、大文件下载、持续输出/长时间任务tail -f 日志、长时间编译/训练/备份/大下载、需要维持会话状态的操作例如登录远程服务器后连续执行多条命令。适合用数字超时的示例ls/rg/pytest/短脚本等快速命令\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。",
"description": "指定终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_session 的 reset 恢复。timeout 必填传入数字最大300表示本次等待输出的时长不会封装命令、不会强杀进程在等待窗口内若检测到命令已完成会提前返回否则在超时后返回已产生的输出并保持命令继续运行。需要强制超时终止请使用 run_command\n若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
@ -1573,14 +1573,14 @@ class MainTerminal:
},
"session_name": {
"type": "string",
"description": "目标终端会话名称(可选,默认使用活动终端"
"description": "目标终端会话名称(必填"
},
"timeout": {
"type": ["number", "string"],
"description": "等待输出的最长秒数必填最大300填 never 表示不封装超时且不中断进程(数字超时会触发外层封装)"
"type": "number",
"description": "等待输出的最长秒数必填最大300不会封装命令、不会中断进程"
}
}),
"required": ["command", "timeout"]
"required": ["command", "timeout", "session_name"]
}
}
},
@ -1608,22 +1608,6 @@ class MainTerminal:
}
}
},
{
"type": "function",
"function": {
"name": "terminal_reset",
"description": "重置指定终端:关闭当前进程并重新创建同名会话,用于从卡死或非法状态中恢复。请在总结中说明重置原因。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"session_name": {
"type": "string",
"description": "目标终端会话名称(可选,默认活动终端)"
}
})
}
}
},
{
"type": "function",
"function": {
@ -2072,13 +2056,13 @@ class MainTerminal:
elif action == "list":
result = self.terminal_manager.list_terminals()
elif action == "switch":
result = self.terminal_manager.switch_terminal(
session_name=arguments.get("session_name", "default")
elif action == "reset":
result = self.terminal_manager.reset_terminal(
session_name=arguments.get("session_name")
)
if result["success"]:
print(f"{OUTPUT_FORMATS['session']} 切换到终端: {arguments.get('session_name', 'default')}")
print(f"{OUTPUT_FORMATS['session']} 终端会话已重置: {result['session']}")
else:
result = {"success": False, "error": f"未知操作: {action}"}
@ -2101,13 +2085,6 @@ class MainTerminal:
max_chars=arguments.get("max_chars")
)
elif tool_name == "terminal_reset":
result = self.terminal_manager.reset_terminal(
session_name=arguments.get("session_name")
)
if result["success"]:
print(f"{OUTPUT_FORMATS['session']} 终端会话已重置: {result['session']}")
# sleep工具
elif tool_name == "sleep":
seconds = arguments.get("seconds", 1)

View File

@ -55,7 +55,6 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
"terminal_session",
"terminal_input",
"terminal_snapshot",
"terminal_reset",
"sleep",
],
),

View File

@ -14,6 +14,7 @@ import queue
from collections import deque
import shutil
import uuid
import codecs
try:
from config import (
OUTPUT_FORMATS,
@ -115,6 +116,7 @@ class PersistentTerminal:
self.output_queue = queue.Queue()
self.reader_thread = None
self.is_reading = False
self._decoder = None
# 状态标志
self.is_interactive = False # 是否在等待输入
@ -355,6 +357,8 @@ class PersistentTerminal:
"run",
"--rm",
"-i",
]
cmd += [
"--name",
container_name,
"-w",
@ -453,19 +457,18 @@ class PersistentTerminal:
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:
# 始终读取字节因为我们没有使用text=True
line_bytes = self.process.stdout.readline()
if line_bytes:
# 解码字节到字符串
line = self._decode_output(line_bytes)
# 处理输出
self.output_queue.put(line)
self._process_output(line)
# 读取任意字节块,不依赖换行符
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
@ -759,7 +762,10 @@ class PersistentTerminal:
"output_with_echo": "命令产生输出,但终端疑似重复回显",
"timeout": f"命令超时({int(timeout)}秒)"
}
message = message_map.get(status, "命令执行完成")
if timeout >= 60:
message = f"[运行了{int(timeout)}秒的所有输出]"
else:
message = message_map.get(status, "命令执行完成")
if output_truncated:
message += f"(输出已截断,保留末尾{TERMINAL_INPUT_MAX_CHARS}字符)"
elapsed_ms = int((time.time() - start_time) * 1000)

View File

@ -528,78 +528,48 @@ class TerminalManager:
# 发送命令
terminal = self.terminals[target_session]
never_timeout = False
if isinstance(timeout, str):
if timeout.lower() == "never":
never_timeout = True
else:
try:
timeout = float(timeout)
except (TypeError, ValueError):
return {
"success": False,
"error": "timeout 参数必须是数字或 'never'",
"status": "error",
"output": "timeout 参数无效"
}
if not never_timeout:
if timeout is None or timeout <= 0:
try:
timeout = float(timeout)
except (TypeError, ValueError):
return {
"success": False,
"error": "timeout 参数必填且需大于0或设置为 'never'",
"error": "timeout 参数必须是数字",
"status": "error",
"output": "timeout 参数缺失"
"output": "timeout 参数无效"
}
timeout = min(timeout, 300)
base_timeout = timeout
marker = f"__CMD_DONE__{int(time.time()*1000)}__"
if timeout is None or timeout <= 0:
return {
"success": False,
"error": "timeout 参数必填且需大于0",
"status": "error",
"output": "timeout 参数缺失"
}
timeout = min(timeout, 300)
wrapped_command, wait_timeout = self._build_wrapped_command(command, marker, timeout)
result = terminal.send_command(
wrapped_command,
timeout=wait_timeout,
timeout_cutoff=base_timeout,
enforce_full_timeout=True,
sentinel=marker,
)
result["timeout"] = base_timeout
result["never_timeout"] = False
self._apply_terminal_input_timeout_hint(target_session, result)
return result
# never_timeout 分支:不包装命令,不发送结束标记,不强杀进程
result = terminal.send_command(
command,
timeout=None,
timeout_cutoff=None,
enforce_full_timeout=False,
timeout=timeout,
timeout_cutoff=timeout,
enforce_full_timeout=True,
sentinel=None,
)
result["timeout"] = "never"
result["never_timeout"] = True
result["timeout"] = timeout
result["never_timeout"] = False
self._apply_terminal_input_timeout_hint(target_session, result)
return result
def _apply_terminal_input_timeout_hint(self, session_name: str, result: Dict) -> None:
"""在终端输入连续超时时追加提示标记(仅数值型 timeout。/ Add hint after consecutive timeouts (numeric only)."""
"""在终端输入连续超时时追加提示标记。/ Add hint after consecutive timeouts."""
status = (result.get("status") or "").lower()
never_timeout = result.get("never_timeout")
if never_timeout is None:
timeout_value = result.get("timeout")
if isinstance(timeout_value, str) and timeout_value.lower() == "never":
never_timeout = True
if status == "timeout" and not never_timeout:
if status == "timeout":
streak = self._terminal_input_timeout_streaks.get(session_name, 0) + 1
else:
streak = 0
self._terminal_input_timeout_streaks[session_name] = streak
if streak == 2:
result["timeout_hint"] = "suggest_adjust_timeout"
elif streak == 3:
result["timeout_hint"] = "suggest_never_timeout"
def _build_wrapped_command(self, command: str, marker: str, timeout: int) -> (str, int):
"""

25
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "4.1.0",
"dependencies": {
"@types/html2canvas": "^0.5.35",
"enquirer": "^2.4.1",
"html2canvas": "^1.4.1",
"katex": "^0.16.9",
"marked": "^11.1.0",
@ -1842,11 +1843,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -2195,6 +2204,19 @@
"node": ">=10.0.0"
}
},
"node_modules/enquirer": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"license": "MIT",
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -3721,7 +3743,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"

View File

@ -12,6 +12,7 @@
},
"dependencies": {
"@types/html2canvas": "^0.5.35",
"enquirer": "^2.4.1",
"html2canvas": "^1.4.1",
"katex": "^0.16.9",
"marked": "^11.1.0",

View File

@ -62,10 +62,10 @@
### 3.4 终端操作
**持久终端**`terminal_session` 系列):
- `terminal_session`管理会话open/close/list/switch最多3个
- `terminal_input`:发送命令(`timeout` 必填最大300s或 `never`
- `terminal_session`管理会话open/close/list/reset最多3个
- `terminal_input`:发送命令(`timeout` 必填最大300s
- `terminal_snapshot`:获取输出快照(判断状态必备)
- `terminal_reset`:重置卡死终端
- 终端卡死需用 `terminal_session` 的 reset 操作恢复
**快速执行**
- `run_command`一次性命令最长30s输出限10000字符

View File

@ -80,10 +80,10 @@
### 3.4 终端操作
**持久终端**`terminal_session` 系列):
- `terminal_session`管理会话open/close/list/switch最多3个
- `terminal_input`:发送命令(`timeout` 必填最大300s或 `never`
- `terminal_session`管理会话open/close/list/reset最多3个
- `terminal_input`:发送命令(`timeout` 必填最大300s
- `terminal_snapshot`:获取输出快照(判断状态必备)
- `terminal_reset`:重置卡死终端
- 终端卡死需用 `terminal_session` 的 reset 操作恢复
**快速执行**
- `run_command`一次性命令最长30s输出限10000字符

View File

@ -981,7 +981,7 @@
await helpers.sleep(1200);
helpers.setStatus('正在规划');
await helpers.showBubble('terminal_reset快速清场并恢复初始提示符。', { duration: 2200 });
await helpers.showBubble('terminal_session reset快速清场并恢复初始提示符。', { duration: 2200 });
await helpers.moveMouseTo(resetBtn, { duration: 650 });
await helpers.click({ count: 1 });
helpers.appendTerminalLines(['session reset complete', '$'], { reset: true });

View File

@ -3362,10 +3362,14 @@ export class MonitorDirector implements MonitorDriver {
};
this.sceneHandlers.terminalSession = async (payload, runtime) => {
this.applySceneStatus(runtime, 'terminalSession', '打开终端');
const action = (payload?.arguments?.action || payload?.action || '').toLowerCase();
// 特殊处理:如果是关闭/切换终端,不要无意中新建会话
if (action === 'close' || action === 'switch') {
if (action === 'reset') {
this.applySceneStatus(runtime, 'terminalSession', '正在重置终端');
} else {
this.applySceneStatus(runtime, 'terminalSession', '打开终端');
}
// 特殊处理:如果是关闭/重置终端,不要无意中新建会话
if (action === 'close' || action === 'reset') {
terminalMenuDebug('terminalSession:action', { action, payload });
const targetSession = this.resolveExistingSessionId(payload);
if (!targetSession) {
@ -3379,15 +3383,14 @@ export class MonitorDirector implements MonitorDriver {
} else {
this.showWindow(shell.element);
}
if (action === 'switch') {
const tab = this.getTabElement(targetSession);
if (tab) {
await this.movePointerToElement(tab, { duration: 420 });
await this.click();
this.activateSession(targetSession);
}
if (action === 'reset') {
await this.openTerminalContextMenu(targetSession);
await this.chooseTerminalMenuAction('reset');
this.terminalHistories.set(targetSession, [{ text: '➜ ', role: 'prompt' }]);
this.renderTerminalHistory(targetSession);
this.appendTerminalNote(targetSession, '终端已重置');
this.ensurePromptLine(targetSession);
await sleep(200);
await sleep(300);
return;
}
// action === 'close'
@ -3476,20 +3479,6 @@ export class MonitorDirector implements MonitorDriver {
await sleep(300);
};
this.sceneHandlers.terminalReset = async (payload, runtime) => {
this.applySceneStatus(runtime, 'terminalReset', '正在重置终端');
const { sessionId } = await this.ensureTerminalSessionReady(payload, { focusPrompt: false, activate: false });
terminalMenuDebug('scene:terminalReset:start', { sessionId });
await this.openTerminalContextMenu(sessionId);
terminalMenuDebug('scene:terminalReset:menu-opened', { sessionId });
await this.chooseTerminalMenuAction('reset');
this.terminalHistories.set(sessionId, [{ text: '➜ ', role: 'prompt' }]);
this.renderTerminalHistory(sessionId);
this.appendTerminalNote(sessionId, '终端已重置');
this.ensurePromptLine(sessionId);
await sleep(300);
};
this.sceneHandlers.terminalSleep = async (payload, runtime) => {
this.applySceneStatus(runtime, 'terminalSleep', '正在等待');
const duration =

View File

@ -110,7 +110,6 @@ const TOOL_SCENE_MAP: Record<string, string> = {
terminal_session: 'terminalSession',
terminal_input: 'terminalInput',
terminal_snapshot: 'terminalSnapshot',
terminal_reset: 'terminalReset',
sleep: 'terminalSleep',
update_memory: 'memoryUpdate',
todo_create: 'todoCreate',

View File

@ -23,7 +23,6 @@ const RUNNING_ANIMATIONS: Record<string, string> = {
terminal_session: 'terminal-animation',
terminal_input: 'terminal-animation',
terminal_snapshot: 'terminal-animation',
terminal_reset: 'terminal-animation',
todo_create: 'file-animation',
todo_update_task: 'file-animation',
create_sub_agent: 'terminal-animation',
@ -48,8 +47,7 @@ const RUNNING_STATUS_TEXTS: Record<string, string> = {
update_memory: '正在更新记忆...',
terminal_session: '正在管理终端会话...',
terminal_input: '调用 terminal_input',
terminal_snapshot: '正在获取终端快照...',
terminal_reset: '正在重置终端...'
terminal_snapshot: '正在获取终端快照...'
};
const COMPLETED_STATUS_TEXTS: Record<string, string> = {
@ -71,8 +69,7 @@ const COMPLETED_STATUS_TEXTS: Record<string, string> = {
update_memory: '记忆更新成功',
terminal_session: '终端操作完成',
terminal_input: '终端输入完成',
terminal_snapshot: '终端快照已返回',
terminal_reset: '终端已重置'
terminal_snapshot: '终端快照已返回'
};
const LANGUAGE_CLASS_MAP: Record<string, string> = {

View File

@ -62,7 +62,6 @@ export const TOOL_ICON_MAP = Object.freeze({
todo_create: 'stickyNote',
todo_update_task: 'check',
terminal_input: 'keyboard',
terminal_reset: 'recycle',
terminal_session: 'monitor',
terminal_snapshot: 'clipboard',
unfocus_file: 'eye',

View File

@ -302,10 +302,10 @@ def _format_terminal_session(result_data: Dict[str, Any]) -> str:
f"终端 {result_data.get('session')} 已关闭,新的活动会话: {new_active}"
f"剩余会话: {', '.join(remaining) if remaining else ''}"
)
if action == "switch":
previous = result_data.get("previous") or ""
current = result_data.get("current") or "未知"
return f"终端已从 {previous} 切换到 {current}"
if action == "reset":
session = result_data.get("session") or "未知会话"
working_dir = result_data.get("working_dir") or "未知目录"
return f"终端 {session} 已重置,工作目录 {working_dir}"
if action == "list":
sessions = result_data.get("sessions") or []
total = result_data.get("total", len(sessions))