dsh 源码解析
第一段 · 底座 · 第 03 章

启动装配:Profile、Bundle 与 patch 分层

没有默认配置文件,只有空数组加 patch 层

上一章讲的是"插件怎么活"。这一章讲的是"谁决定装哪些插件、配什么值"——从 dsh web 这七个字符,到一棵 160 多行的插件树被挂起来的全过程。

涉及源码:apps/cli/src/(879 行)、packages/boot/app-boot/src/vendor/include/src/index.ts(377 行)、vendor/loader/src/packages/bundle/*/cordis.patch.yml(base 451 行 / web-app 445 行 / headless 35 行)。

0. 先说结论

dsh 的启动配置有一个反直觉的设计:没有"默认配置文件"这种东西

一般 Agent 框架的做法是:内置一份 default.yml,用户写 config.yml 覆盖它,代码里做深合并(deep merge)。dsh 全都不这么干:

一般做法 dsh 的做法
内置默认配置文件 空数组 []——profile 的根配置永远是空的
用户配置深合并覆盖默认值 所有内容都是 patch 层,一层层叠上去
合并到字段粒度 整个 config 对象替换,不做深合并
默认值散落在各插件的 ?? default 每个值都在某一层 patch 里显式写出

于是"这次运行到底装了什么、每个值是多少"这个问题有一个确定答案:把所有 patch 层按顺序应用到空数组上,得到的行列表就是全部真相。而且这个"应用"只有一个实现(applyEntryPatches,70 行),启动和 --dump-config 共用它,所以 dump 出来的东西不可能和真正启动的不一致。

本章的主线:

dsh web
  → args.ts        解析出 { mode: 'profile', profile: 'web', patches: [], args: [] }
  → profile-boot   找到 $DSH_HOME/profiles/web/,读它的 package.json
  → loadProfile    dsh.profile.bundles = [dsh-base, dsh-web-app] → 解析出 2 个 bundle 层
  → composeProfile 叠层:bundle 层 → profile 层 → home 层 → --patch → 遥测开关
  → boot()         new Context() → 装 Loader → include 空根 cordis.yml + 全部 patch
  → applyEntryPatches   空数组 + 全部 patch = 最终行列表
  → 每行一个 Fiber      !!js 在各自 fiber 里求值 → 依赖齐了才激活(第 2 章)
  → assertEntriesActivated   有行没激活起来?大声失败,不静默降级
  → watchUserPatches   两个 watcher 盯着两个用户 patch 文件,改一行热生效

全章图示:assets/ch03-patch-layers.svg

patch 分层:从 dsh web 到一棵插件树


1. Profile:一个目录就是一个产品实例

Profile 是 dsh 的产品单位。dsh --profile webdsh --profile headless 不是"同一个程序的两种模式",而是两棵不同的插件树:web 那棵有 HTTP 服务器、浏览器前端 roster、Host 层;headless 那棵一个都没有,只有一个读命令行参数、跑一次任务、打印结果的 runner。

一个 profile 就是 $DSH_HOME/profiles/<name>/ 下的一个目录(packages/boot/app-boot/src/profile.ts:104),里面有四个文件:

文件 作用
package.json 两件事:dsh.profile.bundles 有序层列表;dependencies 装外部插件
cordis.patch.yml 用户自己的 patch 层
pnpm-workspace.yaml 给外部插件用的 pnpm 设置
cordis.yml 永远是空数组,只是给 Loader 一个锚点(见第 7 节)

initProfile()profile.ts:152)第一次用到时生成它们,而且已存在的文件绝不覆盖,所以重复初始化是幂等的:

export function initProfile(dir: string, bundles: readonly string[]): void {
  mkdirSync(dir, { recursive: true })
  const manifestPath = join(dir, 'package.json')
  if (!existsSync(manifestPath)) {
    const manifest: ProfileManifest & { private: boolean } = {
      name: `dsh-profile-${basename(dir)}`,
      private: true,
      dependencies: {},
      dsh: { profile: { bundles: [...bundles] } },
    }
    writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
  }
  const patchPath = join(dir, PROFILE_PATCH_FILENAME)
  if (!existsSync(patchPath)) writeFileSync(patchPath, PROFILE_PATCH_TEMPLATE)
  const workspacePath = join(dir, 'pnpm-workspace.yaml')
  if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
}

只有两个 profile 自带模板、能首次使用时自动初始化(profile.ts:114):

export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
  web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
  headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}

其他名字必须先用 dsh plugin --profile <name> add <package> 建,loadProfile() 遇到不存在且无模板的 profile 会直接抛错并把建法写在错误信息里profile.ts:371):

throw new Error(
  `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
)

这是 dsh 的一条通用规范在配置层的体现——AGENTS.md 里写作 "Misconfiguration fails loud":宁可启动不了,也不要静默降级成一棵不完整的树。后面第 8 节还会看到它更狠的一次应用。

profile 名字的校验

if (name === '' || name.includes('/') || name.includes('\\') || name === '.' || name === '..'
  // The launcher-maintained flat module fallback lives at this sibling path.
  || name === 'node_modules') {
  throw new Error(`dsh: invalid profile name ${JSON.stringify(name)}`)
}

前几个是防目录穿越,最后一个 node_modules 是个真实的坑:$DSH_HOME/profiles/node_modules 被启动器占用了——它是一个扁平的模块回退目录,每个 dsh 安装自带的包在里面有一个符号链接(healProfilesModuleFallbackprofile.ts:223)。

为什么需要它?因为插件模块解析是"双锚点"的:一个 bundle 名先从 dsh 安装目录解析,再从 profile 目录解析。而 Loader 的 baseUrl 是 profile 目录——用户用 dsh plugin add 装的外部插件在 profiles/<name>/node_modules 里,它们 import '@deepseek-ai/cordis' 时必须命中安装自带的那一份 cordis,否则就会加载出第二个 cordis 实例,两套 Context 类型互不相认。Node 的父目录上溯(parent-walk)正好会走到 profiles/node_modules,符号链接把它接回安装目录。配套的 pnpm 设置也是为这个服务的(profile.ts:138):

packages:
  - .

nodeLinker: hoisted
autoInstallPeers: false

hoisted + 不自动装 peer,让外部插件的 peer 依赖"缺失",从而穿透到上面那层回退目录去——故意制造缺失,来强制共享单例


2. Bundle:一个 npm 包只导出一个 YAML

Bundle 是可安装的 patch 层。它的 API 是——没有 API。packages/bundle/base/package.json 里全部的声明是:

{
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

loadProfile() 只读这一个字段(profile.ts:371):

const declared = bundleManifest.dsh?.bundle?.patch
if (declared === undefined) {
  throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
}

注意这里也是 fail loud:把一个不是 bundle 的包写进 bundles 列表,不会被当成"这层没有 patch"而跳过,而是报错。dsh 官方 README 把这个契约写得很明白:

The package has no runtime API; the profile composer resolves the patch through the dsh.bundle.patch manifest field, never through code.

三个自带 bundle 的规模:

Bundle 行数 行(row)数 干什么
dsh-base 451 78 所有 profile 的第一层:模型适配器、工具、持久化、沙箱策略、设置/凭据、遥测、子代理 provider
dsh-web-app 445 84 浏览器界面:HTTP 服务、Host 层、前端 roster、Web 专属的值
dsh-headless 35 6 一次性任务模式:只加 code-runtime、startup、runner 三行,改两行、关一行

headless 那 35 行值得整个贴出来看——它是"用 patch 定义一个新产品形态"的最小样本

- id: system-prompt
  config:
    persona: >-
      You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.

# The shared module-reload HMR row stays off; the launcher's watch-only
# fallback still keeps the user patch layers live until the run exits.
- id: hmr
  disabled: true

- id: tools
  config:
    mode: !!js process.env.DSH_TOOLS_MODE

- insert:
    - id: code-runtime
      name: '@deepseek-ai/dsh-code-runtime-worker-thread'

    - id: headless-startup
      name: '@deepseek-ai/dsh-headless/startup'

    # Reads its task from the ordinary headlessStartup provider.
    - id: headless-runner
      name: '@deepseek-ai/dsh-headless'
      inject: [headlessStartup]
      config:
        task: !!js ctx.headlessStartup.task

一个完整的"无界面 Agent 产品"= 三行改写 + 三行新增。剩下的 78 行能力全部从 base 层继承。这就是"Everything is a Plugin"在产品层面的兑现方式。

最后一行还顺手示范了patch 层里的依赖声明inject: [headlessStartup] 让这一行的 Fiber 等到 headlessStartup 服务出现才激活,于是 !!js ctx.headlessStartup.task 求值时它一定已经在了——这就是第 2 章 epoch 机制的直接应用,只不过写在 YAML 里而不是 static inject 里。


3. patch 算法:70 行决定一切

所有分层最终都收敛到一个函数:applyEntryPatchesvendor/include/src/index.ts:58)。它的 JSDoc 第一句就自称 "THE patch semantics of this include"——挂载时和 --dump-config 时都调它,所以两者不可能漂移。

完整算法:

export function applyEntryPatches(
  data: EntryOptions[],
  patches: PatchOptions[] | undefined,
  warn: (message: string, ...args: any[]) => void,
): EntryOptions[] {
  data = structuredClone(data)
  if (!patches?.length) return data

  const entryMap = new Map<string, EntryOptions>()
  const buildMap = (entries: EntryOptions[]) => {
    for (const entry of entries) {
      if (entry.id) entryMap.set(entry.id, entry)
      if (entry.group && Array.isArray(entry.config)) {
        buildMap(entry.config)
      }
    }
  }
  buildMap(data)

  for (const patch of patches) {
    const { id, insert, name, ...overrides } = patch

    if (insert) {
      // ...插入到 data 末尾,或插入到 id 指定的 group 里
      buildMap(insert)   // ← 关键:索引新插入的行
      continue
    }

    if (!id) { warn('patch: id is required for non-insert patches'); continue }

    const target = entryMap.get(id)
    if (!target) { warn('patch: entry %C not found', id); continue }

    if (name && name !== target.name) {
      warn('patch: name mismatch for %C (expected %C, got %C), skipping', id, target.name, name)
      continue
    }

    for (const [key, value] of Object.entries(overrides)) {
      if (key === 'id') continue
      target[key] = value
    }
  }

  return data
}

只有两种操作:

insert:把行加进去。不带 id 就追加到顶层列表末尾;带 id 就追加到那个 group 行config 数组里(不是 group 会 warn 跳过)。

② id 定位覆写{ id, ...overrides },把 overrides 里的每个键整个赋给目标行。

第二点是全章最需要记牢的一句话:

A patch replaces the targeted row's whole config, so each row below restates every key it owns.packages/bundle/web-app/cordis.patch.yml 开头的注释)

target[key] = value 是赋值,不是合并。所以 web-app 层要改 session-query-sqliteopenAt,必须把 path 一起重写:

- id: session-query-sqlite
  config:
    path: ':memory:'
    openAt: never

这个"缺陷"在 base bundle 的 README 里被明确列为已知限制:

A patch replaces whole row configs — profile overrides must restate every field a row keeps; there is no deep-merge layer.

为什么明知是麻烦还要这样设计? base bundle 的 patch 文件头部给了答案:

# A patch replaces the targeted row's whole `config` rather than merging into
# it, so a row whose value differs by mode does NOT live here: it belongs to
# each mode bundle, keeping any single row down to one bundle layer plus the
# user's.

深合并的代价是:一个字段的最终值可能来自四五层的叠加,你必须在脑子里跑一遍合并算法才知道它是多少;而且"删除一个字段"在深合并里几乎无法表达。整体替换则保证:任何一行的 config,你能在某一个文件里读到它完整的样子。代价转嫁成一条内容约定——按模式变化的值不写进 base,各 mode bundle 各自重述完整配置,于是任何一行最多只经过"一个 bundle 层 + 用户层"两次写。

三个容易忽略的细节

structuredClone 在第一行,而且注释说明就算没有 patch 也要克隆:

The input is never mutated and the result is always detached from it (even with no
patches): patching or mounting shared entry objects would bake earlier values into
the cached parse, so repeated application (config hot-reloads) could never revert
a removed or changed patch.

——热重载会反复对同一份解析结果应用不同的 patch 集。如果第一次应用时把值写进了共享对象,第二次删掉那条 patch 也回不去了。这类"可逆性"关注和第 2 章的 effect disposer 是同一种思路,只是搬到了数据层。

buildMap(insert) 让插入的行可被后续层寻址。注释写了这是补上的行为:

Index what this patch added so a LATER patch in the same list can target it.
[...] without this, inserted rows were silently unpatchable.

没有这一行,dsh-base 插入的 78 行对 dsh-web-app 层就是不可见的,整个分层机制直接失效。

patch 打空了只 warn,不报错。这看起来违反"fail loud",但恰恰是分层机制必需的:base 层的遥测行可能因平台被禁用、某个 profile 可能压根没装某个插件,此时用户层里针对它的 patch 命中不了——把这个当致命错误,会让任何一份 cordis.patch.yml 都绑死在特定 bundle 组合上。启动器自己也依赖这个宽松语义,见下一节的遥测开关。


4. 层序:谁压得过谁

composeProfile()apps/cli/src/profile-boot.ts:142)负责把层排好序,allPatches():122)给出确切顺序:

function allPatches(composed: ComposedProfile): PatchOptions[] {
  return [
    ...composed.bundlePatches,   // ① 每个 bundle 层,按 dsh.profile.bundles 顺序
    ...composed.profile.patches, // ② profile 自己的 cordis.patch.yml
    ...composed.homePatches,     // ③ $DSH_HOME/cordis.patch.yml
    ...composed.overlays,        // ④ --patch 覆盖 + agent-presets 根 + 遥测开关
  ]
}

后面的压前面的(同一行的同一个键,最后一次写生效)。四层的分工:

归属 典型内容
bundle 层 产品/发行方 装什么插件、每个插件的默认配置
profile 层 用户,针对这一个 profile "我的 web profile 要开全文搜索"
home 层 用户,这台机器的所有 profile "这台机器上一律用我自己的模型端点"
overlay 层 单次调用 / 启动器 --patch ./debug.yml、遥测开关

home 层压过 profile 层,源码注释解释了理由:

the home-level user layer ($DSH_HOME/cordis.patch.yml — machine-local
preferences that apply to every profile, so it outranks the per-profile layer)

"作用范围更大的层反而优先级更高"和常见的 CSS/配置直觉相反。这里的取舍是:home 层表达的是这台机器的物理事实(我的代理、我的凭据路径、我这台机器没有 pwsh),而 profile 层表达的是"这个产品实例想怎么跑"。物理事实赢。

启动器自己也用 patch 说话

composeProfile 的最后两件事很能说明"patch 是唯一的配置手段"这个立场彻底到什么程度——连启动器改配置也不走代码路径,而是往层里追加一条 patch

一是 agent-presets 的 shipped 根目录,只有这个 app 自己知道它的位置(profile-boot.ts:159):

if (rows.has('agent-presets')) {
  composedOverlays.push({
    id: 'agent-presets',
    config: {
      ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
      roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }],
    },
  })
}

注意 ...(rows.get('agent-presets')?.config ?? {})——因为 patch 是整体替换,启动器必须自己把已有 config 展开重述。整体替换语义的成本在这里付得很直白。

二是遥测开关(profile-boot.ts:80):

export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
  if ((disabledEnv ?? '') === '' || !hasRow) return undefined
  return { id: TELEMETRY_ROW_ID, disabled: true }
}

两个细节:

  • 任何非空值都算关闭,包括 '0''false'。JSDoc 给的理由:"a privacy switch prefers off-by-mistake over on-by-mistake"——隐私开关的两种误判不对称,宁可误关。
  • 组合里没有遥测行,就不生成这条 patchhasRow 为假)。这就是上一节那个"patch 打空只 warn"的宽松语义在被主动利用:自定义 profile 不装遥测,也能在设了这个环境变量的机器上正常跑,而不是启动时冒出一条无害但吓人的告警。

还有一点:这里用的是 disabled: true 而不是 config: { mode: 'DISABLED' }。base bundle 的注释解释了区别——config 只能让插件"跑起来但什么都不做",disabled根本不加载这一行:"the launchers patch the row disabled; config cannot disable a row"。安全/隐私开关要的是后者。


5. !!js:延迟到行自己的 fiber 里求值

patch 里到处是 !!js

- id: sandbox-policy
  config:
    mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
    workspaceRoot: !!js process.cwd()

- id: session-persistence-jsonl
  config:
    root: !!js dshHomePath('sessions')

- id: bash-sandbox
  disabled: !!js process.platform === 'win32'

这套机制横跨三个文件,链条值得完整走一遍。

① 解析:变成一个普通对象,不求值vendor/include/src/index.ts:9

const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
  kind: 'scalar',
  resolve: (data) => typeof data === 'string',
  construct: (data) => ({ __jsExpr: data }),
  predicate: isJsExpr,
  represent: (data) => data['__jsExpr'],
})

export const entryListSchema = yaml.JSON_SCHEMA.extend(JsExpr)

!!js foo 解析成 { __jsExpr: 'foo' }。而且带 represent,所以能原样写回 YAML——配置回写不会把表达式烧成某次求值的结果。

② 求值:在行自己的 fiber 里,通过一个 waterfallvendor/loader/src/index.ts:92

ctx.on('internal/config', function (this: Fiber, _config, next) {
  const config = next()
  if (!this.entry || this.parent.fiber?.entry === this.entry) return config
  // Tree carriers (Group, Include) keep their configs literal: their
  // entry and patch lists hold other rows' configs, whose `!!js`
  // expressions belong to those rows' own fibers.
  const plugin = this.runtime?.callback as Record<PropertyKey, unknown> | undefined
  if (plugin?.[EntryGroup.key]) return config
  return interpolate(this.ctx, config)
}, { global: true })

三件事同时发生:

  • 它是第 2 章讲的 waterfall 监听器,先 next() 拿到内层结果再加工——所以别的插件可以插在它之前或之后改配置;
  • { global: true } 绕过 context filter,所有 fiber 的配置解析都经过它;
  • tree carrier(Group、Include)直接返回字面值。这一点很关键:Include 的 config 里装的是别人的行和 patch,那些 !!js 属于那些行,不属于 Include。如果在这里求值,!!js ctx.headlessStartup.task 会在 headlessStartup 还不存在的时候炸掉。

③ 求值上下文:with (ctx)vendor/loader/src/config/utils.ts:5

export const evaluate = new Function('ctx', 'expr', `
  with (ctx) {
    return eval(expr)
  }
`)

export function interpolate(ctx: object, value: any) {
  if (isJsExpr(value)) return evaluate(ctx, value.__jsExpr)
  else if (!value || typeof value !== 'object') return value
  else if (Array.isArray(value)) return value.map(item => interpolate(ctx, item))
  else return valueMap(value, item => interpolate(ctx, item))
}

with (ctx) 让 ctx 上的一切都进作用域。所以 !!js ctx.headlessStartup.task 能写,!!js dshHomePath('sessions') 也能写——后者是 boot() 在装 Loader 之前挂上去的(packages/boot/app-boot/src/index.ts:770):

ctx.provide('dshHomePath', dshHomePath)
await ctx.plugin(Loader)

把这三步串起来,就得到了 !!js 真正的语义:它不是"配置文件里的模板变量",而是"在这一行的插件即将激活的那一刻、在这一行自己的 Context 作用域里执行的一段表达式"。因为求值时机跟着 fiber 走,第 2 章的 epoch 机制自动帮它保证了依赖顺序——这就是 headless 那六行能写 !!js ctx.headlessStartup.task 的全部原因。

顺带一个安全边界:dsh 的 AGENTS.md 规定 !!js(两个感叹号,不是 !js只允许出现在插件 config 和 entry 的 disabled,其他元数据保持字面。这是有意的限制——插件身份(nameid)不能是算出来的,否则"这次到底装了哪些插件"就不再是静态可读的事实了。


6. 为什么根配置每次启动都要重写成空数组

profile-boot.ts:60 有一段看起来多余的常量:

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.
[]
`

prepareProfile() 每次启动都无条件重写这个文件:98):

export function prepareProfile(name: string, userLayer = true): Profile {
  healProfilesModuleFallback(INSTALL_ANCHOR)
  const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
  writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
  return profile
}

为什么不是"不存在才写"?JSDoc 交代得很清楚,这是在防一个真实存在的自我污染

The root is always rewritten: the whole composition is patch layers, and the
vendored Loader's tree write-back (a plugin self-disposing persists the current
tree) can bake composed rows into this file — which would duplicate every bundle
insert on the next boot.

链条是这样的:Loader 支持配置回写(Include.write()vendor/include/src/index.ts:371),插件自我卸载时会把当前树持久化回文件。而当前树是"空根 + 所有 patch 应用后"的结果,一旦写回 cordis.yml,下次启动就变成"78 行的根 + 再叠一遍 78 行的 insert"——每个插件装两遍。每次启动都推平根文件,是对这条路径最简单的免疫。

那这个文件为什么还要存在?

The file exists on disk only because the Loader needs a real include root to
anchor `baseUrl` at the profile directory (the config dump anchors on the same
file, so both compose over the identical base).

——只为了给 Loader 一个真实路径来锚定 baseUrl(外部插件的模块解析要从 profile 目录出发,见第 1 节)。它是一个纯粹的坐标原点,不承载任何配置。


7. 启动:从 new Context() 到"每一行都活着"

boot()packages/boot/app-boot/src/index.ts:757)是启动的收口:

const ctx = new Context()
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)
  await ctx.get('loader')?.await()
  if (ctx.get('loader') === undefined) return ctx
  await assertEntriesActivated(ctx, binName)
  return ctx
} catch (cause) {
  await ctx.fiber.dispose()
  // ...
}

prepare 回调是启动器往树里塞"启动期事实"的地方(profile-boot.ts:248):

const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
  app.current = hostCtx
  // Before any config-tree entry mounts, so plugins resolve all launch-time
  // environment values from the same immutable provenance snapshot.
  hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
  provideCmdline(hostCtx, {
    args: options.args,
    exit: code => void shutdown.shutdown(code),
  })
})

两个关键设计:

环境快照是冻结的、单一来源的。 不是让各插件自己读 process.env,而是启动前拍一张不可变快照,所有插件从同一份"出处"(provenance)解析启动期环境值。这样"这次运行的环境是什么"是一个可审计的事实,而不是散布在几十个 process.env.X ?? default 里的推断。

命令行参数不属于启动器。 args.ts 只解析自己那几个 flag,第一个它不认识的 token 开始就全部交给树

dsh --profile web --port 8080       # --port 属于 web app
dsh --profile web --help            # web app 的 help,不是启动器的
dsh --help                          # 启动器自己的 help

参数经 ctx.cmdlineArgs 变成一个不可变快照,任何注入了它的 app 插件都能解析自己那一族 flag、打印自己的 --help。于是"加一个新的产品形态"不需要改启动器一行代码——headless 的 6 行 patch 就是证明。

最狠的一道门:assertEntriesActivated

第 2 章讲过,Cordis 里 PENDING(依赖没齐,插件一行没跑)是完全正常的状态——这正是热插拔的基础。但对一个已经启动完的产品来说,"配置里写了这一行,但它至今没激活"几乎总是配置错误。dsh 于是在启动收尾处加了一道审计(app-boot/src/index.ts:692):

for (const entry of ctx.loader.entries()) {
  const fiber = entry.fiber
  if (fiber === undefined || entry.disabled) continue
  const state = fiber.state
  if (state === FIBER_ACTIVE) continue
  if (state === FIBER_FAILED) {
    try {
      await fiber.await()
    } catch (error) {
      rejectionReasons.push(error)
      failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
    }
    continue
  }
  if (state === FIBER_PENDING) {
    const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
    const subject = missing.length === 1 ? 'service' : 'services'
    failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
  } else {
    failures.push(`${entry.options.name}: fiber state ${String(state)}`)
  }
}
if (failures.length > 0) { /* 抛出,附上每一行的诊断 */ }

