浏览代码

feat(web): manage plugins and per-preset settings from the browser

Add the Manage plugins tab (ui-settings-plugin-manager): package cards
with enable/disable switches, retry and uninstall, an install dialog
streaming pnpm output, and the selected preset's composition with per-row
switches and removal; the read-only list gains provenance facts.

Settings cards edit one scope at a time: ui-settings keeps one describe
mirror per settings scope (SettingsMirrorRegistry) and binds
{ namespace, scope }; ui-settings-plugins stages forms per scope, marks
inherited fields, and adds the skill-filesystem roots card.

Fixes surfaced by driving the browser end to end: the client gateway now
lets a call omit trailing parameters that accept undefined (PR3's
optional scope arguments failed the strict arity check), the plugins
install verb is `add` because the client namespace service reserves
`install`/`remove` (guarded by remote-method-names.host.spec), a plugin
module offers its main export as an addable `.`, and describing a settings
scope no owner registered resolves over the global instance's base and
never throws.

The web e2e scaffold can mount a profile runtime over fixture packages;
plugin-manager.e2e drives the tab and writes one field under a preset
scope; plugin-config and settings-chrome goldens are refreshed.
Yichen Jiang 2 周之前
父节点
当前提交
1570eeeaee
共有 100 个文件被更改,包括 5361 次插入196 次删除
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml
  2. 37 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
  3. 37 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md
  10. 6 0
      apps/web/tests/expected/plugin-config/section.expected.md
  11. 127 0
      apps/web/tests/expected/plugin-manager/manager.expected.md
  12. 5 0
      apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
  13. 5 0
      apps/web/tests/fixtures/plugins/fixture-bundle/index.js
  14. 14 0
      apps/web/tests/fixtures/plugins/fixture-bundle/package.json
  15. 5 0
      apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js
  16. 11 0
      apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json
  17. 189 0
      apps/web/tests/plugin-manager.e2e.ts
  18. 51 2
      apps/web/tests/scaffold.ts
  19. 1 0
      apps/web/tsconfig.json
  20. 2 2
      docs/config-catalog.i18n.yaml
  21. 1 0
      docs/config-catalog.md
  22. 1 0
      docs/config-catalog.zh.md
  23. 2 2
      docs/module-graph.i18n.yaml
  24. 2 0
      docs/module-graph.md
  25. 2 0
      docs/module-graph.zh.md
  26. 2 2
      docs/subsystems/core.i18n.yaml
  27. 1 1
      docs/subsystems/core.md
  28. 1 1
      docs/subsystems/core.zh.md
  29. 2 2
      docs/subsystems/settings.i18n.yaml
  30. 4 3
      docs/subsystems/settings.md
  31. 4 3
      docs/subsystems/settings.zh.md
  32. 2 2
      packages/api/gateway/README.i18n.yaml
  33. 1 1
      packages/api/gateway/README.md
  34. 1 1
      packages/api/gateway/README.zh.md
  35. 12 4
      packages/api/gateway/src/client/index.ts
  36. 54 0
      packages/api/gateway/tests/gateway.client.spec.ts
  37. 49 0
      packages/api/remotes/tests/remote-method-names.host.spec.ts
  38. 5 0
      packages/bundle/web-app/cordis.patch.yml
  39. 1 0
      packages/bundle/web-app/package.json
  40. 2 2
      packages/client/README.i18n.yaml
  41. 1 0
      packages/client/README.md
  42. 1 0
      packages/client/README.zh.md
  43. 2 2
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  44. 1 1
      packages/client/ui-settings-plugin-inventory/README.md
  45. 1 1
      packages/client/ui-settings-plugin-inventory/README.zh.md
  46. 14 1
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  47. 12 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  48. 32 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  49. 6 0
      packages/client/ui-settings-plugin-manager/README.i18n.yaml
  50. 112 0
      packages/client/ui-settings-plugin-manager/README.md
  51. 112 0
      packages/client/ui-settings-plugin-manager/README.zh.md
  52. 73 0
      packages/client/ui-settings-plugin-manager/package.json
  53. 484 0
      packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css
  54. 615 0
      packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx
  55. 82 0
      packages/client/ui-settings-plugin-manager/src/client/index.ts
  56. 216 0
      packages/client/ui-settings-plugin-manager/src/client/locales.ts
  57. 406 0
      packages/client/ui-settings-plugin-manager/src/client/manager-store.ts
  58. 6 0
      packages/client/ui-settings-plugin-manager/src/css-modules.d.ts
  59. 4 0
      packages/client/ui-settings-plugin-manager/src/index.ts
  60. 98 0
      packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx
  61. 394 0
      packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx
  62. 359 0
      packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts
  63. 25 0
      packages/client/ui-settings-plugin-manager/tsconfig.json
  64. 3 0
      packages/client/ui-settings-plugin-manager/tsdown.config.ts
  65. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  66. 6 2
      packages/client/ui-settings-plugins/README.md
  67. 6 2
      packages/client/ui-settings-plugins/README.zh.md
  68. 6 3
      packages/client/ui-settings-plugins/package.json
  69. 1 0
      packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx
  70. 2 0
      packages/client/ui-settings-plugins/src/client/BashCard.tsx
  71. 80 18
      packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx
  72. 3 0
      packages/client/ui-settings-plugins/src/client/PluginCard.tsx
  73. 65 0
      packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css
  74. 48 0
      packages/client/ui-settings-plugins/src/client/SkillFilesystemCard.tsx
  75. 2 0
      packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx
  76. 10 7
      packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts
  77. 10 7
      packages/client/ui-settings-plugins/src/client/bash-card-controller.ts
  78. 83 7
      packages/client/ui-settings-plugins/src/client/card-form.ts
  79. 28 0
      packages/client/ui-settings-plugins/src/client/fields.module.css
  80. 37 13
      packages/client/ui-settings-plugins/src/client/fields.tsx
  81. 47 8
      packages/client/ui-settings-plugins/src/client/index.ts
  82. 33 1
      packages/client/ui-settings-plugins/src/client/locales.ts
  83. 168 0
      packages/client/ui-settings-plugins/src/client/scope-switcher.ts
  84. 205 0
      packages/client/ui-settings-plugins/src/client/scoped-form.ts
  85. 58 0
      packages/client/ui-settings-plugins/src/client/skill-filesystem-card-controller.ts
  86. 29 4
      packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts
  87. 17 12
      packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts
  88. 78 8
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  89. 2 0
      packages/client/ui-settings-plugins/tests/fields.client.spec.tsx
  90. 116 0
      packages/client/ui-settings-plugins/tests/scope-switcher.client.spec.ts
  91. 123 0
      packages/client/ui-settings-plugins/tests/scoped-form.client.spec.ts
  92. 117 3
      packages/client/ui-settings-plugins/tests/section.client.spec.tsx
  93. 133 37
      packages/client/ui-settings-plugins/tests/stores.client.spec.ts
  94. 6 0
      packages/client/ui-settings-plugins/tsconfig.json
  95. 2 2
      packages/client/ui-settings/README.i18n.yaml
  96. 4 4
      packages/client/ui-settings/README.md
  97. 4 4
      packages/client/ui-settings/README.zh.md
  98. 13 10
      packages/client/ui-settings/src/client/index.ts
  99. 20 0
      packages/client/ui-settings/src/client/settings-contract.ts
  100. 85 1
      packages/client/ui-settings/src/client/settings-mirror.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
+2026-09-04-plugin-management-in-web-settings.md: b0ccbe9934ac14ce4ee77b26d2a677f36f623be9
+2026-09-04-plugin-management-in-web-settings.zh.md: 40fc1933deb71331c1bc1c2c16baa90afd097e62

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md

@@ -0,0 +1,37 @@
+# Agent Note: Plugin management lands in Web settings
+
+Status: implemented
+
+English | [中文](2026-09-04-plugin-management-in-web-settings.zh.md)
+
+## Problem
+
+The Host could manage a profile's plugins — install, enable, disable, retry, compose rows into a preset — and resolve a settings namespace per named scope, but the browser exposed none of it. The Plugins section had a configuration tab over the global instance of each namespace and a read-only list; a person who wanted the values of one preset, or a bundle switched on without the CLI, had no surface. The settings client read one `settings.describe` answer and bound every namespace globally, so a per-preset value had no path from the document to a card.
+
+## Decision
+
+**One mirror per settings scope.** `ui-settings` keeps a `SettingsMirrorRegistry`: the global mirror as before, and one mirror per named scope created the first time a consumer binds or describes it. A `settings/document-updated` event names the scope it landed in; a global commit reloads every mirror, because a named scope resolves over the global section, and a scoped commit reloads that scope alone. `bind({ namespace, scope })` derives from the scope's mirror and passes the scope on every write; the snapshot carries `scope`, `registered`, and `inherited`.
+
+**Cards stage per scope, components stay scope-blind.** `ui-settings-plugins` holds one `ScopeSelection` for the configurable tab and a `ScopedCardForms` per card: one `CardForm` per scope, bound lazily, with drafts that belong to the scope they were typed under. The card components read the same hooks and call the same actions, which route to the selected form at call time. A field a named scope does not override reports `inherited` when the global user layer carries it; a reset stages the inherited value. The tab's switch lists the roster's presets plus any scope the document holds a section for. The filesystem skill provider gets its first card, a line-list of extra roots.
+
+**A separate tab for management.** `ui-settings-plugin-manager` registers the **Manage plugins** tab between configuration and the read-only list. It reads packages from the `plugins` Remote and preset compositions from `pluginInventory`, re-reads after every action and every `plugins/changed`, and streams `plugins/install-log` into its install dialog. Destructive actions wait for an acknowledged confirmation listing the dependents the Host reports. The read-only list gains the provenance facts the Host already carried: which layer inserted a preset row and who switched a row off.
+
+**The install verb is `add`.** The client namespace service reserves `install` and `remove` for its own members and refuses a mounted method of that name at page load, after every unit suite has passed. The Host's method is `plugins/add`, as on the CLI, and `packages/api/remotes/tests/remote-method-names.host.spec.ts` checks every `@Remote('<name>')` in the workspace against the names the gateway's own source reserves.
+
+**The web e2e scaffold can mount a profile runtime.** `launchWebScaffold({ profileRuntime })` links fixture packages into the scaffold profile and mounts `ProfileRuntime` over it with `patchReload: 'startup'`, so the manager has a profile to manage while the booted tree never recomposes under a scenario.
+
+## Alternatives considered
+
+**An ambient scope selection inside `ui-settings`.** Rejected: surfaces outside the plugins tab bind global-only namespaces, and a page-wide selection would retarget them.
+
+**Management controls inside the read-only list.** Rejected: the list projects rows of the live tree; management is per package, over the profile manifest, with an install run and confirmations that need their own state.
+
+**Keeping `install` and special-casing the client mount.** Rejected: the reserved names are the service's own members; renaming one method is cheaper than a second lookup path in the gateway.
+
+## Consequences
+
+A preset's settings are editable beside the shared ones, and the document's `scopes.<id>` tree is what the scoped form writes. The plugin manager is usable from the browser end to end: install, switch, retry, uninstall, and compose rows into a preset. Existing plugin-configuration goldens move: the section grows a tab and a scope row.
+
+## Testing
+
+`packages/client/ui-settings/tests` pin the registry's routing and the scoped bind; `packages/client/ui-settings-plugins/tests` the scoped forms, the switch, the skills card, and the inherited badge; `packages/client/ui-settings-plugin-manager/tests` the store's reads, actions, install run, confirmations, and the tab's rendering; `packages/client/ui-settings-plugin-inventory/tests` the provenance facts. `apps/web/tests/plugin-manager.e2e.ts` drives the manager over a scaffold profile runtime and writes one field under a preset scope; `plugin-config` and `settings-chrome` goldens are re-recorded for the new tab and scope row.

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md

@@ -0,0 +1,37 @@
+# Agent Note:插件管理进入 Web 设置
+
+Status: implemented
+
+[English](2026-09-04-plugin-management-in-web-settings.md) | 中文
+
+## 问题
+
+宿主已经能管理 profile 的插件——安装、启用、停用、重试、把行组合进预设——也能按具名 scope 解析 settings 命名空间,但浏览器一样都没暴露。「插件」分区只有一个覆盖每个命名空间全局实例的配置标签页与一个只读列表;想要某个预设的值、或不经 CLI 打开一个 bundle 的人没有任何入口。settings 客户端只读一份 `settings.describe` 应答并把每个命名空间绑定在全局,因此按预设的值没有从文档到卡片的路径。
+
+## 决定
+
+**每个 settings scope 一面镜像。** `ui-settings` 持有 `SettingsMirrorRegistry`:全局镜像如前,每个具名 scope 的镜像在消费方首次绑定或描述它时创建。`settings/document-updated` 事件点名它落在哪个 scope:全局提交重载每一面镜像,因为具名 scope 是在全局分区之上解析的;scoped 提交只重载该 scope。`bind({ namespace, scope })` 从该 scope 的镜像派生并在每次写入时传入 scope;快照携带 `scope`、`registered` 与 `inherited`。
+
+**卡片按 scope 暂存,组件不知道 scope。** `ui-settings-plugins` 为配置标签页持有一个 `ScopeSelection`,每张卡片一个 `ScopedCardForms`:每个 scope 一个 `CardForm`,惰性绑定,草稿属于输入它时所在的 scope。卡片组件读同样的 hooks、调同样的 actions,后者在调用时路由到选中的表单。具名 scope 未覆盖而全局用户层携带的字段报告 `inherited`;重置暂存的是继承值。标签页的开关列出 roster 的预设,外加文档已有分区的任何 scope。文件系统技能提供方得到它的第一张卡片:额外目录的逐行列表。
+
+**管理另开一个标签页。** `ui-settings-plugin-manager` 在配置与只读列表之间注册**插件管理**标签页。它从 `plugins` Remote 读包、从 `pluginInventory` 读预设组合,在每次操作与每个 `plugins/changed` 之后重新读取,并把 `plugins/install-log` 流进安装对话框。破坏性操作等待一次已勾选确认,确认框列出宿主报告的依赖方。只读列表补上宿主早已携带的出处事实:哪一层插入了预设行、谁停用了某一行。
+
+**安装动词是 `add`。** 客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,并在页面加载时——所有单测都通过之后——拒绝同名的挂载方法。宿主的方法与 CLI 一样叫 `plugins/add`,`packages/api/remotes/tests/remote-method-names.host.spec.ts` 用网关源码自己保留的名字检查工作区里每一个 `@Remote('<name>')`。
+
+**web e2e 脚手架可以挂 profile runtime。** `launchWebScaffold({ profileRuntime })` 把 fixture 包链接进脚手架 profile,并以 `patchReload: 'startup'` 在其上挂载 `ProfileRuntime`,于是管理器有 profile 可管,而启动好的树在场景之下绝不重新组合。
+
+## 考虑过的替代方案
+
+**在 `ui-settings` 内部放一个全局的 scope 选择。** 否决:插件标签页之外的表面绑定的是仅全局的命名空间,页面级选择会把它们改指向别处。
+
+**把管理控件放进只读列表。** 否决:列表投影的是在线树的行;管理是按包、基于 profile manifest 的,还有安装运行与确认这些需要自己状态的东西。
+
+**保留 `install` 并在客户端挂载里特判。** 否决:保留名是服务自己的成员;改一个方法名比在网关里多一条查找路径便宜。
+
+## 后果
+
+预设的设置可以在共用设置旁边编辑,scoped 表单写的就是文档的 `scopes.<id>` 树。插件管理器在浏览器里端到端可用:安装、切换、重试、卸载、把行组合进预设。既有的插件配置 golden 会移动:分区多了一个标签页和一行 scope。
+
+## 测试
+
+`packages/client/ui-settings/tests` 钉住 registry 的路由与 scoped 绑定;`packages/client/ui-settings-plugins/tests` 钉住 scoped 表单、开关、技能卡片与继承标记;`packages/client/ui-settings-plugin-manager/tests` 钉住 store 的读取、操作、安装运行、确认与标签页渲染;`packages/client/ui-settings-plugin-inventory/tests` 钉住出处事实。`apps/web/tests/plugin-manager.e2e.ts` 在脚手架 profile runtime 上驱动管理器并在某个预设 scope 下写一个字段;`plugin-config` 与 `settings-chrome` 的 golden 为新标签页与 scope 行重录。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
-2026-09-04-plugin-manager-over-the-profile-runtime.md: 8b12c1dcbee5e5f463fcba181441717de5b20c39
-2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 2f1fa6682124677c0614f24ef005f3efc212bd57
+2026-09-04-plugin-manager-over-the-profile-runtime.md: 1622076cde78dc98becf27ec03de3141a8217fbe
+2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 3bafbf7902031b78933c6f2ee83da14bf407530b

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md

@@ -10,7 +10,7 @@ Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec
 
 ## Decision
 
-**One service, one manifest.** `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote: `list`, `install`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents`. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`.
+**One service, one manifest.** `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote: `list`, `add`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents`. The install verb is `add`, as on the CLI: the client's namespace service reserves `install` and `remove` for its own members, and the mount refuses a method named after one. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`.
 
 **Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决定
 
-**一个服务,一份 manifest。** `dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote:`list`、`install`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
+**一个服务,一份 manifest。** `dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote:`list`、`add`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`。安装动词与 CLI 一样是 `add`:客户端的命名空间服务把 `install` 与 `remove` 留给自己的成员,挂载会拒绝同名方法。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
 
 **启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md
-2026-09-04-settings-namespaces-resolve-per-scope.md: 8275a2b2289a3bb6e31d4fc827585c742de19619
-2026-09-04-settings-namespaces-resolve-per-scope.zh.md: 04d5b2f179d4e84b744cc89a6735ceb01febb541
+2026-09-04-settings-namespaces-resolve-per-scope.md: 1769b049080d307351b63ef9ea5c75fc4e6a70c9
+2026-09-04-settings-namespaces-resolve-per-scope.zh.md: 6cf041ae3e89083b2c931ab5c1342f63f1c70a39

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.md

@@ -16,7 +16,7 @@ The settings seam registered a namespace once per process. That fit host rows, w
 
 **Four layers.** An instance resolves schema defaults, its own composition `base`, the document's global section, and its scope's section (`scopes.<id>.<ns>`) in that order, with the existing field-level merge. A global write re-resolves every instance of the kind, each gated on its own resolved value, so a scope that overrides the changed field is not disturbed; a scoped write commits that instance alone. Revisions are per section, kept off the registrations so a section written for a scope nothing has registered yet — a preset no session composed — is versioned like any other; such a write is accepted when the kind exists and is judged by the shared schema. `scopes` is a reserved namespace.
 
-**Describe per scope.** `describe()` answers one descriptor per kind under the global scope; `describe({ scope })` one per kind under a named scope, with `registered`, the scope's own `user` section, and `inherited` — the value without that section — so a surface can mark a field as overridden, inherited, or default. The controller's `describe(scope?)` and its three write verbs take the same optional trailing `scope`, and both events carry the scope as a trailing argument that is absent for the global instance, which keeps every existing listener and the forwarded-event carrier unchanged.
+**Describe per scope.** `describe()` answers one descriptor per kind under the global scope; `describe({ scope })` one per kind under a named scope, with `registered`, the scope's own `user` section, and `inherited` — the value without that section — so a surface can mark a field as overridden, inherited, or default. A scope no owner registered resolves over the global instance's composition base, because a schema may require what only a composition supplies, and a description never throws: layers the schema refuses describe as the layers beneath them, down to an empty section. The controller's `describe(scope?)` and its three write verbs take the same optional trailing `scope`, and both events carry the scope as a trailing argument that is absent for the global instance, which keeps every existing listener and the forwarded-event carrier unchanged.
 
 **`skill-filesystem` is the first consumer.** Its `customSkillDirs` resolves through `installSection` with the composition value as base; a change replaces the provider's roots and invalidates the catalog, so a person adds a root for one preset from the settings document while the process runs.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-settings-namespaces-resolve-per-scope.zh.md

@@ -16,7 +16,7 @@ settings seam 每个进程只允许注册一次命名空间。这对只存在一
 
 **四层。** 一个 instance 按顺序解析 schema 默认值、自己的组合 `base`、文档的全局段、以及其 scope 的段(`scopes.<id>.<ns>`),沿用既有的字段级合并。全局写入重新解析该 kind 的每个 instance,各自按自身解析值门控,因此覆盖了被改字段的 scope 不会被打扰;scoped 写入只提交那个 instance。revision 按段记录,与注册分离,于是为尚无注册的 scope——还没有会话组合过的 preset——写入的段与其它段同样被版本化;只要 kind 存在,这样的写入就被接受并由共享 schema 判定。`scopes` 是保留的命名空间。
 
-**按 scope 描述。** `describe()` 在全局 scope 下每个 kind 回答一条描述符;`describe({ scope })` 在某个具名 scope 下每个 kind 回答一条,附带 `registered`、该 scope 自己的 `user` 段,以及 `inherited`——没有该段时的值——让界面能把字段标为已覆盖、继承或默认。controller 的 `describe(scope?)` 与三个写入动词接受同样的可选尾随 `scope`,两个事件都把 scope 作为尾随参数携带、全局 instance 时缺席,这让每个既有监听器与转发事件载体保持不变。
+**按 scope 描述。** `describe()` 在全局 scope 下每个 kind 回答一条描述符;`describe({ scope })` 在某个具名 scope 下每个 kind 回答一条,附带 `registered`、该 scope 自己的 `user` 段,以及 `inherited`——没有该段时的值——让界面能把字段标为已覆盖、继承或默认。没有注册者的 scope 在全局实例的组合 base 之上解析——schema 可能要求只有组合才提供的字段——而描述从不抛错:schema 拒绝的层按其下的层描述,直到空分区。controller 的 `describe(scope?)` 与三个写入动词接受同样的可选尾随 `scope`,两个事件都把 scope 作为尾随参数携带、全局 instance 时缺席,这让每个既有监听器与转发事件载体保持不变。
 
 **`skill-filesystem` 是第一个消费方。** 它的 `customSkillDirs` 以组合值为 base 经 `installSection` 解析;变化会替换 provider 的根目录并让目录失效,于是一个人可以在进程运行中从 settings 文档为某个 preset 添加一个根目录。
 

+ 6 - 0
apps/web/tests/expected/plugin-config/section.expected.md

@@ -21,8 +21,14 @@
   - paragraph: 配置和查看本部署已安装的插件。
   - tablist "插件视图":
     - tab "插件配置" [selected]
+    - tab "插件管理"
     - tab "插件列表"
   - tabpanel "插件配置":
+    - text: 生效范围
+    - button "选择这些设置生效的 Agent 预设":
+      - text: 所有预设
+      - img
+    - paragraph: 这里的值对所有 Agent 预设生效,除非某个预设单独覆盖。
     - list:
       - listitem:
         - 'button "展开设置: 终端"':

+ 127 - 0
apps/web/tests/expected/plugin-manager/manager.expected.md

@@ -0,0 +1,127 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "插件" [level=2]
+  - paragraph: 配置和查看本部署已安装的插件。
+  - tablist "插件视图":
+    - tab "插件配置"
+    - tab "插件管理" [selected]
+    - tab "插件列表"
+  - tabpanel "插件管理":
+    - button "刷新"
+    - button "添加插件"
+    - heading "全局插件" [level=3]
+    - paragraph: 按包安装与启停;启用的 bundle 对系统与所有会话生效 · 2 个
+    - list:
+      - listitem:
+        - text: 示例组合包 第三方 Bundle 已停用 @fixture/bundle · 0.0.1
+        - switch "启用 示例组合包"
+        - button "展开 示例组合包":
+          - img
+      - listitem:
+        - text: 示例插件 第三方 插件模块 按行添加 @fixture/plain-plugin · 0.0.1
+        - button "展开 示例插件":
+          - img
+    - heading "会话插件" [level=3]
+    - button "选择要管理的 Agent 预设":
+      - text: 标准模式(默认)
+      - img
+    - paragraph: 按 Agent 预设组成;这里的改动只写入该预设的用户补丁层
+    - list:
+      - listitem:
+        - text: persona @deepseek-ai/dsh-persona
+        - switch "启用行 persona" [checked]
+      - listitem:
+        - text: agent-instructions @deepseek-ai/dsh-agent-instructions
+        - switch "启用行 agent-instructions" [checked]
+      - listitem:
+        - text: tool-bash @deepseek-ai/dsh-tool-bash
+        - switch "启用行 tool-bash" [checked]
+      - listitem:
+        - text: tool-pwsh @deepseek-ai/dsh-tool-pwsh 组合停用
+        - switch "启用行 tool-pwsh"
+      - listitem:
+        - text: tool-fs @deepseek-ai/dsh-tool-fs
+        - switch "启用行 tool-fs" [checked]
+      - listitem:
+        - text: tool-fs-search @deepseek-ai/dsh-tool-fs-search
+        - switch "启用行 tool-fs-search" [checked]
+      - listitem:
+        - text: tool-jobs @deepseek-ai/dsh-tool-jobs
+        - switch "启用行 tool-jobs" [checked]
+      - listitem:
+        - text: skill-filesystem @deepseek-ai/dsh-skill-filesystem
+        - switch "启用行 skill-filesystem" [checked]
+      - listitem:
+        - text: tool-skill @deepseek-ai/dsh-tool-skill
+        - switch "启用行 tool-skill" [checked]
+      - listitem:
+        - text: command-goal @deepseek-ai/dsh-command-goal
+        - switch "启用行 command-goal" [checked]
+      - listitem:
+        - text: tool-goal @deepseek-ai/dsh-tool-goal
+        - switch "启用行 tool-goal" [checked]
+      - listitem:
+        - text: plan-mode @deepseek-ai/dsh-plan-mode
+        - switch "启用行 plan-mode" [checked]
+      - listitem:
+        - text: compaction-basic @deepseek-ai/dsh-compaction-basic
+        - switch "启用行 compaction-basic" [checked]
+      - listitem:
+        - text: command-compact @deepseek-ai/dsh-command-compact
+        - switch "启用行 command-compact" [checked]
+      - listitem:
+        - text: tool-result-pruner @deepseek-ai/dsh-compaction-tool-result-pruner
+        - switch "启用行 tool-result-pruner" [checked]
+      - listitem:
+        - text: tool-subagent-control @deepseek-ai/dsh-tool-subagent-control
+        - switch "启用行 tool-subagent-control" [checked]
+      - listitem:
+        - text: tool-subagent-list-agents @deepseek-ai/dsh-tool-subagent-control/list-agents
+        - switch "启用行 tool-subagent-list-agents" [checked]
+      - listitem:
+        - text: tool-subagent @deepseek-ai/dsh-tool-subagent
+        - switch "启用行 tool-subagent" [checked]
+      - listitem:
+        - text: tool-subagent-fork @deepseek-ai/dsh-tool-subagent
+        - switch "启用行 tool-subagent-fork" [checked]
+      - listitem:
+        - text: tool-subagent-codex @deepseek-ai/dsh-tool-subagent 组合停用
+        - switch "启用行 tool-subagent-codex"
+      - listitem:
+        - text: tool-subagent-claude-code @deepseek-ai/dsh-tool-subagent 组合停用
+        - switch "启用行 tool-subagent-claude-code"
+      - listitem:
+        - text: workflow-worker-thread @deepseek-ai/dsh-workflow-worker-thread
+        - switch "启用行 workflow-worker-thread" [checked]
+      - listitem:
+        - text: tool-workflow @deepseek-ai/dsh-tool-workflow
+        - switch "启用行 tool-workflow" [checked]
+      - listitem:
+        - text: tool-ralph @deepseek-ai/dsh-tool-ralph
+        - switch "启用行 tool-ralph" [checked]
+      - listitem:
+        - text: tool-ask-user @deepseek-ai/dsh-tool-ask-user
+        - switch "启用行 tool-ask-user" [checked]
+      - listitem:
+        - text: tool-todo @deepseek-ai/dsh-tool-todo
+        - switch "启用行 tool-todo" [checked]
+      - listitem:
+        - text: tool-web @deepseek-ai/dsh-tool-web
+        - switch "启用行 tool-web" [checked]

+ 5 - 0
apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml

@@ -0,0 +1,5 @@
+# Web e2e fixture bundle: one inert row, so enabling it changes the profile
+# manifest without composing anything the scaffold tree would notice.
+- insert:
+    - id: fixture-row
+      name: '@fixture/bundle'

+ 5 - 0
apps/web/tests/fixtures/plugins/fixture-bundle/index.js

@@ -0,0 +1,5 @@
+/** Web e2e fixture: an inert cordis plugin. */
+export const name = 'fixture-bundle'
+
+/** Nothing to mount. */
+export function apply() {}

+ 14 - 0
apps/web/tests/fixtures/plugins/fixture-bundle/package.json

@@ -0,0 +1,14 @@
+{
+  "name": "@fixture/bundle",
+  "version": "0.0.1",
+  "private": true,
+  "description": "Web e2e fixture: a bundle whose one row is an inert plugin.",
+  "type": "module",
+  "main": "index.js",
+  "dsh": {
+    "title": "示例组合包",
+    "bundle": {
+      "patch": "./cordis.patch.yml"
+    }
+  }
+}

+ 5 - 0
apps/web/tests/fixtures/plugins/fixture-plain-plugin/index.js

@@ -0,0 +1,5 @@
+/** Web e2e fixture: an inert cordis plugin that a composition adds as one row. */
+export const name = 'fixture-plain-plugin'
+
+/** Nothing to mount. */
+export function apply() {}

+ 11 - 0
apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json

@@ -0,0 +1,11 @@
+{
+  "name": "@fixture/plain-plugin",
+  "version": "0.0.1",
+  "private": true,
+  "description": "Web e2e fixture: a plain plugin module with no bundle, added to a composition per row.",
+  "type": "module",
+  "main": "index.js",
+  "dsh": {
+    "title": "示例插件"
+  }
+}

+ 189 - 0
apps/web/tests/plugin-manager.e2e.ts

@@ -0,0 +1,189 @@
+// Web e2e scenario: the plugin manager tab in Plugins settings over a scaffold
+// profile runtime — one installed bundle switched on, one plain plugin added to
+// an agent preset and switched off there, and the configuration tab writing one
+// field under that preset's settings scope. Zero model calls: everything is
+// client state plus the profile files and the settings document, so there is
+// no fixture and a stray stream would fail loud on the open llm seam.
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { join } from 'node:path'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
+const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
+const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe('web e2e: plugin manager', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  /** The writable preset root the scenario's preset overlays land in. */
+  let presetRoot: string
+
+  beforeAll(async () => {
+    presetRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))
+    scaffold = await launchWebScaffold({
+      // The scaffold pins a roster without a user root, so a preset's user
+      // patch layer needs one: an empty root beside the shipped presets.
+      agentPresets: { roots: [{ path: presetRoot, trust: 'user' }], default: 'standard' },
+      profileRuntime: {
+        packages: [
+          { dir: join(FIXTURE_PLUGINS, 'fixture-bundle') },
+          { dir: join(FIXTURE_PLUGINS, 'fixture-plain-plugin') },
+        ],
+      },
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+    await rm(presetRoot, { recursive: true, force: true })
+  })
+
+  /** Open the settings dialog on the Plugins section and select one of its tabs. */
+  async function openPluginsTab(tab: string) {
+    if (await page.getByRole('dialog', { name: '设置' }).count() > 0) {
+      await page.keyboard.press('Escape')
+      await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
+    }
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '插件', exact: true }).click()
+    await dialog.getByRole('tab', { name: tab, exact: true }).click()
+    await expect
+      .poll(() => dialog.getByRole('tab', { name: tab, exact: true }).getAttribute('aria-selected'), { timeout: 5_000 })
+      .toBe('true')
+    return dialog
+  }
+
+  /** One file under the harness home, or the empty string while it does not exist. */
+  async function homeFile(...segments: string[]): Promise<string> {
+    return readFile(join(scaffold.harnessHome, ...segments), 'utf8').catch(() => '')
+  }
+
+  it('lists the profile packages with their switches and the preset composition', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
+    const dialog = await openPluginsTab('插件管理')
+
+    await dialog.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 })
+    expect(await dialog.getByText('示例插件', { exact: true }).count()).toBe(1)
+    // The bundle is installed but not enabled; the plain plugin carries no switch.
+    const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' })
+    expect(await toggle.getAttribute('aria-checked')).toBe('false')
+    expect(await dialog.getByRole('switch', { name: '启用 示例插件' }).count()).toBe(0)
+    // The default preset's composition renders beside the packages.
+    expect(await dialog.getByRole('button', { name: '选择要管理的 Agent 预设' }).textContent()).toContain('默认')
+    expect(await dialog.getByRole('switch', { name: 'Enable row bash' }).count()).toBe(0)
+
+    const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('enables a bundle into the profile manifest and reports the restart it waits for', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
+    const dialog = await openPluginsTab('插件管理')
+    const toggle = dialog.getByRole('switch', { name: '启用 示例组合包' })
+    await toggle.waitFor({ timeout: 20_000 })
+
+    await toggle.click()
+
+    await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'package.json')).includes('"@fixture/bundle"'), {
+      timeout: 10_000,
+    }).toBe(true)
+    const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
+      dsh: { profile: { bundles: string[] } }
+    }
+    expect(manifest.dsh.profile.bundles).toEqual(['@fixture/bundle'])
+    // A startup-applied profile: the switch is on, and the banner names the package.
+    await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true')
+    await dialog.getByText('以下更改会在下次启动生效:示例组合包').waitFor({ timeout: 10_000 })
+    expect(await dialog.getByText('待重启', { exact: true }).count()).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('adds a plain plugin to a preset, switches its row off there, and removes it again', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-preset-row'))
+    const dialog = await openPluginsTab('插件管理')
+    await dialog.getByRole('button', { name: '展开 示例插件' }).click()
+    await dialog.getByRole('button', { name: '添加到…' }).click()
+    await page.getByRole('menuitem', { name: '预设:标准模式' }).click()
+
+    const overlay = (): Promise<string> => readFile(join(presetRoot, 'standard', 'cordis.patch.yml'), 'utf8').catch(() => '')
+    await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(true)
+    expect(await overlay()).toContain('id: fixture/plain-plugin')
+    // The preset composition lists the new row as the user's, switched on.
+    const rowToggle = dialog.getByRole('switch', { name: '启用行 fixture/plain-plugin' })
+    await rowToggle.waitFor({ timeout: 10_000 })
+    expect(await rowToggle.getAttribute('aria-checked')).toBe('true')
+    expect(await dialog.locator('[data-preset-row="fixture/plain-plugin"]').getAttribute('data-plugin-source')).toBe('user')
+
+    await rowToggle.click()
+    await expect.poll(async () => (await overlay()).includes('disabled: true'), { timeout: 10_000 }).toBe(true)
+    await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false')
+    expect(await dialog.getByText('用户停用', { exact: true }).count()).toBe(1)
+
+    await dialog.getByRole('button', { name: '移除行 fixture/plain-plugin' }).click()
+    await expect.poll(async () => (await overlay()).includes('@fixture/plain-plugin'), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => rowToggle.count(), { timeout: 10_000 }).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it('writes a configuration field under one preset scope and leaves the shared value alone', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-scope'))
+    const dialog = await openPluginsTab('插件配置')
+    const scope = dialog.getByRole('button', { name: '选择这些设置生效的 Agent 预设' })
+    await scope.waitFor({ timeout: 10_000 })
+    expect(await scope.textContent()).toBe('所有预设')
+
+    await scope.click()
+    await page.getByRole('menuitem', { name: '标准模式(默认)' }).click()
+    await expect.poll(() => scope.getAttribute('data-settings-scope'), { timeout: 5_000 }).toBe('preset/standard')
+    await dialog.getByText('这里的值只对本预设生效;未覆盖的字段继承"所有预设"的值。').waitFor({ timeout: 5_000 })
+
+    await dialog.getByText('终端', { exact: true }).click()
+    const timeout = dialog.getByLabel('命令超时(毫秒)')
+    await timeout.waitFor({ timeout: 10_000 })
+    expect(await timeout.inputValue()).toBe('60000')
+    await timeout.fill('12000')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+
+    const settings = (): Promise<string> => homeFile('settings.yaml')
+    await expect.poll(async () => (await settings()).includes('timeoutMs: 12000'), { timeout: 10_000 }).toBe(true)
+    const document = await settings()
+    expect(document).toContain('scopes:')
+    expect(document).toContain('preset/standard:')
+    expect(document.indexOf('scopes:')).toBeLessThan(document.indexOf('timeoutMs: 12000'))
+    // Back under the shared instance the field still shows the composed default.
+    await scope.click()
+    await page.getByRole('menuitem', { name: '所有预设' }).click()
+    await expect.poll(() => scope.getAttribute('data-settings-scope'), { timeout: 5_000 }).toBe('global')
+    const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' })
+    if (await expandTerminal.count() > 0) await expandTerminal.click()
+    await expect.poll(() => dialog.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('60000')
+    expect(await dialog.getByText('已覆盖').count()).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md'])
+  })
+})

+ 51 - 2
apps/web/tests/scaffold.ts

@@ -24,7 +24,7 @@
 // assertConsumed for the teardown fixture-consumption check).
 import { existsSync, readFileSync } from 'node:fs'
 import { createHash } from 'node:crypto'
-import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { basename, dirname, join, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -59,7 +59,11 @@ import {
   assertEntriesLoaded,
   composeEntries,
   healProfilesModuleFallback,
+  loadOptionalPatches,
   loadOverlayPatches,
+  loadProfile,
+  ProfileRuntime,
+  writeProfileManifest,
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
@@ -286,6 +290,18 @@ export interface LaunchOptions {
    * supply private profile layers named by {@link extraOverlayPath}.
    */
   extraInstallAnchors?: string[]
+  /**
+   * Mount a `profileRuntime` over the scaffold profile, so the plugin
+   * manager has a profile to manage. Each package directory is linked into
+   * the profile as an installed dependency (`file:` in its manifest, a
+   * symlink under its `node_modules`); `enabled` lists a bundle in
+   * `dsh.profile.bundles`. The profile applies layer changes at its next
+   * start, so an enable or disable is reported as pending and the booted tree
+   * never recomposes under the scenario.
+   */
+  profileRuntime?: {
+    packages: { dir: string; enabled?: boolean }[]
+  }
   /**
    * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
    * in replay/refresh modes; ignored in record mode (the real adapter
@@ -659,6 +675,23 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       },
     })
     await mkdir(profileDir, { recursive: true })
+    if (options.profileRuntime !== undefined) {
+      const dependencies: Record<string, string> = {}
+      const bundles: string[] = []
+      for (const entry of options.profileRuntime.packages) {
+        const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string }
+        dependencies[manifest.name] = `file:${entry.dir}`
+        if (entry.enabled === true) bundles.push(manifest.name)
+        const link = join(profileDir, 'node_modules', manifest.name)
+        await mkdir(dirname(link), { recursive: true })
+        await symlink(entry.dir, link, 'dir')
+      }
+      writeProfileManifest(profileDir, {
+        name: 'dsh-profile-scaffold',
+        dependencies,
+        dsh: { profile: { bundles, patchReload: 'startup' } },
+      })
+    }
     const rootConfig = join(profileDir, 'cordis.yml')
     await writeFile(rootConfig, '[]\n')
     ctx.baseUrl = pathToFileURL(profileDir).href + '/'
@@ -681,12 +714,28 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     // and a preset resolving package names from its own directory cannot reach
     // `@deepseek-ai/cordis-plugin-group` by name.
     ctx.loader.builtins.group = Group
-    await ctx.loader.create({
+    const rootIncludeId = await ctx.loader.create({
       name: 'cordis:include',
       config: { path: pathToFileURL(rootConfig).href, patches },
     })
     await ctx.loader.await()
     assertEntriesLoaded(ctx, 'web e2e scaffold')
+    if (options.profileRuntime !== undefined) {
+      // The launcher mounts the runtime once the tree is up; the scaffold
+      // profile is read from the harness home this boot pinned. Its
+      // composition stays the scaffold's own: the runtime only ever
+      // recomposes a live-reload profile, and this one applies at startup.
+      const readProfile = (): Profile => loadProfile('dsh', 'scaffold', INSTALL_ANCHOR, harnessHome)
+      const profile = readProfile()
+      await ctx.plugin(ProfileRuntime, {
+        profile,
+        installAnchor: INSTALL_ANCHOR,
+        loadProfile: readProfile,
+        compose: () => patches,
+        rootEntry: () => [...ctx.loader.entries()].find(entry => entry.id === rootIncludeId),
+        readUserPatches: () => loadOptionalPatches('dsh', profile.patchPath) ?? [],
+      })
+    }
     if (options.welcomeNoticePending !== true) {
       await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
         op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,

+ 1 - 0
apps/web/tsconfig.json

@@ -40,6 +40,7 @@
     "tests/lifecycle-chrome.e2e.ts",
     "tests/details-session-lifecycle.e2e.ts",
     "tests/plugin-config.e2e.ts",
+    "tests/plugin-manager.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
     "tests/default-model.e2e.ts",

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 1bc6497abbf1a7abf7b9aed26e512c88702ba1c8
-config-catalog.zh.md: ada410954aa2e30cf73dff1bf5dd96b9c7b27072
+config-catalog.md: e5f86328b6f684c04d3b42bb5b27100fb9326291
+config-catalog.zh.md: 9d75834183f719d08dd3c94e90ba9f9b236c1863

+ 1 - 0
docs/config-catalog.md

@@ -3394,6 +3394,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-models` ([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory` ([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings-plugin-manager` ([`packages/client/ui-settings-plugin-manager/src/index.ts`](../packages/client/ui-settings-plugin-manager/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3396,6 +3396,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-models`([`packages/client/ui-settings-models/src/index.ts`](../packages/client/ui-settings-models/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugin-inventory`([`packages/client/ui-settings-plugin-inventory/src/index.ts`](../packages/client/ui-settings-plugin-inventory/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-settings-plugin-manager`([`packages/client/ui-settings-plugin-manager/src/index.ts`](../packages/client/ui-settings-plugin-manager/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: a5106b0afc58a85a322247cfa41f696a561efdb6
-module-graph.zh.md: c740d26586ca371c07d97e88e4a2bb3cd25adaad
+module-graph.md: 3f0da75ac277814b41a1a25d4da5a7471090eade
+module-graph.zh.md: 9b268765291bdd8754ad2e30d638f83a006a1b4a

+ 2 - 0
docs/module-graph.md

@@ -164,6 +164,7 @@ flowchart TD
     pkg_client_ui_settings_general["client-ui-settings-general"]
     pkg_client_ui_settings_models["client-ui-settings-models"]
     pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
+    pkg_client_ui_settings_plugin_manager["client-ui-settings-plugin-manager"]
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
     pkg_client_ui_skill["client-ui-skill"]
@@ -1218,6 +1219,7 @@ flowchart TD
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — |
 | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
+| [`client-ui-settings-plugin-manager`](../packages/client/ui-settings-plugin-manager) | `client` | — |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |

+ 2 - 0
docs/module-graph.zh.md

@@ -166,6 +166,7 @@ flowchart TD
     pkg_client_ui_settings_general["client-ui-settings-general"]
     pkg_client_ui_settings_models["client-ui-settings-models"]
     pkg_client_ui_settings_plugin_inventory["client-ui-settings-plugin-inventory"]
+    pkg_client_ui_settings_plugin_manager["client-ui-settings-plugin-manager"]
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
     pkg_client_ui_skill["client-ui-skill"]
@@ -1220,6 +1221,7 @@ flowchart TD
 | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | — |
 | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | — |
 | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | — |
+| [`client-ui-settings-plugin-manager`](../packages/client/ui-settings-plugin-manager) | `client` | — |
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: df34d06554cf11fabd107e5b288329298653e710
-core.zh.md: 85234c6fca3a3584055fa36cfa17fe7aa30c680d
+core.md: 0f6dfdaa057f583534bad15f62829998fba5b5ce
+core.zh.md: d7ebd49b980c54ef4f6848017fea2db5ab076410

+ 1 - 1
docs/subsystems/core.md

@@ -888,7 +888,7 @@ Every method that changes the profile reads the manifest afresh and writes it th
  * or the run times out, `plugins/enable-failed` when enabling was asked
  * for and the tree rejected the bundle.
  */
-@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
+@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 
 /**
  * Remove a package from the profile: disable it when enabled, drop every

+ 1 - 1
docs/subsystems/core.zh.md

@@ -898,7 +898,7 @@ Every method that changes the profile reads the manifest afresh and writes it th
  * or the run times out, `plugins/enable-failed` when enabling was asked
  * for and the tree rejected the bundle.
  */
-@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
+@Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 
 /**
  * Remove a package from the profile: disable it when enabled, drop every

+ 2 - 2
docs/subsystems/settings.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/settings.md
-settings.md: ef06544b500ec21234ce3922d9d7b7a30c3ecc2e
-settings.zh.md: b5537332933043df60a62b4ebd8c39af985ebaad
+settings.md: 123b099e86e64c31028d7275409ade1b2905fb94
+settings.zh.md: eee382e60787381fd26110829f483465e226899c

+ 4 - 3
docs/subsystems/settings.md

@@ -112,8 +112,8 @@ interface SettingsDescriptor {
   scope?: SettingsScopeId
   /**
    * Whether an owner registered the namespace under this scope. False for a
-   * scope described from the kind alone — a preset no session composed yet —
-   * whose value then carries no composition `base`.
+   * scope no session composed yet, whose value carries no `base` of its own
+   * and resolves over the global instance's composition when one is registered.
    */
   registered: boolean
   /** Serialized schemastery schema (`schema.toJSON()`). */
@@ -173,7 +173,8 @@ interface SettingsDescribeOptions {
   /**
    * Describe every namespace kind under this named scope instead of the
    * global scope. A kind with no registration under the scope is described
-   * from the kind alone, `registered: false`.
+   * over the global instance's composition, else from the kind alone,
+   * `registered: false`.
    */
   scope?: string
 }

+ 4 - 3
docs/subsystems/settings.zh.md

@@ -112,8 +112,8 @@ interface SettingsDescriptor {
   scope?: SettingsScopeId
   /**
    * Whether an owner registered the namespace under this scope. False for a
-   * scope described from the kind alone — a preset no session composed yet —
-   * whose value then carries no composition `base`.
+   * scope no session composed yet, whose value carries no `base` of its own
+   * and resolves over the global instance's composition when one is registered.
    */
   registered: boolean
   /** Serialized schemastery schema (`schema.toJSON()`). */
@@ -173,7 +173,8 @@ interface SettingsDescribeOptions {
   /**
    * Describe every namespace kind under this named scope instead of the
    * global scope. A kind with no registration under the scope is described
-   * from the kind alone, `registered: false`.
+   * over the global instance's composition, else from the kind alone,
+   * `registered: false`.
    */
   scope?: string
 }

+ 2 - 2
packages/api/gateway/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/gateway/README.md
-README.md: 019aedfa1b900fd1e52744e91789b22c59d994e9
-README.zh.md: 5cf91d3f6c4f86333201209aabac9b0f9a58d703
+README.md: c422510c70d7444b0be2c968e55210121f3724d9
+README.zh.md: 77de51c8f979fef9be1302e8f794d545165f76d8

+ 1 - 1
packages/api/gateway/README.md

@@ -24,7 +24,7 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host
 <a id="host-service-typertgatewayservice-ctx-key-typertgateway"></a>
 ## Host service: `TypertGatewayService` (ctx key: `typertGateway`)
 
-`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance.
+`ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. On the Client, a call names every business argument in order, except that trailing parameters declared `T | undefined` — an optional parameter is one — may be left off, as the generated signature allows; an omitted parameter sends no wire field. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance.
 
 Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation.
 

+ 1 - 1
packages/api/gateway/README.zh.md

@@ -24,7 +24,7 @@ kind: "package-reference"
 <a id="host-service-typertgatewayservice-ctx-key-typertgateway"></a>
 ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)
 
-每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。
+每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。在 Client 侧,调用按顺序给出每个业务参数,只有声明为 `T | undefined` 的尾随参数——可选参数就是这样一种——可以按生成的签名省略;省略的参数不发送 wire 字段。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。
 
 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。
 

+ 12 - 4
packages/api/gateway/src/client/index.ts

@@ -481,12 +481,20 @@ class ClientRemoteService extends Service implements ClientRemote {
     boundIdentity?: BoundContextIdentity,
   ): PreparedClientInvocation {
     const endpoint = endpointOf(descriptor)
-    const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
+    const business = descriptor.parameters.filter((_parameter, index) => index !== projection?.parameterIndex)
+    const expected = business.length
+    // A trailing parameter declared `T | undefined` (an optional parameter is
+    // one) may be left off the call, as its generated signature allows; its
+    // wire field is then absent. A caller supplying the AbortSignal names
+    // every business argument first.
+    let required = expected
+    while (required > 0 && business[required - 1]?.acceptsUndefined === true) required -= 1
     const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
-    if (values.length !== expected && !hasCallerSignal) {
+    if ((values.length < required || values.length > expected) && !hasCallerSignal) {
+      const count = required === expected ? String(expected) : `${String(required)} to ${String(expected)}`
       const contract = descriptor.cancellation === undefined
-        ? `${String(expected)} argument(s)`
-        : `${String(expected)} business argument(s) plus an optional AbortSignal`
+        ? `${count} argument(s)`
+        : `${count} business argument(s) plus an optional AbortSignal`
       throw new Error(
         `client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
       )

+ 54 - 0
packages/api/gateway/tests/gateway.client.spec.ts

@@ -10,6 +10,7 @@ import {
   type ConnectionHandle,
 } from '@deepseek-ai/dsh-client-connection/client'
 import type {
+  InvocationParameterDescriptor,
   InvocationDescriptor,
   RemoteResult,
   TypertContextMap,
@@ -1111,6 +1112,59 @@ describe('Client Typert API', () => {
     })).rejects.toThrow('scope must select its only lookup parameter')
   })
 
+  it('lets a caller omit trailing parameters that accept undefined, as their generated signature allows', async () => {
+    const call = vi.fn<ConnectionHandle['rpc']['call']>()
+      .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
+    const ctx = await bench(call)
+    const optionalTail: InvocationDescriptor = {
+      id: '@fixture/probe#probe/describe',
+      service: 'probe',
+      namespace: 'probe',
+      method: 'describe',
+      invocation: { kind: 'direct' },
+      parameters: [{
+        name: 'request',
+        wire: 'request',
+        source: 'json',
+        codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
+      }, {
+        name: 'scope',
+        wire: 'scope',
+        source: 'json',
+        acceptsUndefined: true,
+        codec: { mode: 'strict', typeSymbol: '@fixture#Scope', schema: z.union([z.undefined(), z.string()]) },
+      }],
+      result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
+    }
+    // A leading parameter accepting undefined earns no omission: the required one after it still counts.
+    const optionalHead: InvocationDescriptor = {
+      ...optionalTail,
+      id: '@fixture/probe#probe/inspect',
+      method: 'inspect',
+      parameters: [
+        optionalTail.parameters[1] as InvocationParameterDescriptor,
+        optionalTail.parameters[0] as InvocationParameterDescriptor,
+      ],
+    }
+    await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [optionalTail, optionalHead] })
+    const probe = ctx.remote.probe as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>
+    const describeCall = probe.describe as (...args: unknown[]) => Promise<unknown>
+    const inspect = probe.inspect as (...args: unknown[]) => Promise<unknown>
+
+    await describeCall({ objective: 'ship' })
+    expect((call.mock.calls[0]?.[2] as { args: Record<string, unknown> }).args).toEqual({ request: { objective: 'ship' } })
+    await describeCall({ objective: 'ship' }, undefined)
+    expect((call.mock.calls[1]?.[2] as { args: Record<string, unknown> }).args).toEqual({ request: { objective: 'ship' } })
+    await describeCall({ objective: 'ship' }, 'preset/standard')
+    expect((call.mock.calls[2]?.[2] as { args: Record<string, unknown> }).args)
+      .toEqual({ request: { objective: 'ship' }, scope: 'preset/standard' })
+    await expect(describeCall()).rejects.toThrow('expected 1 to 2 argument(s), got 0')
+    await expect(describeCall({ objective: 'ship' }, 'preset/standard', 'extra')).rejects.toThrow('got 3')
+    await expect(inspect(undefined)).rejects.toThrow('expected 2 argument(s), got 1')
+    await inspect(undefined, { objective: 'ship' })
+    expect((call.mock.calls[3]?.[2] as { args: Record<string, unknown> }).args).toEqual({ request: { objective: 'ship' } })
+  })
+
   it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>()
       .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })

