Просмотр исходного кода

fix(web): list only installed packages on the sidebar Plugins page

The sidebar page no longer shows the profile template's own bundles
(dsh-base, dsh-web-app). The Settings Plugins section gets its read-only
Plugin list tab back for them, with both of its groups collapsed by
default. Restores @deepseek-ai/dsh-client-ui-settings-plugin-inventory
with its web bundle row, tsconfig entries, catalogs, and goldens, and
drops the built-in tag, locked switch, and source fact the page no
longer needs.
Yichen Jiang 1 неделя назад
Родитель
Сommit
2d660bf078
50 измененных файлов с 2211 добавлено и 126 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
  4. 21 17
      apps/web/tests/expected/plugin-config/section.expected.md
  5. 37 0
      apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
  6. 6 0
      apps/web/tests/expected/settings-chrome/plugins.expected.md
  7. 81 5
      apps/web/tests/settings-chrome.e2e.ts
  8. 2 2
      docs/config-catalog.i18n.yaml
  9. 1 0
      docs/config-catalog.md
  10. 1 0
      docs/config-catalog.zh.md
  11. 2 2
      docs/module-graph.i18n.yaml
  12. 2 0
      docs/module-graph.md
  13. 2 0
      docs/module-graph.zh.md
  14. 5 0
      packages/bundle/web-app/cordis.patch.yml
  15. 1 0
      packages/bundle/web-app/package.json
  16. 2 2
      packages/client/README.i18n.yaml
  17. 2 1
      packages/client/README.md
  18. 2 1
      packages/client/README.zh.md
  19. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  20. 2 2
      packages/client/ui-plugin-manager/README.md
  21. 2 2
      packages/client/ui-plugin-manager/README.zh.md
  22. 21 33
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  23. 0 10
      packages/client/ui-plugin-manager/src/client/locales.ts
  24. 27 35
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  25. 6 0
      packages/client/ui-settings-plugin-inventory/README.i18n.yaml
  26. 105 0
      packages/client/ui-settings-plugin-inventory/README.md
  27. 105 0
      packages/client/ui-settings-plugin-inventory/README.zh.md
  28. 68 0
      packages/client/ui-settings-plugin-inventory/package.json
  29. 431 0
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css
  30. 515 0
      packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx
  31. 58 0
      packages/client/ui-settings-plugin-inventory/src/client/index.ts
  32. 84 0
      packages/client/ui-settings-plugin-inventory/src/client/locales.ts
  33. 6 0
      packages/client/ui-settings-plugin-inventory/src/css-modules.d.ts
  34. 4 0
      packages/client/ui-settings-plugin-inventory/src/index.ts
  35. 104 0
      packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx
  36. 395 0
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  37. 39 0
      packages/client/ui-settings-plugin-inventory/tsconfig.json
  38. 3 0
      packages/client/ui-settings-plugin-inventory/tsdown.config.ts
  39. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  40. 1 0
      packages/client/ui-settings-plugins/README.md
  41. 1 0
      packages/client/ui-settings-plugins/README.zh.md
  42. 1 0
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  43. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  44. 1 1
      packages/host/plugin-inventory/README.md
  45. 1 1
      packages/host/plugin-inventory/README.zh.md
  46. 48 0
      pnpm-lock.yaml
  47. 1 0
      scripts/rescope-vendor.ts
  48. 1 0
      scripts/verify-package-readme-model-experience.ts
  49. 1 0
      tsconfig.base.json
  50. 1 0
      tsconfig.client.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.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-09-plugin-management-in-the-web-sidebar.md
-2026-09-09-plugin-management-in-the-web-sidebar.md: e80a14d3bb805875bc32bb8aefca2fb2c73f4a41
-2026-09-09-plugin-management-in-the-web-sidebar.zh.md: dc86602ffdd9d5dafe1a84162b6918e5a0176641
+2026-09-09-plugin-management-in-the-web-sidebar.md: 229cde95a133d7ae96fa57eb3b071fa9eb887d59
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 1238179a15d64806c50eb5a634a8c85e1d9902d4

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md

@@ -10,7 +10,7 @@ Installed packages belong to the running profile, while Settings is a modal over
 
 ## Decision
 
-**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages packages and global rows, displays install output and confirms dependency-sensitive actions. The Settings Plugins section keeps the global configuration cards and renders its single tab without a tab strip.
+**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages packages and global rows, displays install output and confirms dependency-sensitive actions. The page lists installed packages only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the built-in bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed.
 
 **One store follows Host state.** The manager controller reads package views, refreshes after management operations and reconnects, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
 
@@ -22,7 +22,7 @@ Installed packages belong to the running profile, while Settings is a modal over
 
 ## Consequences
 
-The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section shows the configuration page without tabs. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
+The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
 
 ## Testing
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决定
 
