@deepseek-ai/dsh-agent-presets 0.1.5-rc.2 → 0.1.6-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/preset/agent-presets/README.md
5
- README.md: 6ef3374a5f1ae6b83005073f80d63d156ee92f9c
6
- README.zh.md: a1be6d634e592126711befad4ffcb06ca0bdc26c
5
+ README.md: d0eb1c108eaef091c776992c144ba389be2cf6c0
6
+ README.zh.md: e9c1a35b22a1764e444c5757a1e34761bdc24fdf
package/README.md CHANGED
@@ -33,7 +33,7 @@ The shipped Web `standard`, `ptc`, and `cordis` presets include [explicit file d
33
33
 
34
34
  A session composed from a preset runs the plugins that preset's `agent.cordis.yml` names: its tools, prompt sections, and skills. Sessions joined to the same preset share one installed composition, and each session's state stays separate. A child agent (subagent) joins its parent's composition, so it sees the same tools and prompt sections as the agent that spawned it.
35
35
 
36
- The presets you can choose from come from two places: the presets shipped inside this package under `presets/`, and your own presets under `<dshHome>/.agent-presets`. The picker shows each preset's display name and description; a preset whose composition cannot load is listed with the reason rather than hidden, so you can see what to fix or delete.
36
+ The presets you can choose from come from three sources: the presets shipped inside this package under `presets/`, configured roots, and your own presets under `<dshHome>/.agent-presets`. The picker shows each preset's display name and description; a preset whose composition cannot load is listed with the reason rather than hidden, so you can see what to fix or delete.
37
37
 
38
38
  ### Minimal configuration
39
39
 
@@ -50,7 +50,7 @@ The plugin needs a `default` preset id and scans `roots` for presets:
50
50
 
51
51
  | Field | Default | Meaning |
52
52
  |---|---|---|
53
- | `default` | required | Preset id composed when a session names none |
53
+ | `default` | required | Deployment fallback preset id, used while mode selection is disabled or no user default overrides it |
54
54
  | `roots` | `[]` | Scanned directories in precedence order; each supplies `path` (a leading `~` expands) and `trust` (defaults to `user`) |
55
55
  | `includeShippedRoot` | `true` | Prepend the package's bundled presets as a `system` root before every configured root |
56
56
  | `includeUserRoot` | `true` | Append `<dshHome>/.agent-presets` as a `user` root, after every configured root |
@@ -59,16 +59,17 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
59
59
 
60
60
  The shipped root is prepended before every configured root, so the built-in set remains available and wins duplicate ids even when a patch replaces the roster configuration. `includeShippedRoot: false` drops that built-in set for deployments that supply all presets themselves. `includeUserRoot: false` drops the derived writable root; tests that pin an exact roster disable both derived roots.
61
61
 
62
- ### Choosing the default preset
62
+ ### Showing the picker and choosing its default
63
63
 
64
- The `default` config sets the deployment default. When a settings provider is composed, this plugin registers the `agent-presets` namespace with `config.default` as its base, so a user document layers a per-user default over the deployment's:
64
+ The required `default` config sets the deployment default. When a settings provider is composed, this plugin registers the `agent-presets` namespace with `{ default: config.default, modeSelectionEnabled: true }` as its base, so the existing new-session picker remains visible unless a user turns it off. The Host reads both fields on every default resolution: while `modeSelectionEnabled` is false, an omitted preset resolves to `config.default` even if the user document retains another `default`; while it is true, a user default may override the deployment value:
65
65
 
66
66
  ```yaml
67
67
  agent-presets:
68
+ modeSelectionEnabled: true
68
69
  default: minimal
69
70
  ```
70
71
 
71
- The value is read when a session is created, so a changed default affects only sessions created afterwards; running sessions stay on the preset they were composed from. Clearing the user field re-inherits the composition default.
72
+ A client shows or hides selection by writing only `modeSelectionEnabled`; the [Web GUI settings switch](../../client/ui-agent-preset/README.md) does exactly that. The deployment default governs while selection is hidden; re-enabling it restores the saved user `default`, or keeps the deployment default when none has been saved. While mode selection stays enabled, choosing a default writes a user override for sessions created later. Because the Host owns the policy, it applies to every subsequently created session whose caller omits a preset, including Web, CLI, SDK, and headless callers; an explicitly named preset and every existing session remain unchanged.
72
73
 
73
74
  ### Authoring presets
74
75
 
@@ -82,7 +83,7 @@ A session can switch to a different preset only while it has produced nothing
82
83
 
83
84
  ### Failures and recovery
84
85
 
85
- A preset whose composition is missing, unparsable, not a list of named plugin rows, or naming a module that cannot be resolved is listed as broken with a reason naming the rows at fault; composing such a preset is refused up front, so a session never starts half-composed. What survives to session creation is a row whose module loads and then refuses — a plugin that throws, or one waiting for a service the composition never supplies — which fails the creation and rolls it back, naming every failed row including those inside a group. Fix the preset's file or delete it, then retry.
86
+ A preset whose composition is missing, unparsable, not a list of named plugin rows, or naming a module that cannot be resolved is listed as broken with a reason naming the rows at fault; a package-lookup failure marks only the preset being checked as broken, while the rest of the roster remains available. Composing a broken preset is refused up front, so a session never starts half-composed. What survives to session creation is a row whose module loads and then refuses — a plugin that throws, or one waiting for a service the composition never supplies — which fails the creation and rolls it back, naming every failed row including those inside a group. Fix the preset's file or delete it, then retry.
86
87
 
87
88
  -----
88
89
 
@@ -126,7 +127,7 @@ This section explains the design behind the roster and the standing mount; obser
126
127
 
127
128
  ### The mount audit
128
129
 
129
- A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit.
130
+ A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects an import or activation failure, an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit.
130
131
 
131
132
  ### Authoring mechanics
132
133
 
package/README.zh.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "按 preset cordis.yml 文件进行按会话的 agent 组装,供选择、配置或排查 agent preset 的用户与维护者阅读。"
2
+ description: "按 preset cordis.yml 文件进行按会话的 agent(智能体)组装,供选择、配置或排查 agent preset 的用户与维护者阅读。"
3
3
  kind: "package-reference"
4
4
  ---
5
5
 
@@ -31,9 +31,9 @@ kind: "package-reference"
31
31
 
32
32
  ### preset 给会话带来什么
33
33
 
34
- 从 preset 组装的会话会运行该 preset `agent.cordis.yml` 所列插件:它的工具、提示词段落与 skill。加入同一 preset 的会话共享一份已安装的组装,且各会话的状态彼此隔离。子 agent(subagent)会加入其父方的组装,因此它看到的工具与提示词段落和创建它的 agent 相同。
34
+ 从 preset 组装的会话会运行该 preset `agent.cordis.yml` 所列插件:它的工具、提示词段落与 skill。加入同一 preset 的会话共享一份已安装的组装,且各会话的状态彼此隔离。subagent 会加入其父方的组装,因此它看到的工具与提示词段落和创建它的 agent 相同。
35
35
 
36
- 可选的 preset 来自两处:本包 `presets/` 下随包交付的 preset,以及你自己放在 `<dshHome>/.agent-presets` 下的 preset。选择器会展示每个 preset 的显示名与描述;组装无法加载的 preset 会连同原因一起列出而不是被隐藏,因此你能看到该修什么或删什么。
36
+ 可选的 preset 来自三类来源:本包 `presets/` 下随包交付的 preset、已配置的根目录,以及你自己放在 `<dshHome>/.agent-presets` 下的 preset。选择器会展示每个 preset 的显示名与描述;组装无法加载的 preset 会连同原因一起列出而不是被隐藏,因此你能看到该修什么或删什么。
37
37
 
38
38
  ### 最小配置
39
39
 
@@ -50,7 +50,7 @@ kind: "package-reference"
50
50
 
51
51
  | 字段 | 默认值 | 含义 |
52
52
  |---|---|---|
53
- | `default` | 必填 | 会话未指定时组装的 preset id |
53
+ | `default` | 必填 | 部署 fallback preset id;模式选择关闭或没有用户默认值覆盖时使用 |
54
54
  | `roots` | `[]` | 按优先级排列的扫描目录;每项提供 `path`(开头的 `~` 会展开)与 `trust`(默认为 `user`) |
55
55
  | `includeShippedRoot` | `true` | 在全部已配置根目录之前,前置本包随附的 preset 作为 `system` 根目录 |
56
56
  | `includeUserRoot` | `true` | 在全部已配置根目录之后追加 `<dshHome>/.agent-presets` 作为 `user` 根目录 |
@@ -59,16 +59,17 @@ kind: "package-reference"
59
59
 
60
60
  随附根目录前置在全部已配置根目录之前,因此即使补丁替换 roster 配置,内置集合仍然可用并赢得重复 id。`includeShippedRoot: false` 会为完全自行提供 preset 的部署移除内置集合。`includeUserRoot: false` 会移除推导出的可写根目录;钉住确切 roster 的测试会同时关闭两个推导根目录。
61
61
 
62
- ### 选择默认 preset
62
+ ### 显示选择器并选择默认 preset
63
63
 
64
- `default` 配置设定部署级默认值。当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `config.default` 作为其 base,因此用户文档会在部署默认值之上层叠一份按用户设置的默认值:
64
+ 必填的 `default` 配置设定部署默认值。当组装中存在 settings 提供方时,本插件会注册 `agent-presets` 命名空间,并以 `{ default: config.default, modeSelectionEnabled: true }` 作为 base,因此既有的新建会话选择器会保持显示,除非用户主动关闭。Host 每次解析默认值都会读取这两个字段:`modeSelectionEnabled` 为 `false` 时,未显式指定 preset 的会话解析为 `config.default`,即使用户文档还保留其他 `default` 也会忽略它;该字段为 `true` 时,用户默认值才可覆盖部署值:
65
65
 
66
66
  ```yaml
67
67
  agent-presets:
68
+ modeSelectionEnabled: true
68
69
  default: minimal
69
70
  ```
70
71
 
71
- 该值在会话创建时读取,因此更改默认值只影响此后创建的会话;运行中的会话仍停留在它们当初据以组装的 preset 上。清空用户字段即重新继承组装默认值。
72
+ 客户端只需写入 `modeSelectionEnabled` 即可显示或隐藏选择,[Web GUI 设置开关](../../client/ui-agent-preset/README.zh.md)正是这样做的。选择器隐藏期间由部署默认值生效;再次开启时恢复已保存的用户 `default`,尚未保存时则继续使用部署默认值。模式选择保持开启时,选择默认模式会写入用户覆盖值,仅供此后创建的会话使用。由于该策略归 Host 所有,它适用于 Web、CLI、SDK 与 headless 调用方此后创建的全部未显式指定 preset 的会话;显式指定的 preset 与任何既有会话均不受影响。
72
73
 
73
74
  ### 创作 preset
74
75
 
@@ -82,7 +83,7 @@ agent-presets:
82
83
 
83
84
  ### 失败与恢复
84
85
 
85
- 组装缺失、无法解析、不是具名插件行列表,或者引用了无法解析的模块的 preset 会被列为 broken,原因会指名出问题的行;组装此类 preset 会被提前拒绝,因此会话绝不会以半组装状态启动。能活到会话创建的,是模块能加载但随后拒绝的行——抛错的插件,或等待组装从未提供的服务的插件——它会让创建失败并回滚,且会指名每一个失败的行,包括组内的行。修复 preset 的文件或删除它,然后重试。
86
+ 组装缺失、无法解析、不是具名插件行列表,或者引用了无法解析的模块的 preset 会被列为 broken,原因会指名出问题的行;包查询失败只会把正在检查的 preset 标为 broken,名单中的其他 preset 仍然可用。组装 broken preset 会被提前拒绝,因此会话绝不会以半组装状态启动。能活到会话创建的,是模块能加载但随后拒绝的行——抛错的插件,或等待组装从未提供的服务的插件——它会让创建失败并回滚,且会指名每一个失败的行,包括组内的行。修复 preset 的文件或删除它,然后重试。
86
87
 
87
88
  -----
88
89
 
@@ -113,7 +114,7 @@ agent-presets:
113
114
  | [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 |
114
115
  | [`src/metadata.ts`](src/metadata.ts) | `preset.yml` 展示元数据 |
115
116
  | [`src/session.ts`](src/session.ts) | `agent-preset/selected` 事件与 `agentPreset` Session 投影 |
116
- | [`src/types.ts`](src/types.ts) | client-safe 的线上载荷与 cordis 事件声明 |
117
+ | [`src/types.ts`](src/types.ts) | client-safe 的协议载荷与 cordis 事件声明 |
117
118
  | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:挂载后的服务泄漏复查、未加入 agent 的失败 |
118
119
 
119
120
  ### 常驻挂载
@@ -122,11 +123,11 @@ agent-presets:
122
123
 
123
124
  ### 组合清单
124
125
 
125
- `compositionInventory()` 向插件清单表面提供每个预设的压平行及其名单身份(id、trust、显示名、默认标记):已有存活 standing mount 的预设由其最新世代的 Loader 条目作答——匹配限定在本运行时自己的 root 内,同进程里的第二个 Cordis 运行时不会替它作答;即使文件事后损坏也照常作答,因为挂载才是会话实际运行的组合,broken 裁决只适用于无人组合的预设——开机以来从未被组合的预设由其组合文件作答,`!!js` disabled 门用 Loader 上下文求值,使两种答案反映同一台宿主。读取从不挂载预设——列出所有组合的设置页不会激活其中任何一个。求值器拒绝的门保持 `'conditional'`;在发现的健康裁决与行读取之间变得不可读的文件,会携带竞态原因报告为 broken,而不是被静默丢弃。`./display` 子路径导出 `presetDisplayText` 纯函数,把内置预设 id 映射到各自的字典文案键;它没有任何 import,浏览器包直接内联,也是「哪个内置 id 对应哪份文案」的唯一归属地。
126
+ `compositionInventory()` 向插件清单表面提供每个预设的压平行及其名单身份(id、trust、显示名、默认标记):已有存活 standing mount 的预设由其最新世代的 Loader 条目作答——匹配限定在本运行时自己的 root 内,同进程里的第二个 Cordis 运行时不会替它作答;即使文件事后损坏也照常作答,因为挂载才是会话实际运行的组合,broken 裁决只适用于无人组合的预设——开机以来从未被组合的预设由其组合文件作答,`!!js` disabled 门用 Loader 上下文求值,使两种答案反映同一台宿主。读取从不挂载预设——列出所有组合的设置页不会激活其中任何一个。求值器拒绝的门保持 `'conditional'`;在发现的健康裁决与行读取之间变得不可读的文件,会携带竞态原因报告为 broken,而不是被静默丢弃。`./display` 子路径导出 `presetDisplayText` 映射,把随附 preset id 映射到各自的字典文案键;它没有任何 import,浏览器包直接内联,也是「哪个内置 id 对应哪份文案」的唯一归属地。
126
127
 
127
128
  ### 挂载审计
128
129
 
129
- 直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。
130
+ 直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝导入或激活失败、无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。
130
131
 
131
132
  ### 创作机制
132
133
 
@@ -175,7 +176,7 @@ agent-presets:
175
176
  - **代际只以组装文件为键**——stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
176
177
  - **被替代的代际永不回收**——已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-filesystem` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。
177
178
  - **副本从不被实际挂载以校验**——它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
178
- - **健康问的是「装没装」,不是「能不能 import」**——发现过程证明组装能以加载器方言解析、由具名行组成,且每一行它能证明会启动的行所引用的包装在 harness 基准之上、或所引用的文件确实存在;它从不 import 任何一个,因此入口文件缺失的包、在 apply 时抛错的插件、以及永远等待某个服务的插件,都仍在第一个会话处失败。`disabled` 是加载器唯一会插值的条目字段,因此在该字段写了表达式的行会被跳过,而不是仅凭文件下判断。
179
+ - **健康问的是「装没装」,不是「能不能 import」**——发现过程证明组装能以加载器方言解析、由具名行组成,且对于每个能证明会启动的行,其引用的包存在于 harness 基址以上,或引用的文件确实存在;它从不 import 任何一个,因此入口文件缺失的包、在 apply 时抛错的插件、以及永远等待某个服务的插件,都仍在第一个会话处失败。`disabled` 是加载器唯一会插值的条目字段,因此该字段带表达式的行不予检查,而不是仅凭文件作出判断。
179
180
  - **副本是会漂移的快照**——升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义;随附集合自己也接受同样的代价——`cordis` 与 `code` 都复制了 `standard` 的完整组装并在此基础上编辑——换来整份组装在一个文件里可读。
180
181
  - **根目录扫描不做监听**——每次读取都实际访问文件系统,这让名单保持新鲜,但每次 `list()` 会对每个根目录产生一次 `readdir`。
181
182
 
package/lib/index.js CHANGED
@@ -230,20 +230,13 @@ function entryListProblem(rows, at = "") {
230
230
  }
231
231
  }
232
232
  /**
233
- * Whether a package name is installed anywhere above `base`.
233
+ * Whether a package specifier is installed above `base` without importing it.
234
234
  *
235
- * Node's own upward `node_modules` walk, stopping at the package directory:
236
- * the question is whether the package is there at all, which is what a row
237
- * naming a package a rename or an uninstall took away gets wrong. A pnpm
238
- * store link answers through the symlink, and a link left dangling by a
239
- * deleted checkout answers false — the shape a stale profile install leaves.
240
- *
241
- * `existsSync` rather than the async `stat`: the walk is a handful of lookups
242
- * per package and runs on every roster read, where 150 promise round-trips
243
- * cost more than the lookups they wrap.
244
- * @param name - the package specifier, possibly carrying a subpath.
245
- * @param base - the URL to walk up from.
246
- * @returns true when the package directory is installed above `base`.
235
+ * The direct disk walk accepts unexported subpaths and rejects stale links
236
+ * whose package directory no longer exists.
237
+ * @param name - package specifier, possibly carrying a subpath.
238
+ * @param base - directory URL whose ancestors contain candidate `node_modules` directories.
239
+ * @returns true when the package is installed.
247
240
  */
248
241
  function packageInstalled(name, base) {
249
242
  const pkg = name.split("/").slice(0, name.startsWith("@") ? 2 : 1).join("/");
@@ -258,35 +251,17 @@ function packageInstalled(name, base) {
258
251
  /**
259
252
  * Whether one classified row names a module that exists, importing nothing.
260
253
  *
261
- * Each kind is checked by what actually answers it. A package name is looked
262
- * up on disk — the same upward walk Node's own resolver starts with — and a
263
- * relative or `file:` specifier is statted, because both name one file.
264
- * Nothing is evaluated either way, so a row is judged without its plugin
265
- * observing that discovery looked.
266
- *
267
- * `import.meta.resolve` is deliberately not the fallback for a name the disk
268
- * lookup misses. Its `parentURL` argument only takes effect under
269
- * `--experimental-import-meta-resolve`, which no launch passes, so it would
270
- * resolve from THIS module rather than from the harness — reporting a
271
- * dependency visible only to this package as healthy, and a plugin the mount
272
- * can import as broken. The resolver that does honour an explicit parent is
273
- * the Loader's internal one, whose `resolveSync` signature differs between
274
- * Node 22 and 24 (`ModuleLoader.fromInternal` tags the raw object rather than
275
- * normalising it); reaching into that for a case the walk already covers buys
276
- * nothing a supported deployment needs, because every plugin a preset names
277
- * is installed beside the roster.
278
- *
279
- * What that gives up: a package resolvable ONLY through a loader hook — an
280
- * import map, or a tree with no `node_modules` at all — is reported broken.
281
- * No supported install produces one.
254
+ * Package rows delegate to the injected lookup. Relative and `file:` rows use
255
+ * file metadata. No check evaluates the named module.
282
256
  * @param row - the classified specifier, from {@link classifyRowSpecifier}.
283
257
  * @param presetBase - directory URL a preset-relative specifier resolves against.
284
258
  * @param harnessBase - base URL a package name resolves against.
259
+ * @param resolves - package lookup selected by the owning caller.
285
260
  * @returns true when the row names something that can be imported.
286
261
  */
287
- async function rowResolves(row, presetBase, harnessBase) {
262
+ async function rowResolves(row, presetBase, harnessBase, resolves) {
288
263
  if (row.kind === "builtin") return true;
289
- if (row.kind === "package") return isBuiltin(row.specifier) || packageInstalled(row.specifier, harnessBase);
264
+ if (row.kind === "package") return isBuiltin(row.specifier) || resolves(row.specifier, harnessBase);
290
265
  return await isFile(fileURLToPath(row.kind === "file" ? new URL(row.specifier) : new URL(row.specifier, presetBase)));
291
266
  }
292
267
  /**
@@ -310,17 +285,17 @@ async function rowResolves(row, presetBase, harnessBase) {
310
285
  * @param at - row-path prefix for nested diagnostics, empty at the top level.
311
286
  * @returns one entry per unresolvable row, in composition order.
312
287
  */
313
- async function unresolvableRows(rows, presetBase, harnessBase, at = "") {
288
+ async function unresolvableRows(rows, presetBase, harnessBase, resolves, at = "") {
314
289
  const found = [];
315
290
  for (const [index, entry] of rows.entries()) {
316
291
  const row = entry;
317
292
  if (Boolean(row.disabled)) continue;
318
293
  const positional = at === "" ? `row ${String(index + 1)}` : `${at} row ${String(index + 1)}`;
319
294
  if (row.group === true) {
320
- found.push(...await unresolvableRows(row.config, presetBase, harnessBase, positional));
295
+ found.push(...await unresolvableRows(row.config, presetBase, harnessBase, resolves, positional));
321
296
  continue;
322
297
  }
323
- if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase)) continue;
298
+ if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves)) continue;
324
299
  const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
325
300
  found.push({
326
301
  label,
@@ -334,11 +309,13 @@ async function unresolvableRows(rows, presetBase, harnessBase, at = "") {
334
309
  * loadable. Parsed with the loader's own YAML dialect ({@link entryListSchema},
335
310
  * the one carrying `!!js`), so health can never call a composition broken
336
311
  * that the loader would accept.
312
+ * A package-lookup failure becomes this composition's broken reason, so one
313
+ * preset cannot abort discovery of the rest of the roster.
337
314
  * @param path - absolute path of the composition file.
338
315
  * @param harnessBase - base URL a row's package name resolves against.
339
316
  * @returns one human-readable reason, or undefined when the file is loadable.
340
317
  */
341
- async function compositionProblem(path, harnessBase) {
318
+ async function compositionProblem(path, harnessBase, resolves) {
342
319
  let content;
343
320
  try {
344
321
  content = await readFile(path, "utf8");
@@ -354,7 +331,12 @@ async function compositionProblem(path, harnessBase) {
354
331
  const shape = entryListProblem(rows);
355
332
  if (shape !== void 0) return shape;
356
333
  const presetBase = new URL(".", pathToFileURL(path)).href;
357
- const unresolvable = await unresolvableRows(rows, presetBase, harnessBase);
334
+ let unresolvable;
335
+ try {
336
+ unresolvable = await unresolvableRows(rows, presetBase, harnessBase, resolves);
337
+ } catch (error) {
338
+ return `the composition's plugins cannot be checked: ${(error instanceof Error ? error.message : String(error)).replace(/\n[\s\S]*$/, "")}`;
339
+ }
358
340
  const [first] = unresolvable;
359
341
  if (first === void 0) return void 0;
360
342
  if (unresolvable.length === 1) return `${first.label} names a plugin that cannot be resolved: ${first.name}`;
@@ -387,9 +369,10 @@ async function isFile(path) {
387
369
  * @param root - the directory and the trust its presets inherit.
388
370
  * @param harnessBase - base URL a row's package name resolves against; the
389
371
  * caller's own `ctx.baseUrl`, which is where the installed harness lives.
372
+ * @param resolves - package-presence lookup for the active runtime.
390
373
  * @returns the root's presets ordered by id.
391
374
  */
392
- async function scanRoot(root, harnessBase) {
375
+ async function scanRoot(root, harnessBase, resolves = packageInstalled) {
393
376
  const dir = resolve(expandHomePath(root.path));
394
377
  let children;
395
378
  try {
@@ -403,7 +386,7 @@ async function scanRoot(root, harnessBase) {
403
386
  if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue;
404
387
  const directory = join(dir, child.name);
405
388
  const path = join(directory, COMPOSITION_FILE);
406
- const broken = await isFile(path) ? await compositionProblem(path, harnessBase) : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`;
389
+ const broken = await isFile(path) ? await compositionProblem(path, harnessBase, resolves) : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`;
407
390
  const metadata = await readPresetMetadata(directory);
408
391
  found.push({
409
392
  id: child.name,
@@ -422,11 +405,12 @@ async function scanRoot(root, harnessBase) {
422
405
  * Scan every root in precedence order.
423
406
  * @param roots - roots in precedence order; an earlier root wins a duplicate id.
424
407
  * @param harnessBase - base URL a row's package name resolves against.
408
+ * @param resolves - package-presence lookup for the active runtime.
425
409
  * @returns every discovered preset, first-root-wins per id.
426
410
  */
427
- async function discoverPresets(roots, harnessBase) {
411
+ async function discoverPresets(roots, harnessBase, resolves = packageInstalled) {
428
412
  const byId = /* @__PURE__ */ new Map();
429
- for (const root of roots) for (const preset of await scanRoot(root, harnessBase)) {
413
+ for (const root of roots) for (const preset of await scanRoot(root, harnessBase, resolves)) {
430
414
  if (byId.has(preset.id)) continue;
431
415
  byId.set(preset.id, preset);
432
416
  }
@@ -830,24 +814,28 @@ function serviceForAgent(ctx, agent, name) {
830
814
  /**
831
815
  * Rows that did not reach a usable state, each rendered as one diagnostic line.
832
816
  *
833
- * A row whose module failed to import or whose plugin threw already rejects the
834
- * mount through the loader; what remains observable here is a row still waiting
835
- * for a service the composition never supplies.
817
+ * Wait for the subtree, then report import failures, activation failures, and
818
+ * rows waiting for services the composition does not supply.
836
819
  * @param tree - the mounted subtree.
837
820
  * @returns one line per unusable row, empty when every enabled row is usable.
838
821
  */
839
- function inactiveRows(tree) {
822
+ async function inactiveRows(tree) {
823
+ await tree.await();
840
824
  const lines = [];
841
825
  for (const entry of tree.entries()) {
842
826
  if (entry.disabled) continue;
843
827
  const fiber = entry.fiber;
844
- /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed,
845
- so a settled tree never holds an enabled fiber-less entry; the branch exists
846
- only because `Entry.fiber` is declared optional. */
847
828
  if (fiber === void 0) {
848
829
  lines.push(`${entry.options.id} (${entry.options.name}): never started`);
849
830
  continue;
850
831
  }
832
+ try {
833
+ await fiber.await();
834
+ } catch (error) {
835
+ const detail = mountDetail(error);
836
+ lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`);
837
+ continue;
838
+ }
851
839
  const missing = Object.keys(fiber.inject).filter((name) => fiber.ctx.get(name) === void 0);
852
840
  if (missing.length > 0) lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(", ")}`);
853
841
  }
@@ -856,14 +844,8 @@ function inactiveRows(tree) {
856
844
  /**
857
845
  * The causes of `error` whose detail its own message does not already carry.
858
846
  *
859
- * `AggregateError` names none of its causes in its own message, so its
860
- * `errors` are the branches. The Loader's per-row wrapper takes the opposite
861
- * approach: it appends `cause.message` to the message it builds and keeps the
862
- * cause only as `error.cause`, so following a plain chain would print every
863
- * line twice. That leaves exactly one lossy shape — a wrapped row whose cause
864
- * is an `AggregateError`. Its message ends with the aggregate's own line and
865
- * drops the `errors` behind it, which is how a failed group reports as
866
- * "loader entries failed to apply" and names none of the rows that failed.
847
+ * Aggregate errors carry separate member messages. A wrapper can preserve the
848
+ * aggregate as its cause without including those messages in its own text.
867
849
  * @param error - the failure to read branches from.
868
850
  * @returns the branches to render beneath `error.message`, possibly empty.
869
851
  */
@@ -874,19 +856,12 @@ function detailBranches(error) {
874
856
  /**
875
857
  * The reportable text of a mount failure.
876
858
  *
877
- * The loader reports several failed rows as one `AggregateError`, whose own
878
- * message names none of them; without flattening, a composition that fails on
879
- * two rows says only "loader entries failed to apply" and the operator has
880
- * nothing to act on. Nested groups indent under the row that owns them, so a
881
- * composition failing inside a group still names the rows rather than the
882
- * group alone.
859
+ * A plugin may reject with an aggregate or wrap one as its cause. Include its
860
+ * member messages beneath the row diagnostic so each failure is visible.
883
861
  * @param error - the value the mount rejected with.
884
862
  * @returns a single-line-per-cause description.
885
863
  */
886
864
  function mountDetail(error) {
887
- /* v8 ignore next -- every path into the mount's catch throws an Error: the loader
888
- wraps a row's thrown value before it propagates, and this module's own
889
- rejections are Errors. The fallback keeps a hostile value readable. */
890
865
  if (!(error instanceof Error)) return String(error);
891
866
  const branches = detailBranches(error);
892
867
  if (branches.length === 0) return error.message;
@@ -915,7 +890,7 @@ async function mountPreset(agentCtx, preset) {
915
890
  /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */
916
891
  if (subtree === void 0) throw new Error("mounted subtree did not publish its entry tree");
917
892
  const { tree, fiber } = subtree;
918
- const unusable = inactiveRows(tree);
893
+ const unusable = await inactiveRows(tree);
919
894
  if (unusable.length > 0) throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join("\n")}`);
920
895
  const leaked = leakedServices(agentCtx, fiber);
921
896
  if (leaked.length > 0) throw new Error(`row(s) published process-global service(s) [${leaked.join(", ")}]; a preset service must sit behind an \`isolate\` realm or move to the host composition`);
@@ -1141,14 +1116,17 @@ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializ
1141
1116
  if (target) Object.defineProperty(target, contextIn.name, descriptor);
1142
1117
  done = true;
1143
1118
  };
1144
- /** Settings namespace carrying the user's chosen default preset. */
1119
+ /** Settings namespace carrying the user's preset-picker preference and chosen default. */
1145
1120
  const SETTINGS_NAMESPACE = "agent-presets";
1146
1121
  /** Refuse an empty preset id before invoking a domain operation. */
1147
1122
  function validatePresetId(value, field) {
1148
1123
  if (value.length === 0) throw new RemoteError("gateway/bad-request", `${field} must be a non-empty string`, {});
1149
1124
  }
1150
1125
  /** Runtime schema for the user-writable slice. */
1151
- const AgentPresetSettingsSchema = z.object({ default: z.string() });
1126
+ const AgentPresetSettingsSchema = z.object({
1127
+ default: z.string(),
1128
+ modeSelectionEnabled: z.boolean()
1129
+ });
1152
1130
  /**
1153
1131
  * Registry over the deployment's agent presets.
1154
1132
  *
@@ -1309,7 +1287,10 @@ let AgentPresets = (() => {
1309
1287
  }] : []
1310
1288
  ];
1311
1289
  ctx.inject(["settings"], (settingsCtx) => {
1312
- this.settings = settingsCtx.settings.register(SETTINGS_NAMESPACE, AgentPresetSettingsSchema, { base: { default: config.default } });
1290
+ this.settings = settingsCtx.settings.register(SETTINGS_NAMESPACE, AgentPresetSettingsSchema, { base: {
1291
+ default: config.default,
1292
+ modeSelectionEnabled: true
1293
+ } });
1313
1294
  this.settingsService = settingsCtx.settings;
1314
1295
  settingsCtx.effect(() => () => {
1315
1296
  this.settings = void 0;
@@ -1335,35 +1316,51 @@ let AgentPresets = (() => {
1335
1316
  * every running session on the preset it was composed from.
1336
1317
  */
1337
1318
  get defaultId() {
1338
- return this.settings?.get().default ?? this.config.default;
1319
+ return this.selectionPolicy().defaultId;
1320
+ }
1321
+ /** Read one internally consistent snapshot of the selection policy. */
1322
+ selectionPolicy() {
1323
+ const settings = this.settings?.get();
1324
+ if (settings === void 0) return {
1325
+ enabled: true,
1326
+ defaultId: this.config.default
1327
+ };
1328
+ const enabled = settings.modeSelectionEnabled;
1329
+ return {
1330
+ enabled,
1331
+ defaultId: enabled ? settings.default : this.config.default
1332
+ };
1339
1333
  }
1340
1334
  /**
1341
1335
  * Every preset the configured roots currently supply.
1342
1336
  * @returns the presets, first-root-wins per id.
1343
1337
  */
1344
1338
  async list() {
1345
- return await discoverPresets(this.resolvedRoots, this.harnessBase);
1339
+ const packages = this.ctx.get("pluginPackages");
1340
+ return packages === void 0 ? await discoverPresets(this.resolvedRoots, this.harnessBase) : await discoverPresets(this.resolvedRoots, this.harnessBase, (specifier, base) => packages.packageOf(specifier, base) !== void 0);
1346
1341
  }
1347
1342
  /**
1348
1343
  * The roster off the Host: {@link list} projected to path-free rows, with
1349
- * the default marked and this deployment's authoring capability beside it.
1344
+ * the policy-effective default marked, this deployment's authoring
1345
+ * capability, and its mode-selection policy beside it.
1350
1346
  *
1351
1347
  * Whether a client can open a preset's directory is the Host's own opener
1352
1348
  * capability, not a roster property — a caller needing both joins them.
1353
- * @returns the rows and the authoring capability.
1349
+ * @returns the rows, authoring capability, and effective selection policy.
1354
1350
  */
1355
1351
  async remoteExportList() {
1356
- const defaultId = this.defaultId;
1352
+ const policy = this.selectionPolicy();
1357
1353
  return {
1358
1354
  presets: (await this.list()).map((preset) => ({
1359
1355
  id: preset.id,
1360
1356
  trust: preset.trust,
1361
- isDefault: preset.id === defaultId,
1357
+ isDefault: preset.id === policy.defaultId,
1362
1358
  ...preset.name === void 0 ? {} : { name: preset.name },
1363
1359
  ...preset.description === void 0 ? {} : { description: preset.description },
1364
1360
  ...preset.broken === void 0 ? {} : { broken: preset.broken }
1365
1361
  })),
1366
- authorable: this.authorable
1362
+ authorable: this.authorable,
1363
+ modeSelectionEnabled: policy.enabled
1367
1364
  };
1368
1365
  }
1369
1366
  /**