diff --git a/.oo/rfcs/0011-plugin-extensibility-actions.md b/.oo/rfcs/0011-plugin-extensibility-actions.md new file mode 100644 index 00000000..e54f29b1 --- /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 00000000..f9294984 --- /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 00000000..ad998b6c --- /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`) + +14 个事件(`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` 有 14 个事件,含 `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 00000000..cb051fac --- /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 00000000..470c8c84 --- /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` 的 14 个事件,含 `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/.oo/rfcs/0012-hook-plugin-convergence-events-api.md b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md new file mode 100644 index 00000000..20adedfb --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -0,0 +1,147 @@ +# 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` 的 `waterfall` / `decide` | +| 插件间通知(当前不存在,只能借 `pluginApis.call` 假装) | `ctx.events` 的 `emit` / `parallel` / `serial` | +| `pluginApis.register/call` | **保留不动** —— 它是 1:1 有返回值的 RPC,不是事件 | +| `extensionPoints.register/contribute/getContributions` | **保留不动** —— 它是结构化贡献 registry,不是 dispatch | + +净效果是原语数量不变:hook 那套私有链被通用事件取代,`pluginApis` 与 `extensionPoints` 各司其职。 + +## 六个 mode:Cordis 五个 + `decide` + +全部保留 Cordis 的 `emit` / `parallel` / `serial` / `bail` / `waterfall`(`packages/core/src/events.ts:19-32`),命名不改,另加一个我们特有的 `decide`。 + +不砍 mode,改为**在事件定义上加约束**——约束可在 `define` 时机械校验,比削减词汇表更精确,也保住了与 DSH 的命名对齐。 + +| 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` | + +`parallel` 与 `serial` 的区别不是"派发方等不等",而是**监听器之间能否观察到彼此的副作用**:serial 中第二个监听器跑在第一个完成之后,parallel 中两者交错。这是事件的语义属性,因此保留为独立 mode。 + +## 约束表 + +``` +availability: 'in-process' 仅同一 runtime 内派发 +availability: 'cross-process' 需经上报器跨进程(全部 hook 内置事件) +security: true 该事件的结果影响权限或安全边界 +``` + +| mode | `cross-process` | `security: true` | +| ----------- | --------------- | ---------------- | +| `emit` | ❌ 拒绝 | ❌ 拒绝 | +| `parallel` | ✅ | ❌ 拒绝 | +| `serial` | ✅ | ❌ 拒绝 | +| `bail` | ✅ | ❌ **拒绝** | +| `waterfall` | ✅ | ❌ 拒绝 | +| `decide` | ✅ | ✅ **唯一合法** | + +`define` 时校验,违反即失败(fail loud,纪律 4): + +- `emit` + `cross-process` → 跨进程无法同步派发 +- 非 `decide` + `security: true` → 权限类事件只能用 `decide` +- `decide` 缺判定格 → 无法合并 + +### 为什么权限类禁用 `bail` + +`bail` 是"首个返回非 `undefined` 者胜出并短路"。用于 resolver 类场景(谁能处理这个 URL、谁能解析这个文件类型)完全正当,且 `decide` 表达不了这种"首个响应者"语义。 + +但用于权限裁决时它是提权通道:任何第三方插件都能抢占宿主关心的决策,且抢占静默。这是**用途问题不是 mode 问题**,所以约束打在 `security: true` 这个维度上,而不是砍掉 `bail`。 + +## `decide` —— 我们相对 Cordis 新增的一个 + +```ts +type DecideListener = ( + payload: P +) => D | undefined | Promise +``` + +**不是链式。** 所有监听器并行拿到同一份 payload,各自独立给出判定,宿主按事件定义的**收紧格**(meet)合并。返回 `undefined` 表示"无意见"。 + +关键性质: + +- **合并结果不可能比宿主基线更宽松。** 宿主判定是格的上界,插件只能向下拉。 +- **超时 = 无意见。** 慢插件不会拖垮 agent,也不会静默放宽(基线仍在)。 +- **顺序无关。** 合并可交换,不存在"谁先注册谁赢"的隐式依赖。 + +事件定义必须声明判定格。以工具权限为例: + +``` +allow ⊐ ask ⊐ deny +``` + +宿主给 `allow`、插件 A 给 `ask`、插件 B 无意见 → 结果 `ask`。宿主给 `deny`、插件给 `allow` → 结果仍是 `deny`。 + +用于:权限裁决、内容策略、合规拦截。 + +**这是本设计相对 Cordis 的唯一新增**,它让"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制。DSH 把 `tools/pre-execute` 标为 `waterfall`(其插件等同 shell 权限,无妨),我们标为 `decide`——这是与 DSH 唯一的 mode 分歧。 + +## API 形状 + +mode 声明在**事件定义**上,不在派发调用点。理由:定义方知道该事件如何派发,调用方不该能改;订阅方从定义即可知道自己的契约(要不要 `next`、能不能否决);且定义可被生成进能力目录。 + +```ts +// 定义(宿主或插件,事件 id 为 /) +ctx.events.define({ + name: 'before-save', + mode: 'waterfall', + 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()` 查询。 +- **`waterfall` 监听器不调 `next()` 即短路** —— 这是刻意保留 Cordis 的语义,但必须在文档中明写,且短路事件要进诊断(避免"某插件悄悄吃掉了整条链")。 +- **顺序契约用显式 priority**,不用 Cordis 的 `prepend` 布尔——它只能表达"最前",无法表达多个插件之间的相对顺序。逐 mode 的顺序语义见[运行时与裁决语义](0012-hook-plugin-convergence-runtime.md)。宿主内置监听器占用保留的 priority 段,第三方无法插到它前面。 + +## 与现有原语的边界 + +新人最容易混淆的是"什么时候用 events,什么时候用 pluginApis"。判据: + +| 场景 | 用什么 | +| ------------------------------------ | ----------------------------------------- | +| 我要**通知**别人发生了什么 | `events` + `emit` / `parallel` / `serial` | +| 我要让别人**改写**我的数据 | `events` + `waterfall` | +| 我要让别人**收紧**我的判定 | `events` + `decide` | +| 我要**调用**某个特定插件拿返回值 | `pluginApis.call` | +| 我要让别人**注册结构化贡献**供我读取 | `extensionPoints` | + +一句话:events 是一对多的派发,`pluginApis` 是一对一的调用,`extensionPoints` 是贡献登记。 + +## Hook 事件是内置事件集 + +收敛后,[事件词汇表](0012-hook-plugin-convergence-events.md)里的全部事件都是 `ctx.events` 的内置定义(由宿主 `define`,可用性按 source 分级)。插件订阅它们和订阅其他插件的事件走同一套 API,不存在"hook 插件"这个独立形态。 + +因为 mode 词汇与 DSH 一致,[事件词汇表](0012-hook-plugin-convergence-events.md)中的 mode 可直接对照 DSH 的 `@mode` 标注,唯一分歧是 `tools/pre-execute`(我们 `decide`,DSH `waterfall`)——该事件 `security: true`,按约束表只能用 `decide`。兼容垫片需显式处理这一处(见[迁移与兼容](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 00000000..16073cdf --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events.md @@ -0,0 +1,110 @@ +# 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:Cordis 的 `emit` / `parallel` / `serial` / `bail` / `waterfall` 全部保留(命名不改,与 DSH 对齐),另加我们特有的 `decide`。 + +约束不落在词汇表上,而落在事件定义上——`emit` 不可跨进程;`security: true` 的事件只能用 `decide`。`define` 时机械校验。 + +Mode 是事件定义的一等字段,插件作者不需要从名字推断,宿主据此决定如何派发与合并。 + +**与 DSH 唯一的 mode 分歧**:DSH 把 `tools/pre-execute` 标为 `waterfall`,我们标为 `decide`(该事件 `security: true`)。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),因该事件 `security: true` | +| `PostToolUse` | `tools/post-execute` | waterfall | both | 与 DSH 完全对齐 | +| `GenerateSystemPrompt` | `system-prompt/assemble` | waterfall | both | 与 DSH 完全对齐 | +| `Stop` | `agent/turn-stopping` | serial | both | 与 DSH 完全对齐 | +| `StopFailure` | `agent/error` | emit | both | 对齐 | +| `SubagentStop` | `subagent/end` | emit | both | 对齐 | +| `SessionStart` | `agent/session-start` | emit | both | 对齐 | +| `SessionEnd` | `session/disposed` | emit | both | 对齐 | +| `UserPromptSubmit` | `agent/prompt-submit` | waterfall | both | **无 1:1 对应**。DSH 最近的 `agent/pre-step` 语义更宽(每步触发)。用自有名字但守同一风格,不假装对齐 | +| `PreCompact` | `compaction/pre` | waterfall | both | **DSH 无此事件**(它走 `ctx.compaction` 服务)。我们粒度更细,保留 | +| `Notification` | `agent/notification` | emit | both | 我们自有 | +| `TaskStart` | `task/started` | emit | both | 我们自有(适配器概念) | +| `TaskStop` | `task/stopped` | emit | both | 我们自有 | +| `StartTasks` | `task/batch-start` | emit | both | 我们自有 | + +## 新增:建议补的四个点位 + +这四个是与 DSH 对照后确认的高价值缺口。共同特征是它们都在**模型请求那一层**或**工具执行的环绕层**,我们当前完全没有对应物。 + +### `agent/request` — waterfall — 可用性 `bridge` + +DSH 描述:"Replace the frozen call configuration." + +出站模型请求的最后一道关。插件可改写 system、tools、参数,也可完整审计请求内容。 + +**这是 RFC 0011 纪律 6「model-visible ⟺ logged」的天然落点**——凡进入模型请求的内容都从这里过,可复现性与审计天然成立。 + +可用性受限的原因:我们不自己发模型请求,`native` 源下这一层在适配器 CLI 的进程里,除非上游暴露该点位。**这一条必须诚实标注,不能假装 both。** + +### `agent/request-error` — waterfall — 可用性 `bridge` + +DSH 描述:"Handle one failed model-request attempt before the loop retries or closes its step." + +单次模型请求失败后、重试前的处理。DSH 的 `llm-retry` 就是纯靠这一个事件实现的插件。我们当前的重试逻辑散在各适配器里,无法统一策略或让用户覆盖。 + +### `tools/execute` — waterfall — 可用性 `bridge` + +DSH 描述:"Around-dispatch waterfall for timeout, retry, or metrics." + +环绕整个 dispatch。超时、重试、metrics 用一个事件解决,不必用 pre + post 手工拼状态机。 + +### `tools/result` — emit — 可用性 `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` | emit | 能力面变更通知,插件可感知工具集变化 | + +## 明确不跟的 + +`cordis/*`(自指运行时反射)、`workflow/*`、`goal/*`、`domain/changed`、`typert*`、`spill*`、`session/flush`、`agent/inbox/*` —— 对应子系统我们没有或形态不同。 + +## 事件定义的形式要求 + +每个事件的定义必须携带: + +- `name` —— `namespace/kebab-verb` +- `mode` —— `emit | parallel | serial | bail | waterfall | decide` +- `availability` —— 传输可达性(`in-process | cross-process`)与 source 分级(`both | bridge | native:`) +- `security` —— 该事件结果是否影响权限或安全边界 +- `payload` —— 结构化 schema +- `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),生成 + 门禁能大幅降低漂移而非消除。 + +## 与 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 00000000..98a095f4 --- /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` | 丢弃 + 诊断 | +| 其余 `waterfall` 类 | 同名新事件,`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 步引入的端到端延迟超预算 | 新旧双跑期采集实测数据;超预算则先只切无返回契约的事件,`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 00000000..2c28e31a --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-runtime.md @@ -0,0 +1,118 @@ +# 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;若该事件有返回契约(`waterfall` / `bail` / `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 | 顺序语义 | +| ----------- | ----------------------------------------------------- | +| `emit` | 同步派发,按 priority 升序 | +| `parallel` | 并发启动,顺序无关 | +| `serial` | 按 priority 升序,后者可观察前者副作用 | +| `bail` | 按 priority 升序,首个非 `undefined` 者短路 | +| `waterfall` | 按 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 00000000..870e77fc --- /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 4c029452..8dd0a9cc 100644 --- a/rfc.md +++ b/rfc.md @@ -17,6 +17,22 @@ - [运行时 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) + +## 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)