-**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面管理包与全局行、展示安装输出,并确认影响依赖的操作。设置的插件分区保留全局配置卡片,单个标签页直接展示内容,不显示标签栏。
+**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面管理包与全局行、展示安装输出,并确认影响依赖的操作。页面只列出已安装的包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,内置 bundle(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起。
 
 **一个 store 跟随 Host 状态。** 管理器控制器读取包视图,在管理操作和重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
 
@@ -22,7 +22,7 @@ Status: implemented
 
 ## 后果
 
-web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区显示没有标签页的配置页。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
+web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
 
 ## 测试
 

+ 21 - 17
apps/web/tests/expected/plugin-config/section.expected.md

@@ -22,20 +22,24 @@
     - text: 关闭
   - heading "插件" [level=2]
   - paragraph: 配置和查看本部署已安装的插件。
-  - list:
-    - listitem:
-      - 'button "展开设置: 终端"':
-        - text: 终端 限制 agent 运行的每一条命令。
-        - img
-    - listitem:
-      - 'button "展开设置: Agent 循环"':
-        - text: Agent 循环 Agent 如何派发工具调用。
-        - img
-    - listitem:
-      - 'button "展开设置: Subagent"':
-        - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
-        - img
-    - listitem:
-      - 'button "展开设置: 网页搜索"':
-        - text: 网页搜索 DeepSeek 搜索提供方。
-        - img
+  - tablist "插件视图":
+    - tab "插件配置" [selected]
+    - tab "插件列表"
+  - tabpanel "插件配置":
+    - list:
+      - listitem:
+        - 'button "展开设置: 终端"':
+          - text: 终端 限制 agent 运行的每一条命令。
+          - img
+      - listitem:
+        - 'button "展开设置: Agent 循环"':
+          - text: Agent 循环 Agent 如何派发工具调用。
+          - img
+      - listitem:
+        - 'button "展开设置: Subagent"':
+          - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。
+          - img
+      - listitem:
+        - 'button "展开设置: 网页搜索"':
+          - text: 网页搜索 DeepSeek 搜索提供方。
+          - img

+ 37 - 0
apps/web/tests/expected/settings-chrome/plugin-instances.expected.md

@@ -0,0 +1,37 @@
+- list:
+  - listitem:
+    - button "tool-subagent-control, tool-subagent-control, 已启用":
+      - strong: tool-subagent-control
+      - text: 已启用
+      - img
+      - code: tool-subagent-control
+  - listitem:
+    - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
+      - strong: tool-subagent-control/list-agents
+      - text: 已启用
+      - img
+      - code: tool-subagent-list-agents
+  - listitem:
+    - button "tool-subagent, tool-subagent, 已启用":
+      - strong: tool-subagent
+      - text: 已启用
+      - img
+      - code: tool-subagent
+  - listitem:
+    - button "tool-subagent, tool-subagent-fork, 已启用":
+      - strong: tool-subagent
+      - text: 已启用
+      - img
+      - code: tool-subagent-fork
+  - listitem:
+    - button "tool-subagent, tool-subagent-codex, 已停用":
+      - strong: tool-subagent
+      - text: 已停用
+      - img
+      - code: tool-subagent-codex
+  - listitem:
+    - button "tool-subagent, tool-subagent-claude-code, 已停用":
+      - strong: tool-subagent
+      - text: 已停用
+      - img
+      - code: tool-subagent-claude-code

+ 6 - 0
apps/web/tests/expected/settings-chrome/plugins.expected.md

@@ -0,0 +1,6 @@
+- listitem:
+  - button "tool-subagent, tool-subagent, 已启用":
+    - strong: tool-subagent
+    - text: 已启用
+    - img
+    - code: tool-subagent

+ 81 - 5
apps/web/tests/settings-chrome.e2e.ts

@@ -23,8 +23,11 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/settings-chrome', import.meta.url))
 const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
+const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md')
+const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected.md')
 // The English fallback surface: a browser naming no shipped language.
 const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md')
+const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]'
 const MODE = webSnapshotMode()
 
 describe('web e2e: settings modal and General preferences', () => {
@@ -94,14 +97,75 @@ describe('web e2e: settings modal and General preferences', () => {
     await dialog.getByRole('button', { name: '模型' }).click()
     await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
-    // Plugins: the section is the configuration page, with no tab strip;
-    // management lives on the sidebar's Plugins panel (its own scenario file
-    // drives that page over a profile runtime).
+    // Plugins: the configuration tab beside the read-only Plugin list, a
+    // projection of the same assembled Loader tree; management lives on the
+    // sidebar's Plugins panel (its own scenario file drives that page over a
+    // profile runtime). Capture one stable shipped row rather than the whole
+    // inventory so adding an unrelated plugin does not rewrite this surface's golden.
     await dialog.getByRole('button', { name: '插件', exact: true }).click()
     await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
+    // Both groups start collapsed; the preset group's header still carries its display-only switcher.
+    const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' })
+    await presetSwitcher.waitFor({ timeout: 10_000 })
+    // The shipped default's zh display name comes from the zh dictionaries.
+    expect(await presetSwitcher.textContent()).toBe('标准模式(默认)')
+    const presetToggle = dialog.getByRole('button', { name: '会话插件', exact: true })
+    expect(await presetToggle.getAttribute('aria-expanded')).toBe('false')
+    expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBe(0)
+    await presetToggle.click()
+    await dialog.getByRole('button', { name: /^全局/ }).click()
+    const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
+    await pluginRow.waitFor({ timeout: 10_000 })
+    const expectedPluginCount = [...scaffold.ctx.loader.entries()]
+      .filter(entry => !entry.options.group)
+      .length
+    const pluginSearch = dialog.getByRole('searchbox', { name: '搜索插件' })
+    expect(await pluginSearch.count()).toBe(1)
+    // Every Loader entry appears exactly once in the global group — rows the
+    // presets took over included, preset compositions excluded.
+    expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
+      .toBe(expectedPluginCount)
+    expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
+      .toBe(String(expectedPluginCount))
     expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
-    expect(await dialog.getByRole('tab').count()).toBe(0)
+    expect(await dialog.getByRole('tab', { name: '插件列表', exact: true }).getAttribute('aria-selected')).toBe('true')
     expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
+    const pluginsSnapshot = await captureStableAria(
+      page,
+      PLUGIN_ROW_SELECTOR,
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(PLUGINS_EXPECTED, pluginsSnapshot, MODE)
+    await pluginSearch.fill('tool-subagent')
+    const instanceRows = [
+      ['tool-subagent', '已启用'],
+      ['tool-subagent-fork', '已启用'],
+      ['tool-subagent-codex', '已停用'],
+      ['tool-subagent-claude-code', '已停用'],
+    ] as const
+    for (const [entryId, status] of instanceRows) {
+      const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`)
+      const trigger = row.getByRole('button', { name: `tool-subagent, ${entryId}, ${status}`, exact: true })
+      await trigger.waitFor({ timeout: 10_000 })
+      expect(await trigger.getAttribute('aria-expanded')).toBe('false')
+      const identity = row.locator('code')
+      expect(await identity.textContent()).toBe(entryId)
+      expect(await identity.getAttribute('title')).toBe(entryId)
+    }
+    const instancesSnapshot = await captureStableAria(
+      page,
+      '[data-plugin-scope="preset"] ul',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(PLUGIN_INSTANCES_EXPECTED, instancesSnapshot, MODE)
+    await dialog.getByRole('button', {
+      name: 'tool-subagent, tool-subagent-claude-code, 已停用',
+      exact: true,
+    }).click()
+    expect(await dialog.locator('[data-plugin-entry="tool-subagent-claude-code"] button')
+      .getAttribute('aria-expanded')).toBe('true')
+    await pluginSearch.fill('')
     // Close path 1: Escape.
     await page.keyboard.press('Escape')
     await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0)
@@ -590,6 +654,13 @@ describe('web e2e: settings modal and General preferences', () => {
       const dialog = enPage.getByRole('dialog', { name: 'Settings' })
       await dialog.waitFor({ timeout: 10_000 })
       await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
+      // The plugin list resolves shipped preset names through the en
+      // dictionaries instead of echoing the preset files' Chinese metadata.
+      await dialog.getByRole('button', { name: 'Plugins', exact: true }).click()
+      await dialog.getByRole('tab', { name: 'Plugin list', exact: true }).click()
+      const presetSwitcher = dialog.getByRole('button', { name: 'Choose the agent preset to inspect' })
+      await presetSwitcher.waitFor({ timeout: 10_000 })
+      expect(await presetSwitcher.textContent()).toBe('Standard mode (default)')
       // The plugin manager speaks the en dictionary too: its sidebar entry
       // and the unavailable notice a scaffold without a profile runtime shows.
       await enPage.keyboard.press('Escape')
@@ -644,6 +715,11 @@ describe('web e2e: settings modal and General preferences', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'dialog-en.expected.md',
+      'dialog.expected.md',
+      'plugin-instances.expected.md',
+      'plugins.expected.md',
+    ])
   })
 })

+ 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: 604da8f1422363b40cfe5902ded7e30319d82502
-config-catalog.zh.md: 8e707bd82b90d6006c76be28d68a038d8f207e30
+config-catalog.md: 79a0f9e5dd48aead2c4dde69888770a353e34f76
+config-catalog.zh.md: c845727e87b81ac07cf31705f66fee3013afa877

+ 1 - 0
docs/config-catalog.md

@@ -3533,6 +3533,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
 - `@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-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))

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

@@ -3536,6 +3536,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
 - `@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-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/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: 45cc05fb30f1c0842bd37e310267cf0bf4d12729
-module-graph.zh.md: c1c33021d1aaaaf7688f08cff67325078bca151a
+module-graph.md: b325bf8f195756b88865fc903b1edf8845db2c7b
+module-graph.zh.md: 365a256f0d51bdf400eb1a1e3b623c7d6b83a109

+ 2 - 0
docs/module-graph.md

@@ -172,6 +172,7 @@ flowchart TD
     pkg_client_ui_settings["client-ui-settings"]
     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_plugins["client-ui-settings-plugins"]
     pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
@@ -1310,6 +1311,7 @@ flowchart TD
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — |
 | [`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-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |

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

@@ -174,6 +174,7 @@ flowchart TD
     pkg_client_ui_settings["client-ui-settings"]
     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_plugins["client-ui-settings-plugins"]
     pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
@@ -1312,6 +1313,7 @@ flowchart TD
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | — |
 | [`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-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |

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

@@ -252,6 +252,11 @@
     - id: ui-plugin-manager
       name: '@deepseek-ai/dsh-client-ui-plugin-manager'
 
+    # Read-only Plugin list tab in the Settings Plugins section; the built-in
+    # bundles the sidebar page leaves out are inspected here.
+    - id: ui-settings-plugin-inventory
+      name: '@deepseek-ai/dsh-client-ui-settings-plugin-inventory'
+
     - id: ui-settings-unarchive-sessions
       name: '@deepseek-ai/dsh-client-ui-settings-unarchive-sessions'
 

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

@@ -80,6 +80,7 @@
     "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
     "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "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: 1b43d9edf6290ad5f1f7b4c289fb8d1ee2c79cf1
-README.zh.md: b314d06c376c0cec6d3996fbe0b1059ccb88e047
+README.md: 42b9fc8bf7592a9e9d47281cf11c602a09028136
+README.zh.md: 5e135c26ec37579026352402b73cc021eafcbc68

+ 2 - 1
packages/client/README.md

@@ -72,7 +72,8 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas | — |
 | [`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-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the plugin management tab to Plugins settings: install, enable, disable, retry, and compose packages | — |
+| [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — |
+| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
 | [`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) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |

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

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

+ 2 - 2
packages/client/ui-plugin-manager/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-plugin-manager/README.md
-README.md: 4aa1a8812397f878ea8d448bef2e3305480bd435
-README.zh.md: 871c55c1d00c3ad305d41f4cc45e34cb4e6e1210
+README.md: 206b014932c72674891f21a0336beda7f5c00b34
+README.zh.md: 584beace4ab880c480415bfe111eb57e5afd37da

+ 2 - 2
packages/client/ui-plugin-manager/README.md

@@ -33,11 +33,11 @@ Select **Plugins** in the sidebar. The page reads packages through `api-remotes`
 
 ### Switching a plugin pack
 
-A pack’s switch changes its enabled layer selection. Live profiles recompose before the operation completes; startup-only profiles show a restart notice. Runtime failures leave successful components active and keep the pack enabled, with **Retry** and **Uninstall** on its page. Unreadable bundle declarations prevent enabling. Built-in packs have locked switches. Disabling first checks dependents and asks for confirmation when needed.
+A pack’s switch changes its enabled layer selection. Live profiles recompose before the operation completes; startup-only profiles show a restart notice. Runtime failures leave successful components active and keep the pack enabled, with **Retry** and **Uninstall** on its page. Unreadable bundle declarations prevent enabling. Built-in packs are not on the page; the Settings Plugins section's Plugin list tab inspects them. Disabling first checks dependents and asks for confirmation when needed.
 
 ### Switching one row of a pack
 
-A row's switch on the pack's page calls `plugins.setRowDisabled` against the profile's global user layer: off writes `disabled: true` for the row's id into the profile's `cordis.patch.yml`, on deletes that key again. The tree recomposes at once, so the row's host half unmounts or mounts while the rest of the pack keeps running; a browser half the pack's own client bundle mounts for every component stays until the page reloads. The switch appears only where it acts at once — an external pack that is switched on, on a profile that applies patches while it runs; built-in packs, packs that are off, and profiles that apply patches at their next start list their rows read-only. A row the pack itself keeps off, by a `disabled: true` row or a `!!js` gate, is locked with the reason, because the user layer only ever denies. A failing row can be switched off; a row another layer already owns has nothing mounted to switch. Switching a row off first asks the Host what other rows inject of what the row provides, with the switch inert meanwhile, and opens a confirmation naming them only when there are any; a row nothing depends on switches off at once, and switching on never asks. A list longer than ten rows gets a filter over the row ids.
+A row's switch on the pack's page calls `plugins.setRowDisabled` against the profile's global user layer: off writes `disabled: true` for the row's id into the profile's `cordis.patch.yml`, on deletes that key again. The tree recomposes at once, so the row's host half unmounts or mounts while the rest of the pack keeps running; a browser half the pack's own client bundle mounts for every component stays until the page reloads. The switch appears only where it acts at once — a pack that is switched on, on a profile that applies patches while it runs; packs that are off and profiles that apply patches at their next start list their rows read-only. A row the pack itself keeps off, by a `disabled: true` row or a `!!js` gate, is locked with the reason, because the user layer only ever denies. A failing row can be switched off; a row another layer already owns has nothing mounted to switch. Switching a row off first asks the Host what other rows inject of what the row provides, with the switch inert meanwhile, and opens a confirmation naming them only when there are any; a row nothing depends on switches off at once, and switching on never asks. A list longer than ten rows gets a filter over the row ids.
 
 ### Packages without a bundle
 

+ 2 - 2
packages/client/ui-plugin-manager/README.zh.md

@@ -33,11 +33,11 @@ kind: "package-reference"
 
 ### 切换一个插件包
 
-组合包开关改变其启用层选择。实时 profile 在操作完成前重组;仅启动时生效的 profile 显示重启提示。运行失败会保留成功组件和组合包的启用状态,并在详情页提供**重试**与**卸载**。组合包声明不可读时无法启用。内置组合包的开关被锁定。停用会先检查依赖方,必要时要求确认。
+组合包开关改变其启用层选择。实时 profile 在操作完成前重组;仅启动时生效的 profile 显示重启提示。运行失败会保留成功组件和组合包的启用状态,并在详情页提供**重试**与**卸载**。组合包声明不可读时无法启用。内置组合包不在本页;设置中「插件」分区的「插件列表」标签页负责查看它们。停用会先检查依赖方,必要时要求确认。
 
 ### 切换插件包里的一行
 
-包页面上行的开关以 profile 的全局用户层为目标调用 `plugins.setRowDisabled`:关闭往 profile 的 `cordis.patch.yml` 写入该行 id 的 `disabled: true`,打开再把这个键删掉。树随即重组,该行的宿主半区卸下或挂上,插件包其余部分照常运行;插件包自己的客户端 bundle 为每个组件挂载的浏览器半区要到页面重载才消失。开关只在立即生效的地方出现——已打开的外部插件包,且 profile 在运行中就应用补丁;内置插件包、已关闭的插件包,以及下次启动才应用补丁的 profile,其行只读列出。插件包自己关掉的行,无论是 `disabled: true` 的行还是 `!!js` 门,都带原因锁定,因为用户层只能拒绝。异常的行可以关掉;已被别的层占用 id 的行没有挂载任何东西,无从切换。关闭一行前先向宿主询问有哪些行注入它提供的服务,询问期间开关置灰,有则弹出确认框点名它们;没有依赖方的行直接关闭,打开从不询问。超过十行的列表带一个按行 id 筛选的输入框。
+包页面上行的开关以 profile 的全局用户层为目标调用 `plugins.setRowDisabled`:关闭往 profile 的 `cordis.patch.yml` 写入该行 id 的 `disabled: true`,打开再把这个键删掉。树随即重组,该行的宿主半区卸下或挂上,插件包其余部分照常运行;插件包自己的客户端 bundle 为每个组件挂载的浏览器半区要到页面重载才消失。开关只在立即生效的地方出现——已打开的插件包,且 profile 在运行中就应用补丁;已关闭的插件包,以及下次启动才应用补丁的 profile,其行只读列出。插件包自己关掉的行,无论是 `disabled: true` 的行还是 `!!js` 门,都带原因锁定,因为用户层只能拒绝。异常的行可以关掉;已被别的层占用 id 的行没有挂载任何东西,无从切换。关闭一行前先向宿主询问有哪些行注入它提供的服务,询问期间开关置灰,有则弹出确认框点名它们;没有依赖方的行直接关闭,打开从不询问。超过十行的列表带一个按行 id 筛选的输入框。
 
 ### 没有组合包声明的包
 

+ 21 - 33
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -255,13 +255,11 @@ function EnableSwitch({ pkg, title, t, busy, onSetEnabled }: {
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
   if (pkg.kind !== 'bundle') return null
-  const builtin = !pkg.installed
   return (
     <Switch
       checked={pkg.enabled}
       label={t('enableToggle', { name: title })}
-      disabled={busy || builtin || (!pkg.enabled && pkg.status === 'not-enableable')}
-      {...builtin ? { title: t('builtinLocked') } : {}}
+      disabled={busy || (!pkg.enabled && pkg.status === 'not-enableable')}
       onChange={onSetEnabled}
     />
   )
@@ -276,7 +274,6 @@ function PackageCard({ pkg, t, busy, onOpen, onSetEnabled }: {
   readonly onSetEnabled: (enabled: boolean) => void
 }): ReactNode {
   const title = pkg.title ?? shortName(pkg.name)
-  const builtin = !pkg.installed
   const status = cardStatus(pkg)
   return (
     <li className={`${css.card} ${css.cardLink}`} data-plugin-package={pkg.name} data-plugin-status={pkg.status}>
@@ -284,7 +281,6 @@ function PackageCard({ pkg, t, busy, onOpen, onSetEnabled }: {
         <div className={css.cardMain}>
           <div className={css.titleRow}>
             <button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
-            {builtin ? <Tag>{t('builtinTag')}</Tag> : null}
             {status === null ? null : <Tag tone={status === 'problem' ? 'danger' : 'warning'}>{t(STATUS_KEYS[status])}</Tag>}
           </div>
           {pkg.description === undefined ? null : <span className={css.cardDesc}>{pkg.description}</span>}
@@ -320,14 +316,12 @@ function PackageDetail({
 }): ReactNode {
   const title = pkg.title ?? shortName(pkg.name)
   const bundle = pkg.kind === 'bundle'
-  const builtin = !pkg.installed
   const status = cardStatus(pkg)
   const retryable = bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
-  const removable = pkg.installed
   const affectedIssues = (pkg.issues ?? []).filter(issue => !pkg.rows.some(row => row.entryId === issue.entryId))
-  // A row's switch acts at once only on an external pack composed on a
+  // A row's switch acts at once only on a pack that is on, composed on a
   // profile that applies patches while it runs; elsewhere the rows stay read-only.
-  const switchable = bundle && pkg.installed && pkg.enabled && pkg.liveReload
+  const switchable = bundle && pkg.enabled && pkg.liveReload
   return (
     <div className={css.detail} data-plugin-detail={pkg.name}>
       <button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
@@ -340,7 +334,6 @@ function PackageDetail({
         <div className={css.detailMain}>
           <div className={css.titleRow}>
             <h3 className={css.detailTitle}>{title}</h3>
-            {builtin ? <Tag>{t('builtinTag')}</Tag> : null}
             {status === null ? null : <Tag tone={status === 'problem' ? 'danger' : 'warning'}>{t(STATUS_KEYS[status])}</Tag>}
           </div>
           <p className={css.detailDesc}>{pkg.description ?? t('noDescription')}</p>
@@ -349,30 +342,22 @@ function PackageDetail({
           {retryable
             ? <Button variant="outline" size="sm" disabled={busy} onClick={onRetry}>{t('retryPackage')}</Button>
             : null}
-          {removable
-            ? (
-              <Button
-                variant="outline"
-                size="sm"
-                className={css.danger}
-                aria-label={t('uninstallLabel', { name: title })}
-                disabled={busy}
-                onClick={onUninstall}
-              >
-                {t('uninstall')}
-              </Button>
-            )
-            : null}
+          <Button
+            variant="outline"
+            size="sm"
+            className={css.danger}
+            aria-label={t('uninstallLabel', { name: title })}
+            disabled={busy}
+            onClick={onUninstall}
+          >
+            {t('uninstall')}
+          </Button>
           <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
         </div>
       </div>
       {pkg.reason === undefined || status === 'waiting' ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
       {pkg.kind === 'unknown' ? <p className={css.detailDesc}>{t('unknownPackage')}</p> : null}
-      <dl className={css.facts}>
-        {pkg.version === undefined ? null : <><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></>}
-        <dt>{t('sourceLabel')}</dt>
-        <dd>{t(builtin ? 'sourceBuiltin' : 'sourceExternal')}</dd>
-      </dl>
+      {pkg.version === undefined ? null : <dl className={css.facts}><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></dl>}
       <div className={css.detailSections}>
         {affectedIssues.length === 0 ? null : (
           <section className={css.detailSection} data-plugin-affected-issues>
@@ -601,9 +586,12 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
   const [openPackage, setOpenPackage] = useState<string | null>(null)
   useEffect(() => { ensure() }, [ensure])
 
-  const restartPending = state.packages.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name))
+  // The page manages what the person installed; the bundles the profile
+  // template supplies are inspected in the Settings Plugins section's Plugin list tab.
+  const listed = state.packages.filter(pkg => pkg.installed)
+  const restartPending = listed.filter(pkg => pkg.status === 'restart-required').map(pkg => pkg.title ?? shortName(pkg.name))
   const loaded = state.status === 'ready' || state.status === 'error'
-  const openPkg = openPackage === null ? undefined : state.packages.find(pkg => pkg.name === openPackage)
+  const openPkg = openPackage === null ? undefined : listed.find(pkg => pkg.name === openPackage)
 
   return (
     <section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
@@ -657,10 +645,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
         )
         : null}
       {loaded && openPkg === undefined
-        ? state.packages.length === 0
+        ? listed.length === 0
           ? <p className={css.empty}>{t('empty')}</p>
           : PACKAGE_GROUPS.map((group) => {
-            const members = state.packages.filter(group.holds)
+            const members = listed.filter(group.holds)
             return members.length === 0
               ? null
               : (

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

@@ -18,16 +18,12 @@ export const zh = {
   bundlesTitle: '插件包',
   pluginsTitle: '其他已安装包',
   countUnit: '个',
-  builtinTag: '内置',
   statusRestart: '需重启',
   statusWaiting: '等待依赖',
   statusProblem: '异常',
   reasonLabel: '原因',
   affectedIssuesLabel: '受影响组件的问题',
   versionLabel: '版本',
-  sourceLabel: '来源',
-  sourceBuiltin: '内置',
-  sourceExternal: '第三方',
   noDescription: '暂无描述。',
   partsLabel: '包含的组件',
   partsEmpty: '这个插件包不包含任何组件。',
@@ -51,7 +47,6 @@ export const zh = {
   rowPhaseFailed: '异常',
   rowPhaseUnloading: '卸载中',
   enableToggle: '启用 {name}',
-  builtinLocked: '内置功能随产品启动,不能在这里停用。',
   openDetail: '查看 {name}',
   backToList: '返回插件列表',
   crumbRoot: '插件管理',
@@ -174,16 +169,12 @@ export const en = {
   bundlesTitle: 'Plugin packs',
   pluginsTitle: 'Other installed packages',
   countUnit: 'packages',
-  builtinTag: 'Built-in',
   statusRestart: 'Restart needed',
   statusWaiting: 'Waiting for a dependency',
   statusProblem: 'Problem',
   reasonLabel: 'Reason',
   affectedIssuesLabel: 'Issues in affected components',
   versionLabel: 'Version',
-  sourceLabel: 'Source',
-  sourceBuiltin: 'Built-in',
-  sourceExternal: 'Third-party',
   noDescription: 'No description.',
   partsLabel: 'Components',
   partsEmpty: 'This plugin pack contains no components.',
@@ -207,7 +198,6 @@ export const en = {
   rowPhaseFailed: 'Problem',
   rowPhaseUnloading: 'Unloading',
   enableToggle: 'Enable {name}',
-  builtinLocked: 'A built-in feature starts with the product and cannot be switched off here.',
   openDetail: 'View {name}',
   backToList: 'Back to plugins',
   crumbRoot: 'Manage plugins',

+ 27 - 35
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -134,36 +134,39 @@ describe('PluginManagerPage', () => {
     expect(screen.getByText(en.statusProblem)).toBeTruthy()
   })
 
-  it('lists every package as a card, tags only restarts, problems, and built-ins, and names what waits for a restart', () => {
-    const { version: _unversioned, ...bundledPackage } = pkg({
-      name: '@deepseek-ai/dsh-bundle-first-party', title: 'First party', installed: false,
-    })
+  it('lists the installed packages as cards, leaves built-in bundles to Settings, tags only restarts and problems, and names what waits for a restart', () => {
+    // A library with no version, as pnpm lists a linked directory.
+    const { version: _unversioned, ...someLib } = pkg({ name: 'some-lib', kind: 'unknown', status: 'plain' })
     const { actions } = renderTab({
       packages: [
         pkg({ description: 'A sidebar.' }),
-        bundledPackage,
+        // The profile template's own bundles and the libraries they carry are not installed by the person: not listed.
+        pkg({ name: '@deepseek-ai/dsh-bundle-first-party', title: 'First party', installed: false }),
         pkg({ name: 'unknown', installed: false, kind: 'unknown', status: 'plain' }),
-        pkg({ name: 'builtin-lib', installed: false, kind: 'unknown', status: 'plain' }),
+        pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required', installed: false }),
         pkg({ name: 'broken-bundle', enabled: false, status: 'not-enableable', reason: 'foreign cordis' }),
         pkg({ name: 'dsh-tool-foo', kind: 'unknown', status: 'plain' }),
-        pkg({ name: 'some-lib', kind: 'unknown', status: 'plain' }),
+        someLib,
         pkg({ name: 'pending-bundle', title: 'Pending', enabled: true, status: 'restart-required' }),
-        pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required', installed: false }),
+        pkg({ name: 'dsh-nameless', enabled: false, status: 'restart-required' }),
         pkg({ name: 'off-bundle', enabled: false, status: 'disabled' }),
       ],
     })
-    expect(screen.getByText(en.restartBanner.replace('{names}', 'Pending, untitled'))).toBeTruthy()
+    // The banner names a pack by its title, or by its short name when it has none.
+    expect(screen.getByText(en.restartBanner.replace('{names}', 'Pending, nameless'))).toBeTruthy()
     // Packs and plugins list in their own groups; a library counts among the plugins.
-    expect(document.querySelector('[data-plugin-group="bundles"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('6')
-    expect(document.querySelector('[data-plugin-group="plugins"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('4')
+    expect(document.querySelector('[data-plugin-group="bundles"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('5')
+    expect(document.querySelector('[data-plugin-group="plugins"] [data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('2')
     expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy()
     expect(screen.getByRole('heading', { name: en.pluginsTitle })).toBeTruthy()
+    for (const name of ['@deepseek-ai/dsh-bundle-first-party', 'unknown', 'dsh-untitled']) {
+      expect(document.querySelector(`[data-plugin-package="${name}"]`)).toBeNull()
+    }
     // No kind tag, no running or off tag: the switch says that.
     expect(screen.getByText(en.statusProblem)).toBeTruthy()
     expect(screen.getAllByText(en.statusRestart)).toHaveLength(2)
-    expect(screen.getAllByText(en.builtinTag)).toHaveLength(4)
     expect(screen.getByText('A sidebar.')).toBeTruthy()
-    expect(document.querySelectorAll('[data-tone]')).toHaveLength(7)
+    expect(document.querySelectorAll('[data-tone]')).toHaveLength(3)
 
     const sidebar = screen.getByRole('switch', { name: 'Enable better-sidebar' }) as HTMLButtonElement
     expect(sidebar.getAttribute('aria-checked')).toBe('true')
@@ -171,24 +174,16 @@ describe('PluginManagerPage', () => {
     expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false)
     expect(screen.getByRole('switch', { name: 'Enable broken-bundle' })).toHaveProperty('disabled', true)
     expect(screen.getByRole('switch', { name: 'Enable off-bundle' }).getAttribute('aria-checked')).toBe('false')
-    // A built-in pack keeps a locked switch in the same list; a built-in plugin or library has none.
-    const bundled = screen.getByRole('switch', { name: 'Enable First party' }) as HTMLButtonElement
-    expect(bundled.disabled).toBe(true)
-    expect(bundled.title).toBe(en.builtinLocked)
-    expect(screen.queryByRole('switch', { name: 'Enable unknown' })).toBeNull()
+    // A plugin or library has no switch.
     expect(screen.queryByRole('switch', { name: 'Enable tool-foo' })).toBeNull()
 
-    // Uninstall lives on the package's page, for installed packages the person added.
+    // Uninstall lives on the package's page; a package without a version shows no version fact.
     fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
     fireEvent.click(screen.getByRole('button', { name: 'Uninstall better-sidebar' }))
     expect(actions.uninstall).toHaveBeenCalledWith('dsh-better-sidebar')
+    expect(screen.getByText(en.versionLabel)).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    fireEvent.click(screen.getByRole('button', { name: 'View untitled' }))
-    expect(screen.queryByRole('button', { name: 'Uninstall untitled' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    fireEvent.click(screen.getByRole('button', { name: 'View First party' }))
-    expect(document.querySelector('[data-plugin-detail="@deepseek-ai/dsh-bundle-first-party"] dd')?.textContent).toBe(en.sourceBuiltin)
-    expect(screen.queryByRole('button', { name: 'Uninstall First party' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'View some-lib' }))
     expect(screen.queryByText(en.versionLabel)).toBeNull()
   })
 
@@ -223,6 +218,8 @@ describe('PluginManagerPage', () => {
         }),
       ],
     })
+    // A failing built-in pack is Settings' business, not this page's: no card, no problem tag for it.
+    expect(screen.queryByRole('button', { name: 'View Core' })).toBeNull()
     // A pack whose only trouble is a row waiting for a service is tagged as waiting, not as a problem.
     expect(screen.getByText(en.statusWaiting)).toBeTruthy()
     // The list and its toolbar give way to the page; the crumb leads back.
@@ -231,7 +228,6 @@ describe('PluginManagerPage', () => {
     expect(screen.getByRole('heading', { name: 'better-sidebar' })).toBeTruthy()
     expect(screen.getByText(`${en.reasonLabel}: one row failed`)).toBeTruthy()
     expect(screen.getByText('0.16.0')).toBeTruthy()
-    expect(screen.getByText(en.sourceExternal)).toBeTruthy()
     // Every row in the pack's order: its id over the module it loads, then its state; a failure adds its message.
     expect(screen.getByText('9 total · 2 running · 2 waiting · 2 off · 2 failed')).toBeTruthy()
     const rowText = (id: string): string | undefined => document.querySelector(`[data-plugin-row="include:${id}"]`)?.textContent
@@ -274,8 +270,7 @@ describe('PluginManagerPage', () => {
     expect(screen.queryByText(`${en.reasonLabel}: pending (waiting for service: authorization)`)).toBeNull()
     expect(screen.getByText('1 total · 1 waiting')).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    // A pack without rows says so; a plugin's page has no rows section; a
-    // built-in pack retries but never uninstalls.
+    // A pack without rows says so; a plugin's page has no rows section.
     fireEvent.click(screen.getByRole('button', { name: 'View no-rows' }))
     expect(screen.getByText(en.partsEmpty)).toBeTruthy()
     expect(screen.getByText(en.noDescription)).toBeTruthy()
@@ -284,11 +279,6 @@ describe('PluginManagerPage', () => {
     expect(screen.queryByText(en.partsLabel)).toBeNull()
     expect(screen.queryByRole('button', { name: en.retryPackage })).toBeNull()
     fireEvent.click(screen.getByRole('button', { name: en.backToList }))
-    fireEvent.click(screen.getByRole('button', { name: 'View Core' }))
-    expect(screen.getByText('1 total · 1 running')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: en.retryPackage }))
-    expect(actions.retry).toHaveBeenLastCalledWith('@deepseek-ai/dsh-core-broken')
-    expect(screen.queryByRole('button', { name: 'Uninstall Core' })).toBeNull()
     // A package that leaves the list while its page is open drops back to the cards.
     set({ packages: [] })
     expect(screen.getByText(en.empty)).toBeTruthy()
@@ -354,8 +344,10 @@ describe('PluginManagerPage', () => {
     expect(screen.getByText(en.partsFilterEmpty)).toBeTruthy()
     fireEvent.click(screen.getByRole('button', { name: en.backToList }))
     // Rows stay read-only where a switch would not act now: a profile that
-    // applies patches at its next start, a pack that is off, and a built-in pack.
-    for (const [name, rowId] of [['frozen', 'frozen'], ['parked', 'parked'], ['Core', 'core']] as const) {
+    // applies patches at its next start and a pack that is off. The built-in
+    // pack has no page here at all.
+    expect(screen.queryByRole('button', { name: 'View Core' })).toBeNull()
+    for (const [name, rowId] of [['frozen', 'frozen'], ['parked', 'parked']] as const) {
       fireEvent.click(screen.getByRole('button', { name: `View ${name}` }))
       expect(document.querySelector(`[data-plugin-row="include:${rowId}"]`)?.textContent).toContain(en.partDisabledByUser)
       expect(screen.queryByRole('switch', { name: `Enable component ${rowId}` })).toBeNull()

+ 6 - 0
packages/client/ui-settings-plugin-inventory/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-inventory/README.md
+README.md: d2cfae5d61ba245348c114b7212582ade4c5a37e
+README.zh.md: 107c51c7c32bb7dbc5cc9e250253ad4b40c8c1ac

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

@@ -0,0 +1,105 @@
+---
+description: "Scope-grouped read-only plugin inventory tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-plugin-inventory
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The **Plugin list** tab lets Web users inspect plugins without changing their configuration. It lists agent presets, then the global inventory; both start collapsed and open on demand or during a search. Cards retain the package name as the primary title, identify instances by stable entry id, and expose enablement, source details, runtime status, disabled conditions, and discovery failures; preset-provided global entries name their presets. Search covers both groups and points to matches in other presets. The tab handles loading, empty, no-match, failure, and retry states without exposing transport details, and still shows the global inventory without a preset roster.
+
+## 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 **Plugin list** tab to inspect the Host's plugin inventory. The tab reads no Remote during plugin activation — selecting it for the first time mounts the component and lazily calls `ctx.remote.pluginInventory.list()` through `api-remotes`.
+
+### Reading a card
+
+Each collapsed card uses the short module name as its primary title, shows the stable entry id underneath, and carries a small enablement tag; enabled entries also show a colored root-fiber status dot. A composition-generated subtitle omits its leading `include:` marker, while hover, search, the accessible name, and expanded details retain the complete id. Long entry ids truncate in the row and remain available on hover. 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.
+
+### The preset switcher
+
+The switcher is the same selector-pill-plus-menu control the General settings rows use. It lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen.
+
+### Retrying a failed read
+
+A failed read renders a generic failure state inside the tab; retrying re-runs the lazy `list()` call without exposing transport details.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The tab is a read-only projection of a Host-owned snapshot; it performs no Remote read during plugin activation and takes the snapshot on first selection.
+
+### Registration
+
+The browser plugin registers one localized `settings.plugins.tab` contribution with id `all`; the Plugins section owns the navigation entry and tab chrome. Registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner.
+
+### Rendering
+
+Row keys are scope-qualified (`global:`, `preset:<id>:<index>`), so one module appearing in both scopes keeps distinct disclosure state; a declared entry id appears in expanded details and supplies the collapsed subtitle after removal of a leading composition `include:` marker, while a row without one stays unlabeled. The preset-provided marking is derived client-side: a global entry carries it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+These pages cover the settings section, the remote call, and the Host-side projection.
+
+- [ui-settings-plugins](../ui-settings-plugins/README.md) — the Plugins section this tab registers into.
+- [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.plugins.tab`.
+- [api-remotes](../../api/remotes/README.md) — the Remote BFF surface behind `pluginInventory.list()`.
+- [plugin-inventory](../../host/plugin-inventory/README.md) — the Host-side read-only Loader projection this tab renders.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the package is a browser-side inventory projection 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 freshness and reach of the inventory view; they are current package constraints.
+
+- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one.
+- **Read-only in both planes** — the tab shows global and preset enablement but mutates neither; enable/disable controls that write a custom preset's own composition file are deliberate follow-up work.
+
+<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 read-only Settings contribution.

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

@@ -0,0 +1,105 @@
+---
+description: "dsh Web 客户端设置中按作用域分组的只读插件清单标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-settings-plugin-inventory
+
+[English](README.md) | 中文
+
+## 概述
+
+**插件列表**标签页让 Web 用户查看插件,而不改变其配置。它先列 agent(智能体)预设分组,再列全局清单;两个分组默认都收起,按需展开,搜索期间保持展开。卡片保留包名作为主标题,以稳定的条目 id 标识实例,并展示启停状态、出处、运行状态、禁用条件与发现失败;由预设提供的全局条目会列出对应预设。搜索覆盖两个分组,并指出其他预设中的匹配。标签页处理加载、空结果、无匹配、失败与重试状态,且不暴露传输细节;没有预设 roster 时仍会展示全局清单。
+
+## 目录
+
+- [使用本包](#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` 懒调用 `ctx.remote.pluginInventory.list()`。
+
+### 阅读卡片
+
+每张收起的卡片使用模块短名称作为主标题,在下方显示稳定的条目 id,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。组合生成的次标题省略开头的 `include:` 标记;悬停、搜索、无障碍名称与展开详情仍保留完整 id。长条目 id 会在行内截断,悬停时仍可查看完整值。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-presets/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设文件里的中文名。搜索按模块名称与条目 id 过滤两组。
+
+### 预设切换器
+
+切换器与通用设置各行使用同一种「选择胶囊 + 菜单」控件。它列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选择默认预设或会话预设的入口仍在原处:Agent 预设分区与新会话页。
+
+### 重试失败的读取
+
+读取失败会在标签页内渲染通用失败状态;重试会重新执行懒 `list()` 调用,且不会暴露传输细节。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+该标签页是宿主拥有快照的只读投影;插件激活期间不执行任何 Remote 读取,首次选择时才取快照。
+
+### 注册
+
+浏览器插件注册一个 id 为 `all` 的本地化 `settings.plugins.tab` 贡献;「插件」分区拥有导航入口与标签栏。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方。
+
+### 渲染
+
+行 key 按作用域限定(`global:`、`preset:<id>:<index>`),因此同一模块出现在两个作用域时保持各自的展开状态;声明的条目 id 出现在展开详情中,并在去掉开头的组合 `include:` 标记后作为收起次标题,没有 id 的行不显示次级标签。预设提供标记在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才携带它,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+以下页面覆盖设置分区、Remote 调用与宿主侧投影。
+
+- [ui-settings-plugins](../ui-settings-plugins/README.zh.md)——本标签页注册进的「插件」分区。
+- [ui-settings](../ui-settings/README.zh.md)——声明 `settings.plugins.tab` 的领域底座。
+- [api-remotes](../../api/remotes/README.zh.md)——`pluginInventory.list()` 背后的 Remote BFF 表面。
+- [plugin-inventory](../../host/plugin-inventory/README.zh.md)——本标签页所渲染的宿主侧只读 Loader 投影。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无。该包是浏览器端清单投影,不注册任何面向模型的内容。
+
+#### KV Cache 影响
+
+无;该包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制定义清单视图的新鲜度与触达范围;它们是当前包约束。
+
+- **每次 Settings 挂载或重试只读取一份快照**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。
+- **两个平面都只读**:标签页展示全局与预设的启停状态但都不修改;写回自定义预设组合文件的启停控件是刻意留作后续的工作。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变式:** 不发布伴生入口。本包只持有一个只读 Settings contribution。

+ 68 - 0
packages/client/ui-settings-plugin-inventory/package.json

@@ -0,0 +1,68 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory",
+  "description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
+  "version": "0.1.5-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-settings-plugin-inventory"
+  },
+  "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-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/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "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-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "@deepseek-ai/cordis": "workspace:^",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 431 - 0
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css

@@ -0,0 +1,431 @@
+.section {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  width: 100%;
+  max-width: 760px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.catalogHeading h3,
+.status,
+.failure p {
+  margin: 0;
+}
+
+.status,
+.failure {
+  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);
+}
+
+.failure button {
+  border: 0.5px solid var(--dsw-alias-border-l3);
+  border-radius: 6px;
+  padding: 4px 10px;
+  background: transparent;
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  cursor: pointer;
+}
+
+.catalog {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.search {
+  position: relative;
+  display: flex;
+  align-items: center;
+  width: 100%;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.search > svg {
+  position: absolute;
+  left: 12px;
+  pointer-events: none;
+}
+
+.search input {
+  width: 100%;
+  height: 36px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 10px;
+  padding: 0 34px 0 36px;
+  outline: none;
+  background: var(--dsw-alias-bg-layer-1);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 13px;
+}
+
+.search input::placeholder {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.search input:focus-visible {
+  border-color: var(--dsw-alias-state-business-primary);
+  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 18%, transparent);
+}
+
+.catalogHeading {
+  display: flex;
+  align-items: baseline;
+  gap: 7px;
+  padding: 0 2px;
+}
+
+.catalogHeading h3 {
+  font-size: 13px;
+  line-height: 20px;
+  font-weight: 600;
+}
+
+.catalogHeading span {
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+  font-variant-numeric: tabular-nums;
+}
+
+.cards {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  align-items: start;
+  gap: 10px;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.card {
+  min-width: 0;
+  overflow: hidden;
+  border: 0;
+  box-shadow: var(--dsw-elevation-stroke);
+  border-radius: 14px;
+  background: var(--dsw-alias-bg-layer-3);
+}
+
+.card[data-open='true'] {
+  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
+  box-shadow: var(--dsw-elevation-panel);
+}
+
+.cardContent {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  width: 100%;
+  min-height: 52px;
+  border: 0;
+  padding: 12px 14px;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.cardContent:hover,
+.card[data-open='true'] > .cardContent {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.cardContent:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -2px;
+}
+
+.cardMainRow {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  min-width: 0;
+}
+
+.cardTitle,
+.cardIdentity {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.cardTitle {
+  flex: 1;
+  min-width: 0;
+  font-size: 14px;
+  line-height: 20px;
+  font-weight: 600;
+}
+
+.cardIdentity {
+  display: block;
+  width: 100%;
+  color: var(--dsw-alias-label-tertiary);
+  font-family: var(--ds-font-family-code);
+  font-size: 11px;
+  line-height: 16px;
+}
+
+.cardTrailing {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  gap: 7px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* Accessible-name and layout wrapper: StateDot is aria-hidden and carries no
+   name, and the row needs the phase to stay on one flex line. */
+.phaseDot {
+  display: inline-flex;
+  flex: none;
+}
+
+.group {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.groupTitleRow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 32px;
+}
+
+.group + .group {
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+  padding-top: 14px;
+}
+
+.headerEnd {
+  margin-left: auto;
+}
+
+.groupToggle {
+  display: flex;
+  flex: none;
+  align-items: center;
+  gap: 8px;
+  border: 0;
+  padding: 0;
+  background: transparent;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+
+.groupToggle:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.groupToggle > .chevron {
+  transform: rotate(-90deg);
+}
+
+.groupToggle[aria-expanded='true'] > .chevron {
+  transform: none;
+}
+
+.groupTitle {
+  font-size: 14px;
+  line-height: 22px;
+  font-weight: 400;
+  color: var(--dsw-alias-label-primary);
+}
+
+.groupSub {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px 8px;
+  margin: -6px 0 0 20px;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12px;
+  line-height: 18px;
+  font-variant-numeric: tabular-nums;
+}
+
+.failedCount {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+/* The preset switcher trigger mirrors the General-settings selector pill. */
+.switcher {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  gap: 12px;
+  height: 36px;
+  white-space: nowrap;
+  border: none;
+  border-radius: 18px;
+  padding: 0 14px;
+  background: var(--dsw-alias-bg-module-platform);
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  font-size: 14px;
+  line-height: 22px;
+  cursor: pointer;
+}
+
+.switcher:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.switcher:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.switcher > .chevron {
+  flex: none;
+}
+
+.switcherLabel {
+  max-width: 240px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+
+.groupBody {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+
+.brokenNote {
+  margin: 0;
+  border-radius: 8px;
+  padding: 8px 10px;
+  background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
+  color: var(--dsw-alias-state-error-primary);
+  font-size: 12.5px;
+  line-height: 18px;
+  overflow-wrap: anywhere;
+  white-space: pre-line;
+}
+
+.hint {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 4px 8px;
+  margin: 0;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 12.5px;
+  line-height: 18px;
+}
+
+.jumpLink {
+  border: 0;
+  padding: 0;
+  background: transparent;
+  color: var(--dsw-alias-state-business-primary);
+  font: inherit;
+  font-size: 12.5px;
+  cursor: pointer;
+}
+
+
+.enabledIn {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: baseline;
+  gap: 4px 10px;
+}
+
+.card[data-failed='true'] {
+  border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
+}
+
+.chevron {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.card[data-open='true'] .chevron {
+  transform: rotate(180deg);
+}
+
+.cardDetails {
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+  padding: 10px 14px 12px;
+  background: var(--dsw-alias-bg-module-platform);
+}
+
+.entryValue {
+  display: block;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-primary);
+  font-family: var(--ds-font-family-code);
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.details {
+  display: grid;
+  grid-template-columns: 76px minmax(0, 1fr);
+  gap: 6px 10px;
+  margin: 8px 0 0;
+}
+
+.details div {
+  display: contents;
+}
+
+.details dt {
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 11px;
+  line-height: 17px;
+}
+
+.details dd {
+  min-width: 0;
+  margin: 0;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 12px;
+  line-height: 17px;
+}
+
+.visuallyHidden {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+  .chevron {
+    transition: transform 140ms var(--ds-ease-in-out);
+  }
+}
+
+@media (max-width: 680px) {
+  .cards {
+    grid-template-columns: minmax(0, 1fr);
+  }
+}

+ 515 - 0
packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx

@@ -0,0 +1,515 @@
+import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
+import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
+import {
+  IconChevronDownOutline14,
+  IconSearchOutline16,
+  Menu,
+  StateDot,
+  Tag,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { StateDotState, TagTone } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PluginInventoryLocaleKey } from './locales.ts'
+import css from './PluginInventorySettingsTab.module.css'
+
+type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
+type AgentPresetGroup = NonNullable<PluginInventorySnapshot['agentPresets']>[number]
+type AgentPresetRow = AgentPresetGroup['rows'][number]
+
+/** Registration-side Remote face used by the section. */
+export interface PluginInventorySettingsTabInjected {
+  /** Read a current Host inventory snapshot. */
+  list: () => Promise<PluginInventorySnapshot>
+  /**
+   * Display name for one preset: shipped presets resolve through the
+   * agent-preset dictionaries, user-authored ones keep their own metadata.
+   */
+  presetName: (preset: AgentPresetGroup) => string
+}
+type PluginFiberPhase = PluginInventoryEntry['fiberPhase']
+
+/** Full component props assembled by the Settings slot renderer. */
+export type PluginInventorySettingsTabProps =
+  PropsRuntime<'settings.plugins.tab'>
+  & PropsLocale<'settings.pluginInventory'>
+  & InjectFace<PluginInventorySettingsTabInjected>
+
+type Translate = PluginInventorySettingsTabProps['t']
+
+type ViewState =
+  | { readonly status: 'loading' }
+  | { readonly status: 'error' }
+  | { readonly status: 'ready'; readonly snapshot: PluginInventorySnapshot }
+
+const PHASE_KEYS = {
+  pending: 'pending',
+  loading: 'loadingPhase',
+  active: 'active',
+  failed: 'failed',
+  unloading: 'unloading',
+} satisfies Record<Exclude<PluginFiberPhase, null>, PluginInventoryLocaleKey>
+
+/** Localized accessible label for one root Fiber phase. */
+function phaseLabel(phase: PluginFiberPhase, t: Translate): string {
+  return phase === null ? t('unobserved') : t(PHASE_KEYS[phase])
+}
+
+/** 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
+  return unscoped
+    .replace(/^cordis:/, '')
+    .replace(/^cordis-plugin-/, '')
+    .replace(/^dsh-(?:host-|client-)?/, '')
+}
+
+/** Display an entry identity without the composition-only `include:` marker. */
+function entrySubtitle(entryId: string): string {
+  return entryId.replace(/^include:/, '')
+}
+
+/** Whether one row's module name or entry id matches the catalog query. */
+function matches(moduleName: string, entryId: string | null, normalizedQuery: string): boolean {
+  if (normalizedQuery.length === 0) return true
+  return [moduleName, ...entryId === null ? [] : [entryId]]
+    .some(value => value.toLocaleLowerCase().includes(normalizedQuery))
+}
+
+/** The roster row shown when the preset switcher has no explicit choice. */
+function fallbackPreset(presets: readonly AgentPresetGroup[]): AgentPresetGroup | undefined {
+  return presets.find(preset => preset.isDefault) ?? presets[0]
+}
+
+/** The switcher's display label for one preset. */
+function presetLabel(preset: AgentPresetGroup, t: Translate, presetName: (preset: AgentPresetGroup) => string): string {
+  const name = presetName(preset)
+  if (preset.broken !== undefined) return t('presetOptionBroken', { name })
+  if (preset.isDefault) return t('presetOptionDefault', { name })
+  return name
+}
+
+/** One expandable plugin card; the caller owns the trailing status content. */
+function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: {
+  readonly rowKey: string
+  readonly moduleName: string
+  readonly entryId: string | null
+  readonly trailing: ReactNode
+  readonly ariaLabel: string
+  readonly failed: boolean
+  readonly expanded: string | null
+  readonly onToggle: (key: string) => void
+  readonly children: ReactNode
+}): ReactNode {
+  const open = expanded === rowKey
+  const detailId = `plugin-details-${encodeURIComponent(rowKey)}`
+  return (
+    <li
+      className={css.card}
+      data-plugin-entry={entryId ?? undefined}
+      data-plugin-module={moduleName}
+      data-failed={failed ? 'true' : undefined}
+      data-open={open ? 'true' : undefined}
+    >
+      <button
+        className={css.cardContent}
+        type="button"
+        aria-expanded={open}
+        aria-controls={detailId}
+        aria-label={ariaLabel}
+        onClick={() => { onToggle(rowKey) }}
+      >
+        <span className={css.cardMainRow}>
+          <strong className={css.cardTitle} title={moduleName}>{moduleShortName(moduleName)}</strong>
+          <span className={css.cardTrailing}>
+            {trailing}
+            <IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
+          </span>
+        </span>
+        {entryId === null ? null : <code className={css.cardIdentity} title={entryId}>{entrySubtitle(entryId)}</code>}
+      </button>
+      {open ? <div className={css.cardDetails} id={detailId}>{children}</div> : null}
+    </li>
+  )
+}
+
+/** Detail rows shared by every card: the Loader identity, then labeled facts. */
+function CardFacts({ moduleName, moduleLabel, entryId, facts }: {
+  readonly moduleName: string
+  readonly moduleLabel: string
+  readonly entryId: string | null
+  readonly facts: readonly (readonly [label: string, value: ReactNode])[]
+}): ReactNode {
+  return (
+    <>
+      {entryId === null ? null : <code className={css.entryValue} data-loader-entry>{entryId}</code>}
+      <dl className={css.details}>
+        <div>
+          <dt>{moduleLabel}</dt>
+          <dd>{moduleName}</dd>
+        </div>
+        {facts.map(([label, value]) => (
+          <div key={label}>
+            <dt>{label}</dt>
+            <dd>{value}</dd>
+          </div>
+        ))}
+      </dl>
+    </>
+  )
+}
+
+/* `pending` is the only phase with no work under way. `loading` and
+ * `unloading` are both live transitions the Host is running — an async
+ * disposer can hold `unloading` for a while — so both animate. */
+const PHASE_DOT_STATES = {
+  pending: 'idle',
+  loading: 'ongoing',
+  active: 'done',
+  failed: 'error',
+  unloading: 'ongoing',
+} as const satisfies Record<NonNullable<PluginFiberPhase>, StateDotState>
+
+/** Status dot naming a live root-fiber phase; rows with no live fiber show none. */
+function PhaseDot({ phase, t }: { readonly phase: NonNullable<PluginFiberPhase>; readonly t: Translate }): ReactNode {
+  const status = phaseLabel(phase, t)
+  /* StateDot is aria-hidden, so the phase name lives on this wrapper. */
+  return (
+    <span className={css.phaseDot} role="img" aria-label={status} title={status}>
+      <StateDot state={PHASE_DOT_STATES[phase]} />
+    </span>
+  )
+}
+
+/** Enablement states one inventory row can report. */
+type EnablementKind = 'enabled' | 'disabled' | 'conditional' | 'preset' | 'failed'
+
+const TAG_TONES = {
+  enabled: 'success',
+  disabled: 'neutral',
+  conditional: 'warning',
+  preset: 'info',
+  failed: 'danger',
+} as const satisfies Record<EnablementKind, TagTone>
+
+/** Enablement tag; `kind` selects the palette. */
+function StateTag({ kind, label }: { readonly kind: EnablementKind; readonly label: string }): ReactNode {
+  return <Tag tone={TAG_TONES[kind]}>{label}</Tag>
+}
+
+/** Render the read-only plugin inventory: agent presets first, then the global plane. */
+export function PluginInventorySettingsTab({ list, presetName, t }: PluginInventorySettingsTabProps): ReactNode {
+  const sectionId = useId()
+  const [request, setRequest] = useState(0)
+  const [query, setQuery] = useState('')
+  const [expanded, setExpanded] = useState<string | null>(null)
+  const [chosenPreset, setChosenPreset] = useState<string | null>(null)
+  const [switcherOpen, setSwitcherOpen] = useState(false)
+  const [presetOpen, setPresetOpen] = useState<boolean | null>(null)
+  const [globalOpen, setGlobalOpen] = useState<boolean | null>(null)
+  const [state, setState] = useState<ViewState>({ status: 'loading' })
+
+  useEffect(() => {
+    let current = true
+    void Promise.resolve().then(() => list()).then(
+      (snapshot) => { if (current) setState({ status: 'ready', snapshot }) },
+      () => { if (current) setState({ status: 'error' }) },
+    )
+    return () => { current = false }
+  }, [list, request])
+
+  const normalizedQuery = query.trim().toLocaleLowerCase()
+  const searching = normalizedQuery.length > 0
+  const snapshot = state.status === 'ready' ? state.snapshot : undefined
+  const presets = snapshot?.agentPresets ?? []
+  const selected = presets.find(preset => preset.id === chosenPreset) ?? fallbackPreset(presets)
+
+  /** Presets that actually enable a module, keyed by module name. */
+  const enabledIn = useMemo(() => {
+    const found = new Map<string, [AgentPresetGroup, ...AgentPresetGroup[]]>()
+    for (const preset of presets) {
+      for (const row of preset.rows) {
+        if (row.enabled !== true) continue
+        const groups = found.get(row.moduleName)
+        if (groups === undefined) found.set(row.moduleName, [preset])
+        else if (!groups.includes(preset)) groups.push(preset)
+      }
+    }
+    return found
+  }, [presets])
+
+  const entries = snapshot?.entries ?? []
+  const failedEntries: PluginInventoryEntry[] = []
+  const regularEntries: PluginInventoryEntry[] = []
+  for (const entry of entries) {
+    if (entry.fiberPhase === 'failed') failedEntries.push(entry)
+    else regularEntries.push(entry)
+  }
+
+  const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery)
+  const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery)
+  const filteredFailed = failedEntries.filter(entryMatch)
+  const filteredRegular = regularEntries.filter(entryMatch)
+  const globalCount = filteredFailed.length + filteredRegular.length
+  const selectedRows = selected === undefined ? [] : selected.rows.filter(rowMatch)
+  const otherPresetMatches = searching
+    ? presets.filter(preset => preset !== selected && preset.rows.some(rowMatch))
+    : []
+  const otherMatchCount = otherPresetMatches
+    .reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0)
+
+  // Both groups start collapsed; a search opens them for as long as it lasts.
+  const presetEffectiveOpen = searching || (presetOpen ?? false)
+  const globalEffectiveOpen = searching || (globalOpen ?? false)
+  const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0
+    && otherPresetMatches.length === 0
+
+  const retry = (): void => {
+    setState({ status: 'loading' })
+    setRequest(value => value + 1)
+  }
+  const toggleRow = (key: string): void => {
+    setExpanded(current => current === key ? null : key)
+  }
+
+  /** Trailing status and detail facts for one row of the selected preset. */
+  const presetRowCard = (preset: AgentPresetGroup, row: AgentPresetRow, index: number): ReactNode => {
+    const key = `preset:${preset.id}:${String(index)}`
+    const title = moduleShortName(row.moduleName)
+    const failed = row.fiberPhase === 'failed'
+    const stateText = failed
+      ? t('failedTag')
+      : row.enabled === true ? t('enabledTag') : row.enabled === false ? t('disabledTag') : t('conditionalTag')
+    const kind = failed ? 'failed' : row.enabled === true ? 'enabled' : row.enabled === false ? 'disabled' : 'conditional'
+    return (
+      <PluginCard
+        key={key}
+        rowKey={key}
+        moduleName={row.moduleName}
+        entryId={row.entryId}
+        failed={failed}
+        expanded={expanded}
+        onToggle={toggleRow}
+        ariaLabel={`${title}${row.entryId === null ? '' : `, ${row.entryId}`}, ${stateText}`}
+        trailing={(
+          <>
+            {row.enabled === true && !failed && row.fiberPhase !== null
+              ? <PhaseDot phase={row.fiberPhase} t={t} />
+              : null}
+            <StateTag kind={kind} label={stateText} />
+          </>
+        )}
+      >
+        <CardFacts
+          moduleName={row.moduleName}
+          moduleLabel={t('moduleLabel')}
+          entryId={row.entryId}
+          facts={[
+            [t('fromPreset'), presetName(preset)],
+            [t('configuration'), stateText],
+            ...row.fiberPhase === null ? [] : [[t('runtime'), phaseLabel(row.fiberPhase, t)] as const],
+            ...row.condition === undefined ? [] : [[t('condition'), <code key="condition">{row.condition}</code>] as const],
+          ]}
+        />
+      </PluginCard>
+    )
+  }
+
+  /** One global-plane row; a preset-provided row carries the presets that enable it. */
+  const globalRowCard = (
+    entry: PluginInventoryEntry,
+    providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]],
+  ): ReactNode => {
+    const key = `global:${entry.entryId}`
+    const title = moduleShortName(entry.moduleName)
+    const failed = entry.fiberPhase === 'failed'
+    const stateText = failed
+      ? t('failedTag')
+      : providers !== undefined ? t('presetEnabledTag') : t(entry.enabled ? 'enabledTag' : 'disabledTag')
+    const kind = failed ? 'failed' : providers !== undefined ? 'preset' : entry.enabled ? 'enabled' : 'disabled'
+    return (
+      <PluginCard
+        key={key}
+        rowKey={key}
+        moduleName={entry.moduleName}
+        entryId={entry.entryId}
+        failed={failed}
+        expanded={expanded}
+        onToggle={toggleRow}
+        ariaLabel={`${title}, ${entry.entryId}, ${stateText}`}
+        trailing={(
+          <>
+            {entry.enabled && !failed && entry.fiberPhase !== null
+              ? <PhaseDot phase={entry.fiberPhase} t={t} />
+              : null}
+            <StateTag kind={kind} label={stateText} />
+          </>
+        )}
+      >
+        <CardFacts
+          moduleName={entry.moduleName}
+          moduleLabel={t('moduleLabel')}
+          entryId={entry.entryId}
+          facts={providers !== undefined
+            ? [
+              [t('configuration'), t('presetProvidedDetail')],
+              [t('enabledIn'), (
+                <span className={css.enabledIn}>
+                  <span>{providers.map(preset => presetName(preset)).join(' · ')}</span>
+                  <button
+                    type="button"
+                    className={css.jumpLink}
+                    onClick={() => { setChosenPreset(providers[0].id) }}
+                  >
+                    {t('viewInPreset')}
+                  </button>
+                </span>
+              )],
+            ]
+            : [
+              [t('configuration'), t(entry.enabled ? 'enabledTag' : 'disabledTag')],
+              ...entry.enabled ? [[t('runtime'), phaseLabel(entry.fiberPhase, t)] as const] : [],
+            ]}
+        />
+      </PluginCard>
+    )
+  }
+
+  return (
+    <div className={css.section} aria-busy={state.status === 'loading'}>
+      {state.status === 'loading' ? <p className={css.status}>{t('loading')}</p> : null}
+      {state.status === 'error' ? (
+        <div className={css.failure}>
+          <p role="alert">{t('error')}</p>
+          <button type="button" onClick={retry}>{t('retry')}</button>
+        </div>
+      ) : null}
+      {snapshot !== undefined ? (
+        <div className={css.catalog}>
+          <label className={css.search}>
+            <IconSearchOutline16 aria-hidden="true" />
+            <span className={css.visuallyHidden}>{t('search')}</span>
+            <input
+              type="search"
+              value={query}
+              placeholder={t('search')}
+              aria-label={t('search')}
+              onChange={(event) => { setQuery(event.currentTarget.value) }}
+            />
+          </label>
+          {entries.length === 0 && presets.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
+          {nothingMatches ? <p className={css.status}>{t('emptySearch')}</p> : null}
+
+          {selected !== undefined ? (
+            <section className={css.group} data-plugin-scope="preset" data-preset-id={selected.id}>
+              <div className={css.groupTitleRow}>
+                <button
+                  type="button"
+                  className={css.groupToggle}
+                  aria-expanded={presetEffectiveOpen}
+                  aria-controls={`${sectionId}-preset`}
+                  onClick={() => { setPresetOpen(!presetEffectiveOpen) }}
+                >
+                  <IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
+                  <span className={css.groupTitle}>{t('presetTitle')}</span>
+                </button>
+                <div className={css.headerEnd}>
+                  <Menu
+                    open={switcherOpen}
+                    onClose={() => { setSwitcherOpen(false) }}
+                    items={presets.map(preset => ({ id: preset.id, label: presetLabel(preset, t, presetName) }))}
+                    selectedId={selected.id}
+                    onSelect={(id) => {
+                      setSwitcherOpen(false)
+                      setChosenPreset(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>
+              </div>
+              <p className={css.groupSub}>
+                {t('presetSubtitle')}
+                <span data-preset-plugin-count={selectedRows.length}>
+                  {` · ${String(selectedRows.length)} ${t('countUnit')}`}
+                </span>
+              </p>
+              {presetEffectiveOpen ? (
+                <div id={`${sectionId}-preset`} className={css.groupBody}>
+                  {selected.broken !== undefined ? (
+                    <p className={css.brokenNote} role="alert">{selected.broken}</p>
+                  ) : null}
+                  {selectedRows.length > 0 ? (
+                    <ul className={css.cards}>
+                      {selectedRows.map((row, index) => presetRowCard(selected, row, index))}
+                    </ul>
+                  ) : null}
+                  {otherMatchCount > 0 ? (
+                    <p className={css.hint}>
+                      {t('matchesInOtherPresets', { count: String(otherMatchCount) })}
+                      {otherPresetMatches.map(preset => (
+                        <button
+                          key={preset.id}
+                          type="button"
+                          className={css.jumpLink}
+                          onClick={() => { setChosenPreset(preset.id) }}
+                        >
+                          {presetName(preset)}
+                        </button>
+                      ))}
+                    </p>
+                  ) : null}
+                </div>
+              ) : null}
+            </section>
+          ) : null}
+
+          {entries.length > 0 ? (
+            <section className={css.group} data-plugin-scope="global">
+              <div className={css.groupTitleRow}>
+                <button
+                  type="button"
+                  className={css.groupToggle}
+                  aria-expanded={globalEffectiveOpen}
+                  aria-controls={`${sectionId}-global`}
+                  onClick={() => { setGlobalOpen(!globalEffectiveOpen) }}
+                >
+                  <IconChevronDownOutline14 className={css.chevron} size={12} aria-hidden="true" />
+                  <span className={css.groupTitle}>{t('globalTitle')}</span>
+                </button>
+              </div>
+              <p className={css.groupSub}>
+                {t('globalSubtitle')}
+                <span data-plugin-count={globalCount}>{` · ${String(globalCount)} ${t('countUnit')}`}</span>
+                {filteredFailed.length > 0 ? (
+                  <span className={css.failedCount}>{filteredFailed.length} {t('failedCountLabel')}</span>
+                ) : null}
+              </p>
+              {globalEffectiveOpen && globalCount > 0 ? (
+                <ul className={css.cards} id={`${sectionId}-global`}>
+                  {filteredFailed.map(entry => globalRowCard(entry))}
+                  {filteredRegular.map(entry => globalRowCard(
+                    entry,
+                    entry.enabled ? undefined : enabledIn.get(entry.moduleName),
+                  ))}
+                </ul>
+              ) : null}
+            </section>
+          ) : null}
+        </div>
+      ) : null}
+    </div>
+  )
+}

+ 58 - 0
packages/client/ui-settings-plugin-inventory/src/client/index.ts

@@ -0,0 +1,58 @@
+/** Read-only Host plugin inventory registered into Web Settings. */
+
+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'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/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 { PluginInventorySettingsTab, type PluginInventorySettingsTabInjected } from './PluginInventorySettingsTab.tsx'
+import { en, zh, type PluginInventoryLocaleKey } from './locales.ts'
+
+export type { PluginInventorySettingsTabInjected, PluginInventorySettingsTabProps } from './PluginInventorySettingsTab.tsx'
+export type { PluginInventoryLocaleKey } from './locales.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Read-only Host plugin inventory copy. */
+    'settings.pluginInventory': PluginInventoryLocaleKey
+  }
+}
+
+/** Dictionary namespace owned by this plugin. */
+export const NS = 'settings.pluginInventory'
+
+/** Services required by the Settings registration and generated Remote face. */
+export const inject = ['slots', 'locale', 'remote', 'remote.pluginInventory']
+
+/** Contribute the lazy inventory tab to the Plugins settings section. */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugin-inventory: dictionaries')
+
+  const t = ctx.locale.bind(NS)
+  const list: PluginInventorySettingsTabInjected['list'] = async () => {
+    const result = await ctx.remote.pluginInventory.list()
+    if (!result.ok) {
+      throw new Error(`pluginInventory.list failed: ${result.error.code}: ${result.error.message}`)
+    }
+    return result.value
+  }
+  // Resolved per call over ui-agent-preset's dictionaries, so a language
+  // switch re-resolves shipped names; user-authored metadata passes through.
+  const agentPresetCopy = ctx.locale.bind('settings.agentPreset')
+  const presetName: PluginInventorySettingsTabInjected['presetName'] = preset =>
+    presetDisplayText(preset, agentPresetCopy).name
+  const injected = (): PluginInventorySettingsTabInjected => ({ list, presetName })
+
+  ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
+    name: 'settings.plugins.tab',
+    id: 'all',
+    order: 10,
+    label: () => t('tab'),
+    locale: NS,
+    inject: injected,
+  }, PluginInventorySettingsTab))
+}

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

@@ -0,0 +1,84 @@
+/** Copy dictionaries for the plugin inventory Settings section. */
+
+/** Simplified Chinese dictionary and key source of truth. */
+export const zh = {
+  tab: '插件列表',
+  loading: '正在读取插件…',
+  error: '暂时无法读取插件。',
+  retry: '重试',
+  search: '搜索插件',
+  empty: '暂无插件。',
+  emptySearch: '没有匹配的插件。',
+  presetTitle: '会话插件',
+  presetSubtitle: '由 Agent 预设按会话组成',
+  countUnit: '个',
+  switcherLabel: '选择要查看的 Agent 预设',
+  presetOptionDefault: '{name}(默认)',
+  presetOptionBroken: '{name}(加载失败)',
+  globalTitle: '全局插件',
+  globalSubtitle: '系统与所有会话共用',
+  presetProvidedDetail: '全局已停用,由 Agent 预设按会话提供',
+  enabledIn: '启用于',
+  viewInPreset: '去预设分组查看',
+  matchesInOtherPresets: '其他预设中还有 {count} 个匹配:',
+  failedCountLabel: '个失败',
+  enabledTag: '已启用',
+  disabledTag: '已停用',
+  conditionalTag: '条件启用',
+  presetEnabledTag: '预设中启用',
+  failedTag: '启动失败',
+  moduleLabel: '完整名称',
+  fromPreset: '来自',
+  condition: '禁用条件',
+  configuration: '配置状态',
+  runtime: '运行状态',
+  unobserved: '未运行',
+  pending: '等待依赖',
+  loadingPhase: '加载中',
+  active: '运行中',
+  failed: '启动失败',
+  unloading: '卸载中',
+} satisfies Record<string, string>
+
+/** Plugin inventory locale key union. */
+export type PluginInventoryLocaleKey = keyof typeof zh
+
+/** English dictionary checked against the Chinese key set. */
+export const en = {
+  tab: 'Plugin list',
+  loading: 'Reading plugins…',
+  error: 'Plugins are temporarily unavailable.',
+  retry: 'Retry',
+  search: 'Search plugins',
+  empty: 'No plugins are available.',
+  emptySearch: 'No matching plugins.',
+  presetTitle: 'Session plugins',
+  presetSubtitle: 'Composed per session by agent presets',
+  countUnit: 'plugins',
+  switcherLabel: 'Choose the agent preset to inspect',
+  presetOptionDefault: '{name} (default)',
+  presetOptionBroken: '{name} (failed to load)',
+  globalTitle: 'Global plugins',
+  globalSubtitle: 'Shared by the system and every session',
+  presetProvidedDetail: 'Disabled globally; agent presets provide it per session',
+  enabledIn: 'Enabled in',
+  viewInPreset: 'View in the preset group',
+  matchesInOtherPresets: '{count} more matches in other presets: ',
+  failedCountLabel: 'failed',
+  enabledTag: 'Enabled',
+  disabledTag: 'Disabled',
+  conditionalTag: 'Conditional',
+  presetEnabledTag: 'Enabled via presets',
+  failedTag: 'Failed',
+  moduleLabel: 'Module',
+  fromPreset: 'From',
+  condition: 'Disabled when',
+  configuration: 'Configuration',
+  runtime: 'Status',
+  unobserved: 'Not running',
+  pending: 'Waiting for dependencies',
+  loadingPhase: 'Loading',
+  active: 'Running',
+  failed: 'Failed to start',
+  unloading: 'Unloading',
+} satisfies Record<PluginInventoryLocaleKey, string>

+ 6 - 0
packages/client/ui-settings-plugin-inventory/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-inventory/src/index.ts

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

+ 104 - 0
packages/client/ui-settings-plugin-inventory/tests/browser-plugin.client.spec.tsx

@@ -0,0 +1,104 @@
+// @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 { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply, inject, NS } from '../src/client/index.ts'
+import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
+import type { PluginInventorySettingsTabInjected } from '../src/client/PluginInventorySettingsTab.tsx'
+import { apply as hostApply } from '../src/index.ts'
+
+usePinnedBrowserLanguages('zh-CN')
+afterEach(cleanup)
+
+const EMPTY = { entries: [] }
+type ListResult =
+  | { readonly ok: true; readonly value: typeof EMPTY }
+  | { readonly ok: false; readonly error: { readonly code: string; readonly message: string } }
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotRegistry).await()
+  const locale = new LocaleRuntime(ctx)
+  ctx.provide('locale', locale)
+  class RemoteService extends Service {
+    constructor(serviceCtx: Context) {
+      super(serviceCtx, 'remote')
+    }
+  }
+  new RemoteService(ctx)
+  const list = vi.fn<() => Promise<ListResult>>()
+    .mockResolvedValue({ ok: true, value: EMPTY })
+  ctx.provide('remote.pluginInventory', { list })
+  return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list }
+}
+
+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-inventory browser plugin', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('declares only the services used by the Settings Remote contribution', () => {
+    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginInventory'])
+  })
+
+  it('registers a localized tab without reading the Remote eagerly', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+
+    const entry = b.slots.entries('settings.plugins.tab')[0]!
+    expect(entry.component).toBe(PluginInventorySettingsTab)
+    expect(entry.options).toMatchObject({ id: 'all', order: 10 })
+    expect(entry.locale).toBe(NS)
+    expect(resolveSlotLabel(entry.options.label)).toBe('插件列表')
+    expect(b.list).not.toHaveBeenCalled()
+
+    const injected = (entry.inject as unknown as () => PluginInventorySettingsTabInjected)()
+    await expect(injected.list()).resolves.toEqual(EMPTY)
+    expect(b.list).toHaveBeenCalledOnce()
+    b.list.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'unavailable' } })
+    await expect(injected.list()).rejects.toThrow('pluginInventory.list failed: REMOTE_ERROR: unavailable')
+
+    // 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(injected.presetName({ id: 'standard', trust: 'system', isDefault: true, rows: [] })).toBe('标准模式')
+    expect(injected.presetName({ id: 'mine', trust: 'user', name: '我自己的', isDefault: false, rows: [] })).toBe('我自己的')
+    await b.ctx.fiber.dispose()
+  })
+
+  it('follows locale and recovers across late declaration and declarer reload', async () => {
+    const b = await bench()
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
+
+    const stop = declare(b.slots)
+    await vi.waitFor(() => { expect(b.slots.entries('settings.plugins.tab')).toHaveLength(1) })
+    b.locale.setLocale('en')
+    expect(resolveSlotLabel(b.slots.entries('settings.plugins.tab')[0]!.options.label)).toBe('Plugin list')
+
+    stop()
+    expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
+    declare(b.slots)
+    await vi.waitFor(() => {
+      expect(b.slots.entries('settings.plugins.tab')[0]?.component).toBe(PluginInventorySettingsTab)
+    })
+
+    await fiber.dispose()
+    expect(b.slots.entries('settings.plugins.tab')).toHaveLength(0)
+    expect(() => b.locale.register(NS, 'zh', {})).not.toThrow()
+    await b.ctx.fiber.dispose()
+  })
+})

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

