青雲的博客

第一部:启动——从命令到运行时

从 dsh 命令敲下到 Cordis 组装完成,Agent 和 Session 获得身份,Headless 与 Web 各走各路。

[图片占位:卡通工程师坐在蓝鲸背上,面前是一棵发光的插件树。深蓝+荧光青色调。]
展开阅读路线与实验入口

你在终端里敲了 dsh,或者在浏览器里打开了本地地址。屏幕上出现输入框,光标闪烁。看起来一切就绪。

但在光标开始闪烁之前,已经有几百个模块被加载、几十棵依赖树被组装、多个服务被启动。如果你直接从 packages/core 开始读,会迷路——DeepSeek Harness 没有一个”主入口”告诉你”一切从这里开始”。

第一部只回答一个问题:敲下命令到第一个 Agent 等待输入之前,发生了什么。

这一部解决什么

读完这五章,你能分清四件常被混为一谈的事:加载配置、组装运行时、创建身份、接受输入。dsh --profile web 不等于”Web 版 CLI”,而是通过 ApiProxy 走了另一条受控链。把它们当成同一条路,后面读 Goal、MCP、Sandbox 时会到处撞墙。

accTitle: 第一部阅读路径
accDescr: 从 dsh 命令入口到 CLI/Web 分发,经 profile 补丁栈、Cordis 组装,最终 Agent 与 Session 获得身份
accDescription: 第一部阅读路径流程图,从 dsh 命令开始,经过 Profile + Patch 层叠、Cordis 容器组装、Agent/Session 身份,最后分流到 Headless(直接创建 Agent)和 Web(ApiProxy 控制面)。
flowchart LR
    A["dsh 命令"] --> B["Profile + Patch 层叠"]
    B --> C["Cordis 容器组装"]
    C --> D["Agent/Session 身份"]
    D --> E{"产品入口"}
    E -->|Headless| F["直接创建 Agent"]
    E -->|Web| G["ApiProxy 控制面"]

本部命令只读固定 commit 的 Git 对象,不安装依赖、不启动服务。Web 截图来自隔离工作区。

从最早的决策点开始:dsh 到底是什么。

深入浅出 DeepSeek Harness 第一部:启动——从命令到运行时 第 01 章

从敲下 dsh 到补丁栈组装完成

跟踪 dsh 命令从 process.argv 到 Cordis Loader 启动的完整路径:bin.ts 分发、三层环境加载、Profile 解析、五层补丁栈叠合、boot() 挂载。不是概览,是逐行推导。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

你在终端敲了 dsh,按下回车。

如果你写过 CLI 工具,你可能预期看到一个几百行的 main() 函数:解析参数、初始化配置、创建核心对象、启动服务循环。你打开 apps/cli/src/bin.ts——只有 53 行。没有 Agent 初始化,没有模型连接,没有工具注册,连一个 import 的路径都看不出”这是个 AI Agent 系统”。

这不是因为代码被抽到了别处(虽然确实如此),而是因为 bin.ts 在架构上就不是”主程序”。它是一个分发器——读参数、判断模式、把控制权交给对应的子系统,然后它就退出了自己的作用域。理解这个设计意图是理解整个启动链的前提。

第一次读这一章,别急着记函数名。把三句话放在脑子里就够了:bin.ts 不是主程序,它只是分发入口;决定启动结果的不是 argv 本身,而是后面那条“环境加载 → 补丁栈叠合 → boot() 挂载”的装配链;cordis.yml 也不是让你手工维护的持久配置快照,在这条启动链里它更像 Loader 的写回产物。

所以这章最好别顺着函数一个个啃。先看 bin.ts 这 53 行到底干了什么,再看 parseDshArgs 怎么把内外两层参数切开,然后直接跳去 loadLayeredEnvcomposeProfile,最后再回来看 boot()。这样不容易在细枝末节里绕晕。

bin.ts:一个 switch 语句就是全部

打开文件看:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
wc -l "$repo/apps/cli/src/bin.ts"
# 预期输出:53 apps/cli/src/bin.ts
cat "$repo/apps/cli/src/bin.ts"