+ 49 - 0
packages/api/remotes/tests/remote-method-names.host.spec.ts

@@ -0,0 +1,49 @@
+/**
+ * A Remote method is reached on the browser as `ctx.remote.<namespace>.<method>`,
+ * a member of the namespace service the client gateway mounts. The mount
+ * refuses a method named after one of that service's own members — its
+ * fields and its private `install`/`remove` helpers — and it refuses at page
+ * load, after every unit suite passed. This spec reads the reserved names
+ * off the gateway's own source and checks every `@Remote('<name>')` in the
+ * workspace against them, so the collision fails here instead.
+ */
+
+import { readFileSync } from 'node:fs'
+import { globSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
+
+/** The names the client namespace service keeps for itself. */
+function reservedMethodNames(): Set<string> {
+  const source = readFileSync(join(ROOT, 'packages/api/gateway/src/client/index.ts'), 'utf8')
+  const fields = /const REMOTE_NAMESPACE_FIELDS = new Set\(\[([^\]]*)\]\)/.exec(source)?.[1]
+  const classBody = /class RemoteNamespaceService extends Service \{([\s\S]*?)\n\}/.exec(source)?.[1]
+  if (fields === undefined || classBody === undefined) {
+    throw new Error('the client gateway no longer spells its namespace service the way this spec reads it')
+  }
+  const reserved = new Set([...fields.matchAll(/'([^']+)'/g)].map(match => match[1] as string))
+  for (const match of classBody.matchAll(/^ {2}(?:private |static |readonly |get |async )*([A-Za-z_$][\w$]*)\s*[(:=]/gm)) {
+    reserved.add(match[1] as string)
+  }
+  return reserved
+}
+
+describe('Remote method names', () => {
+  it('never name a member of the client namespace service', () => {
+    const reserved = reservedMethodNames()
+    expect(reserved.has('install')).toBe(true)
+    expect(reserved.has('remove')).toBe(true)
+    const offenders: string[] = []
+    for (const file of globSync('packages/*/*/src/**/*.ts', { cwd: ROOT })) {
+      const source = readFileSync(join(ROOT, file), 'utf8')
+      for (const match of source.matchAll(/@Remote\(\s*'([^']+)'/g)) {
+        const method = match[1] as string
+        if (reserved.has(method)) offenders.push(`${file}: @Remote('${method}')`)
+      }
+    }
+    expect(offenders).toEqual([])
+  })
+})

+ 5 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -211,6 +211,11 @@
     - id: ui-settings-plugin-inventory
       name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
 
+    # Plugin management: install, enable, disable, retry, and compose the
+    # profile's packages. Without a profile runtime it reports itself unavailable.
+    - id: ui-settings-plugin-manager
+      name: '@deepseek-ai/dsh-client-ui-settings-plugin-manager'
+
     - id: ui-conversation
       name: '@deepseek-ai/dsh-client-ui-conversation'
 

+ 1 - 0
packages/bundle/web-app/package.json

@@ -65,6 +65,7 @@
     "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings-plugin-manager": "workspace:^",
     "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^",
     "@deepseek-ai/dsh-client-ui-plan": "workspace:^",
     "@deepseek-ai/dsh-client-ui-schedule": "workspace:^",

+ 2 - 2
packages/client/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: 2358d94f6db0455aaf8fce094a616a273d4b3bab
-README.zh.md: 4fbeacf33dc609102a78418c3679a43f06914bdf
+README.md: 2f15a9b1502b3a85db330b41539c1fd5fd8fa416
+README.zh.md: 3df90cb489cea4af440fbec5a9e129ccff7a09f7

+ 1 - 0
packages/client/README.md

@@ -67,6 +67,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — |
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
+| [`ui-settings-plugin-manager/`](ui-settings-plugin-manager/README.md) | Contributes the plugin management tab to Plugins settings: install, enable, disable, retry, and compose packages | — |
 | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |

+ 1 - 0
packages/client/README.zh.md

@@ -67,6 +67,7 @@ kind: "package-group"
 | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — |
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — |
+| [`ui-settings-plugin-manager/`](ui-settings-plugin-manager/README.zh.md) | 向“插件”设置贡献插件管理标签页:安装、启用、停用、重试与组合包 | — |
 | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |

+ 2 - 2
packages/client/ui-settings-plugin-inventory/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md
-README.md: a9e4108848794bf9168c513a4223dd6769601972
-README.zh.md: ccca8826387f7a97978190eec3175df1b3122a55
+README.md: 5735a243eb2e94439c4ae2ef72c887f69af57cde
+README.zh.md: 07b11784489cde73c479c07d7d3b4cd743985232

+ 1 - 1
packages/client/ui-settings-plugin-inventory/README.md

@@ -29,7 +29,7 @@ Open the Plugins section in Settings and select the **Plugin list** tab to inspe
 
 ### Reading a card
 
-Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.
+Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, whether the preset's composition or the user's patch layer inserted it, who switched it off when it is disabled, its runtime status when the composition is live, and its disable condition when it carries one; a disabled global row names who switched it off; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-presets/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset files' Chinese names. Search filters both groups by module name and entry id.
 
 ### The preset switcher
 

+ 1 - 1
packages/client/ui-settings-plugin-inventory/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 阅读卡片
 
-每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
+每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、是预设组合还是用户补丁层插入了它、被停用时是谁停用的、组合存活时的运行状态,以及它携带的禁用条件;被停用的全局行说明是谁停用的;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
 
 ### 预设切换器
 

+ 14 - 1
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -51,6 +51,12 @@ function phaseLabel(phase: PluginFiberPhase, t: Translate): string {
   return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
 }
 
+/** The fact naming who switched a row off, when something did. */
+function disabledByFact(disabledBy: 'user' | 'composition' | undefined, t: Translate): (readonly [string, ReactNode])[] {
+  if (disabledBy === undefined) return []
+  return [[t('disabledByLabel'), t(disabledBy === 'user' ? 'disabledByUser' : 'disabledByComposition')] as const]
+}
+
 /** Compact a module specifier without guessing whether its Loader id was generated. */
 function moduleShortName(moduleName: string): string {
   const unscoped = moduleName.startsWith('@') ? moduleName.slice(moduleName.indexOf('/') + 1) : moduleName
@@ -81,10 +87,12 @@ function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset
 }
 
 /** One expandable plugin card; the caller owns the trailing status content. */
-function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: {
+function PluginCard({ rowKey, moduleName, entryId, source, trailing, ariaLabel, failed, expanded, onToggle, children }: {
   readonly rowKey: string
   readonly moduleName: string
   readonly entryId: string | null
+  /** Which layer inserted a preset row: its composition, or the user's patch file. */
+  readonly source?: 'preset' | 'user'
   readonly trailing: ReactNode
   readonly ariaLabel: string
   readonly failed: boolean
@@ -99,6 +107,7 @@ function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed,
       className={css.card}
       data-plugin-entry={entryId ?? undefined}
       data-plugin-module={moduleName}
+      data-plugin-source={source}
       data-failed={failed ? 'true' : undefined}
       data-open={open ? 'true' : undefined}
     >
@@ -255,6 +264,7 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent
         rowKey={key}
         moduleName={row.moduleName}
         entryId={row.entryId}
+        source={row.source}
         failed={failed}
         expanded={expanded}
         onToggle={toggleRow}
@@ -274,7 +284,9 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent
           entryId={row.entryId}
           facts={[
             [t('fromPreset'), presetName(preset)],
+            [t('sourceLabel'), t(row.source === 'user' ? 'sourceUser' : 'sourcePreset')],
             [t('configuration'), stateText],
+            ...disabledByFact(row.disabledBy, t),
             ...row.fiberPhase === null ? [] : [[t('runtime'), phaseLabel(row.fiberPhase, t)] as const],
             ...row.condition === undefined ? [] : [[t('condition'), <code key="condition">{row.condition}</code>] as const],
           ]}
@@ -336,6 +348,7 @@ export function PluginInventorySettingsTab({ list, presetName, t }: PluginInvent
             ]
             : [
               [t('configuration'), t(entry.enabled ? 'enabledTag' : 'disabledTag')],
+              ...disabledByFact(entry.disabledBy, t),
               ...entry.enabled ? [[t('runtime'), phaseLabel(entry.fiberPhase, t)] as const] : [],
             ]}
         />

+ 12 - 0
packages/client/ui-settings-plugin-inventory/src/client/locales.ts

@@ -29,6 +29,12 @@ export const zh = {
   failedTag: '启动失败',
   moduleLabel: '完整名称',
   fromPreset: '来自',
+  sourceLabel: '出处',
+  sourcePreset: '预设自带',
+  sourceUser: '用户添加',
+  disabledByLabel: '停用方',
+  disabledByUser: '用户在设置中停用',
+  disabledByComposition: '组合文件停用',
   condition: '禁用条件',
   configuration: '配置状态',
   runtime: '运行状态',
@@ -72,6 +78,12 @@ export const en = {
   failedTag: 'Failed',
   moduleLabel: 'Module',
   fromPreset: 'From',
+  sourceLabel: 'Source',
+  sourcePreset: 'Preset composition',
+  sourceUser: 'Added by the user',
+  disabledByLabel: 'Disabled by',
+  disabledByUser: 'The user, in settings',
+  disabledByComposition: 'The composition file',
   condition: 'Disabled when',
   configuration: 'Configuration',
   runtime: 'Status',

+ 32 - 0
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -278,6 +278,38 @@ describe('PluginInventorySettingsTab', () => {
     expect(screen.queryAllByRole('listitem')).toHaveLength(0)
   })
 
+  it('names where a preset row came from and who switched a row off', async () => {
+    render(<PluginInventorySettingsTab {...props(async () => ({
+      entries: [
+        { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null, disabledBy: 'composition' },
+      ],
+      agentPresets: [{
+        id: 'standard',
+        trust: 'system',
+        isDefault: true,
+        rows: [
+          { entryId: 'extra', moduleName: '@fixture/extra', enabled: false, fiberPhase: null, source: 'user', disabledBy: 'user' },
+          { entryId: 'own', moduleName: '@fixture/own', enabled: true, fiberPhase: null, source: 'preset' },
+        ],
+      }],
+    } as unknown as Snapshot))} />)
+    await screen.findByText(en.presetSubtitle)
+
+    const extra = document.querySelector('[data-plugin-entry="extra"]')
+    expect(extra?.getAttribute('data-plugin-source')).toBe('user')
+    fireEvent.click(screen.getByRole('button', { name: 'extra, Disabled' }))
+    expect(screen.getByText(en.sourceLabel).nextElementSibling?.textContent).toBe(en.sourceUser)
+    expect(screen.getByText(en.disabledByLabel).nextElementSibling?.textContent).toBe(en.disabledByUser)
+
+    fireEvent.click(screen.getByRole('button', { name: 'own, Enabled' }))
+    expect(screen.getByText(en.sourceLabel).nextElementSibling?.textContent).toBe(en.sourcePreset)
+    expect(screen.queryByText(en.disabledByLabel)).toBeNull()
+
+    fireEvent.click(globalToggle())
+    fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' }))
+    expect(screen.getByText(en.disabledByLabel).nextElementSibling?.textContent).toBe(en.disabledByComposition)
+  })
+
   it('renders a rosterless deployment as one expanded global list', async () => {
     const view = await renderReady({
       entries: [

+ 6 - 0
packages/client/ui-settings-plugin-manager/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-manager/README.md
+README.md: 07e7378a6b34538bd24db3e52ee67cd383c2bb29
+README.zh.md: 05c790300315d7c0bc3dd769324a9a82f49999a8

+ 112 - 0
packages/client/ui-settings-plugin-manager/README.md

@@ -0,0 +1,112 @@
+---
+description: "Plugin management tab in Web Plugins settings for the dsh web client: install packages through pnpm, enable and disable bundles, retry failed ones, and compose rows into the global user layer or one agent preset's."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-plugin-manager
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-client-ui-settings-plugin-manager` contributes the **Manage plugins** tab to the Web Settings Plugins section. The tab reads the profile's packages through `ctx.remote.plugins.list()` and the preset compositions through `ctx.remote.pluginInventory.list()` the first time it is selected, and re-reads after every action and every forwarded `plugins/changed` event, so a change made from the CLI or another browser shows without a manual refresh. The global group lists one card per package with its trust, kind, stage, and status tags; a bundle carries a switch that puts it into or takes it out of the profile's layer list, and an expanded card shows the rows it contributes with their phase and failure, the built-in rows it overrides, the modules it declares addable — each with an **Add to…** menu naming the global layer and every preset — and the retry and uninstall actions the package offers. The session group shows the selected preset's composition with a switch per row and a remove action for rows the user added. The install dialog takes an npm spec, a local path, or a git URL, streams pnpm's output as `plugins/install-log` chunks arrive, and reports what the run added. A disable with dependents and every uninstall wait for an acknowledged confirmation that lists the services other rows inject and the user-layer rows naming the package. Without a profile runtime the tab says it is unavailable and offers nothing.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Open the Plugins section in Settings and select the **Manage plugins** tab. The tab reads no Remote during plugin activation — selecting it mounts the component, which then reads the packages and the preset compositions through `api-remotes`.
+
+### Installing a package
+
+**Add plugin** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a git URL — and choose whether a newly installed bundle is enabled right away. The dialog streams the run's output and, once pnpm exits, names the dependencies the run added; a non-zero exit keeps the output for reading. The dialog cannot be closed while the run is in flight.
+
+### Switching a bundle
+
+A bundle's switch calls `plugins.enable` or `plugins.disable`. On a live-reload profile the tree recomposes before the switch settles; a profile that applies layer changes at its next start reports the change as pending, and the tab names every such package in a banner until the restart. A built-in bundle's switch is locked; a bundle the probe refused cannot be switched on and shows the probe's reason. A disable first asks the Host what it would strand and skips the confirmation when nothing does.
+
+### Composing rows
+
+An expanded card lists the modules the package declares addable; **Add to…** writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. The session group's preset switcher shows one preset's composition: a row's switch writes `disabled: true` into that preset's user patch layer or removes the key again, and a row the user added can be removed. Rows that declare no id cannot be switched here.
+
+### Reading a failure
+
+The last action's outcome sits above the groups: a restart notice, a done notice, or the failure with the Host's own reason — a probe refusal, a rejected recomposition, a row id already taken. Dismiss it or let the next action replace it.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+### Registration
+
+The browser plugin registers one localized `settings.plugins.tab` contribution with id `manage` and order 5, between the configuration tab and the read-only list. Registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner. Preset names resolve through the shared `presetDisplayText` fold over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries, as the inventory tab does.
+
+### The store
+
+`PluginManagerController` holds one snapshot: the read status, the packages, the preset groups, the busy keys, the notice, the install dialog, and the pending confirmation. `load` folds concurrent reads into one in-flight read plus one rerun, so an invalidation landing mid-read is never lost. Every action runs under a busy key — the package name, or `<target>:<rowId>` for a row — turns a refused answer into the notice with the Host's code and reason, and re-reads afterwards whatever happened. The plugin's `apply` subscribes `plugins/changed` and `connection/reset` to reload a tab that has rendered once, and `plugins/install-log` to fold chunks whose spec matches the open run.
+
+### Confirmation
+
+`disable` and `uninstall` open the confirmation and ask `plugins.dependents`; the answer for a confirmation that has since changed is dropped. A disable with an empty answer confirms itself. The action the confirmation guards is captured when it opens and runs only through **Continue**.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+These pages cover the settings section, the Remote calls, and the Host-side manager.
+
+- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Plugins section this tab registers into.
+- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only list beside this tab.
+- [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `plugins.*` and `pluginInventory.list()`.
+- [plugin-manager](../../host/plugin-manager/README.md) — the Host-side manager this tab drives.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the package is a browser-side management surface that registers nothing model-facing.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define the reach of the management view; they are current package constraints.
+
+- **Global user-layer rows are not listed per package** — a row added to the global layer shows in the read-only list and in the package's dependents, not on the package card; removing it goes through the patch file or a future row list.
+- **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish.
+- **No version picker** — the spec is typed as pnpm accepts it; the tab neither lists registry versions nor offers upgrades.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. This package owns a Settings contribution over Host-owned facts.

+ 112 - 0
packages/client/ui-settings-plugin-manager/README.zh.md

@@ -0,0 +1,112 @@
+---
+description: "dsh Web 客户端设置里的插件管理标签页:经 pnpm 安装包、启停 bundle、重试失败的 bundle,并把行组合进全局用户层或某个 Agent 预设。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-plugin-manager
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-client-ui-settings-plugin-manager` 向 Web 设置的「插件」分区贡献**插件管理**标签页。该标签页在首次被选择时通过 `ctx.remote.plugins.list()` 读取 profile 的包、通过 `ctx.remote.pluginInventory.list()` 读取预设组合,并在每次操作之后、每个转发的 `plugins/changed` 事件之后重新读取,因此从 CLI 或另一个浏览器做出的改动无需手动刷新即可显示。全局组为每个包列出一张卡片,带信任、种类、阶段与状态标签;bundle 携带一个开关,把它放入或移出 profile 的层列表;展开的卡片显示它贡献的行及各行的阶段与失败、它覆盖的内置行、它声明可添加的模块——每个模块带一个列出全局层与每个预设的**添加到…**菜单——以及该包提供的重试与卸载操作。会话组显示选中预设的组合,每行一个开关,用户添加的行还有移除操作。安装对话框接受 npm 包名、本地路径或 git 地址,随 `plugins/install-log` 块到达流式显示 pnpm 输出,并报告这次运行新增了什么。有依赖方的停用与每次卸载都要等待一次已勾选确认,确认框列出其他行注入的服务与引用该包的用户层行。没有 profile runtime 时标签页声明自己不可用且不提供任何操作。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+打开设置中的「插件」分区并选择**插件管理**标签页。插件激活期间不会读取 Remote——选择该标签页时才挂载组件,组件再通过 `api-remotes` 读取包与预设组合。
+
+### 安装一个包
+
+**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、git 地址——并选择新装的 bundle 是否立即启用。对话框流式显示运行输出,pnpm 退出后列出这次运行新增的依赖;非零退出会保留输出供阅读。运行进行中对话框不能关闭。
+
+### 切换一个 bundle
+
+bundle 的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为待生效,标签页会在横幅里点名每个这样的包直到重启。内置 bundle 的开关被锁定;探针拒绝的 bundle 无法打开并显示探针的原因。停用会先询问宿主它会搁置什么,没有依赖方时跳过确认。
+
+### 组合行
+
+展开的卡片列出该包声明可添加的模块;**添加到…**通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。会话组的预设切换器显示某个预设的组合:行的开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键,用户添加的行可以移除。没有声明 id 的行不能在这里切换。
+
+### 阅读失败
+
+上一次操作的结果停在两组之上:重启提示、完成提示,或带宿主原因的失败——探针拒绝、被拒的重新组合、已被占用的行 id。可以关掉它,也可以让下一次操作替换它。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+### 注册
+
+浏览器插件注册一个 id 为 `manage`、order 为 5 的本地化 `settings.plugins.tab` 贡献,位于配置标签页与只读列表之间。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。预设名与清单标签页一样,经共享的 `presetDisplayText` 纯函数叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析。
+
+### store
+
+`PluginManagerController` 持有一份快照:读取状态、包、预设组、忙碌键、提示、安装对话框与待确认项。`load` 把并发读取折叠成一次在途读取加一次重跑,因此读取中途到达的失效不会丢失。每个操作在一个忙碌键下运行——包名,或行的 `<target>:<rowId>`——把被拒的应答转成带宿主代码与原因的提示,并且无论结果如何随后重新读取。插件的 `apply` 订阅 `plugins/changed` 与 `connection/reset` 以重载渲染过的标签页,订阅 `plugins/install-log` 以折叠 spec 与打开的运行匹配的块。
+
+### 确认
+
+`disable` 与 `uninstall` 打开确认框并询问 `plugins.dependents`;确认框此后已变化时丢弃这份应答。空应答的停用自行确认。确认框守护的操作在打开时捕获,只经**继续**运行。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+这些页面覆盖设置分区、Remote 调用与宿主侧管理器。
+
+- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——本标签页注册进的「插件」分区。
+- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——本标签页旁边的只读列表。
+- [api-remotes](../../api/remotes/README.zh.md)——`plugins.*` 与 `pluginInventory.list()` 背后的 Remote BFF 表面。
+- [plugin-manager](../../host/plugin-manager/README.zh.md)——本标签页驱动的宿主侧管理器。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,本包是浏览器侧的管理表面,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送 provider 请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制界定管理视图的范围;它们是本包当前的约束。
+
+- **全局用户层的行不按包列出**——添加到全局层的行出现在只读列表与该包的依赖方里,而不在包卡片上;移除它要走补丁文件或将来的行列表。
+- **一次只能安装一个**——对话框运行一条 pnpm 命令;第二个 spec 要等第一个结束。
+- **没有版本选择器**——spec 按 pnpm 接受的写法输入;标签页既不列出仓库版本也不提供升级。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变量:** 不发布 companion。本包拥有一个基于宿主事实的设置贡献。

+ 73 - 0
packages/client/ui-settings-plugin-manager/package.json

@@ -0,0 +1,73 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-settings-plugin-manager",
+  "description": "Plugin management tab in Web Plugins settings: install, enable, disable, retry, and compose installed plugin packages",
+  "version": "0.1.2-rc.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-settings-plugin-manager"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-ui-settings",
+        "@deepseek-ai/dsh-client-ui-settings-plugins",
+        "@deepseek-ai/dsh-client-locale",
+        "@deepseek-ai/dsh-client-ui-agent-preset"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-host-plugin-manager": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "clsx": "^2.1.1",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 484 - 0
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.module.css

@@ -0,0 +1,484 @@
+/* Plugin manager tab: toolbar, notices, the package cards, and the preset composition. */
+
+.section {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  width: 100%;
+  max-width: 760px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 8px;
+}
+
+.status,
+.failure p,
+.empty {
+  margin: 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.failure {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.banner {
+  margin: 0;
+  padding: 8px 12px;
+  border-radius: 10px;
+  font-size: 12px;
+  line-height: 18px;
+  background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent);
+  color: var(--dsw-alias-label-primary);
+}
+
+.notice {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin: 0;
+  padding: 8px 12px;
+  border-radius: 10px;
+  font-size: 12px;
+  line-height: 18px;
+  background: var(--dsw-alias-bg-layer-1);
+  color: var(--dsw-alias-label-secondary);
+}
+
+.notice[data-kind='failed'] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.notice span {
+  flex: 1;
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.group {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.groupTitleRow {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.groupTitle {
+  margin: 0;
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 600;
+}
+
+.groupSub {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.switcher {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  max-width: 320px;
+  height: 30px;
+  padding: 0 10px 0 12px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 999px;
+  corner-shape: round;
+  background: var(--dsw-alias-bg-layer-3);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 13px;
+  cursor: pointer;
+}
+
+.switcher:focus-visible,
+.linkButton:focus-visible,
+.switch:focus-visible,
+.iconButton:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+.switcherLabel {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.chevron {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.cards {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.card {
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 14px;
+  background: var(--dsw-alias-bg-layer-3);
+}
+
+.card[data-open='true'] {
+  background: var(--dsw-alias-bg-layer-2);
+  border-color: var(--dsw-alias-label-dimmed);
+}
+
+.cardHead {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 12px 14px;
+}
+
+.cardMain {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+
+.cardTitleRow {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.cardTitle {
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 600;
+}
+
+.cardName {
+  font-size: 12px;
+  line-height: 16px;
+  color: var(--dsw-alias-label-tertiary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.cardDescription {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.tag {
+  display: inline-flex;
+  align-items: center;
+  min-height: 18px;
+  border-radius: 5px;
+  padding: 0 6px;
+  background: var(--dsw-alias-bg-layer-1);
+  color: var(--dsw-alias-label-secondary);
+  font-size: 11px;
+  line-height: 16px;
+  white-space: nowrap;
+}
+
+.tag[data-kind='running'],
+.tag[data-kind='enabled'] {
+  background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);
+  color: var(--dsw-alias-state-success-primary);
+}
+
+.tag[data-kind='failed'],
+.tag[data-kind='not-enableable'] {
+  background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.tag[data-kind='partial'],
+.tag[data-kind='restart-required'] {
+  background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent);
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.cardEnd {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  gap: 8px;
+}
+
+.switch {
+  box-sizing: border-box;
+  position: relative;
+  flex: 0 0 auto;
+  width: 36px;
+  height: 20px;
+  padding: 2px;
+  border: 0;
+  border-radius: 10px;
+  background: var(--dsw-alias-border-l3);
+  cursor: pointer;
+}
+
+.switchOn {
+  background: var(--dsw-alias-brand-primary);
+}
+
+.switch:disabled {
+  cursor: default;
+  opacity: 0.5;
+}
+
+.thumb {
+  display: block;
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  corner-shape: round;
+  background: var(--dsw-alias-label-primary-foreground);
+  transition: transform 120ms ease;
+}
+
+.switchOn .thumb {
+  transform: translateX(16px);
+}
+
+.iconButton {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: 0;
+  border-radius: 8px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.iconButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-primary);
+}
+
+.chevronOpen {
+  transform: rotate(180deg);
+}
+
+.cardBody {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin: 0 14px;
+  padding: 10px 0 12px;
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}
+
+.facts {
+  display: grid;
+  grid-template-columns: max-content minmax(0, 1fr);
+  column-gap: 12px;
+  row-gap: 4px;
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.facts dt {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.facts dd {
+  margin: 0;
+  min-width: 0;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-primary);
+}
+
+.reason {
+  margin: 0;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-state-error-primary);
+  overflow-wrap: anywhere;
+}
+
+.rows {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 28px;
+  padding: 2px 8px;
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-1);
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.rowId {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-family: var(--dsw-font-mono, monospace);
+}
+
+.rowModule {
+  color: var(--dsw-alias-label-tertiary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.rowFailure {
+  flex-basis: 100%;
+  margin: 0;
+  font-size: 12px;
+  color: var(--dsw-alias-state-error-primary);
+  overflow-wrap: anywhere;
+}
+
+.statusDot {
+  display: inline-block;
+  width: 7px;
+  height: 7px;
+  flex: none;
+  border-radius: 999px;
+  corner-shape: round;
+  background: var(--dsw-alias-label-tertiary);
+}
+
+.statusDot[data-phase='active'] {
+  background: var(--dsw-alias-state-success-primary);
+}
+
+.statusDot[data-phase='failed'] {
+  background: var(--dsw-alias-state-error-primary);
+}
+
+.statusDot[data-phase='loading'],
+.statusDot[data-phase='pending'] {
+  background: var(--dsw-alias-state-business-primary);
+}
+
+.actions {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+}
+
+.linkButton {
+  border: 0.5px solid var(--dsw-alias-border-l3);
+  border-radius: 8px;
+  padding: 4px 10px;
+  background: transparent;
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 12px;
+  line-height: 18px;
+  cursor: pointer;
+}
+
+.linkButton:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.linkButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.linkButton[data-danger='true'] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.installBody {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  min-width: min(520px, 80vw);
+}
+
+.installField {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  font-size: 13px;
+}
+
+.installField input[type='text'] {
+  height: 34px;
+  padding: 0 12px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-3);
+  font: inherit;
+  font-size: 13px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.installOption {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+}
+
+/* The log scrolls on the dialog's elevated surface: rebind the bar to the elevation pair. */
+.log {
+  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
+  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
+  max-height: 240px;
+  margin: 0;
+  padding: 10px 12px;
+  overflow: auto;
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-1);
+  font-family: var(--dsw-font-mono, monospace);
+  font-size: 12px;
+  line-height: 18px;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
+
+.dependents {
+  margin: 8px 0 0;
+  padding-left: 18px;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+}

+ 615 - 0
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -0,0 +1,615 @@
+/**
+ * The Manage plugins tab: the profile's packages as cards with their switch,
+ * rows, and actions; the selected agent preset's composition with per-row
+ * switches; the install dialog streaming pnpm's output; and the confirmation
+ * a destructive action waits on, listing what it would strand.
+ */
+
+import { useEffect, useState, type ReactNode } from 'react'
+import clsx from 'clsx'
+import type { PluginPackageView, PluginRowTarget } from '@deepseek-ai/dsh-api-remotes/client'
+import {
+  Button, IconChevronDownOutline14, Menu, Modal,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PluginManagerLocaleKey } from './locales.ts'
+import type {
+  ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PresetGroup, PresetRow,
+} from './manager-store.ts'
+import { rowKey } from './manager-store.ts'
+import css from './PluginManagerSettingsTab.module.css'
+
+/** Full component props assembled by the Settings slot renderer. */
+export type PluginManagerSettingsTabProps =
+  PropsRuntime<'settings.plugins.tab'>
+  & PropsLocale<'settings.pluginManager'>
+  & InjectFace<PluginManagerFace>
+
+type Translate = PluginManagerSettingsTabProps['t']
+type RowView = PluginPackageView['rows'][number]
+type RowPhase = NonNullable<RowView['phase']>
+
+const PHASE_KEYS = {
+  pending: 'rowPhasePending',
+  loading: 'rowPhaseLoading',
+  active: 'rowPhaseActive',
+  failed: 'rowPhaseFailed',
+  unloading: 'rowPhaseUnloading',
+} satisfies Record<RowPhase, PluginManagerLocaleKey>
+
+const STATUS_KEYS = {
+  'running': 'statusRunning',
+  'partial': 'statusPartial',
+  'failed': 'statusFailed',
+  'disabled': 'statusDisabled',
+  'not-enableable': 'statusNotEnableable',
+  'restart-required': 'statusRestartRequired',
+  'plain': 'statusPlain',
+} satisfies Record<PluginPackageView['status'], PluginManagerLocaleKey>
+
+const KIND_KEYS = {
+  bundle: 'bundleTag',
+  plugin: 'pluginTag',
+  library: 'libraryTag',
+} satisfies Record<PluginPackageView['kind'], PluginManagerLocaleKey>
+
+/** Compact a package name to what a person calls it. */
+function shortName(name: string): string {
+  const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
+  return unscoped.replace(/^dsh-(?:host-|client-)?/, '')
+}
+
+/** The roster row shown when the switcher has no explicit choice. */
+function fallbackPreset(presets: readonly PresetGroup[]): PresetGroup | undefined {
+  return presets.find(preset => preset.isDefault) ?? presets[0]
+}
+
+function presetLabel(preset: PresetGroup, t: Translate, presetName: (preset: PresetGroup) => string): string {
+  const name = presetName(preset)
+  if (preset.broken !== undefined) return t('presetOptionBroken', { name })
+  if (preset.isDefault) return t('presetOptionDefault', { name })
+  return name
+}
+
+/** Status dot naming a live root-fiber phase. */
+function PhaseDot({ phase, t }: { readonly phase: RowPhase; readonly t: Translate }): ReactNode {
+  const label = t(PHASE_KEYS[phase])
+  return <span className={css.statusDot} data-phase={phase} role="img" aria-label={label} title={label} />
+}
+
+function Tag({ kind, children }: { readonly kind: string; readonly children: ReactNode }): ReactNode {
+  return <span className={css.tag} data-kind={kind}>{children}</span>
+}
+
+function Switch({ checked, label, disabled, title, onChange }: {
+  readonly checked: boolean
+  readonly label: string
+  readonly disabled: boolean
+  readonly title?: string
+  readonly onChange: (checked: boolean) => void
+}): ReactNode {
+  return (
+    <button
+      type="button"
+      role="switch"
+      aria-checked={checked}
+      aria-label={label}
+      title={title}
+      className={clsx(css.switch, checked && css.switchOn)}
+      disabled={disabled}
+      onClick={() => { onChange(!checked) }}
+    >
+      <span className={css.thumb} />
+    </button>
+  )
+}
+
+/** One package: its header with the switch, and its details once expanded. */
+function PackageCard({ pkg, t, busy, open, presets, presetName, onToggleOpen, onSetEnabled, onRetry, onUninstall, onAddRow }: {
+  readonly pkg: PluginPackageView
+  readonly t: Translate
+  readonly busy: boolean
+  readonly open: boolean
+  readonly presets: readonly PresetGroup[]
+  readonly presetName: (preset: PresetGroup) => string
+  readonly onToggleOpen: () => void
+  readonly onSetEnabled: (enabled: boolean) => void
+  readonly onRetry: () => void
+  readonly onUninstall: () => void
+  readonly onAddRow: (declaredName: string, target: PluginRowTarget) => void
+}): ReactNode {
+  const [addMenu, setAddMenu] = useState<string | null>(null)
+  const title = pkg.title ?? shortName(pkg.name)
+  const bundle = pkg.kind === 'bundle'
+  const locked = pkg.trust === 'builtin'
+  const detailId = `plugin-package-${encodeURIComponent(pkg.name)}`
+  const targets = [
+    { id: 'global', label: t('addToGlobal') },
+    ...presets.map(preset => ({ id: `preset:${preset.id}`, label: t('addToPreset', { name: presetName(preset) }) })),
+  ]
+  const targetOf = (id: string): PluginRowTarget =>
+    id === 'global' ? { kind: 'global' } : { kind: 'preset', preset: id.slice('preset:'.length) }
+  return (
+    <li
+      className={css.card}
+      data-plugin-package={pkg.name}
+      data-plugin-status={pkg.status}
+      data-open={open ? 'true' : undefined}
+    >
+      <div className={css.cardHead}>
+        <div className={css.cardMain}>
+          <div className={css.cardTitleRow}>
+            <span className={css.cardTitle}>{title}</span>
+            <Tag kind={pkg.trust}>{t(pkg.trust === 'builtin' ? 'builtinTag' : 'externalTag')}</Tag>
+            <Tag kind={pkg.kind}>{t(KIND_KEYS[pkg.kind])}</Tag>
+            {pkg.stage === 'boot' ? <Tag kind="boot">{t('stageBootTag')}</Tag> : null}
+            <Tag kind={pkg.status}>{t(STATUS_KEYS[pkg.status])}</Tag>
+          </div>
+          <span className={css.cardName}>{pkg.name}{pkg.version === undefined ? '' : ` · ${pkg.version}`}</span>
+        </div>
+        <div className={css.cardEnd}>
+          {bundle
+            ? (
+              <Switch
+                checked={pkg.enabled}
+                label={t('enableToggle', { name: title })}
+                disabled={busy || locked || (!pkg.enabled && pkg.status === 'not-enableable')}
+                {...locked ? { title: t('builtinLocked') } : {}}
+                onChange={onSetEnabled}
+              />
+            )
+            : null}
+          <button
+            type="button"
+            className={css.iconButton}
+            aria-expanded={open}
+            aria-controls={detailId}
+            aria-label={t(open ? 'collapse' : 'expand', { name: title })}
+            onClick={onToggleOpen}
+          >
+            <IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden="true" />
+          </button>
+        </div>
+      </div>
+      {open
+        ? (
+          <div className={css.cardBody} id={detailId}>
+            {pkg.description === undefined ? null : <p className={css.cardDescription}>{pkg.description}</p>}
+            {pkg.reason === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
+            <dl className={css.facts}>
+              <dt>{t('packageLabel')}</dt>
+              <dd>{pkg.name}</dd>
+              {pkg.enginesDsh === undefined ? null : <><dt>{t('enginesLabel')}</dt><dd>{pkg.enginesDsh}</dd></>}
+              <dt>{t('cordisLabel')}</dt>
+              <dd>{t(pkg.cordisSameCopy === null ? 'cordisUnknown' : pkg.cordisSameCopy ? 'cordisSame' : 'cordisForeign')}</dd>
+              {pkg.probedAt === undefined ? null : <><dt>{t('probedAtLabel')}</dt><dd>{pkg.probedAt}</dd></>}
+              {pkg.overrides.length === 0 ? null : <><dt>{t('overridesLabel')}</dt><dd>{pkg.overrides.join(', ')}</dd></>}
+            </dl>
+            {bundle
+              ? (
+                <>
+                  <p className={css.groupSub}>{t('rowsLabel')}</p>
+                  {pkg.rows.length === 0
+                    ? <p className={css.status}>{t('rowsEmpty')}</p>
+                    : (
+                      <ul className={css.rows}>
+                        {pkg.rows.map(row => (
+                          <li key={row.entryId} className={css.row} data-plugin-row={row.entryId}>
+                            {row.enabled && row.phase !== null ? <PhaseDot phase={row.phase} t={t} /> : null}
+                            <span className={css.rowId}>{row.originalId ?? row.entryId}</span>
+                            <span className={css.rowModule}>{row.moduleName}</span>
+                            {row.enabled
+                              ? null
+                              : <Tag kind="disabled">{t(row.disabledBy === 'user' ? 'rowDisabledByUser' : 'rowDisabledByComposition')}</Tag>}
+                            {row.failure === undefined ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
+                          </li>
+                        ))}
+                      </ul>
+                    )}
+                </>
+              )
+              : null}
+            {pkg.addable.length === 0
+              ? null
+              : (
+                <>
+                  <p className={css.groupSub}>{t('addableLabel')}</p>
+                  <ul className={css.rows}>
+                    {pkg.addable.map(entry => (
+                      <li key={entry.moduleName} className={css.row} data-plugin-addable={entry.moduleName}>
+                        <span className={css.rowId}>{entry.title ?? entry.declaredName}</span>
+                        <span className={css.rowModule}>{entry.moduleName}</span>
+                        {entry.ok
+                          ? (
+                            <Menu
+                              open={addMenu === entry.moduleName}
+                              onClose={() => { setAddMenu(null) }}
+                              items={targets}
+                              onSelect={(id) => {
+                                setAddMenu(null)
+                                onAddRow(entry.declaredName, targetOf(id))
+                              }}
+                              align="end"
+                              portal
+                              anchor={(
+                                <button
+                                  type="button"
+                                  className={css.linkButton}
+                                  aria-haspopup="menu"
+                                  aria-expanded={addMenu === entry.moduleName}
+                                  disabled={busy}
+                                  onClick={() => { setAddMenu(current => current === entry.moduleName ? null : entry.moduleName) }}
+                                >
+                                  {t('addTo')}
+                                </button>
+                              )}
+                            />
+                          )
+                          : <Tag kind="failed">{t('addableNotOk')}</Tag>}
+                        {entry.error === undefined ? null : <p className={css.rowFailure}>{entry.error}</p>}
+                      </li>
+                    ))}
+                  </ul>
+                </>
+              )}
+            <div className={css.actions}>
+              {bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
+                ? <button type="button" className={css.linkButton} disabled={busy} onClick={onRetry}>{t('retryPackage')}</button>
+                : null}
+              {pkg.installed && !locked
+                ? (
+                  <button type="button" className={css.linkButton} data-danger="true" disabled={busy} onClick={onUninstall}>
+                    {t('uninstall')}
+                  </button>
+                )
+                : null}
+            </div>
+          </div>
+        )
+        : null}
+    </li>
+  )
+}
+
+/** One row of the selected preset's composition, with its switch. */
+function PresetRowItem({ preset, row, t, busy, onSetDisabled, onRemove }: {
+  readonly preset: PresetGroup
+  readonly row: PresetRow
+  readonly t: Translate
+  readonly busy: boolean
+  readonly onSetDisabled: (rowId: string, disabled: boolean) => void
+  readonly onRemove: (rowId: string) => void
+}): ReactNode {
+  const id = row.entryId
+  const failed = row.fiberPhase === 'failed'
+  const enabled = row.enabled === true
+  return (
+    <li className={css.row} data-preset-row={id ?? undefined} data-plugin-source={row.source}>
+      {enabled && !failed && row.fiberPhase !== null ? <PhaseDot phase={row.fiberPhase} t={t} /> : null}
+      <span className={css.rowId}>{id ?? shortName(row.moduleName)}</span>
+      <span className={css.rowModule}>{row.moduleName}</span>
+      {row.source === 'user' ? <Tag kind="user">{t('rowSourceUser')}</Tag> : null}
+      {failed ? <Tag kind="failed">{t('rowPhaseFailed')}</Tag> : null}
+      {row.enabled === 'conditional' ? <Tag kind="conditional">{t('rowConditional')}</Tag> : null}
+      {row.enabled === false
+        ? <Tag kind="disabled">{t(row.disabledBy === 'user' ? 'rowDisabledByUser' : 'rowDisabledByComposition')}</Tag>
+        : null}
+      {id === null
+        ? <span className={css.rowModule} title={t('rowNoId')}>{t('rowNoId')}</span>
+        : (
+          <>
+            <Switch
+              checked={row.enabled !== false}
+              label={t('rowToggle', { id })}
+              disabled={busy || preset.broken !== undefined}
+              onChange={(checked) => { onSetDisabled(id, !checked) }}
+            />
+            {row.source === 'user'
+              ? (
+                <button
+                  type="button"
+                  className={css.linkButton}
+                  data-danger="true"
+                  aria-label={t('rowRemove', { id })}
+                  disabled={busy}
+                  onClick={() => { onRemove(id) }}
+                >
+                  {t('rowRemove', { id: '' }).trim()}
+                </button>
+              )
+              : null}
+          </>
+        )}
+    </li>
+  )
+}
+
+/** The install dialog: the spec, the enable choice, and pnpm's output. */
+function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun }: {
+  readonly install: InstallState
+  readonly t: Translate
+  readonly onClose: () => void
+  readonly onEditSpec: (text: string) => void
+  readonly onToggleEnable: () => void
+  readonly onRun: () => void
+}): ReactNode {
+  const running = install.phase === 'running'
+  return (
+    <Modal
+      open={install.open}
+      onClose={onClose}
+      title={t('installTitle')}
+      closeLabel={t('close')}
+      description={t('installDescription')}
+      footer={(
+        <>
+          <Button variant="outline" disabled={running} onClick={onClose}>{t(install.phase === 'idle' ? 'cancel' : 'installClose')}</Button>
+          <Button variant="primary" disabled={running || install.spec.trim() === ''} onClick={onRun}>
+            {t(running ? 'installRunning' : 'installRun')}
+          </Button>
+        </>
+      )}
+    >
+      <div className={css.installBody}>
+        <label className={css.installField}>
+          <span>{t('installSpecLabel')}</span>
+          <input
+            type="text"
+            value={install.spec}
+            placeholder={t('installSpecPlaceholder')}
+            disabled={running}
+            onChange={(event) => { onEditSpec(event.currentTarget.value) }}
+          />
+        </label>
+        <label className={css.installOption}>
+          <input type="checkbox" checked={install.enable} disabled={running} onChange={onToggleEnable} />
+          <span>{t('installEnable')}</span>
+        </label>
+        {install.phase === 'done'
+          ? (
+            <p className={css.status} role="status">
+              {install.installed.length === 0 ? t('installDoneNothing') : t('installDone', { names: install.installed.join(', ') })}
+            </p>
+          )
+          : null}
+        {install.phase === 'failed' ? <p className={css.reason} role="alert">{t('installFailed')}</p> : null}
+        {install.log === '' && install.phase === 'idle'
+          ? null
+          : <pre className={css.log} aria-label={t('installLogLabel')} aria-live="polite">{install.log}</pre>}
+      </div>
+    </Modal>
+  )
+}
+
+/** The confirmation a destructive action waits on, listing what it would strand. */
+function ConfirmDialog({ confirm, t, presets, presetName, onAcknowledge, onConfirm, onCancel }: {
+  readonly confirm: ConfirmState
+  readonly t: Translate
+  readonly presets: readonly PresetGroup[]
+  readonly presetName: (preset: PresetGroup) => string
+  readonly onAcknowledge: (acknowledged: boolean) => void
+  readonly onConfirm: () => void
+  readonly onCancel: () => void
+}): ReactNode {
+  const name = shortName(confirm.packageName)
+  const targetText = (target: PluginRowTarget): string => {
+    if (target.kind === 'global') return t('targetGlobal')
+    const preset = presets.find(candidate => candidate.id === target.preset)
+    return t('targetPreset', { name: preset === undefined ? target.preset : presetName(preset) })
+  }
+  const dependents = confirm.dependents
+  const lines = dependents === undefined
+    ? []
+    : [
+      ...dependents.services.map(service => t('dependentService', {
+        service: service.service, provider: service.providedBy, rows: service.injectedBy.join(', '),
+      })),
+      ...dependents.references.map(reference => t('dependentReference', {
+        row: reference.rowId, target: targetText(reference.target), module: reference.moduleName,
+      })),
+    ]
+  return (
+    <Modal
+      open
+      onClose={onCancel}
+      title={t(confirm.action === 'uninstall' ? 'confirmUninstallTitle' : 'confirmDisableTitle', { name })}
+      closeLabel={t('close')}
+      description={t(confirm.action === 'uninstall' ? 'confirmUninstallDescription' : 'confirmDisableDescription')}
+      footer={(
+        <>
+          <Button variant="outline" onClick={onCancel}>{t('cancel')}</Button>
+          <Button variant="primary" disabled={dependents === undefined || !confirm.acknowledged} onClick={onConfirm}>
+            {t('confirm')}
+          </Button>
+        </>
+      )}
+    >
+      {dependents === undefined ? <p className={css.status}>{t('loading')}</p> : null}
+      {lines.length > 0
+        ? (
+          <>
+            <p className={css.status}>{t('confirmDependents')}</p>
+            <ul className={css.dependents}>{lines.map(line => <li key={line}>{line}</li>)}</ul>
+          </>
+        )
+        : null}
+      <label className={css.installOption}>
+        <input type="checkbox" checked={confirm.acknowledged} onChange={(event) => { onAcknowledge(event.currentTarget.checked) }} />
+        <span>{t('acknowledge')}</span>
+      </label>
+    </Modal>
+  )
+}
+
+function noticeText(notice: ManagerNotice, t: Translate): string {
+  switch (notice.kind) {
+    case 'restart': return t('restartNotice')
+    case 'done': return t('doneNotice')
+    case 'failed': {
+      switch (notice.code) {
+        case 'plugins/not-enableable': return t('notEnableable', { reason: notice.reason })
+        case 'plugins/enable-failed': return t('enableFailed', { reason: notice.reason })
+        case 'plugins/row-conflict': return t('rowConflict', { row: notice.rowId ?? '' })
+        case 'plugins/not-installed': return t('notInstalled', { name: notice.packageName ?? '' })
+        default: return t('actionFailed', { reason: notice.reason })
+      }
+    }
+  }
+}
+
+/** Render the plugin manager: packages first, then the selected preset's composition. */
+export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps): ReactNode {
+  const { t, presetName, ensure } = props
+  const state = props.usePluginManager(snapshot => snapshot)
+  const [expanded, setExpanded] = useState<string | null>(null)
+  const [switcherOpen, setSwitcherOpen] = useState(false)
+  useEffect(() => { ensure() }, [ensure])
+
+  const selected = state.presets.find(preset => preset.id === state.selectedPreset) ?? fallbackPreset(state.presets)
+  const restartPending = state.packages.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name))
+  const loaded = state.status === 'ready' || state.status === 'error'
+
+  return (
+    <div className={css.section} aria-busy={state.status === 'loading'}>
+      <div className={css.toolbar}>
+        <Button variant="outline" size="sm" disabled={!loaded} onClick={props.refresh}>{t('refresh')}</Button>
+        <Button variant="primary" size="sm" disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
+      </div>
+      {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
+      {state.status === 'unavailable' ? <p className={css.status} role="status">{t('unavailable')}</p> : null}
+      {state.status === 'error'
+        ? (
+          <div className={css.failure}>
+            <p role="alert">{t('error')}</p>
+            <Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
+          </div>
+        )
+        : null}
+      {restartPending.length > 0
+        ? <p className={css.banner} role="status">{t('restartBanner', { names: restartPending.join(', ') })}</p>
+        : null}
+      {state.notice === null
+        ? null
+        : (
+          <p className={css.notice} data-kind={state.notice.kind} role={state.notice.kind === 'failed' ? 'alert' : 'status'}>
+            <span>{noticeText(state.notice, t)}</span>
+            <button type="button" className={css.linkButton} onClick={props.dismissNotice}>{t('dismiss')}</button>
+          </p>
+        )}
+      {loaded
+        ? (
+          <>
+            <section className={css.group} data-plugin-scope="global">
+              <div className={css.groupTitleRow}>
+                <h3 className={css.groupTitle}>{t('globalTitle')}</h3>
+              </div>
+              <p className={css.groupSub}>
+                {t('globalSubtitle')}
+                <span data-plugin-count={state.packages.length}>{` · ${String(state.packages.length)} ${t('countUnit')}`}</span>
+              </p>
+              {state.packages.length === 0
+                ? <p className={css.empty}>{t('empty')}</p>
+                : (
+                  <ul className={css.cards}>
+                    {state.packages.map(pkg => (
+                      <PackageCard
+                        key={pkg.name}
+                        pkg={pkg}
+                        t={t}
+                        busy={state.busy.includes(pkg.name)}
+                        open={expanded === pkg.name}
+                        presets={state.presets}
+                        presetName={presetName}
+                        onToggleOpen={() => { setExpanded(current => current === pkg.name ? null : pkg.name) }}
+                        onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
+                        onRetry={() => { props.retry(pkg.name) }}
+                        onUninstall={() => { props.uninstall(pkg.name) }}
+                        onAddRow={(declaredName, target) => { props.addRow(pkg.name, declaredName, target) }}
+                      />
+                    ))}
+                  </ul>
+                )}
+            </section>
+            <section className={css.group} data-plugin-scope="preset" data-preset-id={selected?.id}>
+              <div className={css.groupTitleRow}>
+                <h3 className={css.groupTitle}>{t('presetTitle')}</h3>
+                {selected === undefined
+                  ? null
+                  : (
+                    <Menu
+                      open={switcherOpen}
+                      onClose={() => { setSwitcherOpen(false) }}
+                      items={state.presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t, presetName) }))}
+                      selectedId={selected.id}
+                      onSelect={(id) => {
+                        setSwitcherOpen(false)
+                        props.selectPreset(id)
+                      }}
+                      align="end"
+                      portal
+                      anchor={(
+                        <button
+                          type="button"
+                          className={css.switcher}
+                          aria-haspopup="menu"
+                          aria-expanded={switcherOpen}
+                          aria-label={t('switcherLabel')}
+                          onClick={() => { setSwitcherOpen(value => !value) }}
+                        >
+                          <span className={css.switcherLabel}>{presetLabel(selected, t, presetName)}</span>
+                          <IconChevronDownOutline14 className={css.chevron} aria-hidden="true" />
+                        </button>
+                      )}
+                    />
+                  )}
+              </div>
+              <p className={css.groupSub}>{t('presetSubtitle')}</p>
+              {selected === undefined
+                ? <p className={css.empty}>{t('presetNoRoster')}</p>
+                : selected.broken !== undefined
+                  ? <p className={css.reason} role="alert">{selected.broken}</p>
+                  : selected.rows.length === 0
+                    ? <p className={css.empty}>{t('presetRowsEmpty')}</p>
+                    : (
+                      <ul className={css.rows}>
+                        {selected.rows.map((row, index) => (
+                          <PresetRowItem
+                            key={`${row.entryId ?? row.moduleName}:${String(index)}`}
+                            preset={selected}
+                            row={row}
+                            t={t}
+                            busy={row.entryId !== null && state.busy.includes(rowKey({ kind: 'preset', preset: selected.id }, row.entryId))}
+                            onSetDisabled={(rowId, disabled) => { props.setRowDisabled({ kind: 'preset', preset: selected.id }, rowId, disabled) }}
+                            onRemove={(rowId) => { props.removeRow({ kind: 'preset', preset: selected.id }, rowId) }}
+                          />
+                        ))}
+                      </ul>
+                    )}
+            </section>
+          </>
+        )
+        : null}
+      <InstallDialog
+        install={state.install}
+        t={t}
+        onClose={props.closeInstall}
+        onEditSpec={props.editInstallSpec}
+        onToggleEnable={props.toggleInstallEnable}
+        onRun={props.runInstall}
+      />
+      {state.confirm === null
+        ? null
+        : (
+          <ConfirmDialog
+            confirm={state.confirm}
+            t={t}
+            presets={state.presets}
+            presetName={presetName}
+            onAcknowledge={props.acknowledgeConfirm}
+            onConfirm={props.confirm}
+            onCancel={props.cancelConfirm}
+          />
+        )}
+    </div>
+  )
+}

+ 82 - 0
packages/client/ui-settings-plugin-manager/src/client/index.ts

@@ -0,0 +1,82 @@
+/**
+ * Plugin manager, browser half: the **Manage plugins** tab of the Plugins
+ * settings section. It installs, enables, disables, retries, and uninstalls
+ * the packages of the Host's profile through the `plugins` Remote, and
+ * composes rows into the global user layer or one agent preset's.
+ */
+
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
+// Type-only: the `settings.plugins.tab` slot this tab registers into is
+// declared by ui-settings-plugins; registration goes through `slots.inject`.
+import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: the ctx.remote Context merge and the forwarded-event key face.
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
+// Type-only: the forwarded events' own declaration (`$on`'s key face resolves
+// through the owning package's client-safe types subpath).
+import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
+// Type-only: pulls the 'settings.agentPreset' LocaleNamespaceMap merge, whose
+// dictionaries the shipped-preset name resolution below reads.
+import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
+// Inline-safe shared fold: shipped ids map to dictionary keys in one home.
+import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
+import { PluginManagerSettingsTab } from './PluginManagerSettingsTab.tsx'
+import { PluginManagerController } from './manager-store.ts'
+import { en, zh, type PluginManagerLocaleKey } from './locales.ts'
+
+export type { PluginManagerSettingsTabProps } from './PluginManagerSettingsTab.tsx'
+export type {
+  ConfirmState, InstallState, ManagerNotice, PluginManagerFace, PluginManagerState, PresetGroup, PresetRow,
+} from './manager-store.ts'
+export type { PluginManagerLocaleKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Plugin manager tab copy. */
+    'settings.pluginManager': PluginManagerLocaleKey
+  }
+}
+
+/** Dictionary namespace owned by this plugin. */
+export const NS = 'settings.pluginManager'
+
+/** Services required by the Settings registration and the two Remote faces. */
+export const inject = ['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory']
+
+/**
+ * Contribute the manager tab to the Plugins settings section, and keep it
+ * current on the Host's change events.
+ * @param ctx - the browser plugin context.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugin-manager: dictionaries')
+  const t = ctx.locale.bind(NS)
+  const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
+  const controller = new PluginManagerController(ctx, preset => presetDisplayText(preset, agentPresetCopy).name)
+  ctx.effect(() => () => { controller.dispose() }, 'ui-settings-plugin-manager: controller')
+  // The Host says when what is installed, enabled, or composed changed — from
+  // this page, the CLI, or another browser — and streams install output.
+  ctx.effect(() => {
+    // A tab never rendered holds no snapshot to refresh.
+    const refresh = (): void => {
+      if (controller.getSnapshot().status !== 'idle') void controller.load()
+    }
+    const disposers = [
+      ctx.remote.$on('plugins/changed', refresh),
+      ctx.remote.$on('plugins/install-log', (chunk) => { controller.appendLog(chunk) }),
+      ctx.on('connection/reset', refresh),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'ui-settings-plugin-manager: host invalidations')
+
+  ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
+    name: 'settings.plugins.tab',
+    id: 'manage',
+    order: 5,
+    label: () => t('tab'),
+    locale: NS,
+    inject: () => controller.inject(),
+  }, PluginManagerSettingsTab))
+}

+ 216 - 0
packages/client/ui-settings-plugin-manager/src/client/locales.ts

@@ -0,0 +1,216 @@
+/** Copy dictionaries for the plugin manager Settings tab. */
+
+/** Simplified Chinese dictionary and key source of truth. */
+export const zh = {
+  tab: '插件管理',
+  loading: '正在读取插件…',
+  error: '暂时无法读取插件。',
+  unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
+  retry: '重试',
+  refresh: '刷新',
+  empty: '还没有安装任何插件。',
+  addPlugin: '添加插件',
+  restartBanner: '以下更改会在下次启动生效:{names}',
+  restartNotice: '更改会在下次启动生效。',
+  doneNotice: '已完成。',
+  dismiss: '知道了',
+  globalTitle: '全局插件',
+  globalSubtitle: '按包安装与启停;启用的 bundle 对系统与所有会话生效',
+  presetTitle: '会话插件',
+  presetSubtitle: '按 Agent 预设组成;这里的改动只写入该预设的用户补丁层',
+  switcherLabel: '选择要管理的 Agent 预设',
+  presetOptionDefault: '{name}(默认)',
+  presetOptionBroken: '{name}(加载失败)',
+  presetRowsEmpty: '该预设没有组合任何插件。',
+  presetNoRoster: '本部署没有 Agent 预设。',
+  countUnit: '个',
+  builtinTag: '内置',
+  externalTag: '第三方',
+  bundleTag: 'Bundle',
+  pluginTag: '插件模块',
+  libraryTag: '库',
+  stageBootTag: '启动阶段',
+  statusRunning: '运行中',
+  statusPartial: '部分运行',
+  statusFailed: '启动失败',
+  statusDisabled: '已停用',
+  statusNotEnableable: '无法启用',
+  statusRestartRequired: '待重启',
+  statusPlain: '按行添加',
+  enableToggle: '启用 {name}',
+  builtinLocked: '内置 bundle 随产品启动,不能在这里停用。',
+  expand: '展开 {name}',
+  collapse: '收起 {name}',
+  versionLabel: '版本',
+  packageLabel: '包名',
+  reasonLabel: '原因',
+  rowsLabel: '行',
+  rowsEmpty: '这个包没有向宿主树贡献任何行。',
+  overridesLabel: '覆盖的内置行',
+  addableLabel: '可添加的模块',
+  addableNotOk: '无法导入',
+  enginesLabel: '要求的 dsh 版本',
+  cordisLabel: 'Cordis 副本',
+  cordisSame: '与宿主相同',
+  cordisForeign: '另一份副本',
+  cordisUnknown: '未知',
+  probedAtLabel: '探测时间',
+  addTo: '添加到…',
+  addToGlobal: '全局',
+  addToPreset: '预设:{name}',
+  retryPackage: '重试',
+  uninstall: '卸载',
+  rowPhaseUnobserved: '未运行',
+  rowPhasePending: '等待依赖',
+  rowPhaseLoading: '加载中',
+  rowPhaseActive: '运行中',
+  rowPhaseFailed: '启动失败',
+  rowPhaseUnloading: '卸载中',
+  rowDisabledByUser: '用户停用',
+  rowDisabledByComposition: '组合停用',
+  rowSourceUser: '用户添加',
+  rowSourcePreset: '预设自带',
+  rowConditional: '条件启用',
+  rowToggle: '启用行 {id}',
+  rowRemove: '移除行 {id}',
+  rowNoId: '这一行没有声明 id,无法在这里启停。',
+  installTitle: '添加插件',
+  installDescription: '输入 npm 包名(可带版本)、本地目录路径或 git 地址;将以 pnpm 安装到当前 profile。',
+  installSpecLabel: '包名或路径',
+  installSpecPlaceholder: 'dsh-better-sidebar@latest 或 /path/to/plugin',
+  installEnable: '安装后立即启用(仅 bundle)',
+  installRun: '安装',
+  installRunning: '安装中…',
+  installLogLabel: '安装输出',
+  installDone: '安装完成:{names}',
+  installDoneNothing: '安装完成,没有新增依赖。',
+  installFailed: '安装失败。',
+  installClose: '完成',
+  close: '关闭',
+  cancel: '取消',
+  confirmUninstallTitle: '卸载 {name}',
+  confirmDisableTitle: '停用 {name}',
+  confirmUninstallDescription: '将从 profile 移除这个包及其行。',
+  confirmDisableDescription: '将把这个 bundle 移出启用列表。',
+  confirmDependents: '以下内容依赖它,停用后会进入等待状态:',
+  dependentService: '服务 {service}(由 {provider} 提供)被 {rows} 注入',
+  dependentReference: '{target} 中的行 {row} 引用了 {module}',
+  targetGlobal: '全局补丁层',
+  targetPreset: '预设 {name}',
+  acknowledge: '我了解影响,继续',
+  confirm: '继续',
+  actionFailed: '操作失败:{reason}',
+  notEnableable: '无法启用:{reason}',
+  enableFailed: '启用失败,已恢复停用:{reason}',
+  rowConflict: '目标层已存在 id 为 {row} 的行。',
+  notInstalled: '{name} 没有安装在当前 profile。',
+} satisfies Record<string, string>
+
+/** Plugin manager locale key union. */
+export type PluginManagerLocaleKey = keyof typeof zh
+
+/** English dictionary checked against the Chinese key set. */
+export const en = {
+  tab: 'Manage plugins',
+  loading: 'Reading plugins…',
+  error: 'Plugins are temporarily unavailable.',
+  unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
+  retry: 'Retry',
+  refresh: 'Refresh',
+  empty: 'No plugins are installed yet.',
+  addPlugin: 'Add plugin',
+  restartBanner: 'These changes take effect at the next start: {names}',
+  restartNotice: 'The change takes effect at the next start.',
+  doneNotice: 'Done.',
+  dismiss: 'Got it',
+  globalTitle: 'Global plugins',
+  globalSubtitle: 'Installed and switched per package; an enabled bundle serves the system and every session',
+  presetTitle: 'Session plugins',
+  presetSubtitle: 'Composed per agent preset; changes here land in that preset\'s user patch layer',
+  switcherLabel: 'Choose the agent preset to manage',
+  presetOptionDefault: '{name} (default)',
+  presetOptionBroken: '{name} (failed to load)',
+  presetRowsEmpty: 'This preset composes no plugins.',
+  presetNoRoster: 'This deployment has no agent presets.',
+  countUnit: 'packages',
+  builtinTag: 'Built-in',
+  externalTag: 'Third-party',
+  bundleTag: 'Bundle',
+  pluginTag: 'Plugin module',
+  libraryTag: 'Library',
+  stageBootTag: 'Boot stage',
+  statusRunning: 'Running',
+  statusPartial: 'Partly running',
+  statusFailed: 'Failed to start',
+  statusDisabled: 'Disabled',
+  statusNotEnableable: 'Cannot be enabled',
+  statusRestartRequired: 'Restart pending',
+  statusPlain: 'Added per row',
+  enableToggle: 'Enable {name}',
+  builtinLocked: 'A built-in bundle starts with the product and cannot be disabled here.',
+  expand: 'Show {name}',
+  collapse: 'Hide {name}',
+  versionLabel: 'Version',
+  packageLabel: 'Package',
+  reasonLabel: 'Reason',
+  rowsLabel: 'Rows',
+  rowsEmpty: 'This package contributes no rows to the host tree.',
+  overridesLabel: 'Overridden built-in rows',
+  addableLabel: 'Addable modules',
+  addableNotOk: 'Cannot be imported',
+  enginesLabel: 'Requires dsh',
+  cordisLabel: 'Cordis copy',
+  cordisSame: 'Same as the host',
+  cordisForeign: 'A separate copy',
+  cordisUnknown: 'Unknown',
+  probedAtLabel: 'Probed at',
+  addTo: 'Add to…',
+  addToGlobal: 'Global',
+  addToPreset: 'Preset: {name}',
+  retryPackage: 'Retry',
+  uninstall: 'Uninstall',
+  rowPhaseUnobserved: 'Not running',
+  rowPhasePending: 'Waiting for dependencies',
+  rowPhaseLoading: 'Loading',
+  rowPhaseActive: 'Running',
+  rowPhaseFailed: 'Failed to start',
+  rowPhaseUnloading: 'Unloading',
+  rowDisabledByUser: 'Disabled by the user',
+  rowDisabledByComposition: 'Disabled by the composition',
+  rowSourceUser: 'Added by the user',
+  rowSourcePreset: 'Preset composition',
+  rowConditional: 'Conditional',
+  rowToggle: 'Enable row {id}',
+  rowRemove: 'Remove row {id}',
+  rowNoId: 'This row declares no id, so it cannot be switched here.',
+  installTitle: 'Add plugin',
+  installDescription: 'Enter an npm package (optionally with a version), a local directory, or a git URL; pnpm installs it into the current profile.',
+  installSpecLabel: 'Package or path',
+  installSpecPlaceholder: 'dsh-better-sidebar@latest or /path/to/plugin',
+  installEnable: 'Enable right after installing (bundles only)',
+  installRun: 'Install',
+  installRunning: 'Installing…',
+  installLogLabel: 'Install output',
+  installDone: 'Installed: {names}',
+  installDoneNothing: 'Install finished with no new dependency.',
+  installFailed: 'The install failed.',
+  installClose: 'Done',
+  close: 'Close',
+  cancel: 'Cancel',
+  confirmUninstallTitle: 'Uninstall {name}',
+  confirmDisableTitle: 'Disable {name}',
+  confirmUninstallDescription: 'The package and its rows leave the profile.',
+  confirmDisableDescription: 'The bundle leaves the enabled list.',
+  confirmDependents: 'These depend on it and would wait once it is gone:',
+  dependentService: 'Service {service} (provided by {provider}) is injected by {rows}',
+  dependentReference: 'Row {row} in {target} names {module}',
+  targetGlobal: 'the global patch layer',
+  targetPreset: 'preset {name}',
+  acknowledge: 'I understand the impact, continue',
+  confirm: 'Continue',
+  actionFailed: 'The action failed: {reason}',
+  notEnableable: 'Cannot be enabled: {reason}',
+  enableFailed: 'Enabling failed and the bundle stays disabled: {reason}',
+  rowConflict: 'The target layer already has a row with id {row}.',
+  notInstalled: '{name} is not installed in the current profile.',
+} satisfies Record<PluginManagerLocaleKey, string>

+ 406 - 0
packages/client/ui-settings-plugin-manager/src/client/manager-store.ts

@@ -0,0 +1,406 @@
+/**
+ * The plugin manager tab's state: the Host's package views and preset
+ * compositions, the action in flight, the install run, and the confirmation
+ * a destructive action waits on. Every fact comes from the Host — the store
+ * re-reads after each action and after every `plugins/changed` event, so a
+ * change made on another surface shows here without a manual refresh.
+ */
+
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {
+  PluginDependents, PluginEnableResult, PluginInstallLogChunk, PluginInventorySnapshot, PluginPackageView,
+  PluginRowTarget,
+} from '@deepseek-ai/dsh-api-remotes/client'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+
+/** One preset composition as the inventory reports it, with its rows. */
+export type PresetGroup = NonNullable<PluginInventorySnapshot['agentPresets']>[number]
+
+/** One row of a preset composition. */
+export type PresetRow = PresetGroup['rows'][number]
+
+/** What the last action left to say. */
+export type ManagerNotice =
+  | { readonly kind: 'restart'; readonly packageName: string }
+  | { readonly kind: 'done'; readonly packageName?: string }
+  | {
+    readonly kind: 'failed'
+    /** The Host's failure code, which selects the copy. */
+    readonly code: string
+    /** The Host's reason, shown verbatim. */
+    readonly reason: string
+    readonly packageName?: string
+    readonly rowId?: string
+  }
+
+/** The install dialog. */
+export interface InstallState {
+  readonly open: boolean
+  /** The package spec as typed. */
+  readonly spec: string
+  /** Whether a newly installed bundle is enabled right away. */
+  readonly enable: boolean
+  readonly phase: 'idle' | 'running' | 'done' | 'failed'
+  /** pnpm's output so far, stdout and stderr interleaved as they arrived. */
+  readonly log: string
+  /** Dependencies the last run added, once it finished. */
+  readonly installed: readonly string[]
+}
+
+/** A destructive action waiting for the user's acknowledgement. */
+export interface ConfirmState {
+  readonly action: 'uninstall' | 'disable'
+  readonly packageName: string
+  /** What the action would strand; undefined while the Host is asked. */
+  readonly dependents: PluginDependents | undefined
+  readonly acknowledged: boolean
+}
+
+/** What the tab renders. */
+export interface PluginManagerState {
+  /** `unavailable` when the Host runs without a profile runtime; `error` keeps the last packages. */
+  readonly status: 'idle' | 'loading' | 'ready' | 'error' | 'unavailable'
+  readonly packages: readonly PluginPackageView[]
+  readonly presets: readonly PresetGroup[]
+  /** The preset whose composition the session group shows; null picks the default. */
+  readonly selectedPreset: string | null
+  /** Package names and row keys with an action crossing the wire. */
+  readonly busy: readonly string[]
+  readonly notice: ManagerNotice | null
+  readonly install: InstallState
+  readonly confirm: ConfirmState | null
+}
+
+/** The registration-side face the tab's slot entry injects. */
+export interface PluginManagerFace {
+  hooks: {
+    /** Tab snapshot bound by the renderer as usePluginManager. */
+    pluginManager: SnapshotStore<PluginManagerState>
+  }
+  /** Read the Host once the tab first renders. */
+  ensure: () => void
+  /** Read the Host again. */
+  refresh: () => void
+  openInstall: () => void
+  closeInstall: () => void
+  editInstallSpec: (text: string) => void
+  toggleInstallEnable: () => void
+  runInstall: () => void
+  /** Put a bundle into, or take it out of, the profile's layer list. */
+  setEnabled: (packageName: string, enabled: boolean) => void
+  /** Compose an enabled bundle again from scratch. */
+  retry: (packageName: string) => void
+  /** Ask before removing a package from the profile. */
+  uninstall: (packageName: string) => void
+  acknowledgeConfirm: (acknowledged: boolean) => void
+  confirm: () => void
+  cancelConfirm: () => void
+  /** Add a row naming one of a package's modules — by its declared name, `.` for the main export — to a user layer. */
+  addRow: (packageName: string, declaredName: string, target: PluginRowTarget) => void
+  removeRow: (target: PluginRowTarget, rowId: string) => void
+  setRowDisabled: (target: PluginRowTarget, rowId: string, disabled: boolean) => void
+  selectPreset: (id: string) => void
+  dismissNotice: () => void
+  /** Display name for one preset, resolved through the agent-preset dictionaries. */
+  presetName: (preset: PresetGroup) => string
+}
+
+/** A Remote answer as the generated client returns it. */
+type Answer<T> =
+  | { readonly ok: true; readonly value: T }
+  | { readonly ok: false; readonly error: { readonly code: string; readonly message: string; readonly details?: unknown } }
+
+/** One string field of a failure's details, when the details carry it. */
+function detailOf(error: { details?: unknown }, field: string): string | undefined {
+  const details = error.details
+  if (typeof details === 'object' && details !== null && field in details) {
+    const value = (details as Record<string, unknown>)[field]
+    if (typeof value === 'string') return value
+  }
+  return undefined
+}
+
+/** The Host reason a failure carries, when its details name one; else its message. */
+function reasonOf(error: { message: string; details?: unknown }): string {
+  return detailOf(error, 'reason') ?? error.message
+}
+
+/**
+ * The key one row occupies in the busy list.
+ * @param target - the layer the row lives in.
+ * @param rowId - the row's id in that layer.
+ * @returns the busy key.
+ */
+export function rowKey(target: PluginRowTarget, rowId: string): string {
+  return `${target.kind === 'global' ? 'global' : `preset:${target.preset}`}:${rowId}`
+}
+
+const IDLE_INSTALL: InstallState = { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] }
+
+/** Reads and mutates the profile's plugins through the `plugins` and `pluginInventory` Remotes. */
+export class PluginManagerController {
+  private readonly store: SnapshotStore<PluginManagerState>
+  private inFlight: Promise<void> | undefined
+  private rerun = false
+  private generation = 0
+  private disposed = false
+  private pendingConfirm: (() => Promise<void>) | undefined
+
+  /**
+   * @param ctx - the tab plugin's context, whose `remote.plugins` and
+   * `remote.pluginInventory` namespaces answer.
+   * @param presetName - display name for one preset.
+   */
+  constructor(
+    private readonly ctx: ClientContext,
+    private readonly presetName: (preset: PresetGroup) => string,
+  ) {
+    this.store = createSnapshotStore<PluginManagerState>({
+      status: 'idle', packages: [], presets: [], selectedPreset: null, busy: [], notice: null,
+      install: IDLE_INSTALL, confirm: null,
+    })
+  }
+
+  /**
+   * Read the tab's state.
+   * @returns the current sync snapshot (stable reference until the next change).
+   */
+  getSnapshot(): PluginManagerState {
+    return this.store.getSnapshot()
+  }
+
+  /** Stop publishing and drop every late settlement. */
+  dispose(): void {
+    this.disposed = true
+    this.generation += 1
+  }
+
+  /**
+   * Build the face the tab's slot registration injects.
+   * @returns the tab's snapshot source and its actions.
+   */
+  inject(): PluginManagerFace {
+    return {
+      hooks: { pluginManager: this.store },
+      ensure: () => { if (this.getSnapshot().status === 'idle') void this.load() },
+      refresh: () => { void this.load() },
+      openInstall: () => { this.patch({ install: { ...IDLE_INSTALL, open: true } }) },
+      closeInstall: () => {
+        if (this.getSnapshot().install.phase === 'running') return
+        this.patch({ install: IDLE_INSTALL })
+      },
+      editInstallSpec: (text) => { this.patchInstall({ spec: text }) },
+      toggleInstallEnable: () => { this.patchInstall({ enable: !this.getSnapshot().install.enable }) },
+      runInstall: () => { void this.runInstall() },
+      setEnabled: (packageName, enabled) => { void this.setEnabled(packageName, enabled) },
+      retry: (packageName) => {
+        void this.run(packageName, { packageName }, async () => {
+          this.effect(await this.ctx.remote.plugins.retry(packageName), packageName)
+        })
+      },
+      uninstall: (packageName) => { void this.askConfirm('uninstall', packageName) },
+      acknowledgeConfirm: (acknowledged) => {
+        const confirm = this.getSnapshot().confirm
+        if (confirm !== null) this.patch({ confirm: { ...confirm, acknowledged } })
+      },
+      confirm: () => { void this.confirm() },
+      cancelConfirm: () => { this.pendingConfirm = undefined; this.patch({ confirm: null }) },
+      addRow: (packageName, declaredName, target) => {
+        void this.run(packageName, { packageName }, async () => {
+          this.answer(await this.ctx.remote.plugins.addRow(packageName, target, { module: declaredName }))
+          this.patch({ notice: { kind: 'done', packageName } })
+        })
+      },
+      removeRow: (target, rowId) => {
+        void this.run(rowKey(target, rowId), { rowId }, async () => {
+          this.answer(await this.ctx.remote.plugins.removeRow(target, rowId))
+        })
+      },
+      setRowDisabled: (target, rowId, disabled) => {
+        void this.run(rowKey(target, rowId), { rowId }, async () => {
+          this.answer(await this.ctx.remote.plugins.setRowDisabled(target, rowId, disabled))
+        })
+      },
+      selectPreset: (id) => { this.patch({ selectedPreset: id }) },
+      dismissNotice: () => { this.patch({ notice: null }) },
+      presetName: this.presetName,
+    }
+  }
+
+  /**
+   * Fold one install-log chunk into the open run. A chunk for another spec —
+   * a CLI install running beside the page — is not this dialog's output.
+   * @param chunk - the chunk the Host forwarded.
+   */
+  appendLog(chunk: PluginInstallLogChunk): void {
+    const install = this.getSnapshot().install
+    if (install.phase !== 'running' || chunk.spec !== install.spec.trim()) return
+    this.patchInstall({ log: install.log + chunk.text })
+  }
+
+  /**
+   * Read the packages and the preset compositions. A call during an
+   * in-flight read marks one rerun after it settles.
+   * @returns settlement after this call's freshness is reflected.
+   */
+  load(): Promise<void> {
+    if (this.disposed) return Promise.resolve()
+    if (this.inFlight !== undefined) {
+      this.rerun = true
+      return this.inFlight
+    }
+    const run = Promise.resolve().then(() => this.read())
+    this.inFlight = run
+    return run
+  }
+
+  private async read(): Promise<void> {
+    try {
+      do {
+        this.rerun = false
+        const generation = ++this.generation
+        if (this.getSnapshot().status === 'idle') this.patch({ status: 'loading' })
+        const [packages, inventory] = await Promise.all([
+          this.ctx.remote.plugins.list(),
+          this.ctx.remote.pluginInventory.list(),
+        ])
+        if (generation !== this.generation) return
+        if (!packages.ok) {
+          this.patch({ status: packages.error.code === 'plugins/unavailable' ? 'unavailable' : 'error' })
+          continue
+        }
+        this.patch({
+          status: 'ready',
+          packages: packages.value,
+          presets: inventory.ok ? inventory.value.agentPresets ?? [] : this.getSnapshot().presets,
+        })
+      } while (this.shouldRerun())
+    } finally {
+      this.inFlight = undefined
+    }
+  }
+
+  private shouldRerun(): boolean {
+    return this.rerun
+  }
+
+  private async setEnabled(packageName: string, enabled: boolean): Promise<void> {
+    if (enabled) {
+      await this.run(packageName, { packageName }, async () => {
+        this.effect(await this.ctx.remote.plugins.enable(packageName), packageName)
+      })
+      return
+    }
+    await this.askConfirm('disable', packageName)
+  }
+
+  /**
+   * Open the confirmation for a destructive action, asking the Host what it
+   * would strand. A disable with nothing depending on the bundle needs no
+   * confirmation and runs at once.
+   */
+  private async askConfirm(action: ConfirmState['action'], packageName: string): Promise<void> {
+    const perform = action === 'uninstall'
+      ? async (): Promise<void> => {
+        this.answer(await this.ctx.remote.plugins.uninstall(packageName))
+        this.patch({ notice: { kind: 'done', packageName } })
+      }
+      : async (): Promise<void> => {
+        this.effect(await this.ctx.remote.plugins.disable(packageName), packageName)
+      }
+    this.pendingConfirm = () => this.run(packageName, { packageName }, perform)
+    this.patch({ confirm: { action, packageName, dependents: undefined, acknowledged: false } })
+    const generation = this.generation
+    const dependents = await this.ctx.remote.plugins.dependents(packageName)
+    const confirm = this.getSnapshot().confirm
+    if (generation !== this.generation || confirm?.packageName !== packageName || confirm.action !== action) return
+    const value: PluginDependents = dependents.ok ? dependents.value : { services: [], references: [] }
+    if (action === 'disable' && value.services.length === 0 && value.references.length === 0) {
+      this.patch({ confirm: null })
+      await this.confirm()
+      return
+    }
+    this.patch({ confirm: { ...confirm, dependents: value } })
+  }
+
+  private async confirm(): Promise<void> {
+    const pending = this.pendingConfirm
+    this.pendingConfirm = undefined
+    this.patch({ confirm: null })
+    if (pending !== undefined) await pending()
+  }
+
+  private async runInstall(): Promise<void> {
+    const install = this.getSnapshot().install
+    const spec = install.spec.trim()
+    if (install.phase === 'running' || spec === '') return
+    this.patchInstall({ phase: 'running', log: '', installed: [] })
+    const generation = this.generation
+    const result = await this.ctx.remote.plugins.add(spec, { enable: install.enable })
+    if (this.disposed || generation !== this.generation) return
+    if (result.ok) {
+      this.patchInstall({ phase: 'done', installed: result.value.installed })
+    } else {
+      // Streamed chunks already show the run; otherwise the Host's captured
+      // log, or its message when pnpm never started.
+      const current = this.getSnapshot().install.log
+      const log = current === '' ? detailOf(result.error, 'log') ?? result.error.message : current
+      this.patchInstall({ phase: 'failed', log })
+    }
+    void this.load()
+  }
+
+  /**
+   * Run one action under a busy key, turn its failure into the notice, and
+   * re-read the Host afterwards whatever happened.
+   */
+  private async run(
+    key: string,
+    subject: { packageName?: string; rowId?: string },
+    action: () => Promise<void>,
+  ): Promise<void> {
+    if (this.disposed || this.getSnapshot().busy.includes(key)) return
+    this.patch({ busy: [...this.getSnapshot().busy, key], notice: null })
+    try {
+      await action()
+    } catch (error) {
+      // `patch` drops the notice after disposal; a refused answer carries the
+      // Host's code, anything else is a transport failure.
+      const failure = error instanceof RemoteAnswerError
+        ? error
+        : new RemoteAnswerError('gateway/internal', error instanceof Error ? error.message : String(error))
+      this.patch({ notice: { kind: 'failed', code: failure.code, reason: failure.reason, ...subject } })
+    } finally {
+      this.patch({ busy: this.getSnapshot().busy.filter(entry => entry !== key) })
+    }
+    await this.load()
+  }
+
+  /** Publish an enable-shaped answer's effect as the notice. */
+  private effect(result: Answer<PluginEnableResult>, packageName: string): void {
+    const value = this.answer(result)
+    this.patch({ notice: value.effect === 'restart' ? { kind: 'restart', packageName } : { kind: 'done', packageName } })
+  }
+
+  /** Unwrap an answer, throwing its failure for {@link run} to report. */
+  private answer<T>(result: Answer<T>): T {
+    if (result.ok) return result.value
+    throw new RemoteAnswerError(result.error.code, reasonOf(result.error))
+  }
+
+  private patch(next: Partial<PluginManagerState>): void {
+    if (this.disposed) return
+    this.store.set({ ...this.getSnapshot(), ...next })
+  }
+
+  private patchInstall(next: Partial<InstallState>): void {
+    this.patch({ install: { ...this.getSnapshot().install, ...next } })
+  }
+}
+
+/** A refused Remote answer, carried to the notice with the Host's code and reason. */
+class RemoteAnswerError extends Error {
+  constructor(readonly code: string, readonly reason: string) {
+    super(reason)
+  }
+}

+ 6 - 0
packages/client/ui-settings-plugin-manager/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-settings-plugin-manager/src/index.ts

@@ -0,0 +1,4 @@
+/** Host loader entry for the plugin-manager tab's browser implementation exported from `./client`. */
+
+/** Host plugin body — no host-side behavior for the plugin manager tab. */
+export function apply(): void {}

+ 98 - 0
packages/client/ui-settings-plugin-manager/tests/browser-plugin.client.spec.tsx

@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import { Context, Service } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup } from '@testing-library/react'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
+import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply, inject, NS } from '../src/client/index.ts'
+import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx'
+import type { PluginManagerFace } from '../src/client/manager-store.ts'
+import { apply as hostApply } from '../src/index.ts'
+
+usePinnedBrowserLanguages('zh-CN')
+afterEach(cleanup)
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotRegistry).await()
+  const locale = new LocaleRuntime(ctx)
+  ctx.provide('locale', locale)
+  class LocaleHolder extends Service {
+    constructor(serviceCtx: Context) {
+      super(serviceCtx, 'localeHolder')
+    }
+  }
+  new LocaleHolder(ctx)
+  const list = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))
+  const inventory = vi.fn(() => Promise.resolve({ ok: true as const, value: { entries: [] } }))
+  const remote = new TestRemote(ctx, {
+    plugins: { list },
+    pluginInventory: { list: inventory },
+  })
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, inventory, remote }
+}
+
+function declare(slots: SlotRegistry): () => void {
+  return slots.register({
+    name: 'root',
+    children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
+  } as never, () => null)
+}
+
+describe('ui-settings-plugin-manager browser plugin', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('declares only the services the tab and its two Remote faces use', () => {
+    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.plugins', 'remote.pluginInventory'])
+  })
+
+  it('registers a localized tab that reads the Host only once rendered, and follows Host changes', async () => {
+    const b = await bench()
+    declare(b.slots)
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+
+    const entry = b.slots.entries('settings.plugins.tab')[0]!
+    expect(entry.component).toBe(PluginManagerSettingsTab)
+    expect(entry.options).toMatchObject({ id: 'manage', order: 5 })
+    expect(entry.locale).toBe(NS)
+    expect(resolveSlotLabel(entry.options.label)).toBe('插件管理')
+    expect(b.list).not.toHaveBeenCalled()
+
+    const face = (entry.inject as unknown as () => PluginManagerFace)()
+    // A Host change before the first render is not a reason to read.
+    b.remote.emit('plugins/changed', [{ reason: 'install' }])
+    b.ctx.emit('connection/reset')
+    await Promise.resolve()
+    expect(b.list).not.toHaveBeenCalled()
+    face.ensure()
+    await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') })
+    expect(b.list).toHaveBeenCalledTimes(1)
+    b.remote.emit('plugins/changed', [{ reason: 'enable', packageName: 'x' }])
+    await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) })
+    b.ctx.emit('connection/reset')
+    await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(3) })
+
+    // Install output folds into an open run only.
+    face.openInstall()
+    face.editInstallSpec('pkg')
+    b.remote.emit('plugins/install-log', [{ jobId: 'j', spec: 'pkg', stream: 'stdout', text: 'early' }])
+    expect(face.hooks.pluginManager.getSnapshot().install.log).toBe('')
+
+    // Shipped preset names resolve over the agent-preset dictionaries the
+    // real plugin registers; user-authored metadata stays untranslated.
+    b.locale.register('settings.agentPreset', 'zh', { presetStandardName: '标准模式' } as never)
+    expect(face.presetName({ id: 'standard', trust: 'system', isDefault: true, rows: [] })).toBe('标准模式')
+    expect(face.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的')
+
+    await fiber.dispose()
+    expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
+    b.remote.emit('plugins/changed', [{ reason: 'install' }])
+    await Promise.resolve()
+    expect(b.list).toHaveBeenCalledTimes(3)
+  })
+})

+ 394 - 0
packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx

@@ -0,0 +1,394 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { PluginManagerSettingsTab } from '../src/client/PluginManagerSettingsTab.tsx'
+import type { PluginManagerSettingsTabProps } from '../src/client/PluginManagerSettingsTab.tsx'
+import type { PluginManagerState, PresetGroup } from '../src/client/manager-store.ts'
+import { en, type PluginManagerLocaleKey } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t = ((key: PluginManagerLocaleKey, params?: Record<string, string>): string =>
+  Object.entries(params ?? {}).reduce(
+    (text, [name, value]) => text.replaceAll(`{${name}}`, value),
+    en[key],
+  )) as PluginManagerSettingsTabProps['t']
+
+function pkg(overrides: Partial<PluginPackageView> = {}): PluginPackageView {
+  return {
+    name: 'dsh-better-sidebar',
+    version: '0.16.0',
+    kind: 'bundle',
+    trust: 'external',
+    stage: 'runtime',
+    installed: true,
+    enabled: true,
+    status: 'running',
+    cordisSameCopy: true,
+    rows: [],
+    overrides: [],
+    addable: [],
+    liveReload: true,
+    ...overrides,
+  }
+}
+
+function preset(overrides: Partial<PresetGroup> = {}): PresetGroup {
+  return { id: 'standard', trust: 'system', name: '标准', isDefault: true, rows: [], ...overrides }
+}
+
+const READY: PluginManagerState = {
+  status: 'ready',
+  packages: [],
+  presets: [],
+  selectedPreset: null,
+  busy: [],
+  notice: null,
+  install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] },
+  confirm: null,
+}
+
+function renderTab(state: Partial<PluginManagerState> = {}) {
+  const store = createSnapshotStore<PluginManagerState>({ ...READY, ...state })
+  const actions = {
+    ensure: vi.fn(),
+    refresh: vi.fn(),
+    openInstall: vi.fn(),
+    closeInstall: vi.fn(),
+    editInstallSpec: vi.fn(),
+    toggleInstallEnable: vi.fn(),
+    runInstall: vi.fn(),
+    setEnabled: vi.fn(),
+    retry: vi.fn(),
+    uninstall: vi.fn(),
+    acknowledgeConfirm: vi.fn(),
+    confirm: vi.fn(),
+    cancelConfirm: vi.fn(),
+    addRow: vi.fn(),
+    removeRow: vi.fn(),
+    setRowDisabled: vi.fn(),
+    selectPreset: vi.fn(),
+    dismissNotice: vi.fn(),
+  }
+  const props = {
+    t,
+    ...actions,
+    presetName: (candidate: PresetGroup) => candidate.name ?? candidate.id,
+    usePluginManager: bindSnapshotSelector(store),
+  } as unknown as PluginManagerSettingsTabProps
+  render(<PluginManagerSettingsTab {...props} />)
+  return { store, actions, set: (next: Partial<PluginManagerState>) => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) } }
+}
+
+describe('PluginManagerSettingsTab', () => {
+  it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
+    const { actions, set } = renderTab({ status: 'loading' })
+    expect(actions.ensure).toHaveBeenCalledTimes(1)
+    expect(screen.getByText(en.loading)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.addPlugin })).toHaveProperty('disabled', true)
+
+    set({ status: 'unavailable' })
+    expect(screen.getByRole('status').textContent).toBe(en.unavailable)
+
+    set({ status: 'error' })
+    expect(screen.getByRole('alert').textContent).toBe(en.error)
+    fireEvent.click(screen.getByRole('button', { name: en.retry }))
+    expect(actions.refresh).toHaveBeenCalledTimes(1)
+    expect(screen.getByText(en.empty)).toBeTruthy()
+    expect(screen.getByText(en.presetNoRoster)).toBeTruthy()
+
+    set({ status: 'ready' })
+    fireEvent.click(screen.getByRole('button', { name: en.refresh }))
+    fireEvent.click(screen.getByRole('button', { name: en.addPlugin }))
+    expect(actions.refresh).toHaveBeenCalledTimes(2)
+    expect(actions.openInstall).toHaveBeenCalledTimes(1)
+  })
+
+  it('lists packages with their tags and switches, and names what waits for a restart', () => {
+    const { version: _unversioned, ...firstPartyPackage } = pkg({
+      name: '@deepseek-ai/dsh-bundle-first-party', title: 'First party', trust: 'builtin', stage: 'boot',
+    })
+    const { actions } = renderTab({
+      packages: [
+        pkg(),
+        firstPartyPackage,
+        pkg({ name: 'broken-bundle', enabled: false, status: 'not-enableable', reason: 'foreign cordis' }),
+        pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain' }),
+        pkg({ name: 'some-lib', kind: 'library', status: 'plain' }),
+        pkg({ name: 'pending-bundle', title: 'Pending', enabled: true, status: 'restart-required' }),
+        pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required' }),
+      ],
+    })
+    expect(screen.getByText(en.restartBanner.replace('{names}', 'Pending, untitled'))).toBeTruthy()
+    expect(document.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7')
+    expect(screen.getAllByText(en.externalTag)).toHaveLength(6)
+    expect(screen.getByText(en.builtinTag)).toBeTruthy()
+    expect(screen.getByText(en.stageBootTag)).toBeTruthy()
+    expect(screen.getByText(en.pluginTag)).toBeTruthy()
+    expect(screen.getByText(en.libraryTag)).toBeTruthy()
+    expect(screen.getByText(en.statusNotEnableable)).toBeTruthy()
+    expect(screen.getAllByText(en.statusPlain)).toHaveLength(2)
+    expect(screen.getByText('dsh-better-sidebar · 0.16.0')).toBeTruthy()
+
+    const sidebar = screen.getByRole('switch', { name: 'Enable better-sidebar' }) as HTMLButtonElement
+    expect(sidebar.getAttribute('aria-checked')).toBe('true')
+    fireEvent.click(sidebar)
+    expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
+    const firstParty = screen.getByRole('switch', { name: 'Enable First party' }) as HTMLButtonElement
+    expect(firstParty.disabled).toBe(true)
+    expect(firstParty.title).toBe(en.builtinLocked)
+    expect(screen.getByRole('switch', { name: 'Enable broken-bundle' })).toHaveProperty('disabled', true)
+    // Plain packages carry no switch.
+    expect(screen.queryByRole('switch', { name: 'Enable tool-foo' })).toBeNull()
+  })
+
+  it('expands a package into its facts, rows, addable modules, and actions', () => {
+    const { actions, set } = renderTab({
+      packages: [pkg({
+        description: 'A sidebar.',
+        status: 'partial',
+        reason: 'one row failed',
+        enginesDsh: '>=0.1.0',
+        probedAt: '2026-09-04T00:00:00Z',
+        overrides: ['ui-sidebar'],
+        rows: [
+          { entryId: 'dsh-better-sidebar/better-sidebar', originalId: 'better-sidebar', moduleName: 'dsh-better-sidebar', enabled: true, phase: 'active' },
+          { entryId: 'dsh-better-sidebar/off', moduleName: 'dsh-better-sidebar/off', enabled: false, disabledBy: 'user', phase: null },
+          { entryId: 'dsh-better-sidebar/gated', moduleName: 'dsh-better-sidebar/gated', enabled: false, disabledBy: 'composition', phase: null },
+          { entryId: 'dsh-better-sidebar/crash', moduleName: 'dsh-better-sidebar/crash', enabled: true, phase: 'failed', failure: { stage: 'apply', message: 'boom' } },
+        ],
+        addable: [
+          { moduleName: 'dsh-better-sidebar/tool', declaredName: './tool', title: 'Sidebar tool', ok: true },
+          { moduleName: 'dsh-better-sidebar/broken', declaredName: './broken', ok: false, error: 'cannot import' },
+        ],
+      })],
+      presets: [preset()],
+    })
+    const expand = screen.getByRole('button', { name: 'Show better-sidebar' })
+    fireEvent.click(expand)
+    expect(screen.getByText('A sidebar.')).toBeTruthy()
+    expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy()
+    expect(screen.getByText('>=0.1.0')).toBeTruthy()
+    expect(screen.getByText(en.cordisSame)).toBeTruthy()
+    expect(screen.getByText('2026-09-04T00:00:00Z')).toBeTruthy()
+    expect(screen.getByText('ui-sidebar')).toBeTruthy()
+    expect(screen.getByRole('img', { name: en.rowPhaseActive })).toBeTruthy()
+    // A prefixed row shows the id its bundle declared.
+    expect(document.querySelector('[data-plugin-row="dsh-better-sidebar/better-sidebar"]')?.textContent).toContain('better-sidebar')
+    expect(screen.getByText(en.rowDisabledByUser)).toBeTruthy()
+    expect(screen.getByText(en.rowDisabledByComposition)).toBeTruthy()
+    expect(screen.getByText('boom')).toBeTruthy()
+    expect(screen.getByText('Sidebar tool')).toBeTruthy()
+    expect(screen.getByText(en.addableNotOk)).toBeTruthy()
+    expect(screen.getByText('cannot import')).toBeTruthy()
+
+    fireEvent.click(screen.getByRole('button', { name: en.addTo }))
+    fireEvent.click(screen.getByRole('menuitem', { name: en.addToGlobal }))
+    expect(actions.addRow).toHaveBeenCalledWith('dsh-better-sidebar', './tool', { kind: 'global' })
+    fireEvent.click(screen.getByRole('button', { name: en.addTo }))
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Preset: 标准' }))
+    expect(actions.addRow).toHaveBeenLastCalledWith('dsh-better-sidebar', './tool', { kind: 'preset', preset: 'standard' })
+    fireEvent.click(screen.getByRole('button', { name: en.addTo }))
+    fireEvent.click(screen.getByRole('button', { name: en.addTo }))
+    expect(screen.queryByRole('menuitem')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: en.addTo }))
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(screen.queryByRole('menuitem')).toBeNull()
+
+    fireEvent.click(screen.getByRole('button', { name: en.retryPackage }))
+    expect(actions.retry).toHaveBeenCalledWith('dsh-better-sidebar')
+    fireEvent.click(screen.getByRole('button', { name: en.uninstall }))
+    expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
+
+    // A busy package keeps its controls inert.
+    set({ busy: ['dsh-better-sidebar'] })
+    expect(screen.getByRole('button', { name: en.retryPackage })).toHaveProperty('disabled', true)
+    expect(screen.getByRole('switch', { name: 'Enable better-sidebar' })).toHaveProperty('disabled', true)
+
+    fireEvent.click(screen.getByRole('button', { name: 'Hide better-sidebar' }))
+    expect(screen.queryByText('A sidebar.')).toBeNull()
+  })
+
+  it('names an unknown or foreign cordis copy, an empty row list, and hides actions a package does not offer', () => {
+    renderTab({
+      packages: [
+        pkg({ name: 'foreign', cordisSameCopy: false, installed: false, status: 'disabled', enabled: false }),
+        pkg({ name: 'unknown', cordisSameCopy: null, trust: 'builtin', kind: 'plugin', status: 'plain' }),
+      ],
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'Show foreign' }))
+    expect(screen.getByText(en.cordisForeign)).toBeTruthy()
+    expect(screen.getByText(en.rowsEmpty)).toBeTruthy()
+    expect(screen.queryByRole('button', { name: en.uninstall })).toBeNull()
+    expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Show unknown' }))
+    expect(screen.getByText(en.cordisUnknown)).toBeTruthy()
+    expect(screen.queryByText(en.rowsLabel)).toBeNull()
+  })
+
+  it('renders the selected preset composition with row switches, removal, and the switcher', () => {
+    const rows: PresetGroup['rows'] = [
+      { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active', source: 'preset' },
+      { entryId: 'extra', moduleName: '@fixture/extra', enabled: false, fiberPhase: null, source: 'user', disabledBy: 'user' },
+      { entryId: 'gated', moduleName: '@fixture/gated', enabled: false, fiberPhase: null, source: 'preset', disabledBy: 'composition' },
+      { entryId: 'pwsh', moduleName: '@fixture/pwsh', enabled: 'conditional', fiberPhase: null, source: 'preset' },
+      { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed', source: 'preset' },
+      { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null, source: 'preset' },
+    ]
+    const { actions, set } = renderTab({
+      presets: [preset({ rows }), preset({ id: 'research', trust: 'user', name: 'Research', isDefault: false })],
+    })
+    const switcher = screen.getByRole('button', { name: en.switcherLabel })
+    expect(switcher.textContent).toBe('标准 (default)')
+    expect(screen.getByRole('img', { name: en.rowPhaseActive })).toBeTruthy()
+    expect(screen.getByText(en.rowSourceUser)).toBeTruthy()
+    expect(screen.getByText(en.rowDisabledByUser)).toBeTruthy()
+    expect(screen.getByText(en.rowDisabledByComposition)).toBeTruthy()
+    expect(screen.getByText(en.rowConditional)).toBeTruthy()
+    expect(screen.getByText(en.rowPhaseFailed)).toBeTruthy()
+    expect(screen.getByText(en.rowNoId)).toBeTruthy()
+    expect(screen.queryByRole('switch', { name: 'Enable row anonymous' })).toBeNull()
+
+    fireEvent.click(screen.getByRole('switch', { name: 'Enable row bash' }))
+    expect(actions.setRowDisabled).toHaveBeenCalledWith({ kind: 'preset', preset: 'standard' }, 'bash', true)
+    fireEvent.click(screen.getByRole('switch', { name: 'Enable row extra' }))
+    expect(actions.setRowDisabled).toHaveBeenLastCalledWith({ kind: 'preset', preset: 'standard' }, 'extra', false)
+    fireEvent.click(screen.getByRole('button', { name: 'Remove row extra' }))
+    expect(actions.removeRow).toHaveBeenCalledWith({ kind: 'preset', preset: 'standard' }, 'extra')
+    expect(screen.queryByRole('button', { name: 'Remove row bash' })).toBeNull()
+
+    fireEvent.click(switcher)
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(screen.queryByRole('menuitem')).toBeNull()
+    fireEvent.click(switcher)
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Research' }))
+    expect(actions.selectPreset).toHaveBeenCalledWith('research')
+    set({ selectedPreset: 'research' })
+    expect(screen.getByText(en.presetRowsEmpty)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('Research')
+
+    set({ busy: ['preset:standard:bash'], selectedPreset: 'standard' })
+    expect(screen.getByRole('switch', { name: 'Enable row bash' })).toHaveProperty('disabled', true)
+
+    set({ presets: [preset({ id: 'broken', broken: 'bad yaml', isDefault: false, rows })], selectedPreset: null })
+    expect(screen.getByRole('alert').textContent).toBe('bad yaml')
+    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('标准 (failed to load)')
+  })
+
+  it('shows each notice with the Host reason and dismisses it', () => {
+    const { actions, set } = renderTab({ notice: { kind: 'restart', packageName: 'x' } })
+    expect(screen.getByRole('status').textContent).toContain(en.restartNotice)
+    fireEvent.click(screen.getByRole('button', { name: en.dismiss }))
+    expect(actions.dismissNotice).toHaveBeenCalledTimes(1)
+    set({ notice: { kind: 'done' } })
+    expect(screen.getByRole('status').textContent).toContain(en.doneNotice)
+    set({ notice: { kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: 'x' } })
+    expect(screen.getByRole('alert').textContent).toContain('Cannot be enabled: foreign cordis')
+    set({ notice: { kind: 'failed', code: 'plugins/enable-failed', reason: 'tree rejected', packageName: 'x' } })
+    expect(screen.getByRole('alert').textContent).toContain('tree rejected')
+    set({ notice: { kind: 'failed', code: 'plugins/row-conflict', reason: 'taken', rowId: 'r1' } })
+    expect(screen.getByRole('alert').textContent).toContain('id r1')
+    set({ notice: { kind: 'failed', code: 'plugins/row-conflict', reason: 'taken' } })
+    expect(screen.getByRole('alert').textContent).toContain('id .')
+    set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'missing', packageName: 'y' } })
+    expect(screen.getByRole('alert').textContent).toContain('y is not installed')
+    set({ notice: { kind: 'failed', code: 'plugins/not-installed', reason: 'missing' } })
+    expect(screen.getByRole('alert').textContent).toContain(' is not installed')
+    set({ notice: { kind: 'failed', code: 'gateway/internal', reason: 'offline' } })
+    expect(screen.getByRole('alert').textContent).toContain('The action failed: offline')
+  })
+
+  it('drives the install dialog through its phases', () => {
+    const { actions, set } = renderTab({ install: { open: true, spec: '', enable: true, phase: 'idle', log: '', installed: [] } })
+    const dialog = screen.getByRole('dialog', { name: en.installTitle })
+    expect(dialog).toBeTruthy()
+    const spec = screen.getByLabelText(en.installSpecLabel) as HTMLInputElement
+    fireEvent.change(spec, { target: { value: 'pkg' } })
+    expect(actions.editInstallSpec).toHaveBeenCalledWith('pkg')
+    fireEvent.click(screen.getByLabelText(en.installEnable))
+    expect(actions.toggleInstallEnable).toHaveBeenCalledTimes(1)
+    expect(screen.getByRole('button', { name: en.installRun })).toHaveProperty('disabled', true)
+    expect(screen.queryByLabelText(en.installLogLabel)).toBeNull()
+
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'idle', log: '', installed: [] } })
+    fireEvent.click(screen.getByRole('button', { name: en.installRun }))
+    expect(actions.runInstall).toHaveBeenCalledTimes(1)
+    fireEvent.click(screen.getByRole('button', { name: en.cancel }))
+    expect(actions.closeInstall).toHaveBeenCalledTimes(1)
+
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'running', log: 'Progress', installed: [] } })
+    expect(screen.getByRole('button', { name: en.installRunning })).toHaveProperty('disabled', true)
+    expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('Progress')
+    expect(screen.getByLabelText(en.installSpecLabel)).toHaveProperty('disabled', true)
+
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: 'Progress', installed: ['pkg'] } })
+    expect(screen.getByRole('status').textContent).toBe('Installed: pkg')
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'done', log: '', installed: [] } })
+    expect(screen.getByRole('status').textContent).toBe(en.installDoneNothing)
+    fireEvent.click(screen.getByRole('button', { name: en.installClose }))
+    expect(actions.closeInstall).toHaveBeenCalledTimes(2)
+
+    set({ install: { open: true, spec: 'pkg', enable: false, phase: 'failed', log: 'ERR', installed: [] } })
+    expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
+    expect(screen.getByLabelText(en.installLogLabel).textContent).toBe('ERR')
+    fireEvent.click(screen.getByRole('button', { name: en.close }))
+    expect(actions.closeInstall).toHaveBeenCalledTimes(3)
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(actions.closeInstall).toHaveBeenCalledTimes(4)
+
+    set({ install: { open: false, spec: '', enable: true, phase: 'idle', log: '', installed: [] } })
+    expect(screen.queryByRole('dialog')).toBeNull()
+  })
+
+  it('confirms a destructive action only once its dependents are known and acknowledged', () => {
+    const { actions, set } = renderTab({
+      presets: [preset()],
+      confirm: { action: 'uninstall', packageName: 'dsh-better-sidebar', dependents: undefined, acknowledged: false },
+    })
+    expect(screen.getByRole('dialog', { name: 'Uninstall better-sidebar' })).toBeTruthy()
+    expect(screen.getByText(en.confirmUninstallDescription)).toBeTruthy()
+    expect(screen.getByText(en.loading)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.confirm })).toHaveProperty('disabled', true)
+
+    set({
+      confirm: {
+        action: 'uninstall',
+        packageName: 'dsh-better-sidebar',
+        acknowledged: false,
+        dependents: {
+          services: [{ service: 'sidebar', providedBy: 'dsh-better-sidebar/better-sidebar', injectedBy: ['ui-x', 'ui-y'] }],
+          references: [
+            { target: { kind: 'global' }, rowId: 'g1', moduleName: 'dsh-better-sidebar/tool' },
+            { target: { kind: 'preset', preset: 'standard' }, rowId: 'p1', moduleName: 'dsh-better-sidebar/tool' },
+            { target: { kind: 'preset', preset: 'gone' }, rowId: 'p2', moduleName: 'dsh-better-sidebar/tool' },
+          ],
+        },
+      },
+    })
+    expect(screen.getByText(en.confirmDependents)).toBeTruthy()
+    expect(screen.getByText('Service sidebar (provided by dsh-better-sidebar/better-sidebar) is injected by ui-x, ui-y')).toBeTruthy()
+    expect(screen.getByText('Row g1 in the global patch layer names dsh-better-sidebar/tool')).toBeTruthy()
+    expect(screen.getByText('Row p1 in preset 标准 names dsh-better-sidebar/tool')).toBeTruthy()
+    expect(screen.getByText('Row p2 in preset gone names dsh-better-sidebar/tool')).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.confirm })).toHaveProperty('disabled', true)
+    fireEvent.click(screen.getByLabelText(en.acknowledge))
+    expect(actions.acknowledgeConfirm).toHaveBeenCalledWith(true)
+
+    set({
+      confirm: {
+        action: 'disable', packageName: 'dsh-better-sidebar', acknowledged: true,
+        dependents: { services: [], references: [] },
+      },
+    })
+    expect(screen.getByRole('dialog', { name: 'Disable better-sidebar' })).toBeTruthy()
+    expect(screen.getByText(en.confirmDisableDescription)).toBeTruthy()
+    expect(screen.queryByText(en.confirmDependents)).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: en.confirm }))
+    expect(actions.confirm).toHaveBeenCalledTimes(1)
+    fireEvent.click(screen.getByRole('button', { name: en.cancel }))
+    expect(actions.cancelConfirm).toHaveBeenCalledTimes(1)
+  })
+})

