# Dsh Plugin Helper

> 开发、配置、安装 DeepSeek Harness (dsh) 插件。当用户提出自然语言需求希望做成 dsh 插件，或要求为 dsh 添加/修改工具、服务、事件监听、能力缝（capability seam），或要求配置、安装、卸载、动态加载 dsh plugin 时使用。

- Skill: `vibe-any/dsh-plugin-helper` (Agent Skill, multi-file: 21 files)
- Install (CLI): `npx skillmds@latest add vibe-any/dsh-plugin-helper`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vibe-any/dsh-plugin-helper/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vibe-any (https://skillmd.com/u/vibe-any)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/vibe-any/dsh-plugin-helper

---


# 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）：

1. 本地有 deepseek-harness checkout（当前工作区或用户指定的目录下能找到 `docs/architecture.md` 与 `packages/`）→ `<DOCS>` = 该 checkout 的 `docs/`，直接读文件，并优先相信 `packages/*/*/src` 里的真实类型而非任何文档。**该 checkout 只读**：只查不改（见「交付形态」）。
2. 否则 `<DOCS>` = `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/docs`，用网页拓取工具读 raw Markdown。
3. 无网且无 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 插件」一节。
- **注册即效果**：插件通过 `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>`，供其他插件注入。
- 注入的服务用 `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` 内部固定 spawn `pnpm`**，所以装到 profile 这一步绕不开 pnpm。
- **网络**：首次安装依赖需联网。离线环境里优先选零依赖写法（结构化类型、`node --test`）。
- **Web UI 插件**还需要一个能跑起来的 dsh Web 服务才能验收；端口看启动日志输出，**不要硬编端口**。

### 1. 澄清需求（先勘察，再提问）

**铁律：不要问用户看不见的东西。** 用户不知道有哪些 slot、哪些已被占、哪些位置根本没坐位——问“你想注册到哪个 slot”只会得到一个不存在的答案，然后你拿着它去改上游。顺序必须是：

1. **先自己勘察**。UI 需求查 `references/ui-surfaces.md`（可用位置全表 + 已被占名单 + 常见需求→落点）；host 需求查 `references/extension-points.md`。有 checkout 时用那两份里的 grep 命令复核，快照可能过时。
2. **把 2–3 个真实可行的方案摆给用户**，每个写清：能看到的**位置**（“侧边栏最底部、Settings 上方”）、**代价**（稳定 / 依赖宿主 DOM 可能失效）、**功能差异**。用产品语言，不要只抛 slot 名。
3. **只问产品意图**：位置偏好、数据存哪、给模型还是给人。机制选型是你的活，不是用户的。
4. **预期位置没坐位时，直说**：“你要的位置没有官方扩展点，要么改放到 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 与版本影响：

```ts
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. 验证

独立插件的验证回路，按题选做，**不要只构完就说完成**：

1. 构建后看产物头（UI 插件）：`head -c 120 lib/client.js` 应以 `window.__ModuleLoader__.load({ id: "<包名>"` 开头。
2. 查未解决的 require（UI 插件）：`grep -o 'require("[^"]*")' lib/client.js | sort -u` —— 每一项都必须在模块表里，否则启动即 require 失败。只导入 react 的插件应只看到 `react` 与 `react/jsx-runtime`。
3. 装进 profile 后查组合树：`dsh --profile <name> --dump-config | grep <你的 id>`，并确认**没有** `patch: entry … not found` 警告。
4. 起服务用 `curl` 打自己的路由（端口从启动日志读，不要硬编）：正常路径 **加上拒绝路径**（非同源 POST 应 403、错误 method 应 405、`..` 穿越应 400）。
5. 浏览器实测：`/plugins/<包名>/client.js` 返回 200、UI 出现在预期位置、console 无报错。
6. 纯逻辑（路径校验、解析、合并）写单测。零依赖做法：`node --test "tests/*.test.ts"`（靠 Node 原生剥类型，无需安装测试框架；版本不够时见「环境前提」的降级方案）。**必须给 glob**：`node --test tests/` 会报 `MODULE_NOT_FOUND`。
7. 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 里发布的服务**必须**坐在 `isolate` realm 后面，否则 invariant 直接报错（`a preset service must sit behind an 'isolate' realm or move to the host composition`）。两个会话要看到不同实例时，靠的就是它。

其余：
- 插件自己的配置用 schemastery `Config` schema 声明；缺失必填配置要 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`）：

```sh
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`。与函数形态二选一，不要同时给同一个模块写 named `apply` 和 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 不到模块）：

1. **输出包装**——`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; } });`
2. **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`。
3. **`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 启动即抛。
4. **别用 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 时，按这个顺序选，**不要去改上游**：

1. 找附近的 `list` slot 落地（这是仓库为第三方留的坐位，文档里常写明“additive seat for a surface of your own”）。
2. 需要整屏/整面板时，用框浮层类 slot（如 `shell.overlay`）+ 自己的 `position: fixed` 面板，而不是去抢主区域的 `single` slot。
3. 多个表面要共享状态（入口控开关 + 面板读开关）：它们都在**同一个 `apply` 闭包**里，所以一个普通可观察对象（`subscribe` / `getSnapshot`）+ React 的 `useSyncExternalStore` 就够了，**不需要 cordis 服务**——连 DOM 挂载的那个独立渲染树也能订同一个对象。只有要给**别的插件**用时才发布成服务。注意：`getSnapshot` 必须在无变化时返回**同一个引用**，所以整值替换快照、不要原地改。
4. 面板要占“右侧主区域”：`shell.overlay` 层是穿透的，你的面板要自己 `pointer-events: auto`；左边界用 `ResizeObserver` 量侧边栏列的实时宽度（`getBoundingClientRect().width`）再设 `left`，不要写死像素值。
5. 实在只能靠上游新 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 路由：

```ts
// 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')
  })
}
```

```ts
// 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 按钮之后（即浏览区域之前）：

```ts
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` 为准**：本技能与上游文档都可能比代码陈旧。