你会看到它做了三件事:

  1. 读版本号:从 ../package.jsonversion 字段。没有 hardcoded 版本字符串,没有构建时注入——运行时从文件读。这意味着你改了 package.json 的版本号,下次 dsh --version 就会变,不需要重新构建。

  2. 解析参数parseDshArgs(process.argv.slice(2), readVersion()) 返回一个有区分联合类型的 DshInvocation,只有三种可能:profileplugindump-config

  3. 按 mode 分发:一个 switch (invocation.mode) 语句,三个 case 各自动态 import 对应模块然后调用。注意是 动态 import,不是顶层 import——这意味着如果你走 plugin 路径,profile-boot.ts 的代码根本不会被加载。这是有意的:每条路径只引入自己需要的模块。

最后一行 invocation satisfies never 是 TypeScript 的穷尽性检查:如果未来有人加了第四种 mode 但忘了处理,编译就会报错。这行代码在运行时永远不会被执行。

parseDshArgs:pass-through 的设计意图

现在看参数解析。parseDshArgs 用了 Commander.js,但配置了两个关键选项:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "allowUnknownOption\|passThroughOptions" "$repo/apps/cli/src/args.ts"
# 预期:你会看到 .allowUnknownOption() 和 .passThroughOptions() 各出现多次

.allowUnknownOption() + .passThroughOptions() 的组合效果是:从第一个 Commander 不认识的 token 开始,后面所有参数都原封不动地透传。 Commander 默认行为是遇到未知参数报错退出;这里故意关掉了。

为什么要这样设计?因为 dsh 的参数空间被分成了两层

  • 外层(launcher)--profile--patch--dump-config--dump-default-config-V。这些是 launcher 自己的 flag。
  • 内层(app)--resume--model--help-h 以及其他所有参数。这些属于被启动的 app(由注入的 cmdline 插件解析)。

分层的规则很简单:launcher 的 flag 必须在内层 args 之前。一旦 Commander 遇到它不认识的第一个 token,后面的一切都被当成内层 args。所以:

  • dsh --profile web --resume abc--profile web 是 launcher 的,--resume abc 透传给 web app。
  • dsh --profile web -h-h 被透传给 web app,打印的是 web app 的 help,不是 launcher 的。
  • dsh -h:因为没有 --profile,没有 app 可以接收 -h,所以 Commander 自己处理它,打印 launcher 的 help。

还有一个隐蔽的设计:web 是一个硬编码的子命令别名。dsh web 等价于 dsh --profile web——但它不是通过字符串替换实现的,而是 Commander 的 .command('web') 注册了一个独立的子命令,内部调用 resolveBoot(web, 'web', options, args)。这意味着 web 子命令有自己独立的 --patch--dump-config 选项,和父命令的选项互斥(rejectParentOptions 做检查)。

三层环境加载:谁的 .env 优先级最高

回到 bin.ts。当 mode 是 profile 时,第一件事不是调 runProfile,而是 loadLayeredEnv('dsh')

const { runProfile } = await import('./profile-boot.ts')
await runProfile({
  environment: loadLayeredEnv('dsh'),  // ← 先加载环境
  profile: invocation.profile,
  patchFiles: invocation.patches,
  args: invocation.args,
})

loadLayeredEnv 做的事比名字暗示的要多。它不是简单地读 .env 文件——它实现了一个三层优先级模型,并且在加载任何值之前先做安全校验:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '168,198p' "$repo/packages/boot/app-boot/src/index.ts"
# 预期:看到 loadLayeredEnv 函数,先 parse 两个 layer,再逐个 apply

三层优先级,从高到低:

  1. 进程继承环境process.env):你在 shell 里 export DEEPSEEK_API_KEY=xxx 或者 DEEPSEEK_API_KEY=xxx dsh 设置的。最高优先级,不可被文件覆盖。
  2. 项目级 .env$CWD/.env):当前工作目录下的 .env 文件。
  3. 用户级 .env$DSH_HOME/.env):Harness home 目录下的全局配置。