@@ -0,0 +1,395 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { PluginInventorySettingsTab } from '../src/client/PluginInventorySettingsTab.tsx'
+import type {
+  PluginInventorySettingsTabInjected,
+  PluginInventorySettingsTabProps,
+} from '../src/client/PluginInventorySettingsTab.tsx'
+import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+type Snapshot = Awaited<ReturnType<PluginInventorySettingsTabInjected['list']>>
+const t = ((key: PluginInventoryLocaleKey, params?: Record<string, string>): string =>
+  Object.entries(params ?? {}).reduce(
+    (text, [name, value]) => text.replaceAll(`{${name}}`, value),
+    en[key],
+  )) as PluginInventorySettingsTabProps['t']
+
+function props(
+  list: PluginInventorySettingsTabInjected['list'],
+  presetName: PluginInventorySettingsTabInjected['presetName'] = preset => preset.name ?? preset.id,
+): PluginInventorySettingsTabProps {
+  return {
+    t,
+    list,
+    presetName,
+  } as PluginInventorySettingsTabProps
+}
+
+/** A deployment with a roster: one failed global row, two preset-provided rows. */
+const SNAPSHOT = {
+  entries: [
+    { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' },
+    { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' },
+    { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
+    { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null },
+    { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null },
+    { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null },
+    { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null },
+  ],
+  agentPresets: [
+    {
+      id: 'standard',
+      trust: 'system',
+      name: '标准模式',
+      isDefault: true,
+      rows: [
+        { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active' },
+        { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: true, fiberPhase: null },
+        {
+          entryId: 'pwsh',
+          moduleName: '@fixture/pwsh',
+          enabled: 'conditional',
+          condition: 'process.platform === \'win32\'',
+          fiberPhase: null,
+        },
+        { entryId: 'codex', moduleName: '@fixture/codex', enabled: false, fiberPhase: null },
+        { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed' },
+        { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null },
+      ],
+    },
+    {
+      id: 'ptc',
+      trust: 'system',
+      isDefault: false,
+      rows: [
+        { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null },
+        { entryId: 'bash-fork', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null },
+        { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: 'conditional', fiberPhase: null },
+      ],
+    },
+    { id: 'shattered', trust: 'user', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] },
+  ],
+} as unknown as Snapshot
+
+async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise<ReturnType<typeof render>> {
+  const view = render(<PluginInventorySettingsTab {...props(async () => snapshot)} />)
+  await screen.findByRole('searchbox', { name: en.search })
+  return view
+}
+
+const globalToggle = (): HTMLElement =>
+  screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })
+const presetToggle = (): HTMLElement => screen.getByRole('button', { name: en.presetTitle })
+
+describe('PluginInventorySettingsTab', () => {
+  it('shows the default preset first with both groups collapsed', async () => {
+    const view = await renderReady()
+
+    const switcher = screen.getByRole('button', { name: en.switcherLabel })
+    expect(switcher.textContent).toBe('标准模式 (default)')
+    fireEvent.click(switcher)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
+      '标准模式 (default)',
+      'ptc',
+      '坏预设 (failed to load)',
+    ])
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
+    expect(screen.getByText(en.presetSubtitle)).toBeTruthy()
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
+
+    // Both groups start collapsed; opening the preset group lists its rows while the global plane stays folded.
+    expect(presetToggle().getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryAllByRole('listitem')).toHaveLength(0)
+    fireEvent.click(presetToggle())
+    expect(presetToggle().getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getAllByRole('listitem')).toHaveLength(6)
+    expect(screen.getAllByText(en.enabledTag)).toHaveLength(3)
+    expect(screen.getByText(en.conditionalTag)).toBeTruthy()
+    expect(screen.getByText(en.disabledTag)).toBeTruthy()
+    expect(screen.getByText(en.failedTag)).toBeTruthy()
+    expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy()
+    // No live fiber, no dot: file-state rows carry only their enablement tag.
+    expect(screen.queryByRole('img', { name: 'Not running' })).toBeNull()
+
+    expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
+    expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('7')
+    expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy()
+
+    // A preset row expands into its source facts.
+    fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
+    expect(screen.getByText(en.fromPreset)).toBeTruthy()
+    expect(screen.getByText('标准模式')).toBeTruthy()
+    expect(screen.getByText(en.condition)).toBeTruthy()
+    expect(screen.getByText('process.platform === \'win32\'')).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
+    expect(screen.queryByText(en.condition)).toBeNull()
+
+    // A failed preset row names its runtime state instead of a condition.
+    fireEvent.click(screen.getByRole('button', { name: 'crashy, crashy, Failed' }))
+    expect(screen.getByText(en.runtime)).toBeTruthy()
+    expect(screen.getByText('Failed to start')).toBeTruthy()
+
+    // A row declaring no id has no Loader identity line, only its module.
+    fireEvent.click(screen.getByRole('button', { name: 'anonymous, Enabled' }))
+    expect(view.container.querySelector('[data-loader-entry]')).toBeNull()
+    expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous')
+  })
+
+  it('distinguishes collapsed same-module rows by stable entry id', async () => {
+    const longId = 'include:agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
+    const subtitle = 'agent-presets:tool-subagent-secondary-with-a-complete-stable-identity'
+    await renderReady({
+      entries: [],
+      agentPresets: [{
+        id: 'same-module',
+        trust: 'user',
+        isDefault: true,
+        rows: [
+          { entryId: 'tool-subagent-primary', moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: true, fiberPhase: null },
+          { entryId: longId, moduleName: '@deepseek-ai/dsh-tool-subagent', enabled: false, fiberPhase: null },
+        ],
+      }],
+    })
+
+    fireEvent.click(presetToggle())
+    expect(screen.getByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })
+      .getAttribute('aria-expanded')).toBe('false')
+    const secondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
+    expect(secondary.getAttribute('aria-expanded')).toBe('false')
+    expect(secondary.children).toHaveLength(2)
+    expect(secondary.children[0]?.textContent).toContain('tool-subagent')
+    expect(secondary.children[0]?.textContent).toContain('Disabled')
+    expect(secondary.children[1]?.textContent).toBe(subtitle)
+    expect(screen.getByTitle(longId).textContent).toBe(subtitle)
+
+    fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'include:agent-presets:tool-subagent-secondary' } })
+    expect(screen.queryByRole('button', { name: 'tool-subagent, tool-subagent-primary, Enabled' })).toBeNull()
+    const filteredSecondary = screen.getByRole('button', { name: `tool-subagent, ${longId}, Disabled` })
+    fireEvent.click(filteredSecondary)
+    expect(filteredSecondary.getAttribute('aria-expanded')).toBe('true')
+  })
+
+  it('expands the global plane with failures first and preset-provided rows inline', async () => {
+    const view = await renderReady()
+
+    expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
+    fireEvent.click(globalToggle())
+    expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
+    const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]')
+    expect(failed?.getAttribute('data-plugin-entry')).toBe('telemetry')
+    // Failures float above the Loader-ordered remainder.
+    expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed)
+
+    // Rows the presets took over sit inline, marked instead of plainly disabled.
+    expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2)
+
+    fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
+    expect(screen.getByText(en.presetProvidedDetail)).toBeTruthy()
+    expect(screen.getByText(en.enabledIn)).toBeTruthy()
+    expect(screen.getByText('标准模式 · ptc')).toBeTruthy()
+
+    // The failed global card reports its runtime state.
+    fireEvent.click(screen.getByRole('button', { name: 'telemetry, telemetry, Failed' }))
+    expect(screen.getByText('Failed to start')).toBeTruthy()
+
+    // An enabled entry with no live fiber says so in its details, dot-free.
+    fireEvent.click(screen.getByRole('button', { name: 'unobserved-name, unobserved, Enabled' }))
+    expect(screen.getByText('Not running')).toBeTruthy()
+
+    // A disabled row outside every preset stays plainly disabled.
+    fireEvent.click(screen.getByRole('button', { name: 'dormant, dormant, Disabled' }))
+    expect(screen.queryByText(en.presetProvidedDetail)).toBeNull()
+
+    fireEvent.click(globalToggle())
+    expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryByText(en.presetEnabledTag)).toBeNull()
+  })
+
+  it('switches the inspected preset in place, including broken ones', async () => {
+    const view = await renderReady()
+    const pickPreset = (label: string): void => {
+      fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
+      fireEvent.click(screen.getByRole('menuitem', { name: label }))
+    }
+
+    pickPreset('ptc')
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('3')
+    fireEvent.click(presetToggle())
+    fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash, Enabled' }))
+    // An unnamed preset labels its source by id.
+    expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc')
+
+    pickPreset('坏预设 (failed to load)')
+    expect(screen.getByRole('alert').textContent).toBe('the composition file is missing')
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
+  })
+
+  it('keeps the preset group folded until opened or searched', async () => {
+    const view = await renderReady()
+    const toggle = presetToggle()
+
+    // The header keeps its count while the rows are folded away.
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('6')
+    expect(view.container.querySelectorAll('[data-plugin-scope="preset"] li')).toHaveLength(0)
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+
+    fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: 'pwsh' } })
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getByText(en.conditionalTag)).toBeTruthy()
+
+    fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { target: { value: '' } })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+  })
+
+  it('routes every preset name through the display resolver', async () => {
+    // The resolver stands in for presetDisplayText: shipped presets localize,
+    // user-authored ones keep their own metadata.
+    const localized: PluginInventorySettingsTabInjected['presetName'] = preset =>
+      preset.trust === 'system' ? `Localized ${preset.id}` : preset.name ?? preset.id
+    render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, localized)} />)
+    await screen.findByRole('searchbox', { name: en.search })
+
+    const switcher = screen.getByRole('button', { name: en.switcherLabel })
+    expect(switcher.textContent).toBe('Localized standard (default)')
+    fireEvent.click(switcher)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
+      'Localized standard (default)',
+      'Localized ptc',
+      '坏预设 (failed to load)',
+    ])
+    fireEvent.keyDown(document, { key: 'Escape' })
+
+    fireEvent.click(presetToggle())
+    fireEvent.click(screen.getByRole('button', { name: 'pwsh, pwsh, Conditional' }))
+    expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('Localized standard')
+
+    fireEvent.click(globalToggle())
+    fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
+    expect(screen.getByText('Localized standard · Localized ptc')).toBeTruthy()
+  })
+
+  it('jumps from a preset-provided row to the preset that enables it', async () => {
+    await renderReady()
+    fireEvent.click(screen.getByRole('button', { name: en.switcherLabel }))
+    fireEvent.click(screen.getByRole('menuitem', { name: 'ptc' }))
+
+    fireEvent.click(globalToggle())
+    fireEvent.click(screen.getByRole('button', { name: 'tool-bash, bash-host, Enabled via presets' }))
+    fireEvent.click(screen.getByRole('button', { name: en.viewInPreset }))
+    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent)
+      .toBe('标准模式 (default)')
+  })
+
+  it('searches across scopes and points at matches in other presets', async () => {
+    const view = await renderReady()
+    const search = screen.getByRole('searchbox', { name: en.search })
+
+    fireEvent.change(search, { target: { value: 'tool-bash' } })
+    // Searching forces the collapsed global plane and drawer open.
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('1')
+    expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1')
+    expect(screen.getByText(en.presetEnabledTag)).toBeTruthy()
+    expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull()
+    const hint = screen.getByText((text: string) => text.startsWith('2 more matches'))
+    expect(hint).toBeTruthy()
+    fireEvent.click(screen.getByRole('button', { name: 'ptc' }))
+    expect(screen.getByRole('button', { name: en.switcherLabel }).textContent).toBe('ptc')
+
+    // A match visible only in another preset keeps the pointer without rows.
+    fireEvent.change(search, { target: { value: 'crashy' } })
+    expect(view.container.querySelector('[data-preset-plugin-count]')?.getAttribute('data-preset-plugin-count')).toBe('0')
+    expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy()
+    expect(screen.queryByText(en.emptySearch)).toBeNull()
+
+    // A match on a Loader entry id only reaches the global plane.
+    fireEvent.change(search, { target: { value: '8a1b2c3d' } })
+    expect(view.container.querySelector('[data-plugin-count]')?.getAttribute('data-plugin-count')).toBe('1')
+    expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull()
+
+    fireEvent.change(search, { target: { value: 'not-a-plugin' } })
+    expect(screen.getByText(en.emptySearch)).toBeTruthy()
+    expect(screen.queryAllByRole('listitem')).toHaveLength(0)
+  })
+
+  it('renders a rosterless deployment as one global list, folded until opened', async () => {
+    const view = await renderReady({
+      entries: [
+        { entryId: 'hmr', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' },
+        { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null },
+      ],
+    } as unknown as Snapshot)
+
+    expect(screen.queryByRole('button', { name: en.switcherLabel })).toBeNull()
+    expect(globalToggle().getAttribute('aria-expanded')).toBe('false')
+    expect(screen.queryAllByRole('listitem')).toHaveLength(0)
+    fireEvent.click(globalToggle())
+    expect(globalToggle().getAttribute('aria-expanded')).toBe('true')
+    expect(screen.getAllByRole('listitem')).toHaveLength(2)
+
+    fireEvent.click(screen.getByRole('button', { name: 'hmr, hmr, Enabled' }))
+    expect(screen.getByText(en.runtime)).toBeTruthy()
+    expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('hmr')
+    fireEvent.click(screen.getByRole('button', { name: 'off, off, Disabled' }))
+    expect(screen.getAllByText(en.moduleLabel).length).toBeGreaterThan(0)
+    expect(screen.queryByText(en.runtime)).toBeNull()
+  })
+
+  it('renders a preset-only snapshot without the global section', async () => {
+    await renderReady({
+      entries: [],
+      agentPresets: [{
+        id: 'solo',
+        trust: 'user',
+        isDefault: false,
+        rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }],
+      }],
+    })
+
+    expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull()
+    expect(screen.queryByText(en.empty)).toBeNull()
+    expect(screen.queryAllByRole('listitem')).toHaveLength(0)
+    fireEvent.click(presetToggle())
+    expect(screen.getAllByRole('listitem')).toHaveLength(1)
+  })
+
+  it('shows a generic failure and retries into the empty state', async () => {
+    const list = vi.fn<PluginInventorySettingsTabInjected['list']>()
+      .mockRejectedValueOnce(new Error('private transport detail'))
+      .mockResolvedValueOnce({ entries: [] })
+    render(<PluginInventorySettingsTab {...props(list)} />)
+
+    expect((await screen.findByRole('alert')).textContent).toBe(en.error)
+    expect(screen.queryByText('private transport detail')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: en.retry }))
+    await waitFor(() => { expect(list).toHaveBeenCalledTimes(2) })
+    expect(await screen.findByText(en.empty)).toBeTruthy()
+  })
+
+  it('contains a synchronous Remote failure and ignores a result after unmount', async () => {
+    const syncFailure = vi.fn(() => { throw new Error('namespace unavailable') }) as PluginInventorySettingsTabInjected['list']
+    const failed = render(<PluginInventorySettingsTab {...props(syncFailure)} />)
+    expect((await screen.findByRole('alert')).textContent).toBe(en.error)
+    failed.unmount()
+
+    const deferred = Promise.withResolvers<Snapshot>()
+    const pending = render(<PluginInventorySettingsTab {...props(() => deferred.promise)} />)
+    expect(screen.getByText(en.loading)).toBeTruthy()
+    pending.unmount()
+    await act(async () => { deferred.resolve(SNAPSHOT) })
+
+    const deferredFailure = Promise.withResolvers<Snapshot>()
+    const pendingFailure = render(<PluginInventorySettingsTab {...props(() => deferredFailure.promise)} />)
+    pendingFailure.unmount()
+    await act(async () => { deferredFailure.reject(new Error('late failure')) })
+  })
+})

