插件结构概述
OpenDesk 插件是在 OpenDesk 主进程中运行的受信任 Node.js 模块,由 pluginmgr 应用负责发现、安装、启停、校验和加载。插件可以增加 Tool、Hook、Skill、MCP Server、Command、应用事件监听器、子 Agent 和浏览器页面提示,也可以把这些能力组合在同一个插件中。
本文对应插件 API v1。插件运行在宿主进程中,拥有与 OpenDesk 相同的本机权限;只安装可信来源的插件,并在发布前明确说明文件、网络、进程和凭据访问行为。能力声明、授权和完整性检查用于约束插件所提供能力的加载,不是操作系统级沙箱;安装第三方插件前仍应审查来源。
SDK 与无 SDK 开发
Section titled “SDK 与无 SDK 开发”OpenDesk 支持两种入口写法:
| 方式 | 适用场景 | 运行时要求 |
|---|---|---|
| 依赖 SDK | TypeScript 开发、需要类型检查、需要 Tool 参数辅助函数时推荐 | 安装 @bitclub.ai/opendesk-plugin-sdk;未打包进产物时必须放在 dependencies |
| 不依赖 SDK | 简单 JavaScript 插件、SDK 暂时无法下载或希望减少依赖 | 默认导出符合协议的普通对象;Tool 仍必须单独依赖 Zod 4 |
SDK 的 definePlugin 只提供类型约束并返回原对象,插件运行时不要求入口必须由 definePlugin 创建。无 SDK 版本仍然调用同一套 context API,因此行为与 SDK 版本一致,只是没有编译期类型检查和辅助函数。
不要从 OpenDesk 仓库的 src 目录导入类型或实现。公共类型统一从 @bitclub.ai/opendesk-plugin-sdk 导入;无 SDK 插件使用本文描述的稳定对象协议。
hello-plugin/├── opendesk.plugin.json├── package.json└── index.mjsOpenDesk 直接执行 JavaScript 入口,不会替插件编译 TypeScript。TypeScript 项目必须先构建,并把 JavaScript 产物及运行时依赖放入安装包。
opendesk.plugin.json
Section titled “opendesk.plugin.json”{ "schemaVersion": 1, "id": "hello-plugin", "displayName": "Hello Plugin", "entry": "./index.mjs", "apiVersion": 1, "capabilities": []}依赖 SDK
Section titled “依赖 SDK”package.json:
{ "name": "hello-plugin", "version": "0.1.0", "type": "module", "dependencies": { "@bitclub.ai/opendesk-plugin-sdk": "latest" }}index.mjs:
import { definePlugin } from '@bitclub.ai/opendesk-plugin-sdk';
export default definePlugin({ id: 'hello-plugin', displayName: 'Hello Plugin', version: '0.1.0', capabilities: [], setup(ctx) { console.info('Loaded plugin from', ctx.pluginDir); }});不依赖 SDK
Section titled “不依赖 SDK”package.json:
{ "name": "hello-plugin", "version": "0.1.0", "type": "module"}index.mjs:
export default { id: 'hello-plugin', displayName: 'Hello Plugin', version: '0.1.0', capabilities: [], setup(ctx) { console.info('Loaded plugin from', ctx.pluginDir); }};两种入口的 id 都必须与 manifest 中的 id 一致。建议始终显式声明 capabilities;省略它会请求宿主支持的全部插件能力,通常不是预期结果。
Manifest 参考
Section titled “Manifest 参考”插件目录优先读取 opendesk.plugin.json。npm 包也可以把同样的信息写入 package.json 的 opendesk 字段。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| schemaVersion | 1 | 是 | Manifest 结构版本,当前只能为 1 |
| id | string | 是 | 插件唯一标识;必须以字母开头,只能包含字母、数字、点、下划线和连字符 |
| displayName | string | object | 否 | 面向用户显示的名称;支持按语言本地化的对象形式,详见下文 |
| entry | string | 是 | 相对插件根目录的 JavaScript 入口;不能越出插件目录 |
| apiVersion | 1 | 是 | 插件 API 版本,当前只能为 1 |
| version | string | 否 | 插件版本;npm 包通常使用 package.json 的 version |
| capabilities | string[] | 是 | 插件申请的能力列表,详见下表 |
| platforms | string[] | 否 | 限制插件可运行的目标平台,值为 win32、darwin、linux、android、openharmony 之一。未指定时任意平台都加载该插件 |
| minOpenDeskVersion | string | 否 | 可运行的最低 OpenDesk 语义化版本 |
| maxOpenDeskVersion | string | 否 | 可运行的最高 OpenDesk 语义化版本 |
| resources | object | 否 | 无需执行 setup 即可声明的静态 Skill、MCP Server 和子 Agent |
displayName 本地化
Section titled “displayName 本地化”displayName 支持两种形式,opendesk.plugin.json、package.json#opendesk 和插件入口对象均适用:
- 简单字符串:所有语言下显示同一名称;
- 本地化对象:按语言声明名称,键为 BCP 47 语言标签(如
en、zh、zh-CN),值为该语言下的显示名称。
{ "schemaVersion": 1, "id": "locale-demo", "displayName": { "en": "Locale Demo", "zh": "本地化示例插件" }, "entry": "./index.mjs", "apiVersion": 1, "capabilities": []}解析规则:
- 匹配时只比较语言主标签:请求
zh-CN时zh-CN与zh-TW等键都视为中文条目; - OpenDesk 界面语言为中文时优先取中文条目,为英文时优先取英文条目;
- 没有匹配语言的条目时,回退到对象中声明的第一个翻译;
- 对象中的每个值都必须是非空字符串,空对象或全空值会导致 manifest 校验失败。
插件中心会按当前界面语言显示插件名称,并在应用切换语言后实时更新;Agent 的插件管理工具输出和 CLI 的 plugins status --json 会把该字段解析为当前语言的字符串。未声明 displayName 时各处回退到插件 id。
capabilities
Section titled “capabilities”| 值 | 对应 API |
|---|---|
| hook | ctx.hooks.on |
| tool | ctx.tools.register |
| skill | ctx.skills.register 或 resources.skills |
| mcp | ctx.mcps.registerServer 或 resources.mcpServers |
| command | ctx.commands.register |
| event | ctx.events.onApplication |
| agent | ctx.agents.register、ctx.agents.registerDirectory 或 resources.agents |
| browser | ctx.browser.hint.register |
| channel | ctx.channels.register |
插件只能注册已声明且已经获准的能力。manifest 中的静态 Skill、MCP 或 Agent 资源也必须分别声明 skill、mcp 或 agent。
resources
Section titled “resources”| 字段 | 类型 | 说明 |
|---|---|---|
| skills | string[] | Skill 目录的相对路径。目录可以直接包含 SKILL.md,也可以包含多个以 SKILL.md 为入口的一级子目录 |
| mcpServers | object[] | MCP Server 定义;字段与后文 MCP 插件相同 |
| agents | string[] | 子 Agent 目录的相对路径。目录可以直接包含 agents.md,也可以包含多个以 agents.md 为入口的一级子目录;详见在插件中贡献子 Agent |
resources 是 manifest 中的静态声明。pluginmgr 发现插件后,会先解析这些声明并注册 Skill、MCP Server 和子 Agent,再执行入口的 setup(ctx)。因此,插件只需要携带标准 Skill 目录、MCP Server 文件或 agent 目录,不必在 setup(ctx) 中重复调用 ctx.skills.register、ctx.mcps.registerServer 或 ctx.agents.register。
静态资源与 SDK 无关,但插件仍然必须提供合法的 JavaScript 入口和 setup 函数。它适合内容和配置固定、无需读取 ctx.options 的 Skill、MCP 和子 Agent;需要根据用户配置决定是否注册、动态拼装定义或自行管理生命周期时,应使用 setup(ctx) 中的动态注册。
resources.skills 与 ctx.skills.register
Section titled “resources.skills 与 ctx.skills.register”两种方式最终都会把 Skill 加入同一个 pluginMgr bundle,但输入形式和加载时机不同:
| 对比项 | resources.skills | ctx.skills.register(definition) |
|---|---|---|
| 声明位置 | opendesk.plugin.json 或 package.json#opendesk | 入口的 setup(ctx) |
| Skill 内容来源 | 读取并解析插件目录中的 SKILL.md | 直接使用 definition.body 字符串 |
| 必填信息 | 相对 Skill 目录路径;目录内必须有合法 SKILL.md | name、description、body |
| directory 含义 | 声明路径就是 Skill 目录或 Skill 集合目录 | 可选资源目录;不会自动读取其中的 SKILL.md |
| 加载数量 | 一个路径可加载一个 Skill,也可扫描多个一级子目录 | 每调用一次只注册一个 Skill |
| 加载时机 | 在 setup(ctx) 之前由 PluginMgr 自动加载 | 执行 setup(ctx) 时注册 |
| 条件控制 | 固定加载,不能读取 ctx.options 后决定 | 可以根据 ctx.options、平台或其他运行时条件决定 |
| 元数据来源 | SKILL.md frontmatter | definition 字段及 definition.frontmatter |
| 注销控制 | 没有单独 registration 句柄,由插件生命周期统一管理 | 返回 PluginRegistration,可提前调用 dispose |
| SDK 要求 | 不依赖 SDK | 运行时不依赖 SDK;SDK 只提供 PluginSkillDefinition 类型 |
| capability | manifest/入口必须包含 skill | manifest/入口必须包含 skill |
使用标准 SKILL.md 并希望连同脚本、模板、references 等资源原样发布时,优先使用 resources.skills。Skill 内容需要按插件配置动态生成、需要条件注册,或只存在于代码字符串中时,使用 ctx.skills.register。
同一个 Skill 不要同时使用两种方式注册。静态 Skill 已经在 setup(ctx) 前进入宿主,再调用 ctx.skills.register 注册同名 Skill 会因 资源名称冲突而使插件激活失败。如果 setup(ctx) 后续抛错,PluginMgr 仍会回收此前加载的静态 Skill。
能力声明与加载顺序
Section titled “能力声明与加载顺序”静态加载按以下顺序执行:
- 读取并校验 manifest;
- 解析
resources.skills、resources.mcpServers和resources.agents; - 检查插件申请并获准了
skill、mcp、agent能力; - 注册静态 Skill、MCP Server 和子 Agent;
- 调用入口的
setup(ctx),继续注册动态能力。
manifest 中包含 resources.skills 时,capabilities 必须包含 skill;包含 resources.mcpServers 时,必须包含 mcp;包含 resources.agents 时,必须包含 agent。插件入口中的 capabilities 优先于 manifest,因此入口如果也声明该字段,必须保留这些能力。对于只提供静态资源的插件,建议在入口中省略 capabilities,让宿主直接使用 manifest 的声明。
如果路径、Skill 内容、MCP 定义或 agent 目录无效,插件不会进入正常激活状态。插件被禁用、卸载或重新加载时,静态资源与动态注册项会一起移除;stdio MCP 的连接也由 PluginMgr 关闭。
Skill 目录解析
Section titled “Skill 目录解析”resources.skills 的每一项都是相对插件根目录的目录路径,不是 Skill 定义对象。PluginMgr 按以下规则加载:
- 声明目录自身包含
SKILL.md时,该目录代表一个 Skill; - 声明目录不直接包含
SKILL.md时,只扫描它的一级子目录,并加载其中包含SKILL.md的目录; - 所有路径必须存在且位于插件根目录内,绝对路径、越过根目录的路径和越界符号链接都会被拒绝;
SKILL.md的name、description、user-invocable和disable-model-invocation等 frontmatter 会被解析为 Skill 元数据;- Skill 以只读方式加入 pluginMgr bundle,并保留插件来源信息;同目录中的脚本、模板等资源仍可由该 Skill 使用。
例如 skills 可以指向单个 Skill:
"resources": { "skills": ["./skills/review-code"]}也可以指向一个包含多个 Skill 的集合目录:
"resources": { "skills": ["./skills"]}第二种写法会加载 skills/review-code/SKILL.md、skills/summarize/SKILL.md 等一级子目录,但不会递归扫描更深层级。
MCP Server 解析
Section titled “MCP Server 解析”resources.mcpServers 直接包含 MCP Server 定义:
- stdio Server 必须提供
name、transport: stdio和command,可选args、cwd、env; - sse 或
streamableHttpServer 必须提供name、transport和合法的绝对url,可选headers; - stdio
command以./或../开头时,会按插件根目录解析,并且必须指向插件内的文件; cwd按插件根目录解析,必须是插件内已经存在的目录;args不会自动转换为绝对路径。使用command: node和相对脚本参数时,应把cwd设置为插件根目录;- 推荐使用
node等可移植命令,不要使用process.execPath; - 本地 Server 文件和它的运行时依赖必须随插件一起发布。
完整静态资源示例
Section titled “完整静态资源示例”目录:
review-bundle/├── opendesk.plugin.json├── package.json├── index.mjs├── skills/│ ├── review-code/│ │ ├── SKILL.md│ │ └── references/│ └── summarize/│ └── SKILL.md└── mcps/ └── server.mjsskills/review-code/SKILL.md:
---name: review-codedescription: 检查代码变更并报告正确性、安全性和测试问题user-invocable: truedisable-model-invocation: false---
# Review Code
检查用户指定的代码变更,并按严重程度输出发现。opendesk.plugin.json:
{ "schemaVersion": 1, "id": "review-bundle", "entry": "./index.mjs", "apiVersion": 1, "capabilities": ["skill", "mcp"], "resources": { "skills": ["./skills"], "mcpServers": [ { "name": "review-files", "transport": "stdio", "command": "node", "args": ["./mcps/server.mjs"], "cwd": "." } ] }}如果入口依赖 SDK,可以只用 definePlugin 校验入口结构;不要在入口中把 capabilities 重写为空数组:
import { definePlugin } from '@bitclub.ai/opendesk-plugin-sdk';
export default definePlugin({ id: 'review-bundle', setup(ctx) { console.info('Static resources loaded for', ctx.pluginId); }});不依赖 SDK 时默认导出同样的普通对象:
export default { id: 'review-bundle', setup(ctx) { console.info('Static resources loaded for', ctx.pluginId); }};两种入口都会从 manifest 获得 skill 和 mcp capabilities。不要在 setup(ctx) 中再次注册相同 Skill 或 MCP 名称,否则会发生 资源名称冲突。
使用 package.json 声明
Section titled “使用 package.json 声明”没有 opendesk.plugin.json 时,可以把 entry 改名为 plugin 并放入 package.json#opendesk:
{ "name": "hello-plugin", "version": "0.1.0", "type": "module", "opendesk": { "schemaVersion": 1, "id": "hello-plugin", "plugin": "./index.mjs", "apiVersion": 1, "capabilities": ["hook"] }}如果两处声明同时存在,入口和 id 必须一致。发布 npm 包时 package.json 必须包含 name 和 version。
入口对象参考
Section titled “入口对象参考”入口模块必须默认导出 OpenDeskPlugin 对象。
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 是 | 插件唯一标识,规则与 manifest 相同 |
| displayName | string | object | 否 | 显示名称;入口值优先于 manifest,支持按语言本地化的对象形式 |
| version | string | 否 | 插件版本;入口值优先于 manifest |
| apiVersion | number | 否 | 默认使用当前 API 版本,显式设置时必须为 1 |
| capabilities | PluginCapability[] | 建议 | 申请的能力;入口值优先于 manifest |
| platforms | PluginPlatform[] | 否 | 限制插件可运行的目标平台;入口值优先于 manifest |
| minOpenDeskVersion | string | 否 | 最低宿主版本 |
| maxOpenDeskVersion | string | 否 | 最高宿主版本 |
| configSchema | Zod schema | 否 | 校验插件 options,解析结果必须是普通对象 |
| failureMode | open 或 closed | 否 | Hook 默认失败策略,默认为 open |
| setup | function | 是 | 激活插件时调用,可同步或异步 |
setup 可以返回以下任一种值:
undefined:没有额外清理逻辑;- 清理函数:停用、卸载、重载或宿主退出时执行;
- 带有异步
dispose方法的 PluginRegistration。
通过 context 注册的所有能力都会被宿主跟踪并按注册顺序的逆序释放。插件自行创建的计时器、文件监听器或子进程应由 setup 返回的清理函数释放。
PluginContext 参考
Section titled “PluginContext 参考”| 字段 | 类型 | 说明 |
|---|---|---|
| pluginId | string | 当前插件 id |
| pluginDir | string | 当前插件安装根目录的绝对路径 |
| configDir | string | OpenDesk 配置目录的绝对路径 |
| options | Readonly object | 用户为插件保存的配置,已经由 configSchema 校验 |
| hooks.on | function | 注册 Hook |
| tools.register | function | 注册 Tool |
| skills.register | function | 注册 Skill |
| mcps.registerServer | function | 注册 MCP Server |
| commands.register | function | 注册 Command |
| events.onApplication | function | 监听应用事件 |
| agents.register | function | 注册单个子 Agent |
| agents.registerDirectory | function | 扫描插件目录下的 agent 目录并批量注册子 Agent |
| browser.hint.register | function | 为匹配的页面注册浏览器提示生成器 |
| channels.register | function | 注册 Channel 实例(消息渠道) |
每个 register 或 on 方法都返回 PluginRegistration。通常不需要手工调用 dispose,因为宿主会在插件退出时统一清理;只有插件需要提前撤销某一项注册时才主动调用。
生命周期与安全边界
Section titled “生命周期与安全边界”插件按以下流程加载:
discover -> resolve entry -> validate path containment and managed integrity -> read manifest and static resources -> verify the entry is directly runnable -> import plugin -> validate API version, capability grants and options -> register static resources -> setup(ctx)setup 失败会释放本次激活已创建的注册项。插件停用或宿主重载时,Hook、工具、Skill、MCP、命令、事件监听、子 Agent 和浏览器提示都会随所属插件释放。
Hook 按插件激活顺序串行执行。默认 failureMode: 'open' 会记录失败并继续主流程;安全或认证场景可以在注册 Hook 时指定 failureMode: 'closed',让异常阻止对应操作。托管插件在 import 前检查安装内容完整性;手工和外部插件会标记为 unmanaged,不进行内容锁定。
插件可以组合以下扩展能力,各能力的详细用法见对应章节:
- 在插件中添加工具 — 把工具加入 Agent 可见工具集,支持参数 Schema、权限声明与 CLI/GUI 渲染器
- 在插件中挂载 MCP — 注册本地 stdio 或远程 sse / streamableHttp MCP Server
- 在插件中添加 Skills — 注册可由用户或模型调用的 Skill 说明
- 在插件中使用钩子 — 在模型请求、会话审查、Skill 生命周期和工具调用等节点插入逻辑
- 在插件中贡献子 Agent — 声明可由主 Agent 调度的子 Agent,支持 agents.md 目录与动态注册
- 在插件中注入浏览器提示 — 为特定站点向 use-browser 注入文本提示与可触发的页面动作
- 在插件中开发消息渠道 — 接入 IM 平台收发消息,支持扫码/参数登录与结构化交互(权限审批 / askUser 卡片)
- 扩展自定义命令 — 注册可从 CLI、TUI 和 GUI 调用的命令
- 插件的调试与发布 — 配置、内置插件、安装与开发目录、校验与发布
Event(应用事件监听)能力当前文档暂未覆盖,后续补充。
完整组合示例
Section titled “完整组合示例”一个插件可以声明多个 capability,并在同一个 setup 中注册多项能力。建议保留每个 registration,并在清理函数中按逆序释放;宿主也会在插件停用时进行兜底清理。
import { definePlugin, tool } from '@bitclub.ai/opendesk-plugin-sdk';
export default definePlugin({ id: 'team-helper', capabilities: ['hook', 'tool', 'skill'], setup(ctx) { const registrations = [ ctx.hooks.on('chat.headers', (_input, output) => { output.headers['x-team-helper'] = 'enabled'; }), ctx.tools.register(tool({ name: 'team-status', description: 'Return the current team status', args: {}, async execute() { return { output: 'Team is ready', resultObject: { ready: true } }; } })), ctx.skills.register({ name: 'team-workflow', description: 'Follow the team workflow', body: '# Team workflow\n\nFollow the project review and verification process.', userInvocable: true, modelInvocable: true }) ];
return async () => { for (const registration of registrations.reverse()) { await registration.dispose(); } }; }});