# 插件系统(harness 插件契约) > **教程入口**:[docs/plugins/README.md](./plugins/README.md) —— L0 第一个插件 → L1 工具命令 → L2 设置 → L3 UI 槽位 → L4 React 组件 → L5 自诊断 → L8 依赖互调 → L9 存储;视觉规范 [DESIGN.md](./plugins/DESIGN.md);脚手架 [packages/plugin-template](../packages/plugin-template/)。本文件是契约全集(参考手册)。 > > 状态:v0.8 进行中(M0–M3 已落地;M4 更新管道 / M6 模板与外置示例 / M7 pi 原生目录管控见 roadmap) > 契约版本:`HARNESS_CONTRACT_VERSION = 0.3.0`(`src/main/plugins/plugin-host.ts`;manifest.minContract 与之比对,不满足拒载。0.3.0 新增:依赖声明/服务互调/SQLite 存储) ## 两层能力模型 | | 内置插件 | 外置插件 | |---|---|---| | 位置 | 代码注册(`src/main/plugins/builtin/index.ts`),UI 组件编译进宿主 bundle | `userData/plugins//`(manifest.json + 页面资产 + main.mjs) | | UI 形态 | 宿主树 React 组件(`renderer/src/plugins/builtin-components.ts`,key=插件 id) | iframe 页 `plugin:///`(自带 React,物理隔离) | | main 侧 | 第一方工厂(不动态 import) | 动态 `import()`(cache-bust 重载) | | 宿主能力 | 全量(BrowserSlot/原生视图协调/window.api) | 仅 SDK 桥接 API(postMessage,白名单 invoke) | | 信任 | 随包分发,免签名 | 签名校验(M5 接入;当前未签名也可装载——TODO) | | 管理 | 同一份 manifest/生命周期/管理页(可禁用) | 同左 + zip 导入/在线更新(M4/M5) | 同 id 冲突:高 semver 胜、平级外置胜;**外置遮蔽"组件级"内置必须声明 `replacesNative: true`**(能力降级声明,防原生视图能力被页面级外置静默顶掉)。 ## manifest.json ```json { "id": "hotwords", // ^[a-z][a-z0-9-]*$ "name": "热点词", "version": "1.2.0", // semver(x.y.z) "minContract": "0.1.0", "description": "…", "icon": "Globe", // 侧栏/面板/胶囊图标:lucide 图标名(白名单见 DESIGN.md;kebab 亦可);兼容旧 svg 字符串;未知名回落拼图图标 "order": 10, // 侧栏排序(只在插件间;核心入口恒在前) "panel": true, // 在右侧 SidePanel 出现一个 tab "entry": { "page": "index.html", "main": "main.mjs", "renderer": "renderer.mjs" }, // 内置插件省略 entry "replacesNative": false, "dependencies": { "fetch-tools": "^1.0.0" }, // 必需依赖:id → semver 范围(* ^ ~ >= 精确);缺失/停用/版本不符 → 激活失败并提示 "optionalDependencies": { "embed-store": "~2.1.0" }, // 可选依赖:缺失只降级(requirePlugin 返回 undefined) "updateURL": "https://…/index.json" } ``` 外置插件源(`EXTERNAL_PLUGINS_ENABLED` 开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免):`userData/plugins//` > 项目 `.pi/extensions//` > 全局 `~/.pi/agent/extensions//`。渲染层模块文件**不要命名为 index.ts/index.js**——那是 pi 原生扩展的自动发现名,会被 pi CLI 双重加载。 ## 主进程插件契约(`@aso/plugin-sdk` 主入口) ```ts import { definePlugin } from '@aso/plugin-sdk' export default definePlugin((api) => { // pi 原生扩展 API:工具/事件/命令/消息(语义与 pi CLI 扩展一致) api.pi.registerTool(myTool) api.pi.on('agent_end', () => { /* … */ }) // harness UI 槽位(数据驱动) api.ui.setStatusItem('task', { text: '任务 3/5', tone: 'accent', openView: { view: 'hotwords', params: { id: 1 } } }) api.ui.registerToolbarButton({ id: 'gen', title: '生成标题', openView: { view: 'hotwords' } }) api.ui.openView('hotwords', { tab: 2 }) // 深链打开插件页 // 插件私有存储(SQLite,userData/plugin-data//data.db;API 全 async,宿主共享 worker 执行) await api.storage.declareSchema({ tables: [{ name: 'notes', description: '…', columns: [{ name: 'id', type: 'TEXT', description: '…' }] }] }) await api.storage.kv.set('lastQuery', 'xxx') // KV(宿主管理的 kv 表,JSON 编码) const rows = await api.storage.db.query('SELECT * FROM notes LIMIT 10') api.storage.dir() // 自由文件目录 // 跨插件互调(依赖声明即访问授权;拓扑波次保证依赖先激活) api.service('fetch', (url: string) => fetch(url).then((r) => r.text())) // 暴露服务 const deps = api.requirePlugin('fetch-tools') // 未声明依赖 → 抛错;可选未就绪 → undefined return () => { /* dispose:宿主在 session_shutdown / 禁用 / 重载时调用 */ } }) ``` 实现:harness 在 pi `extensionFactories` 挂**一个聚合 extension**,工厂内创建真实 `ExtensionAPI` 上下文后扇出给所有已启用插件——工具注册走 pi 原生路径(提示词注入、审批拦截、事件时序由 pi 管)。激活顺序 = **必需依赖图的拓扑波次**(依赖先、依赖者后,同波并行;环整环报错;异步工厂限时 10s)。 依赖与存储细则:见 [L8 依赖与互调](./plugins/10-dependencies.md)、[L9 存储](./plugins/11-storage.md)。 ## 插件页(iframe)契约(`@aso/plugin-sdk/client`) ```ts import { getHarnessClient, useHarnessTheme, useDeepLinkParams } from '@aso/plugin-sdk/client' const harness = getHarnessClient('hotwords') harness.openView('chat') // 深链返回聊天/其他插件 harness.setStatus('task', { text: '进行中' }) // 状态胶囊(写主进程单一事实源) harness.invoke('getState') // 白名单:getState / send / getSettings useDeepLinkParams('hotwords') // openView 携带的参数(含挂起重放) useHarnessTheme('hotwords') // { dark, accent }(深浅色/accent 变化推送) ``` - 通信:postMessage(页 → `window.parent`;宿主 → iframe.contentWindow) - 安全:宿主校验 `event.source === iframe.contentWindow` **且** `event.origin === 'plugin://'`;sandbox 无 `allow-top-navigation`;iframe 页**无** window.api 直达 - 主题连续性:插件页 ``(宿主 beautifului.css 的 token 表,深浅色由 `.dark` 类 + 下发的 accent 生效);建议插件自带 Tailwind 按同 token 编译 ## 渲染层模块契约(`@aso/plugin-sdk/renderer`) 外置插件以 React 组件形态进入宿主界面(与内置同槽位),不再是 iframe 物理隔离: ```jsonc // manifest.json { "id": "demo-ui", "panel": true, "entry": { "renderer": "renderer.mjs" } } ``` ```js // renderer.mjs —— 宿主渲染层动态 import(plugin:///renderer.mjs) 后调用默认导出 export default function mount(ctx) { ctx.registerView(MyViewComponent) // 整页视图(侧栏入口 → 主内容区) ctx.registerPanel(MyPanelComponent) // 面板 tab 内容(需 panel: true) } ``` - **单实例约束**:组件必须用 `ctx.react`(宿主 React,即 `window.__ASO_PLUGIN_HOST__.react`)创建;插件自带 React 副本会导致 hooks 全部失效 - JSX 工程构建配方(esbuild,react 别名到宿主实例的 shim): ```sh esbuild renderer.tsx --bundle --format=esm --outfile=renderer.mjs \ --jsx=automatic \ --alias:react=./shims/react.js --alias:react/jsx-runtime=./shims/jsx-runtime.js ``` ```js // shims/react.js —— 构建期别名目标 const R = window.__ASO_PLUGIN_HOST__.react export default R export const { useState, useEffect, useRef, useMemo, useCallback, createContext, useContext, Fragment, memo, forwardRef, createElement } = R ``` ```js // shims/jsx-runtime.js const J = window.__ASO_PLUGIN_HOST__.jsxRuntime export const { jsx, jsxs, Fragment } = J ``` - 装载时机与热重载:插件列表变化(扫描/启停/`plugins:reload`)即按 `?t=` cache-bust 重装;被禁用/移除的插件注册随之清除 - 门控:仅开发版装载(`import.meta.env.DEV` + 主进程 `EXTERNAL_PLUGINS_ENABLED`) - 性能护栏:读源与 import 各限时 8s,超时/失败由宿主槽位呈现统一等待/失败态(ErrorState + 重试,见 `plugins/renderer-host`);渲染期崩溃由 PluginErrorBoundary 兜底 ## 公共 UI 组件库与异步工具(`@aso/plugin-sdk/ui` · `withTimeout`/`withRetry`) - **`./ui` 子入口**:宿主视觉规范的代码化——`Button`/`Input`/`Select`/`Switch`/`Card`/`SettingRow`/`Badge` + 三态组件 `LoadingState`/`ErrorState`/`EmptyState`/`Spinner`。仅 renderer 组件槽位可用 (渲染在宿主 DOM,复用宿主样式表);组件目录与三态铁律见 [docs/plugins/07-ui-components.md](./plugins/07-ui-components.md) - **异步工具(主入口导出)**:`withTimeout(promise, ms, label?)`、`withRetry(fn, { retries, delayMs, timeoutMs, onRetry })`、 `TimeoutError`——插件内一切可能挂起的异步(网络/子进程/文件轮询)必须自带超时与重试 - **宿主兜底**:插件激活限时 10s(异步工厂超时/reject 即标记插件失败,不拖垮 runtime 重建); renderer 装载限时 8s - **机器关卡**:`pnpm plugin:check <插件目录>` 校验契约完整性、同步 IO、console.*、 React 单实例、hex 色值、三态使用等(error 级不通过即 exit 1),规则见 [docs/plugins/08-performance.md](./plugins/08-performance.md) ## 插件设置(声明式 schema,`api.ui.registerSettings`) 插件在 main.mjs 里声明配置表单,宿主负责渲染/持久化/回推——schema 是**纯数据**(不可含函数/React 元素): ```js // main.mjs api.ui.registerSettings({ title: '我的插件设置', items: [ { type: 'string', key: 'token', label: 'API Token', password: true }, { type: 'boolean', key: 'enabled', label: '启用增强', default: true }, { type: 'select', key: 'mode', label: '模式', options: [{ value: 'fast', label: '快速' }], default: 'fast' }, { type: 'path', key: 'dir', label: '数据目录', mode: 'directory' }, { type: 'image', key: 'logo', label: 'Logo' }, { type: 'button', key: 'test', label: '测试连接', variant: 'primary' } ] }) api.ui.getSettingValues() // 当前值(schema default 已合并) api.ui.onSettingsChange((v) => {}) // 用户修改设置后实时推送 api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮项 key) ``` - **标准控件**(`PluginSettingItem`,按 `type` 判别):`string`(含 password)/ `text` 多行 / `number` / `boolean`(switch)/ `select` / `multiSelect` / `slider` / `color` / `path`(file|directory)/ `image`(选择+缩略图)/ `button`(动作)/ `info` / `divider` - **持久化**:值存 `userData/aso-plugin-values.json`(按插件隔离);`getSettingValues` 合并 schema 默认值 - **一级入口**:有配置项(items 非空)的插件,在插件页行内出现「一级入口」开关;打开后设置页左侧「Agent 能力」组追加以插件名命名的导航项,点击进二级配置页。items 为空/未声明 → 无开关、无导航项 - 值变更经 `plugins:settingsValues` 广播:设置页与插件主进程两侧同步 ## UI 槽位总览(对位 pi 的 ctx.ui) | 槽位 | 机制 | 状态 | |---|---|---| | 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ | | 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ | | 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ | | 状态胶囊(B2+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、最大高度随内容自适应、行尾彩色数值与行内深链 | ✅ | | 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ | | 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 | | 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 | ## 交互基线 - 整页替换:点插件入口 → 主列(聊天)隐藏保活;TitleBar 常驻(显示插件名 + 窗口控制 + 胶囊) - 返回聊天:点会话/新会话/Ctrl+N(全局);Esc 只关弹窗、**不**切视图 - Ctrl+K / Ctrl+, 仅聊天视图;切回聊天自动重新吸底(ChatView `visible`) - 侧栏"会话"区头徽标:插件视图期间提示代理活动(待审批 > 出错 > 流式中) - 重载:主进程 `plugins:reload`(重扫 manifest + 重导入 main.mjs + 清内联 UI + 重建 runtime);渲染层收到列表变化后 iframe 换 src 整页重载(= pi `/reload` 等效) ## 安全清单 - `plugin://` 特权协议:路径解析后必须落在该插件目录内(防穿越);`peer://` 仅暴露白名单资产(tokens.css) - CSP:宿主页 `frame-src plugin:`(iframe)+ `script-src plugin:`(renderer 模块动态 import);.mjs/.js 强制 JS MIME - 外置装载为 **build 级开关**(`EXTERNAL_PLUGINS_ENABLED`:开发版开/用户版关,`ASO_EXTERNAL_PLUGINS=1` 豁免);外置 main.mjs 全权运行于主进程(与 pi 扩展同信任级)——**签名校验前,仅自行放置的插件目录可装载**(M5 后强制) - 外置 renderer 模块以渲染层特权运行(无 iframe 沙箱):同样受上述 build 开关门控;组件崩溃不越出 ErrorBoundary ## pi 原生目录(M7) 事实:pi resource-loader **默认自动加载** `~/.pi/agent/extensions/*.ts` 与 `/.pi/extensions/* /index.ts`(`noExtensions` 可关)——即本应用当前已在运行用户为 pi CLI 安装的扩展(逻辑部分;其 TUI UI 调用按 pi 非 TUI 模式语义降级)。 已落地:harness 把同一批目录当作**一等外置插件源**——目录内含 harness `manifest.json` 的子目录获得完整插件待遇(main.mjs 工厂 / entry.renderer React 组件 / entry.page iframe / 面板 tab / 管理页开关)。pi 只自动发现 index.ts/index.js,harness 约定入口为 main.mjs/renderer.mjs,因此**不会被 pi 双重加载**;纯 pi 扩展(index.ts)仍由 pi 原生装载、不参与 harness UI。项目级目录随工作区切换同步(`setProjectExtensionsDir`)。未做:项目目录的 project-trust 门控(与 pi 侧对齐)、设置页按源展示。 ## 开发工作流(外置插件,M6 模板落地后) ``` packages/plugin-template → npm i(workspace 引 @aso/plugin-sdk) 页面:vite build --watch → dist/ # 改即重编 宿主:开发者模式开 fs.watch → 自动 plugins:reload → iframe 换 src ``` **重载粒度**:`plugins:reload` 是宿主级动作——重扫全部插件目录 + 重导入 main.mjs + 清内联 UI + 重建运行时(所有插件短暂重激活)。没有单插件重载粒度(工具聚合在同一个 pi 运行时里,无法热替换单个);改了哪个插件都执行同一个重载。排障走 [docs/plugins/09-troubleshooting.md](./plugins/09-troubleshooting.md)。