+ 39 - 0
packages/client/ui-settings-plugin-inventory/tsconfig.json

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

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

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-settings-plugin-inventory', ['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: 99f0cf19d25bb76b34d95b31f8f8fcbf0c596f79
-README.zh.md: 6a32d0e5370fb9b8030013af22abb44449d97784
+README.md: b4b587e427aa23f2e8ce02706a4b87249955300d
+README.zh.md: b8e7352c15bafbead42fc4b585b57de05fc3d0fa

+ 1 - 0
packages/client/ui-settings-plugins/README.md

@@ -69,6 +69,7 @@ Saving writes staged fields through the client settings scope, which fences each
 These pages cover the settings base, the inventory tab, and the durable seams behind the cards.
 
 - [ui-settings](../ui-settings/README.md) — the domain base declaring `settings.plugins.tab` and the settings scope.
+- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.md) — the read-only Plugin list tab in the same section.
 - [ui-plugin-manager](../ui-plugin-manager/README.md) — the sidebar page for installed packages and global rows.
 - [settings](../../settings/README.md) — the durable user-settings seam and its file provider.
 - [credentials](../../credentials/README.md) — the credential-reference seam secret fields write through.

+ 1 - 0
packages/client/ui-settings-plugins/README.zh.md

@@ -69,6 +69,7 @@ subagent 卡会同时暂存其权限开关与精确模型复选框。启用时
 以下页面覆盖设置底座、清单标签页与卡片背后的持久化 seam。
 
 - [ui-settings](../ui-settings/README.zh.md)——声明 `settings.plugins.tab` 与 settings scope 的领域底座。
