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 as the shared core for web, headless, sdk, and acp, with one mode bundle above it. The standalone sdk-minimal profile instead lists one bundle that owns its complete explicit tree. Generic dsh --profile <name> hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned --patch. A non-shipped target can use --from-default-profile <template> to replace itself with a copy of one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher assembles the copy in a staging directory beside the target and renames it into place, so repeated rescue runs reset the profile; a failed rename restores the previous directory. dsh plugin --profile <name> <args...> is a thin pnpm forwarder that initializes a base-backed 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. Bare plugin names in patch rows use the immutable runtime resolution, which applies installation-first and ordered-bundle rules in memory and expands dependencies from real package directories.
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. Two-anchor resolution plus runtime fallback keeps bundles installation-owned without persisting those links.- 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. - Dynamic template inheritance or cloning a local profile: recording a parent would require merge and upgrade rules for bundle membership, dependencies, and user patches, while copying local state would duplicate machine-specific choices. Template-based creation copies only installation-owned defaults once.
Consequences
- New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape.
- Users can start an independent custom profile from any shipped application template without copying machine-local profile state.
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 and exercises the same profile package-selection rules, so composition drift between test and product fails loudly.
- Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration;
$DSH_HOME/config.yamlis ignored.
中文
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 模板为 web、headless、sdk 与 acp 使用 @deepseek-ai/dsh-base 作为共享核心,并在其上叠加一个模式组合包。独立 sdk-minimal profile则只列出一个拥有完整显式配置树的组合包。通用的 dsh --profile <name> 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 --patch。非内置目标可以使用 --from-default-profile <template>,在启动或配置 dump 之前,用一个默认模板的 bundle 列表与 patch 重载策略的副本替换自身。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 在目标旁的暂存目录中组装副本并重命名到目标位置,因此重复执行 rescue 会重置该 profile;重命名失败时会恢复原有目录。dsh plugin --profile <name> <args...> 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 dsh.profile.bundles;没有组合包声明的包保持为普通依赖。Headless 作为直接 core 入口负责 headless 组合约定。
解析在构造上就是双锚点的:dsh.profile.bundles 中的名称先从 dsh 安装目录解析,再从 profile 目录解析,因此内置组合包始终来自与运行中 dsh 相同的安装,pnpm 从不管理它们。patch 行中的裸插件名称使用不可变 runtime resolution,在内存中应用安装优先与有序 bundle 规则,并从包的真实目录展开依赖。
两项配套重构: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:做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加运行时 fallback 可以让组合包保持由安装持有,无需持久化这些链接。 - 在组合包 manifest 中放一个启动前
context模块承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(ctx.cmdlineArgs、ctx.appExit与环境快照)在任何配置树条目挂载之前,于boot()的prepare钩子中提供。 - 组合包的传递式自动应用:只有直接列在
dsh.profile.bundles中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 - 动态模板继承或克隆本地 profile:记录父级会要求为 bundle 成员关系、依赖和用户 patch 制定合并与升级规则,而复制本地状态会重复机器特定选择。基于模板的创建只会一次性复制安装自有的默认值。
Consequences
- 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装,无需在仓库中为每种部署形态各留一行。
- 用户可以从任意随附应用模板启动一个独立的自定义 profile,而不会复制机器本地的 profile 状态。
apps/cli收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;AppCLIEntry与各表层专属的启动路径全部移除。- 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,并执行相同的 profile 包选择规则,因此测试与产品之间的组合漂移会响亮失败。
- 按发布前姿态,后端不携带旧磁盘配置的兼容行为;
$DSH_HOME/config.yaml会被忽略。