From c32f2e730e35ca2e44ada230da8242a5a91171ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 20:02:39 +0000 Subject: [PATCH 1/3] docs(rfc): add plugin extensibility survey and boundaries (RFC 0011) Establish a referenceable baseline for what the plugin system can actually do today, compare it structurally against DeepSeek Harness / Cordis, and record the boundary decisions that keep getting re-litigated. - current-surface: inventory of the existing extension surface with source locations (config-layer plugin graph, extension points + plugin APIs, toolUsePresentations, the @oneworks/hooks middleware chain, server runtime primitives, security boundaries), plus a record of three misjudgements made during the survey - dsh-comparison: structural comparison pinned to fixed upstream revisions, covering interception vs registration seams, external code-agent scheduling, and the generated-catalog documentation model - boundaries: seven referenceable disciplines (plugins cannot create plugins, view extension ordering, registration seams belong on the resident runtime, no accepted-then-ignored, trust/scope semantics, model-visible implies logged, the three-role seam definition) - actions: prioritised items split by whether they need a product decision Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- .oo/rfcs/0011-plugin-extensibility-actions.md | 98 +++++++++++ .../0011-plugin-extensibility-boundaries.md | 101 +++++++++++ ...11-plugin-extensibility-current-surface.md | 137 +++++++++++++++ ...011-plugin-extensibility-dsh-comparison.md | 159 ++++++++++++++++++ .oo/rfcs/0011-plugin-extensibility.md | 57 +++++++ rfc.md | 8 + 6 files changed, 560 insertions(+) create mode 100644 .oo/rfcs/0011-plugin-extensibility-actions.md create mode 100644 .oo/rfcs/0011-plugin-extensibility-boundaries.md create mode 100644 .oo/rfcs/0011-plugin-extensibility-current-surface.md create mode 100644 .oo/rfcs/0011-plugin-extensibility-dsh-comparison.md create mode 100644 .oo/rfcs/0011-plugin-extensibility.md diff --git a/.oo/rfcs/0011-plugin-extensibility-actions.md b/.oo/rfcs/0011-plugin-extensibility-actions.md new file mode 100644 index 000000000..e54f29b1c --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-actions.md @@ -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`)解析 `/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。 diff --git a/.oo/rfcs/0011-plugin-extensibility-boundaries.md b/.oo/rfcs/0011-plugin-extensibility-boundaries.md new file mode 100644 index 000000000..f92949840 --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-boundaries.md @@ -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 同时存在或有明确规划。 diff --git a/.oo/rfcs/0011-plugin-extensibility-current-surface.md b/.oo/rfcs/0011-plugin-extensibility-current-surface.md new file mode 100644 index 000000000..67d8e28e4 --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-current-surface.md @@ -0,0 +1,137 @@ +# RFC 0011: 现有扩展面盘点 + +返回入口:[RFC 0011 总览](0011-plugin-extensibility.md) + +本章记录 One Works 插件系统**当前实际具备**的扩展能力,作为后续讨论的基线。所有条目都标注了源码位置。 + +## 1. 插件实例图(配置解析层) + +插件实例来自四个来源,后层覆盖前层:全局 `~/.oneworks/global/plugins/*`、项目 `.oo/plugins.dev/*`(默认 `watch: true`)、`plugins` 配置、运行时/任务 overlay。 + +**manifest `children` —— 静态组合依赖**(`packages/utils/src/plugin-resolver.ts:891-985`): + +- 父插件 manifest 声明 `children: { "": { source: {type:'package'|'directory', ...}, activation: 'default'|'optional' } }` +- `activation: 'default'` 自动激活;`'optional'` 需用户显式声明 +- 环检测:`ancestorKeys` + `cycleKey`,撞环抛 `Detected cyclic child plugin graph`(`:908-911`) +- scope 继承:`const scope = config.scope ?? inheritedScope`(`:917`) +- options 合并:manifest 的 child options 打底,用户配置浅覆盖(`mergeOptions`) +- 用户可覆写单个 child,含 `enabled: false` 关掉默认激活的(`hasExplicitChildOverride`) +- 目录 fallback:`collectFallbackDirectoryChildren` 把插件目录下的子目录登记为 `optional` child + +**任务级 overlay**:`PluginOverlayConfig`(`packages/types/src/plugin.ts:76`)的 `mode: 'extend' | 'override'`,`overlaySource` 贯穿整棵解析树(`plugin-resolver.ts:887, 945, 959, 983`),已在 spec/entity 层使用(`packages/workspace-assets/src/prompt-selection.ts:77-81`)。 + +**skill 依赖锁**:插件可依赖外部 skill 文档,经 lockfile 的 `pluginSkills` 把外部安装的 `SKILL.md` 挂到插件实例名下,标记 `plugin-skill-dependency-lock`(`packages/workspace-assets/src/bundle-internal.ts:682-705`)。 + +## 2. 插件间依赖装配(运行时层) + +**这一层是完整的**,语义等价于 Cordis 的 `inject`/`provide`。参考实现是 `packages/plugins/demo` 与 `packages/plugins/demo-extension` 这对。 + +### Extension point + +- `ctx.extensionPoints.register({ id, title, contributionSchema })` —— 暴露扩展点,完整 id 为 `/` +- `ctx.extensionPoints.onAvailable(target, cb)` —— **等待语义**:目标已存在立即触发,不存在则挂起,目标注册时唤醒(`apps/client/src/plugins/plugin-registry.ts:635-674`)。`registerExtensionPoint` 注册后调 `activateExtensionPointListeners(key)` 回头唤醒所有等待者(`:630`) +- `ctx.extensionPoints.contribute(target, contribution)` —— 贡献结构化能力 +- manifest 的静态 `extensionContributions` **也走 `onAvailable`**(`:844-847`),所以声明式贡献同样不怕激活顺序 + +**回收语义**:扩展点 dispose 时 `deactivateExtensionPointListeners` 逐个 `disposeExtensionPointListener`,执行 listener 回调返回的 cleanup(`:619-624, 1019-1023, 1061-1066`)。 + +**竞态保护**:`listener.version` 每次激活自增,异步 handler resolve 回来时比对,不匹配则丢弃刚拿到的 disposable(`:1031-1055`)。等价于 Cordis fiber 的 `epoch`。 + +### Plugin API + +- `ctx.pluginApis.register({ id, inputSchema, outputSchema, handler })` —— handler 的 `meta` 带 `callerScope` / `targetScope` / `apiId` +- `ctx.pluginApis.call(target, input, options?)` —— 目标未注册时不报错,进 `pendingPluginApiCalls` 挂起,`registerPluginApi` 里 `drainPendingPluginApiCalls(key)` 排空(`:472-494, 1091-1128`)。支持 `timeoutMs` 与 `AbortSignal`,调用方插件卸载时通过 signal reject 挂起的 Promise + +### 作用域回收 + +整套清理以 scope 为单位:`disposablesByScope`、`removeExtensionPointListenersByScope`、`rollbackScopeRegistrations(scope, owner)`、`disposeScope(scope)`。owner 是 `Object.freeze` 的 token 存在 `WeakSet` 里(`:190, 340`),用于激活轮次回滚。 + +## 3. 视图侧扩展 + +存在**四种**形态,其中两种已在生产使用: + +| 形态 | 贡献什么 | 状态 | +| ------------------ | -------------------------------------------- | ------------------------------------------------------------------ | +| 元数据贡献 | `{id, title, icon, command}`,owner 自行渲染 | ✅ `extensionContributions` + `view.extensions.getContributions()` | +| **声明式渲染描述** | path + format + item 映射,宿主渲染 | ✅ `toolUsePresentations` | +| 协议投影 | 跨进程事件 + 上面的描述 | ✅ ACP / adapter event projection | +| 视图槽挂组件 | React 节点 | ❌ 不存在 | + +### `toolUsePresentations` + +插件提交**结构化渲染指令**,宿主据此渲染任意工具调用的输入输出(`apps/client/src/plugins/plugin-tool-use.ts`): + +- 字段描述:`{ path, title, format, item: { titlePath, subtitlePath, statusPath, metaPath, detailPath } }` +- 输入格式集合封闭:`inline | text | code | list | chips | records | json` +- 结果格式:`auto | text | code | json | markdown`,另有 `mode: auto | declared | hidden` 做渐进披露 +- 实例参考:`packages/plugins/cua-driver/plugin.json:145` 起 + +**权限设计**:`origin` 默认只能接管自己 scope 下的工具,经 base64 编码的 `oneworks-` 命名空间反解校验(`isToolFromPluginScope`);接管别家工具须显式 `origin: 'any'`,且匹配优先级更低(20/10 vs 40/30)。**表达力做加法,权限做减法。** + +约束见 `packages/plugins/cli-skills/skills/create-plugin/SKILL.md:74`:不允许可执行模板、任意 HTML 或插件私有 React renderer。 + +### 视图侧的已知缺口 + +`view.extensions.getContributions(target)` 返回的是数据记录(`apps/client/src/plugins/plugin-manifest.ts:800`),owner 自行渲染。**无法把 React 组件贡献进别人的视图。** 相关设计约束见[边界与设计纪律](0011-plugin-extensibility-boundaries.md)。 + +## 4. Agent loop 拦截(`@oneworks/hooks`) + +15 个事件(`packages/hooks/src/type.ts`),其中数个**带决策权**: + +| 事件 | 插件能做什么 | +| --------------------------------------------------- | ----------------------------------------------------------------------------- | +| `PreToolUse` | `permissionDecision: 'allow' \| 'deny' \| 'ask'` + 理由 —— **可否决工具调用** | +| `PostToolUse` / `UserPromptSubmit` / `SessionStart` | `additionalContext` 注入 | +| `PreCompact` | `additionalContext` + **`replacementPrompt`** | +| `GenerateSystemPrompt` | system prompt 生成 seam | +| `TaskStart` / `TaskStop` | 拿到 `options` / `adapterOptions` | +| 任意事件 | `continue: false` + `stopReason` —— **可终止循环** | + +插件接口是 koa 式中间件链(`packages/hooks/src/context.ts:11-22`): + +```ts +export type Plugin = + & { name?: string } + & { + [P in keyof HookInputs]: (ctx, input, next) => Promise + } +``` + +`callPluginHook` 按顺序串联,可 `await next()` 后改结果,也可短路(`packages/hooks/src/plugin-hook.ts`)。 + +**跨适配器统一**:`HookSource = 'native' | 'bridge'`。适配器原生支持 hook 的直接透传;不支持的由 `packages/hooks/src/bridge.ts`(516 行)把会话消息与工具事件**合成**成统一 hook 协议。协议形状对齐 Claude Code(`type.ts` 的 JSDoc 直接链到 `docs.anthropic.com/.../hooks`)。 + +**传输是跨进程的**:`call-hook.js` 用 `spawn` 起子进程,`worker-client.ts` 维护 worker 池预热。这决定了它只能承载拦截型 seam,见[总览的结论摘要](0011-plugin-extensibility.md#结论摘要)。 + +**宿主自身也走这条链**:`packages/hooks/src/builtin-permissions.ts` 是一个内置 hook 插件,读权限镜像文件做 allow/deny 判定。即第三方 hook 插件与宿主权限执行器在同一条链上,顺序决定谁说了算。 + +## 5. 服务端插件运行时 + +`PluginServerContext`(`apps/server/src/services/plugins/types.ts:244-268`)的注册原语: + +- `registerCommand(commandId, handler)` +- `registerApi(apiId, options)` —— `handler` 模式或 `proxy` 模式 +- `registerLocalService(serviceId, start)` —— 生命周期绑到 workspace service +- `runtime.registerChannel(channelId, handler)` / `invokeChannel(...)` + +以及三个 facade:`sessions`(`listSessions` / `submitMessage`)、`oneworksChannel`、`roomTunnel`。 + +## 6. 安全边界 + +- 前端插件不直接访问文件系统;服务端插件不能注册顶层 `/api/*`,只能在 `/api/plugins/:scope/*` 下 +- `proxy.ts`:仅允许 loopback 目标(`isLoopbackProxyTarget`),转发前剥掉 `authorization` / `cookie` / `proxy-authorization` +- client asset 路由拦 `..`、绝对路径、null 字节、符号链接逃逸,强制 `X-Content-Type-Options: nosniff` +- `client-source-boundary.ts` 在**构建期**校验源码引用边界(Vite `enforce: 'pre'` transform) +- CSP:`script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'`,**无 `blob:`**(`apps/client/index.html:7`),插件代码只能同源经 `/api/plugins/:scope/client/*` 加载 + +## 已知误判记录 + +本 RFC 调研过程中对自身扩展面出现的错误判断,保留在此作为"为什么需要生成式能力目录"的证据: + +| 误判 | 实际情况 | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| "插件之间不能声明依赖,没有 inject 语义" | `children` 是组合依赖;`extensionPoints.onAvailable` + `pluginApis.call` 是完整的运行时依赖装配,含等待语义、自动回收、epoch 竞态保护 | +| "没有视图侧扩展点" | `toolUsePresentations` 是完整的声明式渲染扩展,已在 cua-driver / browser-driver / external-browser-driver 生产使用 | +| "agent loop 没有任何 seam" | `@oneworks/hooks` 有 15 个事件,含 `PreToolUse` 否决权、`GenerateSystemPrompt` 改写权、`continue: false` 停机权 | + +三次误判都是在能读到完整代码库的前提下发生的,根因是能力面分散在手写文档(`.oo/docs/usage/plugins/ui-runtime.md` 400+ 行)、SKILL.md 和源码之间,没有单一事实源。 diff --git a/.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md b/.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md new file mode 100644 index 000000000..cb051facc --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md @@ -0,0 +1,159 @@ +# RFC 0011: DSH / Cordis 结构对照 + +返回入口:[RFC 0011 总览](0011-plugin-extensibility.md) + +对照上游: `deepseek-ai/deepseek-harness@99f6f02`、`cordiverse/cordis@f46ae95`(cordis 4.0.0-rc.8) + +本章的作用是分清"我们缺的"与"我们刻意不做的"。上游行号对应上述固定 revision。 + +## 1. Cordis 与我们不是同一物种 + +`vendors/cordiverse/cordis` 此前未 checkout,本次调研拉取后阅读了 `packages/core`。 + +**趋同的那一层不是 Cordis 的特色。** `onAvailable` + epoch 防竞态 + 提供方消失回收消费方,是 late binding + 生命周期这一问题的通用解,OSGi ServiceTracker、Eclipse extension point、VS Code `activationEvents` + `extensionDependencies` + `extension.exports` 都是同一形状。收敛源自约束相同,非同源。 + +论亲缘,One Works 更接近 VS Code Extension Host,术语也来自 Eclipse/VS Code 一支:extension point、contribution、activation。 + +### 三条结构性差异 + +**(1) Cordis 是自举的,我们不是。** Cordis 的 loader、hmr、timer、logger-console 自己都是 `@cordisjs/plugin-*`,与用户插件进同一 registry、同一套 fiber 生命周期;除 `packages/core` 这个容器外没有特权核心。 + +我们相反:discovery、runtime、marketplace、HMR 全是宿主代码(`apps/server/src/services/plugins/`),且插件不能占用 `sessions` / `config` / `workspace` / `agent-rooms` 等内置 route key。**有特权宿主 + 只能做加法的扩展**,对 **没有中心、一切皆插件**。 + +**(2) ctx 是继承链 vs 固定 API 表。** Cordis 的 `Context` 是活的:`ctx.plugin(x)` 让插件在运行时动态加载另一个插件,产生子 Context,contexts 形成原型链,`ctx.isolate(name)` 造影子命名空间。 + +我们的 client ctx 是扁平的 16 个 key(`api` / `commands` / `extensionPoints` / `pluginApis` / `routes` / `slots` / `views` / `themes` / `launcher` / `notifications` / `runtime` / `react` / `hot` / `i18n` / `manifest` / `scope`),**没有任何一个能实例化另一个插件**。 + +**(3) 属性注入 vs 带 schema 的调用。** Cordis 的 `provide` 把对象挂到 context 上(`ctx.database` 即实例),靠 `ReflectService`(281 行 Proxy)追踪访问归属做自动清理,拿到的是**对象引用**。 + +我们是 `ctx.pluginApis.call('scope/id', input)`,带 `inputSchema` / `outputSchema`,`meta` 给提供方 `callerScope`。拿到的是**一次调用的返回值**。这不是风格差异——属性注入无法审计、无法拒绝、无法跨进程;带 schema 的调用三样都能。 + +## 2. DSH 暴露给插件的 55 个 ctx 服务 + +``` +agentDefaultModel agentLoop agentPresets agents apiProxy approval attachments +clientModules codeRuntime commands compaction credentials directoryPicker e2b fs +goals invariants jobs llm lsp messageFeedback permissionPresets planMode sandbox +sandboxPolicy sessionPersistence sessionProjections sessionQuery sessions +sessionTitle settings shell shellEnv skills spillStore storage subagents +subprocess systemPrompt terminals timer tokenMeter toolResultPruner tools +typert userQuestions web webServer workflowEngine workspaceRegistry ... +``` + +外加 55 个事件(`agent/pre-step`、`tools/pre-execute`、`tools/post-execute`、`approval/request`、`llm/stream`、`system-prompt/assemble` 等)。 + +**核心机制是插件既消费又提供实现**: + +```ts +export const inject = ['llm'] +ctx.llm.registerAdapter(['deepseek-official'], adapter) +``` + +`ctx.llm` 的契约(`packages/llm/llm/src/index.ts:338`):`registerAdapter` 返回随 fiber 销毁的 handle,重复注册抛 `DUPLICATE_ADAPTER`(全有或全无);`registerConfigurableProviders` 声明"可由配置激活的休眠 provider 路由";API key 走 `ctx.credentials` 凭证 seam,插件拿 ref 不拿明文。 + +### 信任前提不同 + +`packages/preset/agent-presets/src/preset.ts:5-8`: + +> a `user` preset was authored locally, by a person or by an agent, and therefore **carries the same trust as shell access**. + +且 `README.md:133-135` 明确 trust 字段"exists so consumers can present that difference, **not to enforce it**"——它只影响写路径(`remove()` 拒绝非 user preset、`copy()` 落到第一个 user root),不是权限沙箱。 + +同样的坦率也见于 `packages/extensions/tool-cordis`("Treat this toolset like bash access")与 workflow("A vm context and worker thread are not security boundaries")。 + +**DSH 的插件等同于 shell 权限,所以它敢把 `agentLoop` / `tools` / `approval` / `sandboxPolicy` 全开。** 我们是 marketplace 分发 + 卸载账本 + 构建期边界校验 + CSP,不能整套照抄。 + +## 3. 逐项对照 + +### 拦截型 seam:我们基本齐平 + +| DSH | One Works | +| ----------------------------------------- | ---------------------------------------------------------- | +| `tools/pre-execute` + `PreToolDecision` | `PreToolUse` + `permissionDecision` ✅ | +| `tools/post-execute` + `PostToolDecision` | `PostToolUse` + `additionalContext` ✅ | +| `system-prompt/assemble` | `GenerateSystemPrompt` ✅ | +| `PreCompact` / `ctx.compaction` | `PreCompact` + `replacementPrompt` ✅ | +| `session/created` `/disposed` | `SessionStart` / `SessionEnd` ✅ | +| `agent/pre-step` + `PreStepDecision` | `continue: false` ⚠️ 粒度粗 | +| `ctx.approval` | `permissionDecision: 'ask'` ⚠️ 只能触发,不能自定义审批策略 | + +### 注册型 seam:我们没有 + +| DSH | One Works | +| -------------------------------------------------- | ------------------------------------------------------------------------ | +| `ctx.llm.registerAdapter` | ❌ `packages/model-provider-catalog/src/catalog.ts` 是硬编码内置注册表 | +| `ctx.subagents.registerProvider` | ❌ 适配器是编译期内置(根 `package.json` devDependencies + 静态 import) | +| `ctx.tools` 注册工具 | ⚠️ 走 MCP,不走插件 seam | +| `skills` / `sessionTitle` / `web` / `lsp` provider | ❌ | + +**这不是遗漏。** hook 传输是"每事件一次子进程往返",对拦截型完美契合,对注册型根本不成立——LLM adapter 要维持流式连接、跨多次调用持有状态。要开注册型 seam 得走常驻 server plugin runtime。 + +## 4. DSH 如何调度外部 code agent + +`packages/subagent/` 下有 11 个包,其中 4 个是 out-of-process backend: + +| provider | 传输 | 进程归属 | +| ---------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | +| `subagent-codex` | `codex app-server --stdio`,私有 wire | `ctx.subprocess.spawn` | +| `subagent-claude-code` | 官方 `@anthropic-ai/claude-agent-sdk` 的 `query()` | SDK 经 `spawnClaudeCodeProcess` hook 把 CLI 进程交回 `ctx.subprocess` 托管 | +| `subagent-acp` | 通用 ACP over ndjson stdio | `ctx.subprocess.spawn` | +| `subagent-dsh-sdk` | stdio JSON-RPC,子进程是第二个完整 DSH runtime | SDK 自己 | + +**抽象极简**(`packages/subagent/subagent/src/types.ts:285-324`):3 个只读字段 + 1 个必需方法 `start()`。跨进程写进抽象里——`SubagentRun.localAgent: Agent | undefined`,`undefined` 即远程;`subagent/` 包自带 215 行 `out-of-process.ts` 放公共词汇(cwd 解析、永不 reject 的结算、幂等 dispose handle)。 + +**已明文记录的限制**:四个远程 provider 全部 `NO_START_CAPABILITIES`(不能指定 outputSchema / persona / toolFilter / depthLimit)、`inheritsParentContext: false`、均未实现 `prepareContinuable`(**只能 one-shot**)、跨进程只传文本、无人工审批路径、Claude Code 不流式(取消时无部分答案)、`ctx.subagents.interrupt()` 对远程子无效。 + +**ACP 是双向的**:`packages/acp/acp/` 是 server(`AgentSideConnection`),`subagent-acp` 是 client(`ClientSideConnection`)。按能力归属而非协议归属分包。 + +### Workflow 的异构粒度 + +workflow 脚本是模型现写的普通 JS,realm 注入 5 个全局:`agent()` / `parallel()`(有 barrier)/ `pipeline()`(无 barrier)/ `phase()`(纯进度分组)/ `log()`。脚本内**无 fs / network / timer / Node API**。 + +**一次 run 只绑一个 subagent provider**(`workflow-worker-thread/src/host.ts:139`),`ChildStartRequest` 没有"选 provider"字段,文档明说脚本 "cannot observe or replace either policy"。 + +- ❌ "第一步 DSH、第二步 Claude Code" —— workflow 层做不到 +- ✅ "整个 workflow 全跑 Claude Code CLI" —— 改 `provider` 即可 +- ✅ 逐轮异构 —— 在**工具层**:`standard` preset 把同一个 `dsh-tool-subagent` 挂 4 次绑不同 provider,暴露成 `subagent` / `subagent_fork` / `subagent_codex` / `subagent_claude_code`,父 agent 在自己回合里逐个调用 + +易混点:`agent(prompt, { provider, model })` 的 `provider` 是 **LLM 路由**,与 subagent 传输后端是两个命名空间。 + +## 5. 深度对比:我们更深,形态它更开 + +DSH 的 3 个 product provider 是薄的(one-shot、不继承上下文、纯文本、无审批)。我们 16 个适配器有统一 hook 协议、账号池、历史导入、权限镜像、原生历史自动导入,不是一个量级。 + +但形态差异带来的后果已经显现。DSH 的 `CONTRIBUTING.md` 明确不收外部 PR,把人推向 `dsh-plugin` topic,并声明: + +> You may consider this repository an idea, an official showcase, and a source of inspiration, **but not a mandate from us**. + +其社区已产出与 One Works 产品面高度重叠的插件:跨 14+ agent 的历史导入、跨 agent SKILL.md 移植、Cursor/Gemini/Copilot workspace instruction 加载、可视化插件市场、开放侧边栏底座、内联 GenUI 渲染、多个 TUI/VS Code/桌面前端,以及第三方版的 Codex/Claude Code/ACP subagent provider(带两层权限模型与"子 agent 不能派生权限更高的后代"约束)。 + +注意:`dsh-plugin` topic 下约 7,466 个仓库,噪音极高(含大量无关项目蹭 tag),**该数字不能作为插件数量的可信指标**;上述条目经逐条核对描述。 + +## 6. 文档体系对照 + +DSH 是三层: + +1. **概念地图**(手写)—— `docs/architecture.md`,"Events are the extension points" + "Where new behavior goes" 目标→机制表 +2. **生成式目录**(机器生成 + CI 门禁)—— `docs/capability-seams.md`(逐行列 ~55 个 `ctx.*` 的 role / owner / 实现 / 消费者)、`docs/event-producer-consumer.md`(每事件的 dispatch mode、声明位置带文件:行号、生产者、消费者)、`docs/tool-catalog.md`(真实 boot 后读 `ctx.tools.schemas()`)、`docs/config-catalog.md` +3. **feature → mechanism 对照** —— `docs/cookbook/extension-cookbook.md` + +生成器 `scripts/gen-cordis-api.ts` + `verify-cordis-api --check` 挂在 doc-sync 门禁;且 `docs/user/develop/framework/service.md` 明文拒绝维护第二份手工清单。产出还通过 `cordis_inspect` 工具喂给模型。 + +**诚实的限定**:即便如此仍有轻微漂移(`docs/subsystems/workflow.md` 引 `index.ts:157`,实测 168),事件部分的行号则全部正确。生成 + 门禁不是银弹,但显著优于纯手工。 + +我们当前是手写 `.oo/docs/usage/plugins/ui-runtime.md`(400+ 行)+ `create-plugin/SKILL.md`,无生成、无门禁。 + +## 7. 分发模型对照 + +DSH 是 bundle(npm 包,`package.json` 声明 `dsh.bundle.patch` 指向 `cordis.patch.yml`)+ profile(`$DSH_HOME/profiles/` 目录,声明有序 bundle 列表)。层序覆盖,**patch 是整体替换 row 的 `config`,不是深合并**(我们的 `mergeOptions` 是浅合并,两种都可行但须写进文档)。 + +最小插件骨架: + +```ts +export const name = 'hello-plugin' +export function apply(ctx: Context) {/* ... */} +``` + +对比我们需要 `plugin.json` manifest + client/server 双 entry + vite 构建。 + +从 GitHub 安装需用户开 `allowBuilds`,其文档直白提醒这等于"允许该包在你机器上、在 agent sandbox 之外执行代码"。 diff --git a/.oo/rfcs/0011-plugin-extensibility.md b/.oo/rfcs/0011-plugin-extensibility.md new file mode 100644 index 000000000..b78ff3024 --- /dev/null +++ b/.oo/rfcs/0011-plugin-extensibility.md @@ -0,0 +1,57 @@ +# RFC 0011: 插件扩展面盘点与边界 + +返回入口:[RFC 索引](../../rfc.md) + +Status: 调研完成,待决策\ +对照上游: `deepseek-ai/deepseek-harness@99f6f02`(release/dsh-0.1.0-rc.7)、`cordiverse/cordis@f46ae95`(cordis 4.0.0-rc.8)\ +Reviewed: 2026-08-18 + +## 背景 + +One Works 的插件扩展面是分多轮长出来的:`plugins` 配置与 manifest、client/server 双运行时、extension point 与 plugin API、`@oneworks/hooks` 中间件链、marketplace 分发。每一层都有实现,但**没有单一事实源**描述"插件到底能做什么"。 + +这带来两个具体后果: + +1. 内部评审时对现有能力的判断会出错。本 RFC 的调研过程中,对自身扩展面出现过三次错误判断(详见 [现有扩展面盘点](0011-plugin-extensibility-current-surface.md) 的"已知误判记录"),而调研是拿着完整代码库做的。插件作者只会更容易出错。 +2. 新增扩展点时缺少可引用的边界依据,每次都要重新论证。 + +同时,DeepSeek Harness(DSH,基于 Cordis)作为同类系统提供了有价值的对照:它把几乎全部运行时能力做成了命名 seam,并配套了生成式能力目录。它的社区在数月内长出了与 One Works 产品面高度重叠的插件。 + +## 目标 + +- 盘点 One Works 插件系统**当前实际具备**的扩展能力,建立可引用的基线。 +- 与 DSH/Cordis 做结构性对照,分清"我们缺的"与"我们刻意不做的"。 +- 把已达成的边界判断写成可引用的纪律,避免重复论证。 +- 给出按优先级排序的行动项,区分"技术决策"与"需要产品决策"。 + +## 非目标 + +- 本 RFC 不新增任何扩展点,也不修改任何运行时行为。 +- 不对"扩展面开放到什么程度"给出结论——该判断涉及商业路径与维护成本,属于产品决策。 + +## 章节 + +- [现有扩展面盘点](0011-plugin-extensibility-current-surface.md) +- [DSH / Cordis 结构对照](0011-plugin-extensibility-dsh-comparison.md) +- [边界与设计纪律](0011-plugin-extensibility-boundaries.md) +- [行动项与优先级](0011-plugin-extensibility-actions.md) + +## 结论摘要 + +**我们的扩展面比内部认知的更完整。** 依赖装配(extension point 的 `onAvailable` 等待语义、`pluginApis.call` 的挂起队列)、视图侧声明式渲染(`toolUsePresentations`)、agent loop 拦截(`@oneworks/hooks` 的 15 个事件,含 `PreToolUse` 否决权)都已存在并在生产使用。 + +**真正缺失的是"注册型 seam"。** 现有 seam 全部是拦截型:宿主流程跑到某个点回调插件,插件可否决或增补,但不提供实现。缺的是"插件提供一个实现并成为运行时一部分"——典型是 model provider。这不是遗漏,而是 hook 的跨进程传输形态(每事件一次子进程往返)天然只能承载拦截型。要开注册型 seam 需走常驻 server plugin runtime,不是扩展 hook 事件表。 + +**结构性差异只有一条:seam vs 编译期内置。** 我们的 16 个适配器在深度上显著超过 DSH 的 3 个 out-of-process provider(统一 hook 协议、账号池、历史导入、权限镜像),但它们是编译期内置;DSH 的是 `SubagentProvider` seam,第三方发 npm 包、用户配置加一行即可接入。 + +**最高优先级的行动项不涉及任何信任决策:** 抽通用 ACP 适配器层(当前 cline/dsh/goose 各实现一遍),以及建立生成式能力目录 + CI 门禁。详见[行动项](0011-plugin-extensibility-actions.md)。 + +## 调研方法 + +本 RFC 的事实基础来自: + +- 直接阅读 One Works 仓库源码(路径与行号在各章节内标注)。 +- 拉取 `vendors/cordiverse/cordis` submodule(此前未 checkout)并阅读 `packages/core` 源码。 +- 克隆 `deepseek-ai/deepseek-harness` 并由四个并行子任务分别调研:subagent provider 体系、ACP 与外部 agent 集成、workflow 与 preset 编排、插件生态与官方文档。 + +所有结论均标注了来源位置。上游行号对应上述固定 revision,升级上游后需重新核对。 diff --git a/rfc.md b/rfc.md index 4c0294523..ca7b27251 100644 --- a/rfc.md +++ b/rfc.md @@ -17,6 +17,14 @@ - [运行时 API 与服务契约](.oo/rfcs/0006-standard-voice-runtime.md) - [Sender 交互、落地计划与验证](.oo/rfcs/0006-standard-voice-sender-plan.md) +## 插件扩展面 RFC + +- [总览与结论](.oo/rfcs/0011-plugin-extensibility.md) +- [现有扩展面盘点](.oo/rfcs/0011-plugin-extensibility-current-surface.md) +- [DSH / Cordis 结构对照](.oo/rfcs/0011-plugin-extensibility-dsh-comparison.md) +- [边界与设计纪律](.oo/rfcs/0011-plugin-extensibility-boundaries.md) +- [行动项与优先级](.oo/rfcs/0011-plugin-extensibility-actions.md) + ## 插件运行时 RFC - [目录结构、manifest 与共享契约](.oo/rfcs/plugin-runtime-layout-manifest.md) From f68d724079300665be77ef96b48ad5a34c2b20d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:07:23 +0000 Subject: [PATCH 2/3] docs(rfc): add hook/plugin convergence design (RFC 0012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse @oneworks/hooks and the plugin runtime into one extension surface. The hook subprocess becomes a normalising reporter; plugin code moves into whichever process drives the task, consuming one internal event stream. - events-api: ctx.events with three modes narrowed from Cordis's five. emit/parallel/serial fold into `notify` (awaiting is the dispatcher's choice, not the event's); `waterfall` becomes `transform`; `bail` is replaced by `decide` — an order-independent, monotonically-tightening adjudication that encodes "capabilities add, permissions subtract" into dispatch semantics rather than leaving it to each event's implementation - events: the vocabulary, renamed to DSH's namespace/kebab convention for migration parity, with per-source availability grading so unsupported subscriptions fail loud; four gap points identified against DSH, all in the model-request and around-dispatch layers - runtime: reporter contract, endpoint resolution (no daemon needed — the process driving the task is alive by construction), permission layering where host baseline is synchronous and plugins can only tighten, and an explicit priority contract replacing the current array-order guarantee - migration: five reversible steps, compat shim mapping for the old /hooks entry, and an honest capability matrix for a DSH plugin shim Also corrects the hook event count in RFC 0011 from 15 to 14. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- ...11-plugin-extensibility-current-surface.md | 4 +- .oo/rfcs/0011-plugin-extensibility.md | 2 +- ...0012-hook-plugin-convergence-events-api.md | 156 ++++++++++++++++++ .../0012-hook-plugin-convergence-events.md | 113 +++++++++++++ .../0012-hook-plugin-convergence-migration.md | 141 ++++++++++++++++ .../0012-hook-plugin-convergence-runtime.md | 115 +++++++++++++ .oo/rfcs/0012-hook-plugin-convergence.md | 76 +++++++++ rfc.md | 8 + 8 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-events-api.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-events.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-migration.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence-runtime.md create mode 100644 .oo/rfcs/0012-hook-plugin-convergence.md diff --git a/.oo/rfcs/0011-plugin-extensibility-current-surface.md b/.oo/rfcs/0011-plugin-extensibility-current-surface.md index 67d8e28e4..ad998b6cb 100644 --- a/.oo/rfcs/0011-plugin-extensibility-current-surface.md +++ b/.oo/rfcs/0011-plugin-extensibility-current-surface.md @@ -76,7 +76,7 @@ ## 4. Agent loop 拦截(`@oneworks/hooks`) -15 个事件(`packages/hooks/src/type.ts`),其中数个**带决策权**: +14 个事件(`packages/hooks/src/type.ts`),其中数个**带决策权**: | 事件 | 插件能做什么 | | --------------------------------------------------- | ----------------------------------------------------------------------------- | @@ -132,6 +132,6 @@ export type Plugin = | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | "插件之间不能声明依赖,没有 inject 语义" | `children` 是组合依赖;`extensionPoints.onAvailable` + `pluginApis.call` 是完整的运行时依赖装配,含等待语义、自动回收、epoch 竞态保护 | | "没有视图侧扩展点" | `toolUsePresentations` 是完整的声明式渲染扩展,已在 cua-driver / browser-driver / external-browser-driver 生产使用 | -| "agent loop 没有任何 seam" | `@oneworks/hooks` 有 15 个事件,含 `PreToolUse` 否决权、`GenerateSystemPrompt` 改写权、`continue: false` 停机权 | +| "agent loop 没有任何 seam" | `@oneworks/hooks` 有 14 个事件,含 `PreToolUse` 否决权、`GenerateSystemPrompt` 改写权、`continue: false` 停机权 | 三次误判都是在能读到完整代码库的前提下发生的,根因是能力面分散在手写文档(`.oo/docs/usage/plugins/ui-runtime.md` 400+ 行)、SKILL.md 和源码之间,没有单一事实源。 diff --git a/.oo/rfcs/0011-plugin-extensibility.md b/.oo/rfcs/0011-plugin-extensibility.md index b78ff3024..470c8c849 100644 --- a/.oo/rfcs/0011-plugin-extensibility.md +++ b/.oo/rfcs/0011-plugin-extensibility.md @@ -38,7 +38,7 @@ One Works 的插件扩展面是分多轮长出来的:`plugins` 配置与 manif ## 结论摘要 -**我们的扩展面比内部认知的更完整。** 依赖装配(extension point 的 `onAvailable` 等待语义、`pluginApis.call` 的挂起队列)、视图侧声明式渲染(`toolUsePresentations`)、agent loop 拦截(`@oneworks/hooks` 的 15 个事件,含 `PreToolUse` 否决权)都已存在并在生产使用。 +**我们的扩展面比内部认知的更完整。** 依赖装配(extension point 的 `onAvailable` 等待语义、`pluginApis.call` 的挂起队列)、视图侧声明式渲染(`toolUsePresentations`)、agent loop 拦截(`@oneworks/hooks` 的 14 个事件,含 `PreToolUse` 否决权)都已存在并在生产使用。 **真正缺失的是"注册型 seam"。** 现有 seam 全部是拦截型:宿主流程跑到某个点回调插件,插件可否决或增补,但不提供实现。缺的是"插件提供一个实现并成为运行时一部分"——典型是 model provider。这不是遗漏,而是 hook 的跨进程传输形态(每事件一次子进程往返)天然只能承载拦截型。要开注册型 seam 需走常驻 server plugin runtime,不是扩展 hook 事件表。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md new file mode 100644 index 000000000..5c03bda74 --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -0,0 +1,156 @@ +# RFC 0012: 通用事件 API 设计 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +本章设计 `ctx.events` —— 插件系统的通用事件派发 API。设计参照 Cordis 的多 mode 事件模型(`cordiverse/cordis@f46ae95` 的 `packages/core/src/events.ts`),但在三处刻意收窄。 + +## 这不是新增原语,是收敛 + +RFC 0011 纪律 1 的配套约定是"不新增第三种跨插件通信原语"。本章不违反该约定,因为 `ctx.events` **取代**而非新增: + +| 现状 | 收敛后 | +| ------------------------------------------------------- | ------------------------------------------------------ | +| `@oneworks/hooks` 的私有 koa 中间件链 | `ctx.events` 的 `transform` / `decide` mode | +| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `notify` mode | +| `pluginApis.register/call` | **保留不动** —— 它是 1:1 有返回值的 RPC,不是事件 | +| `extensionPoints.register/contribute/getContributions` | **保留不动** —— 它是结构化贡献 registry,不是 dispatch | + +净效果是原语数量不变:hook 那套私有链被通用事件取代,`pluginApis` 与 `extensionPoints` 各司其职。 + +## Cordis 的五个 mode,我们取三个 + +Cordis `EventsService` 提供 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`)。逐条评估: + +| Cordis mode | 我们的结论 | +| ------------------------------ | ------------------------------------------------------------------------------------------ | +| `emit`(同步 fire-and-forget) | **不可用**。Cordis 单进程内同步派发;我们跨 client / server / 上报器三处,所有派发必须异步 | +| `parallel`(await 全部) | **并入 `notify`**。"派发方要不要等"是调用点的事,不该是事件定义的属性 | +| `serial`(顺序,无 `next()`) | **并入 `notify`**,用 dispatch 选项控制 fail-fast | +| `waterfall`(链式改写) | **保留**,改名 `transform` | +| `bail`(首个非空返回胜出) | **不采用**,用 `decide` 取代。理由见下 | + +### 为什么不要 `bail` + +`bail` 的语义是"第一个返回非空值的监听器胜出并短路"。在可信插件场景(Cordis / DSH 的前提是插件等同 shell 权限)这没问题;在 marketplace 分发场景下它是提权通道——**任何第三方插件都能抢占一个宿主关心的决策**,且抢占是静默的。 + +替代方案 `decide` 把"只能收紧"编码进 dispatch 语义本身,而不是指望每个事件的实现自觉。 + +## 三个 mode + +### `notify` —— 通知 + +```ts +type NotifyListener