+- [ui-settings-plugin-inventory](../ui-settings-plugin-inventory/README.zh.md)——同一分区中的只读「插件列表」标签页。
 - [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——管理已安装包与全局行的侧栏页面。
 - [settings](../../settings/README.zh.md)——持久化用户设置 seam 及其文件提供方。
 - [credentials](../../credentials/README.zh.md)——secret 字段写入所经的凭据引用 seam。

+ 1 - 0
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1942,6 +1942,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     declaredBy: 'an entry in \'settings.section\' (client-ui-settings-plugins), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-settings-plugin-inventory PluginInventorySettingsTab id \'all\'',
       'client-ui-settings-plugins ConfigurablePluginsTab id \'configurable\'',
     ],
     replaceRisk: 'none',

+ 2 - 2
packages/host/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/host/plugin-inventory/README.md
-README.md: fda8b8ae0f61b3a107264d633e60d637c41c41c6
-README.zh.md: b7f717ee863efa2765237224bb0cadc9fbf9e61f
+README.md: e8272b020cfcfd48343e1c2df2a7c44760b845ce
+README.zh.md: 052b26ac2cc51f38921c5296ed52333086d355ad

+ 1 - 1
packages/host/plugin-inventory/README.md

@@ -78,7 +78,7 @@ Read these when the inventory contract is not enough: how the Remote reaches cli
 
 - [Remote assembly](../../api/remotes/README.md) — how clients consume `pluginInventory/list` without importing the Host implementation.
 - [Cordis plugin loader](../../../vendor/loader/README.md) — the Loader whose entries this package projects.
