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 侧的落点文件与关键类型——每个 落点都可点开核对。

pitether seam落点文件 · 关键类型
pi.on("tool_call")(block / 原地改 input)tools/pre-execute waterfallsrc/Tether.Hooks/HookPlugins.cs(pre 通道)· ToolPreDecision(src/Tether.Core.Contracts/ToolExecutionContracts.cs)
pi.on("tool_result")tools/post-execute waterfallcontext.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_startagent/pre-step enter + system-prompt 片段src/Tether.Core.AgentLoop/AgentPipelinePreStep.cs · src/Tether.Core.SystemPrompt/
before_provider_request / after_provider_responseagent/request / llm/stream waterfallsrc/Tether.Core.AgentLoop/AgentPipelineRequest.cs · src/Tether.Llm/LlmEvents.cs(llm/stream)
pi.registerToolcontext.RegisterToolsrc/Tether.Core.Tools/ToolRegistryContextExtensions.cs
pi.registerCommandICommandRegistrysrc/Tether.Interaction/Commands.cs(ICommandRegistry)
pi.registerProviderctx.llm 适配器src/Tether.Llm/ILlmClient.cs · src/Tether.Llm.Providers/
ctx.ui.confirm / select / inputapproval / ask-user seamsrc/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_compactcompaction provider seamsrc/Tether.Compaction/CompactionService.cs(ICompactionSummarizer + 行替换)
resources_discoverTether.Skill registrysrc/Tether.Skill/SkillRegistry.cs
user_bashctx.shell provider seamsrc/Tether.Shell/IShellExecutor.cs

逐条落点说明

  • tool_call → tools/pre-execute:pi 的 tool_call 钩子既能拦下工具(block),也能原地改写 input。tether 把它落在 tools/pre-execute waterfall 上:监听器先 next() 拿 baseline ToolPreDecision,再叠加自己的决策。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-step waterfall 里改写「已领取的消息」——AgentPipelinePreStep 用 detached attributed message 保证 跨 listener 传递安全。
  • before_agent_start → agent/pre-step enter + 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/stream waterfall(事件名常量在 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:用户直发命令经 IShellExecutor provider 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),与宿主共享同一进程、 同一权限。因此立场明确:

  • 进程内插件 = 完全信任。加载后它能做宿主能做的一切;本里程碑不承诺沙箱隔离插件代码。
  • 安全性由四条来源与完整性保证托底,而非运行时隔离:
    1. 来源可见——安装来源(path: / nuget:)与解析版本在安装时明确打印;
    2. 安装确认——交互式 y/N 确认(--yes 显式跳过;非 tty 且无 --yes 时稳定失败),确认摘要 列出 spec、版本、digest、贡献的 catalog names 与 replaces;
    3. 冻结 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;
    4. 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_IDpackageId 违反反向域名规则,或声明 catalog 的包 packageId 不以字母开头(durable identity)
INVALID_VERSIONversion 不是合法 SemVer 2.0
INVALID_PLUGIN_NAMEcatalog name 前缀或字符集违规
NAME_SHADOWS_BUILTIN外部 name 遮蔽同名内建行
PACKAGE_NAME_CONFLICT同名 catalog 项被不同 packageId 的既有外部条目占用
REPLACES_INVALIDreplaces 目标为空、非已存在内建行 id,或该目标已被 profile 中其他已安装包认领(并发安装时以 profile 写者租约内的复检为准)
DIGEST_MISMATCH清单声明 digest 与冻结内容计算值不符(frozen digest)
ABI_OUT_OF_RANGE宿主契约版本不落在清单声明的 ABI 范围内(exact generation)
ENTRY_MISSINGentry 相对路径不存在或非普通文件
KIND_UNSUPPORTEDkind 不是 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_call block/改写)。命令规则的 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 的 外部通知类扩展)。

下一步

在 GitHub 上编辑此页