# Agent Note: profile 插件组合包取代固定的表层 overlay

Status: implemented

[English](2026-08-05-profile-plugin-bundles.md) | 中文

## Problem

`dsh` 启动器硬编码了自己的组合：`base.cordis.yml` + `web.cordis.yml` 随 `apps/cli` 一起交付，三种各自定制的入口模式（`--config`、`web`、`-p`）各带一套层栈，外加一个全局的个人 overlay（`$DSH_HOME/config.yaml`）。想把树外插件（一个 TUI、一个提供方扩展包）装进已交付的表层，只能修改仓库；第三方包也没有任何位置可以贡献默认组合。

## Decision

一切都变成 **profile**：即目录 `$DSH_HOME/profiles/<name>`，其中包含一个 `package.json`（pnpm 管理的树外插件 `dependencies`，加上 profile manifest（元数据清单）`dsh.profile` 及其有序的 `bundles` 层列表）和一份用户 `cordis.patch.yml`。**组合包**（bundle）是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包；两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下，因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合：按 `dsh.profile.bundles` 顺序应用每个组合包的 patch，然后是用户层，然后是 `--patch` overlay，最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用，启动、flag 派生与 `--dump-config` 使用完全相同的路径。

随附的组合包是 `@deepseek-ai/dsh-base`（共享核心配置行）、`@deepseek-ai/dsh-web-app`（浏览器 Host 配置行与 Web 运行时粘合层）和 `@deepseek-ai/dsh-headless`（直接叠加在 base 上且不含 web-app 的一次性 runner）。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名；`dsh run [--profile <name>] "task"` 负责一次性执行，默认使用 headless profile；通用的 `dsh --profile <name>` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器，负责初始化 profile，并依据已安装包的组合包声明调和 `dsh.profile.bundles`；没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。

[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法；本 Agent Note 负责该语法所选择的 profile 组合。

解析在构造上就是双锚点的：`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析，再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装，pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找，落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`（安装目录的应用与各组合包所依赖的每个包各一个符号链接，每次启动时修复）。

两项配套重构：webserver 内置的静态 dist 服务改为单一所有者的**回退席位**（`registerFallback`／`applyIndexTaps`），SPA 服务器提取到 `@deepseek-ai/dsh-frontend-static`，使 web 组合包以组合的方式持有自己的 dist，而不是靠启动器代码；[dsh CLI 个人配置决策](../feature/2026-07-20-dsh-cli-personal-config.md)的个人 overlay 机制（`loadPersonalPatches`、`$DSH_HOME/config.yaml`）改为面向逐 profile 与 home 级的 `cordis.patch.yml` 层（`loadOptionalPatches`、接受文件名的 `watchUserPatches`），取代该笔记的各入口模式与文件位置，同时保留其 Harness home 根目录、patch 语义与大声失败的解析。

## Alternatives considered

- **依赖扫描加部分 `patchOrder`**（最初的草案）：扫描 `dependencies` 找出组合包、未列出者按字母序排列，会产生两个真源和一条隐式决胜规则；一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库，不激活任何 patch——行为显式，没有暗中扫描。
- **内置组合包使用 `link:` 条目**：pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新，它会把机器路径嵌进用户文件，并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证（「组合包来自安装目录」），且没有这些繁文缛节。
- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值（dist 路径、flag 事实）：否决，改用纯插件——粘合逻辑就是启动器 patch 的普通配置行，因此组合始终可完整 dump，manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot，且在任何配置树条目挂载之前，于 `boot()` 的 `prepare` 钩子中提供。
- **组合包的传递式自动应用**：只有直接列在 `dsh.profile.bundles` 中的条目才贡献层；想重新导出另一个组合包 patch 的元组合包，必须在自己的 patch 文件中显式完成。

## Consequences

- 新的组合表层（TUI、提供方扩展包）以普通 npm 包形式交付，可按 profile 安装；仓库不再需要为每种部署形态各留一行。
- `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器；`AppCLIEntry` 与各表层专属的启动路径全部移除。
- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层，包括 profiles 模块回退，因此测试与产品之间的组合漂移会大声失败。
- 后端不拒绝磁盘上的任何旧格式（发布前姿态）：`$DSH_HOME/config.yaml` 只是不再被读取。
