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..6e8a625f --- /dev/null +++ b/.oo/rfcs/0012-hook-plugin-convergence-events-api.md @@ -0,0 +1,157 @@ +# 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` 缺判定格 → 无法合并 + +### 为什么 `emit` 不可跨进程 + +**不是因为技术上做不到。** 同进程的 `worker_threads` 之间可以用 `Atomics.wait()` + `SharedArrayBuffer` + `receiveMessageOnPort()` 实现真同步阻塞(`synckit` 即此模式;Node 允许主线程 `Atomics.wait`,浏览器禁止)。真跨进程(`child_process`)则基本不可行——`SharedArrayBuffer` 需要共享内存映射,传不过 IPC 管道;剩下 `execSync` 每事件起进程、阻塞 fd 读不可移植,都不该用。 + +**真正的理由是语义冗余。** `emit` 的价值有三条:无 microtask 调度、派发点与监听器同栈使异常直接传播、顺序确定无交错。跨进程后三条全部失效——已有 IPC 往返使调度开销可忽略,栈本来就断了且异常必须序列化,顺序确定来自串行传输而非同步语义。 + +因此跨进程的"同步 `emit`"实质是一个**阻塞的通知**,而那已经有名字了:`serial`。保留两个名字只会让插件作者在等价选项间做无意义的选择。 + +该论证不依赖传输方式:即便未来把 hook worker 从 `child_process` 换成 `worker_threads`(为省冷启动,见[运行时与裁决语义](0012-hook-plugin-convergence-runtime.md)的待实测项),`Atomics.wait` 可用了,结论仍不变。 + +### 为什么权限类禁用 `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/.oo/rules/PLUGIN-SYSTEM.md b/.oo/rules/PLUGIN-SYSTEM.md new file mode 100644 index 00000000..d130e524 --- /dev/null +++ b/.oo/rules/PLUGIN-SYSTEM.md @@ -0,0 +1,32 @@ +--- +alwaysApply: false +description: 当任务涉及插件系统的统一模型(贡献/参与/提供)、manifest、解析装配、生命周期、事件、信任边界或分发时加载的插件系统入口。 +--- + +# 插件系统入口 + +详细规范已下沉到 [`plugin-system/README.md`](./plugin-system/README.md)。 + +先按任务继续阅读: + +- 总览与状态图:[`plugin-system/README.md`](./plugin-system/README.md) +- 插件包与 manifest:`plugin-system/manifest.md`(待写) +- 解析与装配:`plugin-system/resolution.md`(待写) +- 生命周期:`plugin-system/lifecycle.md`(待写) +- 贡献(UI、声明式渲染、extension points、资产):[`plugin-system/contribute.md`](./plugin-system/contribute.md) +- 参与(事件、mode、裁决):[`plugin-system/participate.md`](./plugin-system/participate.md) +- 提供(plugin APIs、commands、channels、注册型 seam):[`plugin-system/provide.md`](./plugin-system/provide.md) +- 信任与安全边界:`plugin-system/trust.md`(待写) +- 分发与可见性:`plugin-system/distribution.md`(待写) + +相邻主题: + +- hook 事件在各 adapter 的支持矩阵与 `canBlock` 语义:[`hooks/events.md`](./hooks/events.md) +- adapter 的 mock home、原生 skills、MCP:[`ADAPTERS.md`](./ADAPTERS.md) +- 配置加载与合并:[`CONFIG.md`](./CONFIG.md) + +设计论证与决策依据不在本规范内,见 RFC: + +- [RFC 0011 插件扩展面盘点与边界](../../rfc.md)(含七条设计纪律) +- [RFC 0012 Hook 与插件系统收敛](../../rfc.md) +- Plugin Runtime RFC(目录结构、server、client UI、落地计划) diff --git a/.oo/rules/plugin-system/README.md b/.oo/rules/plugin-system/README.md new file mode 100644 index 00000000..3a133ec1 --- /dev/null +++ b/.oo/rules/plugin-system/README.md @@ -0,0 +1,106 @@ +--- +alwaysApply: false +description: 插件系统规范总览:统一模型、能力矩阵、实现状态与分章导航。 +--- + +# 插件系统规范 + +本目录是插件系统的**规范**(这么定的),设计论证见 RFC 0011 / 0012(为什么这么定)。 + +## 统一模型 + +插件与宿主、与其他插件之间**只有三种关系**。任何插件能力都归属且只归属其中一种。 + +| 关系 | 插件做什么 | 谁决定结果 | 权限强度 | +| -------------------- | ------------------------------------------ | ---------------- | -------- | +| **贡献** Contribute | 交出一份数据,别人拿去渲染或使用 | 接收方 | 最弱 | +| **参与** Participate | 在别人的流程里被回调,可观察 / 改写 / 收紧 | 宿主按 mode 合并 | 中 | +| **提供** Provide | 挂出一个具名能力,别人来调用 | 插件自己 | 最强 | + +判据是**谁拥有结果**:贡献交出数据、接收方决定怎么用;参与在既定流程里插一脚、宿主决定怎么合并;提供是插件成为被依赖的一方、它自己说了算。 + +三者互斥且完备。**新能力必须先归类,归不进去说明模型有问题,需要先改模型而不是加第四类。** + +正交的两个维度: + +- **在哪儿跑**:client(浏览器/渲染进程)、server(常驻运行时)、上报器(`oneworks-call-hook` 子进程) +- **谁触发**:用户、agent、宿主、另一个插件 + +## 能力矩阵 + +三种关系 × 三个运行位置。空格即缺口。 + +| | client | server | 上报器 | +| -------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------- | +| **贡献** | slots / views / routes / themes / `toolUsePresentations` / `extensionContributions` ✅ | assets(skills / rules / specs / entities / mcp / hooks 目录)✅ | — | +| **参与** | ❌ 不存在 | 🚧 `ctx.events`(设计中) | ⚠️ 现状:hook 插件代码在这里跑 | +| **提供** | plugin APIs ✅ / commands ✅ | channels ⚠️ 只能自调 / `registerApi` ⚠️ 只能自调 | — | + +矩阵读出三个结论: + +1. **贡献这一行是健康的**,client 与 server 各司其职。 +2. **参与这一行位置错了。** 它应当在 server(常驻、有完整 ctx、能持有状态),现在却在上报器(每事件一次子进程、ctx 只有 `logger`)。RFC 0012 做的事用这个模型说就是一句话:**把"参与"从上报器搬到 server**。 +3. **提供这一行 server 侧是残的。** channels 与 `registerApi` 的 scope 由宿主绑死,server 插件只能调自己的,无法向其他插件提供能力。 + +## 四条不可协商的边界 + +1. **插件不能实例化其他插件。** 动态插件图由宿主经 overlay 注入,发生在配置解析层。 +2. **插件只能收紧权限,不能放宽。** 宿主基线判定是地基,插件判定取 meet。 +3. **能力不支持时必须 fail loud。** 禁止 accepted-then-ignored。 +4. **`scope` 是逻辑隔离,不是安全边界。** 真正的边界见 `trust.md`(待写)。 + +## 分章 + +章节按统一模型组织:三种关系各一章,其余是横切关注点。 + +| 章节 | 覆盖 | +| ------------------------------------ | ----------------------------------------------------------------------------- | +| [`contribute.md`](./contribute.md) | 贡献:UI slots、views、routes、themes、声明式渲染、extension points、资产目录 | +| [`participate.md`](./participate.md) | 参与:事件 mode、约束表、可用性分级、裁决语义 | +| [`provide.md`](./provide.md) | 提供:plugin APIs、commands、channels、注册型 seam | +| `manifest.md`(待写) | 插件包结构、manifest 字段、入口导出约定 | +| `resolution.md`(待写) | 发现来源、children、scope 分配、overlay、冲突处理 | +| `lifecycle.md`(待写) | activate / dispose / reload / watch、失败态 | +| `trust.md`(待写) | 信任模型、安全边界、什么不是保证 | +| `distribution.md`(待写) | marketplace、安装卸载、版本、可见性与诊断 | + +## 实现状态 + +规范同时标注**当前实现状态**。未实现的内容以 `设计中` / `缺失` 标注,**不得按"已有"编写插件或引用**。 + +| 能力 | 关系 | 状态 | 位置 | +| --------------------------------------- | ---- | -------------------- | -------------------------------------------------- | +| manifest / 包结构 / 双入口 | —— | ✅ | `packages/types/src/plugin.ts` | +| 解析、children、scope、环检测 | —— | ✅ | `packages/utils/src/plugin-resolver.ts` | +| 任务级 overlay | —— | ✅ | `PluginOverlayConfig` + `overlaySource` | +| client 生命周期 / watch reload | —— | ✅ | `apps/client/src/plugins/PluginProvider.tsx` | +| UI slots / views / routes / themes | 贡献 | ✅ | `apps/client/src/plugins/plugin-runtime.ts` | +| 声明式渲染 `toolUsePresentations` | 贡献 | ✅ | `apps/client/src/plugins/plugin-tool-use.ts` | +| extension points(等待语义、epoch) | 贡献 | ✅ | `apps/client/src/plugins/plugin-registry.ts` | +| 资产目录投影 | 贡献 | ✅ | `packages/workspace-assets/` | +| hook 事件(14 个,跨 adapter 统一) | 参与 | ⚠️ 位置错(在上报器) | `packages/hooks/` | +| `ctx.events` 通用事件 API | 参与 | 🚧 设计中 | RFC 0012 | +| plugin APIs(挂起队列、timeout) | 提供 | ✅ | `apps/client/src/plugins/plugin-registry.ts` | +| 跨 scope command 调用 | 提供 | ⚠️ 已实现但未文档化 | `plugin-registry.ts:405` | +| server channels / `registerApi` | 提供 | ⚠️ 只能自调 | `apps/server/src/services/plugins/runtime.ts:3519` | +| 注册型 seam(model / adapter provider) | 提供 | ❌ 缺失 | RFC 0011 行动项 P2 | +| marketplace 安装 / 卸载 / 账本 | —— | ✅ | `apps/server/src/services/plugins/marketplace*.ts` | +| ErrorBoundary | —— | ❌ 缺失 | RFC 0011 行动项 P0-3 | +| 生成式能力目录 | —— | ❌ 缺失 | RFC 0011 行动项 P0-2 | + +## 已知不一致 + +规范化过程中发现、尚未消解的实现与文档分歧: + +1. **跨 scope command 调用未文档化。** `executeCommand` 接受 `/` 并会 HTTP 打到目标 scope;`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。需确认是正式通道还是实现顺带。 +2. **"提供"关系在 server 侧不完整。** `invokeChannel` 的 scope 由宿主绑死(`runtime.ts:3519`)。RFC 0012 把"参与"收回 server 后,server 会成为主要执行端,该缺口必须一并解决。 +3. **可用性分级粒度不足。** RFC 0012 用 `both | bridge | native:` 三级,而 [`hooks/events.md`](../hooks/events.md) 的真实矩阵是逐 adapter 逐事件、且带 `canBlock` 维度。事件规范应以该矩阵为准。 +4. **同 scope 内 command id 撞名的处理未核实。** `runtime.ts:2802` 的冲突检查针对内置 route key。 + +## 编写规范时的约定 + +- 新能力先归类到三种关系之一,归不进去先改模型 +- 每章标注状态,不把设计当现状 +- 每条限制写清楚**为什么**,或链到 RFC 的对应论证 +- 矩阵类内容以代码或既有 rules 文档为准,不凭记忆写 +- 与 [`hooks/events.md`](../hooks/events.md) 等既有 rules 重叠时引用而非复制 diff --git a/.oo/rules/plugin-system/contribute.md b/.oo/rules/plugin-system/contribute.md new file mode 100644 index 00000000..da52a901 --- /dev/null +++ b/.oo/rules/plugin-system/contribute.md @@ -0,0 +1,103 @@ +--- +alwaysApply: false +description: 贡献关系:插件交出数据、由接收方渲染或使用的全部能力。 +--- + +# 贡献(Contribute) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件交出一份数据,接收方决定怎么用。** 单向,无返回值,权限强度最弱。 + +接收方可能是宿主(UI slots、资产目录)或另一个插件(extension points)。无论哪种,**插件都不控制最终呈现**。 + +## 为什么贡献是首选形态 + +同一个需求若能用贡献表达,就不该用参与或提供: + +- 接收方完全掌控渲染与使用,插件出错不影响宿主结构 +- 数据可被 schema 校验、可被 i18n / 主题自动处理、可进能力目录 +- 不需要信任插件的执行行为 + +`toolUsePresentations` 是这条原则的样板:**看似"必须自定义渲染"的需求,多数实际是"宿主的声明式格式不够用"**。遇到"插件要塞组件"的诉求,先问缺哪个 format。 + +## 向宿主贡献 + +### UI 贡献点 + +| 类型 | API | 说明 | +| ------ | -------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| slots | `ctx.slots.register(slot, contribution)` | `nav.items`、`nav.moreMenu`、`chat.header.actions`、`workbench.tabs`、`launcher.searchProviders` 等 | +| views | `ctx.views.register(viewId, { renderNode })` | React view;DOM view 为兼容路径 | +| routes | `ctx.routes.register({ id, title, viewId })` | 落在 `/plugins//` | +| themes | `ctx.themes.*` | 主题 token | + +规则: + +- 复用宿主 UI 必须走 `view.ui.*` 声明式组件,不得复制宿主组件 DOM +- 必须复用宿主 React 单例,不得 bundle 第二份 React +- 所有 DOM 事件、style、timer、subscription 必须在返回的 `dispose()` 里清理 + +### 声明式渲染描述 + +`toolUsePresentations` 让插件提交**结构化渲染指令**,宿主据此渲染任意工具调用(`apps/client/src/plugins/plugin-tool-use.ts`): + +```json +{ + "path": "steps", + "title": "Steps", + "format": "records", + "item": { + "titlePath": "node_id", + "subtitlePath": "op", + "metaPath": "context" + } +} +``` + +- 输入格式封闭:`inline | text | code | list | chips | records | json` +- 结果格式:`auto | text | code | json | markdown`,另有 `mode: auto | declared | hidden` 做渐进披露 +- **不允许可执行模板、任意 HTML 或插件私有 React renderer** + +权限设计值得复用:`origin` 默认只能接管**自己 scope 下**的工具(经 base64 编码的 `oneworks-` 命名空间反解校验);接管别家工具须显式 `origin: 'any'`,且匹配优先级更低(20/10 vs 40/30)。**表达力做加法,权限做减法。** + +### 资产目录 + +manifest 的 `assets` 声明目录,宿主投影进工作区:`apps` / `rules` / `skills` / `specs` / `entities` / `mcp` / `hooks`。 + +插件还可依赖外部 skill 文档,经 lockfile 的 `pluginSkills` 挂到插件实例名下,标记 `plugin-skill-dependency-lock`。 + +## 向其他插件贡献:extension points + +owner 开点、contributor 贡献、owner 自行读取渲染。 + +```js +// owner +ctx.extensionPoints.register({ id, title, contributionSchema }) +view.extensions.getContributions('') // React view 内读回 + +// contributor +ctx.extensionPoints.onAvailable('/', point => {/* 返回 cleanup */}) +ctx.extensionPoints.contribute('/', contribution) +``` + +规则: + +- 目标 id 单段视为当前 scope,两段为 `/`,超过两段报诊断 +- **注册贡献必须用 `onAvailable`,不得用 `has()` 做一次性判断** —— 激活顺序不保证。目标已存在时立即触发,不存在则挂起等待 +- `onAvailable` 回调返回的 cleanup 在目标扩展点卸载时由宿主执行 +- manifest 的静态 `extensionContributions` 同样经 `onAvailable` 装配,声明式贡献也不怕顺序 +- **owner 拿到的是数据记录,不是可执行对象**。要触发行为须经 contribution 携带的 `command`(那属于[提供](./provide.md)) + +## 不存在:组件级贡献 + +**无法把 React 组件贡献进别人的视图。** `view.extensions.getContributions()` 返回数据记录。 + +这是刻意的。若要开视图槽,四个前置条件(论证见 RFC 0011 纪律 2): + +1. **ErrorBoundary 先行** —— 现状 `apps/client/src/plugins/` 与 `components/plugins/` 下零个 `ErrorBoundary`,`PluginHost.tsx:275` 裸渲染。此项与视图槽无关,应独立先做 +2. **挂载权归宿主** —— owner 拿到的必须是宿主包好的不透明节点,否则 contributor 代码会跑在 owner 的 viewContext 里,`view.options.update()` 会写到 owner 头上 +3. **扩展点须显式声明接受视图**并携带布局约束,默认保持数据模式 +4. **顺序稳定可预期** —— 按 `order` 或 `pluginScope` 字典序,不能是 Map 插入顺序 + +**决策顺序永远是:先扩格式词汇表 → 再把声明式渲染推广到其他槽 → 视图槽只留给真正无法声明化的场景**(自由画布、图编辑器、地图)。 diff --git a/.oo/rules/plugin-system/participate.md b/.oo/rules/plugin-system/participate.md new file mode 100644 index 00000000..1a433453 --- /dev/null +++ b/.oo/rules/plugin-system/participate.md @@ -0,0 +1,110 @@ +--- +alwaysApply: false +description: 参与关系:插件在宿主流程里被回调,可观察、改写或收紧。 +--- + +# 参与(Participate) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件在别人的流程里被回调,宿主按 mode 合并结果。** 插件能影响流程,但不拥有结果。 + +## 现状:位置是错的 + +| | 应当 | 现状 | +| -------------- | -------------------------- | ----------------- | +| 插件代码跑在哪 | server 常驻运行时 | 上报器子进程 | +| 插件拿到的 ctx | 完整 `PluginServerContext` | 只有 `{ logger }` | +| 能否持有状态 | 能(常驻) | 契约上不能 | +| 入口 | `plugin.server.entry` | `/hooks` | + +RFC 0012 的全部工作用统一模型说就是一句话:**把"参与"从上报器搬到 server**。上报器降级为归一化上报,不再加载任何插件代码。 + +本章描述**目标形态**。当前实现见 `packages/hooks/`,迁移路径见 RFC 0012。 + +## 六个 mode + +沿用 Cordis 命名(与 DSH 对齐,便于插件迁移),另加我们特有的 `decide`。 + +| 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` 的区别不是"派发方等不等",而是**监听器之间能否观察到彼此的副作用**。这是事件的语义属性,因此是两个独立 mode。 + +## 约束表 + +约束打在**事件定义**上,`define` 时机械校验,违反即失败。 + +| mode | `cross-process` | `security: true` | +| ----------- | --------------- | ---------------- | +| `emit` | ❌ | ❌ | +| `parallel` | ✅ | ❌ | +| `serial` | ✅ | ❌ | +| `bail` | ✅ | ❌ | +| `waterfall` | ✅ | ❌ | +| `decide` | ✅ | ✅ 唯一合法 | + +**`emit` 不可跨进程的理由是语义冗余,不是技术做不到。** 跨进程后 `emit` 的三条价值(无调度开销、同栈异常传播、无交错)全部失效,它退化成"阻塞的通知"——而那已经叫 `serial`。该论证不依赖传输方式。 + +**`bail` 禁用于权限类事件**是用途约束不是 mode 约束。`bail` 用于 resolver 场景(谁能处理这个 URL)完全正当;用于权限裁决则是提权通道,任何插件都能静默抢占宿主的决策。 + +## `decide`:单向收紧 + +所有监听器并行拿到同一份 payload,各自独立判定,宿主按事件定义的**收紧格**取 meet。返回 `undefined` 表示无意见。 + +以工具权限为例,格为 `allow ⊐ ask ⊐ deny`: + +- 宿主 allow + 插件 deny = **deny** +- 宿主 deny + 插件 allow = **仍然 deny** +- 插件超时 = 无意见 = 按已有判定走 + +三条性质是推论:合并结果不可能比宿主基线宽松;超时既非 fail-open(基线仍在)也非 fail-closed(慢插件不拖垮 agent);顺序无关(合并可交换)。 + +**这把"能力做加法、权限做减法"从口头约定变成 dispatch 语义强制**,与[贡献](./contribute.md)里 `toolUsePresentations` 的 `origin` 设计同源。 + +超时**必须产生可见诊断**。反复超时的插件应在 `/plugins` 详情页可见。 + +## 顺序契约 + +| mode | 顺序语义 | +| ----------- | ----------------------------------------------------- | +| `emit` | 按 priority 升序 | +| `parallel` | 顺序无关 | +| `serial` | 按 priority 升序,后者可观察前者副作用 | +| `bail` | 按 priority 升序,首个非 `undefined` 者短路 | +| `waterfall` | 按 priority 升序;同 priority 按 scope 字典序稳定排序 | +| `decide` | 顺序无关 | + +**宿主内置监听器占用保留的 priority 段,第三方无法插到它前面。** 这条替代当前"靠数组第一个位置"的隐式保证(`packages/hooks/src/runtime.ts:81-89`)。 + +不采用 Cordis 的 `prepend` 布尔——它只能表达"最前",两个插件都传时退化成看注册顺序。 + +## 可用性分级 + +**并非所有事件在所有 adapter 下都可用。** 权威矩阵是 [`hooks/events.md`](../hooks/events.md):逐 adapter 逐事件标注 native / bridge / 不支持,并带 `canBlock: true|false`。 + +- `native` —— 上游 CLI 原生 hook 透传 +- `bridge` —— 由 `packages/hooks/src/bridge.ts` 从会话消息与工具事件合成 +- `canBlock: false` —— 该事件在该 adapter 下**只能观察,不能阻断** + +订阅一个当前 source 不支持的事件时,宿主**必须报诊断而非静默不触发**。 + +**不要在事件规范里另造一套粗粒度分级。** 以 `hooks/events.md` 的矩阵为准。 + +## 事件词汇表 + +事件名采用 `namespace/kebab-verb`,与 DSH 对齐以便插件迁移。现有 14 个事件的改名映射、以及与 DSH 对照后确认的四个缺口点位(`agent/request`、`agent/request-error`、`tools/execute`、`tools/result`),见 RFC 0012 的事件词汇表章节。 + +事件定义必须携带:`name`、`mode`、`availability`、`security`、`payload`、`result`、`summary`。这份定义应由源码 AST 生成并接入 CI 门禁(RFC 0011 行动项 P0-2),避免与实现漂移。 + +## 事件流的三个消费者 + +统一事件流同时喂:**插件**(`ctx.events.on`)、**session log**(落实 model-visible ⟺ logged)、**UI 实时流**(`apps/server/src/services/client-events.ts` 的 `publishClientEvent`)。 + +三者共用同一份事件定义,不各自造一套。 diff --git a/.oo/rules/plugin-system/provide.md b/.oo/rules/plugin-system/provide.md new file mode 100644 index 00000000..0f6a32a4 --- /dev/null +++ b/.oo/rules/plugin-system/provide.md @@ -0,0 +1,82 @@ +--- +alwaysApply: false +description: 提供关系:插件挂出具名能力供他方调用,含当前 server 侧缺口。 +--- + +# 提供(Provide) + +返回入口:[PLUGIN-SYSTEM.md](../PLUGIN-SYSTEM.md) + +**插件挂出一个具名能力,别人来调用,插件自己决定结果。** 三种关系里权限最强的一种——插件成为被依赖的一方。 + +因此提供类能力必须满足:调用方身份可见、契约可校验、超时可控、销毁可回收。 + +## Plugin APIs(client,✅) + +一对一、带 schema 的调用,是**跨插件调用的首选**。 + +```js +ctx.pluginApis.register({ id, title, inputSchema, outputSchema, handler }) +await ctx.pluginApis.call('/', input, { timeoutMs }) +``` + +规则: + +- handler 的 `meta` 携带 `callerScope` / `targetScope` / `apiId`,**提供方据此决定是否服务** +- 目标未注册时 `call` **挂起等待**而非报错,注册时排空(`drainPendingPluginApiCalls`) +- 支持 `timeoutMs` 与 `AbortSignal`;调用方插件卸载时其挂起的调用被 reject +- 重复注册同一 id 报 duplicate 诊断,后者不生效 + +四条性质(caller 身份、schema、超时、卸载回收)正是"提供"关系该有的护栏,新增提供型能力应对齐这套。 + +## Commands(client → server,⚠️ 跨 scope 未文档化) + +`ctx.commands.execute(commandId)` 的 scope 由宿主绑定,但 `commandId` 接受 `/`: + +``` +plugin-registry.ts:405 + const key = commandId.includes('/') ? commandId : scopedKey(scope, commandId) + // 本地 registry 未命中 → HTTP POST /api/plugins//commands/ +``` + +即**传入带 scope 的 id 即可调用其他插件的命令**。`.oo/docs/usage/plugins/ui-runtime.md:371` 只描述了同 scope 行为。 + +**澄清前新插件不应依赖这条路径**——它可能被收紧为同 scope 限定。跨插件调用请用 plugin APIs:有 schema、有超时、有 caller 身份,commands 三样都没有。 + +command 的正当用途是**被贡献引用**:contribution 携带 `command: '/'`,owner 渲染、用户点击时由宿主调用 contributor 的命令。那是宿主中介,不是插件直接互调。 + +## Server channels(⚠️ 只能自调) + +``` +runtime.ts:3519 + invokeChannel: (channelId, invocation) => + this.invokeRuntimeChannel(scope, channelId, invocation) +``` + +scope 由宿主绑死,插件传不进去,因此 **server 插件只能调用自己的 channel**。`registerApi` 的 proxy 同样锁在 `/api/plugins/:scope/*` 下。 + +### 这是真实缺口 + +**"提供"关系在 server 侧不完整:client 能跨插件,server 不能。** + +RFC 0012 把"参与"收回 server 之后,server 会成为主要执行端——届时"server 插件无法向其他插件提供能力"会从边角问题变成主要障碍。 + +**修法不是给 `invokeChannel` 开跨 scope 参数**——那会绕过 schema 与 caller 身份,把 channel 变成没有护栏的 RPC。正确方向是把 plugin APIs 那套契约(caller 身份、schema、超时、卸载回收)平移到 server 侧。 + +## 注册型 seam(❌ 缺失) + +插件提供一个**实现**并成为运行时的一部分,典型是 model provider、adapter provider。 + +当前完全没有:`packages/model-provider-catalog/src/catalog.ts` 是硬编码内置注册表,适配器是编译期内置(根 `package.json` devDependencies + 静态 import)。第三方要加只能改仓库。 + +**这不是遗漏,是传输形态决定的。** hook 是"每事件一次子进程往返",对拦截型契合、对注册型不成立——LLM adapter 要维持流式连接、跨多次调用持有状态。要开注册型 seam 必须落在常驻 runtime,不是扩事件表。 + +上游 DSH 提供了可行形态:`SubagentProvider` 的 `start()` 只负责"怎么起、怎么说话",真正的长连接与进程生命周期由宿主的 `ctx.subprocess` 托管。**插件提供协议适配,宿主拥有进程和生命周期。** + +若要开,最该先开的是 model provider(数据面而非控制面),且必须带凭证 seam——**插件拿 ref 不拿明文 key**。属于 RFC 0011 行动项 P2,需要产品决策。 + +## 不新增第四种原语 + +提供型通道固定为 plugin APIs / commands / channels,加上未来可能的注册型 seam。新需求应落到已有通道,或改走[贡献](./contribute.md)与[参与](./participate.md)。 + +`ctx.events` 不算新增——它取代 `@oneworks/hooks` 的私有中间件链,净原语数不变。 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)