Skip to content
98 changes: 98 additions & 0 deletions .oo/rfcs/0011-plugin-extensibility-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# RFC 0011: 行动项与优先级

返回入口:[RFC 0011 总览](0011-plugin-extensibility.md)

行动项按"是否需要产品决策"分组。P0/P1 是纯技术改进,不改变任何对外承诺;P2 起需要先有开放程度的判断。

## P0-1:抽通用 ACP 适配器层

**问题**:`agentclientprotocol` 在 `packages/adapters/{cline,dsh,goose}` 各实现了一遍,无共享层,`packages/adapters/` 下也无 acp 包。下一个 ACP agent 需要写第四遍。

**参照**:DSH 的 `subagent-acp` 是通用的,配置里给 `command` / `args` / `env` 即可接入任意 ACP agent,`providerName` 可配,同进程可注册多个不同名字的外部 provider。

**收益**:抽出共享层后,接入新 ACP agent(Cursor、CodeBuddy、opencode 等)从"写一个适配器"降为"加一段配置"。

**风险**:低。纯内部重构,不涉及任何信任决策或对外接口变更。三个现有适配器有各自的 session 投影与能力声明,需确认可共享的是传输层与协议编解码,而非会话语义。

**建议**:先做可行性评估——对比三处实现的重叠度,确认抽象边界应落在 transport / codec 还是更上层。

## P0-2:生成式能力目录 + CI 门禁

**问题**:插件能力面分散在 `.oo/docs/usage/plugins/ui-runtime.md`(400+ 行手写)、`create-plugin/SKILL.md` 与源码之间,无生成、无门禁。本 RFC 调研中对自身能力误判三次(见[现有扩展面盘点](0011-plugin-extensibility-current-surface.md)的"已知误判记录")。

**参照**:DSH 的 `scripts/gen-cordis-api.ts` 从 AST 生成,`verify-cordis-api --check` 挂 doc-sync 门禁,产出还经 `cordis_inspect` 工具喂给模型;`docs/user/develop/framework/service.md` 明文拒绝维护第二份手工清单。

**对我们价值更大的理由**:One Works 本身是 AI 工作区,插件作者会用 Claude Code / Codex 对着我们的 API 写插件。机器可读、CI 校验新鲜度的目录直接决定生成代码的正确率。

**建议实现**:`scripts/gen-plugin-api.ts`,从 `PluginClientContext` / `PluginServerContext` / `PluginViewContext` 的 TS 声明抽结构化目录,产出机器可读 JSON + 渲染 markdown,加 `--check` 模式接入现有检查。首次运行即可量化 `ui-runtime.md` 的漂移程度。

**限定**:不是银弹。DSH 的生成文档仍有轻微漂移(`docs/subsystems/workflow.md` 引 157,实测 168),但事件部分行号全对,整体显著优于纯手工。

## P0-3:补 ErrorBoundary

**问题**:`apps/client/src/plugins/` 与 `apps/client/src/components/plugins/` 下**零个** `ErrorBoundary` / `componentDidCatch`,`PluginHost.tsx:275` 裸渲染 `view.renderNode(viewContext)`。

**风险**:插件 route 页面渲染异常直接白屏,无降级。

**与视图槽无关,应独立先做。** 详见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)纪律 2。

## P1:Hook 权限面对 marketplace 场景的审视

**问题**:`resolvePluginHooksEntryPath`(`packages/utils/src/plugin-resolver.ts:703-707`)解析 `<packageId>/hooks` export,`plugin-entry-cache.ts:43-53` 无条件把能解析出 hooks entry 的实例收进中间件链,**解析链上无 gate**。

而 hook 插件的权限包括:`PreToolUse` 返回 `deny` 否决任意工具调用、`GenerateSystemPrompt` 改写系统提示词、`PreCompact.replacementPrompt` 替换压缩提示词、任意事件 `continue: false` 停机。

**需要核实的点**:

- marketplace 安装的插件是否自动获得 hook 能力,还是需要用户额外确认
- 插件详情页的 `hooks` tab(`PluginDetailPanel.tsx:313`)展示的是资产 hooks(`PluginManifestAssets.hooks`)还是运行时 hook 插件——初步判断是前者(`NativePluginDetailPanel.tsx:116` 把 'mcp' 与 'hooks' 作同类资产分组),但未读完渲染逻辑
- 这些权限是否作为"该插件请求的权限"呈现给用户

