桌面插件终于能正经"按"应用自己的按钮了:ctx.runAction 与 ctx.pet.say


你写了一个桌面插件,在标题栏加了个按钮,点一下就能呼出内置的浏览器面板。实现方式很简单:往 document.body 上派发一次合成的 Ctrl+Shift+L 键盘事件。上线两周后,有用户来报“按钮点了没反应”——因为他在设置里把那个快捷键改掉了,或者干脆解绑了。你的按钮还在,但世界已经变了,而且它不会报错。

这是插件生态里非常典型的一类脆弱:想调用宿主自己的功能,却只能靠“假装用户按了键”这种旁门。同一时期还有另一个例子:一个显示额度余额的插件要往核心宠物身上画气泡,于是自己去 document.querySelector('canvas[aria-label$=" pet"]') 找宠物、加一层固定定位图层、每帧跟着宠物移动——核心改一次标记结构,它就坏一次。

这个窗口里合入的两个改动,把这两件事都变成了受支持的 API。

ctx.runAction:调用应用动作的正门

const result = ctx.runAction('view.showBrowser')
// → { ok: true }

关键在“走的是哪一个处理函数”。useKeybinds 本来就持有一张处理函数表,快捷键通过它分发。现在这张表同时注册给插件动作运行器(lib/keybinds/plugin-actions.ts),ctx.runAction 调用时就是去这张表里查——所以插件调用和用户按键是同一条代码路径,快捷键绑定从头到尾不参与。用户怎么改键都不影响你的按钮。

结果对象只有两种形态,调用永不抛异常:

ctx.runAction('view.showBrowser')   // → { ok: true }
ctx.runAction('session.archive')    // → { ok: false, reason: 'denied',    error: "'session.archive' is not available to plugins. …" }
ctx.runAction('app.quit')           // → { ok: false, reason: 'unknown',   error: "'app.quit' is not a Hermes app action. …" }
ctx.runAction('view.showBrowser')   // → { ok: false, reason: 'unavailable' }  // 应用外壳还没挂载

reason 有三种:unknown(不是合法动作 id)、denied(合法但对插件不可用)、unavailable(启动太早,外壳还没挂)。每一次拒绝还会打一条 [plugin:<插件 id>] runAction: … 的警告,出问题时能在日志里直接看到是谁被拒了。

这个 API 挂在每个插件自己的 ctx 上(和 ctx.pet、ctx.os 一样),而不是全局 host——因为拒绝要归因到具体插件。如果你的插件要兼容老宿主,特性检测一次就够:

if (ctx.runAction) { /* 新宿主 */ }

SDK 侧也补齐了类型:@hermes/plugin-sdk 导出 PluginAppActionId(允许的 id 联合类型)、PluginAppActionInfo、PluginRunActionResult,以及运行时的 PLUGIN_APP_ACTIONS 列表。想要一份可枚举的清单,直接调用 ctx.listActions():

ctx.listActions()
// → [{ id: 'view.showBrowser', label: 'Toggle browser', category: 'view' }, …]

白名单里只有视图和导航

允许的动作刻意收窄到“看和走”这一类:

view.showBrowser、view.showFiles、view.toggleSidebar、view.toggleRightSidebar、view.toggleReview、view.toggleStatusbar、view.findInPage、composer.focus、session.new、session.focusSearch、conversation.scrollPageUp、conversation.scrollPageDown、nav.commandPalette、nav.settings、nav.profiles、nav.capabilities、nav.messaging、nav.artifacts、nav.cron、nav.agents、keybinds.openPanel。

被拒绝的包括:归档会话、置顶、切换 Profile、模型选择器、推理档位、语音与听写、终端、新窗口、打开文件夹、worktree、HUD、关闭/重开标签页。而破坏性和特权操作压根没有暴露:删除会话、退出登录、审批、改模型/改 provider、更新、退出应用。

这套划法的道理很直白:插件可以帮你把界面挪到你顺手的位置,但不能替你做出不可撤销的决定。一个第三方插件点错一下把会话删了,是没法解释的。

ctx.pet.say:让核心宠物的气泡替插件说话

const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', tone: 'info', ttlMs: 8000 })
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' })  // 同 id 原地替换这一行
ctx.pet.clear('balance')   // 移除一行;ctx.pet.clear() 清掉你这个插件的全部行
ctx.pet.visible            // ReadableAtom<boolean>:现在屏幕上有没有宠物

气泡由宿主绘制,插件从头到尾不碰应用 DOM——这正是它相对“自己找宠物画图层”的优势,宠物被弹到独立窗口里时插件也照样能说话。

宿主强制了这些边界,都是为了让这块屏幕区域不被插件玩坏:

  • 只能是纯文本。 控制字符和双向文本覆盖字符会被剥掉,文本压成一行、上限 120 字符,并以 React 文本节点渲染。
  • 带署名。 插件商店里显示的插件名会以小标签形式出现在这行文字上方。
  • 短命且有配额。 默认 6 秒过期(限制在 1–30 秒之间);每个插件最多 3 行同时存活,10 秒内最多调用 10 次,超出会被丢弃并在控制台告警。
  • 核心状态优先。 当 agent 报错或在等你输入时,核心气泡接管显示;你的行如果还没过期,等它恢复后会回来。
  • 跟随用户的宠物设置。 没开宠物、或用户关掉了宠物,什么都不显示。
  • 自动清理。 插件被禁用、卸载或热重载时,它的所有行都会被移除。
  • 没有新增权限模型。 ctx.pet 按插件作用域归属,和 ctx.os 一个思路。

一个完整的按钮插件长什么样

// desktop/plugin.js
export default {
  id: 'browser-toggle',
  titlebarButtons: [
    {
      label: 'Browser',
      onClick(ctx) {
        const r = ctx.runAction('view.showBrowser')
        if (!r.ok) {
          ctx.pet.say(`browser: ${r.reason}`, { id: 'btn', tone: 'warn', ttlMs: 4000 })
        }
      },
    },
  ],
}

原来的实现是一行派发合成按键事件;现在是上面这样——失败有原因、还能顺手告诉用户为什么。

什么时候能用上

两个改动都在最近合入,v0.21.7 的发版正文把它们列在本次窗口内(正文本身没有展开细节,正式的整理说明留给 v0.22.0)。桌面端按自己的打包节奏发布,所以:

  • 源码安装、跟 main 的桌面开发环境:现在就能用。
  • 商店/MSIX/自带安装包的桌面端:等它下一次打包更新;代码里用 ctx.runAction?.(…) 做特性检测即可兼容新旧宿主。

文档也同步更新了:website/docs/developer-guide/desktop-plugin-sdk.md 新增了 Run app actions — ctx.runAction 一节(含 API、示例和白名单表格),内置的 hermes-agent 技能里 desktop-plugins.md 也有同样的说明。

注意

  • 不要再用合成键盘事件触发应用功能。 那正是这次要淘汰的做法:用户改键即失效,且没有任何错误提示。
  • 拒绝要处理,不要忽略。 ok: false 不是异常,静默忽略等于把 bug 留给用户。
  • 宠物气泡不是日志面板。 120 字符、最多 3 行、默认 6 秒——它是“一眼可见的状态”,不是输出通道。
  • 白名单会随版本变化,运行时用 ctx.listActions() 取真实清单,别把它硬编码进文档或类型断言里。

想从零开始写一个桌面插件,可以看 桌面插件 SDK 指南;想了解插件从哪里装、怎么审查,见 插件目录指南;安全边界那部分在 插件隔离与宿主边界。