# deepseek-harness-pro 插件开发与使用指南 > 适用范围:在 `deepseek-harness-pro` 宿主仓中开发、安装、调试、发布 **Cordis 客户端插件**(浏览器侧 UI 插件)。本仓插件一律命名为 `@dhs-pro/*`(`dhs-pro` = DeepSeek Harness Pro,与官方 `@deepseek-ai/*` scope 完全分离),通过 `app//` 工作区目录开发。 > 单一真相:本仓 `app/demo-cordis/` 与 `app/ui-tweak/`(已实现的两份示例)+ 上游 [`docs/plugin-development.md`](plugin-development.md)(Cordis 内核机制详尽说明)+ [`docs/cookbook/`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cookbook/)(任务型 cookbook)。 ## 阅读路径 本文档覆盖**应用层**:host-repo 的工作流(`app//` 布局、symlink、`dsh.profile.bundles` 同步、`tsdown` 打包、npm 发布)。Cordis 框架本身(fiber 状态机、event mode 表格、Schema 校验等)的权威讲解在 [`cordis-tutorial/`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/)。按需跳转: | 你想要 | 去哪 | |---|---| | **理解 Cordis 是什么**(apply / fiber / effect / inject / event mode / Schema)| [`cordis-tutorial/01`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/01-first-plugin.md) — [`07`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/07-into-the-harness.md) | | 写 Node 端 plugin(host plane — 工具 / LLM adapter / hook / service)| [`plugin-development.md`](plugin-development.md) | | **写本仓 `app//` 客户端 UI 插件**(slot 注册、locale、`--dsw-*` 主题、tsdown bundle、发布到 npm)| 本文档 | | 排查插件加载失败 / 想做"自诊断"插件 | 本文档 §10 + [`cordis-tutorial/02`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/02-lifecycle-and-effects.md) 的 fiber 状态机图 | | 写上游 `dsh-base` / `dsh-web-app` 的扩展(如 dsh 内部 bundle)| [`cordis-primer.md`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-primer.md) + [`plugin-development.md`](plugin-development.md) | > **tier 边界**:本文档不重复 Cordis 内核讲解([规则](../../deepseek-harness/docs/AGENTS.md))。需要状态机、event mode、Schema 校验时跳到 tutorial;本文档只讲本仓独有的工作流细节。 ## 目录 | 节 | 解决什么问题 | 何时读 | |---|---|---| | §0 三秒钟速览 | "插件是怎么被宿主仓接管的" | 第一次接触时 | | §1 宿主仓架构 | `app//` + symlink + `dsh.profile.bundles` 同步的整套机制 | 想弄清机制 / 排查问题 | | §2 启动与命令 | 第一次跑起来的完整步骤 + 日常命令 | 第一次配置、迭代时 | | §3 写一个新插件 | 完整创建一个 `app/my-plugin/` 的全流程 | 第一次写插件 | | §4 插件结构详解 | `package.json` / `cordis.patch.yml` / `tsdown.config.ts` / `src/` 的每个字段含义 | 写代码时反复查 | | §5 客户端 `apply(ctx)` 写法 | 函数/对象/类三种形态;`ctx.slots` / `ctx.locale` / `ctx.runtime` 的用法 | 实现浏览器侧 apply 时 | | §6 Slot 系统详解 | `ctx.slots.register` vs `ctx.slots.inject`;四种 props share | 注册到现有 slot 时 | | §7 主题与样式定制 | 改 `--dsw-*` token;CSS Modules 规范 | 改 web 外观时 | | §8 Bundle 与发布 | tsdown 怎么打包;如何发到 npm 或仅本地 | 准备发布时 | | §9 测试与验证 | typecheck、bundle dry-run、浏览器手动验证 | 提交 PR 前 | | §10 常见坑(主动实验清单) | 不修改 submodule、不抢占官方 scope、symlink 失效、`__DSH_BOOT__` 找不到等 | 任何时候 | | §11 与上游文档的边界 | 本文档 vs `plugin-development.md` vs 上游 cookbook | 想知道"在哪查权威" | | §12 发布到 npm | `pnpm pack` + `pnpm publish`;用户视角 `dsh plugin add` | 准备发布时 | --- ## §0 三秒钟速览 ```text 开发者写代码 启动 dsh ───────────── ──────── app// ──────pnpm install──────► symlink: ~/.dsh/profiles/web/node_modules/@dhs-pro/ ─┐ ├── cordis.yml ──inject───► patch: ~/.dsh/profiles/web/cordis.patch.yml ─┤ └── src/ ▼ pnpm dsh:web(零参数启动) │ ▼ upstream dsh → 读 cordis.patch.yml │ → 加载 @dhs-pro/ ▼ browser:Cordis Loader → factory(require) │ ▼ ctx.plugin(apply) → 插件 UI 渲染 ``` 要点: - **本仓插件是 workspace package**,不是 npm 包:`app//package.json` 的 name 是 `@dhs-pro/`,仅在 pnpm workspace 内被 symlink - **`pnpm install` 自动完成所有宿主集成**:symlink + patch 注入 - **启动零参数**:`pnpm dsh:web` 不需要 `--patch`,所有插件由 dsh 自身的 `cordis.patch.yml` 加载 - **不修改 upstream submodule**——插件代码完全在 `app/` 下,可被 git 单独追踪 --- ## §1 宿主仓架构 ### 1.1 三个"app"目录角色 | 路径 | 角色 | 命名 | 上游对应 | |---|---|---|---| | `app/web/` | **宿主 web 前端入口**(Vite 工程) | `@deepseek-ai/dsh-web-frontend`(**必须**保留此名) | `deepseek-harness/apps/web` | | `app//` | **宿主侧 Cordis 客户端插件** | `@dhs-pro/`(自有 scope) | 无 | | `deepseek-harness/` | **upstream submodule**(只读) | 不变 | upstream | `app/web` 的包名**故意**与 upstream `apps/web` 同名:dsh CLI 通过 `require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')` 找到 web 资源;本仓 `pnpm run build` 把宿主 `app/web/dist/` **覆盖** 到 submodule 的 `apps/web/dist/`,让 require.resolve 命中我们的 dist。 `app//` 的插件**用自有 scope** `@dhs-pro/*`,理由: - `@deepseek-ai/*` 是 deepseek 官方 npm scope,发布 npm 包时**会**占用官方 namespace,污染 upstream - `@dhs-pro/*` 清晰表达"这是 dsh-pro 宿主仓出品",与 upstream 分离 - 上游已经接受"dsh base + bundle 替换"的扩展机制(参见 `packages/bundle/web-app/cordis.patch.yml` 注释),我们的 `cordis.patch.yml` 注入与它**同构** ### 1.2 三层目录关系 ``` deepseek-harness-pro/ ├── app/ │ ├── web/ # 宿主 web 入口(Vite),不参与 Cordis Loader │ └── / # 宿主侧 Cordis 客户端插件(workspace package) │ ├── package.json # name = "@dhs-pro/" │ ├── tsconfig.json # paths 指向 deepseek-harness 源码(submodule source 解析) │ ├── tsdown.config.ts # tsdown:node + client 双 bundle │ ├── cordis.yml # dsh patch entry(被 preinstall 注入到 dsh profile) │ └── src/ │ ├── index.ts # Node 端 apply(常为空) │ ├── invariant.ts # 包级 invariant companion │ ├── css-modules.d.ts # *.module.css 类型声明 │ └── client/ │ ├── index.ts # 浏览器端 apply(核心实现) │ ├── locales.ts # i18n 字面联合字典 │ └── .tsx + .module.css ├── deepseek-harness/ # git submodule(只读;preinstall 自动 init + install) ├── docs/ # 本仓文档(含本文件) ├── scripts/ # setup.sh / link-app-workspaces.sh / inject-app-patches.mjs ├── package.json # 根 scripts:dsh:web、build:upstream、demo:bundle 等 ├── pnpm-workspace.yaml # 注册 app/web + app//… 为 workspace members └── README.md ``` ### 1.3 preinstall 钩子做了什么 `root package.json`: ```json { "scripts": { "preinstall": "bash scripts/setup.sh" } } ``` `scripts/setup.sh` 依次: 1. **检测包管理器**(`pnpm > yarn > npm`) 2. **init submodule**:`git submodule update --init --recursive deepseek-harness`(仅当子模块空时;`--recurse-submodules` clone 已自动 init) 3. **装 submodule deps**:`pnpm --dir deepseek-harness install --ignore-scripts`(绕开 upstream `install-lefthook.mjs` 在 submodule 模式下 fail 的问题;详见 §10) 5. **rebuild esbuild**(防御性,让 vite dev server 能跑) 6. **`scripts/link-app-workspaces.sh`** —— 把每个 `app//` symlink 到 `~/.dsh/profiles/web/node_modules/`(从 `package.json#name` 推导 scope;详见 §1.4) 7. **`scripts/inject-app-patches.mjs`** —— 把每个 `app//cordis.yml` 合并写入 `~/.dsh/profiles/web/cordis.patch.yml`(幂等 + 保留用户手写条目;详见 §1.5) ### 1.4 symlink 机制(`scripts/link-app-workspaces.sh`) dsh web 启动时,**Cordis Loader 的 `baseUrl` 是 `$DSH_HOME/profiles/web`**(默认 `~/.dsh/profiles/web`)。浏览器侧的 `import('@dhs-pro/my-plugin/client')` 通过 Node 模块解析**沿 `node_modules/` 向上找**。Loader 的 cwd 不是项目根,而是这个 profile 目录,所以我们必须在 profile 下创建 symlink 桥接。 脚本行为: - 扫描 `app/*/`(跳过 `app/web`,它走 dist 覆盖路径,不需要 Loader 解析) - 对每个含 `package.json` 的目录,读 `name`,如 `@dhs-pro/demo-cordis` → 创建 `~/.dsh/profiles/web/node_modules/@dhs-pro/demo-cordis` → 软链到本仓 `app/demo-cordis/` - 跳过 scope 创建(自动 mkdir -p) - **幂等**(用 `ln -sfn`) profile 目录不存在(首次)时该脚本 no-op,输出"profile does not exist yet"——此时跑一次 `pnpm dsh:web` Ctrl-C 让 dsh 初始化 profile,再跑 `pnpm install`。 ### 1.5 dsh.profile.bundles 同步机制(`scripts/sync-profile-bundles.sh`) 发布后的 dsh 插件走的是**官方渠道**:`dsh plugin --profile add ` 是 dsh CLI 的 pnpm forwarder。安装后 dsh 自己读 `~/.dsh/profiles//package.json#dsh.profile.bundles` 数组,**对每个 bundle 解析其 `dsh.bundle.patch` 字段、加载对应的 cordis patch 文件**(参见 `packages/boot/app-boot/src/profile.ts#loadProfile`)。 本仓 `app//` 的每个插件都已经声明 `dsh.bundle.patch: "./cordis.patch.yml"`,**所以本仓开发期也复用同一条 dsh 官方路径**——`sync-profile-bundles.sh` 做的事只有一件: - 扫描 `app/*/package.json`,收集**所有声明了 `dsh.bundle.patch` 字段**的包名 - 把它们**追加到**(非覆盖)`~/.dsh/profiles/web/package.json#dsh.profile.bundles` - 不动 upstream 自带的 `@deepseek-ai/dsh-base` / `@deepseek-ai/dsh-web-app` 写入结果示例(profile manifest): ```json { "name": "dsh-profile-web", "private": true, "dependencies": { "@dhs-pro/demo-cordis": "file:/path/to/app/demo-cordis", "@dhs-pro/ui-tweak": "file:/path/to/app/ui-tweak" }, "dsh": { "profile": { "bundles": [ "@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "@dhs-pro/demo-cordis", "@dhs-pro/ui-tweak" ] } } } ``` 对比旧机制:本仓之前的 `inject-app-patches.mjs` 直接写 `cordis.patch.yml`,**与 dsh 官方渠道重复**——一旦插件发布走 `dsh plugin add` 也会读自己的 `dsh.bundle.patch`,造成两条入口。新机制**只走官方渠道**(host-repo 是它的本地化),更简洁也更不容易与上游 drift。 何时跑:`pnpm install` 的 `preinstall` 钩子自动调用。手动调:`pnpm run profile:sync`。 **重要**:`~/.dsh/profiles/web/cordis.patch.yml` 在新流程下**仅承载用户自己的 patch**(如关闭某 upstream 插件、改 config 默认值)。`@dhs-pro/*` 插件的 entry 全部由 `dsh.profile.bundles` 数组里的 bundle 加载。**不要**手动编辑 `cordis.patch.yml` 添加 plugin entry——改用 `pnpm run profile:sync` 或 `pnpm install`。 - insert: - id: ui-tweak name: '@dhs-pro/ui-tweak' ``` dsh web 启动时读 `cordis.patch.yml`,对每个 `name` 字段做 Node require.resolve,命中 symlink。 ### 1.6 数据流总图 ``` ┌──────────────────────────────────────────────────────────────────────────┐ │ dsh web process (Node) │ │ ┌─────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ │ │ dsh-base bundle │ │ dsh-web-app bundle │ │ user patch entries│ │ │ │ (model, tools, │ │ (webserver, │ │ (your cordis.yml │ │ │ │ session, settings, │ │ web-runtime, │ │ ↑ auto-injected) │ │ │ │ ...host plane) │ │ browser roster) │ │ │ │ │ └─────────────────────┘ └────────────────────┘ └────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────┘ │ │ Node HTTP: │ GET / → apps/web/dist/index.html (host-built dist) │ GET /assets/* → apps/web/dist/assets/* │ GET /plugins//client.js → symlinked app//lib/client.js │ GET /api/... → RPC (host routes) │ WebSocket → server events (chat, agent, etc.) │ ┌──────────────────────────────────────────────────────────────────────────┐ │ Browser (Vite-served dist) │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ shell (apps/web → dist) │ │ │ │ ├── boot page (CSS-Modules + Cordis Loader bootstrap) │ │ │ │ ├── read window.__DSH_BOOT__ → list of plugin rows │ │ │ │ └── for each row: __ModuleLoader__.load({id, factory}) │ │ │ │ ↓ │ │ │ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │ │ │ @dhs-pro/demo-cordis│ │ @dhs-pro/ui-tweak │ │ │ │ │ │ (Cordis client apply)│ │ (Cordis client apply)│ │ │ │ │ └──────────────────────┘ └──────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────────┘ ``` --- ## §2 启动与命令 ### 2.1 第一次启动 ```sh # 1. clone(含 submodule) git clone --recurse-submodules git@github.com:/deepseek-harness-pro.git cd deepseek-harness-pro # 2. 安装(preinstall 自动 setup.sh) pnpm install # 3. 完整 build:upstream dsh cli + 宿主 web pnpm run build # 4. 启动 pnpm dsh:web # 零参数,自动加载所有 app/* 插件 ``` 浏览器开 `http://127.0.0.1:4567`(默认 web 端口)。 ### 2.2 日常命令 | 命令 | 作用 | |---|---| | `pnpm dsh:web` | 启动 dsh web(**自动加载所有插件**,零参数) | | `pnpm dsh-pro:web` | 同上(语义别名) | | `pnpm run build` | 完整 build:upstream dsh cli + 宿主 web dist 覆盖 | | `pnpm run build:web-only` | 仅重建宿主 web + 覆盖;上游 dsh cli 未变时用 | | `pnpm run build:host-web` | 只跑 `app/web` 的 vite build | | `pnpm run build:upstream` | 只跑 upstream `pnpm run build` | | `pnpm demo:watch` / `pnpm ui-tweak:watch` | tsdown watch,自动重建 lib/ | | `pnpm demo:bundle` / `pnpm ui-tweak:bundle` | 一次性 build | | `pnpm run setup` | 仅跑 `scripts/setup.sh`(重新 init submodule + link + inject) | | `pnpm run apps:link` | 仅重跑 symlink + patch 注入 | ### 2.3 插件开发循环(推荐) 两个终端: ```sh # 终端 1 —— 编译 pnpm demo:watch # tsdown watch 模式,src/ 改了自动重建 lib/ # 终端 2 —— dsh 服务 pnpm dsh:web # 启动 dsh web(一旦启动后改 cordis.yml/patch 不需要重启) ``` 修改 `app//src/client/*` → tsdown 自动重建 → 浏览器刷新(Cmd-R)即可。 `app//package.json` 或 `cordis.patch.yml` 改了 → 跑一次 `pnpm install`(更新 symlink + 同步 `dsh.profile.bundles`)。 ### 2.4 排查命令 ```sh # 看 profile manifest 的 dsh.profile.bundles(应含所有 @dhs-pro/* 插件) cat ~/.dsh/profile*/web/package.json # 看 symlink 状态 ls -la ~/.dsh/profile*/web/node_modules/@dhs-pro/ # 手动重跑 symlink + bundle sync pnpm run apps:link # 仅 symlink pnpm run profile:sync # 仅 bundle sync # 看 dsh web 启动时实际加载了哪些 plugin pnpm dsh:web --verbose ``` --- ## §3 写一个新插件 最快路径: ```sh # 1. 复制 demo-cordis 骨架 mkdir app/my-plugin cp -r app/demo-cordis/{src,tsconfig.json,tsdown.config.ts,package.json,README.md} app/my-plugin/ rm -rf app/my-plugin/lib app/my-plugin/node_modules # 2. 编辑四个关键文件 # package.json: 改 "name": "@dhs-pro/demo-cordis" → "@dhs-pro/my-plugin" # 确认 dsh.client.platform = "web" # 确认 dsh.bundle.patch = "./cordis.patch.yml" # dependencies 改为 npm:^0.1.1-rc.1 引用(不是 link:) # tsdown.config.ts: 改 PKG_NAME 常量 # cordis.patch.yml: 改 - id / name (文件名是 cordis.patch.yml 不是 cordis.yml) # src/client/index.ts: 改 apply(ctx) 实现 # 3. 注册到 workspace # 编辑 pnpm-workspace.yaml:在 - app/demo-cordis 下面加 - app/my-plugin # 4. 安装(自动 symlink + 同步 dsh.profile.bundles) pnpm install # 5. 编译 pnpm --filter @dhs-pro/my-plugin run bundle # 6. 启动 pnpm dsh:web ``` 如果只是想确认它能不能跑起来,最少只需: ```ts // app/my-plugin/src/client/index.ts import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' export const inject = ['slots'] export function apply(ctx: ClientContext): void { ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'my-plugin-hello', }, function HelloWorld() { return
Hello from @dhs-pro/my-plugin!
})) } ``` ```yaml # app/my-plugin/cordis.yml - insert: - id: my-plugin name: '@dhs-pro/my-plugin' ``` ```json // app/my-plugin/package.json { "name": "@dhs-pro/my-plugin", ... } ``` 加上 `pnpm install`(自动注入 patch) + `pnpm dsh:web`,你应该看到一个绿色框出现在 sidebar 底部。 --- ## §4 插件结构详解 ### 4.1 `package.json` ```json { "name": "@dhs-pro/my-plugin", // 必填:插件包名(scope 必须 @dhs-pro/) "version": "0.0.1", // 任意;不发布时可保持 0.0.1 "private": true, // 必填:不发 npm "type": "module", // 必填:ESM "main": "lib/index.js", // Node 端 entry "types": "lib/types/index.d.ts", // Node 端 d.ts "exports": { ".": { // Node 端 "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./invariant": { // invariant companion "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, "./client": { // 浏览器端(dsh.client 必需) "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, "./src/*": "./src/*", // 重要:允许 vite/typescript 直接 import 源码 "./package.json": "./package.json" }, "dsh": { // 必填:客户端插件 manifest "client": { "platform": "web" // 必填:唯一支持值 } }, "scripts": { "bundle": "tsdown", // 一次性 build "watch": "tsdown --watch", // 开发循环 "typecheck": "tsc --noEmit" // 类型检查 }, "license": "UNLICENSED", "dependencies": { // 客户端依赖(用 npm: 版本引用,pnpm 自动从 npm registry 拉) "@deepseek-ai/dsh-client-locale": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-client-runtime": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-client-ui-primitives": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-client-ui-slots": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-invariants": "npm:^0.1.1-rc.1" }, "peerDependencies": { "@deepseek-ai/cordis": "*", // dsh 安装自带(~/.dsh/node_modules);plugin 不直接依赖 "@types/react": "~18.3.1", "react": "^18.2.0" }, "devDependencies": { "@types/react": "~18.3.1", "react": "^18.2.0", "tsdown": "^0.22.2", // build 工具 "@tsdown/css": "^0.22.2", // CSS Modules 支持 "typescript": "^5.5.0" }, "files": [ // 决定哪些文件被包含(pnpm pack 时只有这些进 tarball) "lib/index.js", "lib/index.d.ts", "lib/invariant.js", "lib/invariant.d.ts", "lib/client.js", "lib/style.css", "cordis.patch.yml", "README.md" ] } ``` ### 4.2 `cordis.patch.yml` **入口声明** —— 描述该插件怎么被加载。文件名是 **`cordis.patch.yml`**(不是 `cordis.yml`)——对应 `package.json#dsh.bundle.patch` 字段: ```yaml # cordis.patch.yml — patch overlay for the host-side . # # Consumed by dsh web via: # - this file is shipped inside the plugin's npm tarball # - dsh's loadProfile() reads /package.json#dsh.bundle.patch # and loads this file as a layer in the composed patch stack # - the profile manifest dsh.profile.bundles lists this plugin's # name so the layer gets applied # `id` 是稳定身份(patch 排序、识别、override 锚点)。 # `name` 是 Node 可解析的包名(resolve 时通过 profile 的 node_modules 命中)。 - insert: - id: my-plugin name: '@dhs-pro/my-plugin' ``` 也可以同时**覆盖**upstream 的某行: ```yaml # 想关掉 upstream 的 hmr(disable 该 plugin) - id: hmr disabled: true # 改写 upstream 的某个 config - id: tools config: mode: !!js process.env.DSH_TOOLS_MODE ``` ### 4.3 `tsconfig.json` ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "isolatedModules": true, "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "rootDir": "src", "outDir": "lib/types", "baseUrl": ".", "paths": { "@deepseek-ai/cordis": ["../../deepseek-harness/vendor/cordis/src/index.ts"], "@deepseek-ai/dsh-client-runtime": ["../../deepseek-harness/packages/client/runtime/src/index.ts"], "@deepseek-ai/dsh-client-runtime/client": ["../../deepseek-harness/packages/client/runtime/src/client/index.ts"], "@deepseek-ai/dsh-client-ui-slots": ["../../deepseek-harness/packages/client/ui-slots/src/index.ts"], "@deepseek-ai/dsh-client-ui-primitives": ["../../deepseek-harness/packages/client/ui-primitives/src/index.ts"], "@deepseek-ai/dsh-client-locale": ["../../deepseek-harness/packages/client/locale/src/index.ts"], "@deepseek-ai/dsh-invariants": ["../../deepseek-harness/packages/runtime-diagnostics/invariants/src/index.ts"] } }, "include": ["src"] } ``` `paths` 让你直接 typecheck submodule 源码(不需要先 build upstream)。 ### 4.4 `tsdown.config.ts` `tsdown` 同时打 Node + Client 两个 bundle: ```ts import { defineConfig } from 'tsdown' const PLATFORM_EXTERNALS = [ 'react', 'react/jsx-runtime', 'react-dom', '@deepseek-ai/cordis', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-runtime/client', ] const PKG_NAME = '@dhs-pro/my-plugin' // Node 半:ESM,给 Cordis loader 在 server 启动时 require。 const nodeConfig = defineConfig({ name: 'node', entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' }, outDir: 'lib', format: 'esm', platform: 'neutral', external: PLATFORM_EXTERNALS, dts: { outDir: 'lib/types', tsconfig: './tsconfig.json' }, }) // 浏览器半:CJS + factory wrapper,被 window.__ModuleLoader__.load() 用 require() 加载。 const clientConfig = defineConfig({ name: 'client', entry: { client: 'src/client/index.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', external: PLATFORM_EXTERNALS, dts: false, clean: false, define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'), 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }), }, outputOptions: { entryFileNames: 'client.js', // 关键:把 CJS 包成 window.__ModuleLoader__.load({id, factory}) 的形式 banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PKG_NAME)}, factory: (require) => {`, footer: 'return module.exports; } });', intro: 'var module = { exports: {} }; var exports = module.exports;', }, }) export default [nodeConfig, clientConfig] ``` ### 4.5 `src/` 目录 ``` src/ ├── index.ts Node 端 apply(客户端插件几乎都是空) ├── invariant.ts 包级 invariant companion(运行时健康检查) ├── css-modules.d.ts `*.module.css` 的 ambient module 声明 └── client/ 浏览器端(核心实现) ├── index.ts apply(ctx) + 一切 hooks ├── locales.ts i18n 字面联合字典 └── .tsx + .module.css ``` #### `src/index.ts` (Node 端,几乎是空) ```ts export function apply(): void {} ``` **客户端插件(只贡献 UI)的 Node 端不需要做任何事**。Cordis loader 通过 `lib/index.js` require 这个模块拿 `apply`(空操作)就算挂载成功。如果你的插件是 node 端工具 / LLM adapter / hook 插件,则这里写完整的 apply 实现。 #### `src/invariant.ts` ```ts import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@dhs-pro/my-plugin' export const name = 'my-plugin-invariant' export const inject = ['invariants'] const install: InvariantInstaller = () => {} // 客户端 UI 插件没有 mutable state,留空 export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) ``` #### `src/css-modules.d.ts` ```ts declare module '*.module.css' { const classes: Record export default classes } declare module '*.css' ``` #### `src/client/locales.ts` ```ts export const zh = { helloLabel: '你好', modalTitle: '示例插件', } as const export const en = { helloLabel: 'Hello', modalTitle: 'Demo Plugin', } satisfies Record export type DemoKey = keyof typeof zh ``` `zh` 是字面联合的来源(key 集合);`en` 用 `satisfies` 检查覆盖完整;`DemoKey` 在 `LocaleNamespaceMap` 声明里用。 #### `src/client/index.ts` —— 浏览器端 apply(核心) 模板见 §5。 --- ## §5 客户端 `apply(ctx)` 写法 ### 5.1 形态选择 Cordis 三种 plugin 形态(详见 [`plugin-development.md` §3](plugin-development.md#3-三种插件形式)): ```ts // (a) 函数(最常用,UI 插件都这样) export function apply(ctx: ClientContext): void { { ... } // (b) 对象(当需要 named exports 如 name / Config) export const name = 'my-plugin' export const inject = ['slots', 'locale'] export function apply(ctx: ClientContext): void { ... } // (c) Service 子类(当你要提供一个新的 Cordis 服务) export class MyService extends Service { static inject = ['slots'] constructor(ctx: Context) { super(ctx, 'myService') // 在这里挂 effects / 注册 slot / 等 } } ``` **客户端 UI 插件** 几乎都用 (a) 或 (b)。 ### 5.2 最小可运行的 UI 插件 ```ts // src/client/index.ts import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' export const inject = ['slots'] export function apply(ctx: ClientContext): void { ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'my-plugin-hello', }, function HelloWorld() { return
Hello from @dhs-pro/my-plugin!
})) } ``` `ctx.slots.inject('sidebar.footer.action', () => ...)` 是 "等到 sidebar.footer.action 被某 upstream 包声明" 时再注册;如果该 slot 当前已声明,`ctx.slots.register` 立即执行。 **期望效果**(`pnpm dsh:web` 后浏览器 `http://127.0.0.1:4567`): - 侧边栏底部出现一个浅色按钮(无 label,因为 `wide=false`),鼠标悬停时浅色背景 - 浏览器 Console 无报错;Network 中 `/plugins/@dhs-pro/my-plugin/client.js` 返回 200,body 以 `window.__ModuleLoader__.load({ id: "@dhs-pro/my-plugin", … })` 起首 > 注:本仓用 `ctx.slots.inject` 而非 `ctx.slots.register`,因为 `sidebar.footer.action` 是由 upstream `ui-sidebar` 声明的;用 `inject` 还能在 upstream 还未挂载时优雅等待——一旦声明出现就立即注册。Cordis 内核对 `register` vs `inject` 的语义差异见 [`cordis-tutorial` 相关章节](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/)。 ### 5.3 添加 i18n ```ts // src/client/index.ts import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { HelloPanel } from './HelloPanel.tsx' import { en, zh, type DemoKey } from './locales.ts' // 在 LocalesNamespaceMap 上声明你的 namespace declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { 'my-plugin': DemoKey } } const NS = 'my-plugin' export const inject = ['slots', 'locale'] export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { 'en-US': en, 'zh-CN': zh }), 'my-plugin: dictionaries') ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ name: 'sidebar.footer.action', id: 'my-plugin-hello', locale: NS, }, HelloPanel)) } ``` ```ts // src/client/HelloPanel.tsx import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { useSessions } from '@deepseek-ai/dsh-client-ui-slots' export type HelloPanelProps = PropsRuntime<'sidebar.footer.action'> & PropsLocale<'my-plugin'> export function HelloPanel({ wide, t, useSession }: HelloPanelProps) { // wide 是该 slot 注册时的 wide flag;t 是 locale translator;useSession 是 framework hook return ( ) } ``` ### 5.4 多个 slot 注册(atomically with generator) 当需要一次写多个相关 entry 到同一 slot 且要保证"全部安装成功或全部回滚",用 generator: ```ts export function apply(ctx: ClientContext): void { ctx.slots.inject('tool.call.toolview', function* () { yield ctx.slots.register({ name: 'tool.call.toolview', key: 'my-approve', locale: NS, }, MyApproveView) yield ctx.slots.register({ name: 'tool.call.toolview', key: 'my-decline', locale: NS, }, MyDeclineView) }) } ``` 详见 §6。 ### 5.5 监听事件(hooks) ```ts export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.on('internal/status', (fiber) => { const entry = fiber.entry if (entry === undefined || entry.fiber === undefined) return console.log('plugin status:', entry.options.name, fiber.state) }), 'my-plugin: status listener') } ``` `ctx.on()` 本身是 effect,自动随插件卸载。 ### 5.6 共享 store ```ts // src/client/myStore.ts import type { StoreHandle } from '@deepseek-ai/dsh-client-ui-slots' export interface MyStoreState { count: number } export interface MyStoreActions { inc(): void dec(): void } // 工厂(不是 handle) export function createMyStore(): StoreHandle { let count = 0 return { create() { return { get: () => ({ count }), actions: { inc: () => { count += 1; /* notify */ }, dec: () => { count -= 1; /* notify */ }, }, } }, } } ``` ```ts // src/client/index.ts import { createMyStore } from './myStore.ts' const myStore = createMyStore() // 模块级 handle;只能在 apply 内 .create() export function apply(ctx: ClientContext): void { ctx.slots.inject('some.slot', () => ctx.slots.register({ name: 'some.slot', id: 'some-id', store: myStore, }, MyView)) } ``` Store handle **必须在 `apply` 闭包内** `create()`,**不可**模块级单例。 --- ## §6 Slot 系统详解 ### 6.1 `register` vs `inject` | | `ctx.slots.register(opts, Component)` | `ctx.slots.inject(name, factory)` | |---|---|---| | 时机 | slot 已被某父条目声明时立即生效 | 等到声明出现后再调用 factory | | 适用 | "我知道 slot 已经存在" | "我希望贡献到一个尚未挂载的 slot" | | 卸载 | 调用 fiber dispose 即清 | 父条目声明消失时自动撤销;声明恢复时重挂;调用 fiber dispose 一并清 | | 返回 | disposer | disposer / generator(yield 多个注册 = 原子化) | | 使用 | `ctx.slots.register({ name: 'sidebar.footer.action', id: 'foo' }, MyView)` | `ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({ ... }, MyView))` | **`Try breaking it`**:把 §5.2 的代码改成 `ctx.slots.register`(去掉 `inject` 包一层)。期望:浏览器 Console 报 `slots.register: slot "sidebar.footer.action" not declared`;sidebar 没出现新按钮。这是**故意写错时的效果**——确认错误信号是不是你预期的。 Cordis 内核对 `register` vs `inject` 的差异详见 [`cordis-tutorial` 相关章节](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/)。 ### 6.2 Slot 类型(kind / scope) 每个 slot 在 `SlotMap` 里声明: ```ts declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { 'sidebar.footer.action': { kind: 'list'; scope: 'root' } 'conversation.hero.workspace': { kind: 'single'; scope: 'root' } 'tool.call.toolview': { kind: 'keyed'; scope: 'root' } 'composer.input.action': { kind: 'list'; scope: 'session' } } } ``` 注册时的选项依 kind 不同: | kind | 必需 | 选填 | |---|---|---| | `list` | `id` | `priority` | | `single` | `id`(同一 scope 内唯一) | `priority` | | `keyed` | `key`(跨 plugin 唯一) | — | ### 6.3 四种 props share ```ts type ComposedProps = & PropsRuntime // 框架注入:useSession / useSessions / useWorkspaces + owner params & PropsRenderSlots // 父条目的 children 表(slot 名 → 渲染函数) & PropsStore // useStore(selector) + actions.* & InjectFace // 业务面(apply 内 inject 工厂返回的纯数据/回调) & PropsLocale // t('key') 翻译器 ``` **纪律**(`packages/client/AGENTS.md`): - ✅ 派生这四个 share,**绝不**手写 share 已经派生的成员 - ❌ 不在组件里调用 ctx service、import service class、读 React context - ❌ 不在组件里创建 hook 或 selector(应该来自 Props) - ❌ 不导出实现组件、helpers、常量、store handle ### 6.4 Slot 命名约定 `..`,例: - `sidebar.footer.action` — 侧边栏底部动作列表 - `conversation.hero.workspace` — 对话框头部工作区占位 - `tool.call.toolview` — 工具调用详情视图(keyed) - `composer.input.action` — 输入框周围动作 **给插件 slot 加 namespace 前缀**避免冲突。例如你的 slot `tool.call.toolview` keyed variant 写成 `my-plugin.tool.call.confirm-button` 而不是裸 `tool.call.confirm-button`。 发现/浏览所有 slot:`packages/client/ui-slots/src/index.ts` 的 `SlotMap` interface(向上游 submodule 内 `packages/client/ui-slots/src/index.ts` 查看最新定义)。 > **事件命名约定**(参考 [`cordis-tutorial/04`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/04-events.md)):`namespace/action`,例 `session/event`、`agent/pre-step`。本仓客户端 UI 插件一般**不直接**发事件;事件在 §5.5 监听。 --- ## §7 主题与样式定制 ### 7.1 改 `--dsw-*` CSS 变量 upstream `ui-theme` 暴露的 token: ```css :root { --dsw-alias-bg-elevated: #fff; --dsw-alias-bg-hover: rgba(0, 0, 0, 0.04); --dsw-alias-bg-accent-soft: rgba(0, 100, 200, 0.08); --dsw-alias-border-default: rgba(0, 0, 0, 0.1); --dsw-alias-border-strong: rgba(0, 0, 0, 0.2); --dsw-alias-label-primary: #0f1115; --dsw-alias-label-secondary: #5a6270; /* …完整列表见 deepseek-harness/packages/client/ui-theme/ */ } ``` 在插件的 `apply(ctx)` 里改: ```ts export function apply(ctx: ClientContext): void { const root = document.documentElement.style root.setProperty('--dsw-alias-bg-elevated', '#0d1117') root.setProperty('--dsw-alias-label-primary', '#e6edf3') // …其他 token } ``` 或者用 token 集合(如 `app/ui-tweak` 的 `themes.ts`): ```ts const midnight = { '--dsw-alias-bg-elevated': '#0d1117', '--dsw-alias-label-primary': '#e6edf3', } for (const [key, value] of Object.entries(midnight)) { document.documentElement.style.setProperty(key, value) } ``` ### 7.2 CSS Modules(每个组件的局部样式) 约定(`packages/client/AGENTS.md`): - 用 CSS Modules(`*.module.css`)+ `clsx`,**无**组件库、**无** Tailwind - 字号配行高;不内联主题分支;保留键盘焦点与 reduced-motion - **不**写字面颜色:用 `var(--dsw-alias-*)` 替代 - 中文产品文案 + 英文代码注释 ```css /* MyView.module.css */ .trigger { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--dsw-alias-border-default); background: var(--dsw-alias-bg-elevated); color: var(--dsw-alias-label-primary); cursor: pointer; } .trigger:hover { background: var(--dsw-alias-bg-hover); } ``` ```tsx import css from './MyView.module.css' export function MyView({ ... }: MyViewProps) { return } ``` ### 7.3 注入全局样式表(用于覆盖非组件结构) ```ts // src/client/styleSheet.ts export const TWEAK_STYLESHEET = ` :root { --dsw-tweak-radius: 10px; --dsw-tweak-shadow: 0 6px 28px rgba(0, 0, 0, 0.18); } [ [class*='card'] ] { border-radius: var(--dsw-tweak-radius) !important; } ` export const STYLE_ELEMENT_ID = 'dsh-tweak-sheet' ``` ```ts // src/client/index.ts import { STYLE_ELEMENT_ID, TWEAK_STYLESHEET } from './styleSheet.ts' export function apply(ctx: ClientContext): void { if (document.getElementById(STYLE_ELEMENT_ID) === null) { const el = document.createElement('style') el.id = STYLE_ELEMENT_ID el.textContent = TWEAK_STYLESHEET document.head.appendChild(el) } // …effects 释放时删除这个 element } ``` ### 7.4 限制(必须知道) Cordis slot 系统是**声明式且叠加**的: - ✓ 加新按钮 / 新动画 / 新事件 / 新主题 - ✓ 改 token 影响所有读 token 的部件 - ✓ 自己的 component 内 CSS Module 自由 - ✗ **不能**直接覆盖 upstream shell 已注册的 component(slot 不允许覆盖) 要"换掉"某个 upstream 已注册部件,必须先在 `effects.ts` 里给对应节点 `injectStyle` 或 `setAttribute`,或者开新 slot。 --- ## §8 Bundle 与发布 ### 8.1 本仓插件只是 workspace package `app//` 的 build 产物**不**发布 npm(`package.json` 是 `"private": true`),只在本地被 `dsh web` 通过 symlink 加载。 ### 8.2 如果要发布到 npm(可选) 如果你想把插件独立发布: 1. 改 `package.json`:去掉 `"private": true`,设置 `"publishConfig": { "access": "public" }` 2. `pnpm publish --filter @dhs-pro/my-plugin`(pnpm 会自动只 publish `files` 列表里的内容) 4. 然后用 `npm install @dhs-pro/my-plugin` 安装到任意 dsh profile,让 symlink 自动失效(被 npm 真实包取代) ### 8.3 Bundle 产物说明 `pnpm --filter @dhs-pro/my-plugin run bundle` 后产出: ``` lib/ ├── index.js # Node 端(empty apply + d.ts + invariant) ├── invariant.js ├── client.js # 浏览器端(CJS + factory wrapper) └── types/ ├── index.d.ts ├── invariant.d.ts └── client/ └── index.d.ts ``` **关键**:`lib/client.js` **不是**普通 ESM,而是被 `window.__ModuleLoader__.load(...)` 包裹的 CJS factory: ```js window.__ModuleLoader__.load({ id: "@dhs-pro/my-plugin", factory: (require) => { var module = { exports: {} }; var exports = module.exports; // …CJS module body… return module.exports; } }); ``` 浏览器 module table 拿到 `factory(require)` 后调 `factory(require)` 拿到 `{ apply, inject, name, Config }`,传给 Cordis 的 `ctx.plugin(...)`。 ### 8.4 HMR `dsh-client-hmr` 在 dev 模式自动 stat-poll bundle 文件,调用 `ClientModuleRegistry.rebuilt(id)` 重新哈希,并通过 SSE 广播。浏览器重新拉取 bundle 并重挂 fiber——**应用层无感**。 但要注意: - 改了 `package.json`(新增 dep)需要重启 `dsh web` 才能生效(HMR 不重 build 子模块 deps) - 改了 `cordis.yml`(id/name)需要 `pnpm install` 重新注入 patch --- ## §9 测试与验证 ### 9.1 类型检查 ```sh pnpm --filter @dhs-pro/my-plugin run typecheck ``` 注意:本仓插件的 typecheck 会**顺着 `paths`** 走到 submodule 源码,遇到 submodule 自身的源码类型问题(如 `vendor/cordis` 里的 TS 错误)会一起报错——这些是 upstream 的问题,**不归本插件管**。 实际开发更多依赖 vite build 时的 transpile + 浏览器运行时验证。 ### 9.2 Bundle smoke test ```sh pnpm --filter @dhs-pro/my-plugin run bundle # 看到 ✓ [client] Build complete # 看到 ✓ [node] Build complete # ls lib/client.js 存在 ``` ### 9.3 浏览器手动验证 ```sh # 终端 1 pnpm demo:watch # 或 pnpm --filter @dhs-pro/my-plugin run watch # 终端 2 pnpm dsh:web ``` 浏览器开 `http://127.0.0.1:4567`,**F12 → Console** 看是否有插件错误;**F12 → Network** 看 `/plugins/@dhs-pro/my-plugin/client.js?rev=xxx` 是否 200。 ### 9.4 集成验证 插件被加载后,**应该**在 dsh web 的 stderr 看到类似: ``` [client-modules] registered 1 plugin(s): @dhs-pro/my-plugin (rev=abc123) ``` 如果插件**没出现**在已注册列表,常见原因: | 现象 | 原因 | |---|---| | 出现在 registered 但浏览器没看到 UI | `cordis.yml` 的 slot 名写错,或 register 抛错 | | 完全没出现 | symlink 没建(看 §10) | | 出现在 registered 但 `ctx.plugin` fail | apply() 抛错,看 Console | | 出现 404 in Network | bundle 没 build(`pnpm --filter ... run bundle`) | ### 9.5 提交前自检 - `pnpm install` 干净(无 deprecation warning) - `pnpm --filter @dhs-pro/my-plugin run bundle` 干净 - 浏览器手动验证 1 个完整 session 流程 - 没有破坏任何 upstream slot 的现有 UI(slot 是叠加的,但你的 register 可能写错 id 导致冲突) --- ## §10 常见坑(主动实验清单) 每个坑给一段**主动复现**的实验——跑一遍确认问题在不在你这边,再看修复方案。 ### 10.1 不要修改 `deepseek-harness/` **实验**:在 submodule 内改一个文件(例如 `echo "x" >> deepseek-harness/AGENTS.md`),然后 `git status`。 **预期**:`git status` 的 `Changes not staged for commit` 块出现 `modified: deepseek-harness`,**且**你的 commit 会带这个 submodule diff。 **原因**:`deepseek-harness/` 是 git submodule,tracked 文件的任何修改都进 submodule 的工作区——下次 `git submodule update` 会丢失。 **特例合法**:untracked 文件(如 `node_modules/`、build 产物);host repo 的 `pnpm run build` 把 `app/web/dist/` 覆盖到 submodule 的 `apps/web/dist/`(在 submodule 的 `.gitignore` 里)。 ### 10.2 不要用 `@deepseek-ai/*` scope **实验**:把 `app/demo-cordis/package.json` 的 `"name"` 改成 `@deepseek-ai/dsh-demo-cordis`,跑 `pnpm install && pnpm demo:bundle && pnpm dsh:web`。 **预期**:浏览器 Console 报错或 sidebar 出现两个同名按钮(你覆盖了 upstream 的某个包)。 **原因**:`@deepseek-ai/*` 是 deepseek 官方 npm namespace;同名包让 Loader 的 `node_modules/@deepseek-ai/...` walk 解析到两个目录时按 pnpm 决定,**会**与 upstream 冲突。 **修复**:永远用 `@dhs-pro/`。唯一例外是 `app/web`(必须叫 `@deepseek-ai/dsh-web-frontend` 让 `require.resolve` 命中——这是技术约束不是命名空间抢占)。 ### 10.3 `dsh.profile.bundles` 同步的幂等性 **实验**:跑 `bash scripts/setup.sh` 三次。 **预期**:每次输出同一行 `dsh.profile.bundles = [...]`;`~/.dsh/profiles/web/package.json` 数组内容**不变**。 **原因**:`sync-profile-bundles.sh` 只**追加**不存在的 `@dhs-pro/*` 名到 `dsh.profile.bundles`;不动 upstream 自带的 `@deepseek-ai/dsh-base` 等;不写 `cordis.patch.yml`(用户层)。 如果发现 `dsh.profile.bundles` 出现重复条目——检查 `app//package.json#dsh.bundle.patch` 字段是否拼写错。 ### 10.4 profile 目录首次不存在 **实验**:删 `~/.dsh/profiles/web/`,跑 `pnpm install`。 **预期**:`scripts/setup.sh` 输出 `profile $profile_dir does not exist yet; skipping`。 **原因**:`link-app-workspaces.sh` 和 `sync-profile-bundles.sh` 都对不存在的 profile no-op(避免在空目录上写 `package.json`)。 **修复**:先 `pnpm dsh:web` Ctrl-C 让 dsh 自己初始化 profile,再 `pnpm install`。 ### 10.5 dsh web 启动失败:`lefthook` postinstall 错 **实验**:手动 `pnpm --dir deepseek-harness install`(不带 `--ignore-scripts`)。 **预期 stderr**: ``` [install-lefthook] cannot enable extensions.worktreeConfig while core.worktree is in the common config (file:/…/.git/modules/deepseek-harness/config: "../../../deepseek-harness"); move it to the main worktree config first … [ELIFECYCLE] Command failed with exit code 1. ``` **原因**:submodule 模式下 git 拒绝在 common config 区开 `extensions.worktreeConfig`;`install-lefthook.mjs` 强制要做。 **修复**:`scripts/setup.sh` 已用 `--ignore-scripts` 绕开;如果手动跑请加 `--ignore-scripts`。装 lefthook 钩子不是必需的(host repo 不需要)。 ### 10.6 vite dev 失败:`@deepseek-ai/dsh-client-web` 找不到 **实验**:`rm -rf deepseek-harness` 然后 `pnpm install && pnpm run web`。 **预期**: ``` Failed to resolve entry for package "@deepseek-ai/dsh-client-web" The package may have incorrect main/module/exports specified in its package.json ``` **原因**:`app/web/vite.config.ts` 用 vite alias 解析 `@deepseek-ai/*` 指向 submodule 源码;submodule 缺失就找不到。 **修复**:`pnpm install`(preinstall → `setup.sh` → 自动 `git submodule update --init --recursive` + submodule install)。 ### 10.7 插件改动未生效 **实验**:改 `app/demo-cordis/src/client/index.ts` 一行(比如 console.log),跑 `pnpm demo:bundle`,**不**重启 dsh web,刷新浏览器。 **预期**:浏览器 Console 没出现你的 log(dev 模式 HMR 应该 reload,但 `dsh web` 启动用的是已 build 的 dist,所以 dist 没变)。 **排查**: 1. **bundle 是否新**:`ls -la app/demo-cordis/lib/client.js` 时间戳是刚才? 2. **是否覆盖到 submodule dist**:`ls -la deepseek-harness/apps/web/dist/assets/ | head`,对比 timestamp 与 `app/demo-cordis/lib/client.js` 3. **是否走覆盖脚本**:`pnpm run build:web-only`(只重建 web dist + copy) 任何一步时间戳不对,按对应章节排查。 ### 10.8 浏览器报错 `Cannot find module ...` **实验**:故意把 `app/demo-cordis/tsdown.config.ts` 的 `PLATFORM_EXTERNALS` 删 `'@deepseek-ai/dsh-client-runtime/client'`,跑 `pnpm demo:bundle && pnpm dsh:web`。 **预期 stderr**: ``` Error: Cannot find module '@deepseek-ai/dsh-client-runtime/client' ``` **原因**:tsdown 把 `@deepseek-ai/dsh-client-runtime/client` 当 external 期望运行时由浏览器 module table 提供——但你没声明它,rollup 试图解析实际路径找不到。 **修复**:在 `PLATFORM_EXTERNALS` 加上漏掉的包。带 `/client` 子路径的尤其容易漏。 ### 10.9 Slot 注册不生效 | 现象 | 实验 | 可能原因 | |---|---|---| | 注册不报但不渲染 | 把 §5.2 的代码改成 `ctx.slots.register`(去掉 inject 包裹),刷新浏览器 | slot 未声明;用 `ctx.slots.inject` 包一层等声明 | | 渲染但样式错 | `app/web/HelloWorld.module.css` 没引用 | tsdown 缺 `@tsdown/css` | | 渲染但 props 缺 | locale namespace 没合并 | 漏 `declare module '@deepseek-ai/dsh-client-ui-slots'` | | 类型通过运行时崩 | 编译过但 import 路径大小写错 | 文件名 vs import path 不一致 | 每行都可以**主动复现**确认你的问题在哪一类。 ### 10.10 slot id 与 upstream 冲突 **实验**:把 `app/demo-cordis/cordis.patch.yml` 的 `- id: ui-demo-cordis` 改成 `- id: ui-renderer`(upstream 已用),跑 `pnpm install && pnpm dsh:web`。 **预期 stderr**:`loader: cannot mount plugin — id 'ui-renderer' already registered`。 **原因**:upstream 的 `ui-renderer` 已声明并挂载;同一 id 二次注册抛错。**注意**:id 在 Cordis Loader 的 tree 中是稳定锚点,HMR diff 也以 id 为准——随机改 id 会让每次编辑都触发重挂。 **修复**:plugin 自己用 `-` 命名空间前缀(如 `ui-demo-cordis`、`my-plugin-action-button`),不要裸用上游名。 --- ## §10.11 "插件没生效"完整排查 checklist 当 §10.7 的 4 步都看起来对,但插件还是不工作时,跑这条 checklist: ```sh # 1. symlink 存在且指向正确 ls -la ~/.dsh/profiles/web/node_modules/@dhs-pro/ # 2. profile manifest 列了 bundle grep -A5 bundles ~/.dsh/profiles/web/package.json # 3. bundle 真实存在且最新 ls -la app//lib/client.js # 4. dist 被覆盖到 submodule ls -la deepseek-harness/apps/web/dist/assets/ | head # 5. dsh web 启动 stderr 看 plugin mount 日志 pnpm dsh:web 2>&1 | grep -i "plugin\|error" | head -20 # 6. 浏览器 Network 看 client.js 是否 200 # DevTools → Network → /plugins/@dhs-pro//client.js ``` 任何一步异常,跳到对应 §10.x。 --- ## §11 与上游文档的边界 本文档专注 **host repo(`deepseek-harness-pro`)的插件开发工作流**:写在哪里、用什么 scope、怎么热插拔、怎么与 dsh profile 集成。 **不在本文档覆盖**的 Cordis 内核细节(plugin 形态、Config schema、Hook 协议、Capability Seam 角色分工、Bundle 打包规则、Test 规约、Web 样式 token 完整列表等),请看: - [`cordis-tutorial/`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cordis-tutorial/) — **从零跑的 7 章教程**(`01` first plugin → `07` into the harness);最贴近"我刚接触 Cordis"的入口 - [`plugin-development.md`](plugin-development.md) — Cordis 内核机制 + dsh-base/web-app 内部细节,1200+ 行 - [`web-api.md`](web-api.md) — `pnpm dsh web` 的浏览器↔Node HTTP 协议完整参考,2000+ 行 - [`deepseek-harness/docs/AGENTS.md`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/AGENTS.md) — 上游 docs 目录规约 - [`deepseek-harness/docs/cookbook/`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/cookbook/) — 任务型 cookbook(add a tool / add a package / add a settings card / …) - [`deepseek-harness/docs/subsystems/web.md`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/web.md) — 上游 web 端子系统深读 - [`deepseek-harness/docs/subsystems/client-modules.md`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/client-modules.md) — Loader 扫包 / 合成 `__DSH_BOOT__` / 服务 `/plugins//client.js` 的全部细节 - [`deepseek-harness/packages/client/AGENTS.md`](https://github.com/deepseek-ai/deepseek-harness/blob/main/packages/client/AGENTS.md) — 客户端栈栈规约(Slot、Props、Module、Styling 等的硬约束) > **本仓 §0.1 "阅读路径"** 已经把本文档与 `cordis-tutorial` 的边界画清楚,遇到 Cordis 内核问题先去那里(也避免了文档 tier 重复)。 **何时看哪个文档**:见本节下方的"你想…看哪里"表格。 --- ## §12 发布插件到 npm > dsh 的官方安装命令是 `dsh plugin --profile `—— 它把 args 转发给 pnpm,再 reconcile `dsh.profile.bundles`。所以插件**必须**走 npm(或 file/git)渠道:用户只要 `dsh plugin add ` 即可完成安装 + 自动加载,无需任何脚本改动。 ### 12.1 发布前清单 发布 `@dhs-pro/my-plugin` 前,确保 `package.json` 有以下字段: ```json { "name": "@dhs-pro/my-plugin", "version": "0.0.1", // 每次发布递增 "private": true, // ⚠️ 必须改为 false 才能 publish(见下) "type": "module", "main": "lib/index.js", "types": "lib/index.d.ts", "exports": { ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" }, "./invariant": { "types": "./lib/invariant.d.ts", "default": "./lib/invariant.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "dsh": { "client": { "platform": "web" }, "bundle": { "patch": "./cordis.patch.yml" } // ← 关键 }, "dependencies": { "@deepseek-ai/dsh-client-locale": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-client-runtime": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-client-ui-primitives": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-client-ui-slots": "npm:^0.1.1-rc.1", "@deepseek-ai/dsh-invariants": "npm:^0.1.1-rc.1" }, "peerDependencies": { "@deepseek-ai/cordis": "*", // dsh 安装自带(~/.dsh/node_modules),plugin 不直接依赖 "react": "^18.2.0", "@types/react": "~18.3.1" }, "files": [ "lib/index.js", "lib/index.d.ts", "lib/invariant.js", "lib/invariant.d.ts", "lib/client.js", "lib/style.css", "cordis.patch.yml", // ← 必须列出 "README.md" ] } ``` **关键点**: 1. **`private: true` → 改为 `false`**(或加 `"publishConfig": { "access": "public" }`) 2. **`dsh.bundle.patch`** 必须指向 `./cordis.patch.yml`(**文件命名是 patch.yml**),让 `dsh plugin add` 后 dsh 自动把插件加入 layer stack 3. **`dsh.client.platform`** = `"web"`(也必须),让 client-modules Loader 扫描到并加载到 `__DSH_BOOT__` 4. **dependencies 必须用 `npm:`**版本号(不是 `link:`),发布后没有本仓 `deepseek-harness/`子模块 5. **`@deepseek-ai/cordis`** 移入 `peerDependencies`(dsh 安装自带,不需插件自带) 6. **`files` 列出所有运行时必需文件**,特别是 `cordis.patch.yml` ### 12.2 发布步骤 ```sh # 1. 一次性登录 npm npm login # 2. 构建产物 pnpm --filter @dhs-pro/my-plugin run bundle # 3. (可选)本地 pack 验证 tarball 内容 pnpm pack --filter @dhs-pro/my-plugin tar -tzf dhs-pro-my-plugin-0.0.1.tgz | grep -E "patch|client.js|package.json" # 4. 发布 pnpm publish --filter @dhs-pro/my-plugin # 或 pnpm publish --filter @dhs-pro/my-plugin --dry-run 先验证 ``` > 简化:`pnpm :pack` 跑 bundle + pack(仓库根 `package.json` 已提供)。 ### 12.3 dsh 用户安装 ```sh # 用户视角 dsh plugin --profile web add @dhs-pro/my-plugin ``` 机制: 1. dsh 把 `add @dhs-pro/my-plugin` 转发给 `pnpm add @dhs-pro/my-plugin`(在 profile 目录里) 2. pnpm 装包到 `~/.dsh/profiles/web/node_modules/@dhs-pro/my-plugin`,**自动**拉它的 npm deps(`@deepseek-ai/dsh-client-*`) 3. dsh `reconcilePlugins` 读 `dependencies` + 读每个包的 `dsh.bundle` 字段,发现是 bundle → append 到 `dsh.profile.bundles` 4. dsh 重启后 `loadProfile` 自动读 `cordis.patch.yml` 到 layer stack 5. client-modules Loader 监听到 `internal/plugin` → 读 `package.json#dsh.client` → 加入 `__DSH_BOOT__` → browser 加载 **用户完全不需要手动编辑 `cordis.patch.yml` 或 `dsh.profile.bundles` —— 全自动**。 ### 12.4 用户更新 / 卸载 ```sh dsh plugin --profile web update @dhs-pro/my-plugin # 升级到最新版本 dsh plugin --profile web remove @dhs-pro/my-plugin # 卸载 dsh plugin --profile web list # 看当前已装包 ``` `update` 会让包重新经历 reconcile;如果新版本**增加了** `dsh.bundle.patch` 字段(或改进了 `cordis.patch.yml`),会立即加入 layer stack(`update` 后必须重启 dsh web 才生效)。 `remove` 让 pnpm 卸载包;reconcile 发现 dep 不在了,从 `dsh.profile.bundles` 移除。 ### 12.5 发布非 npm 安装(如 git URL) 不想走 npm 也可以。dsh plugin 是 pnpm forwarder,pnpm 接受: ```sh dsh plugin --profile web add github:my-org/my-plugin dsh plugin --profile web add git+ssh://git@github.com/my-org/my-plugin.git dsh plugin --profile web add file:/path/to/local/checkout dsh plugin --profile web add ./relative/path # 锚定到调用 cwd(防止 profile 自 link) ``` 发布成本:纯 git tag + dsh `dsh plugin add` 命令,无 npm 账号。 ### 12.6 与本仓开发模式的关系 发布后的插件 `dependencies` 改为 `npm:`版本号(脱离 `link:../../deepseek-harness/...`)。 本仓开发期仍通过 tsconfig `paths` 把 `@deepseek-ai/*` 解析到 submodule 源码(§4.3),typecheck 直接编 submodule 代码——这与发布后的 npm 安装不冲突:开发期 source-level,生产期 install-level。 构建产物 `lib/` 在两种模式下都相同。 --- ## 附录 A:现有两个示例插件速查 ### A.1 `app/demo-cordis/` —— 多 Slot 注册范例 - **目的**:演示三个 slot 注册模式(list / single / keyed generator) - **slots**:`sidebar.footer.action`(list,"演示"按钮 → Modal 显示 session 数)、`conversation.hero.workspace`(single,hero 占位)、`tool.call.toolview`(keyed generator,`demo-approve`/`demo-decline` 两行原子注册) - **风格**:纯演示风格,本地默认主题 - **依赖**:仅 upstream Cordis + React - 跑法:`pnpm demo:bundle && pnpm dsh:web` ### A.2 `app/ui-tweak/` —— 主题 / 样式 / JS 特效范例 - **目的**:演示如何在不动 upstream shell 的前提下深度定制 web 视觉与交互 - **做法**: 1. 启动时 `setProperty('--dsw-*', ...)` 改写主题 token 2. 注入 `