但在应用这些值之前,loadLayeredEnv 会检查每个文件中的每一个变量名。如果发现 bootstrap-only 变量——包括 PATHHOMENODE_OPTIONS、所有 DSH_ 前缀、所有 GIT_ 相关变量等——直接 throw Error 拒绝启动:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '104,143p' "$repo/packages/boot/app-boot/src/index.ts"
# 预期:看到 BOOTSTRAP_NAMES 集合和 BOOTSTRAP_PREFIXES 数组

为什么这样设计?因为这些变量决定了进程本身怎样启动、代码从哪里加载、网络怎样连接。如果允许一个 .env 文件覆盖 PATH,那意味着一个项目目录可以劫持你整个命令行环境。如果允许覆盖 GIT_SSH_COMMAND,那意味着一个 .env 文件可以把你的 git 操作重定向到一个恶意脚本。这是安全设计,不是便利设计。

注意加载顺序的细节:先 parse 两个文件(不 apply),全部校验通过后才开始 apply。如果项目级 .env 合法但用户级 .env 有违规变量,两个文件都不会被 apply——保证原子性。

最终 loadLayeredEnv 返回一个 LaunchEnvironmentSnapshot——一个 frozen 的快照对象,记录了每个变量来自哪一层。这个快照在后面会被 ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment) 注入到 Cordis 容器中,所有插件都能查到”这个 API key 到底是从进程环境来的还是从 .env 文件来的”。

Profile:不是配置文件,是一棵补丁树

环境加载完之后,进入 runProfile()。它的第一步是 composeProfile(options.profile, options.patchFiles)

Profile 在 DSH 里不是”一个 JSON/YAML 配置文件”。它是一个目录,位于 $DSH_HOME/profiles/<name>/,里面有:

  • package.json:声明了 dsh.profile.bundles 数组——这个 profile 由哪些 bundle 包组成。
  • cordis.patch.yml:用户自定义的补丁层(可选)。
  • cordis.yml:一个空数组,每次启动被覆写。
  • node_modules/:bundle 包的实际文件。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -A5 "PROFILE_TEMPLATES" "$repo/packages/boot/app-boot/src/profile.ts" | head -8
# 预期:看到 web 和 headless 两个模板的 bundle 数组

如果你第一次运行 dsh --profile web,profile 目录不存在——loadProfile 会自动用模板初始化它。web 模板的 bundles 是 ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']headless 模板是 ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']

为什么 cordis.yml 每次启动都被覆写

这是一个容易让人困惑的设计。打开 prepareProfile()

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -B2 -A8 "PROFILE_ROOT_CONFIG" "$repo/apps/cli/src/profile-boot.ts" | head -15

你会看到 writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG),而 PROFILE_ROOT_CONFIG 就是一段注释加 []。每次启动都写。

为什么?因为 Cordis Loader 有一个 write-back 机制:当一个插件 self-dispose 时,Loader 会把当前运行时的配置树持久化到它读取配置的文件里(就是 cordis.yml)。如果你不在启动时把它清空,上一次运行结束时写回的配置树会和这次启动时补丁栈注入的行叠加——产生重复行。

源码注释说得很清楚:“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.”

所以 cordis.yml 文件存在的唯一理由是:Loader 需要一个真实的文件路径作为 baseUrl 锚点来解析相对路径。它不承载任何有意义的持久化配置——所有配置来自补丁栈。

五层补丁栈:谁叠在谁上面

composeProfile() 的核心工作是把五层补丁按顺序组装:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '140,190p' "$repo/apps/cli/src/profile-boot.ts"

五层,从底到顶:

  1. Bundle layersprofile.layers.flatMap(layer => layer.patches)):每个 bundle 包的 cordis.patch.yml。多个 bundle 按 dsh.profile.bundles 数组顺序展平——先声明的在底层。
  2. Profile patchesprofile.patches):profile 目录自身的 cordis.patch.yml——用户对这个 profile 的定制。
  3. Home patchesloadOptionalPatches(NAME, homePatchPath())):$DSH_HOME/cordis.patch.yml——机器级别的全局定制,应用到所有 profile。
  4. —patch overlayspatchFiles.flatMap(file => loadOverlayPatches(...))):命令行 --patch 参数指定的额外补丁文件。
  5. Telemetry switch(条件注入):如果 DSH_TELEMETRY_DISABLED 环境变量非空且 composition 中有 telemetry 行,追加 {id: 'session-telemetry-otel', disabled: true}