**背景**:这条线是命令行时代的设计(插件由用户手写进配置),marketplace 接上后同一条链变成了分发面。DSH 至少在文档里把等价风险明说了("允许该包在你机器上、在 agent sandbox 之外执行代码")。

**注意**:宿主自身的权限执行器 `builtin-permissions.ts` 也是这条链上的一个 hook 插件,第三方插件与它同链、顺序决定优先级。

## P2:Model provider seam(需产品决策)

**问题**:`packages/model-provider-catalog/src/catalog.ts` 是硬编码内置注册表,第三方加 provider 只能提 PR。

**为什么是最值得开的注册型 seam**:

- 数据面而非控制面——provider 只负责发请求、转流,不干预 agent 决策
- RFC 0006 已把"官方模型服务商"做成一等公民,但目录硬编码
- 销毁机制现成(`addDisposable(scope, ...)` + frozen owner token + `rollbackScopeRegistrations`)

**要抄的形状**(来自 `ctx.llm`):

- `registerConfigurableProviders` 的休眠路由——插件声明能力,用户配置才激活
- 全有或全无 + 重复检测(对应我们已有的 `duplicate()` 诊断)
- **凭证 seam:插件拿 ref 不拿明文 key**。marketplace 插件碰 API key 是明确风险面
- 强制 server-only(`PluginServerManifest.roles` 已有角色概念可挂)

**落点**:常驻 server plugin runtime,不是 hook。见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)纪律 3。

**需要的决策**:是否允许第三方提供模型 provider。这直接关系 RFC 0006 的商业路径。

## P2:适配器 seam 化(需产品决策)

**现状**:16 个 `@oneworks/adapter-*` 是编译期内置(根 `package.json` devDependencies + 静态 import)。加一个适配器要改仓库、进 root package.json、重新发版。

**对照**:DSH 的 `SubagentProvider` 是 seam,第三方发 npm 包、用户配置加一行即可。其社区已产出第三方版的 Codex/Claude Code/ACP provider。

**我们的优势不应低估**:16 个适配器有统一 hook 协议、账号池、历史导入、权限镜像,深度显著超过 DSH 的 3 个薄 provider(one-shot、不继承上下文、纯文本、无审批)。seam 化不等于放弃深度,但需要设计"第三方 provider 能拿到多少宿主能力"的分层。

**需要的决策**:这是本 RFC 中影响最大的一项,涉及维护成本、质量控制与品牌。DSH 的策略是核心保持瘦、扩展面全让给社区(明确不收外部 PR),并有守门测试断言可选 provider 不进 base bundle。这是一种可选路径,不是唯一路径。

## 待核实项

以下问题在调研中出现但未查清,建议在实施 P0-2 时一并解决:

1. 同 scope 内 parent 与 child 的 command id 撞名如何处理(覆盖 / 报错 / 静默保留第一个)——`runtime.ts:2802` 的检查针对内置 route key,此路径未核实
2. 插件详情页 `hooks` tab 的确切数据来源(见 P1)
3. 16 个适配器的上游版本漂移防护是否都达到 dsh 适配器的水平(`DSH_VERSION` 固定 + `isOfficialCompositionComplete` 完整性校验)。DSH 只维护 2 个 product provider 就把限制写成明文 Known Limitations 清单,我们 16 个的成本是另一个量级

## 不建议做的

- **开放 `agentLoop` / `tools` / `approval` / `sandboxPolicy` 的注册型控制面**。DSH 敢开是因为其插件等同 shell 权限(明文记录);我们是 marketplace 分发,开了即提权通道。
- **视图槽先于格式词汇表**。见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)纪律 2。
- **让插件创造插件**。见纪律 1。
101 changes: 101 additions & 0 deletions .oo/rfcs/0011-plugin-extensibility-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# RFC 0011: 边界与设计纪律

返回入口:[RFC 0011 总览](0011-plugin-extensibility.md)

本章把已论证过的边界判断写成可引用的纪律,目的是避免每次提出新扩展点时重新论证。

## 纪律 1:插件不能创造插件

**不新增让插件在运行时实例化其他插件的能力**(相当于 Cordis 的 `ctx.plugin()`)。

需要动态插件图时,由宿主通过 plugin overlay 注入,走同一个 resolver、同一套 scope 分配、同一个 `/plugins` 列举。**动态性发生在配置解析层,不发生在插件代码里。**

### 依据

