pi 移植对照与插件作者指南
把 pi(deepseek-harness 的 ExtensionAPI 生态)里的扩展搬到 tether,需要一张
pi → Cordis/Tether seam 对照表,外加一条明确的「哪些搬不了」边界。本页把每条
映射固化到 tether 侧的落点文件与关键类型,再给出插件作者从 tether plugin create
到 install 的可执行上手路径。对齐上游基线 dsh 与 pi。
映射表:pi ExtensionAPI → tether seam
pi 用一个 ExtensionAPI(pi.on(...) 事件钩子 + pi.registerXxx(...) 注册面 + ctx.ui/fs/shell
能力面)把扩展挂进 harness。tether 不复制这个单一门面,而是把每种能力落到对应的 Cordis 事件
(waterfall / bail 语义)或 Service seam 上。下表逐条给出 tether 侧的落点文件与关键类型——每个
落点都可点开核对。
| pi | tether seam | 落点文件 · 关键类型 |
|---|---|---|
pi.on("tool_call")(block / 原地改 input) | tools/pre-execute waterfall | src/Tether.Hooks/HookPlugins.cs(pre 通道)· ToolPreDecision(src/Tether.Core.Contracts/ToolExecutionContracts.cs) |
pi.on("tool_result") | tools/post-execute waterfall | context.RegisterToolPostExecuteListener(src/Tether.Core.Tools/ToolRegistryContextExtensions.cs)· ToolPostDecision(src/Tether.Core.Contracts/ToolExecutionContracts.cs) |
pi.on("context") | agent/pre-step(改写已领取消息) | src/Tether.Core.AgentLoop/AgentPipelinePreStep.cs |
before_agent_start | agent/pre-step enter + system-prompt 片段 | src/Tether.Core.AgentLoop/AgentPipelinePreStep.cs · src/Tether.Core.SystemPrompt/ |
before_provider_request / after_provider_response | agent/request / llm/stream waterfall | src/Tether.Core.AgentLoop/AgentPipelineRequest.cs · src/Tether.Llm/LlmEvents.cs(llm/stream) |
pi.registerTool | context.RegisterTool | src/Tether.Core.Tools/ToolRegistryContextExtensions.cs |
pi.registerCommand | ICommandRegistry | src/Tether.Interaction/Commands.cs(ICommandRegistry) |
pi.registerProvider | ctx.llm 适配器 | src/Tether.Llm/ILlmClient.cs · src/Tether.Llm.Providers/ |
ctx.ui.confirm / select / input | approval / ask-user seam | src/Tether.Interaction/ApprovalService.cs · src/Tether.Interaction/UserQuestions.cs |
pi.appendEntry | 扩展 SessionEventCatalog(log-only) | src/Tether.Core.Contracts/SessionEventCatalog.cs(SessionEventType<T> + RegisterScoped) |
session_start / session_shutdown | 插件 apply / EffectScope 回收 | Cordis 纪律 1(注册即回收)——见下文作者路径 |
session_before_compact | compaction provider seam | src/Tether.Compaction/CompactionService.cs(ICompactionSummarizer + 行替换) |
resources_discover | Tether.Skill registry | src/Tether.Skill/SkillRegistry.cs |
user_bash | ctx.shell provider seam | src/Tether.Shell/IShellExecutor.cs |
逐条落点说明
tool_call→tools/pre-execute:pi 的tool_call钩子既能拦下工具(block),也能原地改写 input。tether 把它落在tools/pre-executewaterfall 上:监听器先next()拿 baselineToolPreDecision,再叠加自己的决策。Tether.Hooks的 pre 通道即此语义的现成消费者:hook 的block(exit 2)在该点位先提秩为deny再合并([block, ask]得 Deny 而非 Ask,多配 hook 只会更严), 且合并结果与内层 baseline 取更严格者——hook 的ask不能把内层(例如 guardrails 的路径 deny)的Deny降级为可批准的Ask。tool_result→tools/post-execute:结果后处理经context.RegisterToolPostExecuteListener注册,返回ToolPostDecision(可替换成功结果,但不得把失败改成成功)。context→agent/pre-step:pi 在组装上下文时改写消息;tether 在agent/pre-stepwaterfall 里改写「已领取的消息」——AgentPipelinePreStep用 detached attributed message 保证 跨 listener 传递安全。before_agent_start→agent/pre-stepenter + system-prompt:一次性的启动前逻辑落在agent/pre-step的 enter 时机;要往系统提示词塞片段,则经Tether.Core.SystemPrompt的 section 组装面(而非拼接 provider 请求)。before_provider_request/after_provider_response→agent/request/llm/stream:请求 前的坐标改写在agent/request(AgentPipelineRequest);响应流的逐块加工在llm/streamwaterfall(事件名常量在src/Tether.Llm/LlmEvents.cs)。registerTool→context.RegisterTool:工具注册返回IDisposable,随 scope 回收。registerCommand→ICommandRegistry:direct command 不伪装成模型 turn(见 审批与用户交互)。registerProvider→ctx.llm适配器:新模型服务商 = 实现ILlmClient(IChatClient之上) 并按Tether.Llm.Providers的适配器样式接入。ctx.ui.confirm/select/input→ approval / ask-user:确认走ApprovalService,自由文本/选择 走UserQuestions。这是 seam 而非直接 UI 调用——Web UI 只是它的一个 front end。appendEntry→ 扩展SessionEventCatalog:自定义事件用SessionEventType<T>声明,插件 apply 时catalog.RegisterScoped(...)注册。扩展事件一律 log-only:它们进 durable 会话日志供投影/回放,但不改运行时权威状态。session_start/session_shutdown→ apply /EffectScope:pi 的会话生命周期钩子在 tether 里就是插件本身的生命周期——apply 是「进」,scope LIFO 回收是「出」,无需单独钩子。session_before_compact→ compaction seam:替换点是注入的ICompactionSummarizer加整行 替换(组合里 disable 内建compaction行、insert 自己的行)。resources_discover→Tether.Skill:资源发现映射到 skill registry。user_bash→ctx.shell:用户直发命令经IShellExecutorprovider seam。
不可映射边界:pi-tui 渲染面
pi 有一层终端 UI 渲染面,它与 pi 自带的 TUI 强绑定:
ctx.ui.custom()——自定义交互式终端组件;renderCall/renderResult——工具调用/结果的自定义终端渲染;registerMessageRenderer——消息渲染器;registerShortcut——终端快捷键。
pi 自带的 64 个示例扩展里,约 30 个依赖这层渲染面。tether 是 Web UI,没有对等的终端渲染 契约,因此这类扩展不属于「移植」范畴:它们只能被重新设计为客户端模块(归 v0.13 与 #163),而不是往某个 seam 上映射。移植前先判断 一个 pi 扩展是否触及以上四个 API——触及即落入本边界。
不要反复尝试移植 TUI 扩展
本节存在的唯一目的,就是让后续移植者先判断边界再动手:凡是核心价值在于终端 渲染/交互的 pi 扩展,正确路径是把它当作一个 Web 客户端模块重新设计,而不是在 seam 表里 找一个并不存在的对应项。
信任模型:进程内插件 = 完全信任
本里程碑的树外插件以进程内程序集加载(可收集 AssemblyLoadContext),与宿主共享同一进程、
同一权限。因此立场明确:
- 进程内插件 = 完全信任。加载后它能做宿主能做的一切;本里程碑不承诺沙箱隔离插件代码。
- 安全性由四条来源与完整性保证托底,而非运行时隔离:
- 来源可见——安装来源(
path:/nuget:)与解析版本在安装时明确打印; - 安装确认——交互式
y/N确认(--yes显式跳过;非 tty 且无--yes时稳定失败),确认摘要 列出 spec、版本、digest、贡献的 catalog names 与replaces; - 冻结 digest——安装时用
LocalContentSnapshot计算整树 SHA-256(排除清单自身)写进 lock 的digest,同时把tether-plugin.json原始字节的 SHA-256 写进manifestDigest;清单若自带digest声明必须与计算值一致,否则 fail closed(DIGEST_MISMATCH)。加载期两者都逐字节复核, 安装后改写清单任一字段(entry / catalog / 已确认的 replaces / abi / bundle / capabilities)即LOCK_INCONSISTENT; - owner-only 落盘——安装树经
Tether.LocalStorage原子发布为 owner-only(POSIX 0700/0600、 Windows current-user-only DACL),一切失败 fail closed。
- 来源可见——安装来源(
这与上游 dsh、pi 的立场一致:树外插件是受信任的扩展点,不是被隔离的不可信代码。需要隔离 不可信代码时,那是沙箱能力(沙箱)的职责,不在插件分发的信任边界内。
作者上手路径:从 create 到 install
下面是一条可执行的最短闭环。命令族由 tether plugin(实现主体
src/Tether.PluginPackages/PluginCli.cs)提供。
1 · tether plugin create <packageId>
tether plugin create dev.example.sample --out ./sample
脚手架(src/Tether.PluginPackages/PluginScaffold.cs)生成一个可 dotnet build 的骨架:
- csproj——用绝对
HintPath引用宿主的Cordis.dll与Tether.Core.Contracts.dll(Private=false,契约来自宿主、不复制 DLL),并把tether-plugin.json作为Content复制到输出目录; tether-plugin.json——清单(字段速查见下);- 示例
IPlugin——一个继承Cordis.Plugin的示例类型 + 一个 DataAnnotations 校验的 config 类; - README——构建/打包/安装三步说明。
2 · 实现 IPlugin(继承 Cordis.Plugin)+ 注册即回收
把示例源码换成你自己的实现。核心纪律只有一条——注册即回收(Cordis 纪律 1):插件通过
context 注册的一切(事件订阅、服务、子作用域)都必须挂在当前 EffectScope 上,卸载时按 LIFO
逆序回收。
using Cordis;
public sealed class SamplePlugin : Plugin
{
protected override void Apply(Context context)
{
// ctx.On 返回的 IDisposable 只是「提前摘除」的手段;
// 真正的兜底是 scope 回收——用 context.Effect 把一切挂上去。
var subscription = context.On<string>("message", text => { /* ... */ });
context.Effect(subscription.Dispose);
// 服务、事件类型注册同理,全部经 context.Effect 挂到当前 scope。
}
}
为什么这条纪律不可省
插件以可收集 AssemblyLoadContext 加载,热卸/热替换要求没有任何活引用
指向旧程序集。任何一个没回收的事件处理器、静态缓存或定时器回调都会钉住旧 ALC 让卸载失败。
详见动态插件与脚本编译与Hot Reload Watcher。
3 · 配置(configType + DataAnnotations)
清单 catalog 项可声明 configType——一个无参构造的 config 类。Loader 用 YamlDotNet
(大小写不敏感)绑定 YAML config 段,再用 DataAnnotations 校验。带 config 与无 config 两个
构造函数都提供,Loader 按组合里是否有 config 段选择。
可收集程序集陷阱:不要在 config 代码里回调宿主 Validator/TypeDescriptor
外部包的程序集加载在可收集 ALC 里,热卸载依赖「没有任何活引用指向包内类型」。宿主侧一些
API 带有进程级静态缓存,会按 Type 建档,对包内类型建档即把整个 ALC 钉死在内存里,
Unload 后永不回收。已知危险面:
Validator.TryValidateObject/ValidationAttribute元数据缓存(DataAnnotations 的TypeDescriptor/ValidationStore)——config 实现IValidatableObject.Validate时尤其容易踩中: 直觉写法是在Validate里对成员对象回调Validator.TryValidateObject,这会把成员类型 (如每条规则类)钉进宿主缓存。正确做法是逐特性求值:attribute.GetValidationResult(value, new ValidationContext(instance) { MemberName = ... })(Required短路 + 全属性语义可在包内自行复刻,参考tether-plugin-guardrails的GuardrailsOptions.ValidateMember)。TypeDescriptor.GetProperties/GetConverter等描述符 API 的关联性缓存。
Loader 侧已对 collectible config 走无缓存的反射校验路径(CompositionLoader),上述约束只针对
你自己写的包内代码回调宿主缓存 API。判别与回归用例见
tests/Cordis.PluginLoading.Tests 的 Host_validator_process_cache_pins_collectible_alcs_documented_trap。
4 · 构建 + tether plugin pack(冻结 digest)
dotnet build -o ./sample-pkg # 产物(DLL + tether-plugin.json)落到包目录
tether plugin pack ./sample-pkg # 重算内容 digest 并回写清单的 digest 字段
pack 通过完整准入计算权威 digest(排除清单自身,不自引用),回写到 tether-plugin.json 的
digest 字段——这是 create → 迭代的闭环。
5 · tether plugin install path:<pkgdir> 本地验证
tether plugin install path:/abs/path/to/sample-pkg --profile local
安装流程:resolve → 准入校验 → 确认(打印 spec / 版本 / digest / catalog names / replaces)→
owner-only staging → snapshot digest 复核 → 原子发布到
<home>/profiles/<profile>/plugins/<packageId>/<version>/ → 原子更新 profile.yaml 的 plugins[]
(每条目 packageId / version / digest / manifestDigest / source / deps,前五项必需)。
同版本重装只有内容与清单字节都相同才是幂等成功,否则 DIGEST_MISMATCH——改清单要 bump 版本。
install 要求 profile.yaml 已存在
安装/移除都以 profile.yaml 的 plugins[] 为唯一 lock。目标 profile 缺
profile.yaml(含 composition / patchReload / plugins
三键)时 install 稳定失败(PROFILE_MISSING),先创建 profile 装配再安装。
[!TIP] 除了通过命令行
tether plugin install验证,用户还可以直接在 Web UI 左上角的 插件市场 中浏览并管理插件,或基于 插件项目 与项目内 AI 协作完成定制、本地编译与原位换名热更新安装。
装完后用 --dump-config(各宿主通用)核对组合行是否出现,行的 package 列会显示
packageId@version:
tether --dump-config --profile local # 打印组合行表 id / name / enabled / source / package,不挂载
tether-plugin.json 字段速查
| 字段 | 必需 | 语义 |
|---|---|---|
packageId | 是 | 反向域名:≥2 段、全小写、段内 [a-z0-9] 与内部 -,ordinal;声明 catalog 的包(assembly 恒是)必须字母开头,因为 catalog name 以它为前缀而插件名要求 ^[a-z] |
version | 是 | SemVer 2.0(允许 prerelease / build) |
kind | 是 | 本里程碑唯一合法值:assembly |
entry | 是 | 包内相对路径(POSIX / 分隔),必须存在且为普通文件 |
abi | 是 | { "cordis": "^0.1.0", "tether": "^0.1.0" },两个 key 均必需 |
catalog | 是 | ≥1 项;每项 name 必须以 packageId + "." 前缀;可选 type / configType / configSchema / description |
displayName | 否 | 插件在市场中展示的人类可读名称(如「终端工具」) |
description | 否 | 插件在市场中展示的功能描述 |
category | 否 | 插件所属市场分类(如「工具」、「界面」等) |
replaces | 否 | 被本包替换的内建 catalog name;支持字符串简写 "name"(等价于 { "name": N, "with": "<packageId>.<N>" })或对象 { "name": N, "with": M }。按组合行 ID 原位换名(保留原行 ID、拓扑位置、config 配置与启停状态) |
digest | 否 | 打包者冻结声明;存在则必须与计算值一致。权威 digest 永远是安装时计算值 |
icon | 否 | 包内相对路径的显示图标:SVG/PNG/JPEG/WebP(按扩展名判定,大小写不敏感)、≤256 KiB、realpath 必须留在包目录。绝对路径 / URI / 扩展名不在白名单在解析清单时按 MANIFEST_INVALID 拒绝;文件缺失或 realpath 失败(父段不是目录、链接环、无搜索权限)、.. 逃逸、符号链接逃逸、非普通文件、超限在准入时按 CONTENT_INVALID fail closed。读取后投成 data: URL 进 meta.icon |
未知字段一律拒绝(strict schema)。
locale/<lang>.json 显示元数据
包目录下 locale/ 是可选的本地化元数据目录:en.json 是锚(缺席即整个字典不生效),
同目录每个 <lang>.json 文件名必须是语言 id(en、zh、pt-BR……大小写不敏感去重),
内容是 { "meta": { "title": "...", "description": "..." } }——字段出现时必须非空。
插件管理面(pluginInventory/list、pluginManager/listPlugins/listBundles)把这些
读成 meta 字段下发:title 回退链是 meta.title → catalog description → 包名 →
完整模块名,description 无回退;坏 JSON/非法语言 id/坏图标只报 meta.error,不影响
包的管理动作。模型工具面(plugin_manager)不投影 meta。
准入失败分类表(PackageAdmissionCodes)
失败分类码定义在 src/Tether.Core.Contracts/PackageAdmissionCodes.cs,与 Wasm package-v1(#199)
共享同一套词汇(frozen digest / strict schema / durable identity / exact generation)。一切失败
fail closed——准入失败即不发布任何东西。
| Code | 触发 |
|---|---|
MANIFEST_INVALID | 清单未知字段 / 缺必需字段 / 结构错误(strict schema) |
INVALID_PACKAGE_ID | packageId 违反反向域名规则,或声明 catalog 的包 packageId 不以字母开头(durable identity) |
INVALID_VERSION | version 不是合法 SemVer 2.0 |
INVALID_PLUGIN_NAME | catalog name 前缀或字符集违规 |
NAME_SHADOWS_BUILTIN | 外部 name 遮蔽同名内建行 |
PACKAGE_NAME_CONFLICT | 同名 catalog 项被不同 packageId 的既有外部条目占用 |
REPLACES_INVALID | replaces 目标为空、非已存在内建行 id,或该目标已被 profile 中其他已安装包认领(并发安装时以 profile 写者租约内的复检为准) |
DIGEST_MISMATCH | 清单声明 digest 与冻结内容计算值不符(frozen digest) |
ABI_OUT_OF_RANGE | 宿主契约版本不落在清单声明的 ABI 范围内(exact generation) |
ENTRY_MISSING | entry 相对路径不存在或非普通文件 |
KIND_UNSUPPORTED | kind 不是 assembly |
LOCK_INCONSISTENT | 安装记录(lock)与磁盘内容不一致(含清单字节与 manifestDigest 不符) |
CONTENT_INVALID | 内容树超限 / 含链接 / 非法路径(LocalContentSnapshot 拒绝) |
HOT_INSTALL_DISABLED | 在 patchReload=startup 的 profile 上尝试热装 |
PROFILE_MISSING | 目标 profile 缺 profile.yaml 装配文件 |
所有失败经唯一异常载体 PackageAdmissionException(Code + PackageId + Detail)抛出。
三个真实例子
移植时可直接参照 extensions/ 下的真实包(每个都带 README 与测试):
extensions/tether-plugin-file-trigger——事件订阅 + 注入样板:监视文件系统变化,聚合后 经IAgent.InjectAsync(InboxMessage.FromPluginText(...))有界注入下一次模型请求(对应 pi 的context/ 注入类扩展)。extensions/tether-plugin-guardrails——pre-execute 策略层样板:对已知形状的工具在tools/pre-execute上做路径/命令规则判定,其结果作为内层 baseline 被Tether.Hooks以 「取更严格者」的方式合并——hook 的 ask 不会把 guardrails 的 deny 降级(对应 pi 的tool_callblock/改写)。命令规则的ask携带timeoutMs与fallbackDecision:只有真正超时才按 fallback 兜底,无 answerer 或非超时失败一律退回Ask由注册表 fail closed,fallback 为allow时也不放宽 baseline 已有的Ask。路径 glob 在大小写不敏感卷(Windows/macOS)上忽略大小写(.ENV绕不过**/.env),未闭合的[按字面匹配;不含通配符的字面规则只命中该路径本身,保护目录要写secrets/**。命令规则对命令串整体做文本 glob,不是安全边界——git push*不命中git push、/usr/bin/git push或sh -c 'git push',真正的边界是沙箱与路径规则。extensions/tether-plugin-notify——后台 subprocess 样板:turn 完成/审批待办/失败时经ISubprocessRuntime.Spawn触发平台通知,失败静默降级为 log-only、绝不影响 turn(对应 pi 的 外部通知类扩展)。