dsh Plugin Helper
把自然语言需求变成可安装、可配置的 dsh(DeepSeek Harness)插件,并完成验证与安装。
交付形态(最高优先级,先读这条)
产出永远是一个独立插件包,用 dsh plugin add 安装;绝不修改 deepseek-harness 仓库源码。
- 如果当前工作区或用户指定目录里有 deepseek-harness 源码 checkout,它是只读参考:用来查真实类型、查扩展点、抄现有插件的写法。不要改它的
packages/、tsconfig*.json、cordis.yml、packages/bundle/*,也不要往它的 workspace 里加成员。没有 checkout 也能正常开发(靠本技能的references/与templates/)。 - 插件住在仓库外自己的目录(或用户指定的目录),自带
package.json/tsconfig.json/tsdown.config.ts/cordis.patch.yml,能独立安装与构建。 - 安装只走标准命令(见「安装」一节),不靠往仓库里加行、加
references、加 workspace 成员。 - 需求落在没有现成扩展点的位置时(目标 slot 是
single且已被占用,或那个位置根本没有 slot),不要去改仓库开新 slot。两条可行路:挑一个附近的附加式 slot(kind: 'list')—— 稳,但位置有偏差;或向宿主 DOM 挂载—— 位置准,但耦合宿主结构。把两者的代价摊给用户选,并把“理想位置需要上游新增 slot”写成已知限制。无论选哪条,都不改只读仓库。 - 只有用户明确要求「把这个功能合进 deepseek-harness 仓库」时,才走仓库内建包流程(
<DOCS>/cookbook/adding-a-package.md)。
上游文档怎么查
本技能自带的 references/ 与 templates/ 是自足的,正常开发不需要仓库文档(UI 位置查 references/ui-surfaces.md,扩展点查 references/extension-points.md)。需要深入时,按下列顺序解析 <DOCS>,不要直接拿 docs/xxx.md 当路径读(运行环境往往没有 checkout):
- 本地有 deepseek-harness checkout(当前工作区或用户指定的目录下能找到
docs/architecture.md与packages/)→<DOCS>= 该 checkout 的docs/,直接读文件,并优先相信packages/*/*/src里的真实类型而非任何文档。该 checkout 只读:只查不改(见「交付形态」)。 - 否则
<DOCS>=https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/docs,用网页拓取工具读 raw Markdown。 - 无网且无 checkout → 只用
references/extension-points.md与templates/完成工作,并告知用户哪些结论未经上游文档校对。
下文所有 <DOCS>/... 均指这个已解析的根。
核心事实
- dsh 构建在 Cordis 插件框架之上,一切皆插件:模型适配器、工具、会话日志、agent loop 本身都是插件,没有特权核心;新行为挂在文档化扩展点上,不要改 agent-loop。
- 先分清两个平面:
- host 平面(Node)—— 工具、服务、会话、策略。上下文是
Context,经 cordis.yml 行装载。 - client 平面(浏览器 Web UI)—— 上下文是
ClientContext,靠 package.json 的dsh.client声明被发现。它不是“监听 session/event”就能代替的(那条是给外部客户端集成的),详见「Web UI 插件」一节。
- host 平面(Node)—— 工具、服务、会话、策略。上下文是
- 注册即效果:插件通过
ctx.effect()/ctx.on()/ctx.waterfall()注册贡献,插件卸载时自动撤销;每个注册都要可释放。 - 三种插件形态(由需求决定,上游用户文档的正式分法):
- 函数插件:named export
name/inject/Config/apply(ctx, config),禁止 default export(混用会让 Loader 丢弃注入,见<DOCS>/postmortem/0001-acp-default-export-drops-inject.md)。 - 对象插件:
export default { name, inject, apply(ctx) {} }—— 字面量形态,适合极短的逻辑。注意它就是 default export,上一条的禁令只针对“函数形态同时挂 default export”的混用,不是“default export 一律有害”。 - 服务(类)插件:default export 一个
Service子类,向 context 声明稳定ctx.<key>,供其他插件注入。
- 函数插件:named export
- 注入的服务用
ctx.<name>(静态声明);可选服务用ctx.get(name)。 - 模型可见 ⟺ 已记日志:新的模型可见输入必须扩展
SessionEventMap并添加 session event,从日志重建。 - 依赖方向:扩展插件只依赖 Service Definition 包(如
@deepseek-ai/dsh-tools、@deepseek-ai/dsh-llm),不依赖具体 provider 实现。 - 能力缝(capability seam)= Service Definition + Service Provider + Consumer 三件套;三者独立演化时才拆包(shell 三件套是模板)。
工作流
0. 先摸环境与对象(别假设)
不要假设 dsh 已装、假设有源码 checkout、假设 Node/pnpm 就绪、也不要假设用户会编程。能用只读命令探出来的就自己探,不要拿这些去问用户。
| 要确认 | 怎么探 | 探不到时 |
|---|---|---|
| dsh 怎么调用 | dsh --version;不在 PATH 就看是不是在源码树里(root package.json 有 dsh script → 用 pnpm dsh …) |
先告知用户需要先装/定位 dsh,再继续 |
| 目标 profile | ls $DSH_HOME/profiles(默认 ~/.dsh);Web UI 插件通常是 web |
多个时问用户装到哪个 |
| pnpm 在 PATH | pnpm --version |
dsh plugin 是 pnpm 转发器,缺 pnpm 会直接报 dsh: pnpm not found on PATH 并退 127。这是硬前提,先让用户装 pnpm |
| Node 版本 | node -v |
构建与 node --test 剥类型都有版本下限,见下文「环境前提」 |
| 有没有源码 checkout | 找得到 docs/architecture.md + packages/ 就有 |
没有就只用本技能的 references/,并告知哪些结论未经源码校对 |
| 已装了哪些插件 | 读 $DSH_HOME/profiles/<name>/package.json 的 dependencies 与 dsh.profile.bundles |
—— |
| 用户的技术水平 | 看他怎么描述需求(说“插槽/仓库/构建” vs 说“左边那个边栏上加个按钮”) | 按「沟通:用户可能不懂编程」一节的默认策略(当作不熟悉) |
环境前提(缺了先说,不要跑到一半才炸)
- Node:构建工具链需要现代 Node(LTS 即可)。本技能推荐的零依赖单测
node --test "tests/*.test.ts"需要能原生剥 TypeScript 类型的 Node;新版默认开,略旧的要加--experimental-strip-types,更旧的就把测试写成.js或装一个 runner。先node -v或直接试跑一次,别猜。 - 包管理器:插件自己用 npm / pnpm / yarn 都行(下文命令写
pnpm,换成等价命令即可)。但dsh plugin内部固定 spawnpnpm,所以装到 profile 这一步绕不开 pnpm。 - 网络:首次安装依赖需联网。离线环境里优先选零依赖写法(结构化类型、
node --test)。 - Web UI 插件还需要一个能跑起来的 dsh Web 服务才能验收;端口看启动日志输出,不要硬编端口。
1. 澄清需求(先勘察,再提问)
铁律:不要问用户看不见的东西。 用户不知道有哪些 slot、哪些已被占、哪些位置根本没坐位——问“你想注册到哪个 slot”只会得到一个不存在的答案,然后你拿着它去改上游。顺序必须是:
- 先自己勘察。UI 需求查
references/ui-surfaces.md(可用位置全表 + 已被占名单 + 常见需求→落点);host 需求查references/extension-points.md。有 checkout 时用那两份里的 grep 命令复核,快照可能过时。 - 把 2–3 个真实可行的方案摆给用户,每个写清:能看到的位置(“侧边栏最底部、Settings 上方”)、代价(稳定 / 依赖宿主 DOM 可能失效)、功能差异。用产品语言,不要只抛 slot 名。
- 只问产品意图:位置偏好、数据存哪、给模型还是给人。机制选型是你的活,不是用户的。
- 预期位置没坐位时,直说:“你要的位置没有官方扩展点,要么改放到 X,要么用 DOM 挂载(位置准但可能随上游改动失效)”,让用户拍板。不要默默改只读仓库。
例(将“在侧边栏加一个入口,点开一个面板”落地):
侧边栏没有“会话列表上方”的官方扩展点(那块整体属于会话浏览区)。两个选择: A. 插到“新会话”按钮之后(DOM 挂载)——位置就是你要的,代价是依赖宿主类名,宿主改侧边栏结构时入口可能消失。 B. 侧边栏最底部、Settings 上方(官方 list 坐位)——稳定不会坏,但位置不是你要的。 两边的面板都一样(官方浮层坐位)。选哪个?
还要确认的:
- 要不要写代码?先查
references/extension-points.md的「无代码扩展形态」表——加一个 MCP server、一份 SKILL.md、一个 preset 都不需要插件包。 - 跑在哪个平面:host(Node)还是 client(浏览器 UI)?两边都要的就是双面包。
- 给谁用:模型(→ 工具)、人工(→ 命令/UI)、其他插件(→ 服务)?
- 数据存哪、存什么格式?文件树(用户可在 UI 外编辑)还是结构化存储;要不要跟着会话走。
- 需要哪些附加能力:配置项、持久状态(session event)、后台任务(
ctx.jobs)、跨插件通信(事件/服务)。
沟通:用户可能不懂编程
默认把用户当成会用 dsh、但不熟插件开发的人;看到相反信号(他主动谈 slot / cordis / 构建)再升级词汇。不确定时用普通话,不会错。
- 用界面语言描述位置:说“侧边栏最下面、设置上方”,不说“
sidebar.footer.action”。slot 名写在代码里就行。 - 命令自己跑,不要丢一串命令让用户去执行。确实需要他动手时(装 pnpm、添工作区目录、重启服务),只给一步并说清为何。
- 验收说他能看见的现象:“重启后左侧会多一个 X 入口,点开是……”,而不是“组合树里已有该行”。
- 限制说后果,不说术语:不说“
InputActions未暴露 caret 句柄”,说“只能插到输入框末尾,不能插在光标位置”。 - 不要让他在两个技术方案里选而不说后果。每个选项必须带一句“对你意味着什么”(位置准但以后可能失效 / 位置差一点但稳)。他不表态时你给推荐默认值并说明理由。
- 先问要不要写代码:对不编程的用户,需求很可能用 Skill、MCP server 或 preset 就能满足(查
references/extension-points.md)——那比给他一个要自己维护的插件包好得多。 - 插件交付后给他一份 README(安装/配置/卸载/已知限制),别只留下一堆源码。
2. 映射扩展点
查 references/extension-points.md 选机制。常用对应:
| 自然语言需求 | 机制 |
|---|---|
| "模型可以 X"(读写文件、查资料、算东西…) | ctx.tools.register(defineTool(...)) |
| "拦截/放行/审计/超时"(权限、策略) | tools/*、agent/* 事件;waterfall 必须 next() |
| "换一个后端实现"(shell/fs/llm/subagent…) | 能力缝:新 Provider 注册到已有 Definition |
| "加一个全新能力" | 能力缝三件套(Definition + Provider + Consumer) |
| "人工敲命令触发" | ctx.commands |
| "后台跑长任务" | ctx.jobs + job_* 控制工具 |
| "做 UI / 集成外部客户端" | 外部客户端(ACP/SDK):监听 session/event,输入走 agent.followup()。Web UI:走 client 平面插件,位置选型查 references/ui-surfaces.md |
| "给模型加固定上下文" | ctx.systemPrompt.section() 或 agent.inject() |
| "会话里记一条持久事实" | 扩展 SessionEventMap + 渲染 |
3. 生成脚手架
按复杂度从 templates/ 复制对应模板再改写:
templates/tool-plugin/— 最小模型工具(defineTool + Config + bundle 声明)templates/function-plugin/— 事件监听 / 策略钩子templates/service-plugin/— 新服务能力(Service 子类 + Config)templates/client-plugin/— Web UI 插件(host 半边 +./client半边,双面包)
模板是独立可编译的(先装依赖,再跑 build 脚本;npm / pnpm / yarn 皆可)。四个模板都已声明 dsh.bundle 且把 cordis.patch.yml 列入 files,构建后可直接 dsh plugin add 安装。
依赖越少越好,先判断你是不是真的需要 import 上游包:
- 需要值导入(工具的
defineTool、服务基类Service、schemastery 的Config)→ 必须真实依赖对应包,且版本对齐目标 profile 实际装的:在$DSH_HOME/profiles/<name>下跑node -p "require('<包名>/package.json').version"。别信npm view—— 公共 registry 上可能并存一条更旧的发布线(如@deepseek-ai/dsh-client-*的0.0.1-rc.1,而 profile 里是0.1.0-rc.5)。 - 只碰 ctx 上的服务(client 插件、
webServer路由、事件监听)→ 结构化声明你用到的那几个成员即可,零@deepseek-ai依赖。这是现有第三方插件的通行做法,也让插件不受 registry 与版本影响:
interface SlotsLike {
inject(slot: string, register: () => unknown): unknown
register(meta: Record<string, unknown>, component: unknown): () => void
}
interface MyClientContext {
effect(callback: () => () => void, label?: string): void
slots: SlotsLike
}
export function apply(ctx: MyClientContext): void { /* … */ }
host 侧同理(只结构化 inject / effect / webServer.register)。真实签名以 packages/*/*/src 为准,只抄你用到的成员;写多了反而容易和上游漂移。
若用户明确要求把插件合入 deepseek-harness 仓库,才按 <DOCS>/cookbook/adding-a-package.md 的完整清单执行(tsconfig 换成 extends 仓库 base + references、注册进 aggregate、README 带 Model Experience、REAL-composition 测试),并遵循仓库 AGENTS.md 约定。默认不走这条路。
4. 实现
按「编码硬规则」写;事件契约查 references/extension-points.md。
5. 验证
独立插件的验证回路,按题选做,不要只构完就说完成:
- 构建后看产物头(UI 插件):
head -c 120 lib/client.js应以window.__ModuleLoader__.load({ id: "<包名>"开头。 - 查未解决的 require(UI 插件):
grep -o 'require("[^"]*")' lib/client.js | sort -u—— 每一项都必须在模块表里,否则启动即 require 失败。只导入 react 的插件应只看到react与react/jsx-runtime。 - 装进 profile 后查组合树:
dsh --profile <name> --dump-config | grep <你的 id>,并确认没有patch: entry … not found警告。 - 起服务用
curl打自己的路由(端口从启动日志读,不要硬编):正常路径 加上拒绝路径(非同源 POST 应 403、错误 method 应 405、..穿越应 400)。 - 浏览器实测:
/plugins/<包名>/client.js返回 200、UI 出现在预期位置、console 无报错。 - 纯逻辑(路径校验、解析、合并)写单测。零依赖做法:
node --test "tests/*.test.ts"(靠 Node 原生剥类型,无需安装测试框架;版本不够时见「环境前提」的降级方案)。必须给 glob:node --test tests/会报MODULE_NOT_FOUND。 - host 侧逻辑想跑真装配时,写一个最小
cordis.yml(dsh-system-prompt+dsh-tools+ 你的插件)用 Loader 装配,不要只手搭ctx.plugin()。
进仓库的插件另有一套:pnpm run constraints && typecheck && lint && build && hygiene + 包级测试,并补快照覆盖。
6. 配置
组合文件 cordis.yml / cordis.patch.yml 一行一个条目,字段全集(EntryOptions):
| 字段 | 语义 |
|---|---|
id |
条目在所属树里的稳定标识;patch 按它定位要覆盖哪一行,热重载也靠它区分“编辑”与“删了重加” |
name |
插件 specifier(npm 包名或相对路径) |
config |
传给插件的配置;允许 !!js 表达式 |
disabled |
禁用本条目及其全部后代;允许 !!js 表达式 |
group |
标记本条目为嵌套分组容器(子条目列在它下面) |
inject |
本条目要求的服务,或服务 intercept 配置 |
isolate |
{ <服务名>: true | <realm 标签> }——让本条目/本组看到独立的服务实例 |
intercept |
按服务名注入拦截配置,改写下游看到的服务行为 |
除 config 与 disabled,其余元数据保持字面量(!!js、永远不是 !js);条件组合用 overlay。
isolate不是可选装饰:preset 里发布的服务必须坐在isolaterealm 后面,否则 invariant 直接报错(a preset service must sit behind an 'isolate' realm or move to the host composition)。两个会话要看到不同实例时,靠的就是它。
其余:
- 插件自己的配置用 schemastery
Configschema 声明;缺失必填配置要 loud fail,不要静默跳过。 - 部署相关开关必须是 Config 字段,禁止硬编码常量。
7. 安装(按持久性三档)
命令前缀先定下来(§0 已探过):dsh 在 PATH 就直接 dsh …;只能在 deepseek-harness 源码树里跑则用 pnpm dsh …(root package.json 的 dsh script)。下表写 dsh,源码启动就自己补 pnpm 前缀。
| 方式 | 操作 | 说明 |
|---|---|---|
| 动态(仅内存) | cordis_define → cordis_run(cordis_stop / cordis_undefine 撤销;需 @deepseek-ai/dsh-tool-cordis) |
秒级验证、热改,重启消失;不进 cordis.yml、不装包 |
| Profile 持久安装(默认交付方式) | dsh plugin --profile <name> add <npm包|./目录|git spec>;卸载 dsh plugin --profile <name> remove <包名> |
从插件目录里 add .(或 add /abs/path/to/plugin)安装的就是那份本地插件;bundle 层自动纳入 |
| 本地/用户层 patch | dsh --profile <name> --patch <file>,或编辑 $DSH_HOME/profiles/<name>/cordis.patch.yml(默认 ~/.dsh) |
不建包也能加行 |
典型交付(<name> 是目标 profile,Web UI 插件通常是 web):
cd <你的插件目录>
pnpm install && pnpm run build # npm / yarn 等价命令也行
dsh plugin --profile <name> add . # 源码树里跑:pnpm dsh plugin --profile <name> add .
这一步靠 pnpm 完成(dsh plugin 就是 pnpm 转发器),缺 pnpm 会报 dsh: pnpm not found on PATH 并退 127。
开发回路:add <目录> 装的是 link:(profile 的 package.json 里就是 "link:/abs/path"),所以改完代码只需重跑一次 build + 重启 dsh 就生效,不用重新 add。装成功后 profile 会自动把包名追加进 dsh.profile.bundles —— 那才是它被激活为层的证据;只出现在 dependencies 而不在 bundles 里,就是 dsh.bundle 没声明对。
要点:
- 包要被
dsh plugin add激活为层,package.json 必须声明"dsh": { "bundle": { "patch": "./cordis.patch.yml" } };只装依赖不声明会有一句警告且不激活。同时files要包含cordis.patch.yml,否则发包后 patch 丢失。 dsh plugin --profile <name> <pnpm args...>把参数转发给 profile 目录下的 pnpm,所有 pnpm 子命令可用。- patch 有两种条目,别弄错(弄错就是“装上了但不生效”):
- 新增插件行 → 必须用
- insert:包住。顶层insert追加到组合根;只有id指向一个group条目时才插进那个 group。 - 修改已有行 → 不带
insert的平铺- id: …写法,只做覆盖。目标 id 不存在时它不会插入,只warn('patch: entry <id> not found')后跳过(vendor/include/src/index.ts)。拿它当新增用,插件永远不加载,且只有一句警告。 - 同理:
name与目标不符也是 warn + 跳过。所以装完一定用--dump-config确认你的行真在树里。
- 新增插件行 → 必须用
- 高层 patch 覆盖低层;同一列表里先
insert的行,后面的条目可以再按 id 覆盖它。
三个 dsh.* 清单字段(别混)
| 字段 | 谁用 | 含义 |
|---|---|---|
dsh.bundle |
插件/组合包 | { patch: './cordis.patch.yml' }——本包贡献一个配置层 |
dsh.client |
Web UI 插件 | { platform, inject, immediately? }——声明浏览器半边,见「Web UI 插件」 |
dsh.profile |
profile 包 | { bundles: string[] }——按序堆叠的 bundle 层列表 |
分发一整套开箱配置 = 做一个 profile 包:package.json 声明 dsh.profile.bundles(列出要堆的 bundle 包,顺序即应用顺序)+ dependencies,再配一份用户层 cordis.patch.yml。profile 住在 $DSH_HOME/profiles/<name>/,首次 dsh --profile <name> 会自动初始化(web / headless 是现成模板)。层序:各 bundle(按 bundles 顺序)→ profile 的 patch → home 级 patch → --patch overlay。
8. 验证安装
dsh --profile <name> --dump-config查看真实组合树,确认你的行在其中;- 实际跑一次任务,确认插件生效;动态安装用
cordis_inspect_list/cordis_inspect_query确认运行状态。
编码硬规则
- 函数插件:
export const name;export const inject = ['tools'](声明依赖,等服务就绪);export interface Config+export const Config: z<Config> = z.object({...})(schemastery,可.default());export function apply(ctx: Context, config: Config)。没有 default export。 - 对象插件:
export default { name, inject, apply(ctx, config) {} },可带Config。与函数形态二选一,不要同时给同一个模块写 namedapply和 default export。 - 服务插件:
class X extends Service,可选static inject/static Config,构造函数super(ctx, '<key>')(vendored Cordis 4.x 的Service构造器只收ctx和name两个参数);default export 类。提供方自己用声明合并把ctx.<key>加进Context(declare module '@deepseek-ai/cordis'),消费者 import 本包即得类型。static Config有必填字段时构造器的config参数不要给= {}默认值——schemastery 已在装配时填好.default()。 - 工具:
ctx.tools.register(defineTool({...}))。parameters自动校验、args类型由 schema 推导;execute只返回output.schema声明的单个规范 JSON 值;抛错即isError;必须响应exec.signal取消;模型说明放description与output.render;UI 卡片用纯函数presentCall/presentResult(无 I/O、无时钟,可重放)。schema 自动流入 system-prompt,无需额外接线。 - 事件:
emit= 观察;waterfall监听器必须调next()委托(不调 = 短路裁决);parallel= 并行扇出;serial= 顺序 + 返回值。事件契约带@mode。 - 生命周期:
ctx.effect()返回 disposer;ctx.on()框架自动释放;需要特定撤销顺序的工作放同一个 effect。 - 错误:明确失败;空 catch 必须注释吞了什么;不静默跳过缺失引用。
- 块注释里不要写
*/:引用仓库路径时很容易写成packages/client/*/src—— 其中的*/会提前结束注释,后面整段文档被当代码解析,报一堆莫名其妙的语法错(TS1443、TS1351…)且行号指向下方无关代码。写成packages/client/<pkg>/src或改用行注释。 - 类型:跨包边界的 ids 用
Branded<B>;边界处才做运行时校验,同进程类型安全处信任 TS。
Web UI 插件(client 平面)
模板:templates/client-plugin/。一个 Web UI 插件是双面包:host 半边(exports["."])+ 浏览器半边(exports["./client"] → lib/client.js)。
发现链:dsh-client-modules 扫描 host Loader 的条目,找声明了 dsh.client 的包,解析它的 exports["./client"],托管到 /plugins/<id>/client.js。所以:
- cordis.patch.yml 的行是必需的,即使 host 半边的
apply是空的——不是 Loader 条目的包永远不会被扫到。纯 UI 插件的 host 半边就写export function apply(): void {}。 - host 侧行为(命令、工具、projection、session event)放单独的 host 插件,不要把 UI 包撑胖。
files要包含lib/client.js。
编码硬规则:
- 上下文是客户端 context(宿主传的是
ClientContext),不是 host 的Context—— 两边的ctx上有的东西不同。独立插件不必 import 那个类型,结构化声明用到的成员即可(见§3);真要 import 则来自@deepseek-ai/dsh-client-runtime/client。 - 两张 inject 表,各管一事,不互相替代:代码里
export const inject列 cordis 服务名(支持点路径,如'remote.commands')—— 这张决定你的apply何时被调,必需;package.json 的dsh.client.inject列包名加载图边,不依赖其他插件包时写[]就行。 - plugin → plugin 的值导入是禁忌。跨插件协作只能走 inject/服务;只有
import type豁免(import type {} from 'x/client'是拉声明合并的标准写法)。 - 能从宿主拿到的值导入只有平台模块表里那几项:react 家族、
@deepseek-ai/cordis、ui-slots、web-react、ui-primitives、ui-attachment、schema-form(+ 已文档化的dsh-client-runtime/client)。其余一律必须被打进你自己的 bundle。 - 违了会怎么炸,分两种情况:仓库内开发有 purity gate 在构建期拦住;仓库外自建 bundle 没有这道闸—— 违规导入要么在运行时
require失败,要么悄悄内联出第二份实例(更难查)。所以 §5 的grep require自检是你唯一的网,别省。 - 注册仍然是效果:
ctx.effect()的 disposer 让热重载不留残迹。 - 扩展宿主的 keyed map(如 locale 命名空间、slot 名)时,如果你 import 了宿主的类型,就要先声明合并进对应 interface,否则编译不过;结构化写法不涉及这一步。
仓库外自建 client bundle(标准做法)
产物是一个闭包工厂:window.__ModuleLoader__.load({ id, factory })。仓库内用共享的 clientBundle tsdown preset 产出,而该 preset 与 platform externals 清单是仓库内部文件、未发包(packages/client/tsdown.client.ts、packages/client/web/src/platform.ts)。这不是进仓库开发的理由——在自己的 tsdown.config.ts 里搭出同样的产物即可,templates/client-plugin/tsdown.config.ts 已经是可用的完整复刻。
必须完全对齐的四件事(错一个就是启动时 ReferenceError 或 require 不到模块):
- 输出包装——
format: 'cjs'、platform: 'browser'、entryFileNames: 'client.js',并用这三段包住:intro:var module = { exports: {} }; var exports = module.exports;banner:window.__ModuleLoader__.load({ id: "<你的包名>", factory: (require) => {footer:return module.exports; } });
- externals 就是模块表——下面这些保持
external,其余全部 inline(noExternal: id => CLIENT_EXTERNALS.includes(id) ? undefined : true)。表里答不上的require()是必然运行时报错:react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordis、@deepseek-ai/dsh-client-ui-slots、@deepseek-ai/dsh-client-web-react、@deepseek-ai/dsh-client-ui-primitives、@deepseek-ai/dsh-client-ui-attachment、@deepseek-ai/dsh-client-schema-form,加上一个已文档化的例外@deepseek-ai/dsh-client-runtime/client。 define要填——zustand/immer 读process.env.NODE_ENV,zustand 还探import.meta.env.MODE,CJS 输出带不了import.meta:三个 key(process.env.NODE_ENV、import.meta.env.MODE、import.meta.env)都要定义,否则 factory 启动即抛。- 别用 CSS Modules——仓库靠 lightningcss 把
x.module.css转成哈希类名表,那套是仓库内部构建。独立插件更简单:把整份样式当一个字符串,注入一个<style data-plugin="<包名>" data-plugin-css="<包名>/styles">(loader 卸载时按data-plugin清除,热重载不留残迹),构建配置里就不需要任何 CSS 管道。颜色用var(--dsw-alias-*, <兜底值>)跟随主题与皮肤,类名自己加前缀防撞。
自检:构完 head -c 200 lib/client.js 应能看到 window.__ModuleLoader__.load({ id: ...;装上后 dsh --profile <name> --dump-config 确认你的行在组合树里,浏览器 Network 确认 /plugins/<包名>/client.js 200。
目标 slot 已被占用怎么办
完整的位置表(空闲坐位 / 已被占 / 常见需求→落点)在 references/ui-surfaces.md。先分清 slot 的 kind:list 是附加式(你的 id 与现有条目并存),single 是独占式(注册进去 = 整块替掉现有占居者,连它声明的子 slot 一起消失)。声明即占有:子 slot 只能由拥有者在自己 register 的 children 表里开,你的插件无法凭空新增一个 slot。
所以当理想位置没有可用 slot 时,按这个顺序选,不要去改上游:
- 找附近的
listslot 落地(这是仓库为第三方留的坐位,文档里常写明“additive seat for a surface of your own”)。 - 需要整屏/整面板时,用框浮层类 slot(如
shell.overlay)+ 自己的position: fixed面板,而不是去抢主区域的singleslot。 - 多个表面要共享状态(入口控开关 + 面板读开关):它们都在同一个
apply闭包里,所以一个普通可观察对象(subscribe/getSnapshot)+ React 的useSyncExternalStore就够了,不需要 cordis 服务——连 DOM 挂载的那个独立渲染树也能订同一个对象。只有要给别的插件用时才发布成服务。注意:getSnapshot必须在无变化时返回同一个引用,所以整值替换快照、不要原地改。 - 面板要占“右侧主区域”:
shell.overlay层是穿透的,你的面板要自己pointer-events: auto;左边界用ResizeObserver量侧边栏列的实时宽度(getBoundingClientRect().width)再设left,不要写死像素值。 - 实在只能靠上游新 slot 才能做到:按前面几条选一个能跑的方案交付,并把差异写成已知限制告知用户。
往输入框插内容:只有 setDraft
session 作用域的 slot 组件会拿到框架标准 props:inputActions(动作面)与 input(input.draft 是当前草稿)。但公共面只有 setDraft(text) / addImages / removeImage / pruneImages / submit;track(draft, caret) 一类光标句柄是 InputBar 私有的(ui-conversation/src/client/input/contract.ts 开头就标了 frozen)。
所以独立插件做不到“在光标处插入”,只能 setDraft(拼好的新草稿)(追加到末尾,或整体替换)。用户提“插入到光标位置”时,先把这个限制说清楚并给出选项(追加末尾 / 注册 / 触发源走现有插入管道 / 改上游),不要假装实现了光标插入,也不要去碰 textarea 的 DOM 选区(绕过输入机器会弄坏它的 occurrence 计算与撤销栈)。
独立插件的 host ↔ client 通道:ctx.webServer + fetch
ctx.remote.*(Typert RPC)是仓库内的 BFF 装配(packages/api/remotes),独立插件无法往里加新命名空间。独立插件要让浏览器半边读写主机数据(文件、进程、任何 Node 能力),走 HTTP 路由:
// host 半边:等 webServer 就绪后注册路由(注册即效果,卸载自动摘除)
export function apply(ctx: Context): void {
ctx.inject(['webServer'], (host) => {
host.effect(() => host.webServer.register({
kind: 'exact',
path: '/my-plugin/items',
handler: async (request, response) => { /* 读写磁盘,回 JSON */ },
}), 'my-plugin: http routes')
})
}
// client 半边:普通 fetch,同源
const items = await fetch('/my-plugin/items', { cache: 'no-store' }).then(r => r.json())
安全要求(跟着现有插件的做法):每个写路由校验 Origin 与 Host 同源、限制 body 大小、自己校验路径段(拒 .. 与分隔符,防穿越);非 GET 先查 request.method 否则 405。
侧边栏“会话列表上方”这类无 slot 位置:DOM 挂载
侧边栏只声明了 sidebar.workspaces(single,已占)、sidebar.settings(single,已占)、sidebar.footer.action(list,底部),会话列表上方没有 slot。现有第三方插件的做法是把一个 DOM 节点插到 New Session 按钮之后(即浏览区域之前):
const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
const root = column?.querySelector('[class*="logoRow"]')?.parentElement ?? column?.firstElementChild
const anchor = root?.querySelector('button[class*="newSession"]') // 在它后面插
要点:
- 类名是构建期哈希的,只能用
[class*="…"]子串匹配;每一步查找都可能返回空,全部当“稍后再试”处理,不要抛异常。 - shell 是异步挂载的,且 React 重渲染会把你的节点移除:用
MutationObserver盯document.body(childList+subtree),每次变动都重跑一次插入逻辑。 - 插入逻辑要幂等:自定义
data-*守卫属性 + “已经在正确位置就不动”判断,否则会插出重复节点或陷入 observer 自触发循环。 - 侧边栏有折叠态(约 56px 的 rail):量列宽判断,窄时只渲染图标并隐文字,否则布局会溢出。
- 卸载要完全归零:移除节点 +
observer.disconnect()+ 取消订阅(用了 React 就还要root.unmount())。纯 DOM 实现比再开一个 React root 更轻,也更能抗住宿主重渲染。
这是与上游样式耦合的折中方案,要向用户说明这一点;不想承担这个风险就改用 sidebar.footer.action(位置在底部)。
参考
- 本技能自带(总是可用):
references/ui-surfaces.md— Web UI 可扩展位置全表:空闲坐位、已被占名单、常见需求→落点、复核命令references/extension-points.md— 扩展点全表、事件域、ctx 键、决策指南templates/— tool-plugin / function-plugin / service-plugin / client-plugin,均可独立编译、可安装(client-plugin 自带 tsdown 配置与零依赖的结构化写法)
- 上游文档(按「上游文档怎么查」解析
<DOCS>;既无 checkout 又无网时跳过):<DOCS>/architecture.md— 系统地图、组合与安装机制<DOCS>/cordis-primer.md— Cordis 五个概念与分发模式<DOCS>/cookbook/adding-a-tool.md— 工具契约与渲染<DOCS>/cookbook/adding-a-package.md— 仓库内建包清单<DOCS>/cookbook/extension-cookbook.md— 扩展形状 + feature→mechanism 表<DOCS>/cordis-tutorial/07-into-the-harness.md— 第一个 harness 插件<DOCS>/tool-catalog.md/<DOCS>/config-catalog.md— 生成的工具/配置目录<DOCS>/user/develop/basic/— 面向用户的插件教程(tool.md→config.md→publish.md)
有 checkout 时,真实类型以 packages/*/*/src 为准:本技能与上游文档都可能比代码陈旧。