**(1) 清理模型以 scope 为单位。** `disposablesByScope`、`removeExtensionPointListenersByScope`、`rollbackScopeRegistrations(scope, owner)`、`disposeScope(scope)` 全部 keyed on scope。动态子插件只有两条路:自己占新 scope(谁分配?冲突检测在启动期是 fatal;且 `/plugins` store 与 `PluginDetailPanel` 按服务端解析出的 instance 列表渲染,动态 scope 对 UI、诊断、卸载全部隐形),或共享父 scope(那它就不是插件,只是父插件的代码)。

**(2) reload 会失效。** `PluginProvider.tsx:97-104` 的 `reloadPlugin(scope)` 从 `instancesRef`(服务端解析结果)里找 instance,动态创建的东西不在其中,`watch` / HMR 对它是空操作。

**(3) CSP 已堵死代码生成路径。** `script-src` 无 `blob:`(`apps/client/index.html:7`),插件代码只能同源经 `/api/plugins/:scope/client/*` 加载,即只能来自已安装包——那为什么不声明?

**(4) 卸载语义崩塌。** marketplace 有 removal journal / receipt / quotes 一整套账本,运行时拉起的东西没有 install 记录,也就没有 removal 记录。

### 三种被混为一谈的需求

| 需求 | 结论 |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 运行时决定要不要加载某个已安装插件 | 已有更好答案:`activation: 'optional'` + 用户配置开关 + `onAvailable` 被动等待。若不够,应加"插件请求启用某 optional child、宿主弹窗由用户确认",决定权在用户 |
| 参数化多实例 | 配置层已支持(`children` 数组 + 不同 scope)。若诉求是"运行时才知道要几个",那是插件内部数据结构问题,不是插件粒度问题 |
| 运行时生成代码注册为插件 | 一票否决。等于同时绕过 marketplace、构建期边界校验(`client-source-boundary.ts` 只在构建期跑)与 CSP |

### 正确的落点

`PluginOverlayConfig`(`packages/types/src/plugin.ts:76`)的 `mode: 'extend' | 'override'` 与 `overlaySource` 已贯穿整棵解析树,spec/entity 层已在用。要扩展动态插件图应扩展这里。

## 纪律 2:视图扩展优先扩格式词汇表,而非开组件槽

视图扩展存在一条能力光谱:

| 方式 | 贡献什么 | 表达力 | 信任成本 |
| -------------- | ------------------------- | ------ | -------------- |
| 元数据贡献 | `{id,title,icon,command}` | 低 | 无 |
| 声明式渲染描述 | path + format + item 映射 | 中高 | 无(格式封闭) |
| 协议投影 | 跨进程事件 + 上面的描述 | 中高 | 进程边界隔离 |
| 视图槽挂组件 | React 节点 | 最高 | owner 让出画布 |
| iframe | 整页 | 最高 | 强隔离,代价大 |

**决策顺序:**

1. **先扩格式词汇表。** 有人要塞组件时,先问"缺的是哪个 format"。`toolUsePresentations` 证明了很多"必须自定义渲染"的需求实际是"宿主的声明式格式不够用"——cua-driver 的嵌套对象数组 + 渐进披露,一份 schema 就解决了,还白拿 i18n、主题、无障碍与一致性。补一个 `table` / `diff` / `timeline` / `progress` 受益的是所有插件。
2. **把声明式渲染推广到别处。** 目前 `toolUsePresentations` 只服务 `chat.toolUse.presentations` 一个槽。预留的 `message.renderers`、`settings.sections`、`workspace.resourceOpeners` 应复用同一套 field/format 描述,而非各自发明。
3. **视图槽留给真正无法声明化的场景**(自由画布、图编辑器、地图)。

### 若开视图槽,四个前置条件

