The plugin inventory carries every agent preset's composition
插件清单携带每个 Agent 预设的组合
[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and the settings plugin list kept projecting `ctx.loader.entries()` alone. The surface therefore hid the plugins sessions actually run — a directly-plugged preset subtree never appears in the Loader's entries — and actively misled about the rest: the web overlay's deliberate `disabled: true` tombsto
English
Problem
Per-session agent presets moved every model-facing row onto the agent plane, and the settings plugin list kept projecting ctx.loader.entries() alone. The surface therefore hid the plugins sessions actually run — a directly-plugged preset subtree never appears in the Loader's entries — and actively misled about the rest: the web overlay's deliberate disabled: true tombstones (tool-bash, tool-fs, plan-mode, …) rendered as two dozen plainly "disabled" rows while the same modules ran in every standard-preset session. Beside it, General settings carried a default-preset dropdown that wrote the same agent-presets.default field as the roster section's own make-default action — two editors for one fact, one of them blind to the roster it was choosing from.
Decision
The inventory speaks for both planes. pluginInventory/list gains an optional agentPresets block — one group per roster preset with id, trust, display name, default marking, health, and flattened composition rows — supplied by the new AgentPresets.compositionInventory(): a preset with a live standing mount — matched within this runtime's own root, so a second Cordis runtime in the same process never answers for it — answers from its newest generation's Loader entries even when its file has since broken (the mount is what sessions run; the broken verdict applies only to a preset nothing composed), and one never composed since boot answers from its composition file. dsh-host-plugin-inventory resolves the roster as an optional peer through ctx.get('agentPresets') (the plugin-package-inventory-deepseek pattern) and only maps root-fiber states onto its public phase vocabulary, so deployments without a roster keep serving Loader entries alone with the field absent.
File answers are evaluated, not guessed, and reading never mounts. !!js disabled gates are platform/environment conditions the Loader itself evaluates at every mount decision, so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays 'conditional' with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins livePresetMounts() empty after a full inventory read. Building this surface also exposed the reverse leak: EntryTree's constructor files every new tree under the nearest owning Loader entry's subtree slot, so the first standing mount hung the whole preset composition off the roster's own row and root loader.entries() walked it as host entries. PresetTree now reclaims the slot, restoring the standing mount's documented absence from the Loader, and a regression test holds the root entry list identical across a mount.
The list is grouped by scope, with the misleading rows given their own state. The preset group renders first, collapsible and open by default, behind a display-only switcher — the General-settings selector pill over a menu — that opens on the default preset and writes no settings, because inspecting minimal must not change what new sessions run. Preset names resolve through the shared presetDisplayText fold in dsh-agent-presets/display — the groups carry trust for exactly this split, and an inline-safe pure module is the seam that satisfies both the client purity gate (no cross-plugin runtime imports) and the typert client analyzer (no new Context service face) — so shipped presets follow the active locale's dictionaries while user-authored metadata stays untranslated. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled is marked preset-provided in place, its details naming the enabling presets — a third state instead of the generic "disabled" that started this, and deliberately not a sub-group: the preset group above already shows those plugins as compositions, so a second cluster restating them earned its removal. The status dot appears only for a live root fiber — a file-state row carries its enablement tag alone, so an unmounted preset does not read as a column of grey mystery dots. The provider rule is strict enabled === true: counting conditional declarations would claim per-session provision tool-pwsh never delivers on POSIX. Search spans both groups, forces them open, and points at matches sitting in unselected presets.
The General row is deleted, not relocated. The default keeps two surfaces that can still act on it — the roster section's make-default beside the visible roster, and the new-session chip for the session about to start — so ui-agent-preset drops the row, its menu, and the write/writability half of its settings store, which slims to the display roster the header label reads.
Alternatives considered
Render every preset as its own always-open section. Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the per-row provider details and the search pointers preserve the cross-scope answer the all-at-once layout was buying.
Keep file-state gates unevaluated (conditional until first mount). Honest but it re-created the misleading reading this change removes: on a cold host the default preset's tool-bash read as "conditional" and its host row fell back to plain "disabled" until the first session mounted the preset.
A structured composition viewer in the Agent presets section. A second home for the same rows; the section keeps its raw-YAML viewer for authors and the plugin list owns the structured view.
Enable/disable toggles in the same change. Writing a row's disabled back into a custom preset's agent.cordis.yml needs comment-preserving partial YAML edits, applies-to-new-sessions messaging, and a copy-then-edit path for shipped presets — deliberately its own change; this one is read-side truth.
Consequences
Searching "bash" now answers the question that motivated the change in one screen: enabled in the standard preset, provided per session where the global plane disabled it, plainly disabled only where nothing enables it. The wire snapshot's row enablement is the union boolean | 'conditional' with the gate expression beside it, and the settings-chrome goldens pin the grouped layout. ui-agent-preset loses AgentPresetRow and PresetMenu; the settings.agentPreset locale namespace declaration moved to the plugin entry, and the settings-chrome English scenario probes locale resolution through the nav label instead of the deleted row.
中文
问题
按会话的 agent preset 把所有模型侧行移到了 agent 平面,而设置页的插件列表仍只投影 ctx.loader.entries()。这个表面因此看不见会话实际运行的插件——直接 plug 的预设子树从不出现在 Loader 条目里——还对其余部分构成误导:web overlay 刻意的 disabled: true 墓碑(tool-bash、tool-fs、plan-mode……)渲染成二十多行看似单纯"已停用"的条目,而同名模块在每个标准模式会话里运行。旁边,通用设置还有一个默认预设下拉,与名单分区自己的设为默认动作写同一个 agent-presets.default 字段——同一事实两个编辑器,其中一个还看不见它在选择的名单。
决定
清单同时陈述两个平面。pluginInventory/list 增加可选的 agentPresets 块——每个名单预设一组,含 id、trust、显示名、默认标记、健康状态与压平的组合行——由新增的 AgentPresets.compositionInventory() 提供:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答——匹配限定在本运行时自己的 root 内,同进程的第二个 Cordis 运行时不会替它作答;即使文件事后损坏也照常作答(挂载才是会话实际运行的组合,broken 裁决只适用于无人组合的预设)——开机以来从未被组合的预设由其组合文件作答。dsh-host-plugin-inventory 经 ctx.get('agentPresets') 把名单当作可选伙伴解析(即 plugin-package-inventory-deepseek 的模式),自己只把根 Fiber 状态映射到公共阶段词汇,因此没有名单的部署继续只提供 Loader 条目、字段缺席。
文件答案靠求值而非猜测,且读取从不挂载。!!js disabled 门是平台/环境条件,Loader 自己在每次挂载决策时都会求值,因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 'conditional' 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 livePresetMounts() 为空。搭这个表面还暴露了反向泄漏:EntryTree 的构造器把每棵新树挂到最近拥有者 Loader 条目的 subtree 槽上,于是第一个 standing mount 把整棵预设组合挂在了 roster 自己的行下,根 loader.entries() 把它当宿主条目走了一遍。PresetTree 现在归还该槽位,恢复 standing mount「不在 Loader 里」的书面契约;回归测试钉住挂载前后根条目列表逐项相同。
**列表按作用域分组,误导行获得自己的状态。**预设组在前、可折叠且默认展开,其切换器是通用设置同款的「选择胶囊 + 菜单」控件,只改显示、初始停在默认预设且不写任何设置——查看 minimal 绝不能改变新会话运行什么。预设名经 dsh-agent-presets/display 的共享 presetDisplayText 纯函数解析——组正是为此携带 trust,而 inline-safe 纯模块是同时满足客户端打包纯度门(禁止跨插件运行时导入)与 typert client 分析器(不新增 Context 服务面)的接缝——内置预设跟随当前语言字典,用户自建元数据保持不翻译。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,就地标记为预设提供并在详情里列出启用它的预设——用第三种状态取代引发这一切的笼统"已停用",并且刻意不做成子分组:上方的预设组已经把这些插件按组合展示,一个复述它们的第二个聚簇理应被移除。状态圆点只为存活的根 fiber 渲染——文件态的行只带启停标签,未挂载的预设不会读作一列灰色的谜之圆点。提供者规则严格取 enabled === true:把条件声明也算作提供者,会替 tool-pwsh 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开分组,并指出未选中预设里的匹配。
**通用设置行是删除,不是搬家。**默认值保留两个仍能作用于它的表面——名单分区的设为默认(名单可见)与新会话 chip(针对即将开始的会话)——因此 ui-agent-preset 删掉该行、它的菜单以及 settings store 的写入/可写性半边,后者收敛为标题标签读取的展示名单 store。
考虑过的替代方案
**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,行级的提供者详情与搜索指引保留了全展开布局想买到的跨作用域答案。
**文件态门保持不求值(首次挂载前一律 conditional)。**诚实,但重演了本次要消除的误导:冷启动的宿主上,默认预设的 tool-bash 读作"条件启用",其全局行在第一个会话挂载预设之前退回单纯的"已停用"。
**在 Agent 预设分区做结构化组合查看器。**同一批行的第二个家;分区保留面向作者的原始 YAML 查看器,插件列表拥有结构化视图。
**启停开关随本次一起做。**把行的 disabled 写回自定义预设的 agent.cordis.yml 需要保注释的局部 YAML 编辑、"对新会话生效"的提示,以及内置预设的复制后编辑路径——刻意留作独立改动;本次只做读侧真相。
后果
搜索 "bash" 现在一屏回答引发本次改动的问题:在标准模式里启用、在全局平面被停用处按会话提供、只有真的无人启用之处才是单纯的已停用。线上快照的行启停是联合类型 boolean | 'conditional' 并携带门表达式,settings-chrome 的 golden 钉住分组布局。ui-agent-preset 失去 AgentPresetRow 与 PresetMenu;settings.agentPreset 文案命名空间声明移到插件入口,settings-chrome 的英文场景改用导航标签而非已删除的行来探测 locale 解析。