开源自托管 Agent 项目 Hermes Agent 的两套前端分工

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

同一个 Agent 后端挂两套前端,不是重复投入,而是把「谁有权决定对话内容」和「谁有权决定屏幕长什么样」这两件事彻底分开之后,自然会长出来的结果。 看懂这条切线,比看懂它任何一个界面细节都值钱——因为你自己做工具的时候,迟早要面对同一个决定。

先做个消歧:这里说的 Hermes Agent 是 Nous Research 开源的那个常驻自托管 Agent 项目,仓库在 NousResearch/hermes-agent,MIT 许可证。它跟同名的 Hermes 开源模型系列不是一回事,也跟若干同名商标、同名库无关。本文只谈这个仓库里的代码与文档。

站内已有几篇相关的:pi 的 TUI 差分渲染 讲终端重绘这件事的通用技术;Agent 交接给人Agent 团队交付 讲的是跨项目通用的方法论。这篇不重复它们,只回答一个具体问题:这个具体仓库,把刀切在了哪几个位置,你打开它之后该从哪个文件开始读。

一、它其实有三个前端,但只有两套自己实现了对话面

打开仓库根目录能看到三个前端方向的目录:ui-tui/apps/desktop/web/。它们不是三套并列的客户端。

ui-tui/ 是终端界面,用 hermes --tui 启动,技术栈是 React + Ink。它的 README 第一句就把职责写死了:TypeScript 管屏幕,Python 管会话、工具、模型调用和大部分命令逻辑。

apps/desktop/ 是 Electron 桌面端,用 hermes desktop 启动,渲染层是 React,对话面建在 @assistant-ui/react 上。它的工程指南里有一句写得很直白:Desktop 是自己的原生对话面,它不是浏览器仪表盘,也不内嵌 TUI。

web/ 是浏览器仪表盘,重心在配置与运维:web/src/pages/ 下摊开的是配置编辑、密钥管理、会话列表、日志、技能、插件、定时任务、模型这一类页面。这里先埋个提醒:web/README.md 的目录清单只列了三个页面,早就落后于 web/src/pages/ 的实际内容,读文档之前先自己 ls 一遍,别把清单当现状。hermes dashboard 在 9119 端口上提供的是构建产物(从 hermes_cli/web_dist/ 出),本地开发时 Vite 开发服务器把 /api 反向代理到 http://127.0.0.1:9119

仪表盘里确实有一个聊天页,但它不是第三套对话面的实现。web/src/pages/ChatPage.tsx 的文件头把链路画得很清楚:浏览器里跑一个 xterm 终端,按键经 /api/pty 这条 WebSocket 送到后端,后端在一个 PTY 上把 ui-tui/dist/entry.js 跑起来,输出再按 VT100 回吐到网页上。换句话说,网页端的聊天是把终端界面投屏进来,而不是重写一遍。这一条对本文的主题恰好是正面例证:真要省事,就别再造一个对话面。

所以真正的「两套前端」是 TUI 和桌面端。它们面向的不是两类人,是同一类人的两种处境:手上正在 SSH 里、或者屏幕已经被终端占满的时候用前者;需要边聊边看渲染结果、边浏览工作目录的时候用后者。

二、共享层不是一层,是三层,最深的那层在 Python 里

很多人以为「共享」指的是共享一个 UI 组件库。这个项目里不是。它共享的东西一路往下压到了协议之下。

最深一层是处理函数本身tui_gateway/ws.py 的模块文档里写得很清楚:它原封不动复用 tui_gateway.server.dispatch,目的就是让每个 RPC 方法、每个斜杠命令、每个 approval / clarify / sudo 流程、每个 Agent 事件,无论客户端是走 stdio 的 Ink 还是走 WebSocket 的客户端,都从同一批处理器里流过去。

支撑这件事的是 tui_gateway/transport.py。它把 I/O 出口从处理逻辑里解耦出来,用 contextvars 记住当前请求该往哪个对端写;没有绑定传输时回落到模块级的 stdio 传输。这意味着加一个新前端,理论上不需要改任何一个业务处理函数。

