自托管开源 Agent 项目 Hermes Agent 终端界面拆解
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
这个终端界面不是给 Python CLI 套的一层皮肤,它是一个独立的 TypeScript 进程,和 Python 主体之间只有一根 stdio 上的 JSON-RPC 管子;你在输入框里做的每一个动作,落点都在这根管子的某一侧——搞不清落在哪一侧,你既改不动它,也判断不了出问题时该看谁。
先做个名字上的消歧:Hermes 这个词还对应 Nous Research 的开源模型系列,也有若干同名商标和同名库。本篇说的是 GitHub 上 NousResearch/hermes-agent 这个仓库——一个常驻在你自己机器上、自带技能与插件体系的 Agent 项目,采用 MIT 许可证,LICENSE 署名 Nous Research。下面所有路径都可以直接在这个仓库里打开对照。
一、先看清它是两个进程
启动方式是 hermes --tui(也可以用环境变量 HERMES_TUI 打开)。客户端入口是 ui-tui/src/entry.tsx,它做的第一件事是判断 stdin 是不是 TTY,不是就直接退出;然后启动 GatewayClient,再渲染 App。
GatewayClient 会去拉起一个子进程:python -m tui_gateway.entry。解释器的解析顺序在前端自己的 ui-tui/README.md 里写得很死:HERMES_PYTHON → PYTHON → $VIRTUAL_ENV/bin/python → ./.venv/bin/python → ./venv/bin/python → python3(Windows 上是 python)。这个顺序值得记住,它是后面一半启动问题的根源。
两侧的传输是按行分隔的 JSON-RPC,走 stdout/stdin。这里有一个约定很关键:Python 侧写到 stdout 的畸形行会被当作协议噪声,转成 gateway.protocol_error 事件;写到 stderr 的内容变成 gateway.stderr,落进一个内存日志环。两者都不会直接写终端。
这条约定的实际含义是:终端画面只被 Ink 一家写。Python 端任何一句手滑的打印都不会把界面撕碎,而是变成一条事件从侧面冒出来。反过来说,你在 Python 侧调试时不能靠 print,得去看日志环。
至于 Python 主体负责什么,README 一句话概括得挺准:TypeScript 拥有屏幕,Python 拥有会话、工具、模型调用和大部分命令逻辑。
各部分的位置对照
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 客户端入口 | TTY 判定、启动网关客户端、渲染 App | ui-tui/src/entry.tsx | 界面完全起不来 |
| 网关客户端 | 拉起 Python 子进程,架起 stdio JSON-RPC 桥 | ui-tui/src/gatewayClient.ts | 解释器选错、协议噪声 |
| 行编辑器 | 光标移动、按词删除、回车与换行分流 | ui-tui/src/components/textInput.tsx | 修饰键在你的终端里不生效 |
| 草稿状态 | 草稿、多行缓冲、队列条目编辑 | ui-tui/src/app/useComposerState.ts | 反斜杠续行、改排队消息 |
| 本地命令表 | 拼装 SLASH_COMMANDS、大小写不敏感查找 | ui-tui/src/app/slash/registry.ts | 某条斜杠命令在 TUI 里行为特殊 |
| 提交管道 | 发送、!cmd、{!cmd} 插值、忙时输入分流 | ui-tui/src/app/useSubmission.ts | 忙时打的字不知道去了哪 |
| 轮次控制器 | 流式增量缓冲、工具与推理状态、打断转换 | ui-tui/src/app/turnController.ts | 打断之后状态显示不对 |
| 崩溃恢复 | 决定是否重启并恢复网关,带预算 | ui-tui/src/app/gatewayRecovery.ts | 网关反复挂掉 |
| 命令真源 | 命令注册表 + 补全器 + 忙时策略 | hermes_cli/commands.py | 加命令、查某命令忙时行为 |
| 补全 RPC | complete.slash / complete.path 的实现 | tui_gateway/methods_complete.py | 补全列表内容不对 |
| 保守入口 | 白名单式命令执行器 | hermes_cli/console_engine.py | 需要一个不给 shell 的入口 |
顺带说一句渲染:助手输出如果本身带 ANSI,就直接打印;否则交给一个把 Markdown 子集翻译成 Ink 组件的渲染器,支持标题、列表、引用、表格、围栏代码块、diff 着色、行内代码、强调、链接和裸 URL。这一层的取舍思路和”终端里怎么高效重画”是两码事——同一台机器上另一个项目的差分渲染做法我在 终端 Agent 的差分渲染怎么做 里单独拆过,那篇讲的是通用渲染方法论;本篇只管这个项目的分层落点。
二、多行编辑:整个落在前端
多行输入这件事,Python 完全不知情。
行编辑器是自研的,在 ui-tui/src/components/textInput.tsx。回车键的分流逻辑集中在一处:拿到 k.return 后,如果按下了 Shift 或 Ctrl,或者(在 macOS 上)命中动作修饰键、(在非 macOS 上)命中 meta,或者终端送上来的是一个裸的 \n 序列,那就往当前位置插入换行;否则才调用提交回调。
还有一条为终端兼容准备的备用路径:行尾打一个反斜杠再回车,这一行会被追加进多行缓冲区。缓冲区本身是 ui-tui/src/app/useComposerState.ts 里的一个字符串数组状态。为什么要留这条路?因为终端模拟器对修饰键的上报能力差异很大,Shift+Enter 在不少环境里根本传不到程序手上,没有这条备用路径,那些用户就只能写单行。
草稿太长写不下去时,还有一条交接给外部编辑器的路:快捷键把当前草稿(含多行缓冲的内容)写进临时文件,挂起 Ink,启动 $EDITOR,编辑器干净退出后恢复界面并提交保存的文本。README 特别标了一句:在 VSCode/Cursor 里要用 Alt+G,因为这两个环境把主快捷键绑给了 Find Next。这种”某个 IDE 抢键”的细节,只有真在里面用过的人才会写下来。
输入历史落在 ~/.hermes/.hermes_history,或者 HERMES_HOME 指定的位置。
所以多行编辑这一层,你要改就改前端,Python 侧只会看到最终提交的那一段文本。
三、斜杠命令补全:清单在 Python,画面在 TypeScript
这一层是整套设计里最容易看错的地方。
前端的触发规则很简单:输入以 / 开头就走 complete.slash;如果末尾那个 token 以 ./、../、~/、/ 或 @ 开头,就走 complete.path。两者都做了 60 毫秒去抖。
但候选清单不是前端算的。tui_gateway/methods_complete.py 里 complete.slash 的实现,直接把 hermes_cli/commands.py 里的 SlashCommandCompleter 拿过来用,配一个 prompt_toolkit 的 Document 喂进去,再把产出的 Completion 对象序列化成 text / display / meta / kind 四个字段发回前端。也就是说,你在终端里看到的那个下拉列表,是 Python 端一个原本为 REPL 写的补全器的产物,只是换了张脸。
补全器背后的真源是 COMMAND_REGISTRY——一个 CommandDef 列表。CommandDef 上除了 name、description、category、aliases、args_hint、subcommands 这些一眼能懂的字段,还有 cli_only、gateway_only 这类可见性开关。可 Tab 补全的子命令有两条来源:显式写的 subcommands 字段,以及从 args_hint 里用正则抓管道分隔模式(比如 [on|off|status])的兜底。已经写了显式子命令的,不再走兜底。
有一处细节能说明这套东西是被真实使用磨出来的:补全器里维护了一个”会打开选择器的命令”集合,里面是 model、skin、personality 三个。这三个补全时不追加尾空格,其余命令补全到完全匹配时会补一个空格。原因写在注释里:TUI 的提交处理器会在按下回车时先应用补全,如果 /model 被补成了 /model ,选择器就打不开了。
前端也有自己的一份命令表,在 ui-tui/src/app/slash/registry.ts:按注册顺序把各个命令文件拼成 SLASH_COMMANDS,并提供大小写不敏感的查找。命中的命令由前端本地处理;没命中的落到 slash.exec,再到 command.dispatch,交给 Python。这样别名、插件、技能、注册表支撑的命令都由 Python 拥有,前端不必复制一遍逻辑。
对你的意义很直接:往这个项目里加一条斜杠命令,第一个决定是它属于哪一侧。要改屏幕上的东西(密度、重绘、剪贴板、内存诊断),写前端;要动会话、工具、配置,写 Python 并让它落进注册表。
同一套命令逻辑的第三张面孔
hermes_cli/console_engine.py 是另一条路,支撑 hermes console。它刻意比完整 CLI 窄得多,模块开头的说明就写了动机:提供一组精选的原生适配器,未来可以被面板的控制台复用,而不必变成一个裸 shell。
它的形状值得单独看,因为”给半信任场景开一个命令入口”是个很常见的需求:
- 命令按路径元组注册,每条带用法串、摘要、处理函数,以及一个
mutating标记。 - 输入先用 shlex 切词,然后过一道 shell 语法检查:命中
$(、反引号,或者管道、重定向、分号一类 token,直接拒绝,并告诉用户一次只能跑一条受支持的 Hermes 命令。 mutating为真的命令在未确认时不执行,而是返回一个”需要确认”状态和确认文案,REPL 里表现为[y/N]提示。确认后重新执行,同时给底层命令的确认参数预置为真,避免二次追问。- 另有明确的拒绝清单:一批顶层命令(交互式向导、面板、各类常驻服务、登录登出、自更新等)整条不可用;还有一批”命令 + 子命令”组合被单独挡掉,理由各写在字符串里——打开编辑器的、启动服务器的、流式输出的、创建 shell 包装的。
- 子命令的输出用重定向捕获,超过输出上限会截断并注明省略了多少字节。
也就是说,同一套 Python 命令逻辑被三个面孔用着:完整 CLI、TUI 的网关、还有这个白名单控制台。这是复用得比较克制的一个例子——共享的是命令定义与处理函数,不共享权限判定。
四、打断与改口:两侧各有一道判定
Agent 正在跑的时候你又想说话,这是终端 Agent 最难做对的交互。这个项目把它拆成了两道判定。
前端这道决定”这段文本走哪条路”。策略来自配置里的 display.busy_input_mode,取值三种:queue、steer、interrupt,用 /busy 命令切换。逻辑在 ui-tui/src/app/useSubmission.ts:
queue:追加进队列引用,等这一轮结束再排。steer:发session.steer这个 RPC 把文本注入当前轮;如果返回状态不是”已排入”,就退回排队,并在屏幕上留一行系统提示说明退化原因。interrupt(默认):走正常的发送管道。注释写得很清楚——网关才知道 Agent 此刻是在生成模型响应还是在执行工具,所以重定向的决定交给网关,前端不猜。
还有一个手感上的设计:输入框为空时连按两次回车。忙且有活跃会话,就调用 turnController.interruptTurn 强制打断;不忙但队列里有东西,就把下一条发出去。斜杠命令和 !cmd 从不排队,哪怕 Agent 正跑也立即执行。
队列里的条目还能改口:上下方向键优先进入队列条目编辑,只有队列空了才走输入历史。改完重新提交,那一条会被替换、从队列预览里移除,并被提到最前面优先发送——如果 Agent 还在忙,它就等这一轮结束后第一个发出去。
Python 这道决定”这条命令允不允许在跑的时候执行”。hermes_cli/commands.py 的 CommandDef 上有个 busy_policy 字段,三个合法值:dispatch(忙时照跑)、reject(忙时拒绝)、interrupt_then_dispatch(先打断再跑)。另有 busy_handler 指向一个专用的运行中处理器,给那些忙时行为和平时不一样的命令用。is_interrupt_then_dispatch() 是直接从这个字段派生出来的判断函数:先把别名解析成正名,再看它的策略是不是那一档。should_bypass_active_session() 的口径要宽得多,别被名字骗了——它不看策略,只要命令能被解析出来就返回真。
后者的文档注释里记了一段值得读的历史:任何能解析出来的斜杠命令都不该被塞进待处理队列,因为队列里的命令文本会被安全网丢弃——结果就是一条运行中发出的 /model 既打断了 Agent 又被丢掉,用户看到一个零字符的回复。这类注释比任何设计文档都实在。
五、边界与代价
这套设计放弃了不少东西,说清楚比夸它有用。
它只在真终端里活。 入口一上来就查 TTY,不是就退出。你没法把它塞进 CI、管道或者不带终端的容器当交互界面用;那些场景要走别的入口。
它给项目引入了 Node 依赖。 CLI 期望 ui-tui/dist/entry.js 存在,或者有完整源码可以在 ui-tui 下装依赖跑起来。一个纯 Python 环境跑得起 Agent,但跑不起这个界面。前端还带了一个本地依赖的 Ink 渲染器分支(ui-tui/packages/hermes-ink/),这意味着渲染层的问题可能要在这个分支里查。
两个进程强耦合。 Python 侧崩了,前端得靠 gatewayRecovery.ts 这个纯函数决定是否重启并恢复会话,预算是 3 次尝试 / 60 秒。超了就停手,需要人来看。
终端能力差异只能绕,不能消。 修饰键上报、鼠标、翻页键的处理都受终端摆布——README 明确写了 PgUp/PgDn 交给终端模拟器,TUI 不管。Markdown 只渲染一个子集,别指望复杂排版。
它不管的事要认清。 审批、sudo、密钥输入这些流程,前端只是把 Python 发来的结构化请求端到端搬到你眼前,同意与否是你按的键,界面不替你做安全判断——这类”人在环里”该怎么设计门槛与默认值,是另一个话题,我在 Agent 的人在环里怎么设卡 里按方法论讲过,那篇不绑定具体项目。涉及模型服务商的计费与限流机制,界面里能看到用量类命令,但各家规则不同且会调整,以官方最新说明为准。
最后一条代价最实在。 这是一个会常驻在你机器上、能开终端执行命令、能连你的聊天软件账号、会往磁盘写文件、会访问外部服务的程序。TUI 只是你能看见的那一小块。按 Ctrl+C 关掉界面,不等于关掉了后台的一切——退出前你得知道还有什么在跑。权限该怎么收,我在 Agent 权限给太大的后果 里单独写过。
六、上手与避坑清单
解释器被选错。 为什么会踩:解析顺序里前几位是环境变量和当前目录下的虚拟环境,你在别的目录启动、或者机器上有多个 venv 时,很容易起到一个没装依赖的解释器,表现是网关立刻挂掉。怎么避:显式设 HERMES_PYTHON 指向你确认可用的解释器,别依赖顺序碰巧对。
界面根本没出来。 为什么会踩:前端产物缺失,或者你在一个非 TTY 环境里跑。怎么避:先确认 ui-tui/dist/entry.js 存在,没有就在 ui-tui 下装依赖并跑开发命令;再确认你真的在交互终端里,而不是包在某个管道后面。
编辑器快捷键没反应。 为什么会踩:VSCode 和 Cursor 的内置终端把主快捷键绑给了 Find Next,按下去只会打开查找框。怎么避:在这两个环境里改用 Alt+G,README 里写明了这一条。
Shift+Enter 换不了行。 为什么会踩:你的终端不上报这个组合,程序收不到修饰键。怎么避:用行尾反斜杠加回车的备用路径把内容压进多行缓冲,或者干脆交给外部编辑器写。
忙时打的字”消失了”。 为什么会踩:默认行为不是直发,文本按当前策略进了队列或注入了当前轮,你如果只盯着输出区就会以为丢了。怎么避:看队列预览,或先确认 display.busy_input_mode 是哪个值,再用 /busy 调成你想要的手感;要强制打断就在空输入下连按两次回车。
改口被退化成排队还没注意。 为什么会踩:注入当前轮不是总能成功,被拒时会退回排队,只留一行系统提示。怎么避:改口后扫一眼系统提示那行,别只看 Agent 的输出。
新加的命令补全里不出现。 为什么会踩:补全候选来自 Python 侧的注册表和补全器,你只在前端加了处理逻辑,注册表里没有这一条。怎么避:想清楚命令属于哪一侧;需要被补全就落进注册表,前端只做本地覆盖。
自己加的命令带选择器却打不开。 为什么会踩:补全到完全匹配时会追加尾空格,而提交时会先应用补全,多出来的空格会挡住无参数执行。怎么避:照现有做法把这类命令加进补全器里那个”会打开选择器”的集合。
网关反复重启后彻底停手。 为什么会踩:恢复预算是有限的,3 次 / 60 秒用完就不再尝试。怎么避:别指望它自己好,去翻 stderr 日志环和前端那份追加式日志文件,找真正的崩因。
控制台入口里命令跑不了。 为什么会踩:那是白名单执行器,不是 shell;管道、重定向、命令替换会被直接拒,一批命令整条不可用。怎么避:先看它的支持清单,需要交互或常驻服务的操作换回完整 CLI。
收束
把这套东西的分工记成一句话:屏幕、按键、草稿、队列手感在 TypeScript;会话、工具、命令定义、忙时策略在 Python;中间只有一根按行分隔的 JSON-RPC 管子。你想改什么,先判断它在管子的哪一侧,能省掉大半找错文件的时间。
给自己留三个自检问题:这个改动会影响 Python 端吗(如果不会,就别碰网关);我要加的命令需要出现在补全里吗(需要就进注册表);我这条交互在终端不上报修饰键时还成立吗(不成立就得有备用路径)。
接下来该读哪个文件,按目的分:想搞清启动链,读 ui-tui/src/entry.tsx 和 ui-tui/src/gatewayClient.ts;想搞清按键,读 ui-tui/src/components/textInput.tsx;想加命令,读 hermes_cli/commands.py 的注册表和字段注释;想做一个受限入口,读 hermes_cli/console_engine.py。
站内相关的另外两篇分工也说明白:另一个终端 Agent 项目的上手路径 讲的是别的项目怎么起步,开源终端 Agent 怎么选 讲的是选型标准;本篇不做横向排序,只把这一个项目的界面分层讲到能动手改的程度。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 怎么换模型与换供应商 和 开源自托管 Agent 项目 Hermes Agent 的终端后端怎么选。