这是《DeepSeek Harness 权威指南 》系列第 14 篇,也是二开线(B 线)的第 7 篇。源码基线为 deepseek-harness @
47f9438(v0.1.0-rc.5)。B3 里你做了一个能在源码树里跑的插件项目;B5 里你看到了 loader 如何组装插件。这篇回答收尾问题:怎么把一个本地插件整理成符合官方规范、可以被任何 profile 装载的可交付包?

图:源码三件套(package.json 不变式、tsconfig 布局、named-export 模块契约)构建出 lib 产物;消费方通过进程内挂载、Loader+Include 组合或 bundle patch 行加载。
一、先跑起来:一个完整的包骨架
作者本机项目(rex-hugo/.tmp-research/dsh-b6-packaging,完整代码见 GitHub:rex-dhs-core/dsh-b6-packaging
)
dsh-b6-packaging/
├── dsh-hello-tool/
│ ├── package.json # 官方不变式形状(scope 换成 @rex-local)
│ ├── tsconfig.json # 镜像官方项目布局
│ └── src/index.ts # name/inject/Config/apply named-export 插件
├── verify-manifest.ts # constraints-lite:复刻 cookbook 不变式检查
├── load-composition.ts # 真实 Loader + Include + cordis.yml 组合
└── cordis.yml # 一行入口:按包名装载 dsh-hello-tool
运行:
node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-b6-packaging/verify-manifest.ts
node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-b6-packaging/load-composition.ts
1. 插件模块:named-export 契约
dsh-hello-tool/src/index.ts 是完整插件(无 I/O、确定性输出):
import { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import z from '@deepseek-ai/schemastery'
export const name = 'hello-tool'
export const inject = ['tools']
export interface Config {
/** Prefix for every generated greeting. */
greeting: string
}
export const Config: z<Config> = z.object({
greeting: z.string().required(),
})
export function apply(ctx: Context, config: Config): void {
ctx.tools.register(defineTool({
name: 'hello',
description: `Return "${config.greeting}, <name>!".`,
parameters: {
name: { type: 'string', required: true, description: 'Name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `${config.greeting}, ${args.name}!`
},
}))
}
这个形状不是作者发明的,它逐字对齐官方一方的包 dsh-tool-todo(packages/todo/tool-todo/src/index.ts:22-23, 41-43, 128):
export const name = 'tool-todo'
export const inject = ['tools']
...
export const Config: z<Config> = z.object({...})
...
export function apply(ctx: Context, config: Config): void {
模块头注释把原因说得很清楚(同文件 1-6 行):
Named exports preserve loader injection metadata.
即:loader 依赖命名导出 name / inject / Config / apply 来完成依赖注入;包不需要 default export。注意 Config 的 schema 用 @deepseek-ai/schemastery(default import),与官方一致。
2. manifest 不变式检查
verify-manifest.ts 是 constraints-lite:它复刻 docs/cookbook/adding-a-package.zh.md §1 的不变式清单,对 dsh-hello-tool/package.json 逐条断言。它不是官方 pnpm run constraints(本机 pnpm shim 不可用,无法运行仓库脚本),作者把它明确标注为复刻检查。运行输出:
PASS private: true
PASS type: module
PASS main: lib/index.js
PASS types: lib/types/index.d.ts
PASS exports["."].types
PASS exports["."].default
PASS files contains lib/index.js
PASS files contains lib/types d.ts
PASS every peer mirrored in devDependencies
PASS @deepseek-ai/cordis is peer + dev
PASS every dsh peer mirrored in devDependencies
PASS name is scoped lowercase
PASS no src in files
MANIFEST_CONSTRAINTS_LITE=ALL_PASS
3. 真实 Loader 组合
load-composition.ts 使用与官方 loader-composition 测试完全相同的装配方式(packages/llm/llm-deepseek/tests/loader-composition.spec.ts:84-107):
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@rex-local/dsh-hello-tool', HelloTool],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
const configPath = join(import.meta.dirname, 'cordis.yml')
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
运行输出:
=== loader composition ===
loadedEntries=["@rex-local/dsh-hello-tool"]
toolSchemas=[{"name":"hello","description":"Return \"Aloha, <name>!\".","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Name to greet"}},"required":["name"]}}]
helloRegistered=true
execute={"isError":false,"value":"Aloha, Rex!","content":[{"type":"text","text":"Aloha, Rex!"}]}
LOADER_COMPOSITION=PASS
关键点:装载是按包名(@rex-local/dsh-hello-tool)而不是文件路径。demo 用模块映射表替代解析(与官方测试一致,保持确定性);生产环境由 vendored cordis-plugin-loader 从 node_modules 解析——这部分代码在 cordis 生态(vendor/),不在 47f9438 树内,文章不替它背书。
二、模块契约:loader 依赖什么
回顾 B1-B5:你挂载的都是 class 默认导出(ctx.plugin(AgentLoop))。可交付包不同——官方规范要求 named exports:
| 导出 | 类型 | 作用 |
|---|---|---|
name | string | 插件名(loader 诊断、去重) |
inject | string[] | 声明服务依赖,由 loader 注入 |
Config | schemastery schema | 配置校验与描述 |
apply | (ctx, config) => void | 真正的装载逻辑 |
inject: ['tools'] 表示“装载前保证 ctx.tools 可用”——loader 在调用 apply 前按此解析依赖顺序(B3 提过 inject: ['tools'] 只是服务依赖,不是模型可见性)。你的插件缺 inject 时,ctx.tools 可能是 undefined,注册会静默失败;这也是 B1 里“先 ctx.plugin(ToolRuntime) 再注册”的原因。
三、manifest 不变式:官方清单逐条对照
cookbook(docs/cookbook/adding-a-package.zh.md §1)列出由 pnpm run constraints 强制的不变式。以官方 dsh-tool-todo 的真实 package.json(packages/todo/tool-todo/package.json)为模板,关键字段:
{
"name": "@deepseek-ai/dsh-tool-todo",
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
}
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"],
"peerDependencies": {
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
四组必须遵守的规则:
- 入口三件套:
main、types、exports["."]都指向lib/输出树,exports同时给types与default(NodeNext/Node16 消费方解析.d.ts); - 依赖镜像:
@deepseek-ai/cordis与每个 dsh peer 依赖必须同时出现在devDependencies(类型与测试需要);@deepseek-ai/schemastery放dependencies(运行时校验器); - files 白名单:只发
lib产物——不发src、声明映射、JS map 或陈旧的根声明文件; - 仓库内
private: true(发布用publishConfig.access控制)。
作者本机包的差异只有 scope(@rex-local/ 而非 @deepseek-ai/)与版本(0.0.0 而非根版本)——因为它不在仓库 workspace 内,不受版本同步约束;其余字段逐一满足不变式(见第一节 PASS 输出)。
四、tsconfig 项目布局
官方包的 tsconfig.json(packages/todo/tool-todo/tsconfig.json)与作者本机版本同形:
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/tools" }
]
}
extends根tsconfig.base.json;rootDir: src/outDir: lib/types把声明文件送进lib/types;references声明项目引用:vendor 的cosmokit/cordis+ 每个 dsh 依赖包;- 新包还要在
tsconfig.host.json(Host 包)或tsconfig.client.json(Client 包)的references中登记——普通包恰好属于一个 aggregate,绝不两个都加; - 源码内相对导入显式
.ts后缀(编译器输出时重写为.js,声明文件保留.ts后缀,NodeNext 消费方解析到同目录.d.ts)。
五、Loader 组合:cordis.yml 按包名装载
第一节的 demo 已经展示了官方测试的装载方式。这里把它放进上下文:官方 bundle 的每一个入口都是一行 id + name + config(packages/bundle/base/cordis.patch.yml:15-25):
- insert:
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
name 可以是包名(从 node_modules 解析),也可以是文件路径(B3 的 --patch ./scratch-plugin/cordis.yml)。Loader 做的事就是:解析每行 → internal.import(name) 拿到模块 → 按模块契约(name/inject/Config/apply)装载。
六、bundle patch 的分层组装
cordis.patch.yml 是“按行 id 的补丁”,不是合并。头注释(packages/bundle/base/cordis.patch.yml:1-13)定义了全部规则:
A patch replaces the targeted row's whole `config` rather than merging into
it, so a row whose value differs by mode does NOT live here ...
Row order carries no load semantics (activation is service-availability
driven); the grouping is for readers.
四层来源,后写者胜:
| 层 | 内容 | 覆盖能力 |
|---|---|---|
| base patch | 空 profile 根上的一次 insert,所有 mode 共享 | 插入 |
| mode patch | web / cli / headless 各自不同的行 | 按 id 重写 |
| 用户 profile | $DSH_HOME/cordis.patch.yml | 按 id 重写 / disabled |
--patch overlay | 命令行临时叠加(含 B3 的路径 patch) | 按 id 重写 / disabled |