+ 359 - 0
packages/client/ui-settings-plugin-manager/tests/manager-store.client.spec.ts

@@ -0,0 +1,359 @@
+/**
+ * The manager store: what it reads, how actions cross the wire, which
+ * failures become notices, and how the install run folds its output.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { PluginManagerController, rowKey, type PresetGroup } from '../src/client/manager-store.ts'
+
+const BUNDLE: PluginPackageView = {
+  name: 'dsh-better-sidebar',
+  version: '0.16.0',
+  kind: 'bundle',
+  trust: 'external',
+  stage: 'runtime',
+  installed: true,
+  enabled: false,
+  status: 'disabled',
+  cordisSameCopy: true,
+  rows: [],
+  overrides: [],
+  addable: [],
+  liveReload: true,
+}
+
+const STANDARD: PresetGroup = {
+  id: 'standard',
+  trust: 'system',
+  isDefault: true,
+  rows: [{ entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null, source: 'preset' }],
+}
+
+/** What one install run answers. */
+type InstallValue = { installed: string[]; enabled: never[]; installedOnly: never[]; plain: never[]; jobId: string }
+
+function ok<T>(value: T) {
+  return { ok: true as const, value }
+}
+
+function refused(code: string, message: string, details: object = {}) {
+  // The double's code map is keyed by literal codes; a spec-chosen string stands in.
+  return { ok: false as const, error: new RemoteError(code as never, message, details as never) }
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((res) => { resolve = res })
+  return { promise, resolve }
+}
+
+function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}) {
+  const plugins = {
+    list: vi.fn(() => Promise.resolve(ok([BUNDLE]))),
+    add: vi.fn(() => Promise.resolve(ok({ installed: ['a'], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))),
+    uninstall: vi.fn(() => Promise.resolve(ok(undefined))),
+    enable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
+    disable: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'restart' }))),
+    retry: vi.fn(() => Promise.resolve(ok({ changed: true, effect: 'live' }))),
+    addRow: vi.fn(() => Promise.resolve(ok({ target: { kind: 'global' }, rowId: 'r', file: '/f' }))),
+    removeRow: vi.fn(() => Promise.resolve(ok(undefined))),
+    setRowDisabled: vi.fn(() => Promise.resolve(ok(undefined))),
+    dependents: vi.fn(() => Promise.resolve(ok({ services: [], references: [] }))),
+    ...overrides,
+  }
+  const inventory = { list: vi.fn(() => Promise.resolve(ok({ entries: [], agentPresets: [STANDARD] }))) }
+  const ctx = { remote: { plugins, pluginInventory: inventory } } as never
+  const controller = new PluginManagerController(ctx, preset => preset.name ?? preset.id)
+  const face = controller.inject()
+  return { plugins, inventory, controller, face, state: () => controller.getSnapshot() }
+}
+
+describe('PluginManagerController', () => {
+  it('starts idle, reads packages and presets on first use, and folds concurrent loads', async () => {
+    const gate = deferred<ReturnType<typeof ok<PluginPackageView[]>>>()
+    const { plugins, face, state, controller } = bench({ list: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])) })
+    expect(state().status).toBe('idle')
+    face.ensure()
+    face.ensure()
+    await Promise.resolve()
+    expect(state().status).toBe('loading')
+    const mid = controller.load()
+    gate.resolve(ok([]))
+    await mid
+    // The in-flight read reran once for the load that landed mid-read.
+    expect(plugins.list).toHaveBeenCalledTimes(2)
+    expect(state()).toMatchObject({ status: 'ready', packages: [BUNDLE], presets: [STANDARD] })
+    face.ensure()
+    expect(plugins.list).toHaveBeenCalledTimes(2)
+    face.refresh()
+    await controller.load()
+    expect(plugins.list).toHaveBeenCalledTimes(3)
+    expect(face.presetName(STANDARD)).toBe('standard')
+  })
+
+  it('reports an unavailable profile runtime and keeps the last packages across a failed read', async () => {
+    const { plugins, controller, state } = bench({
+      list: vi.fn()
+        .mockResolvedValueOnce(refused('plugins/unavailable', 'no profile', { reason: 'no profile' }))
+        .mockResolvedValueOnce(ok([BUNDLE]))
+        .mockResolvedValueOnce(refused('gateway/internal', 'boom')),
+    })
+    await controller.load()
+    expect(state().status).toBe('unavailable')
+    await controller.load()
+    expect(state()).toMatchObject({ status: 'ready', packages: [BUNDLE] })
+    await controller.load()
+    expect(state()).toMatchObject({ status: 'error', packages: [BUNDLE] })
+    expect(plugins.list).toHaveBeenCalledTimes(3)
+  })
+
+  it('keeps the held presets when the inventory read is refused', async () => {
+    const { inventory, controller, state } = bench()
+    await controller.load()
+    inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never)
+    await controller.load()
+    expect(state().presets).toEqual([STANDARD])
+  })
+
+  it('enables a bundle, marks it busy meanwhile, and says when a restart is needed', async () => {
+    const gate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' | 'restart' }>>>()
+    const { plugins, face, state, controller } = bench({ enable: vi.fn().mockReturnValueOnce(gate.promise) })
+    await controller.load()
+    face.setEnabled(BUNDLE.name, true)
+    face.setEnabled(BUNDLE.name, true)
+    expect(state().busy).toEqual([BUNDLE.name])
+    expect(plugins.enable).toHaveBeenCalledTimes(1)
+    gate.resolve(ok({ changed: true, effect: 'restart' }))
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name })
+    expect(plugins.list).toHaveBeenCalledTimes(2)
+    face.dismissNotice()
+    expect(state().notice).toBeNull()
+  })
+
+  it('turns a refused enable into a notice carrying the Host reason, or its message without one', async () => {
+    const { face, state, controller } = bench({
+      enable: vi.fn()
+        .mockResolvedValueOnce(refused('plugins/not-enableable', 'plugin-manager: x cannot', { reason: 'foreign cordis' }))
+        .mockResolvedValueOnce(refused('plugins/not-enableable', 'no string reason', { reason: 42 })),
+    })
+    await controller.load()
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).not.toBeNull() })
+    expect(state().notice).toEqual({
+      kind: 'failed', code: 'plugins/not-enableable', reason: 'foreign cordis', packageName: BUNDLE.name,
+    })
+    expect(state().busy).toEqual([])
+    face.setEnabled(BUNDLE.name, true)
+    await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'no string reason' }) })
+  })
+
+  it('disables at once when nothing depends on the bundle, and asks first when something does', async () => {
+    const { plugins, face, state, controller } = bench()
+    await controller.load()
+    face.setEnabled(BUNDLE.name, false)
+    await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledWith(BUNDLE.name) })
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name }) })
+
+    plugins.dependents.mockResolvedValueOnce(ok({
+      services: [{ service: 'sidebar', providedBy: 'dsh-better-sidebar/better-sidebar', injectedBy: ['x'] }],
+      references: [],
+    }) as never)
+    face.setEnabled(BUNDLE.name, false)
+    expect(state().confirm).toEqual({ action: 'disable', packageName: BUNDLE.name, dependents: undefined, acknowledged: false })
+    await vi.waitFor(() => { expect(state().confirm?.dependents).toBeDefined() })
+    face.acknowledgeConfirm(true)
+    expect(state().confirm?.acknowledged).toBe(true)
+    face.confirm()
+    expect(state().confirm).toBeNull()
+    await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledTimes(2) })
+  })
+
+  it('always asks before uninstalling, and cancelling runs nothing', async () => {
+    const { plugins, face, state, controller } = bench()
+    await controller.load()
+    face.uninstall(BUNDLE.name)
+    await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) })
+    face.cancelConfirm()
+    expect(state().confirm).toBeNull()
+    face.confirm()
+    await Promise.resolve()
+    expect(plugins.uninstall).not.toHaveBeenCalled()
+
+    face.uninstall(BUNDLE.name)
+    await vi.waitFor(() => { expect(state().confirm?.dependents).toBeDefined() })
+    face.acknowledgeConfirm(true)
+    face.confirm()
+    await vi.waitFor(() => { expect(plugins.uninstall).toHaveBeenCalledWith(BUNDLE.name) })
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'done', packageName: BUNDLE.name }) })
+    // An acknowledgement with no confirmation open is ignored.
+    face.acknowledgeConfirm(true)
+    expect(state().confirm).toBeNull()
+  })
+
+  it('drops a dependents answer that arrives after the confirmation changed', async () => {
+    const gate = deferred<ReturnType<typeof ok<{ services: never[]; references: never[] }>>>()
+    const { plugins, face, state, controller } = bench({ dependents: vi.fn().mockReturnValueOnce(gate.promise) })
+    await controller.load()
+    face.uninstall(BUNDLE.name)
+    face.cancelConfirm()
+    gate.resolve(ok({ services: [], references: [] }))
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state().confirm).toBeNull()
+    // A refused dependents read confirms with an empty list rather than blocking.
+    plugins.dependents.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never)
+    face.uninstall(BUNDLE.name)
+    await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [], references: [] }) })
+  })
+
+  it('retries, adds rows, removes rows, and switches rows under their own busy keys', async () => {
+    const { plugins, face, state, controller } = bench()
+    await controller.load()
+    face.retry(BUNDLE.name)
+    await vi.waitFor(() => { expect(plugins.retry).toHaveBeenCalledWith(BUNDLE.name) })
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'done', packageName: BUNDLE.name }) })
+
+    face.addRow('@fixture/tool', '.', { kind: 'preset', preset: 'standard' })
+    await vi.waitFor(() => {
+      expect(plugins.addRow).toHaveBeenCalledWith('@fixture/tool', { kind: 'preset', preset: 'standard' }, { module: '.' })
+    })
+    await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'done', packageName: '@fixture/tool' }) })
+
+    const target = { kind: 'preset', preset: 'standard' } as const
+    face.setRowDisabled(target, 'bash', true)
+    expect(state().busy).toEqual([rowKey(target, 'bash')])
+    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith(target, 'bash', true) })
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    face.removeRow(target, 'extra')
+    await vi.waitFor(() => { expect(plugins.removeRow).toHaveBeenCalledWith(target, 'extra') })
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    face.selectPreset('research')
+    expect(state().selectedPreset).toBe('research')
+  })
+
+  it('reports a row conflict with the row id and a thrown transport failure generically', async () => {
+    const { face, state, controller } = bench({
+      addRow: vi.fn(() => Promise.resolve(refused('plugins/row-conflict', 'taken', { rowId: 'r', target: { kind: 'global' } }))),
+      removeRow: vi.fn(() => Promise.reject(new Error('offline'))),
+      // A rejection that is not an Error reaches the notice by its string form.
+      setRowDisabled: vi.fn().mockRejectedValueOnce('plain text'),
+    })
+    await controller.load()
+    face.addRow('p', 'p', { kind: 'global' })
+    await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed' }) })
+    expect(state().notice).toEqual({ kind: 'failed', code: 'plugins/row-conflict', reason: 'taken', packageName: 'p' })
+    face.removeRow({ kind: 'global' }, 'r')
+    await vi.waitFor(() => {
+      expect(state().notice).toEqual({ kind: 'failed', code: 'gateway/internal', reason: 'offline', rowId: 'r' })
+    })
+    face.setRowDisabled({ kind: 'global' }, 'r', true)
+    await vi.waitFor(() => { expect(state().notice).toMatchObject({ reason: 'plain text' }) })
+  })
+
+  it('runs an install, folds its own log chunks, and closes only once it settled', async () => {
+    const gate = deferred<ReturnType<typeof ok<InstallValue>>>()
+    const { plugins, face, state, controller } = bench({ add: vi.fn().mockReturnValueOnce(gate.promise) })
+    await controller.load()
+    face.runInstall()
+    expect(plugins.add).not.toHaveBeenCalled()
+    face.openInstall()
+    expect(state().install).toMatchObject({ open: true, spec: '', enable: true, phase: 'idle' })
+    face.editInstallSpec('  dsh-better-sidebar ')
+    face.toggleInstallEnable()
+    face.runInstall()
+    face.runInstall()
+    expect(plugins.add).toHaveBeenCalledTimes(1)
+    expect(plugins.add).toHaveBeenCalledWith('dsh-better-sidebar', { enable: false })
+    expect(state().install.phase).toBe('running')
+    face.closeInstall()
+    expect(state().install.open).toBe(true)
+    controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'Progress\n' })
+    controller.appendLog({ jobId: 'j2', spec: 'other', stream: 'stdout', text: 'not mine' })
+    expect(state().install.log).toBe('Progress\n')
+    gate.resolve(ok({ installed: ['dsh-better-sidebar'], enabled: [], installedOnly: [], plain: [], jobId: 'j1' }))
+    await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
+    expect(state().install.installed).toEqual(['dsh-better-sidebar'])
+    controller.appendLog({ jobId: 'j1', spec: 'dsh-better-sidebar', stream: 'stdout', text: 'late', exitCode: 0 })
+    expect(state().install.log).toBe('Progress\n')
+    await vi.waitFor(() => { expect(plugins.list).toHaveBeenCalledTimes(2) })
+    face.closeInstall()
+    expect(state().install.open).toBe(false)
+  })
+
+  it('shows the Host log of a failed install, or its message when no chunk arrived', async () => {
+    const { face, state, controller, plugins } = bench({
+      add: vi.fn()
+        .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ERR_PNPM' }))
+        .mockResolvedValueOnce(refused('gateway/internal', 'offline'))
+        .mockResolvedValueOnce(refused('plugins/install-failed', 'exit 1', { spec: 'x', exitCode: 1, log: 'ignored' })),
+    })
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('x')
+    face.runInstall()
+    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    expect(state().install.log).toBe('ERR_PNPM')
+    face.runInstall()
+    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(2) })
+    await vi.waitFor(() => { expect(state().install.log).toBe('offline') })
+    face.runInstall()
+    controller.appendLog({ jobId: 'j', spec: 'x', stream: 'stderr', text: 'streamed' })
+    await vi.waitFor(() => { expect(plugins.add).toHaveBeenCalledTimes(3) })
+    await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
+    expect(state().install.log).toBe('streamed')
+  })
+
+  it('drops every late settlement after disposal', async () => {
+    const enableGate = deferred<ReturnType<typeof ok<{ changed: boolean; effect: 'live' }>>>()
+    const installGate = deferred<ReturnType<typeof ok<InstallValue>>>()
+    const { face, state, controller } = bench({
+      enable: vi.fn().mockReturnValueOnce(enableGate.promise),
+      add: vi.fn().mockReturnValueOnce(installGate.promise),
+    })
+    await controller.load()
+    face.openInstall()
+    face.editInstallSpec('x')
+    face.runInstall()
+    face.setEnabled(BUNDLE.name, true)
+    const before = state()
+    controller.dispose()
+    enableGate.resolve(ok({ changed: true, effect: 'live' }))
+    installGate.resolve(ok({ installed: [], enabled: [], installedOnly: [], plain: [], jobId: 'j' }))
+    await Promise.resolve()
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state()).toBe(before)
+    await controller.load()
+    expect(state()).toBe(before)
+    face.setEnabled(BUNDLE.name, false)
+    expect(state()).toBe(before)
+  })
+
+  it('drops a read that settles after disposal', async () => {
+    const gate = deferred<ReturnType<typeof ok<PluginPackageView[]>>>()
+    const { state, controller } = bench({ list: vi.fn().mockReturnValueOnce(gate.promise) })
+    const loading = controller.load()
+    await Promise.resolve()
+    const before = state()
+    controller.dispose()
+    gate.resolve(ok([BUNDLE]))
+    await loading
+    expect(state()).toBe(before)
+  })
+
+  it('reports a thrown enable failure after disposal to nobody', async () => {
+    const enableGate = deferred<never>()
+    const { face, state, controller } = bench({ enable: vi.fn().mockReturnValueOnce(enableGate.promise) })
+    await controller.load()
+    face.setEnabled(BUNDLE.name, true)
+    const before = state()
+    controller.dispose()
+    enableGate.resolve(refused('gateway/internal', 'late') as never)
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state()).toBe(before)
+  })
+})

