[F1]三天 460 个 PR,给你的 Hermes 装上仪表盘
上篇结尾答应各位,明天发“9 月 AI 圈值得记住的几件事”。稿子其实两天前就写完了,计划赶不上版本:例行看版本,Hermes 出 v0.21.5 了(tag 是 2026.9.24,发布 9 月 24 日)。按我的老规矩,新版本解读插队,月度盘点那篇顺延到国庆后——它不该过期,九月的事值得在十月初再复盘一遍。距离 v0.21.4 那个“先把火灭完”的补丁版,三天。
先照例给这版定性。官方 release notes 写得罕见地坦白:这是一个 rollup 补丁版,收了自 v0.21.4 以来合并的约 460 个 PR,目的是给 Docker 镜像和托管部署一个稳定标签,“完整的策展版说明留给 v0.22.0”。也就是说,官方自己只点名了一小撮改动,剩下的都塞在“Also in the window, undocumented here on purpose”那一段长句里。
所以我照老规矩,自己去 commit 记录里刨。两个 tag 之间 git log v2026.9.21..v2026.9.24 --no-merges 数出来 1610 个非合并提交、28 个合并提交,官方口径 460 个合并 PR、475 个关闭 issue。按 conventional commit 前缀分桶:
上一版 0.21.4 是六成 fix 的“止血包”,这一版 fix 占比降回 47%,perf 单独拉出来占了 35 条——刨完我的结论放在前面:0.21.5 的主线不是加功能,是“让看不见的东西现形”。它给把 agent 当 24 小时服务跑的人补了四块东西:一个能看见常驻目标和排队消息的 CLI 仪表盘、一条把推送消息回填进聊天上下文的水泥、一台给每个 profile 单独开关的网关电闸、一轮把启动耗时按毫秒级砍下去的热路径手术。官方 release notes 里那一段被一笔带过的“hot-path performance work”,反而是我从 commit 里挖出来最有数字感的部分。
还是老规矩,每个特性都翻到源码和 commit message 核对过,按“以前什么行为 → 现在什么行为”来讲。