-- [Plugin manager](../../client/ui-plugin-manager/README.md) — the browser-side projection that renders the inventory.
+- [Plugin inventory settings surface](../../client/ui-settings-plugin-inventory/README.md) — the browser-side projection that renders the inventory.
 
 -----
 

+ 1 - 1
packages/host/plugin-inventory/README.zh.md

@@ -78,7 +78,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产
 
 - [Remote 组合](../../api/remotes/README.zh.md)——客户端如何在不导入 Host 实现的情况下消费 `pluginInventory/list`。
 - [Cordis 插件 loader](../../../vendor/loader/README.md)——本包所投影条目的那个 Loader。
-- [插件管理页面](../../client/ui-plugin-manager/README.zh.md)——渲染该清单的浏览器侧投影。
+- [插件清单设置界面](../../client/ui-settings-plugin-inventory/README.zh.md)——渲染该清单的浏览器侧投影。
 
 -----
 

+ 48 - 0
pnpm-lock.yaml

@@ -1950,6 +1950,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-settings-models':
         specifier: workspace:^
         version: link:../../client/ui-settings-models
+      '@deepseek-ai/dsh-client-ui-settings-plugin-inventory':
+        specifier: workspace:^
+        version: link:../../client/ui-settings-plugin-inventory
       '@deepseek-ai/dsh-client-ui-settings-plugins':
         specifier: workspace:^
         version: link:../../client/ui-settings-plugins