+ 25 - 0
packages/client/ui-settings-plugin-manager/tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    { "path": "../../../vendor/cordis" },
+    { "path": "../../api/remotes/tsconfig.client.json" },
+    { "path": "../../host/plugin-manager" },
+    { "path": "../../preset/agent-presets" },
+    { "path": "../../test-support/client-runtime" },
+    { "path": "../locale" },
+    { "path": "../store" },
+    { "path": "../ui-agent-preset" },
+    { "path": "../ui-primitives" },
+    { "path": "../ui-renderer" },
+    { "path": "../ui-settings" },
+    { "path": "../ui-settings-plugins" },
+    { "path": "../ui-slots" }
+  ]
+}

+ 3 - 0
packages/client/ui-settings-plugin-manager/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-manager', ['lib/types/index.js'])

+ 2 - 2
packages/client/ui-settings-plugins/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
-README.md: 444b05b16b79a3a660a71984c92969d367fe22a7
-README.zh.md: c05859fc9f12ce0fe2013dc9f3549e156bd7234f
+README.md: 032dc5489011973556b7945f45d88898255e0026
+README.zh.md: 7d6879a96f34a563607e87fe6a2b4fefd3041cd7

+ 6 - 2
packages/client/ui-settings-plugins/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-client-ui-settings-plugins` is the **Plugins** settings section of the dsh web client: users edit host-plane plugin configuration on its **Plugin configuration** tab, and feature plugins contribute their own pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns: a card shows the plugin's name and what it governs, and expanding it reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. Cards stage edits locally and write only on save, with every write fenced by the namespace revision the form read.
+`dsh-client-ui-settings-plugins` is the **Plugins** settings section of the dsh web client: users edit host-plane plugin configuration on its **Plugin configuration** tab, and feature plugins contribute their own pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns: a card shows the plugin's name and what it governs, and expanding it reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. One switch above the cards chooses the scope every card edits — the values shared by all agent presets, or one preset's own — and under a preset a field marks whether it inherits the shared value. Cards stage edits locally per scope and write only on save, with every write fenced by the namespace revision the form read.
 
 ## Table of Contents
 
@@ -25,7 +25,11 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`).
+Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), the DeepSeek search provider (`web-search-deepseek`), and the filesystem skill provider's extra roots (`skill-filesystem`).
+
+### Choosing the scope
+
+The **Applies to** switch above the cards selects the settings scope every card edits: **All presets** is the global instance of each namespace, and each agent preset in the roster is that preset's named scope (`preset/<id>`). Under a preset, a field the preset does not override shows **Inherited** when the shared user layer carries it, a reset stages the inherited value rather than the composition default, and a card whose plugin the preset does not compose says so — its values are stored and take effect once a preset composes the plugin. Drafts belong to the scope they were typed under and survive a switch. A roster the Host refuses leaves the global instance editable and says the presets could not be listed.
 
 ### What appears here
 

