The shipped preset root is the plugin's own
The shipped preset root is the plugin's own
`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a s
English
Problem
composeProfile delivered the shipped agent-preset root by pushing a boot-time overlay whose config spread the composed roster row and then hard-set roots to the shipped root alone. Because an id-targeted patch replaces the whole config value, the overlay squashed every root the profile's cordis.patch.yml (or the home layer, or a --patch overlay) had configured: a deployment pointing agent-presets at a shared preset directory booted with only the shipped root plus the roster's writable home root, and every custom preset vanished from the Web picker. dsh --dump-config composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them. The overlay also froze the row's boot-time config above every live reload, so no cordis.patch.yml edit to the row took effect until restart. Externally reported with an accurate root cause in discussion #3636.
Under the whole-config-replacement patch semantics, any "must survive user layers" value needs enforcement after composition — and review rejected keeping that enforcement in the launcher: apps/cli special-casing one plugin's row id, config keys, and precedence is coupling the composition machinery should not carry.
Decision
The shipped presets are the plugin's own. The four built-in compositions moved from apps/cli/config/agent-presets/ into packages/preset/agent-presets/presets/, listed in the package's files, and dsh-agent-presets resolves SHIPPED_PRESET_ROOT relative to its own module — the Loader imports the plugin by package name at runtime, so the directory exists on disk in both the source and installed layouts, the same mechanism that lets the cordis preset carry its skills inside its directory. resolvedRoots becomes shipped root (system trust) unless includeShippedRoot is false, then config.roots in order, then the derived writable home root unless includeUserRoot is false — prepended, so the shipped set always mounts and wins a duplicate id.
This completes the per-session preset roster direction that #2278 started for the writable root: both non-configured roots are now the package's, the launcher composes patch layers with no plugin knowledge, and the squash, the reload freeze, and the dump divergence stop being possible rather than being corrected. The always-load guarantee no longer rides patch ordering: includeShippedRoot defaults true in the schema, so a user layer replacing the row's whole config keeps the shipped set, and only an explicit false — as deliberate as disabling the row — drops it. The compositions bind to the host's agent-plane services, not to the Web surface: no preset row names a client or web plugin, and a host lacking an injected service leaves that row waiting exactly as under any other root.
Testing
shipped-root.spec.ts covers the plugin ownership directly: a bare roster lists the four shipped presets system-trusted and carrying no reason other than unresolved rows (proving the moved files resolve from the package). Health has since grown a module-resolution pass — preset health resolves the rows it can prove will start — and a fixture base is not the install a shipped row's packages sit in, so the assertion names the reason it tolerates rather than requiring none, the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and includeShippedRoot: false mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built lib/ verifies the bundled layout resolves the directory too. Gate scripts (verify-cordis-config, verify-runtime-closure) scan the new location.
Alternatives considered
Keep the launcher patch but derive it per composition, prepending instead of replacing. The first merged-nowhere iteration of this fix: correct on the squash, the reload freeze, and the dump (which gained the derived layer as a labeled dump layer), with the reporter's overlay-prepend shape as its core. Superseded in review because every variant keeps apps/cli special-casing the roster row; the coupling, not the mechanics, was the objection.
Have the bundle declare the shipped root itself (!!js package-relative path). Removes the launcher coupling but hangs the always-load guarantee back on patch ordering: a user layer replacing the row's config drops the bundle's entry — the reported bug's shape again.
Provide the root out of band (a launcher-provided context value the plugin prepends). The launcher still has to know to provide a preset fact; the special case survives in a different channel.
Consequences
config.roots is purely deployment-added directories; the dump shows exactly that, and the shipped root is documented plugin behavior surfaced at runtime through agentPresets.roots. apps/cli ships no config/ directory and its files entry is gone. Any composition that mounts the roster — and any embedder of the package — gets the shipped set by default and turns it off with one config line; embedders wanting bare machinery set includeShippedRoot: false. The presets' bare plugin names still resolve through the boot's flat installation fallback, unchanged by the move.
中文
问题
composeProfile 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 config 展开已组合的 roster 行后,把 roots 硬设为仅含内置根。由于 id 定向补丁整体替换 config 值,这个 overlay 压掉了 profile 的 cordis.patch.yml(以及 home 层、--patch overlay)配置的全部根目录:把 agent-presets 指向共享 preset 目录的部署,启动后只剩内置根加 roster 的可写 home 根,所有自定义 preset 从 Web 选择器中消失。dsh --dump-config 只组合文件承载的层,dump 显示配置的根目录完好而启动却丢弃了它们。该 overlay 还把行的启动时 config 冻结在所有热重载之上,重启前对该行的任何 cordis.patch.yml 编辑都不生效。外部报告 discussion #3636 给出了准确根因。
在"补丁整体替换 config"的语义下,任何"必须在用户层之后存活"的值都需要组合后的强制注入——而评审否决了把这份强制留在启动器里:apps/cli 对某一个插件的行 id、config 键与优先级做特判,是组合机器不应携带的耦合。
决定
内置 preset 归插件自有。四套内置组合从 apps/cli/config/agent-presets/ 搬入 packages/preset/agent-presets/presets/,列入包的 files;dsh-agent-presets 相对自己的模块解析 SHIPPED_PRESET_ROOT——Loader 在运行时按包名导入插件,目录在源码与安装两种布局中都真实存在于磁盘上,与 cordis preset 目录内随行携带 skill 依赖的是同一机制。resolvedRoots 变为:除非 includeShippedRoot 为 false,先是内置根(system 信任),再按序 config.roots,最后除非 includeUserRoot 为 false 追加推导的可写 home 根——前置,因此内置集合始终挂载并赢得重复 id。
这补全了 #2278 为可写根开启的会话级 preset roster 方向:两个非配置根现在都属于本包,启动器不带任何插件知识地组合补丁层,压掉、重载冻结与 dump 分叉从"被修复"变为"不再可能发生"。"一定加载"的保证不再依赖补丁顺序:includeShippedRoot 在 schema 中默认 true,用户层整体替换该行 config 后内置集合依然保留,只有显式 false——与整行 disable 同级的故意行为——才会去掉它。组合绑定的是宿主的 agent-plane 服务而非 Web 表面:没有任何 preset 行引用 client 或 web 插件;宿主缺少被注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。
测试
shipped-root.spec.ts 直接覆盖插件所有权:裸 roster 列出四套内置 preset 为 system 信任、且除未解析行外不携带其他原因(证明搬移后的文件能从包内解析)。健康检查此后新增了一趟模块解析——见预设健康解析它能证明会启动的行——而 fixture 基准并不是内置行所引用的包所在的那个安装,因此该断言点名它容忍的原因,而不是要求一个都没有;内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;includeShippedRoot: false 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built lib/ 运行验证打包布局同样解析得到目录。门禁脚本(verify-cordis-config、verify-runtime-closure)扫描新位置。
曾考虑的替代方案
保留启动器补丁但按组合派生、前置而非替换。 本修复未曾合入的第一版:对压掉、重载冻结与 dump(曾以带标签层渲染派生补丁)判断均正确,核心即报告者的 overlay 前置形状。在评审中被替代,因为每个变体都让 apps/cli 对 roster 行做特判;被否决的是耦合而非机制。
由 bundle 自己声明内置根(!!js 包相对路径)。 去掉启动器耦合,但把"一定加载"的保证重新挂回补丁顺序:用户层整体替换该行 config 时 bundle 的条目被丢弃——又回到所报 bug 的形状。
带外提供根目录(启动器提供的上下文值,由插件前置)。 启动器仍需知道"要为 preset 提供一个事实";特判换了通道继续存在。
后果
config.roots 纯粹是部署追加的目录;dump 展示的正是它,内置根成为文档化的插件行为,运行时经 agentPresets.roots 呈现。apps/cli 不再携带 config/ 目录,其 files 条目移除。任何挂载 roster 的组合——以及任何嵌入本包的使用方——默认获得内置集合,一行配置即可关闭;只要纯机制的嵌入方设 includeShippedRoot: false。preset 里的裸插件名仍经启动的扁平安装后备解析,搬移不改变这一点。