第二层是线协议。两边用的是同一套:换行分隔的 JSON-RPC,双向。ws.py 的文档明确写了「与 stdio 相同,无 framing 差异」,连接建立后立刻发一个 gateway.ready 事件。这句话的分量在于:如果协议在两个传输上不一致,上面那层「复用 dispatch」就会被迫在处理器里写分支,共享立刻破产。

第三层才是 TypeScript 侧的共享包 @hermes/shared(目录在 apps/shared/)。它导出的东西很克制:JsonRpcGatewayClientGatewayEventName 这类传输与事件契约、buildHermesWebSocketUrlresolveGatewayWsUrl 这类连接解析、SKIN_COLOR_TOKENS / SKIN_BRANDING_TOKENS 这套皮肤令牌、以及计费相关的类型和 driveChargeSettlement。它没有导出任何一个可视组件。

皮肤这块最能说明共享层的边界画在哪。apps/shared/src/skin.ts 的注释把整条链写全了:皮肤以 YAML 形式写在 Hermes home 的皮肤目录下(或者用内置的),由 Python 侧的皮肤引擎 hermes_cli/skin_engine.py 解析,然后通过 JSON-RPC 推给每个前端;这是所有 TypeScript 前端消费的唯一形状,但每个前端自己拥有一个解析器,把它归一化成自己的渲染模型——TUI 走 fromSkin 变成 ANSI 安全的 Ink 主题,桌面端走 skinToDesktopTheme 变成 CSS 自定义属性。它还老实交代了一个取向:令牌是「终端优先」的,因为 CLI 是最老的界面,GUI 从那少数几个承重令牌里推导自己更完整的调色板。

这套令牌本身就是一份可读的分工说明书:里面既有 ui_accentui_border 这种两边都用得上的,也有 promptinput_ruleshell_dollarstatus_bar_bg 这种只有终端界面才有对应物的。共享的是「名字和语义」,不是「怎么画」。