+ 6 - 2
packages/client/ui-settings-plugins/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-client-ui-settings-plugins` 是 dsh Web 客户端的**插件**设置分区:用户在其**插件配置**标签页上编辑宿主平面插件配置,功能插件则通过 `settings.plugins.tab` 贡献自己的页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片:卡片展示插件名称及其管辖范围,展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。卡片暂存用户输入,只有用户保存时才写入,且每次写入都以表单读取时的命名空间 revision 设栅。
+`dsh-client-ui-settings-plugins` 是 dsh Web 客户端的**插件**设置分区:用户在其**插件配置**标签页上编辑宿主平面插件配置,功能插件则通过 `settings.plugins.tab` 贡献自己的页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片:卡片展示插件名称及其管辖范围,展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。卡片上方的一个开关选择每张卡片编辑的范围——所有 Agent 预设共用的值,或某个预设自己的值——在预设之下,字段会标注它是否继承共用值。卡片按范围暂存用户输入,只有用户保存时才写入,且每次写入都以表单读取时的命名空间 revision 设栅。
 
 ## 目录
 
@@ -25,7 +25,11 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
+打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)、DeepSeek 搜索提供方(`web-search-deepseek`)以及文件系统技能提供方的额外目录(`skill-filesystem`)。
+
+### 选择生效范围
+
+卡片上方的**生效范围**开关选择每张卡片编辑的 settings scope:**所有预设**是每个命名空间的全局实例,roster 中的每个 Agent 预设则是该预设的具名 scope(`preset/<id>`)。在某个预设之下,预设未覆盖而共用用户层携带的字段显示**继承**,重置暂存的是继承值而不是组合默认值,插件未被该预设组合的卡片会说明这一点——它的值会被保存,并在某个预设组合该插件后生效。草稿属于输入它时所在的范围,切换后仍然保留。宿主拒绝 roster 时全局实例仍可编辑,并提示无法列出预设。
 
 ### 这里会出现什么
 

+ 6 - 3
packages/client/ui-settings-plugins/package.json

@@ -30,7 +30,8 @@
       "inject": [
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-settings",
-        "@deepseek-ai/dsh-api-remotes"
+        "@deepseek-ai/dsh-api-remotes",
+        "@deepseek-ai/dsh-client-ui-agent-preset"
       ],
       "platform": "web"
     }
@@ -45,16 +46,18 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0",
-    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^"
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 1 - 0
packages/client/ui-settings-plugins/src/client/AgentLoopCard.tsx

@@ -34,6 +34,7 @@ export function AgentLoopCard(props: AgentLoopCardProps) {
         label={t('agentLoopMaxParallel')}
         hint={t('agentLoopMaxParallelHint')}
         overriddenLabel={t('overridden')}
+        inheritedLabel={t('inherited')}
         resetLabel={t('reset')}
         invalidLabel={t('invalidNumber')}
         numeric

+ 2 - 0
packages/client/ui-settings-plugins/src/client/BashCard.tsx

@@ -35,6 +35,7 @@ export function BashCard(props: BashCardProps) {
         label={t('bashTimeoutMs')}
         hint={t('bashTimeoutMsHint')}
         overriddenLabel={t('overridden')}
+        inheritedLabel={t('inherited')}
         resetLabel={t('reset')}
         invalidLabel={t('invalidNumber')}
         numeric
@@ -48,6 +49,7 @@ export function BashCard(props: BashCardProps) {
         label={t('bashMaxOutputBytes')}
         hint={t('bashMaxOutputBytesHint')}
         overriddenLabel={t('overridden')}
+        inheritedLabel={t('inherited')}
         resetLabel={t('reset')}
         invalidLabel={t('invalidNumber')}
         numeric

+ 80 - 18
packages/client/ui-settings-plugins/src/client/ConfigurablePluginsTab.tsx

@@ -4,40 +4,102 @@
  * The tab enumerates settings namespaces but never interprets one — a card
  * arrives through `settings.plugin.item` keyed by the namespace it edits, so a
  * plugin that ships a browser half owns its own card and this tab only decides
- * which keys to dispatch.
+ * which keys to dispatch. Above the cards, one switch chooses the scope every
+ * card edits: the values shared by all agent presets, or one preset's own.
  */
 
-import { Fragment } from 'react'
+import { Fragment, useEffect, useState } from 'react'
+import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type {} from './slot-contract.ts'
 import type { ConfigurablePluginsTabFace } from './tab-store.ts'
+import { PRESET_SCOPE_PREFIX, type PresetScopeRow, type ScopeSwitcherFace } from './scope-switcher.ts'
 import css from './PluginsSettingsSection.module.css'
 
+/** Menu id of the global instance; a scope id never collides with it, as every scope carries a `/`. */
+const GLOBAL_OPTION = 'global'
+
 /** Props the renderer binds for the configurable tab. */
 export type ConfigurablePluginsTabProps =
   PropsRuntime<'settings.plugins.tab'>
   & PropsLocale<'settings.plugins'>
   & PropsRenderSlots<'settings.plugin.item'>
-  & InjectFace<ConfigurablePluginsTabFace>
+  & InjectFace<ConfigurablePluginsTabFace & ScopeSwitcherFace>
 
 /**
- * Render cards registered by plugins that expose editable settings.
- * @param props - locale copy, slot rendering, and the namespaces to dispatch.
- * @returns the card list, or the empty line once the Host has answered.
+ * Render the scope switch and the cards registered by plugins that expose
+ * editable settings.
+ * @param props - locale copy, slot rendering, the namespaces to dispatch, and the scope switch.
+ * @returns the switch and card list, or the empty line once the Host has answered.
  */
 export function ConfigurablePluginsTab(props: ConfigurablePluginsTabProps) {
-  const { t, renderSlot } = props
+  const { t, renderSlot, ensureScopes, selectScope, presetName } = props
   const { loaded, namespaces } = props.useConfigurablePlugins(snapshot => snapshot)
-  if (namespaces.length > 0) {
-    return (
-      <ul className={css.cards}>
-        {namespaces.map(ns => (
-          // One dispatch per namespace, so the list identity is the namespace
-          // rather than a position that shifts as cards arrive.
-          <Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
-        ))}
-      </ul>
-    )
+  const switcher = props.useScopeSwitcher(snapshot => snapshot)
+  const [open, setOpen] = useState(false)
+  useEffect(() => { ensureScopes() }, [ensureScopes])
+
+  const presetLabel = (preset: PresetScopeRow): string => {
+    const name = presetName(preset)
+    if (preset.broken !== undefined) return t('scopePresetBroken', { name })
+    if (preset.isDefault) return t('scopePresetDefault', { name })
+    return name
   }
-  return loaded ? <p className={css.empty}>{t('empty')}</p> : null
+  const items = [
+    { id: GLOBAL_OPTION, label: t('scopeGlobal') },
+    ...switcher.presets.map(preset => ({ id: `${PRESET_SCOPE_PREFIX}${preset.id}`, label: presetLabel(preset) })),
+    ...switcher.extraScopes.map(scope => ({ id: scope, label: scope })),
+  ]
+  const selectedId = switcher.scope ?? GLOBAL_OPTION
+  const selected = items.find(item => item.id === selectedId)
+  // A selected scope the options no longer list keeps its raw id visible.
+  const selectedLabel = selected?.label ?? selectedId
+
+  return (
+    <div className={css.configurable}>
+      <div className={css.scopeRow}>
+        <span className={css.scopeLabel}>{t('scopeLabel')}</span>
+        <Menu
+          open={open}
+          onClose={() => { setOpen(false) }}
+          items={items}
+          selectedId={selectedId}
+          onSelect={(id) => {
+            setOpen(false)
+            selectScope(id === GLOBAL_OPTION ? null : id)
+          }}
+          align="end"
+          portal
+          anchor={(
+            <button
+              type="button"
+              className={css.scopeSwitcher}
+              aria-haspopup="menu"
+              aria-expanded={open}
+              aria-label={t('scopeSwitcherLabel')}
+              data-settings-scope={switcher.scope ?? 'global'}
+              onClick={() => { setOpen(value => !value) }}
+            >
+              <span className={css.scopeSwitcherLabel}>{selectedLabel}</span>
+              <IconChevronDownOutline14 className={css.scopeChevron} aria-hidden="true" />
+            </button>
+          )}
+        />
+      </div>
+      <p className={css.scopeHint}>
+        {switcher.status === 'error' ? t('scopeRosterFailed') : t(switcher.scope === undefined ? 'scopeHintGlobal' : 'scopeHintPreset')}
+      </p>
+      {namespaces.length > 0
+        ? (
+          <ul className={css.cards}>
+            {namespaces.map(ns => (
+              // One dispatch per namespace, so the list identity is the namespace
+              // rather than a position that shifts as cards arrive.
+              <Fragment key={ns}>{renderSlot('settings.plugin.item', {}, { entryKey: ns })}</Fragment>
+            ))}
+          </ul>
+        )
+        : loaded ? <p className={css.empty}>{t('empty')}</p> : null}
+    </div>
+  )
 }

+ 3 - 0
packages/client/ui-settings-plugins/src/client/PluginCard.tsx

@@ -82,6 +82,9 @@ export function PluginCard(props: PluginCardProps) {
         ? (
           <div className={css.body}>
             {!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
+            {state.scope !== undefined && !state.registered
+              ? <p className={css.readOnly} role="status">{props.t('notMounted')}</p>
+              : null}
             {props.children}
             <div className={css.footer}>
               {state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}

+ 65 - 0
packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css

@@ -83,3 +83,68 @@
   font-size: 13px;
   color: var(--dsw-alias-label-tertiary);
 }
+
+/* Configurable tab: the scope switch above the card list. */
+
+.configurable {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.scopeRow {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.scopeLabel {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--dsw-alias-label-primary);
+}
+
+.scopeSwitcher {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  max-width: 320px;
+  height: 30px;
+  padding: 0 10px 0 12px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 999px;
+  corner-shape: round;
+  background: var(--dsw-alias-bg-layer-3);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 13px;
+  cursor: pointer;
+}
+
+.scopeSwitcher:hover {
+  border-color: var(--dsw-alias-label-dimmed);
+}
+
+.scopeSwitcher:focus-visible {
+  outline: 2px solid var(--dsw-alias-brand-primary);
+  outline-offset: 1px;
+}
+
+.scopeSwitcherLabel {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.scopeChevron {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.scopeHint {
+  margin: 0;
+  font-size: 12px;
+  line-height: 1.5;
+  color: var(--dsw-alias-label-tertiary);
+}

+ 48 - 0
packages/client/ui-settings-plugins/src/client/SkillFilesystemCard.tsx

@@ -0,0 +1,48 @@
+/** The filesystem skill provider's card: the extra roots the agent discovers skills in. */
+
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { ValueField } from './fields.tsx'
+import { PluginCard } from './PluginCard.tsx'
+import type { SkillFilesystemCardFace } from './skill-filesystem-card-controller.ts'
+import type {} from './slot-contract.ts'
+
+/** Props the renderer binds for the skill roots card. */
+export type SkillFilesystemCardProps =
+  PropsRuntime<'settings.plugin.item'>
+  & PropsLocale<'settings.plugins'>
+  & InjectFace<SkillFilesystemCardFace>
+
+/**
+ * Render the skill roots card.
+ * @param props - locale copy, the card snapshot, and its form actions.
+ * @returns the card.
+ */
+export function SkillFilesystemCard(props: SkillFilesystemCardProps) {
+  const { t } = props
+  const state = props.useSkillFilesystemCard(snapshot => snapshot)
+  return (
+    <PluginCard
+      t={t}
+      titleKey="skillFilesystemTitle"
+      descriptionKey="skillFilesystemDescription"
+      state={state}
+      onSave={props.save}
+      onDiscard={props.discard}
+    >
+      <ValueField
+        id="plugin-config-skill-filesystem-dirs"
+        label={t('skillFilesystemCustomSkillDirs')}
+        hint={t('skillFilesystemCustomSkillDirsHint')}
+        overriddenLabel={t('overridden')}
+        inheritedLabel={t('inherited')}
+        resetLabel={t('reset')}
+        invalidLabel={t('invalidNumber')}
+        multiline
+        disabled={!state.writable}
+        {...state.customSkillDirs}
+        onEdit={(text) => { props.edit('customSkillDirs', text) }}
+        onReset={() => { props.resetField('customSkillDirs') }}
+      />
+    </PluginCard>
+  )
+}

+ 2 - 0
packages/client/ui-settings-plugins/src/client/WebSearchCard.tsx

@@ -53,6 +53,7 @@ export function WebSearchCard(props: WebSearchCardProps) {
         label={t('webSearchBaseUrl')}
         hint={t('webSearchBaseUrlHint')}
         overriddenLabel={t('overridden')}
+        inheritedLabel={t('inherited')}
         resetLabel={t('reset')}
         invalidLabel={t('invalidNumber')}
         disabled={disabled}
@@ -65,6 +66,7 @@ export function WebSearchCard(props: WebSearchCardProps) {
         label={t('webSearchMaxUses')}
         hint={t('webSearchMaxUsesHint')}
         overriddenLabel={t('overridden')}
+        inheritedLabel={t('inherited')}
         resetLabel={t('reset')}
         invalidLabel={t('invalidNumber')}
         numeric

+ 10 - 7
packages/client/ui-settings-plugins/src/client/agent-loop-card-controller.ts

@@ -1,8 +1,8 @@
 /** The agent-loop card's staged form over the `agent-loop` settings namespace. */
 
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
+import { numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
+import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts'
 
 /**
  * Namespace of the agent loop's user-owned settings. Spelled here rather than
@@ -33,14 +33,17 @@ export interface AgentLoopCardFace extends CardActions {
   }
 }
 
-/** Bridges the `agent-loop` scope onto the card's staged form. */
+/** Bridges the `agent-loop` namespace, under the selected scope, onto the card's staged form. */
 export class AgentLoopCardController {
-  private readonly form: CardForm<AgentLoopSettings>
+  private readonly form: ScopedCardForms<AgentLoopSettings>
   private readonly store: SnapshotStore<AgentLoopCardState>
 
-  /** @param scope - the bound settings scope for the `agent-loop` namespace. */
-  constructor(scope: SettingsScope<AgentLoopSettings>) {
-    this.form = new CardForm(scope, [numberField('maxParallelToolCalls')])
+  /**
+   * @param selection - the scope selection shared with the tab.
+   * @param bindScope - binds the `agent-loop` namespace under one scope.
+   */
+  constructor(selection: ScopeSelection, bindScope: BindScope<AgentLoopSettings>) {
+    this.form = new ScopedCardForms(selection, bindScope, [numberField('maxParallelToolCalls')])
     this.store = this.form.bind(() => this.projection())
   }
 

+ 10 - 7
packages/client/ui-settings-plugins/src/client/bash-card-controller.ts

@@ -1,8 +1,8 @@
 /** The shell card's staged form over the `bash` settings namespace. */
 
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
-import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
+import { numberField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
+import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts'
 
 /**
  * Namespace of the shell capability. Spelled here rather than imported: a
@@ -35,14 +35,17 @@ export interface BashCardFace extends CardActions {
   }
 }
 
-/** Bridges the `bash` scope onto the shell card's staged form. */
+/** Bridges the `bash` namespace, under the selected scope, onto the shell card's staged form. */
 export class BashCardController {
-  private readonly form: CardForm<BashSettings>
+  private readonly form: ScopedCardForms<BashSettings>
   private readonly store: SnapshotStore<BashCardState>
 
-  /** @param scope - the bound settings scope for the `bash` namespace. */
-  constructor(scope: SettingsScope<BashSettings>) {
-    this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')])
+  /**
+   * @param selection - the scope selection shared with the tab.
+   * @param bindScope - binds the `bash` namespace under one scope.
+   */
+  constructor(selection: ScopeSelection, bindScope: BindScope<BashSettings>) {
+    this.form = new ScopedCardForms(selection, bindScope, [numberField('timeoutMs'), numberField('maxOutputBytes')])
     this.store = this.form.bind(() => this.projection())
   }
 

+ 83 - 7
packages/client/ui-settings-plugins/src/client/card-form.ts

@@ -56,6 +56,12 @@ export interface CardFieldState {
    * reporting a state the pending edit already contradicts.
    */
   overridden: boolean
+  /**
+   * Under a named scope, whether the field's value comes from the GLOBAL user
+   * layer rather than this scope's own: not overridden here, overridden
+   * there. Always false for the global instance.
+   */
+  inherited: boolean
   /** Whether the draft is not a value this field accepts, which blocks saving. */
   invalid: boolean
 }
@@ -64,6 +70,14 @@ export interface CardFieldState {
 export interface CardShell {
   /** False while the namespace is not served to this client; the card renders nothing. */
   available: boolean
+  /** The named scope the form edits, such as `preset/<id>`; undefined for the global instance. */
+  scope: string | undefined
+  /**
+   * Whether a live Host plugin registered the namespace under the form's
+   * scope. False under a named scope whose composition does not mount the
+   * plugin: edits are stored and take effect once one does.
+   */
+  registered: boolean
   /** Whether the Host document accepts writes. */
   writable: boolean
   /** Whether the form holds edits that a save would write. */
@@ -145,6 +159,25 @@ export function textField(field: string): CardFieldSpec {
   }
 }
 
+/**
+ * A list of lines. Each non-blank line is one entry; blank lines are dropped
+ * and every line is trimmed. An empty draft clears the field.
+ * @param field - field name inside the namespace section.
+ * @returns the field's conversion spec.
+ */
+export function linesField(field: string): CardFieldSpec {
+  return {
+    field,
+    format: value => Array.isArray(value)
+      ? value.flatMap(entry => typeof entry === 'string' ? [entry] : []).join('\n')
+      : '',
+    parse: (text) => {
+      const lines = text.split('\n').map(line => line.trim()).filter(line => line !== '')
+      return lines.length === 0 ? { kind: 'clear' } : { kind: 'set', value: lines }
+    },
+  }
+}
+
 /**
  * Stages one card's edits over one settings namespace and writes them on save.
  *
@@ -175,6 +208,24 @@ export class CardForm<T> {
     scope.subscribe(() => { this.publish() })
   }
 
+  /**
+   * The settings scope this form stages over, for reads and writes outside the staged fields.
+   * @returns the bound settings scope.
+   */
+  scopeOf(): SettingsScope<T> {
+    return this.scope
+  }
+
+  /**
+   * Observe the form: the scope moved or a draft changed.
+   * @param listener - invoked after each change.
+   * @returns the disposer removing this listener.
+   */
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
   /**
    * Publish a projection of this form, rebuilt whenever the scope or a draft changes.
    * @param project - build the card's state from the form's current reads.
@@ -182,7 +233,7 @@ export class CardForm<T> {
    */
   bind<S>(project: () => S): SnapshotStore<S> {
     const store = createSnapshotStore(project())
-    this.listeners.add(() => { store.set(project()) })
+    this.subscribe(() => { store.set(project()) })
     return store
   }
 
@@ -195,6 +246,8 @@ export class CardForm<T> {
     const plan = this.plan()
     return {
       available: snapshot.status === 'ready',
+      scope: snapshot.scope,
+      registered: snapshot.registered,
       writable: snapshot.writable,
       dirty: plan.length > 0,
       invalid: plan.some(item => item.run === undefined),
@@ -211,20 +264,33 @@ export class CardForm<T> {
   field(field: string): CardFieldState {
     const staged = this.staged.get(field)
     if (this.secretSpecs.has(field)) {
-      return { text: staged?.text ?? '', overridden: false, invalid: false }
+      return { text: staged?.text ?? '', overridden: false, inherited: false, invalid: false }
     }
     const spec = this.spec(field)
     if (staged === undefined) {
-      return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
+      return {
+        text: spec.format(this.sectionValue(field)), overridden: this.stored(field), inherited: false, invalid: false,
+      }
     }
     const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
     return {
       text: staged.text,
       overridden: write?.kind === 'set',
+      inherited: false,
       invalid: write === undefined,
     }
   }
 
+  /**
+   * Whether the form's user layer carries the field — for a named scope, that
+   * scope's own section. Presence, not value, is what marks an override.
+   * @param field - field name inside the namespace section.
+   * @returns whether the layer holds an entry for it.
+   */
+  hasOverride(field: string): boolean {
+    return this.stored(field)
+  }
+
   /**
    * Build the edit, reset, save, and discard actions bound to this form.
    * @returns the actions a card's slot entry injects.
@@ -233,7 +299,7 @@ export class CardForm<T> {
     return {
       edit: (field, text) => { this.stage(field, { text, clear: false }) },
       resetField: (field) => {
-        this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
+        this.stage(field, { text: this.spec(field).format(this.fallbackValue(field)), clear: true })
       },
       save: () => { void this.save() },
       discard: () => {
@@ -307,7 +373,11 @@ export class CardForm<T> {
 
   private async store(field: string, value: unknown): Promise<boolean> {
     await this.scope.set(field, value)
-    return this.userLayer()?.[field] === value
+    const stored = this.userLayer()?.[field]
+    // A list lands as a copy; compare it by content.
+    return Array.isArray(value) && Array.isArray(stored)
+      ? value.length === stored.length && value.every((entry, index) => entry === stored[index])
+      : stored === value
   }
 
   private stage(field: string, edit: StagedEdit): void {
@@ -332,8 +402,14 @@ export class CardForm<T> {
     return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
   }
 
-  private baseValue(field: string): unknown {
-    return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
+  /**
+   * What the field reverts to once cleared: under a named scope the value the
+   * scope inherits (global user layer over composition), else the composition layer.
+   */
+  private fallbackValue(field: string): unknown {
+    const snapshot = this.snapshotOf()
+    const layer = (snapshot.inherited ?? snapshot.base) as Record<string, unknown> | undefined
+    return layer?.[field]
   }
 
   private userLayer(): Record<string, unknown> | undefined {

+ 28 - 0
packages/client/ui-settings-plugins/src/client/fields.module.css

@@ -100,6 +100,34 @@
   border-color: var(--dsw-alias-label-error);
 }
 
+.textarea {
+  min-height: 72px;
+  padding: 8px 12px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 8px;
+  background: var(--dsw-alias-bg-layer-3);
+  font: inherit;
+  font-size: 13px;
+  line-height: 1.5;
+  color: var(--dsw-alias-label-primary);
+  resize: vertical;
+}
+
+.textarea:focus-visible {
+  outline: none;
+  border-color: var(--dsw-alias-brand-primary);
+}
+
+.textarea:disabled {
+  color: var(--dsw-alias-label-tertiary);
+  cursor: default;
+}
+
+.textareaInvalid {
+  composes: textarea;
+  border-color: var(--dsw-alias-label-error);
+}
+
 .invalid {
   margin: 0;
   font-size: 12px;

+ 37 - 13
packages/client/ui-settings-plugins/src/client/fields.tsx

@@ -20,10 +20,14 @@ export interface FieldProps {
   text: string
   /** True when saving would leave a user-layer entry for this field. */
   overridden: boolean
+  /** True when a named scope takes this field's value from the global user layer. */
+  inherited: boolean
   /** True when the draft is not a value this field accepts. */
   invalid: boolean
   /** Copy for the overridden badge. */
   overriddenLabel: string
+  /** Copy for the inherited badge. */
+  inheritedLabel: string
   /** Copy for the reset control. */
   resetLabel: string
   /** Copy shown in place of the hint while the draft is invalid. */
@@ -39,7 +43,7 @@ export interface FieldProps {
 /**
  * A staged value field. `numeric` only hints the keypad: which drafts a field
  * accepts is decided by its spec, so the control never silently rewrites what
- * the user typed.
+ * the user typed. `multiline` renders a text area for line-list fields.
  * @param props - the field's copy, its staged text, and the edit actions.
  * @returns the labelled control.
  */
@@ -48,7 +52,10 @@ export function ValueField(props: FieldProps & {
   numeric?: boolean
   /** Placeholder shown while the draft is empty. */
   placeholder?: string
+  /** Render a text area: the draft holds one entry per line. */
+  multiline?: boolean
 }) {
+  const invalidProps = props.invalid ? { 'aria-invalid': true as const } : {}
   return (
     <div className={css.field}>
       <div className={css.head}>
@@ -67,19 +74,36 @@ export function ValueField(props: FieldProps & {
               </button>
             </span>
           )
-          : null}
+          : props.inherited
+            ? <span className={css.badges}><span className={css.badgeMuted}>{props.inheritedLabel}</span></span>
+            : null}
       </div>
-      <input
-        id={props.id}
-        className={props.invalid ? css.inputInvalid : css.input}
-        type="text"
-        {...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
-        {...props.invalid ? { 'aria-invalid': true } : {}}
-        value={props.text}
-        placeholder={props.placeholder ?? ''}
-        disabled={props.disabled}
-        onChange={(event) => { props.onEdit(event.target.value) }}
-      />
+      {props.multiline === true
+        ? (
+          <textarea
+            id={props.id}
+            className={props.invalid ? css.textareaInvalid : css.textarea}
+            rows={3}
+            {...invalidProps}
+            value={props.text}
+            placeholder={props.placeholder ?? ''}
+            disabled={props.disabled}
+            onChange={(event) => { props.onEdit(event.target.value) }}
+          />
+        )
+        : (
+          <input
+            id={props.id}
+            className={props.invalid ? css.inputInvalid : css.input}
+            type="text"
+            {...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
+            {...invalidProps}
+            value={props.text}
+            placeholder={props.placeholder ?? ''}
+            disabled={props.disabled}
+            onChange={(event) => { props.onEdit(event.target.value) }}
+          />
+        )}
       <p className={props.invalid ? css.invalid : css.hint}>
         {props.invalid ? props.invalidLabel : props.hint}
       </p>

+ 47 - 8
packages/client/ui-settings-plugins/src/client/index.ts

@@ -20,16 +20,25 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: the ctx.remote Context merge and the forwarded-event key face.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
+// Type-only: pulls the 'settings.agentPreset' LocaleNamespaceMap merge, whose
+// dictionaries the shipped-preset name resolution below reads.
+import type {} from '@deepseek-ai/dsh-client-ui-agent-preset/client'
+// Inline-safe shared fold: shipped ids map to dictionary keys in one home.
+import { presetDisplayText } from '@deepseek-ai/dsh-agent-presets/display'
 import { AgentLoopCard } from './AgentLoopCard.tsx'
 import { BashCard } from './BashCard.tsx'
 import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
 import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
 import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
+import { SkillFilesystemCard } from './SkillFilesystemCard.tsx'
 import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
 import { WebSearchCard } from './WebSearchCard.tsx'
 import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
 import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
 import { ConfigurablePluginsTabController } from './tab-store.ts'
+import { ScopeSelection } from './scoped-form.ts'
+import { ScopeSwitcherController, type PresetScopeRow } from './scope-switcher.ts'
+import { SKILL_FILESYSTEM_NS, SkillFilesystemCardController } from './skill-filesystem-card-controller.ts'
 import {
   SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController,
 } from './subagent-model-selection-card-controller.ts'
@@ -39,6 +48,8 @@ import { en, zh } from './locales.ts'
 export type { PluginsSettingsSectionInjected, PluginsSettingsSectionProps } from './PluginsSettingsSection.tsx'
 export type { ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
 export type { ConfigurablePluginsTabFace, ConfigurablePluginsTabState } from './tab-store.ts'
+export type { BindScope, ScopeSelectionState } from './scoped-form.ts'
+export type { PresetScopeRow, ScopeSwitcherFace, ScopeSwitcherState } from './scope-switcher.ts'
 export type { PluginCardProps } from './PluginCard.tsx'
 export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
 export type { FieldProps } from './fields.tsx'
@@ -47,6 +58,7 @@ export type {
 } from './card-form.ts'
 export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts'
 export type { BashCardFace, BashCardState } from './bash-card-controller.ts'
+export type { SkillFilesystemCardFace, SkillFilesystemCardState } from './skill-filesystem-card-controller.ts'
 export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts'
 
 /** Dictionary namespace owned by this plugin. */
@@ -54,7 +66,7 @@ const NS = 'settings.plugins'
 
 /** Required services (cordis fiber inject). */
 export const inject = [
-  'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope',
+  'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.credentials', 'remote.session', 'settingsScope',
 ]
 
 /**
@@ -65,13 +77,30 @@ export function apply(ctx: ClientContext): void {
   const t = ctx.locale.bind(NS)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
 
-  const bash = new BashCardController(ctx.settingsScope.bind({ namespace: SHELL_NS }))
-  const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
-  const webSearch = new WebSearchCardController(
-    ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx)
+  // One scope selection over every card: the global instance, or one
+  // preset's named scope. A card binds its namespace under a scope the first
+  // time that scope is selected.
+  const selection = new ScopeSelection()
+  const bindScope = <T>(namespace: string) => (scope: string | undefined) =>
+    ctx.settingsScope.bind<T>({ namespace, ...scope === undefined ? {} : { scope } })
+  const bash = new BashCardController(selection, bindScope(SHELL_NS))
+  const agentLoop = new AgentLoopCardController(selection, bindScope(AGENT_LOOP_NS))
+  const webSearch = new WebSearchCardController(selection, bindScope(WEB_SEARCH_NS), ctx)
   const subagentModelSelection = new SubagentModelSelectionCardController(
-    ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
-    ctx,
+    selection, bindScope(SUBAGENT_MODEL_SELECTION_NS), ctx)
+  const skillFilesystem = new SkillFilesystemCardController(selection, bindScope(SKILL_FILESYSTEM_NS))
+
+  // A refused or failed roster is the switch's state to report; the Host's
+  // message belongs to the transport, not to a scope picker.
+  const roster = (): Promise<readonly PresetScopeRow[] | undefined> => ctx.remote.agentPresets.list()
+    .then(result => result.ok ? result.value.presets : undefined, () => undefined)
+  const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
+  const switcher = new ScopeSwitcherController(
+    selection, ctx.settingsScope.describe(), roster, preset => presetDisplayText(preset, agentPresetCopy).name)
+  ctx.effect(() => () => { switcher.dispose() }, 'ui-settings-plugins: scope switch')
+  ctx.effect(
+    () => ctx.on('connection/reset', () => { switcher.reset() }),
+    'ui-settings-plugins: scope roster generation',
   )
 
   // The credential a card reports is not part of any settings section, so its
@@ -160,7 +189,11 @@ export function apply(ctx: ClientContext): void {
     order: 0,
     label: () => t('configurableTab'),
     locale: NS,
-    inject: () => configurable.inject(),
+    inject: () => {
+      const tab = configurable.inject()
+      const scope = switcher.inject()
+      return { ...tab, ...scope, hooks: { ...tab.hooks, ...scope.hooks } }
+    },
     children: { 'settings.plugin.item': { kind: 'keyed', scope: 'root' } },
   }, ConfigurablePluginsTab))
 
@@ -189,5 +222,11 @@ export function apply(ctx: ClientContext): void {
       locale: NS,
       inject: () => webSearch.inject(),
     }, WebSearchCard)
+    yield ctx.slots.register({
+      name: 'settings.plugin.item',
+      key: SKILL_FILESYSTEM_NS,
+      locale: NS,
+      inject: () => skillFilesystem.inject(),
+    }, SkillFilesystemCard)
   })
 }

+ 33 - 1
packages/client/ui-settings-plugins/src/client/locales.ts

@@ -3,7 +3,9 @@
 /** Locale keys these surfaces render. */
 export type PluginsSettingsLocaleKey =
   | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
-  | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
+  | 'scopeLabel' | 'scopeSwitcherLabel' | 'scopeGlobal' | 'scopePresetDefault' | 'scopePresetBroken'
+  | 'scopeHintGlobal' | 'scopeHintPreset' | 'scopeRosterFailed' | 'notMounted'
+  | 'overridden' | 'inherited' | 'reset' | 'readOnly' | 'expand' | 'collapse'
   | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
   | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
   | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint'
@@ -17,6 +19,8 @@ export type PluginsSettingsLocaleKey =
   | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable'
   | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty'
   | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff'
+  | 'skillFilesystemTitle' | 'skillFilesystemDescription'
+  | 'skillFilesystemCustomSkillDirs' | 'skillFilesystemCustomSkillDirsHint'
 
 /** English copy. */
 export const en: Record<PluginsSettingsLocaleKey, string> = {
@@ -26,7 +30,17 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
   tabs: 'Plugin views',
   configurableTab: 'Plugin configuration',
   empty: 'This deployment exposes no plugin settings.',
+  scopeLabel: 'Applies to',
+  scopeSwitcherLabel: 'Choose which agent preset these settings apply to',
+  scopeGlobal: 'All presets',
+  scopePresetDefault: '{name} (default)',
+  scopePresetBroken: '{name} (failed to load)',
+  scopeHintGlobal: 'Values here apply to every agent preset unless a preset overrides them.',
+  scopeHintPreset: 'Values here apply to this preset only; a field it does not override inherits the value for all presets.',
+  scopeRosterFailed: 'Agent presets could not be listed; only the shared settings are editable.',
+  notMounted: 'This preset does not compose the plugin; saved values take effect once a preset does.',
   overridden: 'Overridden',
+  inherited: 'Inherited',
   reset: 'Reset to default',
   readOnly: 'This deployment stores settings read-only.',
   expand: 'Show settings',
@@ -72,6 +86,10 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
   subagentModelSelectionRequired: 'Select at least one model before saving.',
   subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.',
   subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.',
+  skillFilesystemTitle: 'Skills',
+  skillFilesystemDescription: 'Where the agent discovers skills.',
+  skillFilesystemCustomSkillDirs: 'Extra skill directories',
+  skillFilesystemCustomSkillDirsHint: 'One absolute path per line, scanned after the built-in roots. Leave blank to use only the built-in roots.',
 }
 
 /** Simplified Chinese copy. */
@@ -82,7 +100,17 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
   tabs: '插件视图',
   configurableTab: '插件配置',
   empty: '本部署没有开放任何插件设置。',
+  scopeLabel: '生效范围',
+  scopeSwitcherLabel: '选择这些设置生效的 Agent 预设',
+  scopeGlobal: '所有预设',
+  scopePresetDefault: '{name}(默认)',
+  scopePresetBroken: '{name}(加载失败)',
+  scopeHintGlobal: '这里的值对所有 Agent 预设生效,除非某个预设单独覆盖。',
+  scopeHintPreset: '这里的值只对本预设生效;未覆盖的字段继承"所有预设"的值。',
+  scopeRosterFailed: '暂时无法列出 Agent 预设,只能编辑共用设置。',
+  notMounted: '本预设未组合这个插件;保存的值会在某个预设组合它之后生效。',
   overridden: '已覆盖',
+  inherited: '继承',
   reset: '恢复默认',
   readOnly: '本部署的设置为只读。',
   expand: '展开设置',
@@ -128,4 +156,8 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
   subagentModelSelectionRequired: '保存前请至少选择一个模型。',
   subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。',
   subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。',
+  skillFilesystemTitle: '技能',
+  skillFilesystemDescription: 'Agent 从哪些目录发现技能。',
+  skillFilesystemCustomSkillDirs: '额外技能目录',
+  skillFilesystemCustomSkillDirsHint: '每行一个绝对路径,在内置目录之后扫描。留空表示只用内置目录。',
 }

+ 168 - 0
packages/client/ui-settings-plugins/src/client/scope-switcher.ts

@@ -0,0 +1,168 @@
+/**
+ * The configurable tab's scope switch: the global instance, or one agent
+ * preset's named scope. The options come from the preset roster when the
+ * deployment composes one, plus any named scope the settings document
+ * already holds a section for — a scope whose preset was deleted still
+ * shows, under its raw id, so its section is never hidden.
+ */
+
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
+import type { ScopeSelection } from './scoped-form.ts'
+
+/** Scope id prefix the agent-preset roster names its standing scopes with. */
+export const PRESET_SCOPE_PREFIX = 'preset/'
+
+/** One roster preset as the switch lists it. */
+export interface PresetScopeRow {
+  /** The preset id; its scope is `preset/<id>`. */
+  readonly id: string
+  /** Shipped or locally authored, which decides how its name resolves. */
+  readonly trust: 'system' | 'user'
+  /** Locally authored display name, when the preset declares one. */
+  readonly name?: string
+  /** Whether a session that names no preset is composed from it. */
+  readonly isDefault: boolean
+  /** Why discovery could not load the preset, when it could not. */
+  readonly broken?: string
+}
+
+/** What the switch renders. */
+export interface ScopeSwitcherState {
+  /** The selected scope; undefined for the global instance. */
+  scope: string | undefined
+  /** Roster presets, in roster order. */
+  presets: readonly PresetScopeRow[]
+  /** Named scopes the document holds that no roster preset owns, as raw ids. */
+  extraScopes: readonly string[]
+  /** Roster request state; `idle` until the tab first renders. */
+  status: 'idle' | 'loading' | 'ready' | 'error'
+}
+
+/** The registration-side face the tab injects for its switch. */
+export interface ScopeSwitcherFace {
+  hooks: {
+    /** Switch snapshot bound by the renderer as useScopeSwitcher. */
+    scopeSwitcher: SnapshotStore<ScopeSwitcherState>
+  }
+  /** Select the scope every card edits; null selects the global instance. */
+  selectScope: (scope: string | null) => void
+  /** Read the roster the first time the tab renders, and retry after a failure. */
+  ensureScopes: () => void
+  /** Display name for one roster preset, resolved through the agent-preset dictionaries. */
+  presetName: (preset: PresetScopeRow) => string
+}
+
+/** Answers the roster, when the deployment composes one; undefined when the Host refused it. */
+export type RosterReader = () => Promise<readonly PresetScopeRow[] | undefined>
+
+/** Derives the switch options from the roster and the global describe answer. */
+export class ScopeSwitcherController {
+  private readonly store: SnapshotStore<ScopeSwitcherState>
+  private presets: readonly PresetScopeRow[] = []
+  private status: ScopeSwitcherState['status'] = 'idle'
+  private generation = 0
+  private disposed = false
+  private readonly disposers: Array<() => void>
+
+  /**
+   * @param selection - the scope selection shared with every card.
+   * @param describeFace - the global describe face, whose `scopes` names the document's sections.
+   * @param roster - reads the preset roster; undefined when the deployment composes none.
+   * @param presetName - display name for one roster preset.
+   */
+  constructor(
+    private readonly selection: ScopeSelection,
+    private readonly describeFace: SettingsDescribeFace,
+    private readonly roster: RosterReader | undefined,
+    private readonly presetName: (preset: PresetScopeRow) => string,
+  ) {
+    this.store = createSnapshotStore(this.projection())
+    this.disposers = [
+      selection.subscribe(() => { this.publish() }),
+      describeFace.subscribe(() => { this.publish() }),
+    ]
+  }
+
+  /** Stop following the selection and the mirror, and drop late roster answers. */
+  dispose(): void {
+    this.disposed = true
+    this.generation += 1
+    for (const dispose of this.disposers) dispose()
+  }
+
+  /**
+   * Re-read the roster after a (re)connect: the Host behind the page may differ.
+   */
+  reset(): void {
+    if (this.disposed) return
+    this.generation += 1
+    this.status = 'idle'
+    this.presets = []
+    this.publish()
+  }
+
+  /**
+   * Build the face the tab's slot registration injects.
+   * @returns the switch snapshot and its actions.
+   */
+  inject(): ScopeSwitcherFace {
+    return {
+      hooks: { scopeSwitcher: this.store },
+      selectScope: (scope) => { this.selection.select(scope ?? undefined) },
+      ensureScopes: () => { void this.load() },
+      presetName: this.presetName,
+    }
+  }
+
+  /**
+   * Read the roster once from `idle` or `error`; a read in flight or a held
+   * answer is kept.
+   * @returns settlement after the answer is published.
+   */
+  async load(): Promise<void> {
+    if (this.disposed || this.status === 'loading' || this.status === 'ready') return
+    if (this.roster === undefined) {
+      this.status = 'ready'
+      this.publish()
+      return
+    }
+    const generation = ++this.generation
+    this.status = 'loading'
+    this.publish()
+    const presets = await this.roster()
+    // Disposal and reset both advance the generation, which is the one
+    // liveness check a settled read needs.
+    if (generation !== this.generation) return
+    if (presets === undefined) {
+      this.status = 'error'
+    } else {
+      this.presets = presets
+      this.status = 'ready'
+    }
+    this.publish()
+  }
+
+  private projection(): ScopeSwitcherState {
+    const owned = new Set(this.presets.map(preset => `${PRESET_SCOPE_PREFIX}${preset.id}`))
+    const described = this.describeFace.getSnapshot().view?.scopes ?? []
+    return {
+      scope: this.selection.current(),
+      presets: this.presets,
+      extraScopes: described.filter(scope => !owned.has(scope)),
+      status: this.status,
+    }
+  }
+
+  private publish(): void {
+    const next = this.projection()
+    const previous = this.store.getSnapshot()
+    // Every settings commit refreshes the mirror; keep the reference until a
+    // fact moves (packages/client/AGENTS.md reactive rule 5).
+    if (previous.scope === next.scope && previous.status === next.status
+      && previous.presets === next.presets
+      && previous.extraScopes.length === next.extraScopes.length
+      && previous.extraScopes.every((scope, index) => scope === next.extraScopes[index])) return
+    this.store.set(next)
+  }
+}

+ 205 - 0
packages/client/ui-settings-plugins/src/client/scoped-form.ts

@@ -0,0 +1,205 @@
+/**
+ * The scope a card edits, and the per-scope forms behind one card.
+ *
+ * The configurable tab offers one scope switch over every card: the global
+ * instance, or one agent preset's named scope. A card controller stages
+ * edits through {@link ScopedCardForms}, which keeps one {@link CardForm} per
+ * scope — drafts belong to the scope they were typed under and survive a
+ * switch — and re-projects the selected scope's form whenever the selection
+ * or that form moves. Card components stay unaware of scopes: they read the
+ * same hooks and call the same actions, which route to the selected form at
+ * call time.
+ */
+
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
+import {
+  CardForm, type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell,
+} from './card-form.ts'
+
+/** The scope the configurable tab is editing. */
+export interface ScopeSelectionState {
+  /** The named settings scope, such as `preset/<id>`; undefined for the global instance. */
+  scope: string | undefined
+}
+
+/** Map key of the global instance inside {@link ScopedCardForms}. */
+const GLOBAL_KEY = ''
+
+/** The one scope selection the configurable tab and every card share. */
+export class ScopeSelection {
+  private readonly store = createSnapshotStore<ScopeSelectionState>({ scope: undefined })
+
+  /**
+   * Read the selection.
+   * @returns the current selection (stable reference until the next change).
+   */
+  getSnapshot(): ScopeSelectionState {
+    return this.store.getSnapshot()
+  }
+
+  /**
+   * Observe selection changes.
+   * @param listener - invoked after each change.
+   * @returns the disposer removing this listener.
+   */
+  subscribe(listener: () => void): () => void {
+    return this.store.subscribe(listener)
+  }
+
+  /**
+   * The selected scope.
+   * @returns the scope id; undefined for the global instance.
+   */
+  current(): string | undefined {
+    return this.store.getSnapshot().scope
+  }
+
+  /**
+   * Select the scope every card edits.
+   * @param scope - a named scope, or undefined for the global instance.
+   */
+  select(scope: string | undefined): void {
+    if (scope === this.current()) return
+    this.store.set({ scope })
+  }
+}
+
+/** Bind one namespace under one scope; the caller's settings-scope binder supplies it. */
+export type BindScope<T> = (scope: string | undefined) => SettingsScope<T>
+
+/**
+ * One card's forms across scopes, presenting the selected scope's form.
+ *
+ * A form is bound the first time its scope is selected and kept for the
+ * page's lifetime; the global form is bound at once, because the card's
+ * availability is read from it before any switch.
+ */
+export class ScopedCardForms<T> {
+  private readonly forms = new Map<string, CardForm<T>>()
+  private readonly listeners = new Set<() => void>()
+
+  /**
+   * @param selection - the scope selection shared with the tab.
+   * @param bindScope - binds the card's namespace under one scope.
+   * @param specs - the section fields the card edits.
+   * @param secrets - the card's write-only controls, written outside the section.
+   */
+  constructor(
+    private readonly selection: ScopeSelection,
+    private readonly bindScope: BindScope<T>,
+    private readonly specs: CardFieldSpec[],
+    private readonly secrets: CardSecretSpec[] = [],
+  ) {
+    this.formFor(undefined)
+    selection.subscribe(() => {
+      this.formFor(selection.current())
+      this.publish()
+    })
+  }
+
+  /**
+   * The scope the presented form edits.
+   * @returns the scope id; undefined for the global instance.
+   */
+  scopeId(): string | undefined {
+    return this.selection.current()
+  }
+
+  /**
+   * The selected scope's settings scope, for reads and writes outside the staged form.
+   * @returns the bound settings scope of the presented form.
+   */
+  scope(): SettingsScope<T> {
+    return this.current().scopeOf()
+  }
+
+  /**
+   * Observe the selected form: a scope switch, a Host acceptance, or a draft change.
+   * @param listener - invoked after each change.
+   * @returns the disposer removing this listener.
+   */
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  /**
+   * Publish a projection of the selected form, rebuilt whenever it moves.
+   * @param project - build the card's state from the current reads.
+   * @returns the store the card's component reads through its bound selector.
+   */
+  bind<S>(project: () => S): SnapshotStore<S> {
+    const store = createSnapshotStore(project())
+    this.subscribe(() => { store.set(project()) })
+    return store
+  }
+
+  /**
+   * Read the selected form's card-level state.
+   * @returns the form state every card shares.
+   */
+  shell(): CardShell {
+    return this.current().shell()
+  }
+
+  /**
+   * Read one control's state under the selected scope. Under a named scope a
+   * field this scope does not override reports whether the GLOBAL user layer
+   * carries it, so the control can say the value is inherited.
+   * @param field - field name of a section field or of a write-only control.
+   * @returns the draft text, its override, inheritance, and validity.
+   */
+  field(field: string): CardFieldState {
+    const state = this.current().field(field)
+    if (this.scopeId() === undefined || state.overridden) return state
+    return { ...state, inherited: this.global().hasOverride(field) }
+  }
+
+  /**
+   * Build the edit, reset, save, and discard actions. Each routes to the form
+   * selected when it is invoked, not when the actions were built.
+   * @returns the actions a card's slot entry injects.
+   */
+  actions(): CardActions {
+    return {
+      edit: (field, text) => { this.current().actions().edit(field, text) },
+      resetField: (field) => { this.current().actions().resetField(field) },
+      save: () => { void this.save() },
+      discard: () => { this.current().actions().discard() },
+    }
+  }
+
+  /**
+   * Write the selected form's staged edits.
+   * @returns settlement after every write and the read-back.
+   */
+  save(): Promise<void> {
+    return this.current().save()
+  }
+
+  private current(): CardForm<T> {
+    return this.formFor(this.selection.current())
+  }
+
+  private global(): CardForm<T> {
+    return this.formFor(undefined)
+  }
+
+  private formFor(scope: string | undefined): CardForm<T> {
+    const key = scope ?? GLOBAL_KEY
+    let form = this.forms.get(key)
+    if (form === undefined) {
+      form = new CardForm(this.bindScope(scope), this.specs, this.secrets)
+      this.forms.set(key, form)
+      form.subscribe(() => {
+        if (this.selection.current() === scope) this.publish()
+      })
+    }
+    return form
+  }
+
+  private publish(): void {
+    for (const listener of this.listeners) listener()
+  }
+}

+ 58 - 0
packages/client/ui-settings-plugins/src/client/skill-filesystem-card-controller.ts

@@ -0,0 +1,58 @@
+/** The skill roots card's staged form over the `skill-filesystem` settings namespace. */
+
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { linesField, type CardActions, type CardFieldState, type CardShell } from './card-form.ts'
+import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts'
+
+/**
+ * Namespace of the filesystem skill provider. Spelled here rather than
+ * imported: a client package must not depend on a Host package.
+ */
+export const SKILL_FILESYSTEM_NS = 'skill-filesystem'
+
+/** The skill-provider fields this card edits. */
+export interface SkillFilesystemSettings {
+  /** Extra skill roots scanned after the built-in ones. */
+  customSkillDirs?: string[]
+}
+
+/** What the skill roots card renders. */
+export interface SkillFilesystemCardState extends CardShell {
+  /** Extra skill roots, one per line. */
+  customSkillDirs: CardFieldState
+}
+
+/** The registration-side face the skill roots card's slot entry injects. */
+export interface SkillFilesystemCardFace extends CardActions {
+  hooks: {
+    /** Card snapshot bound by the renderer as useSkillFilesystemCard. */
+    skillFilesystemCard: SnapshotStore<SkillFilesystemCardState>
+  }
+}
+
+/** Bridges the `skill-filesystem` namespace, under the selected scope, onto the card's staged form. */
+export class SkillFilesystemCardController {
+  private readonly form: ScopedCardForms<SkillFilesystemSettings>
+  private readonly store: SnapshotStore<SkillFilesystemCardState>
+
+  /**
+   * @param selection - the scope selection shared with the tab.
+   * @param bindScope - binds the `skill-filesystem` namespace under one scope.
+   */
+  constructor(selection: ScopeSelection, bindScope: BindScope<SkillFilesystemSettings>) {
+    this.form = new ScopedCardForms(selection, bindScope, [linesField('customSkillDirs')])
+    this.store = this.form.bind(() => this.projection())
+  }
+
+  private projection(): SkillFilesystemCardState {
+    return { ...this.form.shell(), customSkillDirs: this.form.field('customSkillDirs') }
+  }
+
+  /**
+   * Build the face the card's slot registration injects.
+   * @returns the card's snapshot and its form actions.
+   */
+  inject(): SkillFilesystemCardFace {
+    return { hooks: { skillFilesystemCard: this.store }, ...this.form.actions() }
+  }
+}

+ 29 - 4
packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts

@@ -5,6 +5,7 @@ import type { ModelProviderGroup } from '@deepseek-ai/dsh-api-remotes/client'
 import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
 import type { CardShell } from './card-form.ts'
+import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts'
 
 /** Namespace of the Host-owned subagent model-selection preference. */
 export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection'
@@ -124,8 +125,14 @@ function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly Allow
   return left.every(route => rightKeys.has(subagentModelKey(route)))
 }
 
-/** Bridges one settings scope and the live adapter directory onto a staged card. */
+/**
+ * Bridges the `subagent-model-selection` namespace, under the selected scope,
+ * and the live adapter directory onto a staged card. The draft belongs to
+ * the scope it began under: a scope switch drops it.
+ */
 export class SubagentModelSelectionCardController {
+  private readonly forms: ScopedCardForms<SubagentModelSelectionSettings>
+  private draftScope: string | undefined
   private catalogGroups: readonly ModelProviderGroup[] = []
   private catalogPartial = false
   private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle'
@@ -142,16 +149,25 @@ export class SubagentModelSelectionCardController {
   private readonly unsubscribe: () => void
 
   /**
-   * @param scope - bound `subagent-model-selection` settings scope.
+   * @param selection - the scope selection shared with the tab.
+   * @param bindScope - binds the `subagent-model-selection` namespace under one scope.
    * @param ctx - the card plugin's context, whose `remote.session` namespace
    * answers the Host model catalog.
    */
   constructor(
-    private readonly scope: SettingsScope<SubagentModelSelectionSettings>,
+    selection: ScopeSelection,
+    bindScope: BindScope<SubagentModelSelectionSettings>,
     private readonly ctx: ClientContext,
   ) {
+    this.forms = new ScopedCardForms(selection, bindScope, [])
     this.store = createSnapshotStore(this.projection())
-    this.unsubscribe = scope.subscribe(() => {
+    this.unsubscribe = this.forms.subscribe(() => {
+      if (this.draftRoutes !== undefined && this.forms.scopeId() !== this.draftScope) {
+        // A draft typed under another scope must not be written into this one.
+        this.saveGeneration += 1
+        this.saving = false
+        this.clearDraft()
+      }
       if (!this.saving && this.draftRoutes !== undefined
         && this.scope.getSnapshot().revision !== this.draftRevision) {
         if (this.currentEnabled() === this.enabled()
@@ -187,6 +203,11 @@ export class SubagentModelSelectionCardController {
     }
   }
 
+  /** The selected scope's settings scope. */
+  private get scope(): SettingsScope<SubagentModelSelectionSettings> {
+    return this.forms.scope()
+  }
+
   private currentRoutes(): AllowedSubagentModel[] {
     return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? []
   }
@@ -211,6 +232,7 @@ export class SubagentModelSelectionCardController {
         snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [],
       )
       this.draftRevision = snapshot.revision
+      this.draftScope = this.forms.scopeId()
     }
     return this.draftRoutes
   }
@@ -240,6 +262,7 @@ export class SubagentModelSelectionCardController {
     this.draftEnabled = undefined
     this.draftRoutes = undefined
     this.draftRevision = undefined
+    this.draftScope = undefined
     this.failed = false
     this.conflicted = false
   }
@@ -338,6 +361,8 @@ export class SubagentModelSelectionCardController {
     const enabled = this.enabled()
     return {
       available: snapshot.status === 'ready',
+      scope: snapshot.scope,
+      registered: snapshot.registered,
       writable: snapshot.writable,
       dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired),
       invalid: enabled && desired.length === 0,

+ 17 - 12
packages/client/ui-settings-plugins/src/client/web-search-card-controller.ts

@@ -13,11 +13,12 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis'
 // Type-only: pulls the ctx.remote merge into this program.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
+import type { SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
 import {
-  CardForm, numberField, textField,
+  numberField, textField,
   type CardActions, type CardFieldState, type CardShell,
 } from './card-form.ts'
+import { ScopedCardForms, type BindScope, type ScopeSelection } from './scoped-form.ts'
 
 /**
  * Namespace of the DeepSeek search provider. Spelled here rather than
@@ -73,28 +74,32 @@ export interface WebSearchCardFace extends CardActions {
   }
 }
 
-/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */
+/** Bridges the `web-search-deepseek` namespace, under the selected scope, and the credentials domain onto the card. */
 export class WebSearchCardController {
-  private readonly form: CardForm<WebSearchSettings>
+  private readonly form: ScopedCardForms<WebSearchSettings>
   private readonly store: SnapshotStore<WebSearchCardState>
   private credential: CredentialState = { ref: '', configured: false, writable: true }
 
   /**
-   * @param scope - the bound settings scope for the `web-search-deepseek` namespace.
+   * @param selection - the scope selection shared with the tab.
+   * @param bindScope - binds the `web-search-deepseek` namespace under one scope.
    * @param ctx - the card plugin's context, whose `remote.credentials` namespace
    * answers for the credential the section references.
    */
   constructor(
-    private readonly scope: SettingsScope<WebSearchSettings>,
+    selection: ScopeSelection,
+    bindScope: BindScope<WebSearchSettings>,
     private readonly ctx: ClientContext,
   ) {
-    this.form = new CardForm(
-      scope,
+    this.form = new ScopedCardForms(
+      selection,
+      bindScope,
       [textField('baseURL'), numberField('maxUses')],
       [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }],
     )
     this.store = this.form.bind(() => this.projection())
-    scope.subscribe(() => { void this.readCredential() })
+    // The selected scope's section names the reference; a switch re-reads it.
+    this.form.subscribe(() => { void this.readCredential() })
     void this.readCredential()
   }
 
@@ -118,7 +123,7 @@ export class WebSearchCardController {
    * reference in force.
    */
   private async readCredential(): Promise<void> {
-    const ref = refOf(this.scope.getSnapshot())
+    const ref = refOf(this.form.scope().getSnapshot())
     if (ref !== this.credential.ref) {
       // A new reference knows nothing yet; keeping the old answer would claim
       // the key is configured under a name nobody has checked.
@@ -126,7 +131,7 @@ export class WebSearchCardController {
       this.store.set(this.projection())
     }
     const response = await this.ctx.remote.credentials.describe([ref])
-    if (!response.ok || ref !== refOf(this.scope.getSnapshot())) return
+    if (!response.ok || ref !== refOf(this.form.scope().getSnapshot())) return
     const view = response.value[ref]
     const next: CredentialState = {
       ref,
@@ -169,7 +174,7 @@ export class WebSearchCardController {
   private async writeKey(value: string): Promise<boolean> {
     // Refusals surface through the re-read below: the Host is the only
     // authority on whether the key now exists.
-    await this.ctx.remote.credentials.set(refOf(this.scope.getSnapshot()), value)
+    await this.ctx.remote.credentials.set(refOf(this.form.scope().getSnapshot()), value)
     await this.readCredential()
     return this.credential.configured
   }

+ 78 - 8
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -9,7 +9,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
 import type {
-  ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
+  BashCardFace, ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, ScopeSwitcherFace,
 } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
 import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts'
 import { apply as hostApply } from '../src/index.ts'
@@ -34,26 +34,36 @@ async function bench(served?: string[]) {
   const models = vi.fn(() => Promise.resolve({
     ok: true as const, value: { groups: [], failures: [] },
   }))
-  const describeSettings = vi.fn(() => Promise.resolve(served === undefined
+  const describeSettings = vi.fn((scope?: string) => Promise.resolve(served === undefined
     ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) }
     : {
       ok: true,
       value: {
         writable: true,
         hasDocument: true,
+        ...scope === undefined ? { scopes: [] } : { scope, scopes: [scope] },
         namespaces: served.map(ns => ({
-          ns, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
+          ns, registered: scope === undefined, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
+          ...scope === undefined ? {} : { scope, inherited: {} },
         })),
       },
     }))
+  const listPresets = vi.fn(() => Promise.resolve({
+    ok: true as const,
+    value: {
+      authorable: true,
+      presets: [{ id: 'standard', trust: 'system' as const, isDefault: true }],
+    },
+  }))
   const remote = new TestRemote(ctx, {
+    agentPresets: { list: listPresets },
     credentials: { describe: describeCredentials, set: vi.fn() },
     session: { modelCatalog: models },
     settings: { describe: describeSettings },
   })
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   return {
-    ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote,
+    ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, listPresets, models, remote,
   }
 }
 
@@ -71,7 +81,7 @@ describe('ui-settings-plugins apply', () => {
 
   it('declares the services it uses', () => {
     expect(inject).toEqual([
-      'slots', 'locale', 'remote', 'remote.credentials', 'remote.session', 'settingsScope',
+      'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.credentials', 'remote.session', 'settingsScope',
     ])
   })
 
@@ -117,7 +127,7 @@ describe('ui-settings-plugins apply', () => {
 
     const tab = slots.entries('settings.plugins.tab')[0]!
     const tabFace = (tab.inject as unknown as () => ConfigurablePluginsTabFace)()
-    expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins'])
+    expect(Object.keys(tabFace.hooks)).toEqual(['configurablePlugins', 'scopeSwitcher'])
     for (const entry of slots.entries('settings.plugin.item')) {
       const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
       // Each card injects exactly one snapshot store plus its own actions.
@@ -132,7 +142,7 @@ describe('ui-settings-plugins apply', () => {
     await ctx.plugin({ inject: [...inject], apply }).await()
 
     expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
-      .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek'])
+      .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek', 'skill-filesystem'])
   })
 
   it('dispatches the served namespaces its cards claim, and no others', async () => {
@@ -221,6 +231,66 @@ describe('ui-settings-plugins apply', () => {
     expect(describeCredentials).not.toHaveBeenCalled()
   })
 
+  it('lists the roster on first use and switches every card to the selected preset scope', async () => {
+    const { ctx, slots, describeSettings, listPresets, remote } = await bench(['shell'])
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const tab = slots.entries('settings.plugins.tab')[0]!
+    const face = (tab.inject as unknown as () => ConfigurablePluginsTabFace & ScopeSwitcherFace)()
+    expect(listPresets).not.toHaveBeenCalled()
+    face.ensureScopes()
+    await vi.waitFor(() => {
+      expect(face.hooks.scopeSwitcher.getSnapshot()).toMatchObject({
+        status: 'ready', presets: [{ id: 'standard', isDefault: true }],
+      })
+    })
+    // Shipped preset names resolve through the agent-preset dictionaries the
+    // real plugin registers; user-authored metadata stays as declared.
+    ctx.locale.register('settings.agentPreset', 'zh', { presetStandardName: '标准模式' } as never)
+    expect(face.presetName({ id: 'standard', trust: 'system', isDefault: true })).toBe('标准模式')
+    expect(face.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false })).toBe('我自己的')
+
+    const bash = slots.entries('settings.plugin.item')[0]!
+    const bashFace = (bash.inject as unknown as () => BashCardFace)()
+    await vi.waitFor(() => { expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: undefined, registered: true }) })
+    describeSettings.mockClear()
+    face.selectScope('preset/standard')
+    // The scoped mirror is read on first selection, and the card follows it.
+    await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalledWith('preset/standard') })
+    await vi.waitFor(() => {
+      expect(bashFace.hooks.bashCard.getSnapshot()).toMatchObject({ scope: 'preset/standard', registered: false })
+    })
+    expect(face.hooks.scopeSwitcher.getSnapshot().scope).toBe('preset/standard')
+    // A scoped commit reloads the scoped mirror only.
+    describeSettings.mockClear()
+    remote.emit('settings/document-updated', ['shell', 1, 'preset/standard'])
+    await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalledTimes(1) })
+    expect(describeSettings).toHaveBeenCalledWith('preset/standard')
+    // A reconnect re-reads the roster on the next use.
+    ctx.emit('connection/reset')
+    expect(face.hooks.scopeSwitcher.getSnapshot().status).toBe('idle')
+    face.ensureScopes()
+    await vi.waitFor(() => { expect(listPresets).toHaveBeenCalledTimes(2) })
+  })
+
+  it('reports a roster the Host refused or a transport that failed without losing the global instance', async () => {
+    const { ctx, slots, listPresets } = await bench(['shell'])
+    listPresets
+      .mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'no roster', {}) } as never)
+      .mockRejectedValueOnce(new Error('offline'))
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    const tab = slots.entries('settings.plugins.tab')[0]!
+    const face = (tab.inject as unknown as () => ScopeSwitcherFace)()
+    face.ensureScopes()
+    await vi.waitFor(() => { expect(face.hooks.scopeSwitcher.getSnapshot().status).toBe('error') })
+    expect(face.hooks.scopeSwitcher.getSnapshot()).toMatchObject({ scope: undefined, presets: [] })
+    ctx.emit('connection/reset')
+    face.ensureScopes()
+    await vi.waitFor(() => { expect(listPresets).toHaveBeenCalledTimes(2) })
+    await vi.waitFor(() => { expect(face.hooks.scopeSwitcher.getSnapshot().status).toBe('error') })
+  })
+
   it('registers into a declaration that arrives after apply', async () => {
     const { ctx, slots } = await bench()
     await ctx.plugin({ inject: [...inject], apply }).await()
@@ -235,7 +305,7 @@ describe('ui-settings-plugins apply', () => {
     declareRoot(slots)
     const fiber = ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
-    expect(slots.entries('settings.plugin.item')).toHaveLength(4)
+    expect(slots.entries('settings.plugin.item')).toHaveLength(5)
 
     await fiber.dispose()
 

+ 2 - 0
packages/client/ui-settings-plugins/tests/fields.client.spec.tsx

@@ -15,6 +15,8 @@ const frame = {
   invalidLabel: 'Enter a number.',
   disabled: false,
   overridden: false,
+  inherited: false,
+  inheritedLabel: 'Inherited',
   invalid: false,
 }
 

+ 116 - 0
packages/client/ui-settings-plugins/tests/scope-switcher.client.spec.ts

@@ -0,0 +1,116 @@
+/**
+ * The scope switch's options: roster presets, document-only scopes, and the
+ * roster request lifecycle.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SettingsDescribeFace, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
+import { ScopeSelection } from '../src/client/scoped-form.ts'
+import { ScopeSwitcherController, type PresetScopeRow } from '../src/client/scope-switcher.ts'
+
+const STANDARD: PresetScopeRow = { id: 'standard', trust: 'system', isDefault: true }
+
+function describeFace(scopes: readonly string[] = []) {
+  const store = createSnapshotStore<SettingsMirrorSnapshot>({
+    status: 'ready',
+    view: { writable: true, hasDocument: true, namespaces: [], scopes },
+    error: null,
+  })
+  const face: SettingsDescribeFace = {
+    getSnapshot: () => store.getSnapshot(),
+    subscribe: listener => store.subscribe(listener),
+    ensure: () => Promise.resolve(),
+    acceptView: () => {},
+  }
+  return { face, store }
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
+  return { promise, resolve, reject }
+}
+
+describe('ScopeSwitcherController', () => {
+  it('reads the roster once from idle, lists document-only scopes, and follows the selection', async () => {
+    const selection = new ScopeSelection()
+    const { face, store } = describeFace(['preset/standard', 'preset/deleted'])
+    const roster = vi.fn(() => Promise.resolve([STANDARD]))
+    const controller = new ScopeSwitcherController(selection, face, roster, preset => preset.name ?? preset.id)
+    const injected = controller.inject()
+    expect(injected.hooks.scopeSwitcher.getSnapshot()).toEqual({
+      scope: undefined, presets: [], extraScopes: ['preset/standard', 'preset/deleted'], status: 'idle',
+    })
+    injected.ensureScopes()
+    injected.ensureScopes()
+    expect(injected.hooks.scopeSwitcher.getSnapshot().status).toBe('loading')
+    await vi.waitFor(() => { expect(injected.hooks.scopeSwitcher.getSnapshot().status).toBe('ready') })
+    expect(roster).toHaveBeenCalledTimes(1)
+    expect(injected.hooks.scopeSwitcher.getSnapshot()).toMatchObject({
+      presets: [STANDARD], extraScopes: ['preset/deleted'],
+    })
+    injected.ensureScopes()
+    expect(roster).toHaveBeenCalledTimes(1)
+    expect(injected.presetName(STANDARD)).toBe('standard')
+
+    const before = injected.hooks.scopeSwitcher.getSnapshot()
+    // An unrelated mirror refresh keeps the reference.
+    store.set({ ...store.getSnapshot() })
+    expect(injected.hooks.scopeSwitcher.getSnapshot()).toBe(before)
+    injected.selectScope('preset/standard')
+    expect(injected.hooks.scopeSwitcher.getSnapshot().scope).toBe('preset/standard')
+    injected.selectScope(null)
+    expect(selection.current()).toBeUndefined()
+    // A document gaining a scope section shows it.
+    store.set({ ...store.getSnapshot(), view: { writable: true, hasDocument: true, namespaces: [], scopes: ['preset/x'] } })
+    expect(injected.hooks.scopeSwitcher.getSnapshot().extraScopes).toEqual(['preset/x'])
+    controller.dispose()
+    const after = injected.hooks.scopeSwitcher.getSnapshot()
+    injected.selectScope('preset/x')
+    expect(injected.hooks.scopeSwitcher.getSnapshot()).toBe(after)
+  })
+
+  it('reports a failed roster, retries it, and answers ready at once without a roster', async () => {
+    const selection = new ScopeSelection()
+    const { face } = describeFace()
+    const roster = vi.fn()
+      .mockResolvedValueOnce(undefined)
+      .mockResolvedValueOnce([STANDARD])
+    const controller = new ScopeSwitcherController(selection, face, roster, preset => preset.id)
+    await controller.load()
+    expect(controller.inject().hooks.scopeSwitcher.getSnapshot().status).toBe('error')
+    await controller.load()
+    expect(controller.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'ready', presets: [STANDARD] })
+
+    const bare = new ScopeSwitcherController(selection, face, undefined, preset => preset.id)
+    await bare.load()
+    expect(bare.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'ready', presets: [] })
+  })
+
+  it('drops a late answer after a reset or disposal', async () => {
+    const selection = new ScopeSelection()
+    const { face } = describeFace()
+    const first = deferred<readonly PresetScopeRow[]>()
+    const roster = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValueOnce([STANDARD])
+    const controller = new ScopeSwitcherController(selection, face, roster, preset => preset.id)
+    const loading = controller.load()
+    controller.reset()
+    expect(controller.inject().hooks.scopeSwitcher.getSnapshot().status).toBe('idle')
+    first.resolve([{ id: 'stale', trust: 'user', isDefault: false }])
+    await loading
+    expect(controller.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'idle', presets: [] })
+    await controller.load()
+    expect(controller.inject().hooks.scopeSwitcher.getSnapshot().presets).toEqual([STANDARD])
+
+    const second = deferred<readonly PresetScopeRow[]>()
+    const late = new ScopeSwitcherController(selection, face, () => second.promise, preset => preset.id)
+    const pending = late.load()
+    late.dispose()
+    late.reset()
+    second.resolve([STANDARD])
+    await pending
+    expect(late.inject().hooks.scopeSwitcher.getSnapshot()).toMatchObject({ status: 'loading', presets: [] })
+  })
+})

+ 123 - 0
packages/client/ui-settings-plugins/tests/scoped-form.client.spec.ts

@@ -0,0 +1,123 @@
+/**
+ * The per-scope forms behind one card: drafts stay with the scope they were
+ * typed under, actions route to the selected scope, and inheritance is read
+ * off the global form.
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { numberField } from '../src/client/card-form.ts'
+import { ScopeSelection, ScopedCardForms } from '../src/client/scoped-form.ts'
+
+interface Shell {
+  timeoutMs?: number
+}
+
+function ready(host: StubSettingsScope<Shell>, scope: string | undefined, fields: {
+  value: Shell
+  user: Partial<Shell>
+  inherited?: Shell
+}) {
+  host.publish({
+    status: 'ready', writable: true, scope, registered: true, revision: 1,
+    value: fields.value, base: { timeoutMs: 60000 }, user: fields.user,
+    ...fields.inherited === undefined ? {} : { inherited: fields.inherited },
+  })
+}
+
+function bench() {
+  const hosts = new Map<string, StubSettingsScope<Shell>>()
+  const bindScope = vi.fn((scope: string | undefined) => {
+    const host = stubSettingsScope<Shell>()
+    hosts.set(scope ?? '', host)
+    return host.scope
+  })
+  const selection = new ScopeSelection()
+  const forms = new ScopedCardForms(selection, bindScope, [numberField('timeoutMs')])
+  return { hosts, bindScope, selection, forms, host: (scope?: string) => hosts.get(scope ?? '')! }
+}
+
+describe('ScopeSelection', () => {
+  it('publishes a change of scope once per distinct value', () => {
+    const selection = new ScopeSelection()
+    const listener = vi.fn()
+    selection.subscribe(listener)
+    selection.select('preset/a')
+    selection.select('preset/a')
+    expect(listener).toHaveBeenCalledTimes(1)
+    expect(selection.current()).toBe('preset/a')
+    expect(selection.getSnapshot()).toEqual({ scope: 'preset/a' })
+    selection.select(undefined)
+    expect(selection.current()).toBeUndefined()
+  })
+})
+
+describe('ScopedCardForms', () => {
+  it('binds the global form at once and a named scope on first selection, then keeps both', () => {
+    const { bindScope, selection, forms } = bench()
+    expect(bindScope.mock.calls).toEqual([[undefined]])
+    selection.select('preset/a')
+    expect(bindScope.mock.calls).toEqual([[undefined], ['preset/a']])
+    selection.select(undefined)
+    selection.select('preset/a')
+    expect(bindScope).toHaveBeenCalledTimes(2)
+    expect(forms.scopeId()).toBe('preset/a')
+  })
+
+  it('presents the selected scope\'s form and keeps each scope\'s drafts', () => {
+    const { selection, forms, host } = bench()
+    ready(host(), undefined, { value: { timeoutMs: 60000 }, user: {} })
+    const store = forms.bind(() => ({ ...forms.shell(), timeoutMs: forms.field('timeoutMs') }))
+    forms.actions().edit('timeoutMs', '1000')
+    expect(store.getSnapshot()).toMatchObject({ scope: undefined, dirty: true, timeoutMs: { text: '1000' } })
+
+    selection.select('preset/a')
+    ready(host('preset/a'), 'preset/a', {
+      value: { timeoutMs: 9000 }, user: {}, inherited: { timeoutMs: 9000 },
+    })
+    expect(store.getSnapshot()).toMatchObject({
+      scope: 'preset/a', dirty: false, timeoutMs: { text: '9000', overridden: false, inherited: false },
+    })
+    forms.actions().edit('timeoutMs', '2000')
+    expect(store.getSnapshot()).toMatchObject({ dirty: true, timeoutMs: { text: '2000', overridden: true } })
+
+    selection.select(undefined)
+    expect(store.getSnapshot()).toMatchObject({ scope: undefined, dirty: true, timeoutMs: { text: '1000' } })
+    // A publication from the unselected scope does not re-project the selected one.
+    const listener = vi.fn()
+    forms.subscribe(listener)
+    ready(host('preset/a'), 'preset/a', { value: { timeoutMs: 9000 }, user: {}, inherited: { timeoutMs: 9000 } })
+    expect(listener).not.toHaveBeenCalled()
+    ready(host(), undefined, { value: { timeoutMs: 60000 }, user: {} })
+    expect(listener).toHaveBeenCalledTimes(1)
+  })
+
+  it('marks a named scope\'s field inherited when the global user layer carries it', async () => {
+    const { selection, forms, host } = bench()
+    ready(host(), undefined, { value: { timeoutMs: 12000 }, user: { timeoutMs: 12000 } })
+    selection.select('preset/a')
+    ready(host('preset/a'), 'preset/a', {
+      value: { timeoutMs: 12000 }, user: {}, inherited: { timeoutMs: 12000 },
+    })
+    expect(forms.field('timeoutMs')).toEqual({ text: '12000', overridden: false, inherited: true, invalid: false })
+    expect(forms.scope()).toBe(host('preset/a').scope)
+
+    // Once the scope overrides the field, inheritance no longer applies.
+    host('preset/a').set.mockImplementation((field: string, value: unknown) => {
+      ready(host('preset/a'), 'preset/a', {
+        value: { [field]: value }, user: { [field]: value }, inherited: { timeoutMs: 12000 },
+      })
+    })
+    forms.actions().edit('timeoutMs', '3000')
+    await forms.save()
+    expect(forms.field('timeoutMs')).toMatchObject({ text: '3000', overridden: true, inherited: false })
+    // Reset stages the inherited value, and discard drops it.
+    forms.actions().resetField('timeoutMs')
+    expect(forms.field('timeoutMs')).toMatchObject({ text: '12000', overridden: false, inherited: true })
+    forms.actions().discard()
+    expect(forms.field('timeoutMs')).toMatchObject({ text: '3000', overridden: true })
+    // Under the global instance nothing is ever inherited.
+    selection.select(undefined)
+    expect(forms.field('timeoutMs')).toMatchObject({ overridden: true, inherited: false })
+  })
+})

+ 117 - 3
packages/client/ui-settings-plugins/tests/section.client.spec.tsx

@@ -10,6 +10,10 @@ import { BashCard } from '../src/client/BashCard.tsx'
 import type { BashCardProps } from '../src/client/BashCard.tsx'
 import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
 import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
+import { SkillFilesystemCard } from '../src/client/SkillFilesystemCard.tsx'
+import type { SkillFilesystemCardProps } from '../src/client/SkillFilesystemCard.tsx'
+import type { SkillFilesystemCardState } from '../src/client/skill-filesystem-card-controller.ts'
+import type { ScopeSwitcherState } from '../src/client/scope-switcher.ts'
 import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
 import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
 import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
@@ -26,10 +30,13 @@ import { en } from '../src/client/locales.ts'
 
 afterEach(cleanup)
 
-const t = (key: keyof typeof en) => en[key]
+const t = (key: keyof typeof en, params?: Record<string, string>) =>
+  Object.entries(params ?? {}).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, value), en[key])
 
 const settled: CardShell = {
   available: true,
+  scope: undefined,
+  registered: true,
   writable: true,
   dirty: false,
   invalid: false,
@@ -38,7 +45,7 @@ const settled: CardShell = {
 }
 
 function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
-  return { text, overridden: false, invalid: false, ...rest }
+  return { text, overridden: false, inherited: false, invalid: false, ...rest }
 }
 
 function cardActions() {
@@ -56,17 +63,42 @@ function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
   render(<PluginsSettingsSection {...props} />)
 }
 
-function renderConfigurable(namespaces: string[], cards: Record<string, string> = {}, loaded = true) {
+function renderConfigurable(
+  namespaces: string[],
+  cards: Record<string, string> = {},
+  loaded = true,
+  switcher: Partial<ScopeSwitcherState> = {},
+) {
   const store = createSnapshotStore<ConfigurablePluginsTabState>({ loaded, namespaces })
+  const switcherStore = createSnapshotStore<ScopeSwitcherState>({
+    scope: undefined, presets: [], extraScopes: [], status: 'ready', ...switcher,
+  })
+  const actions = { ensureScopes: vi.fn(), selectScope: vi.fn() }
   const props = {
     t,
+    ...actions,
+    presetName: (preset: { id: string; name?: string }) => preset.name ?? preset.id,
     useConfigurablePlugins: bindSnapshotSelector(store),
+    useScopeSwitcher: bindSnapshotSelector(switcherStore),
     renderSlot: (_name: string, _owner: object, opts?: { entryKey?: string }) => {
       const card = opts?.entryKey === undefined ? undefined : cards[opts.entryKey]
       return card === undefined ? null : <li>{card}</li>
     },
   } as unknown as ConfigurablePluginsTabProps
   render(<ConfigurablePluginsTab {...props} />)
+  return actions
+}
+
+function renderSkillFilesystemCard(state: Partial<SkillFilesystemCardState> = {}) {
+  const store = createSnapshotStore<SkillFilesystemCardState>({
+    ...settled,
+    customSkillDirs: field(''),
+    ...state,
+  })
+  const actions = cardActions()
+  const props = { ...actions, t, useSkillFilesystemCard: bindSnapshotSelector(store) } as unknown as SkillFilesystemCardProps
+  render(<SkillFilesystemCard {...props} />)
+  return actions
 }
 
 function renderBashCard(state: Partial<BashCardState> = {}) {
@@ -206,6 +238,88 @@ describe('ConfigurablePluginsTab', () => {
   })
 })
 
+describe('ConfigurablePluginsTab scope switch', () => {
+  it('asks for the roster once mounted and offers the global instance plus every preset', () => {
+    const actions = renderConfigurable([], {}, true, {
+      presets: [
+        { id: 'standard', trust: 'system', name: '标准模式', isDefault: true },
+        { id: 'research', trust: 'user', name: 'Research', isDefault: false },
+        { id: 'broken', trust: 'user', isDefault: false, broken: 'bad yaml' },
+      ],
+      extraScopes: ['preset/deleted'],
+    })
+    expect(actions.ensureScopes).toHaveBeenCalledTimes(1)
+    const trigger = screen.getByRole('button', { name: en.scopeSwitcherLabel })
+    expect(trigger.textContent).toBe(en.scopeGlobal)
+    expect(trigger.getAttribute('data-settings-scope')).toBe('global')
+    expect(screen.getByText(en.scopeHintGlobal)).toBeTruthy()
+
+    fireEvent.click(trigger)
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(screen.queryByRole('menuitem')).toBeNull()
+    fireEvent.click(trigger)
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Research' }))
+    expect(actions.selectScope).toHaveBeenCalledWith('preset/research')
+    fireEvent.click(trigger)
+    expect(screen.getByRole('menuitem', { name: '标准模式 (default)' })).toBeTruthy()
+    expect(screen.getByRole('menuitem', { name: 'broken (failed to load)' })).toBeTruthy()
+    expect(screen.getByRole('menuitem', { name: 'preset/deleted' })).toBeTruthy()
+    fireEvent.click(screen.getByRole('menuitem', { name: en.scopeGlobal }))
+    expect(actions.selectScope).toHaveBeenLastCalledWith(null)
+  })
+
+  it('names the selected scope, keeps an unlisted one visible by id, and reports a roster failure', () => {
+    renderConfigurable([], {}, true, {
+      scope: 'preset/research',
+      presets: [{ id: 'research', trust: 'user', name: 'Research', isDefault: false }],
+    })
+    const trigger = screen.getByRole('button', { name: en.scopeSwitcherLabel })
+    expect(trigger.textContent).toBe('Research')
+    expect(trigger.getAttribute('data-settings-scope')).toBe('preset/research')
+    expect(screen.getByText(en.scopeHintPreset)).toBeTruthy()
+    cleanup()
+
+    renderConfigurable([], {}, true, { scope: 'preset/gone', status: 'error' })
+    expect(screen.getByRole('button', { name: en.scopeSwitcherLabel }).textContent).toBe('preset/gone')
+    expect(screen.getByText(en.scopeRosterFailed)).toBeTruthy()
+  })
+})
+
+describe('SkillFilesystemCard', () => {
+  it('edits the roots as one entry per line and marks an inherited value', () => {
+    const actions = renderSkillFilesystemCard({
+      scope: 'preset/research',
+      registered: false,
+      customSkillDirs: field('/a\n/b', { inherited: true }),
+    })
+    fireEvent.click(screen.getByRole('button', { name: `${en.expand}: ${en.skillFilesystemTitle}` }))
+    const roots = screen.getByLabelText(en.skillFilesystemCustomSkillDirs) as HTMLTextAreaElement
+    expect(roots.tagName).toBe('TEXTAREA')
+    expect(roots.value).toBe('/a\n/b')
+    expect(screen.getByText(en.inherited)).toBeTruthy()
+    expect(screen.queryByRole('button', { name: en.reset })).toBeNull()
+    expect(screen.getByText(en.notMounted)).toBeTruthy()
+    fireEvent.change(roots, { target: { value: '/c' } })
+    expect(actions.edit).toHaveBeenCalledWith('customSkillDirs', '/c')
+  })
+
+  it('offers the reset for an overridden root list and no inherited badge for the global instance', () => {
+    const actions = renderSkillFilesystemCard({ customSkillDirs: field('/a', { overridden: true }) })
+    fireEvent.click(screen.getByRole('button', { name: `${en.expand}: ${en.skillFilesystemTitle}` }))
+    expect(screen.getByText(en.overridden)).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: en.reset }))
+    expect(actions.resetField).toHaveBeenCalledWith('customSkillDirs')
+    expect(screen.queryByText(en.inherited)).toBeNull()
+    expect(screen.queryByText(en.notMounted)).toBeNull()
+  })
+
+  it('marks an invalid root draft on the text area', () => {
+    renderSkillFilesystemCard({ customSkillDirs: field('?', { invalid: true }) })
+    fireEvent.click(screen.getByRole('button', { name: `${en.expand}: ${en.skillFilesystemTitle}` }))
+    expect(screen.getByLabelText(en.skillFilesystemCustomSkillDirs).getAttribute('aria-invalid')).toBe('true')
+  })
+})
+
 describe('BashCard', () => {
   it('renders nothing while its namespace is unavailable', () => {
     const { container } = render(<div />)

+ 133 - 37
packages/client/ui-settings-plugins/tests/stores.client.spec.ts

@@ -6,7 +6,8 @@
 import { describe, expect, it, vi } from 'vitest'
 import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError, stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import { CardForm, numberField, textField } from '../src/client/card-form.ts'
+import { CardForm, linesField, numberField, textField } from '../src/client/card-form.ts'
+import { ScopeSelection } from '../src/client/scoped-form.ts'
 import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts'
 import { BashCardController, type BashSettings } from '../src/client/bash-card-controller.ts'
 import {
@@ -104,7 +105,7 @@ describe('CardForm', () => {
   it('shows the effective value and stays clean until something is staged', () => {
     const { subject } = form()
 
-    expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
+    expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, inherited: false, invalid: false })
     expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false })
   })
 
@@ -123,7 +124,7 @@ describe('CardForm', () => {
 
     subject.actions().edit('timeoutMs', '9000')
 
-    expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false })
+    expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, inherited: false, invalid: false })
     expect(subject.shell().dirty).toBe(true)
     expect(host.set).not.toHaveBeenCalled()
 
@@ -150,7 +151,7 @@ describe('CardForm', () => {
 
     subject.actions().edit('timeoutMs', 'soon')
 
-    expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true })
+    expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, inherited: false, invalid: true })
     expect(subject.shell()).toMatchObject({ dirty: true, invalid: true })
 
     await subject.save()
@@ -167,7 +168,7 @@ describe('CardForm', () => {
     subject.actions().resetField('timeoutMs')
 
     // The badge previews the save: the field will no longer be overridden.
-    expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false })
+    expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, inherited: false, invalid: false })
     expect(host.unset).not.toHaveBeenCalled()
 
     await subject.save()
@@ -194,7 +195,7 @@ describe('CardForm', () => {
 
     subject.actions().edit('timeoutMs', '')
 
-    expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false })
+    expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, inherited: false, invalid: false })
     await subject.save()
 
     expect(host.unset.mock.calls).toEqual([['timeoutMs']])
@@ -331,7 +332,7 @@ describe('BashCardController', () => {
   it('projects both fields and saves them in one write pass', async () => {
     const host = stubSettingsScope<BashSettings>()
     acceptWrites(host)
-    const controller = new BashCardController(host.scope)
+    const controller = new BashCardController(new ScopeSelection(), () => host.scope)
     host.publish({
       status: 'ready',
       writable: true,
@@ -363,7 +364,7 @@ describe('BashCardController', () => {
   it('stages a reset and applies it on save', async () => {
     const host = stubSettingsScope<BashSettings>()
     acceptWrites(host)
-    const controller = new BashCardController(host.scope)
+    const controller = new BashCardController(new ScopeSelection(), () => host.scope)
     host.publish({
       status: 'ready',
       writable: true,
@@ -387,7 +388,7 @@ describe('BashCardController', () => {
 
   it('discards staged edits without writing', () => {
     const host = stubSettingsScope<BashSettings>()
-    const controller = new BashCardController(host.scope)
+    const controller = new BashCardController(new ScopeSelection(), () => host.scope)
     host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} })
     const face = controller.inject()
 
@@ -403,7 +404,7 @@ describe('AgentLoopCardController', () => {
   it('saves the only field it owns', async () => {
     const host = stubSettingsScope<AgentLoopSettings>()
     acceptWrites(host)
-    const controller = new AgentLoopCardController(host.scope)
+    const controller = new AgentLoopCardController(new ScopeSelection(), () => host.scope)
     host.publish({
       status: 'ready',
       writable: true,
@@ -425,7 +426,7 @@ describe('AgentLoopCardController', () => {
 
   it('reports a read-only document so the card can disable its controls', () => {
     const host = stubSettingsScope<AgentLoopSettings>()
-    const controller = new AgentLoopCardController(host.scope)
+    const controller = new AgentLoopCardController(new ScopeSelection(), () => host.scope)
 
     host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } })
 
@@ -459,7 +460,7 @@ describe('SubagentModelSelectionCardController', () => {
     const models = modelsApi({
       groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     host.publish({
       status: 'ready', writable: true, revision: 3,
       value: { enabled: false, allowedModels: [] }, user: {},
@@ -490,7 +491,7 @@ describe('SubagentModelSelectionCardController', () => {
 
   it('starts an empty draft when a ready test scope has no decoded value', () => {
     const host = stubSettingsScope<SubagentModelSelectionSettings>()
-    const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, modelsApi().ctx)
     host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
     const face = controller.inject()
 
@@ -506,7 +507,7 @@ describe('SubagentModelSelectionCardController', () => {
     const models = modelsApi({
       groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
     const face = controller.inject()
 
@@ -533,7 +534,7 @@ describe('SubagentModelSelectionCardController', () => {
       groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
       failures: [{ id: 'beta', name: 'Beta', message: 'offline' }],
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     host.publish({
       status: 'ready', writable: true, revision: 5,
       value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
@@ -566,7 +567,7 @@ describe('SubagentModelSelectionCardController', () => {
     const models = modelsApi({
       groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     const face = controller.inject()
     await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() })
 
@@ -586,7 +587,7 @@ describe('SubagentModelSelectionCardController', () => {
   it('reports a directory error and retries it', async () => {
     const host = stubSettingsScope<SubagentModelSelectionSettings>()
     const models = modelsApi({ error: 'offline' })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
     const face = controller.inject()
     const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
@@ -602,7 +603,7 @@ describe('SubagentModelSelectionCardController', () => {
     const models = modelsApi({
       groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     host.publish({
       status: 'ready', writable: true, revision: 4,
       value: { enabled: false, allowedModels: [] }, user: {},
@@ -636,7 +637,7 @@ describe('SubagentModelSelectionCardController', () => {
     const models = modelsApi({
       groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     host.publish({
       status: 'ready', writable: true, revision: 4,
       value: { enabled: false, allowedModels: [] }, user: {},
@@ -673,7 +674,7 @@ describe('SubagentModelSelectionCardController', () => {
       })
       .mockImplementationOnce(() => refreshed.promise)
     const controller = new SubagentModelSelectionCardController(
-      host.scope, ctxWith({ session: { modelCatalog: models } }),
+      new ScopeSelection(), () => host.scope, ctxWith({ session: { modelCatalog: models } }),
     )
     const face = controller.inject()
     const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
@@ -712,7 +713,7 @@ describe('SubagentModelSelectionCardController', () => {
       status: 'ready', writable: true, revision: 4,
       value: { enabled: false, allowedModels: [] }, user: {},
     })
-    const controller = new SubagentModelSelectionCardController(host.scope, models.ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, models.ctx)
     const face = controller.inject()
     face.toggleEnabled()
     await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) })
@@ -752,7 +753,7 @@ describe('SubagentModelSelectionCardController', () => {
         },
       })
     const controller = new SubagentModelSelectionCardController(
-      host.scope, ctxWith({ session: { modelCatalog: models } }),
+      new ScopeSelection(), () => host.scope, ctxWith({ session: { modelCatalog: models } }),
     )
     const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
     await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
@@ -778,7 +779,8 @@ describe('SubagentModelSelectionCardController', () => {
         allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [],
       } })
     })
-    const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.ctx)
+    const controller = new SubagentModelSelectionCardController(
+      new ScopeSelection(), () => ({ ...host.scope, mutate }), catalog.ctx)
     const face = controller.inject()
 
     face.save()
@@ -807,7 +809,8 @@ describe('SubagentModelSelectionCardController', () => {
 
     const pending = deferred<never>()
     const models = vi.fn(() => pending.promise)
-    const controller = new SubagentModelSelectionCardController(host.scope, ctxWith({ session: { modelCatalog: models } }))
+    const controller = new SubagentModelSelectionCardController(
+      new ScopeSelection(), () => host.scope, ctxWith({ session: { modelCatalog: models } }))
     const face = controller.inject()
     face.toggleEnabled()
     face.retryCatalog()
@@ -818,7 +821,7 @@ describe('SubagentModelSelectionCardController', () => {
 
     const pendingResolve = deferred<never>()
     const resolving = new SubagentModelSelectionCardController(
-      host.scope,
+      new ScopeSelection(), () => host.scope,
       ctxWith({ session: { modelCatalog: () => pendingResolve.promise } }),
     )
     const resolvingFace = resolving.inject()
@@ -832,7 +835,7 @@ describe('SubagentModelSelectionCardController', () => {
 
   it('ignores writes while read-only and scope notifications after disposal', () => {
     const host = stubSettingsScope<SubagentModelSelectionSettings>()
-    const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().ctx)
+    const controller = new SubagentModelSelectionCardController(new ScopeSelection(), () => host.scope, modelsApi().ctx)
     host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} })
     const face = controller.inject()
 
@@ -857,7 +860,7 @@ describe('WebSearchCardController', () => {
   it('reads the credential state for the reference the tab names', async () => {
     const host = stubSettingsScope<WebSearchSettings>()
     const credentials = credentialsApi(true)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     const state = () => controller.inject().hooks.webSearchCard.getSnapshot()
     await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
 
@@ -873,7 +876,7 @@ describe('WebSearchCardController', () => {
   it('writes the staged key through the credentials domain, never the settings section', async () => {
     const host = stubSettingsScope<WebSearchSettings>()
     const credentials = credentialsApi(false)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     host.publish({ status: 'ready', writable: true, value: {}, user: {} })
     const face = controller.inject()
 
@@ -898,7 +901,7 @@ describe('WebSearchCardController', () => {
   it('keeps the stored key when the draft is left blank', () => {
     const host = stubSettingsScope<WebSearchSettings>()
     const credentials = credentialsApi(true)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     host.publish({ status: 'ready', writable: true, value: {}, user: {} })
     const face = controller.inject()
 
@@ -913,7 +916,7 @@ describe('WebSearchCardController', () => {
   it('re-reads when the Host reports the watched reference changed', async () => {
     const host = stubSettingsScope<WebSearchSettings>()
     const credentials = credentialsApi(false)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     host.publish({ status: 'ready', writable: true, value: {}, user: {} })
     await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() })
     credentials.describe.mockClear()
@@ -937,7 +940,7 @@ describe('WebSearchCardController', () => {
   it('addresses the reference the tab declares rather than the default', async () => {
     const host = stubSettingsScope<WebSearchSettings>()
     const credentials = credentialsApi(false)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} })
     const face = controller.inject()
 
@@ -951,7 +954,7 @@ describe('WebSearchCardController', () => {
   it('reports a key the Host did not store as a failed save', async () => {
     const host = stubSettingsScope<WebSearchSettings>()
     const credentials = credentialsApi(false)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     host.publish({ status: 'ready', writable: true, value: {}, user: {} })
     const face = controller.inject()
 
@@ -971,7 +974,7 @@ describe('WebSearchCardController', () => {
     })
     const describe = vi.fn(refusal)
     const set = vi.fn(refusal)
-    const controller = new WebSearchCardController(host.scope, ctxWith({ credentials: { describe, set } }))
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, ctxWith({ credentials: { describe, set } }))
     const face = controller.inject()
     await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
 
@@ -993,7 +996,7 @@ describe('WebSearchCardController', () => {
       ok: false as const,
       error: new RemoteError('gateway/internal', 'no credential provider', {}),
     }))
-    const controller = new WebSearchCardController(host.scope, ctxWith({
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, ctxWith({
       credentials: { describe, set: vi.fn() },
     }))
     await vi.waitFor(() => { expect(describe).toHaveBeenCalled() })
@@ -1005,7 +1008,7 @@ describe('WebSearchCardController', () => {
     const host = stubSettingsScope<WebSearchSettings>()
     acceptWrites(host)
     const credentials = credentialsApi(true)
-    const controller = new WebSearchCardController(host.scope, credentials.ctx)
+    const controller = new WebSearchCardController(new ScopeSelection(), () => host.scope, credentials.ctx)
     host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} })
     const face = controller.inject()
 
@@ -1019,6 +1022,68 @@ describe('WebSearchCardController', () => {
   })
 })
 
+describe('CardForm lines field', () => {
+  it('formats a list one entry per line, parses trimmed non-blank lines, and clears on empty', async () => {
+    const host = stubSettingsScope<{ customSkillDirs?: string[] }>()
+    acceptWrites(host)
+    host.publish({
+      status: 'ready', writable: true, value: { customSkillDirs: ['/a', '/b'] }, base: {}, user: {}, revision: 1,
+    })
+    const subject = new CardForm(host.scope, [linesField('customSkillDirs')])
+    expect(subject.field('customSkillDirs').text).toBe('/a\n/b')
+    // A list carrying a non-string entry renders only its strings.
+    host.publish({ value: { customSkillDirs: ['/a', 3] as never } })
+    expect(subject.field('customSkillDirs').text).toBe('/a')
+    host.publish({ value: { customSkillDirs: ['/a', '/b'] } })
+
+    subject.actions().edit('customSkillDirs', ' /c \n\n/d\n')
+    await subject.save()
+    expect(host.set).toHaveBeenCalledWith('customSkillDirs', ['/c', '/d'])
+    expect(subject.shell()).toMatchObject({ dirty: false, failed: false })
+
+    subject.actions().edit('customSkillDirs', '  \n ')
+    await subject.save()
+    expect(host.unset).toHaveBeenCalledWith('customSkillDirs')
+    expect(subject.field('customSkillDirs').text).toBe('')
+    // A stored list that differs from the staged one is a failed save.
+    host.set.mockImplementationOnce((field: string, value: unknown) => {
+      host.publish({ value: { [field]: value }, user: { [field]: ['/other'] } })
+    })
+    subject.actions().edit('customSkillDirs', '/e')
+    await subject.save()
+    expect(subject.shell().failed).toBe(true)
+  })
+
+  it('publishes to subscribers until they leave, and saves through its actions', async () => {
+    const host = stubSettingsScope<{ timeoutMs?: number }>()
+    acceptWrites(host)
+    host.publish({ status: 'ready', writable: true, value: { timeoutMs: 1 }, base: {}, user: {}, revision: 1 })
+    const subject = new CardForm(host.scope, [numberField('timeoutMs')])
+    const listener = vi.fn()
+    const off = subject.subscribe(listener)
+    subject.actions().edit('timeoutMs', '2')
+    expect(listener).toHaveBeenCalledTimes(1)
+    off()
+    subject.actions().save()
+    await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('timeoutMs', 2) })
+    expect(listener).toHaveBeenCalledTimes(1)
+  })
+
+  it('resets a named scope\'s field to the inherited value rather than the composition layer', () => {
+    const host = stubSettingsScope<{ timeoutMs?: number }>()
+    host.publish({
+      status: 'ready', writable: true, scope: 'preset/research', registered: true,
+      value: { timeoutMs: 9000 }, base: { timeoutMs: 60000 }, inherited: { timeoutMs: 12000 },
+      user: { timeoutMs: 9000 }, revision: 1,
+    })
+    const subject = new CardForm(host.scope, [numberField('timeoutMs')])
+    expect(subject.shell()).toMatchObject({ scope: 'preset/research', registered: true })
+    expect(subject.hasOverride('timeoutMs')).toBe(true)
+    subject.actions().resetField('timeoutMs')
+    expect(subject.field('timeoutMs')).toEqual({ text: '12000', overridden: false, inherited: false, invalid: false })
+  })
+})
+
 describe('ConfigurablePluginsTabController', () => {
   function settingsApi(namespaces: string[]) {
     const describe = vi.fn(() => Promise.resolve({
@@ -1026,8 +1091,9 @@ describe('ConfigurablePluginsTabController', () => {
       value: {
         writable: true,
         hasDocument: true,
+        scopes: [],
         namespaces: namespaces.map(ns => ({
-          ns, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0,
+          ns, registered: true, schema: {}, value: {}, applies: 'live', secrets: [], revision: 0,
         })),
       },
     }))
@@ -1114,7 +1180,7 @@ describe('ConfigurablePluginsTabController', () => {
     let notify = (): void => {}
     let snapshot: SettingsMirrorSnapshot = {
       status: 'ready' as const,
-      view: { writable: true, hasDocument: true, namespaces: [] },
+      view: { writable: true, hasDocument: true, scopes: [], namespaces: [] },
       error: null,
     }
     const describeFace = {
@@ -1136,6 +1202,7 @@ describe('ConfigurablePluginsTabController', () => {
       view: {
         writable: true,
         hasDocument: true,
+        scopes: [],
         namespaces: [{
           ns: 'bash', registered: true, schema: {}, value: {}, applies: 'live', secrets: [], revision: 1,
         }],
@@ -1158,3 +1225,32 @@ describe('ConfigurablePluginsTabController', () => {
       .toEqual({ loaded: true, namespaces: [] })
   })
 })
+
+describe('SubagentModelSelectionCardController across scopes', () => {
+  it('drops a draft begun under another scope when the selection moves', async () => {
+    const hosts = new Map<string, StubSettingsScope<SubagentModelSelectionSettings>>()
+    const bindScope = (scope: string | undefined) => {
+      const host = stubSettingsScope<SubagentModelSelectionSettings>()
+      hosts.set(scope ?? '', host)
+      host.publish({
+        status: 'ready', writable: true, revision: 1, scope, registered: true,
+        value: { enabled: false, allowedModels: [] }, base: {}, user: {},
+      })
+      return host.scope
+    }
+    const selection = new ScopeSelection()
+    const controller = new SubagentModelSelectionCardController(selection, bindScope, modelsApi().ctx)
+    const face = controller.inject()
+    const state = () => face.hooks.subagentModelSelectionCard.getSnapshot()
+    face.toggleEnabled()
+    expect(state()).toMatchObject({ dirty: true, enabled: true, scope: undefined })
+    selection.select('preset/research')
+    expect(state()).toMatchObject({ dirty: false, enabled: false, scope: 'preset/research', registered: true })
+    // A save on the new scope writes that scope's instance.
+    face.toggleEnabled()
+    expect(state().dirty).toBe(true)
+    await Promise.resolve()
+    expect(hosts.get('')!.mutate).not.toHaveBeenCalled()
+    controller.dispose()
+  })
+})

+ 6 - 0
packages/client/ui-settings-plugins/tsconfig.json

@@ -34,6 +34,12 @@
     },
     {
       "path": "../ui-slots"
+    },
+    {
+      "path": "../ui-agent-preset"
+    },
+    {
+      "path": "../../preset/agent-presets"
     }
   ]
 }

+ 2 - 2
packages/client/ui-settings/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
-README.md: bd2f5d840f07aa12f76c72f10c52f7627509685f
-README.zh.md: a930f7db3631d1ddce3381d9cb30de257a7a8f17
+README.md: 43009af11de0fe5bf634db52e001944221db3322
+README.zh.md: fd7193465308ee6d21ecef34252bd60499133acb

+ 4 - 4
packages/client/ui-settings/README.md

@@ -29,11 +29,11 @@ Feature plugins use this package to store and edit their preferences without re-
 
 ### Binding a namespace
 
-A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
+A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. A spec may name a `scope` — a named settings scope such as `preset/<id>` — to bind that scope's instance of the namespace: the snapshot then carries `scope`, `registered` (whether a live plugin registered the namespace under it), and `inherited` (the value the scope resolves without its own section, which is what a cleared field falls back to), and every write carries the scope so it lands in the document's `scopes.<id>` tree.
 
 ### Filling the settings slots
 
-A settings surface registers into the slot types this package declares. The shell (`sidebar.settings` occupant, navigation, chrome) lives in ui-settings-general; feature pages register `settings.section` contributions; the Plugins section hosts `settings.plugins.tab` pages; onboarding steps register `settings.onboarding`. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`) read the same mirror through `ctx.settingsScope.describe()`.
+A settings surface registers into the slot types this package declares. The shell (`sidebar.settings` occupant, navigation, chrome) lives in ui-settings-general; feature pages register `settings.section` contributions; the Plugins section hosts `settings.plugins.tab` pages; onboarding steps register `settings.onboarding`. Cross-namespace surfaces (schema introspection, the served-namespace directory, `hasDocument`, the `scopes` directory) read the same mirror through `ctx.settingsScope.describe()`; `describe(scope)` answers the mirror of one named scope.
 
 ### Observable success and failures
 