关键细节:这些层不是简单的”后面覆盖前面”。它们被 composeEntries() 传给 applyEntryPatches([], layers.flat(), warn)——这是 @deepseek-ai/cordis-plugin-include 的补丁算法。它的语义是 id-targeted merge

  • 如果一个补丁指定了 id 字段,它会找到现有 entry list 中相同 id 的行,做 config 深合并disabled 切换
  • 如果补丁包含 insert 数组,这些行被追加到 entry list 末尾。
  • 一个补丁可以同时做 id-targeted 修改和 insert。

这意味着”五层叠合”不是”第五层的配置完全替换第一层的”——而是每一层只能修改它指定 id 的行追加新行。你在 profile patch 里写 {id: 'session-telemetry-otel', config: {sampleRate: 0.5}},只会修改 telemetry 行的 config.sampleRate,不会影响该行的其他配置。

用 —dump-config 验证补丁栈

你可以用 --dump-config 观察补丁栈的最终效果:

dsh --profile web --dump-config
# 输出是一个 YAML 文档,每一段用 # == 注释标注来源
# 你会看到 bundle 层的行、profile 层的修改、home 层的修改按顺序标注

--dump-default-config 只打印 bundle 层(不含用户层和 --patch),用来对比”官方默认”和”你的定制”之间的差异。

boot():创建 Context、挂载插件树、等待稳定

补丁栈组装完后,runProfile() 在调 boot() 之前还做了两件事:

第一,创建 ProcessShutdown:监听 SIGTERM(exit 0)和 SIGINT(exit 130)。这两个退出码不是随便选的——SIGTERM 是 supervisor 的正常停止请求(systemd stop、Docker stop),SIGINT 是用户按 Ctrl+C。130 = 128 + 2(SIGINT 信号编号),这是 Unix shell 的惯例退出码。注意 shutdown handler 在 boot() 之前就注册了——因为 boot 过程中也可能收到信号。

第二,installFailLoud():安装 unhandledRejection 处理器。当未捕获的 Promise rejection 发生时,它给终端 2 秒恢复时间再 exit(1)。这 2 秒不是 debounce——是等终端把光标位置、颜色设置、raw mode 恢复正常。没有这个延迟,shell 提示符会错乱。

然后进入 boot()。它在 packages/boot/app-boot/src/index.ts 的第 757 行:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '757,800p' "$repo/packages/boot/app-boot/src/index.ts"

boot() 的执行步骤:

  1. new Context():创建 Cordis 根 Context(后面第2章详细解释 Context 是什么)。
  2. 设置 ctx.baseUrl:指向 cordis.yml 所在目录,用于解析相对模块路径。
  3. ctx.provide('dshHomePath', dshHomePath):让 !!js 表达式能引用 Harness home 路径。
  4. await ctx.plugin(Loader):挂载 Cordis Loader 插件。
  5. await prepare?.(ctx):运行调用方的 prepare 回调——runProfile 在这里 provide 环境快照和命令行参数。
  6. await mountRootInclude(ctx, absoluteConfigPath, patches):把 cordis.yml 作为根 Include 挂载,并传入组装好的补丁栈。
  7. await ctx.get('loader')?.await():等待整棵插件树全部完成加载。
  8. await assertEntriesActivated(ctx, binName):检查每个 entry 的 fiber 是否都到达了 ACTIVE 状态。如果有 PENDING(依赖未满足)或 FAILED(初始化报错),抛出详细的诊断信息。

注意 boot() 的错误处理:它用 stage 变量区分了两个阶段。如果 prepare 抛异常,错误信息是 “host preparation failed”——这是宿主代码的 bug。如果 mountRootInclude 之后的任何步骤抛异常,错误信息是 “plugin tree failed to load”——这是配置或插件的问题。出错时,boot() 会先 await ctx.fiber.dispose() 清理部分构建的 Context(dispose 不会再 reject),然后抛出标记了 cause 的包装 Error。