※ ※ ※
⏱ 先对账:三天窗口里到底塞了多少东西
1610 个非合并提交按 scope 归桶,改动最集中的模块:
desktop 292 条是全场最大单项,但这版 desktop 的改动几乎全在插件 SDK 和界面模式上,对 CLI 用户影响间接;gateway/config/agent 加起来一百多条,才是“把 Hermes 当服务跑”的人直接能吃到的肉。下面按“递进”来讲:先让你看见(dock),再让它记得住(webhook 镜像),再让你管得住(profile 电闸 + cron 合并修复),最后跑得快(热路径手术)。
※ ※ ※
📟 常驻目标不再隐身:live dock 补上了两块盲区
这是对 CLI / TUI 重度用户最有用的改动:feat(cli): live dock shows the standing /goal and queued prompts。
先说背景。/goal 是 Hermes 的常驻目标机制——你给它立一个目标,它一轮一轮自动往下干,直到一个裁判模型说“完成”为止(官方 tips 里管这叫 Ralph loop)。这个机制从 0.21 就有,但它的状态一直在隐身:active 还是 parked 还是 paused,只能靠状态栏里一小截 ⊙ goal 3/20 的文字提示;你在 /queue 里排着队的消息,agent 干活期间完全不可见。我特意试了一次:立一个目标、再往队列里塞三条追问,然后盯着屏幕看 agent 干活——除了转动的 spinner,界面上没有任何地方告诉我“它还挂着目标、队列里还压着三条”。
现在的行为:状态栏上方那个本来就存在、只画子代理和后台进程的 live dock,往上加了一行 goal 状态、往下加了一个 Queue 块。commit message 把渲染规则写得很死:
- ▪ goal 行只有三种样子:⊙ goal · 3/20 turns · <标题>(active)、⏳ goal parked · until 14:32 · <原因>、⏸ goal paused · <原因>。目标完成或清除,这行就消失。
- ▪ Queue 块显示条数 + 头三条预览,超过三行收成一个 +N more。我数了源码里的常量:hermes_cli/cli_session_dock.py 里写死 QUEUE_ROWS = 3——排队消息每轮只排空一条,超过 3 条干脆不展开,把屏幕留给输入框。
细节里我最服气的是这条约束:goal 状态只从当前 session 已经持有的 GoalManager 读,dock 每秒刷一次,但“绝不做那件会去开 state.db 的事”(原话:must never be the thing that opens state.db)。所以你 /new 换会话之后,dock 不可能把上一个会话的目标串台显示给你——它压根不查库,只读内存里那个 manager,而 manager 绑死了 session_id。顺手把上一轮那个“parked 在等什么”的判定也翻了:goals.py 的 status_line() 里,parked 分三种——等别的 session、等某个 pid 活着、等到某个时间点,dock 行里的 until 14:32 就是第三种读出来的。
对每天挂着几个目标跑自动化的人,这个改动的价值在于:你把终端丢在那儿去干别的,回来第一眼就知道它有没有偷懒停在半路。以前要 /goal status 问一句才知道,现在抬头就有。
TUI 端配了同一条:feat(tui): goal row above the live-work dock。TUI 的队列本来就在输入框上方可见,缺的只是 goal,这版把 goal 行补在 dock 顶上,状态从已有的 session.control 契约读,不新开对 state.db 的轮询。
※ ※ ※
📬 推送消息“回填”聊天上下文:webhook mirror 的水泥活
第二块,也是我个人认为这版最被低估的改动:feat(webhook): mirror cross-platform deliveries into the target chat session(follow-up:fix(webhook): make delivery mirroring opt-in and profile-scoped)。
先还原它的 bug 场景,commit message 里的原话比任何转述都清楚:一条 webhook 路由往 Telegram/Discord 投递时,跑在一个临时的 webhook:<route>:<delivery_id> session 里——投递的那段文本,从来没有进到目标聊天自己的 session。于是你在 Telegram 里收到机器人推来的一条 CI 通知,随手回一句“那他挂了吗?”,机器人一脸茫然:它在那个聊天里根本没有“我刚给你发了什么”的记录。
这就是“fire-and-forget”投递的经典断层:发的人不记得自己发过,回的人以为在接着对话。
现在的修法,是把投递成功后的那段文本,以带标签的 user 角色回写进目标聊天的 transcript:[Webhook delivery: <路由名>]\n<正文>。注意三个设计决定,每一个都在 commit message 和源码里钉死了:
- 默认关。这条能力最初是社区 PR 里“默认开”的(原 feat 作者写的是 opt-out),维护者在 follow-up 里把它改成 mirror_to_session: true 才开,跟 cron 的 mirror_delivery 对齐。理由写得很直白:mirrored 文本是以用户权威进入对话的,在 deliver_only 路由上它甚至是未经任何加工的原始 payload——一个路由作者必须主动要求“让这段外部内容冒充我说话”,默认不许。
- best-effort,永不拖垮投递。镜像写失败最多 debug 日志,投递该成功成功;目标聊天还没有 gateway session(从来没人跟 agent 说过话)就直接跳过。
- profile 隔离。/p/<profile>/ 路由的镜像只写进那个 profile 的 state.db。源码注释里点了 Telegram 的坑:DM 的 chat_id 在每一个 bot 那里都是同一个人的 ID,不隔离的话,A 机器人的投递会串进 B 机器人的会话历史。
CLI 和路由配置两种用法我都在本机临时 profile 里实跑过。建一条带镜像的纯推送路由(不跑 agent、零 token):
$ hermes webhook subscribe deploy-notice \
--prompt "Build {status} on branch {branch}" \
--deliver telegram --deliver-only --mirror-to-session \
--description "CI push, mirrored so replies have context"
Created webhook subscription: deploy-notice
URL: http://localhost:8644/webhooks/deploy-notice
Profile: default
Secret: <自动生成的 40 位随机串,输出此处略>
Events: (all)
Deliver: telegram
Mode: direct delivery (no agent, zero LLM cost)
Replies: each delivery is mirrored into the target chat's session
Message: Build {status} on branch {branch}
落盘的订阅文件(webhook_subscriptions.json)里能看到那个布尔位被老老实实写进去了:
{
"deploy-notice": {
"description": "CI push, mirrored so replies have context",
"prompt": "Build {status} on branch {branch}",
"deliver": "telegram",
"deliver_only": true,
"mirror_to_session": true
}
}
hermes webhook subscribe --help 里那行 help 文本自己就带着安全告诫——“Only for sources whose content you trust in your conversation”。我的读法:给自家服务的推送开镜像,给公共 issue tracker、陌生表单这类不受控源的推送别开,因为镜像段文本会以 user 身份进上下文,等于把它写进了下次对话的原料。
一句话总结这个功能:它让“机器人推给你消息、你在同一个对话里回它”从两个断开的记忆,缝成了同一段对话。跟 0.21.0 的 cron mirror_delivery 是一个哲学(推送过的内容就是聊过天),这版把同样的水泥抹到了 webhook 这面墙上。