它把 Cordis 那个宽容的生命周期模型,在产品边界上收紧成一条硬约束,而且诊断信息直接告诉你缺哪个服务(waiting for services: fs, shell)。这是本章最值得抄走的一招:框架层要宽容(才能热插拔),产品层要严格(才能可诊断),两者不矛盾,只需要在启动收尾处加一次审计。

FAILED 分支里还有个细节:它 await fiber.await() 只为了拿回那个 rejection 的原始 stack,再把最深层的 cause 的 stack 追加到最终错误信息里:

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}` : ''

因为 Loader 的事务性挂载会给每一层树包一层错误信息,不挖到底你只能看到一串包装消息,看不到真正炸的那行代码。


8. 热更新:改一行 YAML,不重启

长驻界面(web)上,两个用户 patch 文件都是热的(profile-boot.ts:285):

await watchUserPatches(ctx, { binName: NAME, filename: composed.profile.patchPath, compose: composeLive })
await watchUserPatches(ctx, { binName: NAME, filename: homePatchPath(), compose: composeLive })

composeLive() 每次重新组装整个层栈(:240):

const composeLive = (): PatchOptions[] => structuredClone([
  ...composed.bundlePatches,
  ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
  ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
  ...composed.overlays,
])

四个不那么显然的决定,注释都给了原因:

① bundle 层在下、overlay 在上,所以用户编辑永远挤不掉它们。 热重载不是"打一个增量补丁",而是重新走一遍完整的分层——层序是配置模型的一部分,不能因为触发路径不同而变化。

② 两个文件都整份重读,哪怕 HMR 只告诉你其中一个变了。 理由:the HMR watcher hands us only the changed file's patches, which one of the reads duplicates — fresh reads keep the two watchers from stitching in each other's stale copy。两个 watcher 各自持有对方的旧副本,是这类"多 watcher 拼装同一份状态"的经典 bug。

③ 每代都 structuredClone 理由和第 3 节同源,但更尖锐:

the include pushes `insert` rows into the mounted tree BY REFERENCE and later
id-targeted patches mutate those objects in place. Reusing one parsed patch
object across applications would bake a user override into the bundle's
in-memory insert row, so removing the override could never revert the row to
the bundle default.

——删掉自己刚加的一行覆盖,值回不去默认。这是配置热重载里最难查的一类 bug("我删了那行怎么还生效?重启就好了"),dsh 用两处 structuredClone 从根上堵掉。

④ 组合里没有 HMR 服务时,临时装一个"只看文件不重载模块"的实例:279):

if (ctx.get('hmr') === undefined) {
  if (ctx.get('timer') === undefined) {
    await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
  }
  await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
}

web/headless 两个 bundle 都把共享的 hmr 行关掉了(模块级热重载的卸载路径未经充分测试),但 cordis.patch.yml 的热生效是对用户承诺过的行为。启动器于是补一个 root: [] 的 HMR——不监视任何模块目录,只做文件监视。注释里那句 A silent skip would break the documented hot-reload contract 是这类"承诺过的行为不能因为内部重构悄悄消失"的教科书写法。

顺着 watchUserPatches 再往下看一层,还有一个第 2 章遗留概念的实战(app-boot/src/index.ts:255):

if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}

INACTIVE_EFFECT 就是第 2 章讲的"在已经开始卸载的 fiber 上创建 effect"。什么时候会遇到?一次性任务跑得比 watcher 建立还快,整棵树已经在拆了。这不是错误,是"app 完全按要求退出了",所以返回一个空 disposer 而不是崩掉。同一段代码在 runProfile 里还有一次对称处理(suppressShutdownErrorprofile-boot.ts:195)。


9. --dump-config:不会漂移的自省

想知道这次到底装了什么,不用启动:

dsh --profile web --dump-config          # 完整组合
dsh --profile web --dump-default-config  # 只有 bundle 层

runDumpConfigapps/cli/src/dump-config.ts)把每一层连着它的来源文件名一起打印,并且锚定同一个空根文件

const loaded = prepareProfile(profile, !defaultOnly)
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
  label: layer.packageName,
  patches: layer.patches,
}))
// ...profile 层、home 层、每个 --patch 覆盖各自成一层
process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))

它可靠的原因写在模块 JSDoc 里:compose the profile's patch layers through the include plugin's patch algorithm without booting or evaluating !!js——共用 applyEntryPatches,只是不启动、不求值 !!jscomposeEntries()profile.ts:413)也是同一个入口,注释说得直白:the same single applyEntryPatches call the boot include makes, so flag derivation and config dumps see exactly what mounts

--dump-default-config 还有个专门用途:它完全不解析用户层prepareProfile(profile, false)loadProfile(..., { userLayer: false }))。所以当你把 cordis.patch.yml 写坏、启动直接失败时,这条命令仍然能跑——它是配置坏掉时的恢复诊断工具,这也是为什么 loadProfile 要专门开一个 userLayer 开关。


10. 一份 patch,两套 shell 栈

平台差异是分层机制最能打的一个实战场景。base bundle 里:

- id: bash-sandbox
  name: '@deepseek-ai/dsh-bash-sandbox'
  disabled: !!js process.platform === 'win32'
  config:
    timeoutMs: 60000

- id: pwsh-sandbox
  name: '@deepseek-ai/dsh-pwsh-sandbox'
  disabled: !!js process.platform !== 'win32'

tool-bash / tool-pwsh 同样成对门控。一份 patch 文件,每台主机上恰好一套 shell 栈(README:one shared patch file, exactly one shell stack per host)。

这里藏着一个第 8、9 章会展开、但现在就能看见形状的事实:换 shell provider 不影响权限模型。Windows 上 sandbox / sandbox-policy 照样强制文件效果策略(走 dsh-sandbox-localdsh-sandbox-windows-acl 的受限令牌运行器),审批服务照跑,fs-sandbox 照样拦 ctx.fs 写入。这正是第 2 章 epoch 机制加上第 8 章 capability seam 的联合效果:consumer 认的是服务名,不是实现。

base README 还给了一条很有教育意义的告警——如果 Windows 用户想换回 bash:

the bash-restore recipe must be complete: disable pwsh-sandbox/tool-pwsh AND re-enable bash-sandbox/tool-bash — both executor families register the same bash service, so an incomplete recipe fails loud at load

只做一半会怎样?两个 executor 家族注册同一个 bash 服务 → 重复注册 → 加载时大声失败。这是"注册即 effect"的一个副产品:服务注册冲突在加载期就暴露,而不是运行到某次工具调用时才出现诡异行为。同一条规则在 README 里还有一个例子:Windows 上 fs-sandbox 已经在管 ctx.fs,再挂 dsh-fs-local 会双重注册 ctx.fs,同样 fail loud。


11. 这套设计的代价

诚实地列一下:

① 没有深合并,用户覆写要重述整块 config。 想改 session-query-sqliteopenAt,得把 path 一起写。行的 config 一大,这就很烦,而且 bundle 升级后新增的字段不会自动流到你的覆写层里——你的覆写会把它顶掉。

② 层数多,"这个值从哪来"要跑 dump 才知道。 四层 + 启动器追加,虽然 --dump-config 会标注来源,但比"就一个 config.yml"确实重。

③ 行 id 是隐式公共 API。 用户 patch 靠 id 定位,所以重命名一个 row id 会静默地让用户的 patch 打空(只 warn)。当前处于 developer preview 的 foundation over blast radius 阶段可以接受,正式发布后这就是兼容性负担。

!!jseval with (ctx) { return eval(expr) } 无沙箱。它读的是本机文件(bundle 自带 + 用户自己写的),信任模型上等价于"能改配置文件的人本来就能执行代码",但这条边界必须清楚:不要从不可信来源接收 patch 文件

⑤ 启动期的严格性有代价。 assertEntriesActivated 会因为"某行在等一个永远不会来的服务"而拒绝启动整个产品。诊断友好,但也意味着一个可选功能的配置错误会让整个 app 起不来——这是 dsh 主动选的一边。


12. 可迁移的经验

抛开 dsh 和 Cordis,这一章有五条可以直接搬到任何项目的做法:

① 配置的"默认值"应该是数据,不是代码。 把默认值从各处的 ?? default 里收回来,放进一个显式的、可读的默认层。"这个值是多少"就变成了读文件,而不是读代码。

② 分层的应用算法要只有一份实现。 dsh 让启动和 --dump-config 共用 applyEntryPatches——dump 不可能和真实启动不一致。凡是"自省工具另写了一遍逻辑"的项目,最后都会碰上 dump 和现实不符的调试噩梦。

③ 每次重新组装,而不是打增量补丁。 热重载重跑整个分层,比"算出 diff 再 apply"简单得多,也不可能因为漏算一个 diff 而进入不一致状态。

④ 共享的配置对象一定要克隆。 这是本章重复出现两次的教训:insert 的行是按引用推进树的,后续 patch 原地改它们;复用同一份解析结果,就会让"删掉覆盖"永久失效。

⑤ 框架宽容、产品严格。 生命周期模型该允许"等依赖"这种中间态(否则没法热插拔),但产品启动完成时必须审计一次并大声失败。这条对任何有插件系统的项目都成立。


附:本章源码引用表

位置 内容
apps/cli/src/args.ts:1 启动器命令语法与"第一个不认识的 token 起交给 app"的契约
apps/cli/src/bin.ts:30 四种 invocation 的分派,按需 import runner
apps/cli/src/dump-config.ts:30 runDumpConfig():按层打印,锚定同一个空根
apps/cli/src/profile-boot.ts:60 PROFILE_ROOT_CONFIG:永远是 [] 的根
apps/cli/src/profile-boot.ts:80 resolveTelemetryPatch():任何非空值都关闭
apps/cli/src/profile-boot.ts:98 prepareProfile():每次启动重写根文件
apps/cli/src/profile-boot.ts:122 allPatches():四层的确切顺序
apps/cli/src/profile-boot.ts:142 composeProfile():层组装 + 启动器追加的两条 patch
apps/cli/src/profile-boot.ts:195 suppressShutdownError():正常退出不是失败
apps/cli/src/profile-boot.ts:207 runProfile():信号、fail-loud、boot、watcher
apps/cli/src/profile-boot.ts:240 composeLive():热重载的重新组装 + structuredClone
apps/cli/src/profile-boot.ts:279 HMR 缺失时的 watch-only 兜底
packages/boot/app-boot/src/index.ts:232 watchUserPatches()INACTIVE_EFFECT 的处理
packages/boot/app-boot/src/index.ts:278 loadOptionalPatches():缺文件=无层,坏文件=报错
packages/boot/app-boot/src/index.ts:298 loadOverlayPatches():点名的文件缺失即报错
packages/boot/app-boot/src/index.ts:692 assertEntriesActivated():产品边界的严格审计
packages/boot/app-boot/src/index.ts:757 boot():两个失败阶段标签 + 挖最深 cause
packages/boot/app-boot/src/index.ts:770 ctx.provide('dshHomePath', ...)!!js 的作用域来源
packages/boot/app-boot/src/profile.ts:104 resolveProfileDir():名字校验与 node_modules 保留名
packages/boot/app-boot/src/profile.ts:114 PROFILE_TEMPLATES:仅 web / headless 自动初始化
packages/boot/app-boot/src/profile.ts:138 hoisted + autoInstallPeers: false:故意制造缺失以共享单例
packages/boot/app-boot/src/profile.ts:152 initProfile():幂等初始化
packages/boot/app-boot/src/profile.ts:223 healProfilesModuleFallback():扁平模块回退目录
packages/boot/app-boot/src/profile.ts:371 loadProfile():bundle 解析与两处 fail loud
packages/boot/app-boot/src/profile.ts:413 composeEntries():与启动共用的同一次 applyEntryPatches
vendor/include/src/index.ts:9 !!js 的 YAML 类型:可原样写回
vendor/include/src/index.ts:58 applyEntryPatches():THE patch semantics
vendor/include/src/index.ts:145 PatchOptions:patch 能改哪些字段
vendor/include/src/index.ts:225 enqueue():并发 apply 的串行化
vendor/include/src/index.ts:371 write():配置回写——第 6 节要防的那条路径
vendor/loader/src/index.ts:92 internal/config waterfall:!!js 求值时机与 tree carrier 例外
vendor/loader/src/config/utils.ts:5 evaluate()with (ctx) { eval(expr) }
packages/bundle/base/cordis.patch.yml 78 行的共享核心;平台门控;遥测默认关
packages/bundle/web-app/cordis.patch.yml 84 行的浏览器界面层
packages/bundle/headless/cordis.patch.yml 35 行 = 一个完整的无界面产品形态

官方对应文档:apps/cli/README.mdapps/cli/reference/README.mdapps/cli/composition.md(生成的组合图)、packages/bundle/*/README.mddocs/cordis-primer.md#loader-configuration