组成部分它负责什么对应仓库位置你什么时候会碰到它
Agent 与网关处理器会话、工具、模型调用、审批与澄清流程tui_gateway/server.pytui_gateway/methods_*.py想改 Agent 行为、加 RPC 方法时
传输抽象同一批处理器往 stdio 或 WebSocket 写tui_gateway/transport.pytui_gateway/ws.py接第三个客户端、排查「事件发给了错误的对端」时
TS 共享契约事件名、WebSocket 客户端、连接 URL 解析、皮肤令牌、计费类型apps/shared/src/(包名 @hermes/shared前端要认识一个新事件、或改连接与鉴权时
终端界面Ink 渲染、行编辑器、补全、队列、终端态适配ui-tui/src/,含分叉的 Ink 渲染器 ui-tui/packages/hermes-ink/改终端交互、按键、滚动、ANSI 渲染时
桌面端渲染层路由、面板、设计系统原语、i18napps/desktop/src/加界面、加设置项、加一种面板时
桌面端原生层后端解析与探活、安装/更新、文件与 git 能力、窗口apps/desktop/electron/main.ts 及旁边的模块启动失败、装不上、跨平台打包时
浏览器控制台配置、密钥、会话、日志等运维页面,外加一块 TUI 投屏web/src/pages/只想改配置、或者手边只有浏览器时

三、终端那套:预算全花在输入和终端兼容上

TUI 的入口是 ui-tui/src/entry.tsx。它做的第一件事是判断 stdin 是不是 TTY,不是就直接退出——这条守卫决定了它不能被当成后台进程随便拉起来。然后启动 GatewayClient,再渲染 App

GatewayClient 会去 spawn python -m tui_gateway.entry。解释器怎么找,README 给了明确顺序:HERMES_PYTHONPYTHON → 虚拟环境目录下的 python → ./.venv/bin/python./venv/bin/pythonpython3(Windows 上是 python)。这个顺序值得记住,因为多环境机器上「跑起来了但用的不是你以为那个环境」几乎全都出在这里。

有一处设计我觉得是这套 TUI 最克制的地方:子进程的 stderr 不直接落到终端,而是被收进内存里的日志环;stdout 上出现无法解析的行也不打印,而是合成 gateway.protocol_error 事件,stderr 行合成 gateway.stderr 事件。终端界面最容易被后端一行意外的 print 冲毁版面,它从一开始就把这条路堵了。背后的约束很朴素:屏幕是共享资源,谁都不能随便往上写。

其余的力气几乎全花在输入上。ui-tui/src/components/textInput.tsx 是自己写的行编辑器,README 里那张按键表长得像 readline 手册:词级移动、Ctrl+W 删词、Ctrl+U / Ctrl+K 删到行首行尾,\ + Enter 作为不支持修饰键终端的多行回退方案。Cmd/Ctrl+G 把当前草稿(含多行缓冲)写进临时文件、挂起 Ink、拉起 $EDITOR,编辑器正常退出就把内容提交回去——在 VSCode / Cursor 里要用 Alt+G,因为主键位被它们绑给了「查找下一个」。PgUp / PgDn 干脆不接,留给终端模拟器。

繁忙状态下的行为也是终端特有的:普通文本在 Agent 忙时进队列而不是直接发,斜杠命令和 !cmd 则不排队、立即执行;{!cmd} 是发送前的内联 shell 插值,而排队中的草稿保留原始文本,真正发送时才展开。补全请求有 60 毫秒防抖,以 / 开头走一条补全通道,末尾 token 以 ./../~//@ 开头走路径补全。

审批、澄清、sudo、密钥输入这四类阻塞式提问,在 TUI 里不是独立屏幕,而是 app.tsx 里的状态分支;提问打开时主输入的按键全部挂起。斜杠命令有自己的注册表,按 core → billing → credits → session → ops → setup → debug 的顺序装配,查找不区分大小写;匹配不到的命令不报错,而是依次落到 slash.execcommand.dispatch 交给 Python——这一步让别名、插件、技能带来的命令不必在 TypeScript 里复制一遍。

还有几个小而实在的机制:网关崩了之后是否重启并恢复,由一个纯函数决定,预算封顶在 3 次 / 60 秒;会话开始时拉一次完整配置,之后每 5 秒轮询配置文件的修改时间,变了就应用显示设置并触发 MCP 重载;工具跑超过 8 秒会冒出一句环境提示,免得屏幕看着像卡死;子任务扇出的完成快照留最近 10 条在内存环里,供回放用。

四、桌面端:多出来的那一层是「机器」

桌面端的架构指南把参与方拆成三家,各自对一件事说了算:Electron 管机器(进程生命周期、原生文件系统 / git / 窗口、安装与更新,以及一条窄的、带类型的能力桥);渲染层管体验(导航、呈现、临时交互状态);Agent 后端管干活(会话、工具、模型调用、流式输出)。后端以无头的 hermes serve 进程跑着,对外暴露那套 JSON-RPC / WebSocket 接口,渲染层通过 @hermes/shared 连上去——同一条路浏览器仪表盘也在走。

TUI 里不存在的那部分复杂度,全在 Electron 这一层。最典型的是「后端到底用哪个」这件事,README 把它写成了一条有序阶梯:HERMES_DESKTOP_HERMES_ROOT → 开发时的当前源码检出 → 已完成的托管安装 → HERMES_DESKTOP_HERMESPATH 上的 hermes → 能 import 运行时的系统 Python → 首次启动的引导安装器。关键在下一句:候选在使用前先探活,「存在一个 shim 或解释器」不算证据。更早的运行时如果还没有 serve,会回落到无头的 dashboard --no-open——文档特意声明这只是后端命令层面的兼容,不会启动也不会嵌入仪表盘界面。

这条阶梯在工程指南里被抽成了一个可复用的形状:优先级写在一个地方、以数据或纯函数存在;候选只有在正确的边界上被验证过才可信;读失败落到下一级,权威写失败则报错或回滚而不是悄悄换目标;「能力缺失」和「临时故障」要区别对待;重试有界并且以一个真实的恢复入口收尾;每条策略只有一个解析器,避免两个调用点各答一套。后端发现、命令与版本回退、连接与鉴权、工作目录选择、能力探测都是同一个形状。

界面这一层反过来收得很紧。DESIGN.md 的原则是一句「一个关注点一个来源、令牌优先于字面量、扁平优先于套盒」:按钮只有 src/components/ui/button.tsx 一个来源,调用点只传 variant 和 size,不许传 h-* / px-* 覆盖;文本输入类控件共用 src/components/ui/control.ts 里的形状;页面左右留白用 src/app/layout-constants.ts 里的常量而不是写死 padding;浮层的阴影和发丝边框统一走令牌,不许每个浮层自己发明;层叠顺序去 styles.css 里的梯子上挑一级,包括启动链那几级,而不是随手写 z-index。

这里面有两条我认为值得直接抄走。一是「有约束就写成测试」:不许在按钮上用原生 title=,就落成 src/components/ui/__tests__/no-native-title.test.ts,谁写谁红。二是「命名契约与代码同批改」:设计文档区分了耐久的原则和当期的命名契约(令牌、按钮变体、原语名),并且明确要求改原语就在同一次改动里更新文档里的条目——文档里一个过期的名字,和一个过期的类型一样算 bug。

交互取向上有几条也是明确写下来的:意图先于自动化,工具产出了东西不等于可以替用户开面板、移焦点、切路由;直接操作先画后同步,失败要看得见地回滚;昂贵的有状态界面(终端、活的工具)隐藏时保持挂载,可见性不等于生命周期;键盘归属跟随焦点,一个取消手势只做一件事。文案上还有一条硬规矩:所有用户可见字符串走 i18n,四个语言包要一起改,只改英文算回归。

五、这个分法放弃了什么

两套前端共享到协议层为止,代价是明确的,文档也没打算掩饰。

共享的是契约,不是观感。 同一个功能要出现在两个界面上,就得实现两次:一次在 Ink 里,一次在 React 里。皮肤令牌能保证颜色语义一致,不能替你把面板画出来。你不能指望在一处加一个组件、两个界面同时长出来。

TUI 天然吃不下一部分能力。 桌面端 README 列的并排预览、文件浏览器、语音、图形化设置这些,在终端里要么没有对等物,要么只能大打折扣。这不是谁偷懒,是画布不一样。

桌面端多出一整块「机器」问题。 后端解析阶梯、探活、首次安装、更新、跨平台打包与签名、Electron 单实例锁,这些 TUI 完全不需要操心。装机相关的东西一旦出问题,排查入口在原生层而不是界面层。

远程模式下执行边界在对端,不在你眼前这台机器。 桌面端 README 说得很明白:远程模式里网关主机就是执行边界,Agent 的工具、终端命令、文件操作都跑在远端 Hermes 主机上,不是显示界面的这台电脑上。这句话请当成安全条款读——你在窗口里点的每一次批准,作用的是别人那台机器的文件系统。相关的权限收口思路可以参考 最小权限设计

它明确不管的事也值得列一下:TUI 的澄清模式在当前客户端里没有专用取消快捷键,sudo 与密钥提问只有 Ctrl+C 一条取消路径;PgUp / PgDn 归终端模拟器;桌面端的内部注册表被明确声明为「组合接缝,不是公开插件 ABI」,别拿它当扩展系统用;工程指南还专门提醒,「插件」这个词在 Hermes 的不同界面里指的不是一件事,别把一个界面的扩展模型套到另一个上。

最后是这类项目共同的代价:它常驻在你的机器上、会开终端执行命令、会往磁盘写文件、会访问外部服务。审批与 sudo 提问是它给你的刹车,但刹车只有踩了才算。仓库里 skills/ 有 14 个分类目录共 70 份技能说明,optional-skills/ 有 21 个分类目录共 111 份,plugins/ 有 18 个顶层插件目录,optional-mcps/ 有 6 个——每装一个,都是在给这台常驻进程加一份权限。装之前先读它的说明,别按目录名猜它会做什么。开源终端 Agent 之间怎么比,可以看 开源终端 Agent 怎么选

六、上手与避坑清单

从桌面端进,还是从 TUI 进。 如果你已经装了 CLI,桌面端 README 推荐的路径是直接 hermes desktop,它会拿你现有的配置、密钥、会话和技能去构建并启动图形界面。会踩的坑是反过来:机器上没有可用运行时、也没有保存过远程连接时,首次启动会先问你是连已有网关还是本地安装——这一步选错,后面所有「找不到会话」的困惑都从这里来。避法是先想清楚你的 Agent 该跑在哪台机器上,再回答这个问题。

多 Python 环境机器上先固定解释器。 TUI 的解释器解析有明确顺序,会踩的原因是你以为它用了当前激活的虚拟环境,实际按顺序命中了更靠前的一项。避法是显式设置那个专门给 Hermes 用的 Python 环境变量,别依赖顺序。

别在真配置上做开发。 会踩的原因是桌面端开发跑起来就连你真实的 Hermes home,一次实验把配置改花了。仓库给了现成的出口:仓库根下的 scripts/dev-sandbox.sh 会给你一个一次性 home、独立的 Electron 用户数据目录和不同的应用名(避开单实例锁);也可以只用一个临时的 HERMES_HOME 起。另外有一条专门给启动界面用的假启动脚本,用确定的延迟来演练启动覆盖层,不用真的等一次安装。

启动失败先看日志再改配置。 会踩的原因是启动失败的表象在界面上,根因在后端。桌面端把启动日志落在 Hermes home 下的 logs/desktop.log,里面带后端输出和最近的 Python 回溯。文档的建议就是先看它。首次安装卡住有两个粗暴但明确的复位动作:删掉引导完成标记文件强制走一次干净的首次安装,或者删掉那个 Python 虚拟环境目录重建。

从源码跑 TUI 前先确认构建产物。 会踩的原因是 CLI 期望 ui-tui/dist/entry.js 存在,否则需要完整源码在手、能在 ui-tui/ 里装依赖并跑开发命令。只拷了半份源码就 hermes --tui 的话,报错信息不会直接告诉你缺的是构建产物。

改了界面别只跑单测。 桌面端 README 把开 PR 前该跑的检查列成了一串:格式修复、类型检查、lint、界面测试、平台相关测试;改到安装、启动、更新、打包这些发布路径上的东西,要跑更全的那一档。会踩的原因是渲染层测试全绿并不覆盖原生层,而原生层坏掉的表现是「用户根本打不开」。

改字符串就改全四个语言包。 会踩的原因是只改英文不报错,问题以「标点漂移、标签过期」的形式在别的语言里留下来。设计文档把这个直接定性为回归。

Alt+G 而不是 Ctrl+G(在 VSCode / Cursor 的终端里)。 会踩的原因是主键位被编辑器抢去做「查找下一个」,你按下去什么也不会发生。这条在 README 的按键表里有备注,属于典型的「不看文档就以为坏了」。

收个尾

如果你只想拿走一条:共享层的位置决定了你以后加第三个界面的成本。 这个项目把共享压到了处理函数与线协议这一级,再往上只共享契约和语义令牌,不共享像素。代价是每个界面要各写一遍,收益是加一个客户端不用碰任何业务逻辑。

想继续读的话,我建议的顺序是:先 ui-tui/README.md,它是三份文档里最像「事实清单」的一份,事件表和按键表可以当接口文档用;再 apps/desktop/README.md 的原理与连接两节,把后端解析阶梯读明白;然后 apps/desktop/AGENTS.md,它讲的是判断而不是清单,「按权威决定状态归属」和「把跨越处都写成可观测的阶梯」这两节,换到别的项目上一样成立;最后 apps/desktop/DESIGN.md 末尾那份新增前的自检清单,直接拿来当你自己项目的设计评审模板。

顺手自检三个问题:你的两个界面之间共享的最深一层在哪,是组件、契约,还是处理函数?你的每条「选哪个」策略是不是只有一个解析器,并且候选用之前会验证?你的界面约束是写在文档里,还是写成了一条会红的测试?前两个问题这个仓库给了可抄的答案,第三个它给了可抄的手法。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的插件系统开源自托管 Agent 项目 Hermes Agent

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。