※ ※ ※
🔌 电闸终于分得开:gateway.standalone 与单 profile 停/启/重启
第三块,对运维人群。multiplex 拓扑(一个宿主 gateway 同时服务多个 profile)从 0.21 铺开之后,运维手里其实一直少两个开关:
其一:停一个 profile 的 bot,会误伤全体。 commit message 原话:每个已安装的具名 profile 只要目录存在就自动上线,“想停掉某一个 profile 的 bot,唯一的办法是停宿主 gateway——而那是把所有人的都停了”。这版的 feat(gateway): stop, start and restart one profile under the host multiplexer 把 hermes -p X gateway stop 变成真语义:先写 profiles/X/gateway.parked 标记文件(防止 30 秒一次的 reconcile 循环在“你发指令”和“标记落盘”之间把 X 又捞回来,这个先后顺序是 commit message 里专门点出的),再给宿主发 unserve-profile 控制指令,在 X 自己的作用域里拆掉它的 adapters、reconnects、cron。start 反过来删标记发 serve-profile。默认 profile 保持“动整机”的老语义不变。
其二:有些 profile 就是不想被 multiplex。 gateway.multiplex_profiles: false 这条逃生通道在上个版本被正式退役了(写成 false 会被 gateway 原地改回 true 并在下次 update 摘要里打一次告示,不会静默翻转),取而代之的是官方文档明说的临时兼容层:在具名 profile 自己的 config.yaml 里写 gateway.standalone: true,这个 profile 就重新跑自己独立的 gateway,宿主 gateway 继续服务其余所有人。hermes_cli/profiles.py 里 profile_is_standalone() 按文件签名做了 memo,容错拉胯的 YAML(读失败一律按非 standalone 处理并 warn),默认 profile 设了这个键会被无视并 warn 一次——它自己就是宿主。文档给这玩意的定性也很诚实:temporary compatibility shim,不是长期受支持的拓扑。
这两个开关合起来的意思是:fleet 的拓扑从“盒子开机状态推断出来的”,变回了“运维逐 profile 写出来的”。 对挂多个 bot 的团队,这是这版最硬的运维改动。
配套还有一个我专门去看的边角:fix(profiles): merge distributed cron jobs safely 加了个前置防线——profile distribute 之前先拒绝任何排不进目标机的 cron job,“在任何文件被替换之前”。以及一个 CLI 体验修复:fix(tui-gateway): never park an idle /steer on the agent——agent 没在跑的时侯你发 /steer,以前返回“已排队”但其实没人来取,可能拖到下一轮才混进错误的上下文;现在明确回 rejected,客户端落到普通队列,steer 不丢也不再乱序。这类“边界时刻不吞消息”的修复,在 stream 侧还有一个:tool boundary 上尾段发送失败,以前约 1100 字符直接蒸发,现在失败即触发 flush。丢过机器人回信的人知道这种 commit 的分量。
※ ※ ※
🚀 0.9 秒冷启动去哪了:这版最实在的 35 条 perf
官方 release notes 里 hot-path performance 只有一行带过。但我把 35 条 perf 提交挨个过了一遍,发现核心叙事藏在一条 commit message 里,它罕见地带着完整 profiling 记录:perf(config): route the config and manifest loaders through fast_safe_load。
故事线是这样的:Hermes 到处在解析 YAML(gateway 启动读 config.yaml,加上每个插件的 manifest)。安装器生成 config.yaml 的方式,是复制一份自带的 example 文件——我量了本机仓库里那个文件:121,580 字节,绝大部分是注释。纯 Python 的 PyYAML SafeLoader 解析这种大块头很慢,作者 profiling 之后确认瓶颈就在 scanner:reader.forward 34ms、scanner.scan_to_next_token 28ms、reader.peek 13ms——全是纯 Python 的活。
解法是早就躺在代码库里的一个函数:utils.fast_safe_load。CSafeLoader(libyaml 的 C 实现)如果可用就用它,不可用退回 SafeLoader——同一份输入、同一个受限 tag 集、同一个结果,只换 loader。函数头顶的注释写着它存在的意义:startup 要解析 config.yaml 和每一个插件 manifest,慢路径吃掉约 0.9 秒冷启动。迁移做了一半停了很久,这版把文件级加载器全部接上——顺带解释了我 grep 到的一个现象:load_gateway_config() 全仓 101 处调用,此前没有任何缓存。
commit message 里带了官方 A/B(同机、中位数、清 __pycache__):
48.8 ms → 2.46 ms. Per path: load_gateway_config 48.3 → 1.84 ms, managed config.yaml 44.4 → 1.03 ms, 105 plugin.yaml manifests 59.3 → 5.9 ms.
本机我也照方法复测了一遍。一份 12 万字节、100+ 行的真实 config:
$ python - <<'EOF'
import yaml, time, statistics
raw = open("cli-config.yaml.example", encoding="utf-8").read()
def bench(loader, n=7):
ts = []
for _ in range(n):
t0 = time.perf_counter()
yaml.load(raw, Loader=loader)
ts.append((time.perf_counter() - t0) * 1000)
return statistics.median(ts)
print("SafeLoader :", round(bench(yaml.SafeLoader), 1), "ms")
print("CSafeLoader:", round(bench(yaml.CSafeLoader), 2), "ms")
EOF
SafeLoader : 65.4 ms
CSafeLoader: 1.81 ms
单次解析差 36 倍;再拿本机 104 个 plugin.yaml 全量过一遍,109.3 ms → 13.5 ms,差 8.1 倍——manifest 小而多,倍数被文件打开的固定开销摊薄了。量级和官方数字对得上。
对启动只慢半秒这件事值不值?对交互 CLI 是体感问题;但 Hermes 这类 gateway 进程里,配置读取散落在消息处理、状态检查、心跳恢复各处热路径上(这版还有配套:read_raw_config 无锁快路径、配对审批表按 mtime 缓存不再每条消息重读、CA bundle 只解析一次而不是每个 Hermes 请求一次)。单次省 47 毫秒 × 每天几十万次加载,这才是这轮手术真正要省的东西。
同类手术里我还想点名一条小的:fix(config): an unreadable config.yaml no longer rebuilds its fallback on every load。config 读失败(EMFILE/EACCES 这种)时,旧代码不缓存错误、每次都重新解析 last-known-good 备份——文件一直坏着,就等于每次加载都比正常缓存命中贵 170 倍。修复之后回退路径也被缓存,且缓存命中时会先裸读一次原文件(不解析),一旦 EMFILE 解除立刻回到真配置。坏配置不再让网关反复踩自己。这种 commit 不写在 release notes 里,但它决定半夜告警时的行为是不是可预测。
※ ※ ※
🍱 顺手说三个不在主线上、但你会碰到的
kanban 开票变两栏弹窗。 feat(kanban): open tickets in a centered two-column modal + design pass:抽屉改成居中模态窗,左栏是诊断、描述、结果、依赖、评论、activity、runs 和 worker 日志尾巴,右栏是属性侧边栏(assignee、模型覆写、优先级、估算、附件)。依赖 chip 从裸 ID 变成渲染标题——后端 GET /tasks/:id 新增 link_tasks 字段返回 {id,title,status},对老后端自动降级回短 ID。任务描述文本开始按 markdown 渲染。用过 Jira 的人都知道这一刀切在哪:看一张票不再靠滚动条考古。
webhook 的安全地基顺带加固。 这版之前建立时,webhook 签名校验已经支持 Svix 风格(whsec_ 前缀 secret、{id}.{timestamp}.{body} 的 HMAC-SHA256、带 300 秒防重放窗口、支持 secret 轮换期的多版本 v1 签名)。如果你在用 Grafana/Supabase 这类服务往 Hermes 推事件,对一下自己服务商标签格式在不在支持列内,别裸奔 HMAC。
桌面端插件 SDK 的“暗浪”。 官方 release notes 用分号列了二十来个 desktop 项,292 条提交的大头就在这。对普通用户可感知的是两件事:Simple/Advanced 双界面模式上线;MCP 配置页重做成 Connectors 页,装完插件给它的 MCP 服务器一个“Connect now”入口,插件的 tools 和 skills 即刻出现在所有打开的会话里。如果你等 Hermes 出桌面插件生态,SDK 的地基就是这版铺的——只是它选择不写进 highlights。
※ ※ ※
升级与我的看法
升级照旧:git 安装 hermes update,Docker / 托管直接吃 v2026.9.24 标签的镜像。本机升到 v0.21.5 后 hermes --version 实跑输出:Hermes Agent v0.21.5 (2026.9.24) · upstream fdec926e。
把这版放回时间线看:0.21.2-0.21.3 修认证与 session store,0.21.4 六成 fix 的止血包,0.21.5 则是止血之后第一次系统性“补感知”——dock 让你看得见它手里的活,mirror 让它记得住自己推送过的话,standalone/parked 让你管得住单个 bot,perf 那 35 条把服务天天挨的热路径账结清。官方把完整的策展版说明压到 v0.22.0 一起发,这已经是第三次 rollup 时“只说一半”了。我倾向于把这当成一个信号:0.21 周期的工程量已经大到官方需要用两个版本来还说明文档的债。追新功能的人会嫌它无聊,把 Hermes 挂在服务器上干活的人,会在某个没被打断的下午隐约觉得哪里不对——哦,机器人这三天没犯过那个老毛病。
下一篇预告:给娃做了个背古诗 App,从 75 首教材诗的判分引擎到飞花令对战的防刷闸门,一个小学生产品的技术选型比想象中刺激。做 AI 应用的朋友,坑我先踩了。
※ ※ ※
※ ※ ※
资料来源
- ▪ Hermes Agent 官方仓库(NousResearch/hermes-agent)tag v2026.9.24 的 release 说明(v0.21.5,发布于 2026-09-24;460 merged PRs / 475 closed issues / ~460 PRs 口径均引自原文)
- ▪ 提交统计与分桶:作者本机仓库 git log v2026.9.21..v2026.9.24 --no-merges(1610 条非合并提交)程序化统计;scope 计数按 conventional commit 前缀归桶
- ▪ 关键提交逐条核对 commit message 与 diff:16db511e(fast_safe_load 迁移,含官方 A/B 数字与 profiling 记录)、11fb429f 与 8eda97ca(webhook mirror 的 feat 与 opt-in follow-up)、43455f56/17536534(CLI/TUI dock 的 goal+queue 行)、0238c9d7(gateway.standalone)、4c342c05(单 profile 停/启/重启)、39682e32(cron 合并前置防线)、6d150c7e/4c41d980/714f646e(stream 丢尾、ledger 修剪、config 回退缓存)、2476c828(kanban 两栏弹窗)
- ▪ 源码核对:hermes_cli/cli_session_dock.py(QUEUE_ROWS = 3 与 never-opens-state.db 注释)、hermes_cli/goals.py(status_line 三态渲染)、hermes_cli/profiles.py(profile_is_standalone 与默认 profile 豁免)、gateway/platforms/webhook.py(mirror 规则、Svix 签名校验、_V2_REPLAY_WINDOW_SECONDS = 300)、gateway/mirror.py(user 角色回写的 strict-alternation 约束)、utils.py(fast_safe_load 与 CSafeLoader 回退)
- ▪ CLI 实跑:本机临时 profile 下 hermes webhook subscribe … --mirror-to-session 的完整输出与订阅 JSON,跑完即删(URL 端口 8644 为 gateway/platforms/webhook.py 中 DEFAULT_PORT 默认值;示例 secret 已截断)
- ▪ 性能复测:本机 venv Python 对 121,580 字节 config 样例(cli-config.yaml.example,官方 release 仓库自带)与 104 个 plugin.yaml 的 SafeLoader vs CSafeLoader 中位数对比,数字与 commit 内官方 A/B 同量级
- ▪ 文中示例主机与路由均为通用场景描述(webhook URL 用 localhost,chat_id 用占位数字),未使用作者真实服务器的运行数据与账号信息
- ▪ 生成日期:2026-09-26;文中“实测”均为本机当日执行记录
评论 (0)
登录 或 注册 后参与讨论。