还有一个深度细节:assertEntriesActivated 检查的不只是 FAILED。它还检查 PENDING——如果一个 entry 的 fiber 还在 PENDING,说明它声明的 inject 依赖永远不会被满足(没有其他 entry 会 provide 那个 service)。此时它会打印缺失的 service 名称列表,帮你定位是哪个 bundle 漏了。

boot() 之后:HMR 和 user patch watching

boot() 返回后,runProfile() 还有最后一段——安装 HMR 和 user patch 热重载:

repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '239,280p' "$repo/apps/cli/src/profile-boot.ts"

如果 composition 没有挂载 HMR 服务(web bundle 禁用了共享模块重载的 hmr 行),runProfile 会自己创建一个只做 config-reload 的 HMR 实例——保证 cordis.patch.yml 的编辑在所有 surface 上都能热生效。

然后调用两次 watchUserPatches:一次监听 profile 的 cordis.patch.yml,一次监听 home 的 cordis.patch.yml。每次文件变化时,watcher 重新 parse 文件、调用 composeLive() 重新组装补丁栈、然后 entry.update() 把新补丁传给 root Include。注意 composeLive() 里用了 structuredClone——因为 include 的 patch 应用会就地修改被 insert 的 row 对象,如果复用同一个 parsed patch 对象,用户覆写会被烙进 bundle 的内存行里,删掉覆写也无法恢复默认值。

如果在 watcher 设置过程中,tree 已经被 dispose 了(比如一个 one-shot surface 已经完成并退出),suppressShutdownError 会吞掉错误而不是让进程 crash——因为这不是 bug,是正常退出的竞态。

串起来:完整的启动 timeline

flowchart TD
    A["终端: dsh --profile web --resume abc"] --> B["bin.ts: parseDshArgs"]
    B --> C{"invocation.mode"}
    C -->|profile| D["loadLayeredEnv('dsh')"]
    D --> E["三层 .env 加载<br/>bootstrap-only 拒绝检查"]
    E --> F["runProfile()"]
    F --> G["composeProfile('web', patches)"]
    G --> G1["prepareProfile: 自动初始化 + 覆写 cordis.yml"]
    G1 --> G2["加载 bundle layers"]
    G2 --> G3["加载 home patches"]
    G3 --> G4["加载 --patch overlays"]
    G4 --> G5["注入 telemetry switch"]
    G5 --> H["createProcessShutdown<br/>SIGTERM→0, SIGINT→130"]
    H --> I["installFailLoud<br/>unhandledRejection → 2s delay → exit 1"]
    I --> J["boot(name, config, patches, prepare)"]
    J --> J1["new Context()"]
    J1 --> J2["ctx.plugin(Loader)"]
    J2 --> J3["prepare: provide env + cmdline"]
    J3 --> J4["mountRootInclude + patches"]
    J4 --> J5["await loader settlement"]
    J5 --> J6["assertEntriesActivated"]
    J6 --> K["条件安装 HMR"]
    K --> L["watchUserPatches × 2"]
    L --> M["等待 app 运行或退出"]
    C -->|plugin| N["spawnSync pnpm + reconcilePlugins"]
    C -->|dump-config| O["boot + render config + exit"]

dsh 敲下到第一个 app 插件可以开始接收输入,中间经过了:参数解析 → 环境加载 → profile 解析 → 补丁栈组装 → Context 创建 → Loader 挂载 → 插件树加载 → 激活检查 → HMR 安装 → patch 热监听。每一步都是有明确边界的,每一步出错都有独立的诊断路径。

这章不能证明什么

这章跟踪了从命令行到”树稳定”的全路径,但它完全没有解释:

  • Context 和 Fiber 是什么new Context() 创建的对象为什么是 Proxy?为什么 inject 声明能控制启动顺序?(下一章)
  • Loader 怎样把 YAML 行变成运行中的插件mountRootInclude 之后发生了什么?entry 如何变成 fiber?(下一章)
  • Agent 和 Session 在哪里被创建:在补丁栈里某个 bundle 行声明了 Agent service,但它什么时候被实例化、identity 怎么来的?(第3章)

理解完 CLI 分发入口之后,Cordis 才是下一层关键:为什么它不是传统 DI 容器,为什么 ctx.someService 会 throw,为什么 Fiber 状态机会成为整个系统的心跳。