这是《DeepSeek Harness 权威指南 》系列的第 8 篇,也是二开线(B 线)的起点。 代码基线:deepseek-harness @
47f9438(v0.1.0-rc.5);核验环境:Windows、Node v24.16.0、pnpm 11.7.0。本文的插件、配置、命令输出和 Web UI 截图均来自本机运行。
你写好了 hello.ts,终端没有报 TypeScript 错,cordis.yml 也看起来没问题。启动 dsh 后,什么都没发生。这个体验很容易让人把注意力放在 apply() 的代码上,然后开始往里面加日志、加工具、加依赖。
多数第一个插件的问题不在插件逻辑,而在“这段代码是否已进入运行时插件树”。 磁盘文件、配置树条目与 Node 已加载模块,是三件不同的事。少验证其中任意一层,都会得到一个看似成功、却没有启动的插件。
本篇只完成一个小目标:在源码 checkout 中创建一个本地 b0-hello 插件,把它挂到 Web profile,看到它的启动日志和 Web UI。为了让这个目标可复用,我们还要把两道验证的边界讲透:
--dump-config证明配置条目进入了树;dsh web证明 Loader 能动态 import 模块,并且apply(ctx)确实执行了。
这不是重复验证。它们分别隔离配置问题与运行时模块问题,尤其能避开 Windows 上最常见的 E:/... 路径陷阱。
本系列全部 demo 的完整代码见 rex-dhs-core/dsh-b0 (公开仓库,含运行证据)。
零、先建立一个心智模型:文件、接线与通电
传统 Node 项目里,写下 import './plugin.ts',代码通常就进入进程了。dsh 的插件不是这样进入系统。它要经过三层:文件存在、patch 把文件引用插进插件树、Loader 在启动时按 URL import 它。
把它想成接一台设备:
b0-hello.ts是设备本体;cordis.yml是接线图;- Loader 是合闸后的供电过程;
[b0-hello] mounted ...是设备亮起来的指示灯。
只看到接线图里画着设备,不等于设备已经通电。--dump-config 做的事很具体:它读取内置配置、当前 profile 配置、用户配置和这次命令传入的 patch,算出本次启动会有哪些插件条目,再把这份清单和每一层来源打印出来。它不会启动 Web 服务,不会打开 name 指向的 .ts 文件,也不会执行 apply(ctx)。
源码文件开头把这个范围直接写在注释里(apps/cli/src/dump-config.ts:1-7):
/**
* Config-dump entry for `dsh --profile <name> --dump-config`: compose the
* profile's patch layers through the include plugin's patch algorithm without
* booting or evaluating `!!js`, with one source layer per bundle, the
* profile's own patch file, and each `--patch` overlay.
* @module @deepseek-ai/dsh/dump-config
*/
这里的 without booting 指“不启动 Web 或其他运行形态”,without evaluating !!js 指“不执行配置里可能嵌入的 JavaScript”。对 B0 而言,结果就是:它只处理插件清单,不执行插件代码。
它的结论范围只到配置:b0-hello 已被选进本次插件清单。它不覆盖模块运行:Node 能否加载该文件、模块加载后是否报错,仍需通过真实启动验证。配置清单正确而 Web 启动失败并不矛盾;前者检查 YAML、patch 顺序和条目 id,后者检查文件 URL、模块导出与运行时异常。