@@ -3802,6 +3805,51 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1
 
+  packages/client/ui-settings-plugin-inventory:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent-presets':
+        specifier: workspace:^
+        version: link:../../preset/agent-presets
+      '@deepseek-ai/dsh-api-remotes':
+        specifier: workspace:^
+        version: link:../../api/remotes
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
+      '@deepseek-ai/dsh-client-test-runtime':
+        specifier: workspace:^
+        version: link:../../test-support/client-runtime
+      '@deepseek-ai/dsh-client-ui-agent-preset':
+        specifier: workspace:^
+        version: link:../ui-agent-preset
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-renderer':
+        specifier: workspace:^
+        version: link:../ui-renderer
+      '@deepseek-ai/dsh-client-ui-settings':
+        specifier: workspace:^
+        version: link:../ui-settings
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@testing-library/react':
+        specifier: ^16.1.0
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
+
   packages/client/ui-settings-plugins:
     devDependencies:
       '@deepseek-ai/cordis':

+ 1 - 0
scripts/rescope-vendor.ts

@@ -129,6 +129,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
   { file: 'packages/experimental/inspector/tests/cordis-tree.host.spec.ts', upstream: ['cordis'] },
   { file: 'packages/experimental/inspector/tests/plugin.client.spec.ts', upstream: ['cordis'] },
   { file: 'scripts/gen-cordis-catalog.ts', upstream: ['cordis'] },