@@ -51,11 +51,11 @@ The package realizes one ownership rule: the browser keeps one shared mirror of
 
 ### The describe mirror
 
-The plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
+The plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the `settings.describe` readers in the browser: one mirror per settings scope, held by `SettingsMirrorRegistry`. The global mirror is created eagerly and read on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription); a named scope's mirror is created the first time a consumer binds or describes that scope and reads `settings.describe(scope)`. A commit names the scope it landed in: a global commit reloads every mirror, because a named scope resolves over the global section, and a scoped commit reloads that scope's mirror alone. Cross-namespace surfaces read a mirror through `ctx.settingsScope.describe(scope?)`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in).
 
 ### Scope derivation
 
-`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
+`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror of the spec's settings scope on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read beyond a named scope's first mirror read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`, and passes the spec's settings scope as the trailing argument when one is bound. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it.
 
 ### Schema service
 

+ 4 - 4
packages/client/ui-settings/README.zh.md

@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### 绑定命名空间
 
-功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
+功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。spec 可以指定一个 `scope`——诸如 `preset/<id>` 的具名 settings scope——以绑定该 scope 下的命名空间实例:此时快照携带 `scope`、`registered`(是否有存活插件在该 scope 下注册了命名空间)与 `inherited`(scope 在没有自己分区时解析出的值,也就是清除字段后回落的值),并且每次写入都携带该 scope,从而落在文档的 `scopes.<id>` 树里。
 
 ### 填充设置 slot
 
-设置界面会注册进本包声明的 slot 类型。外壳(`sidebar.settings` 占位方、导航、界面框架)位于 ui-settings-general;功能页面注册 `settings.section` 贡献;「插件」分区承载 `settings.plugins.tab` 页面;首次使用引导步骤注册 `settings.onboarding`。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`)通过 `ctx.settingsScope.describe()` 读同一面镜像。
+设置界面会注册进本包声明的 slot 类型。外壳(`sidebar.settings` 占位方、导航、界面框架)位于 ui-settings-general;功能页面注册 `settings.section` 贡献;「插件」分区承载 `settings.plugins.tab` 页面;首次使用引导步骤注册 `settings.onboarding`。跨命名空间的表面(schema 内省、已服务命名空间目录、`hasDocument`、`scopes` 目录)通过 `ctx.settingsScope.describe()` 读同一面镜像;`describe(scope)` 回答某个具名 scope 的镜像。
 
 ### 可观察的成功与失败
 