图:模块文件先被 patch 引用,再由 Loader 动态 import。下方两张卡分别说明 --dump-config 与真实启动各自能证明什么。
一、先让源码 checkout 具备运行资格
官方 README 的“从源码运行”顺序是 clone → pnpm install → pnpm run build → pnpm dsh web(README.zh.md:25-35)。在敲第一条命令前,开发指南给出了四个明确前提:
| 前提 | 源码实际规定的内容 | 对第一个插件的影响 |
|---|---|---|
| Node.js | 支持 22.19+ 与 24+;本基线的根 package.json 要求 Node 满足 ^22.19.0 或 >=24.0.0 | 低于这个范围,不应先调试插件代码 |
| pnpm | 必须能通过 Corepack 使用;仓库固定 [email protected],若 pnpm --version 无法由 Corepack 解析,应先执行 corepack enable | 不要用随手安装的其他包管理器替代 workspace 安装 |
| Git | 需要 2.26 或更高版本 | pnpm install 会配置 worktree-local Lefthook hook 与 merge driver,旧 Git 会卡在仓库初始化而不是插件加载 |
| DeepSeek API Key | 仅真实模型、key-backed demo 和真实 API e2e 需要 | B0 只验证本地模块加载,因此不是前置条件 |
这些约束分别来自 docs/development.md:9-14;其中 Node/pnpm 的精确版本还由 package.json:7-10 固定。新 checkout 建议按下面顺序完成环境准备:
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run typecheck
pnpm run build
pnpm dsh web
这里有两种常被混为一谈的检查:
| 命令 | 回答的问题 | 不能替代什么 |
|---|---|---|
pnpm run typecheck | Host/Client 两个 TypeScript 聚合是否都能通过 | 不产出完整 Web bundle |
pnpm run build | 库产物和 Web 前端 bundle 是否都已生成 | 不检查你的 patch 是否把插件接进树 |
pnpm dsh web | profile 能否 boot,Web UI 是否运行 | 不告诉你 patch 的来源层级为何这样组合 |
官方开发指南把 pnpm run typecheck 退出成功定义为 fresh clone 的 setup 完成条件(docs/development.md:34-40)。而 pnpm run build 的根脚本明确由 build:lib 与 build:web 组成(package.json:19-24);CLI 参考则说明 source checkout 缺 Host 产物或前端 bundle 时,启动会出现模块解析错误或要求重新 build 的提示(apps/cli/reference/README.md:82-84)。
本机环境输出:
v24.16.0
11.7.0
47f9438
本次在该 checkout 实跑 pnpm run build 成功。构建日志会列出大量 workspace package;Web 阶段最终由 Vite 完成。开发中不要把“依赖已经装过”当成“浏览器产物是新鲜的”——CLI 不会替你判断 bundle 是否过期。
API Key 不属于本篇前置条件
开发指南的凭据约定可以直接写成四句话:
- 真实 DeepSeek adapter、依赖模型回答的 demo 与真实 API e2e 从进程环境或仓库根目录、已被 Git 忽略的
.env读取DEEPSEEK_API_KEY。 DEEPSEEK_BASE_URL也是可选环境变量;未设置时使用公共 API 地址。- 真实 API e2e 在没有
DEEPSEEK_API_KEY时会自行 skip,而不是把 keyless 的插件加载测试判成失败。 - 真实凭据不能提交进仓库;
.env是本地便利层,不是配置样例的替代品。
这四个条件来自 docs/development.md:90-99。第 1 节的前提表还说明,Web/headless/ACP 的 key-backed demo 才需要 key;本篇启动 Web profile、加载 b0-hello、读取 --dump-config 都不发模型请求。因此本机可以先点“稍后配置”进入 UI,再单独验证插件挂载。
这一区分很重要。先把插件的“能加载”与模型的“能回答”拆开,排障面会小很多。 B2 接自定义模型时才把凭据与 provider 放进同一个问题里。
二、两个文件:最小插件与一次性接线图
官方“第一个插件”教程给出的最小定义很克制:插件是导出 apply 的 TypeScript 模块,框架加载时把 ctx 传进来(docs/user/develop/basic/index.zh.md:15-29)。我们不在 B0 提前注册工具或监听事件,只让 apply 打一行日志。
1. 模块:scratch-plugin/src/b0-hello.ts
下面的文件已在本机真实启动:
import type { Context } from '@deepseek-ai/cordis'
export const name = 'b0-hello'
export function apply(_ctx: Context) {
console.log('[b0-hello] mounted from a local --patch overlay')
}
name 让插件有稳定身份;apply(ctx) 才是挂载入口。当前 _ctx 没被使用并不代表它多余——B1 写工具时会通过它拿到 ctx.tools,B3 写门禁时会通过它订阅事件。B0 故意不把后续章节的复杂性塞进第一个例子。
2. patch:scratch-plugin/cordis.yml
- insert:
- id: b0-hello
name: file:///E:/coding/deepseek-harness/scratch-plugin/src/b0-hello.ts
这份 YAML 做的不是“安装 npm 包”,而是向本次 profile 组合追加一行。id: b0-hello 是配置树中可定位、可被后续 patch 替换的身份;name 是 Loader 要 import 的模块地址。
这里的 file:/// 不是装饰。官方第一个插件文档强调 name 需要绝对路径(docs/user/develop/basic/index.zh.md:46-56)。在 POSIX 环境里,绝对路径能直接被 ESM loader 处理;在 Windows 上,盘符写法 E:/... 会被 Node 当成 e: 协议。Windows 上应把盘符绝对路径写成 file:///E:/... URL。
--patch 是 B0 选择的实验接线方式。一次 Web 启动会依次采用内置默认配置、当前 profile 的配置、用户目录的配置,再叠加命令行给出的 --patch;后面的层可以覆盖前面的层。命令结束后,这份临时 patch 不会写回你的 profile。行为稳定后再迁入 profile patch;第一次试验先用 overlay,撤销成本最低。
顺序不是约定俗成的口头规则,源码在 apps/cli/src/profile-boot.ts:121-129 直接返回了这一数组:
/** The full patch stack of one composed profile, in application order. */
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [
...composed.bundlePatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlays,
]
}
数组从上到下就是应用顺序;末位的 overlays 对应命令行 --patch,能够覆盖前面同 id 的配置,但不会修改那些配置文件本身。
三、第一道验证:先确认条目进了树
dsh 的参数有两层:--profile、--patch、--dump-config 决定怎样组合插件配置;web 后面的参数才交给已经选定的 Web 应用。因此启动选项应写在前面,避免把“想换 profile”误传给 Web 页面。
CLI 文件开头用下面这段注释定义了这个边界(apps/cli/src/args.ts:1-16):
/**
* Commander adapter for the `dsh` command line.
*
* The launcher parses only what it owns — which profile to boot, which extra
* patch overlays to apply, and the config dumps — and hands **everything after
* its own flags** to the booted tree verbatim, where injected app plugins parse
* their own flag families and print their own `--help` (see
* `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first
* token this parser does not recognize starts the inner arguments, so
* `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`,
* and `dsh --profile web -h` prints the web app's help, not this one's.
*
* `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's
* plugin dependencies by forwarding to pnpm.
* @module @deepseek-ai/dsh/args
*/
本机用下面命令只查看 Web 配置;这里用 --profile web 选择 Web profile,因此所有启动选项都紧跟在 dsh 后面:
pnpm dsh --profile web --patch ./scratch-plugin/cordis.yml --dump-config
本机输出共有 494 行。尾部原样摘录:
# == E:\coding\deepseek-harness\scratch-plugin\cordis.yml
- id: b0-hello
name: file:///E:/coding/deepseek-harness/scratch-plugin/src/b0-hello.ts
这三行足以回答第一个问题:本地 overlay 已位于配置树的最上层,b0-hello 条目存在,Loader 将拿到正确的 file URL。
这三行证明配置层已经正确,但还没有碰到插件代码。下面是生成这份输出的完整函数主体,逐字摘自 apps/cli/src/dump-config.ts:30-52:
export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
const loaded = prepareProfile(profile, !defaultOnly)
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
label: layer.packageName,
patches: layer.patches,
}))
if (!defaultOnly) {
if (existsSync(loaded.patchPath)) {
layers.push({ label: loaded.patchPath, patches: loaded.patches })
}
const homePatchFile = homePatchPath()
const homePatches = loadOptionalPatches(NAME, homePatchFile)
if (homePatches !== undefined) {
layers.push({ label: homePatchFile, patches: homePatches })
}
for (const file of patches) {
const absolute = resolve(file)
layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
}
}
// The dump anchors on the same empty root file the boot includes.
process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))
}
按代码顺序读:loaded.layers 先给出选定 profile 已准备好的层;存在的 profile patch 与可选的 home patch 分别加入 layers;for (const file of patches) 把每个 --patch 文件转成绝对路径后继续加入;写出语句只把 layers 渲染到标准输出。函数里没有读取 b0-hello.ts,也没有调用 apply();模块地址的可用性留给下一节的真实启动验证。
四、第二道验证:让 Loader 执行 import 模块
日常从源码 checkout 启动时,不需要手写 node --import tsx。执行 pnpm dsh 即可;根 package.json 中对应的原始脚本只有这一行(package.json:136):
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
所以本机用正常入口启动 Web:
pnpm dsh web --patch ./scratch-plugin/cordis.yml
本机 stdout:
$ node --import tsx/esm apps/cli/src/bin.ts "web" "--patch" "./scratch-plugin/cordis.yml"
[b0-hello] mounted from a local --patch overlay
dsh web: http://127.0.0.1:3080
第一行是 pnpm 展开并执行的源码入口;第二行来自我们的 apply();第三行来自 Web app。三行连在一起证明:命令行 patch 已被带入 dsh、插件模块已经加载并执行、Web profile 也已完成启动。
下面是启动过程中“创建运行上下文 → 挂载插件树 → 等待所有条目完成 → 保留最深错误”的实际源码,逐字摘自 packages/boot/app-boot/src/index.ts:764-802:
const ctx = new Context()
// Two failure labels: `prepare` runs before any config-tree entry mounts,
// so its failure is host setup, not the plugin tree.
let stage = 'host preparation failed'
try {
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)
await prepare?.(ctx)
stage = 'plugin tree failed to load'
await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
// A surface can finish and dispose the whole tree while startup is still
// in flight, before the last entry settles. The Loader service goes with
// it, and the activation audit describes a live tree — reading `ctx.loader`
// past this point would throw a TypeError over an app that exited exactly
// as asked. Transactional group updates settle
// lifecycle inside the mount, so the teardown can land before it returns;
// re-check after every await.
await ctx.get('loader')?.await()
if (ctx.get('loader') === undefined) return ctx
await assertEntriesActivated(ctx, binName)
return ctx
} catch (cause) {
// Root-fiber disposal contains cleanup failures per observer (Cordis
// fiber.ts hardening) and a repeated call returns the settled single-shot
// result, so this await cannot reject and replace `cause`.
await ctx.fiber.dispose()
const detail = cause instanceof Error ? cause.message : String(cause)
// The transactional Loader wraps a failing entry apply in one message per
// tree layer; every layer's message is folded into `detail` above, and the
// deepest cause is the plugin's own thrown error, whose stack names the
// real failure site — append it so the startup diagnostic preserves the
// original activation error instead of only the wrap chain.
let deepest: unknown = cause
while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
}
}
读这段代码时抓四个动作即可:ctx.plugin(Loader) 启用插件加载器;mountRootInclude(...) 把配置清单挂入运行时;await ctx.get('loader')?.await() 等待所有插件完成初始化;catch 中沿着 cause 链找到最深错误,再连同“哪个启动阶段失败”一起抛出。因此坏路径例子会同时出现 b0-hello、plugin tree failed to load 和 Node 的 ERR_UNSUPPORTED_ESM_URL_SCHEME。

