在命令行工具里,确认框通常是最普通的交互:用户输入一个危险命令,程序弹出 [1] Approve Once / [2] Always Approve / [3] Cancel,然后等待选择。
但 NousResearch/hermes-agent issue #23853 记录了一个典型的终端输入所有权问题:Hermes CLI 中执行 /reload-mcp 后,确认框显示出来了,用户却无法输入,整个 SSH 会话像被冻住一样,只能关闭终端重连。
这个问题表面上像“确认框坏了”,根因其实是 prompt_toolkit 的 raw mode、后台 daemon thread 和 Python 内置 input() 混在一起使用,导致回车字符没有按 input() 期待的方式到达。
现象:确认框出现,但任何选项都输不进去
复现路径很短:
1. 在 SSH 会话里运行 hermes CLI
2. 输入 /reload-mcp
3. 看到确认框:
[1] Approve Once / [2] Always Approve / [3] Cancel
4. 输入 1、2 或 3 都没有响应
5. 终端会话卡住,只能关闭或 kill
issue 中还提到,类似模式可能影响其它 destructive slash commands,例如:
/clear
/new
/reset
/undo
后续评论确认:这些命令如果走同样的 _prompt_text_input → input() 路径,也会遇到同类 deadlock。
关键链路:从 /reload-mcp 到后台线程里的 input()
issue 给出的根因路径是:
cli.py:_confirm_and_reload_mcp
↓
_prompt_text_input
↓
发现当前不在 main thread
↓
fallback 到 Python 内置 input()
↓
prompt_toolkit 仍然持有 stdin / raw mode
↓
input() 等不到它想要的换行
↓
终端卡死
_prompt_text_input 的设计意图大概是:
如果在主线程,就使用 prompt_toolkit 兼容的方式提问;
如果不在主线程,就退回普通 input()。
但在 interactive CLI 里,这个 fallback 并不安全。
为什么 raw mode 下 input() 会卡住?
普通终端 canonical mode 下,用户按 Enter,程序通常能读到一行以 \n 结束的输入。
但 prompt_toolkit 为了实现高级交互,会把终端切到 raw mode。raw mode 下,按键会更直接地传给程序,Enter 可能表现为 \r,而不是 input() 期待的 \n。
于是出现错位:
用户按了 Enter
↓
raw mode 里产生的是 \r
↓
input() 在另一个线程里等 \n
↓
永远等不到完整的一行
同时,prompt_toolkit 的主事件循环仍然在管理 stdin。后台线程再调用 input(),等于两个读者争抢同一个输入流。
这类 bug 的本质不是某个选项值解析错了,而是:
终端输入只能有一个明确的 owner。
TUI fallback 里也可能出现同类死锁
issue 还提到 TUI 路径中的 slash worker subprocess:
tui_gateway/slash_worker.py
如果 TUI handler 没有命中,命令可能落到 slash.exec fallback。此时 worker subprocess 的 stdin 可能是 pipe,不是用户直接交互的 TTY。
如果这时调用 input():
worker 从 pipe 读 stdin
parent gateway 等 worker stdout
worker 等用户输入
用户根本输不到 worker stdin
结果就是父子进程互相等待。
这和 CLI raw mode 的表现不同,但原则一样:不要在非交互上下文里突然调用阻塞式 input()。
修复思路一:显式参数绕开交互确认
issue 中提出的一个稳妥方向是让 /reload-mcp 支持显式参数:
/reload-mcp now
表示只执行一次 reload,跳过交互确认。
/reload-mcp always
表示持久化关闭 reload 确认,例如:
approvals:
mcp_reload_confirm: false
这种设计对 CLI/TUI 都友好,因为它把交互选择变成了命令参数。对于自动化、SSH、subprocess、非 TTY 场景,显式参数比弹确认框可靠得多。
修复思路二:非交互上下文直接拒绝阻塞输入
另一个关键保护是,在调用 _prompt_text_input 前先判断当前环境是否真的能交互:
if not sys.stdin.isatty():
# pipe / non-TTY,不要 input()
return
if threading.current_thread() is not threading.main_thread():
# daemon thread,不要 input()
return
更完整的行为可以是:
打印清晰提示:当前上下文不能交互确认;
建议用户改用 /reload-mcp now 或 /reload-mcp always;
不要让程序进入无限等待。
这类 guard 不只是避免 bug,也能让错误恢复路径更友好。
最终修复:使用 prompt_toolkit-native modal
维护者评论中说明,问题已在 PR #23907 修复。关键方向是:_reload_mcp 确认不再用裸 input() 与 prompt_toolkit 争抢 stdin,而是改用和其他 destructive slash commands 一样的 prompt_toolkit-native modal。
也就是说,修复后的原则是:
既然 CLI 已由 prompt_toolkit 管理输入,确认框也应通过 prompt_toolkit 的机制显示和读取。
这样输入事件、焦点、raw mode 和按键处理都由同一个系统管理,不会出现主线程和后台线程分别读 stdin 的冲突。
为什么这类问题在 SSH 里特别明显?
SSH 会话通常让用户更容易感知“整个终端冻住”:
- 关闭窗口会断开连接;
- 按 Ctrl+C 可能不一定被正确处理;
- 后台线程仍在等待输入;
- prompt_toolkit 仍然持有 terminal state;
- 用户看不到更明确的错误提示。
本地终端里可能还能尝试切换、kill、重开 tab;SSH 环境里则更像是会话被锁死。
所以 issue 标注的环境很关键:
Hermes Agent CLI mode
SSH session
Linux / Python 3.11+
prompt_toolkit raw mode
如何排查类似 CLI 确认框卡死?
如果你在任何 Python CLI 工具中遇到“确认框显示了但输不了”的问题,可以按这个顺序看:
1. 确认是否使用 prompt_toolkit / curses / textual 等 UI 框架
这类框架通常会接管 stdin、terminal mode 和键盘事件。
2. 搜索是否有后台线程调用 input()
尤其是类似:
threading.current_thread() is not threading.main_thread()
之后 fallback 到 input() 的代码。
3. 检查 stdin 是否是 TTY
sys.stdin.isatty()
如果是 pipe 或 subprocess stdin,交互式确认通常不应启动。
4. 检查 Enter 的行结束符
raw mode 下可能是 \r,而阻塞式 line input 期待 \n。
5. 给 slash command 增加非交互参数
例如:
now
always
yes
--force
--no-confirm
不要把自动化路径绑死在弹窗确认上。
对 MCP reload 命令的启发
/reload-mcp 这类命令比较特殊:它涉及工具列表刷新、MCP server reload、OAuth 状态、可用工具更新等,确实适合保留确认。
但确认不等于一定要弹交互框。
更稳的设计通常是:
交互 CLI:使用 prompt_toolkit-native modal
TUI:使用 TUI 自己的 modal/command handler
非 TTY:拒绝阻塞,提示显式参数
脚本化:支持 now / always / --yes
这样既保留安全性,也不会让终端卡死。
与模型 API 层的边界
这个问题发生在 Hermes CLI / TUI 的输入层:
slash command → confirmation prompt → stdin handling → MCP reload
如果你的 Hermes 同时接了 DeepAI API 中转站或其它 OpenAI-compatible endpoint,模型调用层主要关注:
Base URL
API Key
model id
streaming
tool calling
usage 字段
而 /reload-mcp 卡在确认框,排查重点应放在 CLI/TUI 的终端输入、线程模型和 MCP reload handler 上。把模型 API 配置和本地输入死锁分层看,能更快定位问题。
FAQ
为什么我看到了确认框,却无法输入 1/2/3?
因为确认框可能是在后台 daemon thread 里调用了 Python input(),而主线程的 prompt_toolkit 仍在管理 stdin。两个输入系统冲突后,input() 等不到完整换行。
/clear、/new、/reset、/undo 也会受影响吗?
如果它们走同样的 _prompt_text_input fallback,就可能受影响。issue 评论明确提到这些 destructive slash commands 也有同类风险。
为什么 SSH 会话里更像“死机”?
因为终端状态被 raw mode 和阻塞输入卡住后,远程会话缺少直观恢复方式,用户往往只能关闭连接或 kill 进程。
修复后应该用什么方式弹确认框?
在 prompt_toolkit 驱动的 CLI 里,应使用 prompt_toolkit-native modal,而不是后台线程里的裸 input()。
自动化场景应该怎么执行 /reload-mcp?
更可靠的设计是支持显式参数,例如 /reload-mcp now 或 /reload-mcp always,避免在非 TTY 或 pipe 中等待交互输入。
总结
#23853 的核心可以概括为:
Hermes 在 prompt_toolkit raw mode 的 CLI 中,从 daemon thread fallback 到 input(),导致 stdin 所有权冲突和换行符错位,最终让 /reload-mcp 确认框卡死。
这类问题的修复原则很清楚:
不要在非主线程或非 TTY 环境里调用阻塞式 input();
交互确认应交给当前 UI 框架;
自动化路径应提供显式参数绕过弹窗。
对 CLI/TUI 工具来说,确认框不是小细节。它位于安全操作和用户控制之间,一旦输入层设计不稳,就会把一个普通确认变成整个会话的死锁点。