@@ -51,11 +51,11 @@ kind: "package-reference"
 
 ### Describe 镜像
 
-插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
+插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中的 `settings.describe` 读取方:每个 settings scope 一面镜像,由 `SettingsMirrorRegistry` 持有。全局镜像急切创建,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时读取(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口);具名 scope 的镜像在消费方首次绑定或描述该 scope 时创建,读取 `settings.describe(scope)`。提交会点名它落在哪个 scope:全局提交重载每一面镜像,因为具名 scope 是在全局分区之上解析的;scoped 提交只重载该 scope 的镜像。跨命名空间表面通过 `ctx.settingsScope.describe(scope?)` 读某一面镜像,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。
 
 ### Scope 派生
 
-`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
+`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由 spec 所指 settings scope 的镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,除具名 scope 的首次镜像读取外绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后,绑定了具名 scope 时把它作为尾随参数传入。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。
 
 ### Schema 服务
 

+ 13 - 10
packages/client/ui-settings/src/client/index.ts

@@ -22,7 +22,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/types'
 import type {} from '@deepseek-ai/dsh-settings/types'
 import { SettingsSchemaService } from './schema.ts'
 import { SettingsScopeBinder } from './settings-scope.ts'
-import { SettingsDescribeMirror } from './settings-mirror.ts'
+import { SettingsMirrorRegistry } from './settings-mirror.ts'
 
 export type {
   SettingsGeneralItemOwnerProps, SettingsHeaderOwnerProps, SettingsOnboardingOwnerProps,
@@ -33,7 +33,7 @@ export type { SettingsScope, SettingsScopeSnapshot, SettingsScopeSpec } from './
 export type { SettingsSchemaService } from './schema.ts'
 export type { SchemaNode } from './schema.ts'
 export type {
-  SettingsDescribeFace, SettingsDescribeView, SettingsMirrorSnapshot,
+  SettingsDescribeFace, SettingsDescribeView, SettingsMirrorRegistry, SettingsMirrorSnapshot,
 } from './settings-mirror.ts'
 
 /**
@@ -43,9 +43,12 @@ export type {
 export const inject = ['remote', 'remote.settings']
 
 /**
- * Provide the settings-namespace scope service over one shared describe
- * mirror, and keep that mirror fresh on the two signals that can move the
- * settings document: a document commit and a (re)connect.
+ * Provide the settings-namespace scope service over one describe mirror per
+ * scope, and keep those mirrors fresh on the two signals that can move the
+ * settings document: a document commit and a (re)connect. A commit names the
+ * scope it landed in: a global commit reloads every mirror, because a named
+ * scope resolves over the global section; a scoped commit reloads that
+ * scope's mirror alone.
  *
  * Constructing the service in this plugin's fiber keeps its traced methods
  * bound to each consuming plugin's context.
@@ -56,18 +59,18 @@ export function apply(ctx: Context): void {
   // Resolved once here, where `remote` is declared in this plugin's own
   // `inject`; the binder hands the same answer to every scope it binds.
   const persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory'
-  const mirror = new SettingsDescribeMirror(ctx, persistence)
+  const mirrors = new SettingsMirrorRegistry(ctx, persistence)
   ctx.effect(() => {
     const disposers = [
-      ctx.remote.$on('settings/document-updated', () => { void mirror.load() }),
-      ctx.on('connection/reset', () => { void mirror.load() }),
+      ctx.remote.$on('settings/document-updated', (_ns, _revision, scope) => { void mirrors.invalidate(scope) }),
+      ctx.on('connection/reset', () => { void mirrors.reload() }),
     ]
     // The first connection also emits connection/reset, so startup normally
     // costs two reads (budgeted in startup-rpc-budget.e2e.ts). The in-flight
     // fold does not merge them into one; it guarantees at most one pending
     // read at a time and that no invalidation arriving mid-read is lost.
-    void mirror.ensure()
+    void mirrors.global.ensure()
     return () => { for (const dispose of disposers) dispose() }
   }, 'ui-settings: describe mirror invalidations')
-  new SettingsScopeBinder(ctx, { mirror, schema, persistence })
+  new SettingsScopeBinder(ctx, { mirrors, schema, persistence })
 }

+ 20 - 0
packages/client/ui-settings/src/client/settings-contract.ts

@@ -31,12 +31,32 @@ export interface SettingsScopeSnapshot<T> {
   writable: boolean
   /** `host` syncs with the Host document; `memory` keeps a remote browser process-local. */
   mode: 'host' | 'memory'
+  /** The named scope this scope reads, such as `preset/<id>`; undefined for the global instance. */
+  scope: string | undefined
+  /**
+   * Whether a live Host plugin registered the namespace under this scope.
+   * False under a named scope no composition has mounted the plugin in: the
+   * section can still be written, and takes effect once one does.
+   */
+  registered: boolean
+  /**
+   * Under a named scope, the value the scope resolves WITHOUT its own user
+   * section — what a field reverts to once cleared. Undefined for the global
+   * instance, whose fallback is {@link base}.
+   */
+  inherited: unknown
 }
 
 /** Domain-owned description of one settings namespace consumed by a browser plugin. */
 export interface SettingsScopeSpec<T> {
   /** Settings namespace registered by the owning Host plugin. */
   namespace: string
+  /**
+   * Named settings scope to read and write, such as `preset/<id>`; undefined
+   * binds the global instance. A named scope resolves over the global
+   * section, and its writes land in the document's `scopes.<id>` tree.
+   */
+  scope?: string
   /**
    * Narrow one wire section; undefined keeps the last accepted value. The
    * default validates the section against the namespace's own serialized wire

+ 85 - 1
packages/client/ui-settings/src/client/settings-mirror.ts

@@ -21,6 +21,10 @@ export interface SettingsDescribeView {
   writable: boolean
   /** Whether a native settings document exists for the Host to open. */
   hasDocument: boolean
+  /** The named scope the answer describes; absent for the global scope. */
+  scope?: string
+  /** Every named scope the Host knows: registered instances and document sections. */
+  scopes: readonly string[]
 }
 
 /** Mirror state every derived settings surface renders from. */