图:本机启动正确 patch 后访问 http://127.0.0.1:3080。截图中跳过了 API Key 配置,页面没有会话或个人数据;hello 插件本身没有 UI 贡献,所以它的可观测证据是终端 mounted 日志。
如果你日常不用源码入口,官方等价命令更短:pnpm dsh web --patch ./scratch-plugin/cordis.yml(docs/user/develop/basic/index.zh.md:58-64)。插件文件本身仍然要写成绝对的 file:/// URL;--patch 文件相对路径与 YAML 内模块 URL 是两个不同层面的路径。
五、Windows 的分水岭:坏路径为什么能通过 dump
为了验证这不是文档上的格式偏好,我们把同一个插件的 name 改成故意错误的盘符路径:
- insert:
- id: b0-hello
name: E:/coding/deepseek-harness/scratch-plugin/src/b0-hello.ts
先跑 --dump-config,它仍然成功,尾部如下:
# == E:\coding\rex-hugo\.tmp-research\dsh-b0\cordis-windows-path-bad.yml
- id: b0-hello
name: E:/coding/deepseek-harness/scratch-plugin/src/b0-hello.ts
这正说明 dump 的职责只到配置合成。改为真实启动后,stderr 的第 5-6 行原样如下:
Error: dsh: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to import loader entry b0-hello (E:/coding/deepseek-harness/scratch-plugin/src/b0-hello.ts): Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'e:'
Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'e:'

