Profile plugin bundles replace the fixed surface overlays
profile 插件组合包取代固定的表层 overlay
The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.yml` shipped inside `apps/cli`, three bespoke entry modes (`--config`, `web`, `-p`) each with its own layer stack, and a single global personal overlay (`$DSH_HOME/config.yaml`). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party
English
Problem
The dsh launcher hardcoded its compositions: base.cordis.yml + web.cordis.yml shipped inside apps/cli, three bespoke entry modes (--config, web, -p) each with its own layer stack, and a single global personal overlay ($DSH_HOME/config.yaml). There was no way to install an out-of-tree plugin (a TUI, a provider pack) into a shipped surface without editing the repository, and no place where a third-party package could contribute a default composition.
Decision
Everything becomes a profile: a directory $DSH_HOME/profiles/<name> with a package.json (pnpm-managed out-of-tree plugin dependencies plus the profile manifest dsh.profile with its ordered bundles layer list) and a user cordis.patch.yml. A bundle is an npm package declaring "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }; the two manifest kinds live under distinct dsh.profile / dsh.bundle keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in dsh.profile.bundles order, then the user layer and --patch overlays — one applyEntryPatches call shared by boot and --dump-config. App invocation values later moved from launcher-derived patches to startup services in the app-owned command-line decision.
The default Profile templates use @deepseek-ai/dsh-base (shared core rows), @deepseek-ai/dsh-web-app (browser Host rows and Web runtime glue), and @deepseek-ai/dsh-headless (a direct one-shot runner over base, without web-app). Generic dsh --profile <name> hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned --patch. dsh plugin --profile <name> <args...> is a thin pnpm forwarder that initializes the profile and reconciles dsh.profile.bundles with installed bundle declarations; a package without a bundle declaration remains a plain dependency. Headless as a direct core entry point owns the headless composition contract.
Resolution is two-anchored by construction: dsh.profile.bundles names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running dsh and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback $DSH_HOME/profiles/node_modules (one symlink per package the installation's app and bundles depend on, healed on every launch).
Two supporting refactors: the webserver's built-in static dist serving became the single-owner fallback seat (registerFallback/applyIndexTaps), with the SPA server extracted to @deepseek-ai/dsh-host-frontend-static so the web bundle owns its dist as composition, not launcher code; and the personal-overlay machinery of the dsh CLI personal-config decision (loadPersonalPatches, $DSH_HOME/config.yaml) was retargeted to the per-profile and home-level cordis.patch.yml layers (loadOptionalPatches, watchUserPatches taking a filename), superseding that note's entry modes and file location while keeping its Harness-home root, patch semantics, and fail-loud parsing.
Alternatives considered
- Dependency-scan plus partial
patchOrder(the original sketch): scanningdependenciesfor bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordereddsh.profile.bundleslist is smaller and fully deterministic. A rawpnpm addinside the profile installs a library without activating any patch — explicit, no spooky scan. link:entries for in-box bundles: pnpm cannot version, install, or update alink:into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.- A pre-boot
contextmodule in the bundle manifest for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (ctx.cmdlineArgs,ctx.appExit, and the environment snapshot) are provided inboot()'spreparehook, before any config-tree entry mounts. - Transitive bundle auto-application: only direct
dsh.profile.bundlesentries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
Consequences
- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile; the repository no longer needs a row for every deployment shape.
apps/clishrank to argv parsing, profile machinery consumption, and the pnpm forwarder;AppCLIEntryand the per-surface boot paths are gone.- The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly.
- Backends reject nothing old on disk (pre-release stance):
$DSH_HOME/config.yamlis simply no longer read.
中文
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——启动与 --dump-config 共享同一条 applyEntryPatches 路径。随后,应用持有命令行的决策又把调用期取值从启动器派生的 patch 迁移到了启动服务。
默认 Profile 模板使用的组合包是 @deepseek-ai/dsh-base(共享核心配置行)、@deepseek-ai/dsh-web-app(浏览器 Host 配置行与 Web 运行时粘合层)和 @deepseek-ai/dsh-headless(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 dsh --profile <name> 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 --patch。dsh plugin --profile <name> <args...> 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 dsh.profile.bundles;没有组合包声明的包保持为普通依赖。Headless 作为直接 core 入口负责 headless 组合约定。
解析在构造上就是双锚点的:dsh.profile.bundles 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 dsh 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 $DSH_HOME/profiles/node_modules(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
两项配套重构:webserver 内置的静态 dist 服务改为单一所有者的回退席位(registerFallback/applyIndexTaps),SPA 服务器提取到 @deepseek-ai/dsh-host-frontend-static,使 web 组合包以组合的方式持有自己的 dist,而不是靠启动器代码;dsh CLI 个人配置决策的个人 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 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(ctx.cmdlineArgs、ctx.appExit与环境快照)在任何配置树条目挂载之前,于boot()的prepare钩子中提供。 - 组合包的传递式自动应用:只有直接列在
dsh.profile.bundles中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
Consequences
- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装;仓库不再需要为每种部署形态各留一行。
apps/cli收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;AppCLIEntry与各表层专属的启动路径全部移除。- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会响亮失败。
- 后端不拒绝磁盘上的任何旧格式(发布前姿态):
$DSH_HOME/config.yaml只是不再被读取。