@@ -80,10 +84,13 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
    * @param ctx - the providing plugin's context, whose `remote.settings`
    * namespace answers the describe read.
    * @param persistence - client-selected Host persistence; non-loopback pages may remain process-local.
+   * @param scope - the named settings scope this mirror describes, such as
+   * `preset/<id>`; undefined mirrors the global scope.
    */
   constructor(
     private readonly ctx: ClientContext,
     private readonly persistence: 'host' | 'memory' = 'host',
+    readonly scope?: string,
   ) {
     this.store = createSnapshotStore<SettingsMirrorSnapshot>({
       status: persistence === 'host' ? 'idle' : 'unavailable',
@@ -180,7 +187,11 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
         const generation = ++this.generation
         let outcome: { view: SettingsDescribeView } | { failure: string }
         try {
-          const response = await this.ctx.remote.settings.describe()
+          // The wire method takes an optional trailing scope; the global read
+          // sends no argument rather than an explicit undefined.
+          const response = this.scope === undefined
+            ? await this.ctx.remote.settings.describe()
+            : await this.ctx.remote.settings.describe(this.scope)
           outcome = response.ok
             ? { view: response.value }
             : { failure: response.error.message }
@@ -211,3 +222,76 @@ export class SettingsDescribeMirror implements SettingsDescribeFace {
     return this.rerun
   }
 }
+
+/**
+ * One mirror per settings scope: the global mirror every existing consumer
+ * derives from, plus one per named scope created the first time a consumer
+ * binds it. Scoped mirrors are kept for the page's lifetime — the set of
+ * scopes is the set of presets, which is small and stable.
+ *
+ * Invalidation routes by the scope a `settings/document-updated` event
+ * names: a global commit re-resolves every instance (a named scope inherits
+ * the global section), so it reloads every mirror; a scoped commit changes
+ * that scope alone.
+ */
+export class SettingsMirrorRegistry {
+  /** The global mirror, created eagerly so the first consumer costs no wait. */
+  readonly global: SettingsDescribeMirror
+  private readonly scoped = new Map<string, SettingsDescribeMirror>()
+
+  /**
+   * @param ctx - the providing plugin's context, whose `remote.settings`
+   * namespace answers every describe read.
+   * @param persistence - client-selected Host persistence, shared by every mirror.
+   */
+  constructor(
+    private readonly ctx: ClientContext,
+    private readonly persistence: 'host' | 'memory',
+  ) {
+    this.global = new SettingsDescribeMirror(ctx, persistence)
+  }
+
+  /**
+   * The mirror describing one scope, creating a named scope's mirror on first use.
+   * @param scope - the named scope; undefined answers the global mirror.
+   * @returns the mirror.
+   */
+  mirrorFor(scope?: string): SettingsDescribeMirror {
+    if (scope === undefined) return this.global
+    let mirror = this.scoped.get(scope)
+    if (mirror === undefined) {
+      mirror = new SettingsDescribeMirror(this.ctx, this.persistence, scope)
+      this.scoped.set(scope, mirror)
+    }
+    return mirror
+  }
+
+  /**
+   * The named scopes a mirror has been created for, in creation order.
+   * @returns the scope ids.
+   */
+  scopes(): string[] {
+    return [...this.scoped.keys()]
+  }
+
+  /**
+   * Reload after a document commit. A global commit reaches every scope's
+   * resolved value, so every mirror reloads; a scoped commit reloads that
+   * scope's mirror alone — an unmirrored scope has nothing to refresh.
+   * @param scope - the scope the commit named; undefined for the global section.
+   * @returns settlement after the affected mirrors reflect the commit.
+   */
+  invalidate(scope?: string): Promise<void> {
+    if (scope !== undefined) return this.scoped.get(scope)?.load() ?? Promise.resolve()
+    return this.reload()
+  }
+
+  /**
+   * Reload every mirror, as a (re)connect requires.
+   * @returns settlement after every mirror reflects the Host.
+   */
+  reload(): Promise<void> {
+    return Promise.all([this.global.load(), ...[...this.scoped.values()].map(mirror => mirror.load())])
+      .then(() => undefined)
+  }
+}

部分文件因为文件数量过多而无法显示