插件项目与定制开发指南

在 Tether 中,插件项目就是一个普通工作区。Tether 摒弃了复杂的浏览器内 IDE,让插件开发回归标准工程实践:项目是一个带 .tether/plugin-project.json 标记的真实 Git 仓库。用户和项目内 AI 均直接在本地源码树中工作,AI 利用通用的文件读写与终端工具理解和修改代码,并通过专属的 plugin_project 工具执行编译、打包、本地版本分配、原位换名热更新安装与验证。

1. 插件项目目录结构

当用户在插件市场中点击「定制」或新建插件时,系统会在磁盘(默认为 ~/.tether/plugin-projects/<slug>/,可通过 settings 中的 plugin-market.projectsRoot 配置)生成标准插件项目:

<slug>/
├── .git/                         # Git 仓库,初始提交为上游镜像原版源码
├── .gitignore                    # 忽略 bin/、obj/、.tether/local/、.tether/reference/ 等
├── .tether/
│   ├── plugin-project.json       # 项目元数据标记文件(schema 1,纳入版本控制)
│   ├── local/                    # 本机指针与构建暂存(gitignored,自动刷新)
│   │   ├── host.props            # 记录 TetherHostDir、TetherSdkDir 与 TetherHostVersion
│   │   ├── client-tsconfig.json  # 前端 TypeScript 编译继承基座
│   │   ├── client-sdk.mjs        # 前端构建辅助脚本
│   │   └── build/<n>/            # 编译输出打包目录(保留最近 3 次构建)
│   ├── reference/<Asm>/          # 契约参考源码(只读参考,gitignored,不参与编译)
│   └── skills/tether-plugin-dev/ # 随项目分发的开发 Skill
│       └── SKILL.md              # 插件开发技能说明书(取自 SDK docs/plugin-dev.md)
├── Directory.Build.props         # MSBuild 引导:导入 local/host.props 与 SDK props
├── Directory.Build.targets       # MSBuild 引导:导入 SDK targets
├── AGENTS.md                     # 插件事实清单:组合行、依赖/提供服务、配置类型、前端 slot、硬约束
├── README.md                     # 项目使用与调试说明
├── <AssemblyName>.csproj         # C# 工程文件(基于 SDK,排除宿主原程序集引用)
├── **/*.cs                       # 插件 C# 源码
├── tether-plugin.json            # 插件包清单(包含 packageId、catalog、replaces 等)
└── client/                       # 可选:前端 TS/React 模块源码
    ├── package.json              # 前端包声明(devDependencies 由 SDK 固定)
    ├── tsconfig.json             # 继承 ../.tether/local/client-tsconfig.json
    ├── tsdown.config.ts          # 导出 tetherClientBuild 构建预设
    └── src/                      # 前端组件源码(导出 inject 与 apply)

[!NOTE]

  • 契约参考源码位置:在远端公开仓库中,契约源码统一保存在仓库根目录的 reference/<Asm>/(如 Cordis、Tether.Core.Contracts);当源码拉取到本地落地为插件项目时,存放于 .tether/reference/<Asm>/(仅供 AI 和开发者查阅契约接口,不参与工程编译)。
  • 每个插件最多一个本地项目:若磁盘上已存在对应插件的项目目录,再次点击「定制」或「获取源码」时,系统会返回 PROJECT_EXISTS 并直接在工作区中打开已有项目,不会重复创建或覆盖已有修改。

项目标记文件:.tether/plugin-project.json

标记文件是项目身份的权威依据(Schema 版本为 1,提交进 Git):

{
  "schema": 1,
  "marketId": "Tether.Terminal.Tools",
  "origin": "builtin",
  "packageId": "custom.terminal-tools",
  "displayName": "终端工具",
  "customizable": true,
  "notCustomizableReason": null,
  "source": {
    "repo": "https://github.com/eanzhao-os/tether-plugins",
    "ref": "tether-v0.21.0",
    "path": "plugins/terminal-tools",
    "digest": "sha256:...",
    "verified": true
  },
  "createdAt": "2026-09-22T12:00:00Z",
  "createdByHost": "0.21.0"
}
  • origin:取值为 builtin(内置插件定制)、extension(官方扩展定制)或 template(从模板新建);
  • customizable: false(只读研究项目):若条目不可定制,标记中 customizable 为 false 并记录 notCustomizableReason。此类项目为只读研究项目,禁止执行 install 或 uninstall;
  • 本机指针自愈机制:.tether/local/ 记录了当前宿主 DLL 与 SDK 的绝对路径。当宿主版本升级或目录移动后,调用 plugin_project status 会自动按当前运行的宿主重新生成指针,保证后续 dotnet build 始终对齐运行中的宿主环境。

2. 插件 SDK 架构

插件 SDK 随宿主一起分发,位于 <宿主目录>/sdk/plugin/(开发期主仓库位于 sdk/plugin/),作为 Tether.PluginPackages 的内容分发,版本与宿主保持严格一致。

C# MSBuild 支持 (Tether.Plugin.props / targets)

  • Tether.Plugin.props:
    • 设定 TargetFramework 为 net10.0,默认开启 Nullable 与 ImplicitUsings;
    • 默认排除 .tether/** 与 client/** 文件,避免误编译;
    • 自动链接 SDK 自带的 PluginEvents.cs 会话事件助手。
  • Tether.Plugin.targets:
    • 批量引用宿主 DLL:自动引用 $(TetherHostDir)*.dll(设置 Private=false),契约直接由运行中的宿主提供,不将 DLL 复制到输出目录;
    • <TetherPluginExcludeReference>:关键隔离属性。必须声明为被替换的内置程序集名(例如 <TetherPluginExcludeReference>Tether.Terminal.Tools</TetherPluginExcludeReference>)。SDK 会在编译引用解析阶段将其从引用列表中剥除,彻底避免编译器报 CS0433 类型重复冲突;
    • 清单复制:自动将 tether-plugin.json 复制到编译输出目录;
    • 前端驱动:当项目包含 client/package.json 时,在 C# 编译前自动触发 pnpm install(仅在缺 node_modules 时)与 pnpm run bundle,并将打包产物 client/lib/client.js 作为嵌入资源打包进程序集;嵌入资源的 LogicalName 由 <TetherClientResourceName> 指定(缺省为 <AssemblyName>.Client)。

前端 Client SDK (sdk/plugin/client/)

  • tsdown.preset.mjs:提供 tetherClientBuild({ id, entry, outDir? }) 预设,统一处理 Rollup/tsdown 打包规则;
  • externals.json:严格锁定外部导入白名单(包含 @deepseek-ai/cordis、react、react-dom 以及宿主已加载的 @deepseek-ai/dsh-client-* 平台模块);
  • versions.json:锁定 tsdown、TypeScript、React 等依赖版本,防止开发期依赖漂移;
  • 类型取舍:首版 SDK 真实修订时不分发 .d.ts 类型声明文件。前端打包只做纯语法转译与外部依赖擦除,运行时由宿主模块加载器(__ModuleLoader__)解析注入。

3. 插件模板与两种创建方式

SDK 自带两套标准项目模板(位于 sdk/plugin/templates/),支持离线开箱即用:

  1. backend(纯后端模板):
    • 适用于增加后台工具、事件监听、数据处理或外部服务接入;
    • 包含标准 Cordis.Plugin 实现类、DataAnnotations 校验的 Options 配置类、csproj 与 tether-plugin.json。
  2. fullstack(带前端全栈模板):
    • 适用于需要扩展 Web UI 界面(如侧栏按钮、面板、自定义对话框)的插件;
    • 在纯后端基础上增加 WebPlugin.cs(注册客户端模块)与 client/ TS 源码包;
    • 示例展示了在 sidebar.footer.action 扩展槽注入按钮并弹出原生 Dialog 的标准写法。

命令行创建 vs 市场新建插件

请注意两种新建方式的不同职责与生命周期:

  • 命令行 tether plugin create:

    # 创建纯后端模板代码骨架
    tether plugin create local.my-tool --template backend --out ./my-tool
    
    # 创建带前端的全栈模板代码骨架
    tether plugin create local.my-ui-tool --template fullstack --out ./my-ui-tool
    

    tether plugin create 只生成模板工程代码骨架,它不是一个插件项目:不包含 .tether/plugin-project.json 标记,不执行 git init,也不向宿主工作区注册表登记。用户可在其中手动 dotnet build -o <pkgdir>,并通过 tether plugin install path:<pkgdir> 安装。

  • 市场「+ 新建插件」(createFromTemplate): 在 Web 市场界面点击「+ 新建插件」则会生成完整的插件项目:实例化模板后,自动写入 .tether/plugin-project.json 标记(origin: "template")、复制开发 Skill 说明书、执行 git init 并完成初始提交、在宿主工作区注册表中登记为插件工作区(kind: plugin),并尽力下载契约参考源码。

4. plugin_project 工具与四大 Outcome

在插件项目工作区内,AI 可调用专属的 plugin_project 编排工具。该工具在非插件项目环境中对模型不可见。

支持动作与 CamelCase 返回结构

所有端点返回与错误载荷均遵循严格的 小驼峰(camelCase) 命名:

  1. status:查询项目元数据、上游来源与校验状态、当前安装运行状态以及本机工具链检测结果;同时自动刷新 .tether/local/ 中的本机指针:
    {
      "marketId": "Tether.Terminal.Tools",
      "origin": "builtin",
      "packageId": "custom.terminal-tools",
      "displayName": "终端工具",
      "customizable": true,
      "notCustomizableReason": null,
      "isReadOnlyResearchProject": false,
      "source": { "repo": "...", "ref": "...", "verified": true },
      "installation": { "status": "active", "version": "1.0.0-local.1" },
      "toolchain": {
        "dotnet": { "ready": true, "version": "10.0.100" },
        "node": { "ready": true, "version": "22.19.0" },
        "pnpm": { "ready": true, "version": "10.0.0" }
      },
      "projectDirectory": "/path/to/project"
    }
    
  2. build:在项目根目录执行 dotnet build(含前端构建),捕获并返回最多 50 条结构化诊断及末尾 16 KiB 日志(构建超时为 3 分钟):
    {
      "success": true,
      "exitCode": 0,
      "diagnostics": [
        { "severity": "warning", "path": "Tools.cs", "line": 42, "column": 10, "code": "CS0168", "message": "..." }
      ],
      "logTail": "Build succeeded..."
    }
    
  3. install:
    • 检查 danger-full-access 权限与工具链;
    • 动态分配下一个本地递增版本号(如 1.0.0-local.1);
    • 在 .tether/local/build/<n>/ 中执行隔离构建,并在输出中改写清单版本;
    • 打包并安全安装到当前运行 profile;
    • 触发组合原位换名热更新;
    • 核验运行状态并如实回报;
  4. uninstall:卸载当前项目的定制包,原位恢复被替换的内置组合行。

发生非插件项目、权限不足或项目忙碌等非预期错误时,返回结构化错误信息:

{
  "outcome": "failed",
  "errorCode": "PERMISSION_REQUIRED",
  "error": {
    "code": "PERMISSION_REQUIRED",
    "message": "会话预设不含 danger-full-access 权限,无法执行构建或安装。请在侧边栏或设置中切换到完全访问预设(danger-full-access)后重试。"
  }
}

四大 Outcome(执行结果)

install 与 uninstall 动作严格如实回报,返回以下四种结构化结果之一:

Outcome状态含义与系统行为责任与指引
applied组合热更新已确认成功:定制包所有行均为 active 状态,运行版本为目标版本,被替换的内置行已安全停用(卸载时则是内置行恢复 active)定制功能已立即生效;若返回包含 browserReload: true,须提醒用户刷新浏览器以加载新界面
restart-required定制包已正确写入 profile,但当前组合未开启热更新(hmr 行关闭)必须明确告知用户手动重启 Tether 宿主生效,AI 绝不擅自自动重启
rolled-back激活失败(如插件 ApplyAsync 阶段抛出异常、配置校验失败或缺失依赖服务),宿主已安全回滚至安装前的稳定状态(上一定制版本或内置版本)查看返回的异常信息与失败行列表,修复代码缺陷后重新安装
failed编译失败、清单准入不合格、权限不足或被保护规则拒绝;宿主运行状态和 profile 均未发生任何变动查看编译诊断或错误码,排查修复后重试

browserReload 行为与前端热重载限制

  • browserReload 置位条件:
    • 在 install 时,当且仅当编译出的前端文件内容摘要(client.js sha256)与安装前相比发生改变时,返回 browserReload: true;若前端内容未变或为纯后端插件,则为 false(或省略);
    • 在 uninstall 时,当且仅当被卸载的定制包包含前端模块(hasClient)时,返回 browserReload: true;纯后端插件卸载时不触发;
  • 前端热重载限制:目前前端模块动态热替换(#326)尚未实现。修改了前端代码安装成功后(返回 browserReload: true),必须刷新浏览器页面以加载更新后的前端组件。

并发与写锁保障

  • 项目级并发锁:plugin_project 内部通过并发字典(ActiveInstalls)锁定项目目录。同一项目同一时刻只允许一个 install 执行;并发调用将立即返回 outcome: "failed" 与 errorCode: "plugin-project-busy";
  • Profile 级写锁:安装与卸载通过 ProfileWriterLock.AcquireAsync 获取全局跨进程/跨协程写锁(profile.lock),确保对 profile YAML 与 lock 文件的安全独占写入。

5. 版本号分配与构建目录保留规则

为了避免同版本重新安装时的摘要冲突并保留版本可追溯性,plugin_project install 采用本地版本自动分配机制:

  • 版本格式:
    • 若 tether-plugin.json 中的原始版本号不含预发布段(如 1.0.0),分配格式为:1.0.0-local.<n>;
    • 若原始版本号已包含预发布段(如 0.9.0-alpha),分配格式为:0.9.0-alpha.local.<n>;
  • 序号计算:n 从 1 开始,根据当前 profile 中已安装的同一包历史记录自动递增(匹配历史版本号尾部的 (?:\.local|-local)\.(?<num>\d+)$ 取最大值 + 1);
  • 源码保护:项目根目录下的 tether-plugin.json 中的 version 绝不被修改。版本号仅在构建暂存区 .tether/local/build/<n>/ 打包时动态改写;
  • 构建输出目录保留(PruneBuildOutputs):每次安装在 .tether/local/build/<n>/ 建立独立目录进行编译打包;构建完成后,系统按构建序号倒序与时间倒序排列,包含当前构建在内共保留最近 3 次构建目录,其余更早的历史构建目录将被自动安全清理。

6. replaces 准入规则与原位换名语义

通过编写 tether-plugin.json 中的 replaces 字段,定制插件可无缝替换内置能力:

清单写法

  1. 字符串简写(推荐):
    "replaces": ["terminal-tools"]
    
    等价于:{ "name": "terminal-tools", "with": "<packageId>.terminal-tools" }。
  2. 显式对象写法(当插件类型名称与原名不一致时):
    "replaces": [
      { "name": "compaction", "with": "custom.my-compaction.advanced-compactor" }
    ]
    

替换准入规则(Admission Rules)

定制包在被安装进 profile 前,必须通过包准入检查(PackageAdmission):

  1. 目标行必须存在且不受保护:被替换的行必须在宿主 catalog 中存在;若目标行属于系统受保护集合(PluginProtection,如存储、组合管理等),准入直接拒绝;
  2. 同一内置行只能被一个包替换:同一 profile 中,一个内置组合行在同一时刻只能由一个定制包替换;若已有其他定制包替换了该行,后安装的包将准入失败。该检查在安装时会先后跑两次:取 profile 写者租约前基于快照快速失败,持锁后对重新加载的 profile 复跑权威判定——两个并发安装竞争同一 replaces 目标时至多一个成功,不会在 lock 里留下重复认领;
  3. 未挂载行容错:若定制包声明替换的内置行未在当前运行组合(profile composition)中挂载,系统只记录非致命警告(Warning),跳过该替换行的挂载,不阻断安装流程。

原位换名(In-place Rename)底层机制

替换不是简单粗暴的“删行增行”,而是基于组合行 ID 寻址的原位重命名:

  • 保留一切现有上下文:替换行继承内置行的原始行 ID、在组合拓扑中的精确相对位置、用户在 profile YAML 中已配置的 config 属性,以及用户的启用/停用状态;
  • 精准替换实现:仅将该行绑定的插件类型指向定制包中的对应插件;
  • 安全反向恢复:当卸载定制包或在市场点击「恢复原版」时,系统执行反向原位换名,将被替换的内置插件原样换回,用户已有的配置与状态完全保留。

7. 会话事件规范(#329)

若定制插件需要记录自定义会话事件,必须遵守结构化身份与只读容错规范:

结构化身份等价性

Tether 的 SessionEventCatalog 支持结构化事件等价性:只要定制副本的事件描述符具有相同的事件名(Name)、相同的 Payload 类型全名(含命名空间)以及相同的事件标志(RequiredOnRead 等),宿主即将其视为等价事件。定制插件可无缝读写原版插件记录的历史日志。

外部包新增事件必须声明为可选

[!CAUTION] 外部插件程序集(TetherPlugin.*)加载在独立的可回收 ALC 中。如果外部插件声明了 requiredOnRead: true(必须可读)的新事件,一旦用户卸载定制插件或恢复原版,内置宿主在打开包含该未知必读事件的会话日志时将会直接拒绝读取(Fail Loud)。

为了保护用户会话数据的长久可用性:

  • 外部插件新增事件一律必须声明为可选(requiredOnRead: false);
  • 使用 SDK 提供的助手方法:
    using Tether.PluginSdk;
    
    public static readonly SessionEventType<MyCustomPayload> MyEvent =
        PluginEvents.Optional<MyCustomPayload>("custom/my-event", MyEventJsonContext.Default.MyCustomPayload);
    
  • 宿主准入层会校验可回收 ALC 中的事件注册:若检测到外部包试图声明未知的必读(required)事件,将直接阻断并使安装 rolled-back。

8. 热更新边界与约束

  • 热更新生效条件:宿主组合的 hmr 行启用时(Web 宿主默认开启),定制包的安装与卸载完全支持进程内热应用;
  • 契约不可替换:契约程序集(Service Definition,如 Tether.Core.Contracts)永远由宿主进程加载,插件工程不能修改或替换契约接口;
  • 前端热重载限制:目前前端模块动态热替换尚未接通,修改了前端代码安装成功后(install 返回 browserReload: true),需要手动刷新浏览器页面;
  • 严禁自动重启:Tether 坚决遵循责任边界纪律,任何情况下绝不擅自自动重启宿主进程;当遇到需要重启的情况(Outcome 为 restart-required),由 AI 转述给用户,由用户自主决定何时重启。

9. 常见编译与运行时错误排查

错误信息 / 现象根因分析解决办法
CS0433: The type 'X' exists in both ...宿主自带的原程序集被 MSBuild 默认引用进来了,导致类型定义冲突检查 csproj 中的 <TetherPluginExcludeReference> 是否存在,确保其值等于原程序集名
编译成功但装上后行为没变 / 依旧加载原版定制程序集使用了保留前缀定制版程序集命名必须为 TetherPlugin.<Name>,严禁以 Tether. 或 Cordis. 开头,否则加载器会回退解析到宿主内部自带程序集
找不到 Tether 插件 SDK / TetherSdkDir 为空.tether/local/host.props 本机指针丢失或过期调用 plugin_project status 动作,它会根据当前运行宿主自动重新写入指针文件
卸载后内存未释放 / 插件 ALC 无法回收插件在 static 字段中持有了对象,或在配置类中调用了带有静态类型缓存的宿主验证器(如 Validator.TryValidateObject)遵守 Cordis 纪律:跨插件状态放进服务,配置校验采用逐特性求值,绝不在 static 字段缓存委托或持有宿主对象
NOT_CUSTOMIZABLE该插件为只读研究项目无法安装或卸载,可作为源码阅读与实验参考
PERMISSION_REQUIRED会话预设缺少 danger-full-access 权限在 Web 界面切换会话预设为完全访问(danger-full-access)
plugin-project-busy该项目已有另一个构建或安装正在进行中等待当前操作完成后重试,避免并发执行安装

10. 可定制判定的 6 条规则与原因码

发布脚本与运行时市场共用同一套纯函数式计算规则(CustomizabilityEvaluator)来判定一个程序集是否可定制:

  1. 规则 1:必须有可导出插件行 —— 程序集至少贡献一个由组合根语法提取的可导出条目。若未贡献任何可导出插件行,原因码为 no-plugin(不进入市场);
  2. 规则 2:不可撕裂共享类型 —— 除组合根(Tether.Bundles、Tether.Boot、apps/*)外,没有其他宿主程序集反向引用它。若被其他包引用,原因码为 shared-types(替换后会导致依赖方的服务类型身份断裂);
  3. 规则 3:核心行保护 —— 贡献的所有行均不在系统受保护集合(PluginProtection)中。若包含受保护行,原因码为 protected;
  4. 规则 4:无内部友元授权 —— 没有宿主程序集对其授予 InternalsVisibleTo。若有友元依赖,原因码为 internals;
  5. 规则 5:前端依赖白名单 —— 前端模块(若有)仅引用 SDK externals.json 白名单中的模块。若引用了宿主未导出的私有模块,原因码为 client-deps;
  6. 规则 6:发行索引与独立编译门 —— 发行版嵌入索引中有登记,且通过独立编译门验证。未登记原因码为 unpublished,独立编译失败原因码为 standalone-build-failed。

[!NOTE] 在官方仓库导出时,全量评测得到的 35 个可定制条目全部通过了独立编译门校验(standaloneBuild == "ok"),能够以独立工程形式直接开箱编译运行。

11. 独立工程导出与发布流程

可定制插件工程通过 tether plugin export 工具导出,并通过 scripts/publish-plugins.sh 脚本发布到公开仓库 eanzhao-os/tether-plugins:

1. 独立工程导出 (tether plugin export)

内省已构建宿主的 catalog,确定性提取所有带插件行的程序集(含不可定制条目,供只读研究):

  • 为每个工程生成独立的 csproj、清单文件、前端包、AGENTS.md(严格填充 15 个占位符)与 README.md;
  • 契约参考源码导出到公开仓库根目录下的 reference/<Asm>/;
  • 在公开仓库根目录生成 plugins.index.json、index.json、README.md(分类汇总表)与 NOTICE(MIT 声明);
  • 全量导出时自动将索引写回宿主源码的 build/plugins.index.json。

2. 发布脚本 (npm run plugins:publish)

  • 临时 Worktree 隔离:基于 origin/main 检出临时 detached worktree,避免本地未提交改动泄露;
  • 独立编译门(Standalone Build Gate):遍历导出的全部独立工程,使用刚构建的宿主与 SDK 执行单独编译(C# + 前端),清理编译产生的临时产物;
  • --strict 门禁:断言所有规则 1–5 判定为可定制的条目必须全部独立编译通过;严禁为了让 strict 通过而降级条目(诚信不变量);
  • --dry-run 选项:完整运行导出与编译门禁校验,生成报告,不需要目标仓库的 LICENSE,绝不向远端仓库推送;
  • 安全检查门禁:
    • LICENSE 检查:仅在向远端实际推送前检查,目标仓库缺少 LICENSE 时拒绝推送;
    • 凭据与密钥扫描:严格扫描导出目录中的 .env*、*.key、*.pem、*.p12 文件与私钥文本标记(BEGIN ... PRIVATE KEY),一旦命中立即 Fail-Closed 阻断发布;
  • 结构化报告:生成 publish-report.md 与 publish-report.json 并同步至 build/ 目录;
  • Git 提交与 Tag:提交信息格式为 publish <short-sha>;发行版本自动打带注释的 Git Tag:tether-v<hostVersion>;
  • 嵌入索引阻断门:设置环境变量 TETHER_REQUIRE_PLUGIN_INDEX=1 时,若 Release 构建未包含 build/plugins.index.json 将在 MSBuild 构建期强制阻断构建,确保发行二进制完整嵌入权威索引。

12. 随 SDK 分发的 Skill 说明

随插件 SDK 一同分发的说明文档包括:

  • sdk/plugin/docs/plugin-dev.md:插件开发的核心技术说明书(随项目落地为 .tether/skills/tether-plugin-dev/SKILL.md,由项目内 AI 自行阅读学习,涵盖 Cordis 插件生命周期、服务注入、工具注册与调试技巧);
  • sdk/plugin/docs/AGENTS.template.md:生成器按插件信息动态填充的 AGENTS.md 模板。

下一步

在 GitHub 上编辑此页