+  { file: 'packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx', upstream: ['cordis'] },
   // The UI locale namespace and input-trigger source id are product keys.
   { file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] },
   { file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] },

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -123,6 +123,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-plugin-manager': { kind: 'none', reason: 'Browser-side management surface; registers nothing model-facing.' },
+  'packages/client/ui-settings-plugin-inventory': { kind: 'none', reason: 'Browser-side inventory projection; registers nothing model-facing.' },
   'packages/client/ui-settings-unarchive-sessions': { kind: 'none', reason: 'Browser-side settings page; registers nothing model-facing.' },
   'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },

+ 1 - 0
tsconfig.base.json

@@ -235,6 +235,7 @@
       "@deepseek-ai/dsh-client-ui-settings-general": ["./packages/client/ui-settings-general/src"],
       "@deepseek-ai/dsh-client-ui-settings-models": ["./packages/client/ui-settings-models/src"],
       "@deepseek-ai/dsh-client-ui-plugin-manager": ["./packages/client/ui-plugin-manager/src"],
+      "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"],
       "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": ["./packages/client/ui-settings-unarchive-sessions/src"],
       "@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
       "@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],

+ 1 - 0
tsconfig.client.json

@@ -110,6 +110,7 @@
     { "path": "./packages/client/ui-settings-general" },
     { "path": "./packages/client/ui-settings-models" },
     { "path": "./packages/client/ui-plugin-manager" },
+    { "path": "./packages/client/ui-settings-plugin-inventory" },
     { "path": "./packages/client/ui-settings-unarchive-sessions" },
     { "path": "./packages/client/locale" },
     { "path": "./packages/client/ui-renderer" },