图:同一份坏路径能作为字符串进入 dump 的配置树,但会在 Loader 动态 import 时被 Node 拒绝。正确路径是 file:///E:/...。
这一点可以减少一轮很常见的误诊:看到 dump 输出里有条目,就去怀疑 apply、inject 或 dsh 的运行时。实际错误发生在 apply 之前,模块甚至没有被求值。先修 URL,再讨论插件代码。
六、把排障顺序固定下来
第一个插件失败时,不要从日志最深处开始猜。按下面顺序把问题分到不同层:
| 观察到的现象 | 优先检查 | 能排除什么 |
|---|---|---|
pnpm run typecheck 或 pnpm run build 失败 | Node 版本、pnpm、依赖安装、Host/Client 产物 | 先不看插件 YAML |
--dump-config 没有 b0-hello | --patch 路径、YAML 缩进、insert、flag 位置 | 先不看 TypeScript 导出 |
dump 有 b0-hello,dsh web 报 loader/import 错误 | name 的 file URL、文件是否存在、模块导出 | 先不怀疑 Web UI |
| stdout 有 mounted,浏览器打不开 | Web URL、端口占用、前端 build 产物 | 插件已经不是首要问题 |
| stdout 有 mounted,但界面没变化 | 插件没有注册 UI 节点或工具,这是预期 | 进入 B1/B4 的扩展点选择 |
这张表的关键不是列命令,而是每一步只回答一个问题。把所有命令混成一条“再跑一次 dsh”会让问题在不同层之间反复跳转。
七、开发时哪些配置应当留在 patch,哪些应当进 profile
--patch 与 profile 的 cordis.patch.yml 都能改变树,但承担的职责不同:
| 选择 | 适合什么 | 代价 | B0 的选择 |
|---|---|---|---|
命令行 --patch | 一次性试验、复现 bug、教程演示 | 下次启动需要再次指定 | 让 hello 插件可逆地接入 |
profile 的 cordis.patch.yml | 每次启动都需要的个人插件组合 | 会影响该 profile 的后续运行 | 等行为稳定后再迁入 |
| home 层 patch | 多个 profile 共享的机器偏好 | 影响范围最大 | 不用于第一个插件 |
| npm 安装的 profile plugin | 已打包、需分发的插件 | 需要包元数据与发布流程 | 留给 B6 |
profile 根配置不是让你手改的“总配置文件”。源码把它定义为一个空列表,并在注释里明确写出 bundle、cordis.patch.yml 与 --patch 的组合方式(apps/cli/src/profile-boot.ts:59-67):
/** The empty root entry list every profile tree patches over. */
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
`
/** Root config filename inside a profile directory. */
export const PROFILE_ROOT_FILENAME = 'cordis.yml'
所以长期配置应写入 profile 的 cordis.patch.yml,而不是直接改这个空根文件;B0 先用命令行 overlay,是为了把影响半径压缩到一次命令,确认行为后再让配置成为默认。
八、Windows 额外说明:不要把 shell 问题带进 hello 插件
本篇不执行 shell 工具,也不需要 WSL。dsh 的 base bundle 在 Windows 会启用 PowerShell 行、关闭 Bash 行;这是平台组合的选择,不是 hello 插件的要求。插件加载失败时先处理 file URL,不要为了“让教程更像 Linux”而去替换内置 Bash/Pwsh 服务。
另一个边界是 Python SDK:其文档对 persistent PTY 的 Windows agent 有限制。它不影响本篇 Web profile 的本地插件,但会影响 B5 的 SDK 场景。把平台限制放在使用该能力的章节,B0 只处理本地模块加载。
九、决策表
| 设计问题 | 容易采取的做法 | B0 的做法 | 淘汰前者的原因 |
|---|---|---|---|
| 第一个插件做多大 | 直接写工具、权限、UI | 只打印 mounted 日志 | 先隔离加载链,失败位置唯一 |
| 插件如何接入 | 直接改 profile 根配置 | --patch overlay 插入条目 | 实验可撤销,不污染默认 profile |
| 怎么确认成功 | 只看 dump 或只看浏览器 | dump + 实际启动 | 一道查树,一道查动态 import |
| Windows 模块地址 | 盘符绝对路径 E:/... | file:///E:/... | Node ESM 只接受合法 URL scheme |
| API Key | 先配好模型再开始 | 加载链完成后再接模型 | 不让凭据问题遮住插件问题 |
| 失败时怎么排查 | 从 apply 代码开始猜 | 工具链 → 配置树 → import → UI | 每一步缩小一层故障域 |
十、下一篇
B0 只证明“插件能进来”。下一篇让它成为模型可调用的能力:用 defineTool、schema、权限与测试写第一个工具插件,并把它放进 A4 的工具流水线。
下一篇:写第一个工具插件:把一段 TypeScript 变成模型能力
FAQ
Q:DeepSeek Harness 写第一个插件需要 API Key 吗?
不需要。插件加载、–dump-config 和 Web UI 启动不要求 API Key;只有调用真实 DeepSeek 模型、真实 API e2e 或依赖模型回答的 demo 才需要 DEEPSEEK_API_KEY。需要时从进程环境或仓库根目录、已被 Git 忽略的 .env 提供,绝不能提交真实凭据。
Q:dsh 插件最小需要导出什么? 一个 TypeScript 模块导出 apply(ctx) 即可;建议同时导出稳定的 name。apply 在 Loader 成功加载模块后被调用,你在其中通过 ctx 注册工具、事件、服务或 effect。
Q:Windows 上 cordis.yml 里的插件路径怎么写? 写 file:///E:/绝对路径/插件.ts,而不是 E:/绝对路径/插件.ts。Node ESM 在 Windows 会把后者理解为 e: 协议,真实启动时报 ERR_UNSUPPORTED_ESM_URL_SCHEME。
Q:–dump-config 成功,为什么 dsh web 仍然启动失败? –dump-config 只合并配置树,不 boot、不求值、不动态 import 模块;它能证明条目进入树,不能证明 Node 能加载 name 指向的文件。必须再做一次真实启动。
Q:dsh 的 –patch 覆盖层会改写我的 profile 吗? 不会。–patch 是本次启动的附加 overlay,按命令行顺序叠加在 profile 与 home 层之后。它适合本地试验;需要长期保留的配置再放到 profile 的 cordis.patch.yml。
Q:从源码运行 dsh 后,什么时候需要重新 pnpm run build? 新 checkout 必须先 build;官方 CLI 参考还说明,缺少 Host 产物或前端/客户端插件 bundle 时,profile boot 会报模块解析或要求 build 的错误。现有旧 bundle 不会自动检测新鲜度。
互动模块
① 站队:本地扩展第一次接入时,你习惯 A. 直接改长期配置,还是 B. 先用一次性 overlay 验证?B 的成本多一条命令,换来更小的影响半径;你会怎么选?
② 征集:你在 Windows 上遇到过
ERR_UNSUPPORTED_ESM_URL_SCHEME吗?是哪个工具把盘符当成协议解析了?把错误上下文贴出来,后续 B1 的排障清单会补进真实案例。③ 转发:把这篇转给那个刚开始给 dsh 写插件、却一直只盯着
apply()的同事——先确认模块确实进了树,能省掉第一轮无效调试。