= (payload: P) => void | Promise +``` + +监听器互相独立,任一失败不影响同侪,不影响派发结果。派发方通过选项决定是否等待全部完成。 + +用于:状态变更广播、审计、遥测、UI 更新。 + +### `transform` —— 链式改写 + +```ts +type TransformListener

= ( + payload: P, + next: (payload: P) => Promise

+) => Promise

+``` + +顺序执行,**必须调 `next()`**,不调即短路整条链。每个监听器可在 `await next()` 前后改写 payload。 + +这就是现有 hook chain 的语义,等价于 Cordis 的 `waterfall`。 + +用于:system prompt 组装、上下文注入、请求改写。 + +### `decide` —— 单向收紧的裁决 + +```ts +type DecideListener = ( + payload: P +) => D | undefined | Promise +``` + +**不是链式。** 所有监听器并行拿到同一份 payload,各自独立给出判定,宿主按事件定义的**收紧格**(meet)合并。返回 `undefined` 表示"无意见"。 + +关键性质: + +- **合并结果不可能比宿主基线更宽松。** 宿主判定是格的上界,插件只能向下拉。 +- **超时 = 无意见。** 慢插件不会拖垮 agent,也不会静默放宽(基线仍在)。 +- **顺序无关。** 合并是可交换的,因此不存在"谁先注册谁赢"的隐式依赖。 + +事件定义必须声明判定格。以工具权限为例: + +``` +allow ⊐ ask ⊐ deny +``` + +宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`(`allow` 在格中不低于 `deny`,取 meet 后不变)。 + +用于:权限裁决、内容策略、合规拦截。 + +**`decide` 是本设计相对 Cordis 的主要改进**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。 + +## API 形状 + +mode 声明在**事件定义**上,不在派发调用点。理由:定义方知道该事件如何派发,调用方不该能改;订阅方从定义即可知道自己的契约(要不要 `next`、能不能否决);且定义可被生成进能力目录。 + +```ts +// 定义(宿主或插件,事件 id 为 /) +ctx.events.define({ + name: 'before-save', + mode: 'transform', + payload: payloadSchema, + result: resultSchema, + summary: '保存前改写文档内容' +}) + +// 订阅(任意插件) +const off = ctx.events.on('demo/before-save', async (payload, next) => { + const result = await next({ ...payload, content: rewrite(payload.content) }) + return result +}) + +// 派发(仅定义方 scope 可派发) +const output = await ctx.events.dispatch('demo/before-save', payload) + +// 能力查询 +ctx.events.availability('agent/request') // 'both' | 'bridge' | 'native:' +``` + +### 约束 + +- **派发权归定义方。** 只有定义该事件的 scope 能 `dispatch`,否则第三方可以伪造宿主事件。 +- **`decide` 事件的定义必须带判定格**,否则 `define` 失败(fail loud,纪律 4)。 +- **订阅不支持的事件必须报诊断**,不得静默不触发。可用性经 `availability()` 查询。 +- **`transform` 监听器不调 `next()` 即短路** —— 这是刻意保留 Cordis 的语义,但必须在文档中明写,且短路事件要进诊断(避免"某插件悄悄吃掉了整条链")。 +- **顺序契约用显式 priority**,不用 Cordis 的 `prepend` 布尔。`notify` 顺序无关;`transform` 按 priority 升序;`decide` 顺序无关(合并可交换)。宿主内置监听器占用保留的 priority 段,第三方无法插到它前面。 + +## 与现有原语的边界 + +新人最容易混淆的是"什么时候用 events,什么时候用 pluginApis"。判据: + +| 场景 | 用什么 | +| ------------------------------------ | ---------------------- | +| 我要**通知**别人发生了什么 | `events` + `notify` | +| 我要让别人**改写**我的数据 | `events` + `transform` | +| 我要让别人**收紧**我的判定 | `events` + `decide` | +| 我要**调用**某个特定插件拿返回值 | `pluginApis.call` | +| 我要让别人**注册结构化贡献**供我读取 | `extensionPoints` | + +一句话:events 是一对多的派发,`pluginApis` 是一对一的调用,`extensionPoints` 是贡献登记。 + +## Hook 事件是内置事件集 + +收敛后,[事件词汇表](0012-hook-plugin-convergence-events.md)里的全部事件都是 `ctx.events` 的内置定义(由宿主 `define`,可用性按 source 分级)。插件订阅它们和订阅其他插件的事件走同一套 API,不存在"hook 插件"这个独立形态。 + +原 mode 词汇的映射: + +| 事件词汇表中的 mode | 本章 mode | +| ------------------- | -------------------------------------- | +| `waterfall` | `transform` | +| `emit` | `notify` | +| `serial` | `notify`(dispatch 时 fail-fast) | +| `parallel` | `notify`(dispatch 时 await all) | +| —— | `decide`(权限类事件专用,DSH 无对应) | + +据此,词汇表中 `tools/pre-execute` 标为 **`decide`** 而非 DSH 的 `waterfall`——它在我们这里是权限裁决而非数据改写。这是与 DSH 唯一的 mode 分歧,源于信任模型不同,兼容垫片需显式处理(见[迁移与兼容](0012-hook-plugin-convergence-migration.md))。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events.md b/.oo/rfcs/0012-hook-plugin-convergence-events.md new file mode 100644 index 000000000..9d6ef060b --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events.md @@ -0,0 +1,113 @@ +# RFC 0012: 事件词汇表 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +本章定义收敛后的内部事件标准。事件名对齐 DSH(`deepseek-ai/deepseek-harness@99f6f02`),差异处显式标注。 + +## Mode 词汇 + +派发语义与 API 形状见[通用事件 API 设计](0012-hook-plugin-convergence-events-api.md)。本章使用收窄后的三个 mode: + +| mode | 语义 | 对应 Cordis / DSH | +| ----------- | ------------------------- | ------------------------------ | +| `notify` | 通知,监听器互相独立 | `emit` / `parallel` / `serial` | +| `transform` | 链式改写,必须调 `next()` | `waterfall` | +| `decide` | 单向收紧的裁决,顺序无关 | 无对应(我们特有) | + +Mode 是事件定义的一等字段,插件作者不需要从名字推断,宿主据此决定如何派发与合并。 + +**与 DSH 的一处刻意分歧**:DSH 把 `tools/pre-execute` 标为 `waterfall`,我们标为 `decide`。原因是它在我们这里是权限裁决而非数据改写——DSH 的插件等同 shell 权限所以 waterfall 无妨,我们是 marketplace 分发,必须保证插件只能收紧。 + +## 可用性分级 + +`native` 源的事件由上游适配器 CLI 的 hook 协议决定;`bridge` 源由我们自己合成(`packages/hooks/src/bridge.ts`)。**并非所有事件在所有 source 下都可用。** + +| 级别 | 含义 | +| ------------------ | ----------------------------------------- | +| `both` | native 与 bridge 均可用 | +| `bridge` | 仅 bridge 源可用(上游 CLI 不暴露该点位) | +| `native:` | 仅特定适配器可用 | + +插件订阅一个当前 source 不支持的事件时,宿主**必须报出诊断而非静默不触发**(RFC 0011 纪律 4:禁止 accepted-then-ignored)。能力查询走 `ctx.events.availability(name)`。 + +## 迁移映射:现有 14 个事件 + +| 现有名 | 新名 | mode | 可用性 | 备注 | +| ---------------------- | ------------------------ | ---------- | ------ | ----------------------------------------------------------------------------------------------------- | +| `PreToolUse` | `tools/pre-execute` | **decide** | both | 名称对齐;**mode 刻意分歧**(DSH 为 waterfall),见上 | +| `PostToolUse` | `tools/post-execute` | transform | both | 与 DSH 完全对齐 | +| `GenerateSystemPrompt` | `system-prompt/assemble` | transform | both | 与 DSH 完全对齐 | +| `Stop` | `agent/turn-stopping` | notify | both | 名称对齐;DSH 为 `serial`,我们用 `notify` + fail-fast 派发 | +| `StopFailure` | `agent/error` | notify | both | 对齐 | +| `SubagentStop` | `subagent/end` | notify | both | 对齐 | +| `SessionStart` | `agent/session-start` | notify | both | 对齐 | +| `SessionEnd` | `session/disposed` | notify | both | 对齐 | +| `UserPromptSubmit` | `agent/prompt-submit` | transform | both | **无 1:1 对应**。DSH 最近的 `agent/pre-step` 语义更宽(每步触发)。用自有名字但守同一风格,不假装对齐 | +| `PreCompact` | `compaction/pre` | transform | both | **DSH 无此事件**(它走 `ctx.compaction` 服务)。我们粒度更细,保留 | +| `Notification` | `agent/notification` | notify | both | 我们自有 | +| `TaskStart` | `task/started` | notify | both | 我们自有(适配器概念) | +| `TaskStop` | `task/stopped` | notify | both | 我们自有 | +| `StartTasks` | `task/batch-start` | notify | both | 我们自有 | + +## 新增:建议补的四个点位 + +这四个是与 DSH 对照后确认的高价值缺口。共同特征是它们都在**模型请求那一层**或**工具执行的环绕层**,我们当前完全没有对应物。 + +### `agent/request` — transform — 可用性 `bridge` + +DSH 描述:"Replace the frozen call configuration." + +出站模型请求的最后一道关。插件可改写 system、tools、参数,也可完整审计请求内容。 + +**这是 RFC 0011 纪律 6「model-visible ⟺ logged」的天然落点**——凡进入模型请求的内容都从这里过,可复现性与审计天然成立。 + +可用性受限的原因:我们不自己发模型请求,`native` 源下这一层在适配器 CLI 的进程里,除非上游暴露该点位。**这一条必须诚实标注,不能假装 both。** + +### `agent/request-error` — transform — 可用性 `bridge` + +DSH 描述:"Handle one failed model-request attempt before the loop retries or closes its step." + +单次模型请求失败后、重试前的处理。DSH 的 `llm-retry` 就是纯靠这一个事件实现的插件。我们当前的重试逻辑散在各适配器里,无法统一策略或让用户覆盖。 + +### `tools/execute` — transform — 可用性 `bridge` + +DSH 描述:"Around-dispatch waterfall for timeout, retry, or metrics." + +环绕整个 dispatch。超时、重试、metrics 用一个事件解决,不必用 pre + post 手工拼状态机。 + +### `tools/result` — notify — 可用性 `both` + +DSH 描述:"Observe the frozen, lossless-JSON final outcome." + +与 `tools/post-execute` 分开的价值:post-execute 可改写结果,result 是**冻结只读**的。审计类消费者拿不到修改权,不会误伤。 + +## 中等价值缺口(本期不做,记录待评估) + +| DSH 事件 | mode | 价值 | +| ----------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------- | +| `fs/write-intent` / `fs/edit-intent` | decide | 比工具粒度更细的单次文件写入决策。我们有 `packages/fs-authority-native`,需先查清是否已有等价机制未暴露 | +| `approval/request` | decide | 让插件参与审批**应答**,而非只能返回 `'ask'` 把球踢给用户 | +| `tools/change` / `skills/change` / `commands/change` / `system-prompt/change` | notify | 能力面变更通知,插件可感知工具集变化 | + +## 明确不跟的 + +`cordis/*`(自指运行时反射)、`workflow/*`、`goal/*`、`domain/changed`、`typert*`、`spill*`、`session/flush`、`agent/inbox/*` —— 对应子系统我们没有或形态不同。 + +## 事件定义的形式要求 + +每个事件的定义必须携带: + +- `name` —— `namespace/kebab-verb` +- `mode` —— `notify | transform | decide` +- `availability` —— `both | bridge | native:` +- `payload` —— 结构化 schema +- `result` —— `transform` 的返回契约 / `decide` 的判定格;`notify` 事件此字段为空 +- `summary` —— 一句话语义 + +这份定义是[RFC 0011 行动项 P0-2「生成式能力目录」](0011-plugin-extensibility-actions.md)的输入之一:事件表应由源码 AST 生成,`--check` 模式接入 CI,避免与实现漂移。DSH 的做法可参照(`scripts/gen-cordis-api.ts` + `verify-cordis-api`),但需注意其生成文档仍存在轻微漂移(`docs/subsystems/workflow.md` 引 `index.ts:157`,实测 168),生成 + 门禁能大幅降低漂移而非消除。 + +## 与 session log 的关系 + +统一事件流应同时喂三个消费者:插件、session log、UI 实时流(`apps/server/src/services/client-events.ts` 已有 `publishClientEvent` 基建)。 + +三者共用同一份事件定义,而非各自造一套。这是本次收敛的附带收益,也是纪律 6 落地的实际路径。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-migration.md b/.oo/rfcs/0012-hook-plugin-convergence-migration.md new file mode 100644 index 000000000..ab8abe72f --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-migration.md @@ -0,0 +1,141 @@ +# RFC 0012: 迁移与兼容 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +## 落地顺序 + +分五步,每步可独立评审与回滚。前两步无行为变更。 + +### 第 1 步:定事件词汇表(无行为变更) + +产出 `packages/types` 下的事件定义:名称、mode、availability、payload schema、判定格。仅新增类型与常量,不接线。 + +同时产出 `scripts/gen-plugin-api.ts` 的事件部分与 `--check` 门禁(RFC 0011 行动项 P0-2)。**先有生成器再有实现**,避免定义与实现从第一天就分叉。 + +### 第 2 步:`ctx.events` API(无行为变更) + +在 `PluginServerContext` 上实现 `define` / `on` / `dispatch` / `availability`,语义见[通用事件 API 设计](0012-hook-plugin-convergence-events-api.md)。此时尚无内置事件被 `define`,插件可用它做插件间通信。 + +### 第 3 步:上报器 + endpoint 解析 + +`oneworks-call-hook` 改为归一化上报;桌面走 workspace server,CLI 走自身。宿主 `define` 全部内置事件。 + +**此步开始行为变更**,需要: + +- 新旧双跑一段时间(旧链仍执行,新链只上报不生效),比对两侧判定结果 +- 回归用例覆盖 `builtin-permissions` 的等价性 + +### 第 4 步:权限收紧语义 + builtin 迁移 + +`tools/pre-execute` 切到 `decide`,宿主基线判定从 hook 插件形态改为运行时内置的同步判定。第三方插件的 `allow` 返回值失效(**必须报诊断,不能静默忽略** —— 纪律 4)。 + +### 第 5 步:旧入口下线 + +`/hooks` 导出保留一个 minor 版本做兼容(经垫片映射到新 API),随后下线。 + +## 现有 hook 插件的迁移 + +### 内置 + +`packages/hooks/src/builtin-permissions.ts` 是宿主自己的权限执行器。它不走"迁移"路径,而是**改写为运行时内置的同步基线判定**(见[运行时与裁决语义](0012-hook-plugin-convergence-runtime.md))。这是第 4 步的核心工作量。 + +### 第三方 / 一方插件 + +旧形态: + +```js +// /hooks +export default { + name: 'my-plugin', + async PreToolUse(ctx, input, next) { + if (isDangerous(input.toolName)) { + return { hookSpecificOutput: { permissionDecision: 'deny', ... } } + } + return next() + } +} +``` + +新形态: + +```js +// plugin.server.entry +export function activatePlugin(ctx) { + ctx.events.on('tools/pre-execute', (payload) => { + if (isDangerous(payload.toolName)) { + return { decision: 'deny', reason: '...' } + } + return undefined // 无意见 + }) +} +``` + +变化点: + +- 入口从 `/hooks` 移到 `plugin.server.entry`,与 server 插件同一形态 +- `decide` 不再有 `next()`,返回 `undefined` 表示无意见 +- ctx 从只有 `logger` 变成完整的 `PluginServerContext`(scope / options / pluginRoot / registerChannel / 常驻状态) +- **返回 `allow` 不再生效**,会得到一条诊断 + +### 兼容垫片 + +第 5 步之前,`/hooks` 导出由宿主的兼容层加载并映射到新 API。映射规则: + +| 旧 | 新 | +| -------------------------------- | ------------------------------------ | +| `PreToolUse` 返回 `deny` / `ask` | `tools/pre-execute` 的 `decide` 判定 | +| `PreToolUse` 返回 `allow` | 丢弃 + 诊断 | +| 其余 `transform` 类 | 同名新事件,`next()` 语义不变 | +| `continue: false` | 对应事件的终止语义 | + +垫片只保证**语义等价的子集**能跑,不保证全部。不能等价映射的必须报错而非静默降级。 + +## DSH 插件兼容垫片 + +目标形态:`@oneworks/plugin-dsh-compat`,让 DSH 的**纯监听型**插件在我们这里运行。 + +### 可行的部分 + +DSH 插件的典型形态: + +```ts +export const name = 'my-dsh-plugin' +export const inject = ['tools'] +export function apply(ctx: Context) { + ctx.on('tools/pre-execute', async (exec, next) => {/* ... */}) +} +``` + +垫片提供一个 shim `ctx`,把 `ctx.on(name, handler)` 转发到我们的 `ctx.events.on`。因为事件名对齐,大部分监听型插件的主体逻辑可以不改。 + +### 不可行的部分 + +必须在垫片文档里写清楚,避免"看起来能跑实际半残": + +| DSH 能力 | 垫片状态 | +| ------------------------------------------------------ | ----------------------------------------------- | +| `ctx.on('<对齐的事件名>')` | ✅ 可映射 | +| `ctx.logger` | ✅ 可映射 | +| `ctx.effect()` 生命周期 | ⚠️ 部分——映射到我们的 dispose,但无 fiber 状态机 | +| `inject` 服务依赖 | ⚠️ 仅当依赖的服务我们有对应物 | +| `ctx.llm` / `ctx.subagents` / `ctx.tools` 等注册型服务 | ❌ 我们没有注册型 seam(RFC 0011 P2) | +| `ctx.plugin()` 动态加载子插件 | ❌ 违反 RFC 0011 纪律 1,永不支持 | +| `tools/pre-execute` 返回 `allow` | ❌ 我们是 `decide` 单向收紧 | +| payload 形状(`ToolExecution` 等) | ⚠️ 需逐事件适配,非自动 | + +**垫片的价值判断**:它买到的是"概念可移植 + 迁移成本可控",不是 drop-in。是否值得实现取决于 DSH 生态里有多少纯监听型插件是我们想要的——这个应在实现前做一次抽样调查,而不是先建垫片再找用户。 + +### 反向:让我们的插件跑在 DSH + +不在本 RFC 范围。但事件名与 mode 对齐之后,反向垫片在理论上同样可行,可作为后续选项保留。 + +## 风险与回滚 + +| 风险 | 缓解 | +| -------------------------------------- | ------------------------------------------------------------------------------------- | +| 第 3 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切 `notify` 类事件,`decide` 类留在旧链 | +| `builtin-permissions` 迁移后判定不等价 | 回归用例先行;双跑期比对两侧判定,不一致即阻断 | +| 现有插件生态被打断 | 兼容垫片保留一个 minor 版本;下线前在 `/plugins` 详情页对使用旧入口的插件显示迁移提示 | +| 事件定义与实现漂移 | 第 1 步先建生成器与 CI 门禁,早于实现 | + +每一步都可独立回滚:第 1、2 步无行为变更;第 3 步双跑期可关闭新链;第 4 步可退回 hook 形态的 builtin;第 5 步是纯删除。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-runtime.md b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md new file mode 100644 index 000000000..48191645f --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md @@ -0,0 +1,115 @@ +# RFC 0012: 运行时与裁决语义 + +返回入口:[RFC 0012 总览](0012-hook-plugin-convergence.md) + +本章定义上报器、runtime endpoint 解析、权限裁决与顺序契约。 + +## 上报器 + +`oneworks-call-hook` 从"插件执行宿主"降级为"归一化上报器"。职责三条,不多不少: + +1. 读上游 CLI 传入的 hook 输入 +2. 归一化为[事件词汇表](0012-hook-plugin-convergence-events.md)定义的事件 +3. 上报到 runtime endpoint;若该事件是 `transform` / `decide`,等待回执并按上游协议格式写回 + +**上报器内不加载任何插件代码。** 这条是硬约束——一旦上报器开始加载插件,两套系统就会重新分叉。 + +### 常驻 worker 的新定位 + +原设计中常驻 worker 解决的是"原生 hook 被反复调用时,插件上下文重复加载的性能问题"。新模型下插件常驻在 runtime 里,一次加载、跨事件持有状态,该约束被更彻底地解决。 + +常驻 worker 仍保留,但职责收窄为**省去上报器自身的 Node 冷启动**。判断保留与否的依据应是实测:上报器变薄后冷启动成本可能已低于维护 worker 池的复杂度。**这是实现期的实测决策,不在本 RFC 预先拍板。** + +## Runtime endpoint 解析 + +上报器不关心"谁在听",只往解析出的 endpoint 报。endpoint 取决于谁在驱动这次任务: + +| 运行形态 | endpoint | +| ------------------ | ---------------- | +| 桌面 / Web | workspace server | +| `npx oneworks ...` | **CLI 进程自身** | + +CLI 的 `run` 命令本来就在自己进程内驱动任务,已有 `apps/cli/src/commands/run/runtime-event-sink.ts`、`permission-decision.ts`、`input-bridge.ts` 等基建;`resolveServerBaseUrl` + `daemon` 选项的模式也已存在(`apps/cli/src/commands/plugin-cli.ts:185`、`channel.ts:682`)。 + +### 为什么不需要 daemon + +**只要有 agent 在跑,驱动它的进程必然活着** —— 否则没人消费 agent 的输出。因此不存在"没有 server"的场景: + +- 无需为 hook 拉起 daemon +- 无需设计降级路径 +- 插件在桌面与 CLI 两种模式下看到的 ctx 完全一致 + +这一条是整个方案的承重墙。若未来出现"任务驱动方可以先于任务结束而退出"的形态(例如 fire-and-forget 后台任务),必须重新论证本节,而不是给上报器加降级分支。 + +## 权限裁决 + +### 分层 + +``` +宿主基线判定(同步、本地、必答) + ↓ 作为 decide 事件的初始值 +插件判定(各自独立、只能收紧、可超时) + ↓ 按判定格取 meet +最终判定 +``` + +**宿主内置权限判定是地基**:读权限镜像文件(现由 `packages/hooks/src/builtin-permissions.ts` 实现),同步本地、不依赖插件、不会超时。 + +**插件只能收紧**:宿主 allow + 插件 deny = deny;宿主 deny + 插件 allow = **仍然 deny**。 + +### 超时语义 + +`decide` 事件的插件监听器超时 = **该插件这次没有意见**,按已有判定走。 + +- 不是 fail-open —— 宿主基线仍然生效 +- 不是 fail-closed —— 慢插件不会拖垮 agent + +超时**必须产生一条可见诊断**,不得静默。反复超时的插件应在 `/plugins` 详情页可见,让用户能定位是哪个插件在拖慢。 + +### 为什么这样切 + +`PreToolUse` 在热路径上——每次工具调用都要跑。若采用链式裁决 + 超时兜底,就必须在"慢插件让 agent 拒绝一切"和"慢插件让权限系统失效"之间二选一,两个都是不可接受的失败模式。 + +把插件限制为单向收紧之后,这个二选一消失了。代价是插件不能用于"放宽权限"——但那本来就不该是第三方插件的能力。 + +这与 `toolUsePresentations` 的 `origin` 设计同源:**能力做加法,权限做减法**。 + +## 顺序契约 + +现状是数组顺序 + builtin 排第一(`packages/hooks/src/runtime.ts:81-89`)。收敛后必须显式化: + +| mode | 顺序语义 | +| ----------- | ----------------------------------------------------- | +| `notify` | 顺序无关,监听器互相独立 | +| `transform` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | +| `decide` | 顺序无关(判定合并可交换) | + +**宿主内置监听器占用保留的 priority 段,第三方插件无法插到它前面。** 这条替代当前"靠数组第一个位置"的隐式保证。 + +不采用 Cordis 的 `prepend` 布尔选项——它只能表达"最前",无法表达多个插件之间的相对顺序,且两个都传 `prepend` 时结果取决于注册顺序。 + +## 事件流的三个消费者 + +统一事件流同时喂: + +1. **插件** —— 经 `ctx.events.on` 订阅 +2. **session log** —— 落实 RFC 0011 纪律 6「model-visible ⟺ logged」 +3. **UI 实时流** —— `apps/server/src/services/client-events.ts` 的 `publishClientEvent` 已有 EventEmitter 基建 + +三者共用同一份事件定义。当前 hook 层对前端完全是黑盒,收敛后可在插件详情页与会话视图里看到实际发生了什么。 + +## 可见性与权限呈现 + +RFC 0011 行动项 P1 记录的问题在此一并解决: + +- 插件订阅的事件进 `/plugins` 详情页,与 contributions 并列展示 +- **订阅 `decide` 类事件应作为"该插件请求的权限"显式呈现给用户**,因为那意味着它能否决工具调用 +- 事件订阅进[生成式能力目录](0011-plugin-extensibility-actions.md)(P0-2) + +## 待实测确认 + +以下项目需在实现期用实测数据决定,本 RFC 不预设结论: + +1. **上报器变薄后是否仍需常驻 worker** —— 对比冷启动成本与 worker 池维护复杂度 +2. **`decide` 事件的超时预算** —— 需要 `tools/pre-execute` 端到端延迟基线;当前无实测数据 +3. **`builtin-permissions` 迁移后的等价性** —— 需要一组回归用例证明新旧判定结果一致 diff --git a/.oo/rfcs/0012-hook-plugin-convergence.md b/.oo/rfcs/0012-hook-plugin-convergence.md new file mode 100644 index 000000000..870e77fc4 --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence.md @@ -0,0 +1,76 @@ +# RFC 0012: Hook 与插件系统收敛 + +返回入口:[RFC 索引](../../rfc.md) + +Status: 设计草案,待评审\ +前置: [RFC 0011 插件扩展面盘点与边界](0011-plugin-extensibility.md)\ +对照上游: `deepseek-ai/deepseek-harness@99f6f02`\ +Reviewed: 2026-08-18 + +## 问题 + +`@oneworks/hooks` 与插件运行时目前是两套几乎零交集的系统。它们共用同一棵插件实例树(都经 `resolveConfiguredPluginInstances`),除此之外没有任何共享。 + +具体差距: + +1. **两个 ctx 不对等。** `HookContext` 只有 `{ logger }` 一个字段(`packages/hooks/src/context.ts:7-9`);`PluginServerContext` 有 scope / pluginRoot / workspaceFolder / projectHome / options / sessions / registerCommand / registerApi / registerLocalService / dispose / runtime.registerChannel。hook 侧能经工厂形态 `(config) => Partial` 拿到 options(`loader.ts:39-41`),但 scope、pluginRoot 与自己 server 端注册的一切都拿不到。 +2. **同一个插件包要写两套形状的代码。** hook 是 `/hooks` 导出 `Partial`;server 是 `plugin.server.entry` 导出 `activatePlugin(ctx)`。 +3. **没有跨侧通道。** `packages/hooks/src/` 全部源码中没有 `serverBaseUrl` / `runtimeEndpoint` 等任何指向宿主的东西。 +4. **生命周期语义不一致。** server 插件有 activate / dispose / watch reload;hook 侧契约上是无状态的每事件调用。 +5. **可见性不一致。** server / client 的 contributions 在 `/plugins` 详情页可见;运行时 hook 插件在 `plugin-entry-cache.ts:43-53` 无条件收进链,而它握有 `PreToolUse` 否决权。 + +## 为什么不能直接照抄 DSH + +DSH 没有独立的 hook 子系统——它的 `docs/cookbook/extension-cookbook.md` 把"Hook 系统"直接映射到监听 `agent/session-start`、`agent/pre-step`、`agent/request`、`tools/pre-execute`、`tools/post-execute`、`agent/turn-stopping` 这些 ctx 事件。所谓 hook 插件(`hooks-claude-code` / `hooks-codex`)只是读外部 hook 配置文件、桥接到这条内部总线上的普通插件。 + +**它能这么做是因为它自己就是 agent,hook 点在它自己的 loop 里,是进程内事件。** 我们是驱动 16 个外部 CLI 的宿主,`native` 源的 hook 点在那些 CLI 的进程里,由它们 spawn 我们的 `oneworks-call-hook`。这个进程位置不由我们决定。 + +可迁移的是**"一个插件只有一种心智模型"**这个结果,不是"进程内事件"这个实现。 + +## 方案 + +**hook 子进程降级为上报器,不再承载任何插件代码;插件在"驱动这次任务的那个进程"里消费统一事件流。** + +``` +适配器 CLI ──spawn──> oneworks 上报器(薄) + │ 归一化 + 上报(裁决型再等回执) + ▼ + 驱动这次任务的进程内的插件运行时 + │ + ┌───────────┼───────────┐ + ▼ ▼ ▼ + 插件消费 session log UI 实时流 +``` + +三点结论: + +**1. 不需要 daemon。** 上报器只往环境里给的 runtime endpoint 报,谁是那个 endpoint 取决于谁在驱动任务:桌面/Web 是 workspace server,`npx oneworks ...` 是 CLI 进程自己(`apps/cli/src/commands/run/` 已有 `runtime-event-sink.ts` / `permission-decision.ts`,本来就在进程内跑任务并处理权限决策)。 + +**只要有 agent 在跑,驱动它的进程必然活着**——否则没人消费 agent 的输出。因此不存在"没有 server"的场景,无需为此拉 daemon,也无需降级路径。插件在两种模式下看到的 ctx 完全一致。 + +**2. 常驻 worker 的职责收窄。** 原设计中常驻 worker 承担的是"原生 hook 反复调用时避免重复加载插件上下文"。新模型下上报器不跑插件代码,插件常驻在 runtime 里一次加载、跨事件持有状态——这个约束被更彻底地解决了。常驻 worker 仍可保留以省去上报器自身的 Node 冷启动,但职责从"承载执行上下文"降为"省一次进程启动"。 + +**3. 第三方插件对权限只有否决权。** 宿主内置权限判定是同步本地的(读权限镜像文件),不依赖插件、不会超时,是地基;插件只能在其上收紧。宿主 allow + 插件 deny = deny;宿主 deny + 插件 allow = **仍然 deny**。 + +于是"插件超时"= 该插件这次没有意见 = 按宿主判定走。既不是 fail-open(地基仍在),也不是 fail-closed(慢插件不会拖垮 agent)。这与 `toolUsePresentations` 的 `origin` 设计同源——**能力做加法,权限做减法**。 + +## 命名与迁移 + +采用 DSH 的 `namespace/kebab` 事件命名与 `emit | waterfall | serial | parallel` mode 词汇。理由有二:该约定本身更好(带命名空间、可扩展、不撞名),且顺带买到迁移友好。 + +**但要写清楚它买到的是什么。** DSH 插件是 Cordis 插件(`apply(ctx)` + `ctx.on(...)`),ctx 是完全不同的对象,payload 形状也不同。命名对齐买到的是"概念可移植 + 机械适配层可行",不是 drop-in。 + +目标形态:同时对齐**事件名 + mode 词汇 + 重叠事件的 payload 形状**,使 `@oneworks/plugin-dsh-compat` 垫片对**纯监听型插件**可行。详见[迁移与兼容](0012-hook-plugin-convergence-migration.md)。 + +## 章节 + +- [通用事件 API 设计](0012-hook-plugin-convergence-events-api.md) —— `ctx.events` 的三个 mode 与派发语义 +- [事件词汇表](0012-hook-plugin-convergence-events.md) —— 名称、mode、payload、按 source 的可用性分级 +- [运行时与裁决语义](0012-hook-plugin-convergence-runtime.md) —— 上报器、endpoint 解析、权限收紧、顺序契约 +- [迁移与兼容](0012-hook-plugin-convergence-migration.md) —— 落地顺序、旧入口下线、DSH 垫片 + +## 非目标 + +- 不开放注册型 seam(model provider / adapter provider)。那属于 RFC 0011 行动项的 P2,需要产品决策,与本 RFC 无关。 +- 不改变插件的分发、安装与卸载模型。 +- 不引入 Cordis 或任何 IoC 容器。本 RFC 只收敛事件面,不动插件装配模型。 diff --git a/rfc.md b/rfc.md index ca7b27251..8dd0a9cc4 100644 --- a/rfc.md +++ b/rfc.md @@ -25,6 +25,14 @@ - [边界与设计纪律](.oo/rfcs/0011-plugin-extensibility-boundaries.md) - [行动项与优先级](.oo/rfcs/0011-plugin-extensibility-actions.md) +## Hook 与插件系统收敛 RFC + +- [总览](.oo/rfcs/0012-hook-plugin-convergence.md) +- [通用事件 API 设计](.oo/rfcs/0012-hook-plugin-convergence-events-api.md) +- [事件词汇表](.oo/rfcs/0012-hook-plugin-convergence-events.md) +- [运行时与裁决语义](.oo/rfcs/0012-hook-plugin-convergence-runtime.md) +- [迁移与兼容](.oo/rfcs/0012-hook-plugin-convergence-migration.md) + ## 插件运行时 RFC - [目录结构、manifest 与共享契约](.oo/rfcs/plugin-runtime-layout-manifest.md) From ea1b8dc05e66435ce1ef13d69640142dccaf0f82 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 21:46:28 +0000 Subject: [PATCH 3/3] docs(rfc): keep all five Cordis modes, constrain at define time Revise RFC 0012's event API: instead of narrowing Cordis's five dispatch modes down to three, keep all of them under their original names and move the restrictions onto the event definition, where they can be checked mechanically at define time. Three earlier exclusions were wrong: - `emit` was dropped by over-generalising "hook events cross a process boundary" to "all events do". Plugin-to-plugin events live in one runtime, where synchronous dispatch is both valid and preferable. - `parallel` and `serial` were folded together on the premise that awaiting is the dispatcher's choice. That conflated two things: the real distinction is whether listeners can observe each other's side effects, which is a property of the event, not the call site. - `bail` was banned for a real hazard applied too broadly. First-responder resolution is legitimate; only permission adjudication is unsafe, and that already has `decide`. Constraints now: `emit` cannot be cross-process, and `security: true` events accept only `decide`. Keeping Cordis's names also restores full mode parity with DSH apart from `tools/pre-execute`, which is `security: true` and therefore `decide` on our side. Docs only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014uDzTTAD3QqpHS8SHgRWEo --- ...0012-hook-plugin-convergence-events-api.md | 107 ++++++++---------- .../0012-hook-plugin-convergence-events.md | 55 +++++---- .../0012-hook-plugin-convergence-migration.md | 4 +- .../0012-hook-plugin-convergence-runtime.md | 9 +- 4 files changed, 83 insertions(+), 92 deletions(-) diff --git a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md index 5c03bda74..20adedfbc 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-events-api.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -10,59 +10,60 @@ RFC 0011 纪律 1 的配套约定是"不新增第三种跨插件通信原语"。 | 现状 | 收敛后 | | ------------------------------------------------------- | ------------------------------------------------------ | -| `@oneworks/hooks` 的私有 koa 中间件链 | `ctx.events` 的 `transform` / `decide` mode | -| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `notify` mode | +| `@oneworks/hooks` 的私有 koa 中间件链 | `ctx.events` 的 `waterfall` / `decide` | +| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `emit` / `parallel` / `serial` | | `pluginApis.register/call` | **保留不动** —— 它是 1:1 有返回值的 RPC,不是事件 | | `extensionPoints.register/contribute/getContributions` | **保留不动** —— 它是结构化贡献 registry,不是 dispatch | 净效果是原语数量不变:hook 那套私有链被通用事件取代,`pluginApis` 与 `extensionPoints` 各司其职。 -## Cordis 的五个 mode,我们取三个 +## 六个 mode:Cordis 五个 + `decide` -Cordis `EventsService` 提供 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`)。逐条评估: +全部保留 Cordis 的 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`),命名不改,另加一个我们特有的 `decide`。 -| Cordis mode | 我们的结论 | -| ------------------------------ | ------------------------------------------------------------------------------------------ | -| `emit`(同步 fire-and-forget) | **不可用**。Cordis 单进程内同步派发;我们跨 client / server / 上报器三处,所有派发必须异步 | -| `parallel`(await 全部) | **并入 `notify`**。"派发方要不要等"是调用点的事,不该是事件定义的属性 | -| `serial`(顺序,无 `next()`) | **并入 `notify`**,用 dispatch 选项控制 fail-fast | -| `waterfall`(链式改写) | **保留**,改名 `transform` | -| `bail`(首个非空返回胜出) | **不采用**,用 `decide` 取代。理由见下 | +不砍 mode,改为**在事件定义上加约束**——约束可在 `define` 时机械校验,比削减词汇表更精确,也保住了与 DSH 的命名对齐。 -### 为什么不要 `bail` +| mode | 语义 | 监听器契约 | +| ----------- | ----------------------------------- | ------------------------------- | +| `emit` | 同步 fire-and-forget | `(payload) => void` | +| `parallel` | 并发启动,await 全部 | `(payload) => Promise` | +| `serial` | 顺序执行,后者可观察前者副作用 | `(payload) => Promise` | +| `bail` | 首个返回非 `undefined` 者胜出并短路 | `(payload) => R \| undefined` | +| `waterfall` | 链式改写,必须调 `next()` | `(payload, next) => Promise