1. **ErrorBoundary 是前置条件,不是可选项。** 现状:`apps/client/src/plugins/` 与 `components/plugins/` 下**零个** `ErrorBoundary` / `componentDidCatch`,`PluginHost.tsx:275` 是裸渲染 `view.renderNode(viewContext)`。单插件页面崩溃只影响自己尚可接受;一旦 contributor 组件挂进 owner 页面,一个异常会带塌 owner 整页,而用户只会认为是 owner 插件坏了。**此项与是否做视图槽无关,应独立先做。**
2. **挂载权归宿主。** owner 拿到的必须是宿主包好的不透明节点(内部仍走 `PluginHost` 的 `(scope, viewId)` 路径),而非 contributor 的组件引用。否则 contributor 代码会跑在 **owner 的 viewContext** 里——`view.options.update()` 会把配置写到 owner 头上,`data.useQuery` 的 SWR key 前缀也会串(`PluginHost.tsx:136, 160`)。
3. **扩展点须显式声明接受视图**,并携带布局约束(`maxHeight` / `orientation` / 是否允许自撑高),由宿主在包裹层强制。默认应保持数据模式。
4. **顺序必须稳定可预期**——按 `order` 字段或 `pluginScope` 字典序,不能是 Map 插入顺序(那取决于插件激活顺序,而激活顺序本身不保证,这正是 `onAvailable` 要解决的问题)。

## 纪律 3:注册型 seam 走常驻 runtime,不扩 hook 事件表

hook 传输是跨进程的(`call-hook.js` 用 `spawn`,`worker-client.ts` 维护 worker 池),形态是"一次事件,JSON 进 JSON 出"。

- 对**拦截型**完美契合——事件本来就是离散的
- 对**注册型**不成立——LLM adapter 要维持流式连接、跨多次调用持有状态

因此新增注册型 seam 应落在 `registerLocalService` 那条常驻线上,而非新增 hook 事件。

**DSH 提供了一个可行的折中形态**:`SubagentProvider` 的 `start()` 只负责"怎么起、怎么说话",真正的长连接与进程生命周期由宿主的 `ctx.subprocess` 托管。`subagent-claude-code` 尤其典型——SDK 自己要拉进程,它用 `spawnClaudeCodeProcess` hook 把进程句柄夺回来交给宿主统管,于是 teardown 阶梯、孤儿进程回收、超时全归宿主。

**插件提供协议适配,宿主拥有进程和生命周期** —— 这个形态比让插件直接持有连接安全得多,且已被上游验证。

## 纪律 4:禁止 accepted-then-ignored

能力不支持时必须 fail loud,不得静默降级。

DSH 把这条作为相对 Claude Code 的**刻意分歧**记录在案:hook 误用在 CC 里退化成 `null`,DSH 一律 fatal 抛出。其远程 subagent provider 的 `NO_START_CAPABILITIES` 也是同理——服务层在 `start()` 之前就抛 `UNSUPPORTED_CAPABILITY`,而非接受后忽略。

我们已有部分实践(`resolveInstance` 的环检测抛错、scope 冲突启动期 fatal、`duplicate()` 诊断),应确立为统一纪律。

**反例警示**:`subagent-acp` 的 `toAcpPrompt()` 把非 text block **静默丢弃**,而同抽象下的 Codex / Claude Code provider 则**直接抛错**。同一 seam 两种行为是需要避免的形态。

## 纪律 5:trust / scope 字段的语义须明确写出

DSH 的 `PresetTrust` README 写得很直白:trust 字段"exists so consumers can present that difference, **not to enforce it**"。

我们的 `scope` 同理——它是**逻辑隔离**(防命名冲突、划分 API 命名空间),真正的安全边界来自进程边界、CSP、构建期校验与 proxy 白名单。这一点必须在文档中明确,避免团队产生虚假安全感。

**当前需要澄清的一处**:因为 child 默认继承 parent scope(`plugin-resolver.ts:917`),parent 与 child 落在同一 scope 命名空间。已确认 `runtime.ts:2802` 的冲突检查针对的是内置 route key,同 scope 内 command id 撞名的处理路径尚未核实,应在实现能力目录时一并查清并写入文档。

## 纪律 6:Model-visible ⟺ logged(建议采纳)

来自 DSH `AGENTS.md`:任何进入模型请求的内容必须能从 session log 重建;新增模型可见输入必须同时新增 session event。

这条对可复现性、审计与"用户能看懂 agent 为什么这么做"是根本性的,且与开放程度无关。DSH 的 `agent-preset/selected` 会话事件就是例证——因为 preset 决定模型看到的工具 schema 与 prompt,切换必须可从日志重建。

## 纪律 7:capability seam 的定义

来自 DSH `AGENTS.md`:**一个 capability seam 由 Service Definition / Service Provider / Consumer 三个 role 构成,单个 role 不构成 seam。**

这个定义可以直接用来防止"开了个接口但没人实现也没人消费"的假扩展点。新增 seam 的评审应要求三个 role 同时存在或有明确规划。
Loading
Loading