一个文件就能给 Hermes 桌面加个面板:Desktop Plugin SDK 上手

你每天盯着 Hermes 桌面端干活,慢慢会冒出一个很朴素的念头:要是有个小面板显示今天几个 cron 任务的运行状态就好了,或者状态栏加个按钮,点一下把日志尾巴捞出来。以前这种想法要么忍了,要么去啃整个 App 的源码——那是一份几十万行、对第三方不友好的工程。
现在它有了一条完全不同的路:写一个文件,丢进目录,几秒后生效。Hermes 桌面端窗口里的每一个界面元素——布局面板、侧边导航、状态栏、标题栏工具、⌘K 命令面板、快捷键、主题——都是往同一个注册表里注册出来的,而核心代码注册的方式和插件一模一样。插件不是后补的口子,它就是这个 App 本来的组织方式。
三分钟:写出第一个插件
先看最短的完整例子。建一个文件:
// $HERMES_HOME/desktop-plugins/hello/plugin.js (默认就是 ~/.hermes/desktop-plugins/hello/plugin.js)
import { host, haptic, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
function HelloPane() {
const gateway = useValue(host.state.gateway)
return jsxs('div', {
className: 'flex h-full flex-col gap-2 p-3 text-sm',
children: [
jsx('div', { className: 'font-medium', children: 'Hello, Hermes' }),
jsx('div', {
className: 'text-(--ui-text-tertiary)',
children: `gateway: ${gateway}`
})
]
})
}
export default {
id: 'hello', // 必须和文件夹名一致
name: 'Hello',
register(ctx) {
// 右侧挂一个 260px 宽的面板
ctx.register({
id: 'pane',
area: 'panes',
title: 'hello',
data: { placement: 'right', width: '260px' },
render: () => jsx(HelloPane, {})
})
// 状态栏右下角放一个小按钮
ctx.register({
id: 'chip',
area: 'statusBar.right',
order: 130,
render: () =>
jsx('button', {
type: 'button',
className: 'px-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
onClick: () => {
haptic('tap')
host.notify({ kind: 'info', message: 'Hello from my plugin!' })
},
children: 'hello'
})
})
}
}
保存。App 会盯着 desktop-plugins/ 这个目录,几秒内把它加载进来,之后每次保存都原地热重载。没出现的话,⌘K 搜 Reload desktop plugins。加载失败会在界面上弹一条带着错误信息的提示,改完再存就行——坏插件只会弹错,不会把 App 搞崩。
插件契约:两个接口就够了
一个插件就是 default export 的一个对象:
interface HermesPlugin {
id: string // 稳定标识,同时作为 `plugin:<id>` 来源标记和 id 命名空间
name?: string // 设置界面里显示的名字,默认等于 id
defaultEnabled?: boolean // 默认 true;设为 false 就是"清单里能看到,但要用户手动开"
register: (ctx: PluginContext) => void
}
register 拿到的是一个被限定过范围的上下文。它不直接碰注册表——上下文会自动打上来源标记(source: 'plugin:<id>')并给每个贡献项的 id 加命名空间前缀(<id>:<本地id>),所以两个插件永远不会撞 id。
interface PluginContext {
readonly source: string // 例如 'plugin:hello'
register: (c: PluginContribution) => () => void // 注册一项,返回注销函数
registerMany: (cs: PluginContribution[]) => () => void // 批量注册
rest: <T>(path, opts?) => Promise<T> // 打到自己后端的 REST(/api/plugins/<id>)
socket: (path, onMessage) => () => void // 打到同一命名空间的 WebSocket
os: PluginOs // 系统通知、打开外链、在文件管理器里定位、剪贴板
storage: PluginStorage // 插件私有持久化(键存在 hermes.plugin.<id>. 下)
}
而贡献项是所有界面共用的那一个原语:
interface Contribution {
id: string // 本地 id,宿主会加命名空间
area: string // 放到哪里(见下面的区域表)
title?: string
order?: number // 同一区域内的排序,越小越靠前
when?: () => boolean // 动态显隐,由区域重新求值
enabled?: boolean
render?: () => ReactNode // 要挂载的组件
data?: unknown // 区域专属的载荷
}
给 render、给 data,或者两个都给,取决于目标区域。
往哪儿放:区域速查
| 界面位置 | area |
你要提供什么 |
|---|---|---|
| 布局面板(可停靠/拖拽) | PANES_AREA('panes') |
title + render + data(placement、dock、width、height) |
| 整页路由 | ROUTES_AREA |
data(path)+ render |
| 侧边导航项 | SIDEBAR_NAV_AREA |
data(path、label、codicon) |
| 状态栏 | STATUSBAR_AREAS.left / .right |
render,或 data 形式的 StatusbarItem |
| 标题栏 | TITLEBAR_AREAS.left / .center / .right |
data 形式的 TitlebarTool,或作用域内的 <Contribute> |
| ⌘K 命令面板 | PALETTE_AREA |
data 形式的 PaletteContribution |
| 快捷键 | KEYBINDS_AREA |
data 形式的 KeybindContribution |
| 主题 | THEMES_AREA |
data 形式的一个 DesktopTheme |
| 输入区 | COMPOSER_AREAS.* |
渲染插槽,或中间件 / 附件提供者 |
面板的 placement 是语义位置(main、left、right、top、bottom):它会和同位置的已有面板叠成标签页,之后用户可以随便拖。想直接落在某个边缘而不是叠进去,就再加一个 dock 手势——和用鼠标把面板拖到某个投放区是同一件事。
三个必须记住的坑
第一,别写 JSX 语法。 磁盘插件是不编译直接加载的,所以 <div> 这种东西进不去解析器。UI 用 react/jsx-runtime 里的 jsx() / jsxs() 调用写(或者 React.createElement),就像上面那个例子里那样。看着有点啰嗦,但它换来的就是“不需要构建步骤”。
第二,只有三个模块能 import。 @hermes/plugin-sdk、react、react/jsx-runtime——其他说明符一律解析失败,而且是故意的。SDK 外面有一道 lint 围栏拦这个:插件想用 App 内部模块(@/…)会直接报错,正确做法是把需要的能力加进 SDK。
第三,桌面插件是 App 级的,不按 profile 隔离。 它只有一份根目录,对窗口连的每个 profile、每个网关、每台远程机器都生效。想按 profile 区分行为,就在插件内部读当前 profile 自己处理。
界面语言用 App 自己的
SDK 里的 ui.* 直接给你 App 的真实组件、主题变量、图标和格式化函数。这一点比听起来重要:手搓一套自己的样式,结果就是插件界面和 App 长得不一样,用户一眼看出是外来的。用 ui 里的东西,配色和间距跟着主题走——比如上面例子里的 text-(--ui-text-tertiary),就是主题变量,切主题时自动跟着变。
想要后端?加一个名字空间
插件的 REST 和 socket 都打在自己独占的命名空间 /api/plugins/<id> 上。你只要在插件包里放一个 plugin_api.py,这个命名空间就活了(桌面端和 Web dashboard 的 SDK 共用的只有这一层后端名字空间,前端 API 是两套,别搞混)。这样你的插件可以有自己的进程内状态、读写自己的文件、对着 gateway 做长连接,而不必把逻辑塞进 render。React Query 也一并给你了,所以拉数据、缓存、失效这套不用自己搭。
三种投放方式
| 方式 | 放哪儿 | 谁用 | 要构建吗 |
|---|---|---|---|
| 磁盘(推荐) | $HERMES_HOME/desktop-plugins/<id>/plugin.js |
用户、Agent | 不用,纯 ESM 直接加载 |
| 统一包 | $HERMES_HOME/plugins/<id>/desktop/plugin.js |
同时还有 Agent 侧代码的插件 | 不用,同一条磁盘管线 |
| 内置 | apps/desktop/src/plugins/<id>/plugin.tsx |
随 App 一起发布的仓库内插件 | 要,走 App 自己的 Vite 构建 |
三种走的是同一个 HermesPlugin 契约,都会出现在 Capabilities → Plugins 里,都能随时开关。想找现成参考,官方有一个配套的 hermes-example-plugins 仓库放示例实现;App 自带的 Radio(电台播放 + 状态栏播放控件 + 音频可视化波形)本身就是一个 SDK 插件,默认关着,在 Capabilities → Plugins 里打开——它是个不错的“插件能写到什么程度”的样本。
安全边界:这不是沙箱
这一点文档里说得非常直白,值得原样转达:加载进来的插件是在渲染进程里按 ESM 求值的,拥有 App 的全部权限——React 单例、整套 SDK(host.request 的 gateway JSON-RPC、ctx.rest、storage、navigate)。这里的隔离只是错误隔离(错误边界 + 独立监听器),它能保证一个坏插件不会把 App 弄崩,但拦不住它做 App 能做的任何事。
对本地来源来说这可以接受——磁盘上的文件本来就能运行代码。但正因如此,远程来源(https + 白名单)不能直接复用这条管线,它需要真正的边界(iframe/worker + CSP + 能力门禁)才能上线;{ integrity } 选项是传输层的接口,不是信任层的接口。
所以:只装你愿意让它跑在你机器上的插件。如果你想先了解官方在插件来源和审核上做了什么,插件目录那篇值得一并读——目录解决的是“从哪来、审核没审、装的是不是原版”。
从哪儿开始
- 想要 Agent 侧(Python CLI / gateway)的插件,那是另一套:看终端后端插件和把模型 provider 打包成 pip 插件。
- 想先看现成的界面插件长什么样,桌面技能中心那篇讲了 Desktop 里现成的浏览与安装体验。
- 环境还没搭起来?先看安装指南,桌面端跑起来之后再写插件最省事。
最后一句实话:SDK 的时间线很短——Radio 作为内置 SDK 插件随桌面端上线是最近的事,插件页面的整理也在最近才完成。这意味着接口还可能动,也是个机会:现在能跑通的小插件,很可能是别人还没想到要写的那个。