` | +| `decide` | 单向收紧合并,顺序无关 | `(payload) => D \| undefined` | -`bail` 的语义是"第一个返回非空值的监听器胜出并短路"。在可信插件场景(Cordis / DSH 的前提是插件等同 shell 权限)这没问题;在 marketplace 分发场景下它是提权通道——**任何第三方插件都能抢占一个宿主关心的决策**,且抢占是静默的。 +`parallel` 与 `serial` 的区别不是"派发方等不等",而是**监听器之间能否观察到彼此的副作用**:serial 中第二个监听器跑在第一个完成之后,parallel 中两者交错。这是事件的语义属性,因此保留为独立 mode。 -替代方案 `decide` 把"只能收紧"编码进 dispatch 语义本身,而不是指望每个事件的实现自觉。 +## 约束表 -## 三个 mode - -### `notify` —— 通知 - -```ts -type NotifyListener

= (payload: P) => void | Promise +``` +availability: 'in-process' 仅同一 runtime 内派发 +availability: 'cross-process' 需经上报器跨进程(全部 hook 内置事件) +security: true 该事件的结果影响权限或安全边界 ``` -监听器互相独立,任一失败不影响同侪,不影响派发结果。派发方通过选项决定是否等待全部完成。 +| mode | `cross-process` | `security: true` | +| ----------- | --------------- | ---------------- | +| `emit` | ❌ 拒绝 | ❌ 拒绝 | +| `parallel` | ✅ | ❌ 拒绝 | +| `serial` | ✅ | ❌ 拒绝 | +| `bail` | ✅ | ❌ **拒绝** | +| `waterfall` | ✅ | ❌ 拒绝 | +| `decide` | ✅ | ✅ **唯一合法** | -用于:状态变更广播、审计、遥测、UI 更新。 +`define` 时校验,违反即失败(fail loud,纪律 4): -### `transform` —— 链式改写 +- `emit` + `cross-process` → 跨进程无法同步派发 +- 非 `decide` + `security: true` → 权限类事件只能用 `decide` +- `decide` 缺判定格 → 无法合并 -```ts -type TransformListener