图:同一行 id 只出现在“base + 一个 mode + 用户”三层;行字段 = id + name + config? + disabled?,支持 !!js 表达式。
两个值得注意的细节:
disabled支持!!js表达式:disabled: !!js process.platform === 'win32'让 pwsh-sandbox 只在 Windows 装载、bash-sandbox 只在非 Windows 装载(cordis.patch.yml:178-186)——平台分支在配置层完成;- config 也是
!!js可求值的:approval 的 policy 按环境变量决定(cordis.patch.yml:188-191),配置不再是静态文本。
七、验证与发布:官方命令与诚实边界
官方清单(cookbook §5)要求维护者按序执行:
pnpm install # registers the workspace
pnpm run doc-sync
pnpm run constraints && pnpm run typecheck && pnpm run lint
pnpm run build && pnpm run hygiene
以及行为专项测试与覆盖率门槛(仓库测试政策)。作者本机没有运行以上任何一条:本机 pnpm/Corepack shim 在进入 harness loader 前就失败(B3 已记录)。文章能实证的只有:
- manifest 不变式的复刻检查(constraints-lite,13 项全 PASS);
- 真实 Loader+Include 组合装载与执行(LOADER_COMPOSITION=PASS);
- 模块契约与官方包逐字对齐(tool-todo 源码比对)。
npm 发布(publishConfig.access: public + npm publish)属于把包带给仓库外用户的那一步,文章仅按官方 manifest 记录字段,未执行真实发布。
八、常见错误写法
| 过强或错误的说法 | 更准确的表述 |
|---|---|
| “插件必须 default export。” | 官方包用 named exports(name/inject/Config/apply);default export 是另一条兼容路径。 |
| “package.json 的 files 可以带上 src。” | 官方规范只发 lib 产物,src / map / 陈旧声明都不发。 |
| “exports 只有 default 就够了。” | 每个入口同时给 types 与 default,否则 TS 消费方解析失败。 |
| “patch 的 config 是合并的。” | 按行 id 整行替换;同一行最多三层来源,后写者胜。 |
| “patch 行顺序决定装载顺序。” | 头注释明确:行顺序无装载语义,激活按服务可用性驱动。 |
| “constraints-lite 等于 pnpm run constraints。” | constraints-lite 是作者复刻的 13 项检查;官方脚本含 workspace 全局规则,本机未运行。 |
| “demo 证明了 npm 发布。” | 没有执行发布;只验证了包骨架、不变式与 Loader 装载。 |
九、下一步
B6 的结论:可交付包 = 模块契约(named exports)+ manifest 不变式 + 项目布局 + 一条装载路径。你的插件只要满足这四件事,就能被任何 profile 按包名装载,也能进官方 bundle 的 patch 行。
设计自己的包时,按这个顺序问:
- 模块导出
name / inject / Config / apply了吗?(loader 依赖它们) - manifest 满足四组不变式吗?(入口 / 镜像 / files / private)
- tsconfig 把声明送进
lib/types了吗? - 用
cordis.yml一行验证装载了吗?(Loader+Include)
下一篇:B7:端到端真实实战案例——从零做一个有落地场景的 dsh 插件
(B7 需要你提供自研二开仓库地址;在拿到仓库前,先把 B6 的包骨架当作 B7 的起点。)
打包是把“能跑的代码”变成“能被任何人装载的包”的最后一步:契约、清单、布局、装载四条线都对齐,你的插件才真正可交付。
