这是《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、模块导出与运行时异常。

DeepSeek Harness 本地插件加载链:文件、patch、Loader 与两道验证

图:模块文件先被 patch 引用,再由 Loader 动态 import。下方两张卡分别说明 --dump-config 与真实启动各自能证明什么。

一、先让源码 checkout 具备运行资格

官方 README 的“从源码运行”顺序是 clone → pnpm installpnpm run buildpnpm dsh webREADME.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 typecheckHost/Client 两个 TypeScript 聚合是否都能通过不产出完整 Web bundle
pnpm run build库产物和 Web 前端 bundle 是否都已生成不检查你的 patch 是否把插件接进树
pnpm dsh webprofile 能否 boot,Web UI 是否运行不告诉你 patch 的来源层级为何这样组合

官方开发指南把 pnpm run typecheck 退出成功定义为 fresh clone 的 setup 完成条件(docs/development.md:34-40)。而 pnpm run build 的根脚本明确由 build:libbuild: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 不属于本篇前置条件

开发指南的凭据约定可以直接写成四句话:

  1. 真实 DeepSeek adapter、依赖模型回答的 demo 与真实 API e2e 从进程环境或仓库根目录、已被 Git 忽略的 .env 读取 DEEPSEEK_API_KEY
  2. DEEPSEEK_BASE_URL 也是可选环境变量;未设置时使用公共 API 地址。
  3. 真实 API e2e 在没有 DEEPSEEK_API_KEY 时会自行 skip,而不是把 keyless 的插件加载测试判成失败。
  4. 真实凭据不能提交进仓库;.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 分别加入 layersfor (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-helloplugin tree failed to load 和 Node 的 ERR_UNSUPPORTED_ESM_URL_SCHEME

DeepSeek Harness Web UI 在本地插件启动后可访问

图:本机启动正确 patch 后访问 http://127.0.0.1:3080。截图中跳过了 API Key 配置,页面没有会话或个人数据;hello 插件本身没有 UI 贡献,所以它的可观测证据是终端 mounted 日志。

如果你日常不用源码入口,官方等价命令更短:pnpm dsh web --patch ./scratch-plugin/cordis.ymldocs/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 输出里有条目,就去怀疑 applyinject 或 dsh 的运行时。实际错误发生在 apply 之前,模块甚至没有被求值。先修 URL,再讨论插件代码。

六、把排障顺序固定下来

第一个插件失败时,不要从日志最深处开始猜。按下面顺序把问题分到不同层:

观察到的现象优先检查能排除什么
pnpm run typecheckpnpm run build 失败Node 版本、pnpm、依赖安装、Host/Client 产物先不看插件 YAML
--dump-config 没有 b0-hello--patch 路径、YAML 缩进、insert、flag 位置先不看 TypeScript 导出
dump 有 b0-hellodsh 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() 的同事——先确认模块确实进了树,能省掉第一轮无效调试。