= ( - payload: P, - next: (payload: P) => Promise

-) => Promise

-``` - -顺序执行,**必须调 `next()`**,不调即短路整条链。每个监听器可在 `await next()` 前后改写 payload。 +### 为什么权限类禁用 `bail` -这就是现有 hook chain 的语义,等价于 Cordis 的 `waterfall`。 +`bail` 是"首个返回非 `undefined` 者胜出并短路"。用于 resolver 类场景(谁能处理这个 URL、谁能解析这个文件类型)完全正当,且 `decide` 表达不了这种"首个响应者"语义。 -用于:system prompt 组装、上下文注入、请求改写。 +但用于权限裁决时它是提权通道:任何第三方插件都能抢占宿主关心的决策,且抢占静默。这是**用途问题不是 mode 问题**,所以约束打在 `security: true` 这个维度上,而不是砍掉 `bail`。 -### `decide` —— 单向收紧的裁决 +## `decide` —— 我们相对 Cordis 新增的一个 ```ts type DecideListener = ( @@ -76,7 +77,7 @@ type DecideListener = ( - **合并结果不可能比宿主基线更宽松。** 宿主判定是格的上界,插件只能向下拉。 - **超时 = 无意见。** 慢插件不会拖垮 agent,也不会静默放宽(基线仍在)。 -- **顺序无关。** 合并是可交换的,因此不存在"谁先注册谁赢"的隐式依赖。 +- **顺序无关。** 合并可交换,不存在"谁先注册谁赢"的隐式依赖。 事件定义必须声明判定格。以工具权限为例: @@ -84,11 +85,11 @@ type DecideListener = ( allow ⊐ ask ⊐ deny ``` -宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`(`allow` 在格中不低于 `deny`,取 meet 后不变)。 +宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`。 用于:权限裁决、内容策略、合规拦截。 -**`decide` 是本设计相对 Cordis 的主要改进**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。 +**这是本设计相对 Cordis 的唯一新增**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。DSH 把 `tools/pre-execute` 标为 `waterfall`(其插件等同 shell 权限,无妨),我们标为 `decide`——这是与 DSH 唯一的 mode 分歧。 ## API 形状 @@ -98,7 +99,7 @@ mode 声明在**事件定义**上,不在派发调用点。理由:定义方 // 定义(宿主或插件,事件 id 为 /) ctx.events.define({ name: 'before-save', - mode: 'transform', + mode: 'waterfall', payload: payloadSchema, result: resultSchema, summary: '保存前改写文档内容' @@ -122,20 +123,20 @@ ctx.events.availability('agent/request') // 'both' | 'bridge' | 'native:` +- `mode` —— `emit | parallel | serial | bail | waterfall | decide` +- `availability` —— 传输可达性(`in-process | cross-process`)与 source 分级(`both | bridge | native:`) +- `security` —— 该事件结果是否影响权限或安全边界 - `payload` —— 结构化 schema -- `result` —— `transform` 的返回契约 / `decide` 的判定格;`notify` 事件此字段为空 +- `result` —— `waterfall` / `bail` 的返回契约、`decide` 的判定格;`emit` / `parallel` / `serial` 此字段为空 - `summary` —— 一句话语义 这份定义是[RFC 0011 行动项 P0-2「生成式能力目录」](0011-plugin-extensibility-actions.md)的输入之一:事件表应由源码 AST 生成,`--check` 模式接入 CI,避免与实现漂移。DSH 的做法可参照(`scripts/gen-cordis-api.ts` + `verify-cordis-api`),但需注意其生成文档仍存在轻微漂移(`docs/subsystems/workflow.md` 引 `index.ts:157`,实测 168),生成 + 门禁能大幅降低漂移而非消除。 diff --git a/.oo/rfcs/0012-hook-plugin-convergence-migration.md b/.oo/rfcs/0012-hook-plugin-convergence-migration.md index ab8abe72f..98a095f41 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-migration.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-migration.md @@ -85,7 +85,7 @@ export function activatePlugin(ctx) { | -------------------------------- | ------------------------------------ | | `PreToolUse` 返回 `deny` / `ask` | `tools/pre-execute` 的 `decide` 判定 | | `PreToolUse` 返回 `allow` | 丢弃 + 诊断 | -| 其余 `transform` 类 | 同名新事件,`next()` 语义不变 | +| 其余 `waterfall` 类 | 同名新事件,`next()` 语义不变 | | `continue: false` | 对应事件的终止语义 | 垫片只保证**语义等价的子集**能跑,不保证全部。不能等价映射的必须报错而非静默降级。 @@ -133,7 +133,7 @@ export function apply(ctx: Context) { | 风险 | 缓解 | | -------------------------------------- | ------------------------------------------------------------------------------------- | -| 第 3 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切 `notify` 类事件,`decide` 类留在旧链 | +| 第 3 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切无返回契约的事件,`decide` 类留在旧链 | | `builtin-permissions` 迁移后判定不等价 | 回归用例先行;双跑期比对两侧判定,不一致即阻断 | | 现有插件生态被打断 | 兼容垫片保留一个 minor 版本;下线前在 `/plugins` 详情页对使用旧入口的插件显示迁移提示 | | 事件定义与实现漂移 | 第 1 步先建生成器与 CI 门禁,早于实现 | diff --git a/.oo/rfcs/0012-hook-plugin-convergence-runtime.md b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md index 48191645f..2c28e31a7 100644 --- a/.oo/rfcs/0012-hook-plugin-convergence-runtime.md +++ b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md @@ -10,7 +10,7 @@ 1. 读上游 CLI 传入的 hook 输入 2. 归一化为[事件词汇表](0012-hook-plugin-convergence-events.md)定义的事件 -3. 上报到 runtime endpoint;若该事件是 `transform` / `decide`,等待回执并按上游协议格式写回 +3. 上报到 runtime endpoint;若该事件有返回契约(`waterfall` / `bail` / `decide`),等待回执并按上游协议格式写回 **上报器内不加载任何插件代码。** 这条是硬约束——一旦上报器开始加载插件,两套系统就会重新分叉。 @@ -80,8 +80,11 @@ CLI 的 `run` 命令本来就在自己进程内驱动任务,已有 `apps/cli/s | mode | 顺序语义 | | ----------- | ----------------------------------------------------- | -| `notify` | 顺序无关,监听器互相独立 | -| `transform` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | +| `emit` | 同步派发,按 priority 升序 | +| `parallel` | 并发启动,顺序无关 | +| `serial` | 按 priority 升序,后者可观察前者副作用 | +| `bail` | 按 priority 升序,首个非 `undefined` 者短路 | +| `waterfall` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | | `decide` | 顺序无关(判定合并可交换) | **宿主内置监听器占用保留的 priority 段,第三方插件无法插到它前面。** 这条替代当前"靠数组第一个位置"的隐式保证。