第一部分 · 总览
第 2 章:启动装配——从 dsh web 到插件树
一行命令如何变成一棵五十多个包的树:launcher、profile、bundle 与 patch 分层
一行命令,两种职责
运行 npx @deepseek-ai/dsh web,浏览器在 http://127.0.0.1:3080 打开。这一行命令背后有两层完全不同的职责,而 dsh 的启动器刻意把这两层分开:
- Launcher(启动器)只回答一个问题:启动哪个 profile,叠上哪些额外 patch?它解析自己的少量旗标,然后把其余所有参数原样交给启动后的插件树。
- App(应用)由插件树自己定义:Web 应用的
--host/--port、headless 的任务文本,都是树里某个插件读ctx.cmdlineArgs自己解析的。
这个分工在 apps/cli/src/args.ts 的头部注释里说得很清楚:
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.
于是 dsh --profile web --help 打印的是 Web 应用自己的帮助,而不是启动器的;dsh --profile headless "run the tests" 把 "run the tests" 当任务文本交给 headless runner。启动器不认识任何应用旗标——它只负责把树拼对,树的内部语言由树自己说。
入口:bin.ts 的三路分发
apps/cli/src/bin.ts 是整个仓库的入口(约 50 行)。它先读版本号、解析参数,然后按解析结果分三路动态 import:
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
switch (invocation.mode) {
case 'profile': {
const { runProfile } = await import('./profile-boot.ts')
await runProfile({
environment: loadLayeredEnv('dsh'),
profile: invocation.profile,
patchFiles: invocation.patches,
args: invocation.args,
})
break
}
case 'plugin': {
const { runPlugin } = await import('./plugin.ts')
process.exit(runPlugin(invocation.profile, invocation.args))
break
}
case 'dump-config': {
const { runDumpConfig } = await import('./dump-config.ts')
runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
break
}
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
}
三个模式:profile 正常启动一个 profile;plugin 把参数转发给 pnpm 管理 profile 的插件依赖(dsh plugin --profile web add <package>);dump-config 不启动,只打印拼好的插件树(下一节会用到)。注意 invocation satisfies never——三个分支穷尽后,编译器要求默认分支不可达。动态 import 是刻意的:每个模式只加载自己需要的模块,dsh --help 不必加载 Web 应用。
profile 与 bundle:两层「装配单元」
dsh 的装配系统有两个正交的概念,第 16 章还会再遇到它们的孪生兄弟(preset)。这里先定义启动期的两个:
| 概念 | 是什么 | 存放位置 | 声明方式 |
|---|---|---|---|
| profile | 一个命名组合:它列出自己堆叠哪些 bundle,持有用户自己的 cordis.patch.yml | $DSH_HOME/profiles/<name>/($DSH_HOME 默认 ~/.dsh) | 该目录 package.json 的 dsh.profile.bundles |
| bundle | 一个可安装的「patch 层 + 它装载的代码」的发布格式 | npm 包(如 @deepseek-ai/dsh-base) | 该包 package.json 的 dsh.bundle.patch 指向 cordis.patch.yml |
dsh-base 是每个 profile 的第一层:模型适配、工具、持久化、沙箱与审批策略、设置、凭证、遥测。`dsh-web-app 在其上叠加浏览器应用;dsh-headless 叠加一次性 runner(不起服务器)。profile.ts 里的模板常量定义了这套默认关系:新建 web profile 时自动初始化成 base + web-app,新建 headless 是 base + headless,自定义 profile 只含 base。
每个 bundle 的 patch 文件是一份 cordis.yml 风格的配置行清单。以 packages/bundle/base/cordis.patch.yml 为例,里面定义了沙箱模式与审批策略这两个安全旋钮:
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission-presets'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
注意 !!js(两个感叹号)——这是 dsh 的 cordis.yml 方言里唯一允许的 JS 求值形式:配置值可以是惰性求值的表达式(这里把 DSH_PERMISSION_MODE 环境变量变成部署决策)。!js(单感叹号)被明确禁止,防止把不可序列化的对象混进配置元数据。
patch 分层:树是这样拼出来的
一个 profile 的插件树不是「读一份配置然后启动」,而是在空根上按顺序叠 patch 层。应用顺序(profile-boot.ts 的 allPatches):
1. bundle 层:dsh.profile.bundles 里每个 bundle 的 patch,按列出顺序 2. profile 自己的 cordis.patch.yml($DSH_HOME/profiles/<name>/) 3. home 级 cordis.patch.yml($DSH_HOME/cordis.patch.yml,机器级偏好,压过一切 profile) 4. --patch 覆盖层(argv 顺序) 5. 遥测开关层(DSH_TELEMETRY_DISABLED 的非空值 → 禁用 session-telemetry-otel 行)
patch 按行的 id 定位:覆写就是整段替换该行的 config,插入就是加新行。每一层都可以把下面任意一层的任意一行换掉或禁用——「上层永远压过下层」是唯一的规则。有一个细节值得注意:dump-config 能打印出你机器上真正启动的树,因为合成用的是同一条 applyEntryPatches 路径:
dsh --profile web --dump-config
任何打印出来的行,你都可以用自己的一层 patch 替换它。这就是「每个部分都可从配置替换」的可操作版本——不是文档承诺,而是 dump 出来的事实。
boot:把 patch 变成活的树
runProfile()(profile-boot.ts)把合成好的 patch 栈交给 app-boot 的 boot()。这一段值得逐句读:
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)
// The command line and bounded exit request are launcher facts available
// to every app plugin that injects the argument snapshot.
provideCmdline(hostCtx, {
args: options.args,
exit: code => void shutdown.shutdown(code),
})
})
三个要点:
structuredClone是刻意的——patch 对象会被 Loader 按引用插入树,后续 id 定向的 patch 会原地改这些对象;如果不克隆,一次覆写会把 bundle 的默认行永久污染(注释里写得很细:「Reusing one parsed patch object across applications would bake a user override into the bundle's in-memory insert row」)。- 启动环境(
DSH_LAUNCH_ENVIRONMENT_KEY)与命令行(provideCmdline)在任何插件挂载之前就注入,保证树里所有插件读到同一份不可变快照。 process.on('SIGTERM')与SIGINT在整个启动窗口就位——SIGTERM 是 supervisor 的常规停止(退出码 0),SIGINT 是用户中断(退出码 130),两者都走fiber.dispose()让树自己清理。
启动完成后还有一步:配置级 HMR。两个 watcher(profile 层与 home 层)监听 cordis.patch.yml 的修改,改动即时重组合并热装载——用户改一行配置不必重启。Web bundle 会禁用模块级 reload(其生命周期未经测试),所以这里挂的是 watch-only 实例。这个「编辑即生效」的契约是 dsh 把「配置即代码」落到实处的一部分。
dsh 的启动器对「树里有什么」一无所知:它不读插件的配置 schema、不解析应用旗标、不决定哪个服务该启动。它只做四件事:选 profile、叠 patch、注入启动事实、转发剩余参数。树的内部世界完全由树自己组织——这和第 17 章浏览器里那棵 client 树形成呼应:宿主只提供「如何到达插件代码」的机制,插件代码自己决定一切。
一个预告:profile 与 preset 的区别
第 16 章会深入 preset,这里先给出一个定位,避免混淆:profile 是启动期、进程级、系统面的——它决定这个进程启动哪些子系统;preset 是运行期、per-session、模型面的——它决定某一个会话给模型看哪些工具、哪些提示词。一个管「进程长什么样」,一个管「这个 agent 长什么样」。两者都叫「组合」,但作用在不同维度。
实践应用
- launcher 与 app 分离:启动器只解析自己的旗标,其余参数原样交给应用。这让「一个 bin、多套应用」成为可能(web/headless/自定义 profile 共用同一个 bin.ts),也让应用的
--help永远是应用自己的。 - 配置即叠加层:patch 分层 + 整段替换 + 上层压过下层,是「用户永远能覆盖任何默认」的干净机制;配合
--dump-config把合成结果变成可检查的事实。 - 克隆后应用:patch 对象按引用插入 + 原地修改的陷阱,用
structuredClone从源头消除——配置合成这类「同一份数据被多次应用」的场景都值得借鉴。 - 启动事实先于一切插件注入:环境快照与命令行在挂载前提供,避免「哪个插件先读到环境」的竞态。
总结
这一章追完了从 dsh web 到插件树的全过程:launcher 只选 profile 叠 patch,app-boot 在空根上按序应用 patch 层,把 bundle、用户层、覆盖层拼成一棵活的树,再注入启动事实、挂上配置 HMR。树长出来了,但还什么都没做——下一章进入树的心脏:ReactLoopAgent 如何在 turn/step 状态机里驱动一个会话,把「一条消息」变成日志里的一串事实。