diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
index 07c6da31be..030a4eadb9 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
+++ b/.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: d2a3329e7c878f25ad5e50d98fc2e3ab39722887
-2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62
+2026-09-09-plugin-management-in-the-web-sidebar.md: ba359aeefe3883445fd5f3e3dc164262e0613219
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: 91664deeb422c1dc6c34f826a85f398183128d1c
diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
index d2a3329e7c..ba359aeefe 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
@@ -10,9 +10,9 @@ 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 the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own.
+**Management is a sidebar entry.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. It lists installed bundles and installation-provided optional bundles. Settings keeps the read-only plugin inventory, including the installation's own bundles (`dsh-base`, `dsh-web-app`); both inventory groups start collapsed, and the inventory carries no management controls. Configuration placement follows the [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md).
-**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
+**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration forms use the existing global settings bindings.
**Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window.
@@ -20,11 +20,11 @@ Installed packages belong to the running profile, while Settings is a modal over
**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.
-**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section.
+**Configuration placement.** The [plugin configuration decision](2026-09-16-plugin-configuration-on-the-plugins-page.md) owns this choice and its alternatives; sidebar navigation and installation-request ownership remain independent of it.
## 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 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.
+The web bundle's **Plugins** sidebar entry opens profile management. Settings exposes the read-only inventory, while plugin forms live on the Plugins page. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar; `plugin-config` and `settings-chrome` exercise the forms and read-only inventory respectively.
## Testing
diff --git a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
index aae035b3a9..91664deeb4 100644
--- a/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
@@ -1,4 +1,4 @@
-# Agent Note:插件管理移到 Web 侧栏
+# Agent Note: 插件管理移到 Web 侧栏
Status: implemented
@@ -10,9 +10,9 @@ Status: implemented
## 决定
-**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。
+**管理位于侧栏。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。它列出已安装的组合包与安装随附的可选组合包。设置保留只读插件清单,包括随安装提供的组合包(`dsh-base`、`dsh-web-app`);清单的两个分组默认收起,且不带管理控件。配置的位置遵循[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)。
-**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
+**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置表单使用原有全局 settings 绑定。
**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
@@ -20,11 +20,11 @@ Status: implemented
**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。
-**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。
+**配置的位置。** 该选择及其替代方案由[插件配置页决策](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md)维护;侧栏导航与安装请求的归属独立于它。
## 后果
-web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
+web bundle 的**插件**侧栏入口打开 profile 管理。设置提供只读清单,插件表单位于插件页。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器;`plugin-config` 与 `settings-chrome` 分别验证表单和只读清单。
## 测试
diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
index 8c5140bfb7..de121c7c16 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.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-10-public-package-manifest.md
-2026-09-10-public-package-manifest.md: 0e6c1650a6b04e98aeba1aa6ef45df78e3f2cb44
-2026-09-10-public-package-manifest.zh.md: 9b7d15f479ca1aab7bea655b2dee43625728a442
+2026-09-10-public-package-manifest.md: 2d35a3ad17c5aa62df4a5eccde351f325936a84f
+2026-09-10-public-package-manifest.zh.md: 7dc03e5160b3837ea07e19469b1d8a0a4f64ebe6
diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
index 0e6c1650a6..2d35a3ad17 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md
@@ -14,7 +14,7 @@ Plugin authors need npm identity, runtime requirements, and DSH declarations fro
Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`. Format and DSH compatibility declarations are not enforced by current installers or loaders.
-The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
+The image packer owns `configTrees`, and the workspace catalog generator owns Session migration declarations. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active.
Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy.
diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
index 9b7d15f479..7dc03e5160 100644
--- a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md
@@ -14,7 +14,7 @@ Status: implemented
运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。
-镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
+镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。
各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
index d1c3e82cbb..8c89aed087 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.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-16-plugin-configuration-on-the-plugins-page.md
-2026-09-16-plugin-configuration-on-the-plugins-page.md: fb330bee44373d20c7aeea8cee521bf8def025d4
-2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 65c1f5a8bf4654b395ab4bfcf8015ae7c23a6acc
+2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028
+2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
index fb330bee44..475580af48 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
@@ -10,21 +10,23 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta
## Decision
-**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens a page headed by the row id. Every entry is rendered in two views the page passes as owner props: `summary` for the one-liner under the title, `page` for the form. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin.
+**The Plugins page hosts configuration; Settings keeps the inventory.** The page declares three slots as children of its `main` entry. `plugins.item` (list) lists an official plugin in the Official group by its `label`. `plugins.bundle.config` (keyed by the bundle's package name) renders on the bundle's page between its description and its rows. `plugins.row.config` (keyed by `#`) gives that row a configure control that opens its own page. The [plugin metadata decision](2026-09-18-localized-package-metadata.md) owns that page's title and description; its slot key and technical identities do not change with the displayed text.
+
+The page passes `view: 'page'` for each form. Official plugin cards and details also use `view: 'summary'`; a row's detail page uses it only when the plugin has no display description. Bundle configuration renders only `page`. The page draws the title, icon, and crumb, projects the three ledgers into one observable (`configLedgerSource`) beside its store, and never names a configurable plugin. Sidebar navigation and installation-request ownership follow the [sidebar management decision](2026-09-09-plugin-management-in-the-web-sidebar.md).
**Attribution is declared by the registrant, in the slot name and key.** No manifest field, no registration metadata, and no owner information from the settings service: an official page registers without a bundle, a bundle's page with its package name, a row's page with the bundle and the row id its patch declares. A community bundle that ships no browser half has no configuration page; the page renders no generic form from a settings schema.
-**Only a save writes.** The form's discard control and its unsaved marker are gone: leaving a page drops the staged edits, which the form does on unmount. A save still writes through the client settings scope with the revision fence.
+**Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence.
**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired.
-**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review leaves `OPTIONAL_BUNDLES` and the CLI's dependencies: it is a published experimental package the install guide names as its example.
+**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency.
## Consequences
- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own.
-- The four pages, their forms, and the settings write path are unchanged; `ui-settings-plugins` keeps its name for the section it still owns while its pages live on the Plugins page.
-- Settings lists the inventory only; the settings goldens that carried the Plugins nav entry and the configuration tab were re-recorded.
+- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page.
+- Settings lists the inventory only; the Settings browser goldens cover that read-only section.
## Alternatives considered
@@ -32,8 +34,8 @@ A plugin's settings lived in Settings, on the Plugins section's configuration ta
**A generic form from the settings schema.** `describe()` already ships each namespace's schemastery schema, so a community namespace without a browser half could get a default form. Deferred: the official pages are curated, a generic form would expose internal namespaces without an allowlist, and attributing a namespace to a bundle needs an owner in the descriptor.
-**Keeping the cards in Settings and linking to them from the page.** Cheapest, but the review asked for the form on the plugin's own page.
+**Keeping the cards in Settings and linking to them from the page.** This separates a plugin's description and configuration across different pages.
## Testing
-`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the new Official group.
+`ui-plugin-manager` unit tests cover the ledger projection, the Official group's cards and pages, a bundle's form, and a row's page, including description precedence over its summary; `ui-settings-plugins` tests cover registration per served namespace and its withdrawal, and the form's save-only behavior. The `plugin-config` web lane edits the shell page through the real wire and opens a community fixture's row page after switching its bundle on; the `plugin-manager` lane records the Official group.
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
index 65c1f5a8bf..22d0aa4ff1 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
@@ -1,6 +1,6 @@
-# Agent Note:插件页上的插件配置
+# Agent Note: 插件页上的插件配置
-状态:已实现
+Status: implemented
[English](2026-09-16-plugin-configuration-on-the-plugins-page.md) | 中文
@@ -10,21 +10,23 @@
## 决策
-**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开以行 id 为标题的页面。每个条目都按页面以 owner props 传入的两种视图渲染:`summary` 是标题下的一句话简介,`page` 是表单。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。
+**插件页承载配置;设置只保留清单。** 页面在其 `main` 条目下声明三个子 slot。`plugins.item`(list)按 `label` 把官方插件列在官方分组里。`plugins.bundle.config`(以组合包的包名为键)渲染在组合包页面的描述与行之间。`plugins.row.config`(以 `<包名>#<行 id>` 为键)给这一行一个配置控件,打开它自己的页面。该页面的标题与描述遵循[插件元信息决策](2026-09-18-localized-package-metadata.zh.md);slot 键和技术身份不随展示文本改变。
+
+页面通过 `view: 'page'` 渲染每个表单。官方插件卡片与详情还使用 `view: 'summary'`;行详情页只在插件没有展示描述时使用它。组合包配置仅渲染 `page`。页面负责画标题、图标与面包屑,把三份账本投影成一个可观察对象(`configLedgerSource`)绑在 store 旁边,自身从不点名任何可配置插件。侧栏导航与安装请求归属遵循[侧栏管理决策](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)。
**归属由注册方在 slot 名与键里声明。** 不加 manifest 字段,不加注册元数据,也不从 settings 服务取 owner 信息:官方页面不带组合包注册,组合包的页面带自己的包名注册,行的页面带组合包与其 patch 声明的行 id 注册。没有浏览器半侧的社区组合包就没有配置页;页面不会从 settings schema 渲染通用表单。
-**只有保存才写入。** 表单的放弃控件与未保存标记都去掉了:离开页面即丢弃暂存修改,表单在卸载时执行。保存仍经由客户端 settings scope,带 revision 栅栏。
+**只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。
**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。
-**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,不再有官方标签;配置页排在其后。Auto review 离开 `OPTIONAL_BUNDLES` 与 CLI 的依赖:它是一个已发布的实验包,安装引导拿它作示例。
+**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。
## 后果
- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
-- 四个页面、它们的表单与 settings 写入路径不变;`ui-settings-plugins` 保留其名字,因为它仍拥有那个分区,尽管它的页面住在插件页上。
-- 设置只列出清单;带有插件导航项与配置标签页的设置类 golden 已重录。
+- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。
+- 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。
## 考虑过的替代方案
@@ -32,8 +34,8 @@
**从 settings schema 生成通用表单。** `describe()` 已经把每个命名空间的 schemastery schema 发给浏览器,没有浏览器半侧的社区命名空间本可获得一份默认表单。延期:官方页面是精选的,通用表单会在没有白名单的情况下暴露内部命名空间,而把命名空间归属到组合包还需要 descriptor 带 owner。
-**把卡片留在设置里、从插件页链接过去。** 最省事,但 review 要求表单在插件自己的页面上。
+**把卡片留在设置里、从插件页链接过去。** 这会把一个插件的描述和配置分散到不同页面。
## 测试
-`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录新的官方分组。
+`ui-plugin-manager` 单测覆盖账本投影、官方分组的卡片与页面、组合包的表单以及行的页面,包括描述优先于其摘要的规则;`ui-settings-plugins` 单测覆盖按被服务的命名空间注册与撤下,以及表单只保存不放弃的行为。`plugin-config` web lane 通过真实链路编辑 shell 页面,并在开启社区夹具的组合包后打开其行的页面;`plugin-manager` lane 记录官方分组。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml
new file mode 100644
index 0000000000..89a7fa2cc4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md
+2026-09-18-localized-package-metadata.md: a7e41cccbbce63547e8e5f175ec2350bcf515bc2
+2026-09-18-localized-package-metadata.zh.md: d70aed2a57db4c474245806d4bd3db0129f4beb4
diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md
new file mode 100644
index 0000000000..a7e41cccbb
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md
@@ -0,0 +1,87 @@
+# Agent Note: Plugin-owned localized display metadata
+
+Status: implemented
+
+English | [中文](2026-09-18-localized-package-metadata.zh.md)
+
+## Problem
+
+Plugin Manager needs readable titles and descriptions for installed bundles and individual plugins, including disabled plugins. One npm package can export several plugins with different purposes; a package-level introduction cannot describe each one. Registering introductions only during activation also leaves disabled or failed plugins without display text.
+
+## Decision
+
+Each plugin supplies optional `meta.title` and `meta.description` strings in its `locale/.json` resources, starting with `locale/en.json`. These strings directly provide its display title and description. Other top-level locale content belongs to its existing consumers and is not included in metadata responses.
+
+```json
+{
+ "meta": {
+ "title": "File Search",
+ "description": "Search files in your workspace."
+ }
+}
+```
+
+The configured Cordis plugin name owns the resource address. Node module resolution selects files through the applicable profile and package exports without evaluating plugin code:
+
+| Plugin form | Locale resource |
+|---|---|
+| npm package `my-plugins` | `my-plugins/locale/en.json`, exposed by `./locale/*.json` |
+| npm subpath `my-plugins/search` | `my-plugins/search/locale/en.json`, exposed by `./search/locale/*.json` |
+| JavaScript path `./plugins/search.js` | No sibling-resource lookup; the configured path is the final title fallback |
+
+Locale addresses are not derived from a resolved JavaScript filename or an owning package directory. File paths and file URLs, including Windows drive and UNC paths, skip resource resolution and return no metadata; a file address alone is not a metadata error. They supply neither sibling locale metadata nor a neighboring package manifest. The [package cookbook](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) owns directory examples, export declarations, publication entries, and author verification.
+
+Language files for one plugin share a directory. The Host resolves `en.json`, discovers language filenames in that directory, and resolves each resource using the same plugin specifier and parent URL. The English resource anchors discovery, but any language may omit either display field. Language identifiers are case-insensitive and duplicates are rejected. Present locale display fields must be non-empty strings.
+
+The Host reads installed bundles and their declared plugin rows without importing or activating them. Remotes carry multilingual values or literal fallback text for the two display fields. The Client reuses its existing language selection and fallback chain at render time, without adding language registration, plugin-specific language defaults, or another language fallback policy.
+
+Each field falls back independently before view-specific name formatting:
+
+| Field | Existing locale fallback chain | Same-address package fallback | Final fallback |
+|---|---|---|---|
+| Title | `meta.title` | Non-empty `name` from `/package.json` | Complete configured Cordis plugin name |
+| Description | `meta.description` | Non-empty `description` from `/package.json` | No package description |
+
+The package fallback obeys resource exports and belongs to the same plugin address; a subpath does not inherit its owning package's introduction. The Host supplies the package or final value as the English fallback for a field that has translations but no English value, preserving the existing Client locale API. Missing or unexported resources and missing fields use these fallbacks; invalid locale fields or malformed files report a diagnostic instead of silently falling back, while retaining management operations.
+
+Plugin Manager uses this metadata on installed bundle cards and details, component lists, and component configuration details, preserving complete technical-name fallbacks. Settings uses it in the plugin inventory, including preset plugins, but shortens literal package-name and module-name fallbacks by removing their npm scope and Cordis/DSH prefixes. Translated titles remain verbatim in both views; full module names, entry ids, search identities, and operation targets stay unchanged. The [Settings inventory README](../../../../packages/client/ui-settings-plugin-inventory/README.md#use-this-package) owns the prefix rules.
+
+A row configuration page uses its registered `summary` view only when the plugin has no display description. The bundle's raw `description` field remains available to Host and model consumers; it is not an additional UI fallback around resource exports. These rules preserve one displayed description while keeping configuration summaries available for plugins without one.
+
+Disabled plugins remain readable without activation. The Install view still uses registry information from `pnpm view`; it does not replace that information with locale metadata. Registry, Git, and tarball inspection before installation does not read remote package contents for translation. Model tool results retain their existing package information and exclude multilingual UI dictionaries. Session events remain unchanged.
+
+Built-in bundle introductions live in their exported language files. Beta markers, page grouping, configuration slot ownership, and enablement targets remain unchanged. Plugin introductions and component interaction copy retain separate owners.
+
+## Existing decisions
+
+[Public package declarations](../../implemented/architecture/2026-09-10-public-package-manifest.md) still govern manifest type and reader responsibilities; [locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md) still governs component wording and display identity; [plugin configuration pages](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md) still determine configuration slot ownership. Plugin metadata extends display data without replacing those decisions.
+
+## Alternatives considered
+
+**Keep built-in package dictionaries in Plugin Manager.** Every package addition requires changing the manager, and external plugins cannot independently supply translations.
+
+**Declare references in package.json.** This duplicates the declaration between a manifest and locale files, and one package-level declaration cannot distinguish its exported plugins.
+
+**Read locale beside the resolved JavaScript file.** Build tools can put several plugin entries in the same directory. Resource exports preserve each plugin's identity independently of its compiled layout.
+
+**Register copy when the displayed plugin activates.** Disabled packages, packages without a Client half, and packages that fail to load still need readable introductions.
+
+**Add base.json or package-specific fallback.** This introduces a second policy alongside the existing English fallback and extension-language fallback chains.
+
+**Download remote packages for pre-installation previews.** This expands lookup cost and the package contents that must be handled; this phase only reads files already present locally.
+
+## Verification
+
+- Bundles, child plugins, and read-only inventory display local metadata; disabled packages need no activation for reading.
+- Language switching updates titles and descriptions; Settings shortens only literal technical-name fallbacks, not translated titles, and keeps module identities and operation targets intact.
+- Direct metadata, unrelated locale content, absent resources, malformed JSON, missing display fields, and conflicting language identifiers have focused coverage.
+- Separate exports in one package and same-named packages under different parents retain independent metadata without executing plugin entries.
+- Windows drive paths, UNC paths, relative and absolute file paths, and file URLs return no metadata without invoking resource resolution.
+- Missing locale resources and individual fields fall back independently to address-local package fields, then the complete module specifier without a description.
+- Publication checks require readable resource exports and included language files, without duplicate ownership of built-in copy in the manager and plugins.
+- Management tools preserve existing model output, and pre-installation inspection adds no package-content lookup.
+- Focused tests and the complete `pnpm run build` succeed.
+
+## Consequences
+
+Omitted language files or exports can make metadata unavailable after publication, so verification covers both resolution and packaged files. Metadata errors remain per-plugin diagnostics without removing the manager's repair controls; file diagnostics retain their absolute paths. Display metadata is read per request without a metadata cache. Lookup retains the complete module specifier and its resolution parent; package-name-only caching cannot distinguish exported plugins or different installations.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md
new file mode 100644
index 0000000000..d70aed2a57
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md
@@ -0,0 +1,87 @@
+# Agent Note: 插件自有的多语言展示元信息
+
+Status: implemented
+
+[English](2026-09-18-localized-package-metadata.md) | 中文
+
+## 问题
+
+Plugin Manager 需要为已安装的 bundle 与各个插件展示可读的标题和描述,包括尚未启用的插件。同一个 npm 包可以导出多个用途不同的插件,包级介绍无法分别描述它们。依赖激活来注册介绍,也会让禁用或加载失败的插件缺少展示文案。
+
+## 决策
+
+每个插件从 `locale/en.json` 开始,在自己的 `locale/.json` 资源中提供可选的 `meta.title` 与 `meta.description` 字符串。这些字符串直接提供展示标题和描述。locale 的其他顶级内容仍归现有消费方所有,不进入元信息响应。
+
+```json
+{
+ "meta": {
+ "title": "File Search",
+ "description": "Search files in your workspace."
+ }
+}
+```
+
+资源地址归 Cordis 配置中的插件名所有。Node 模块解析通过适用的 profile 和包 exports 选择文件,不执行插件代码:
+
+| 插件形式 | locale 资源 |
+|---|---|
+| npm 包 `my-plugins` | 请求 `my-plugins/locale/en.json`,由 `./locale/*.json` 导出 |
+| npm 子路径 `my-plugins/search` | 请求 `my-plugins/search/locale/en.json`,由 `./search/locale/*.json` 导出 |
+| JavaScript 路径 `./plugins/search.js` | 不查找同级资源;配置中的路径名作为标题的最终回退 |
+
+locale 地址不从解析后的 JavaScript 文件名或所属包目录推导。文件路径与文件 URL(包括 Windows 盘符路径和 UNC 路径)跳过资源解析,返回无元信息;文件地址本身不构成元信息错误。它们既不提供同级 locale 元信息,也不提供旁边的包 manifest(元数据清单)。目录示例、exports 声明、发布条目与作者验证步骤由[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)维护。
+
+同一插件的语言文件放在同一目录。Host 解析 `en.json`,从所在目录发现语言文件名,再用同一插件模块名与父 URL 解析各个资源。英文资源作为发现入口,但任何语言都可以省略任一展示字段。语言标识不区分大小写,重复标识拒绝。locale 中声明的展示字段必须是非空字符串。
+
+Host 读取已安装 bundle 及其声明的插件行,不 import 或激活它们。Remote 携带两个展示字段的多语言值或纯文本回退值。Client 在渲染时复用现有 locale 的语言选择与回退链,不增加语言注册、插件级默认语言或另一套语言回退规则。
+
+每个字段先独立回退,再按页面规则格式化技术名称:
+
+| 字段 | 现有 locale 语言回退链 | 同地址包字段回退 | 最终回退 |
+|---|---|---|---|
+| 标题 | `meta.title` | `<插件模块名>/package.json` 中非空的 `name` | Cordis 配置中的完整插件名 |
+| 描述 | `meta.description` | `<插件模块名>/package.json` 中非空的 `description` | 不提供包描述 |
+
+包字段回退遵守资源 exports,并且仍属于同一插件地址,子路径不继承所属包的介绍。字段有其他语言译文但没有英文值时,Host 将包字段或最终值放入英文回退位,保持现有 Client locale API 不变。资源不存在、未导出或缺少字段时使用上述回退;locale 字段无效或文件格式损坏时明确诊断,不静默回退,同时保留管理操作。
+
+插件管理页在已安装 bundle 的卡片和详情、组件列表、组件配置详情中使用这些元信息,保留完整技术名回退。设置中的插件清单也使用它,包括预设内插件,但会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀。两个页面都原样展示翻译标题;完整模块名、条目 id、搜索身份与操作目标保持不变。具体前缀规则由[设置插件清单 README](../../../../packages/client/ui-settings-plugin-inventory/README.zh.md#use-this-package)维护。
+
+行配置页仅在插件没有展示描述时使用注册组件的 `summary` 视图。组合包原始的 `description` 字段仍供 Host 与模型消费方使用,不作为绕过资源 exports 的额外 UI 回退。这些规则只展示一份描述,同时保留无描述插件的配置摘要。
+
+禁用插件不需要激活即可读取。Install 界面仍使用 `pnpm view` 返回的 registry 信息,不用 locale 元信息替换。安装前的 registry、Git 和 tarball 查询不为翻译读取远程包内容。模型工具结果保留现有包信息,不携带供 UI 使用的多语言字典。Session 事件不变。
+
+内置 bundle 的介绍放在其导出的语言文件中。beta 标记、页面分组、配置槽归属与启停目标保持原样。插件介绍和组件自身的交互文案各自保持所有权。
+
+## 现有决策
+
+[公共包声明](../../implemented/architecture/2026-09-10-public-package-manifest.zh.md) 继续约束 manifest 类型和读取者的职责;[locale-owned UI copy](../../implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md) 继续约束组件文案和展示身份;[插件配置页](../../implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md) 继续决定配置槽归属。插件元信息只扩展展示数据,不取代这些决策。
+
+## 考虑过的替代方案
+
+**在 Plugin Manager 中继续维护内置包词典。** 每增加一个包都需要修改管理器,而且外部插件无法独立提供翻译。
+
+**在 package.json 中声明引用。** 将声明重复放在 manifest 与 locale 文件中,而且一份包级声明无法区分包内导出的插件。
+
+**从解析后的 JavaScript 文件旁读取 locale。** 构建工具可能把多个插件入口放在同一目录。资源 exports 让各插件的身份独立于编译产物布局。
+
+**由被展示插件激活时注册文案。** 未启用包、无 Client 半边的包和加载失败的包仍然需要可读的介绍。
+
+**增加 base.json 或包级 fallback。** 会与现有 locale 的英文兜底和扩展语言回退链产生第二套规则。
+
+**为安装前预览下载远程包。** 扩大查询成本及需要处理的包内容;本期只处理已经存在于本地的文件。
+
+## 验证
+
+- bundle、子插件与只读 inventory 展示本地元信息;禁用包不需要激活即可读取。
+- 切换语言更新标题和描述;设置页仅缩短字面技术名回退,不缩短翻译标题,也不改变模块身份和操作目标。
+- 直接元信息、无关 locale 内容、缺失资源、损坏 JSON、缺失展示字段及语言标识冲突均有针对性覆盖。
+- 同包的不同 exports,以及不同父目录中的同名包,各自保持独立元信息,读取不执行插件入口。
+- Windows 盘符路径、UNC 路径、相对与绝对文件路径及文件 URL 均不调用资源解析,直接返回无元信息。
+- locale 资源或个别字段缺失时,各字段独立回退到该插件地址的包字段,再回退到完整模块名且不显示描述。
+- 发布检查要求资源 exports 可解析且包含语言文件,内置文案不在管理器和插件中双份维护。
+- 管理工具保留现有模型输出,安装前查询不增加包内容探查。
+- 针对性测试以及完整 `pnpm run build` 通过。
+
+## 影响
+
+遗漏语言文件或 exports 会使发布后的元信息不可用,因此验证覆盖解析与打包后的文件。元信息错误作为单插件诊断呈现,不能让整个管理页失去修复入口;文件诊断保留绝对路径。展示元信息按请求读取,不设元信息缓存。查询保留完整模块名及其解析父地址;只按包名缓存无法区分导出插件或不同安装目录。
diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml
new file mode 100644
index 0000000000..d8c7d77519
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md
+2026-09-18-profile-plugin-host-runtime-instances.md: 76135318b3a935e4588c360df3210618a6c69216
+2026-09-18-profile-plugin-host-runtime-instances.zh.md: b2229574876b0ad326348ababd6b6fca6f3a1ac2
diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md
new file mode 100644
index 0000000000..76135318b3
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.md
@@ -0,0 +1,37 @@
+# Agent Note: Profile plugins share the installation's host runtime instances
+
+Status: implemented
+
+English | [中文](2026-09-18-profile-plugin-host-runtime-instances.zh.md)
+
+## Problem
+
+A plugin the profile installs out-of-tree resolves its bare specifiers along the ancestor `node_modules` chain, where the profile's own layer sits above the runtime resolution's interception layer ([lookup order](2026-09-19-profile-resolution-lookup-order.md)). It therefore receives its own copy of every package it reaches through a `dependencies` edge, while the Host that loads the plugin keeps the installation's copy. Most packages tolerate that. `@deepseek-ai/dsh-scope` does not: it tags a scoped context with a module-local `Symbol('dsh.scope')` and keeps scope parents and event-carrier keys in module-local tables, and every registry that reads a tag — tool registration, prompt sections, MCP resources, scoped events — compares it by identity. A profile copy's `createScope` writes a tag the installation's `scopeOf` cannot read, so a registration meant for one Agent lands in the process-global layer.
+
+Issue #4573 is that failure in production. While this package reached both runtimes through `dependencies`, a profile that mounted the Playwright MCP browser provider installed a second `dsh-scope`: the first Agent's MCP tools registered globally, the second Agent's tool synchronization collided, and `failOnStartupError: true` turned that collision into a rejected Agent creation. Matching the profile's package versions to the Host's did not change the outcome, because a duplicate exists at every version.
+
+## Decision
+
+An out-of-tree plugin package declares every host runtime whose cross-instance identity matters in matching `peerDependencies` and `devDependencies`, never in `dependencies`. Profile pnpm settings are `nodeLinker: hoisted` with `autoInstallPeers: false`, so such a peer is not installed into the profile, and the import reaches the installation's single instance through the runtime resolution's interception layer at `$DSH_HOME/profiles/node_modules` ([lookup order](2026-09-19-profile-resolution-lookup-order.md)).
+
+`@deepseek-ai/dsh-scope` carries that identity through `createScope` as well as through the readers `scopeOf`, `carrierKeyOf`, and `scopeTarget`: the writer mints the tag its readers compare. `@deepseek-ai/dsh-mcp-client` joins it because its live `serverName` reservations are module-local, so a second copy keeps a second reservation table for the same namespace. `@deepseek-ai/dsh-experimental-browser-use-runtime` therefore declares both as peers with matching dev entries and keeps `@deepseek-ai/schemastery` as its only runtime dependency, which carries no instance-local state.
+
+`tests/shared-host-runtimes.spec.ts` pins those sections for that package. `tests/host-runtime-duplication.spec.ts` reproduces what the rule prevents: one second copy of both packages puts each Agent's MCP tools in the global layer and rejects the second Agent's creation, while the shipped single-instance layout keeps the global layer empty and gives both Agents their own client.
+
+The [published dependency faces](../process/2026-08-26-published-dependency-faces.md) note owns `verify-package-dependencies`: its `peerRequiredHostExports` table classifies the `dsh-scope` readers, and its package selection covers Client-faced packages and an explicit Host list. That gate selects no package under `packages/experimental/`, and it does not classify `createScope`; this note owns the rule for the out-of-tree plugin packages the gate does not reach.
+
+## Alternatives considered
+
+**Mount the MCP client on `agent.ctx` instead of a scope this package mints.** It loses because the profile copy's `scopeOf` also cannot read the tag the installation wrote for `agent.ctx`, so the `serverName` reservation degrades to the process-global owner and the second Agent fails at that check — the error form seen on `0.1.6-alpha.1`. Removing the duplicate is the repair for both forms.
+
+**Make `dsh-scope`'s tag a `Symbol.for`, so two copies agree.** That fixes the tag lookup alone. Scope parents and event-carrier keys stay per copy, so the chain walk and carrier admission of one copy remain blind to links the other recorded — a smaller visible failure with a more confusing cause. It also keeps the duplicate that the profile never needed.
+
+**Match the profile's package versions to the Host's.** Equal versions still load two module instances from two directories, so the defect survives it.
+
+## Consequences
+
+- A released fix reaches a profile when the plugin dependency is upgraded and reinstalled. An already-installed profile keeps its copies until then, and no Host-side change can repair it.
+- The rule makes the plugin depend on the installation shipping the peer. Both packages are dependencies of the `@deepseek-ai/dsh` installation, so the runtime resolution supplies them; a plugin outside the installation's closure would fail to load rather than start with a second copy.
+- `docs/module-graph.md` renders these edges in its peer section, so the graph states the ownership boundary that the sections encode.
+- Three sibling experimental providers (`browser-use-stagehand-native`, `computer-use-cua-driver-mcp`, `computer-use-cua-driver-native`) declare `@deepseek-ai/dsh-mcp-client` as a dependency. They mount no per-Agent MCP client, so their own use does not reproduce this decision's failure. A profile that installs one of them beside this package hoists that dependency into the profile's own `node_modules`, above the interception layer, so this package's peer resolves to that copy: the two `dsh-mcp-client` instances keep separate `serverName` reservations, and a name reserved on one is not detected on the other. The scope split does not return, because none of them declares `@deepseek-ai/dsh-scope`. Issue #4628 tracks moving those three declarations to peers with their own verification.
+- `verify-package-dependencies` still selects no `packages/experimental/` package, so the sections of this package are pinned only by `tests/shared-host-runtimes.spec.ts`. Extending that gate's selection is the open coverage gap this note records.
diff --git a/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md
new file mode 100644
index 0000000000..b222957487
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: Profile 插件共享安装自带的宿主运行时实例
+
+Status: implemented
+
+[English](2026-09-18-profile-plugin-host-runtime-instances.md) | 中文
+
+## Problem
+
+profile 从 npm 装进来的 out-of-tree 插件,沿祖先 `node_modules` 链解析裸模块名,而 profile 自己那一层位于运行时解析(runtime resolution)的拦截层之上([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。于是它会为经 `dependencies` 边到达的每个包各留一份自己的副本,而加载它的 Host 用的是安装里那一份。多数包可以容忍这种重复;`@deepseek-ai/dsh-scope` 不能:它用一个模块级 `Symbol('dsh.scope')` 给作用域上下文打标记,并把作用域父链和事件载体键保存在模块级表里,而每个读标记的注册表——工具注册、提示词 section、MCP 资源、作用域事件——都按同一性比较它。profile 那份副本的 `createScope` 写下的标记,安装那份的 `scopeOf` 读不到,于是本该属于某个 Agent 的注册落进了进程全局层。
+
+Issue #4573 就是这个问题在生产里的形态。当本包经 `dependencies` 到达这两个运行时时,挂载 Playwright MCP 浏览器提供方的 profile 多装了一份 `dsh-scope`:第一个 Agent 的 MCP 工具注册到全局层,第二个 Agent 的工具同步撞名,`failOnStartupError: true` 把这次撞名放大成 Agent 创建失败。把 profile 的包版本对齐到 Host 并不能改变结果,因为无论哪个版本,副本都是第二份模块实例。
+
+## Decision
+
+Out-of-tree 插件包,凡是跨实例同一性要紧的宿主运行时,一律写成匹配的 `peerDependencies` 与 `devDependencies`,绝不写进 `dependencies`。Profile 的 pnpm 配置是 `nodeLinker: hoisted` 加 `autoInstallPeers: false`,这类 peer 不会装进 profile;导入走到 `$DSH_HOME/profiles/node_modules` 时,由运行时解析(runtime resolution)的拦截层提供安装里的那一份实例([解析顺序](2026-09-19-profile-resolution-lookup-order.zh.md))。
+
+`@deepseek-ai/dsh-scope` 的同一性既在读取者 `scopeOf`、`carrierKeyOf`、`scopeTarget` 上,也在写入者 `createScope` 上:标记由写入者铸造,再由读取者比较。`@deepseek-ai/dsh-mcp-client` 同属这一类,因为它在线的 `serverName` 保留表是模块级的,第二份副本会为同一命名空间保留第二张表。因此 `@deepseek-ai/dsh-experimental-browser-use-runtime` 把两者声明为 peer(并补匹配的 dev 条目),只把不携带实例内状态的 `@deepseek-ai/schemastery` 留在运行时依赖里。
+
+`tests/shared-host-runtimes.spec.ts` 钉住这个包的分区。`tests/host-runtime-duplication.spec.ts` 复现这条规则拦下的问题:两份包各多一份副本时,每个 Agent 的 MCP 工具都落进全局层,第二个 Agent 的创建被拒;而实际发布的单实例布局下全局层为空,两个 Agent 各拿到自己的客户端。
+
+[Published dependency faces](../process/2026-08-26-published-dependency-faces.zh.md) 拥有 `verify-package-dependencies`:它的 `peerRequiredHostExports` 表分类了 `dsh-scope` 的读取者,它的包选择覆盖 Client 面的包与一份显式 Host 名单。该 gate 不选择 `packages/experimental/` 下的任何包,也没有分类 `createScope`;gate 覆盖不到的 out-of-tree 插件包,由本记录拥有这条规则。
+
+## Alternatives considered
+
+**把 MCP 客户端挂到 `agent.ctx`,而不是本包自造的作用域。** 它不成立,是因为 profile 那份副本的 `scopeOf` 同样读不到安装为 `agent.ctx` 写下的标记,`serverName` 保留会退化成进程全局所有者,第二个 Agent 在那一步就失败——即 `0.1.6-alpha.1` 上看到的错误形态。消除重复副本才是两种形态共同的修复。
+
+**把 `dsh-scope` 的标记改成 `Symbol.for`,让两份副本互相认得。** 这只修好标记查找。作用域父链与事件载体键仍各留一份,一份副本的链式遍历与载体准入看不见另一份记录的关系——可见错误更小,成因更难定位,而且 profile 本不需要的那份重复依然存在。
+
+**把 profile 的包版本对齐到 Host。** 版本相同,两个目录依旧加载出两份模块实例,问题照旧。
+
+## Consequences
+
+- 修复要生效,需要升级并重装 profile 里的插件依赖。在此之前,已装好的 profile 一直带着那份副本,Host 侧无法补救。
+- 这条规则让插件依赖「安装确实带了这个 peer」。两个包都是 `@deepseek-ai/dsh` 安装的依赖,运行时解析能提供它们;若某插件不在安装闭包内,它会直接加载失败,而不是带着第二份副本启动。
+- `docs/module-graph.md` 把这些边画进 peer 区,图与分区表达的是同一条所有权边界。
+- 另有三个实验提供方(`browser-use-stagehand-native`、`computer-use-cua-driver-mcp`、`computer-use-cua-driver-native`)把 `@deepseek-ai/dsh-mcp-client` 写成依赖。它们不按 Agent 挂 MCP 客户端,因此自身不会触发本决策要防的故障。若一个 profile 把其中之一与本包一起安装,hoisted 会把那份依赖平铺到 profile 自己的 `node_modules`,位置高于拦截层,于是本包的 peer 解析到那份副本:两份 `dsh-mcp-client` 各存一张 `serverName` 保留表,一份上保留的名字在另一份上不再被检测。作用域标记的分裂不会回来,因为它们都没有把 `@deepseek-ai/dsh-scope` 写成依赖。Issue #4628 记录把这三处声明改为 peer 并各自验证的后续工作。
+- `verify-package-dependencies` 仍不选择 `packages/experimental/` 下的包,所以本包的分区只由 `tests/shared-host-runtimes.spec.ts` 钉住。扩大该 gate 的选择范围,就是本记录留下的覆盖缺口。
diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md
index 3b7a533f1a..d062b53002 100644
--- a/apps/web/tests/expected/plugin-config/official.expected.md
+++ b/apps/web/tests/expected/plugin-config/official.expected.md
@@ -25,5 +25,5 @@
- text: "1"
- list:
- listitem:
- - button "查看 live-client": live-client
- - switch "启用 live-client"
+ - button "查看 @fixture/live-client": "@fixture/live-client"
+ - switch "启用 @fixture/live-client"
diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md
index 85b1d52b9e..03308874b2 100644
--- a/apps/web/tests/expected/plugin-config/row.expected.md
+++ b/apps/web/tests/expected/plugin-config/row.expected.md
@@ -1,5 +1,7 @@
-- button "返回 live-client": live-client
-- heading "fixture-live-client" [level=3]
+- button "返回 @fixture/live-client": "@fixture/live-client"
+- heading "@fixture/live-client" [level=3]
+- paragraph:
+ - code: fixture-live-client
- paragraph:
- code: "@fixture/live-client"
- paragraph: 示例配置项
diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md
new file mode 100644
index 0000000000..74cc1b4c15
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md
@@ -0,0 +1,24 @@
+- button "Back to plugins": Plugins
+- button "Uninstall @fixture/bundle": Uninstall
+- switch "Enable @fixture/bundle"
+- heading "@fixture/bundle" [level=3]
+- text: v0.0.1
+- paragraph:
+ - code: "@fixture/bundle"
+- paragraph: Registry description for the fixture bundle.
+- heading "Components" [level=4]
+- text: 3 total · 3 off
+- list:
+ - listitem:
+ - text: "@fixture/bundle Registry description for the fixture bundle."
+ - code: fixture-row
+ - text: "Off"
+ - listitem:
+ - text: File Search Search package introduction.
+ - code: fixture-search
+ - code: "@fixture/bundle/search"
+ - text: "Off"
+ - listitem:
+ - text: "@fixture/bundle/review"
+ - code: fixture-review
+ - text: "Off"
diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md
new file mode 100644
index 0000000000..0512893d3e
--- /dev/null
+++ b/apps/web/tests/expected/plugin-manager/exports.expected.md
@@ -0,0 +1,25 @@
+- button "返回插件列表": 插件列表
+- button "卸载 @fixture/bundle": 卸载
+- switch "启用 @fixture/bundle"
+- heading "@fixture/bundle" [level=3]
+- text: v0.0.1
+- paragraph:
+ - code: "@fixture/bundle"
+- paragraph: Registry description for the fixture bundle.
+- heading "包含的组件" [level=4]
+- text: 共 3 个 · 3 已停用
+- list:
+ - listitem:
+ - text: "@fixture/bundle Registry description for the fixture bundle."
+ - code: fixture-row
+ - text: 已关闭
+ - listitem:
+ - text: 文件搜索 搜索工作区中的文件。
+ - code: fixture-search
+ - code: "@fixture/bundle/search"
+ - text: 已关闭
+ - listitem:
+ - text: 代码审查 审查工作区中的改动。
+ - code: fixture-review
+ - code: "@fixture/bundle/review"
+ - text: 已关闭
diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
index ae850f5daa..92fafe32cb 100644
--- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
+++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
@@ -25,6 +25,6 @@
- text: "1"
- list:
- listitem:
- - button "查看 bundle": bundle
- - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
- - switch "启用 bundle" [checked]
+ - button "查看 @fixture/bundle": "@fixture/bundle"
+ - text: Registry description for the fixture bundle.
+ - switch "启用 @fixture/bundle" [checked]
diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md
index a13d1a001e..45658f20cf 100644
--- a/apps/web/tests/expected/plugin-manager/manager.expected.md
+++ b/apps/web/tests/expected/plugin-manager/manager.expected.md
@@ -25,6 +25,6 @@
- text: "1"
- list:
- listitem:
- - button "查看 bundle": bundle
- - text: "Web e2e fixture: a bundle whose one row is an inert plugin."
- - switch "启用 bundle"
+ - button "查看 @fixture/bundle": "@fixture/bundle"
+ - text: Registry description for the fixture bundle.
+ - switch "启用 @fixture/bundle"
diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
index 5eec14673b..93ebcf1143 100644
--- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
+++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
@@ -1,10 +1,9 @@
- button "返回插件列表": 插件列表
-- switch "启用 missing-bundle" [checked]
-- heading "missing-bundle" [level=3]
+- switch "启用 @fixture/missing-bundle" [checked]
+- heading "@fixture/missing-bundle" [level=3]
- text: 异常
- paragraph:
- code: "@fixture/missing-bundle"
-- paragraph: 暂无描述。
- status: "原因: dsh: cannot resolve profile bundle \"@fixture/missing-bundle\" from the dsh installation or {{home}}/profiles/scaffold; run 'dsh plugin --profile scaffold install' if its dependency is not installed"
- heading "包含的组件" [level=4]
- paragraph: 这个插件包不包含任何组件。
diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
index 4c654a7971..612291fe84 100644
--- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
@@ -2,7 +2,7 @@
- listitem:
- button "tool-subagent-control, tool-subagent-control, 已启用":
- strong: tool-subagent-control
- - text: 已启用
+ - text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
- code: tool-subagent-control
- listitem:
- button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
@@ -12,20 +12,20 @@
- listitem:
- button "tool-subagent, tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用
+ - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent
- listitem:
- button "tool-subagent, tool-subagent-fork, 已启用":
- strong: tool-subagent
- - text: 已启用
+ - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-fork
- listitem:
- button "tool-subagent, tool-subagent-codex, 已停用":
- strong: tool-subagent
- - text: 已停用
+ - text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-codex
- listitem:
- button "tool-subagent, tool-subagent-claude-code, 已停用":
- strong: tool-subagent
- - text: 已停用
+ - text: 已停用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-claude-code
diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md
index e8536c690a..7cfe33f5f0 100644
--- a/apps/web/tests/expected/settings-chrome/plugins.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md
@@ -1,5 +1,5 @@
- listitem:
- button "tool-subagent, tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用
+ - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
index a3e71146b9..8038d9e378 100644
--- a/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/cordis.patch.yml
@@ -1,4 +1,8 @@
-# Web e2e fixture bundle: one inert row whose mount state exposes live layer changes.
+# Web e2e fixture bundle with independently localized plugin exports.
- insert:
- id: fixture-row
name: '@fixture/bundle'
+ - id: fixture-search
+ name: '@fixture/bundle/search'
+ - id: fixture-review
+ name: '@fixture/bundle/review'
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json
new file mode 100644
index 0000000000..6da7422b1a
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/en.json
@@ -0,0 +1,3 @@
+{
+ "meta": {}
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json
new file mode 100644
index 0000000000..90a5b56fb2
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/review/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "代码审查",
+ "description": "审查工作区中的改动。"
+ }
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json
new file mode 100644
index 0000000000..e27e893200
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/en.json
@@ -0,0 +1,5 @@
+{
+ "meta": {
+ "title": "File Search"
+ }
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json
new file mode 100644
index 0000000000..44f2e03849
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/locale/search/zh.json
@@ -0,0 +1,6 @@
+{
+ "meta": {
+ "title": "文件搜索",
+ "description": "搜索工作区中的文件。"
+ }
+}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json
index 263634fb08..3224dfa296 100644
--- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json
@@ -2,9 +2,18 @@
"name": "@fixture/bundle",
"version": "0.0.1",
"private": true,
- "description": "Web e2e fixture: a bundle whose one row is an inert plugin.",
+ "description": "Registry description for the fixture bundle.",
"type": "module",
"main": "index.js",
+ "exports": {
+ ".": "./index.js",
+ "./package.json": "./package.json",
+ "./search": "./search.js",
+ "./search/package.json": "./search.package.json",
+ "./review": "./review.js",
+ "./search/locale/*.json": "./locale/search/*.json",
+ "./review/locale/*.json": "./locale/review/*.json"
+ },
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/review.js b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js
new file mode 100644
index 0000000000..356cb5cf9a
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/review.js
@@ -0,0 +1,2 @@
+/** Inert review plugin for exported metadata browser coverage. */
+export function apply() {}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.js b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js
new file mode 100644
index 0000000000..c8858a7889
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.js
@@ -0,0 +1,2 @@
+/** Inert search plugin for exported metadata browser coverage. */
+export function apply() {}
diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json
new file mode 100644
index 0000000000..52a0c0f420
--- /dev/null
+++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json
@@ -0,0 +1,4 @@
+{
+ "name": "Search plugin",
+ "description": "Search package introduction."
+}
diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts
index 825d95b081..11394b867b 100644
--- a/apps/web/tests/plugin-config.e2e.ts
+++ b/apps/web/tests/plugin-config.e2e.ts
@@ -33,6 +33,7 @@ describe('web e2e: plugin configuration pages', () => {
// The live-client fixture is a bundle with a browser half; switched on
// below, that half registers its row's configuration into the page.
scaffold = await launchWebScaffold({
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
})
browser = await chromium.launch()
@@ -286,21 +287,21 @@ describe('web e2e: plugin configuration pages', () => {
const panel = await openPlugins()
// Off, the bundle's browser half is not loaded and the row has no configuration to open.
- await panel.getByRole('button', { name: '查看 live-client', exact: true }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/live-client', exact: true }).click()
const row = panel.locator('[data-plugin-row]', { hasText: 'fixture-live-client' })
await row.waitFor({ timeout: 10_000 })
- expect(await panel.getByRole('button', { name: '配置 fixture-live-client' }).count()).toBe(0)
+ expect(await panel.getByRole('button', { name: '配置 @fixture/live-client' }).count()).toBe(0)
// Switched on, the Host recomposes and the browser half mounts without a
// reload; its registration puts the configure control on the row.
- await panel.getByRole('switch', { name: '启用 live-client' }).click()
- const configure = panel.getByRole('button', { name: '配置 fixture-live-client' })
+ await panel.getByRole('switch', { name: '启用 @fixture/live-client' }).click()
+ const configure = panel.getByRole('button', { name: '配置 @fixture/live-client' })
await configure.waitFor({ timeout: 30_000 })
await configure.click()
const rowPage = panel.locator('[data-plugin-row-detail="@fixture/live-client#fixture-live-client"]')
await rowPage.waitFor({ timeout: 10_000 })
- expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('fixture-live-client')
+ expect(await rowPage.getByRole('heading', { level: 3 }).textContent()).toBe('@fixture/live-client')
expect(await rowPage.getByText('示例配置项', { exact: true }).count()).toBe(1)
const form = rowPage.getByRole('form', { name: '动态插件配置' })
await form.getByLabel('问候语').fill('你好')
@@ -309,7 +310,7 @@ describe('web e2e: plugin configuration pages', () => {
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(ROW_EXPECTED, snapshot, MODE)
- await rowPage.getByRole('button', { name: '返回 live-client' }).click()
+ await rowPage.getByRole('button', { name: '返回 @fixture/live-client' }).click()
await panel.locator('[data-plugin-detail="@fixture/live-client"]').waitFor({ timeout: 10_000 })
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index f2146bc906..373b0ba459 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -19,6 +19,8 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url))
const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md')
const LIVE_EXPECTED = join(SNAPSHOT_DIR, 'live-enabled.expected.md')
+const EXPORTS_EXPECTED = join(SNAPSHOT_DIR, 'exports.expected.md')
+const EXPORTS_EN_EXPECTED = join(SNAPSHOT_DIR, 'exports-en.expected.md')
const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
const MODE = webSnapshotMode()
@@ -30,6 +32,7 @@ describe('web e2e: plugin manager', () => {
beforeAll(async () => {
scaffold = await launchWebScaffold({
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }], bundles: ['@fixture/missing-bundle'] },
})
browser = await chromium.launch()
@@ -52,11 +55,32 @@ describe('web e2e: plugin manager', () => {
}
}
+ /** Change the UI language through Settings and close the dialog. */
+ async function setLanguage(language: 'en' | 'zh'): Promise {
+ if (await page.locator('html').getAttribute('lang') === language) return
+ const settings = language === 'en' ? '设置' : 'Settings'
+ const source = language === 'en' ? '中文' : 'English'
+ const target = language === 'en' ? 'English' : '中文'
+ if (await page.getByRole('dialog', { name: settings }).count() === 0) {
+ await page.getByRole('button', { name: settings, exact: true }).click()
+ }
+ await page.getByRole('dialog', { name: settings }).getByRole('button', { name: source }).click()
+ await page.getByRole('menuitem', { name: target }).click()
+ const dialog = page.getByRole('dialog', { name: language === 'en' ? 'Settings' : '设置' })
+ await dialog.waitFor()
+ await page.keyboard.press('Escape')
+ await expect.poll(() => dialog.count()).toBe(0)
+ }
+
/** Select the sidebar's Plugins entry and wait for the management page in the main column. */
async function openPluginsPanel() {
await closeSettings()
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
+ await panel.waitFor({ timeout: 10_000 })
+ while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) {
+ await panel.getByRole('button', { name: /^返回/ }).first().click()
+ }
await panel.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
return panel
}
@@ -69,8 +93,8 @@ describe('web e2e: plugin manager', () => {
it('starts with an unavailable selected bundle and lets the user clear its error', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle'))
const panel = await openPluginsPanel()
- await panel.getByRole('button', { name: '查看 missing-bundle', exact: true }).click()
- const toggle = panel.getByRole('switch', { name: '启用 missing-bundle', exact: true })
+ await panel.getByRole('button', { name: '查看 @fixture/missing-bundle', exact: true }).click()
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/missing-bundle', exact: true })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
expect(await toggle.isDisabled()).toBe(false)
await panel.getByText(/cannot resolve profile bundle/).waitFor({ timeout: 10_000 })
@@ -93,8 +117,8 @@ describe('web e2e: plugin manager', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-list'))
const panel = await openPluginsPanel()
- await panel.getByText('bundle', { exact: true }).waitFor({ timeout: 20_000 })
- const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+ await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).waitFor({ timeout: 20_000 })
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
expect(await toggle.getAttribute('aria-checked')).toBe('false')
// The profile's own group holds its one bundle; the installation's optional bundles open the Official
// group, followed by the official plugins that registered their configuration, and its other bundles
@@ -104,18 +128,47 @@ describe('web e2e: plugin manager', () => {
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(1)
// A bundle that is off still shows the rows its patch declares, without switches.
- await panel.getByRole('button', { name: '查看 bundle' }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
- expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).count()).toBe(0)
- await panel.getByRole('button', { name: '卸载 bundle' }).waitFor({ timeout: 5_000 })
+ expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0)
+ await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 })
await panel.getByRole('button', { name: '返回插件列表' }).click()
- await expect.poll(() => panel.getByRole('button', { name: '卸载 bundle' }).count(), { timeout: 5_000 }).toBe(0)
+ await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0)
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MANAGER_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+ it('localizes independent exports and falls back per field without activating the plugins', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports'))
+ const panel = await openPluginsPanel()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
+ const search = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/search' })
+ const review = panel.locator('[data-plugin-row]', { hasText: '@fixture/bundle/review' })
+ await search.getByText('文件搜索', { exact: true }).waitFor()
+ await review.getByText('代码审查', { exact: true }).waitFor()
+ expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1)
+ expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1)
+ await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor()
+ expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false)
+ await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
+ try {
+ await setLanguage('en')
+ await search.getByText('File Search', { exact: true }).waitFor()
+ expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0)
+ expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(1)
+ expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0)
+ await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd), MODE)
+ expect(await panel.locator('[data-plugin-name]').textContent()).toBe('@fixture/bundle')
+ } finally {
+ await setLanguage('zh')
+ await closeSettings()
+ }
+ await panel.getByRole('button', { name: '返回插件列表' }).click()
+ expect(tripwire.pageErrors).toEqual([])
+ })
+
it('updates built-in names and descriptions when the UI language changes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-locale'))
const panel = await openPluginsPanel()
@@ -123,15 +176,16 @@ describe('web e2e: plugin manager', () => {
const packageName = panel.locator('[data-plugin-name]')
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('启用团队协作、团队工具、成员列表和共享任务看板。').count()).toBe(1)
+ const child = panel.locator('[data-plugin-row]', { hasText: 'tool-agent-team' })
+ await child.getByText('团队工具', { exact: true }).waitFor()
+ expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1)
try {
- await page.getByRole('button', { name: '设置', exact: true }).click()
- await page.getByRole('dialog', { name: '设置' }).getByRole('button', { name: '中文' }).click()
- await page.getByRole('menuitem', { name: 'English' }).click()
- await page.getByRole('dialog', { name: 'Settings' }).waitFor()
- await page.keyboard.press('Escape')
+ await setLanguage('en')
await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor()
expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile')
expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1)
+ await child.getByText('Team Tools', { exact: true }).waitFor()
+ expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: 'Back to plugins' }).click()
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
@@ -140,14 +194,7 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
}
} finally {
- if (await page.locator('html').getAttribute('lang') === 'en') {
- if (await page.getByRole('dialog', { name: 'Settings' }).count() === 0) {
- await page.getByRole('button', { name: 'Settings', exact: true }).click()
- }
- await page.getByRole('dialog', { name: 'Settings' }).getByRole('button', { name: 'English' }).click()
- await page.getByRole('menuitem', { name: '中文' }).click()
- await page.getByRole('dialog', { name: '设置' }).waitFor()
- }
+ await setLanguage('zh')
await closeSettings()
}
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).waitFor()
@@ -244,7 +291,7 @@ describe('web e2e: plugin manager', () => {
it('enables a bundle into the profile manifest, mounts its rows live, and switches one of them', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-enable'))
const panel = await openPluginsPanel()
- const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
await toggle.waitFor({ timeout: 20_000 })
const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
expect(mounted()?.fiber?.state).toBeUndefined()
@@ -262,8 +309,8 @@ describe('web e2e: plugin manager', () => {
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE)
// The pack's page lists its rows as the Host runs them, each with a switch that writes the profile patch.
- await panel.getByRole('button', { name: '查看 bundle' }).click()
- const rowSwitch = panel.getByRole('switch', { name: '启用组件 fixture-row' })
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
+ const rowSwitch = panel.getByRole('switch', { name: '启用组件 @fixture/bundle' })
await rowSwitch.waitFor({ timeout: 10_000 })
expect(await rowSwitch.getAttribute('aria-checked')).toBe('true')
await rowSwitch.click()
@@ -283,7 +330,9 @@ describe('web e2e: plugin manager', () => {
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
- await assertFixtureInventory(SNAPSHOT_DIR, ['manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md',
+ ])
})
})
@@ -291,6 +340,7 @@ describe('web e2e: plugin manager', () => {
describe('web e2e: startup-applied plugin management', () => {
it('saves a bundle selection that waits for the next start and keeps its rows read-only', async () => {
const scaffold = await launchWebScaffold({
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
profile: { hmr: false, packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-bundle') }] },
})
let browser: Browser | undefined
@@ -302,7 +352,7 @@ describe('web e2e: startup-applied plugin management', () => {
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
- const toggle = panel.getByRole('switch', { name: '启用 bundle' })
+ const toggle = panel.getByRole('switch', { name: '启用 @fixture/bundle' })
await toggle.waitFor({ timeout: 20_000 })
const mounted = () => [...scaffold.ctx.loader.entries()].find(entry => entry.options.id === 'fixture-row')
const bundles = async () => {
@@ -317,9 +367,9 @@ describe('web e2e: startup-applied plugin management', () => {
await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 })
expect(mounted()?.fiber?.state).toBeUndefined()
// The pack's page lists its rows from their declarations, with no live entry to switch.
- await panel.getByRole('button', { name: '查看 bundle' }).click()
+ await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
- expect(await panel.getByRole('switch', { name: '启用组件 fixture-row' }).isDisabled()).toBe(true)
+ expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true)
await panel.getByRole('button', { name: '返回插件列表' }).click()
await toggle.click()
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index b396d5f969..d84614d33c 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -37,7 +37,10 @@ describe('web e2e: settings modal and General preferences', () => {
let tripwire: ReturnType
beforeAll(async () => {
- scaffold = await launchWebScaffold({ developerTools: false })
+ scaffold = await launchWebScaffold({
+ developerTools: false,
+ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
+ })
browser = await chromium.launch()
// Chinese browser: the shared page asserts the localized settings surface
// the client derives from it (the English default has its own spec below).
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 1dd61bd367..3037c7f95d 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/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: 34cb5fba2ee0b3db399330aba1dad76bb39e7865
-config-catalog.zh.md: b4225b187e226fcd90adff08acb09d45720b8414
+config-catalog.md: e7b759ad04ba1c8222359f935e3834d4fe81295e
+config-catalog.zh.md: 9d39b4821cd6fb223db0a028de3376a6e3804b4b
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 34cb5fba2e..e7b759ad04 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1906,7 +1906,7 @@ export interface Config {
}
```
-Source: [`packages/boot/plugin-manager/src/index.ts:35`](../packages/boot/plugin-manager/src/index.ts)
+Source: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index b4225b187e..9d39b4821c 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1908,7 +1908,7 @@ export interface Config {
}
```
-来源: [`packages/boot/plugin-manager/src/index.ts:35`](../packages/boot/plugin-manager/src/index.ts)
+来源: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts)
diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml
index 6e6c6710dd..b2b0467564 100644
--- a/docs/cookbook/adding-a-package.i18n.yaml
+++ b/docs/cookbook/adding-a-package.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/cookbook/adding-a-package.md
-adding-a-package.md: 771bc2b63dba0234252a6925c0aaa4b4f3a16142
-adding-a-package.zh.md: 8be5e47a206983bef3f86d4210f59159f87a9fae
+adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083
+adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c
diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md
index 771bc2b63d..8c818bc371 100644
--- a/docs/cookbook/adding-a-package.md
+++ b/docs/cookbook/adding-a-package.md
@@ -14,6 +14,8 @@ packages///
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../..// for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
+ locale/en.json # optional display metadata: meta.title and meta.description
+ locale/zh.json # translations using the same fields
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
# + the gated "Known Limitations and Deferred Work" section
@@ -69,6 +71,8 @@ Use a singular `ctx` key for one engine, runtime, policy, controller, resolver,
Use `SDK` only for the JSON-RPC client/server protocol used by the supported Python and TypeScript SDKs. DeepSeek Harness itself is an agent harness, not an SDK project. Use the canonical product spelling `Typert`, never `TypeRT` or `typeRT`.
+
+
## 4. Write the package README
Keep package-specific service API, config, events, extension points, and design notes first. Choose the frontmatter `kind` from the four kind labels in the [dsh-doc metadata reference](../../.agents/skills/dsh-doc/references/metadata-links-i18n.md#the-kind-system) — group, reference, library, or bundle — matching the package's repository position and entry shape; each kind selects one README template. The limitations section records durable consumer gaps and non-obvious maintainer constraints owned by this package; ordinary cleanup stays in its source TODO or Agent Note. An indirect Model Experience sentence may name the consumer that surfaces this package's contribution, but it does not restate that consumer's implementation. End a package README with this canonical sequence:
@@ -105,7 +109,56 @@ Fill Model Experience from the implementation. Use one H3 per direct, conditiona
A package with no context effect or one consumer-owned path uses the audited `None, as ` or `Indirectly, through ` sentence in [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts), followed by a `KV Cache effect` H4 and one non-empty paragraph; a model-agnostic generic package may instead join `NO_MODEL_EXPERIENCE_SECTION`. Do not expand either case into a description of another package's work. The limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) is independent. The [Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md) records the rationale.
-## 5. Verify
+
+
+## 5. Add optional plugin display metadata
+
+For an npm package plugin, define its title and description in `locale/en.json`. Other language files, such as `locale/zh.json`, use the same fields:
+
+```json
+{
+ "meta": {
+ "title": "Workspace Tools",
+ "description": "Tools for your workspace."
+ }
+}
+```
+
+Merge these entries into `package.json`, retaining existing runtime exports and publication files:
+
+```json
+{
+ "exports": {
+ "./package.json": "./package.json",
+ "./locale/*.json": "./locale/*.json"
+ },
+ "files": ["locale/*.json"]
+}
+```
+
+Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic.
+
+Fields fall back independently before view-specific name formatting, using the existing locale language chain first:
+
+- Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name.
+- Description: locale `meta.description` → `package.json.description` → no description.
+
+Export `/locale/en.json` for locale lookup; expose `/package.json` when package-field fallback is needed.
+
+Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins.
+
+Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description.
+
+The Install view still uses npm registry information from `pnpm view`, not locale metadata.
+
+Verify the result:
+
+1. Run `pnpm run verify-package-meta` from the repository root to check fields, resource exports, and publication coverage.
+2. Switch an installed plugin's applicable Plugin Manager and Settings entries between English and Chinese; check the title, description, per-field fallback, and Settings-only compact technical names.
+
+See the [plugin metadata Agent Note](../../.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.md) for resource ownership and non-activation rationale.
+
+## 6. Verify
```sh
pnpm install # registers the workspace
diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md
index 8be5e47a20..f6a09b411e 100644
--- a/docs/cookbook/adding-a-package.zh.md
+++ b/docs/cookbook/adding-a-package.zh.md
@@ -14,6 +14,8 @@ packages///
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../..// for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
+ locale/en.json # optional display metadata: meta.title and meta.description
+ locale/zh.json # translations using the same fields
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
# + the gated "Known Limitations and Deferred Work" section
@@ -107,7 +109,56 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
没有上下文效果或仅有消费方拥有路径的包使用 [`SENTENCE_MODEL_EXPERIENCE`](../../scripts/verify-package-readme-model-experience.ts) 中经过审计的 `None, as ` 或 `Indirectly, through ` 语句,随后添加 `KV Cache effect` H4 和一个非空正文段落;与模型无关的通用包可以改为加入 `NO_MODEL_EXPERIENCE_SECTION`。两种情况都不要展开为对另一个包工作的描述。limitations [allowlist](../../scripts/verify-package-readme-limitations.ts) 独立管理。[Model Experience Agent Note](../../.agents/notes/implemented/process/2026-07-12-package-model-experience-contract.zh.md) 记录了设计动机。
-## 5. 验证
+
+
+## 5. 添加可选的插件展示元信息
+
+对于 npm 包插件,在 `locale/en.json` 中定义标题和描述。其他语言文件(如 `locale/zh.json`)使用相同字段:
+
+```json
+{
+ "meta": {
+ "title": "Workspace Tools",
+ "description": "Tools for your workspace."
+ }
+}
+```
+
+将以下条目合并进 `package.json`,保留已有的运行时 exports 和发布文件:
+
+```json
+{
+ "exports": {
+ "./package.json": "./package.json",
+ "./locale/*.json": "./locale/*.json"
+ },
+ "files": ["locale/*.json"]
+}
+```
+
+语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。
+
+各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链:
+
+- 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。
+- 描述:locale `meta.description` → `package.json.description` → 不显示描述。
+
+导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。
+
+已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
+
+仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。
+
+Install 界面仍使用 `pnpm view` 返回的 npm registry 信息,不用 locale 元信息替换。
+
+验证结果:
+
+1. 在仓库根目录运行 `pnpm run verify-package-meta`,检查字段、资源 exports 和发布文件覆盖。
+2. 对已安装插件,在适用的插件管理页与设置条目中切换中英文,检查标题、描述、逐字段回退,以及仅设置页使用的技术短名。
+
+资源归属与不激活插件的原因见[插件元信息 Agent Note](../../.agents/notes/implemented/architecture/2026-09-18-localized-package-metadata.zh.md)。
+
+## 6. 验证
```sh
pnpm install # registers the workspace
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 0044c74d09..0e97efd9a4 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: 0f195f42945bcd9419e0a428c5a5fee74ac83280
-event-producer-consumer.zh.md: 90d320e11f1b77c434f8c445143bae1a35b5011e
+event-producer-consumer.md: 9f9d2e2172ec0af51306b24060cd0e12d1b6efee
+event-producer-consumer.zh.md: 4e46a064d1471d1e8802afc04a86772515fe3b09
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 0f195f4294..9f9d2e2172 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -51,9 +51,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
-| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:223`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:229`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 90d320e11f..4e46a064d1 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -53,9 +53,9 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
-| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:223`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:229`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:236`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:228`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:234`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:241`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 9d0907f841..1d41a997fa 100644
--- a/docs/module-graph.i18n.yaml
+++ b/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: 87f2b54fa88a22113bd0cdc8e9d7e8a9d24dc4ca
-module-graph.zh.md: 1fe79b74999782377c8c7456c1e0a8231a85ec1d
+module-graph.md: 1b013cd5d96c133ed3ffcd419d2cdebe653187e9
+module-graph.zh.md: d8b981f500fce55c29a5ab99df1cb493064f3365
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 87f2b54fa8..1b013cd5d9 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -839,10 +839,6 @@ flowchart TD
pkg_experimental_browser_use_playwright_mcp --> pkg_browser_use
pkg_experimental_browser_use_playwright_mcp --> pkg_system_prompt
pkg_experimental_browser_use_playwright_mcp --> pkg_tools
- pkg_experimental_browser_use_runtime --> pkg_agent
- pkg_experimental_browser_use_runtime --> pkg_browser_use
- pkg_experimental_browser_use_runtime --> pkg_system_prompt
- pkg_experimental_browser_use_runtime --> pkg_tools
pkg_experimental_browser_use_stagehand_native --> pkg_agent
pkg_experimental_browser_use_stagehand_native --> pkg_browser_use
pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt
@@ -1107,6 +1103,12 @@ flowchart TD
pkg_compaction_tool_result_pruner --> pkg_llm
pkg_compaction_tool_result_pruner --> pkg_session
pkg_compaction_tool_result_pruner --> pkg_token_meter
+ pkg_experimental_browser_use_runtime --> pkg_agent
+ pkg_experimental_browser_use_runtime --> pkg_browser_use
+ pkg_experimental_browser_use_runtime --> pkg_mcp_client
+ pkg_experimental_browser_use_runtime --> pkg_scope
+ pkg_experimental_browser_use_runtime --> pkg_system_prompt
+ pkg_experimental_browser_use_runtime --> pkg_tools
pkg_webhook_github --> pkg_credentials
pkg_webhook_github --> pkg_host_webserver
pkg_webhook_github --> pkg_session
@@ -1510,7 +1512,6 @@ flowchart TD
| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
| [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
@@ -1556,6 +1557,7 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 1fe79b7499..d8b981f500 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -841,10 +841,6 @@ flowchart TD
pkg_experimental_browser_use_playwright_mcp --> pkg_browser_use
pkg_experimental_browser_use_playwright_mcp --> pkg_system_prompt
pkg_experimental_browser_use_playwright_mcp --> pkg_tools
- pkg_experimental_browser_use_runtime --> pkg_agent
- pkg_experimental_browser_use_runtime --> pkg_browser_use
- pkg_experimental_browser_use_runtime --> pkg_system_prompt
- pkg_experimental_browser_use_runtime --> pkg_tools
pkg_experimental_browser_use_stagehand_native --> pkg_agent
pkg_experimental_browser_use_stagehand_native --> pkg_browser_use
pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt
@@ -1109,6 +1105,12 @@ flowchart TD
pkg_compaction_tool_result_pruner --> pkg_llm
pkg_compaction_tool_result_pruner --> pkg_session
pkg_compaction_tool_result_pruner --> pkg_token_meter
+ pkg_experimental_browser_use_runtime --> pkg_agent
+ pkg_experimental_browser_use_runtime --> pkg_browser_use
+ pkg_experimental_browser_use_runtime --> pkg_mcp_client
+ pkg_experimental_browser_use_runtime --> pkg_scope
+ pkg_experimental_browser_use_runtime --> pkg_system_prompt
+ pkg_experimental_browser_use_runtime --> pkg_tools
pkg_webhook_github --> pkg_credentials
pkg_webhook_github --> pkg_host_webserver
pkg_webhook_github --> pkg_session
@@ -1512,7 +1514,6 @@ flowchart TD
| [`workspace-changes`](../packages/deliverables/workspace-changes) | `deliverables` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
| [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
@@ -1558,6 +1559,7 @@ flowchart TD
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml
index fdf1602146..c602a72982 100644
--- a/docs/subsystems/boot.i18n.yaml
+++ b/docs/subsystems/boot.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/boot.md
-boot.md: 90ad5c760823e2451e7d138288778c0b2bfa625f
-boot.zh.md: 7a08fb8a8640ba0a6ad8c174ac7eb2fe433f6ab6
+boot.md: d8f7ea8018dbbf5ec3b20f14cf4c1884af167a94
+boot.zh.md: 3353427b4e727713113842b89e094f26e75217bb
diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md
index 90ad5c7608..d8f7ea8018 100644
--- a/docs/subsystems/boot.md
+++ b/docs/subsystems/boot.md
@@ -8,9 +8,9 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
`PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id.
-`PluginInfo` carries module identity, effective enablement and fiber phase, plus a unique `patchId` or a `readOnlyReason`.
+`PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`.
-`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error.
+`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time.
`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one.
@@ -69,8 +69,8 @@ Manage profile files and apply their declared reload lifecycle.
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
- * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
- * bundle, and removal availability.
+ * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
+ * whether the installation offers the bundle, and removal availability.
*/
@Remote listBundles(): Promise
diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md
index 7a08fb8a86..3353427b4e 100644
--- a/docs/subsystems/boot.zh.md
+++ b/docs/subsystems/boot.zh.md
@@ -8,9 +8,9 @@
`PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。
-`PluginInfo` 包含模块标识、实际启停状态和 fiber 阶段,以及唯一的 `patchId` 或 `readOnlyReason`。
+`PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。
-`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。
+`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。
`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。
@@ -69,8 +69,8 @@ Manage profile files and apply their declared reload lifecycle.
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
- * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
- * bundle, and removal availability.
+ * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
+ * whether the installation offers the bundle, and removal availability.
*/
@Remote listBundles(): Promise
diff --git a/package.json b/package.json
index 00c03e6bc6..4d36479376 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"verify-dsh-package-licenses": "tsx scripts/verify-dsh-package-licenses.ts",
"verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts",
"verify-package-invariants": "tsx scripts/verify-package-invariants.ts",
+ "verify-package-meta": "tsx scripts/verify-package-meta.ts",
"verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs",
"verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts",
"verify-package-readme-summaries": "tsx scripts/verify-package-readme-summaries.ts",
diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml
index 8a41d074ff..aed223888a 100644
--- a/packages/boot/app-boot/README.i18n.yaml
+++ b/packages/boot/app-boot/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/boot/app-boot/README.md
-README.md: fed6f6833dd22254270b86beccd797d3b6054d7f
-README.zh.md: bedf8ae3d019607cbf04e24a38febd3441e06bbd
+README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594
+README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae
diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md
index fed6f6833d..8439fb9ec1 100644
--- a/packages/boot/app-boot/README.md
+++ b/packages/boot/app-boot/README.md
@@ -66,6 +66,10 @@ Before mounting profile rows, the `dsh` launcher computes one immutable runtime
Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
+### Reading plugin display metadata
+
+Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format.
+
### Startup and reload failures
diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md
index bedf8ae3d0..0bb8c9819d 100644
--- a/packages/boot/app-boot/README.zh.md
+++ b/packages/boot/app-boot/README.zh.md
@@ -66,6 +66,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
+### 读取插件展示元信息
+
+使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。
+
### 启动与重载失败
diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts
index bb7d055649..a20d1e50ce 100644
--- a/packages/boot/app-boot/src/index.ts
+++ b/packages/boot/app-boot/src/index.ts
@@ -19,6 +19,7 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
export { readProfilePatches, resolveTelemetryPatch, type ProfileContext, type ProfilePnpmInvocation } from './profile-context.ts'
export { sanitizeProfile } from './profile-sanitize.ts'
+export { readPluginMeta } from './package-meta.ts'
import type {} from '@deepseek-ai/dsh-system-prompt'
export {
diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts
new file mode 100644
index 0000000000..cc8fe2ebfe
--- /dev/null
+++ b/packages/boot/app-boot/src/package-meta.ts
@@ -0,0 +1,137 @@
+/** Read plugin display metadata through exported locale resources without evaluating plugin code. */
+
+import { readdirSync, readFileSync } from 'node:fs'
+import { dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
+import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
+import { barePackageName } from './profile-resolution/resolver.ts'
+
+const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u
+
+type DisplayFields = Record<'title' | 'description', string | undefined>
+
+/**
+ * Resolve a plugin resource through the active Node ESM resolver without evaluating it.
+ * @param specifier - complete resource module specifier, including its locale filename.
+ * @param parentURL - owning module-resolution base.
+ * @returns the local filesystem path selected by Node and the active profile.
+ * @throws when the resolver is unavailable or the resource cannot resolve to a local file.
+ */
+export function resolvePluginResource(specifier: string, parentURL: string): string {
+ const loader = ModuleLoader.fromInternal()
+ if (loader === undefined) throw new Error('Plugin metadata requires the Node module resolver')
+ const result = loader.version === 'v2'
+ ? loader.resolveSync(parentURL, { specifier, attributes: {} })
+ : loader.resolveSync(specifier, parentURL, {})
+ return fileURLToPath(result.url)
+}
+
+function missingResource(error: unknown): boolean {
+ const code = (error as NodeJS.ErrnoException | null)?.code
+ return code === 'ERR_PACKAGE_PATH_NOT_EXPORTED' || code === 'ERR_MODULE_NOT_FOUND'
+ || code === 'MODULE_NOT_FOUND' || code === 'ENOENT' || code === 'ENOTDIR'
+}
+
+function optionalResourcePath(specifier: string, parentURL: string): string | undefined {
+ try {
+ return resolvePluginResource(specifier, parentURL)
+ } catch (error) {
+ if (missingResource(error)) return undefined
+ throw error
+ }
+}
+
+function objectOf(value: unknown, path: string): Record {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new Error(`${path} must be an object`)
+ }
+ return value as Record
+}
+
+function textOf(value: unknown, path: string): string | undefined {
+ if (value === undefined) return undefined
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`${path} must be a non-empty string`)
+ return value
+}
+
+function readObject(file: string): Record {
+ let contents: unknown
+ try {
+ contents = JSON.parse(readFileSync(file, 'utf8')) as unknown
+ } catch (error) {
+ throw new Error(`${file}: ${String(error)}`)
+ }
+ return objectOf(contents, file)
+}
+
+function fallbackText(value: unknown): string | undefined {
+ return typeof value === 'string' && value.trim() !== '' ? value : undefined
+}
+
+function dictionariesOf(englishPath: string, specifier: string, parentURL: string): Map {
+ const dictionaries = new Map()
+ for (const entry of readdirSync(dirname(englishPath), { withFileTypes: true })) {
+ if (!entry.name.endsWith('.json')) continue
+ const resource = `${specifier}/locale/${entry.name}`
+ const language = entry.name.slice(0, -5)
+ if (!LANGUAGE_ID.test(language)) throw new Error(`${resource} must use a language id as its filename`)
+ const id = language.toLowerCase()
+ if (dictionaries.has(id)) throw new Error(`${resource} duplicates locale ${id}`)
+ const file = resolvePluginResource(resource, parentURL)
+ if (dirname(file) !== dirname(englishPath)) {
+ throw new Error(`${resource}: ${file} must share the English locale directory ${dirname(englishPath)}`)
+ }
+ const parsed = readObject(file)
+ const meta = parsed.meta === undefined ? undefined : objectOf(parsed.meta, `${file}: meta`)
+ dictionaries.set(id, {
+ title: textOf(meta?.title, `${file}: meta.title`),
+ description: textOf(meta?.description, `${file}: meta.description`),
+ })
+ }
+ return dictionaries
+}
+
+function localizedText(
+ field: keyof DisplayFields, dictionaries: Map, fallback: string | undefined, finalFallback: string,
+): LocalizedText | undefined {
+ const entries = [...dictionaries].flatMap(([language, fields]): [string, string][] => {
+ const value = fields[field]
+ return value === undefined ? [] : [[language, value]]
+ })
+ if (entries.length === 0) return fallback
+ return { en: fallback ?? finalFallback, ...Object.fromEntries(entries) }
+}
+
+/**
+ * Read meta.title and meta.description from a plugin's exported locale JSON files.
+ * Non-package specifiers are skipped without invoking the resource resolver.
+ * Language files share the directory containing the resolved English resource;
+ * each file is resolved through the complete plugin specifier before reading.
+ * Missing fields use the same address's package.json name/description. Translation
+ * maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions.
+ * @param specifier - configured plugin module name, including any package subpath.
+ * @param parentURL - owning Loader tree's module-resolution base.
+ * @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text.
+ */
+export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined {
+ if (barePackageName(specifier) === undefined) return undefined
+ try {
+ const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL)
+ const dictionaries = englishPath === undefined ? new Map() : dictionariesOf(englishPath, specifier, parentURL)
+ const english = dictionaries.get('en')
+ const manifestPath = english?.title === undefined || english.description === undefined
+ ? optionalResourcePath(`${specifier}/package.json`, parentURL)
+ : undefined
+ const manifest = manifestPath === undefined ? undefined : readObject(manifestPath)
+ const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier)
+ const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '')
+ if (title === undefined && description === undefined) return undefined
+ return {
+ ...title === undefined ? {} : { title },
+ ...description === undefined ? {} : { description },
+ }
+ } catch (error) {
+ return { error: `Plugin metadata for ${specifier}: ${String(error)}` }
+ }
+}
diff --git a/packages/boot/app-boot/src/profile-resolution/service.ts b/packages/boot/app-boot/src/profile-resolution/service.ts
index f0b92228dd..fad03a2582 100644
--- a/packages/boot/app-boot/src/profile-resolution/service.ts
+++ b/packages/boot/app-boot/src/profile-resolution/service.ts
@@ -4,6 +4,7 @@ import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import { Service, type Context } from '@deepseek-ai/cordis'
+import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
import {
barePackageName,
installRuntimeInterception,
@@ -11,6 +12,7 @@ import {
type RuntimeInterception,
} from './resolver.ts'
import type { RuntimeResolution } from '../profile.ts'
+import { readPluginMeta } from '../package-meta.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -101,6 +103,16 @@ export class PluginPackages extends Service {
if (!this.packages.has(key)) this.packages.set(key, readPackage(dir, name))
return this.packages.get(key)
}
+
+ /**
+ * Read display metadata without loading or activating the target plugin.
+ * @param specifier - configured package module, including package subpaths.
+ * @param parentURL - owning Loader tree's resolution base.
+ * @returns local display metadata or its diagnostic; undefined for non-package requests or absent metadata.
+ */
+ metaOf(specifier: string, parentURL: string): PluginLocalizedMeta | undefined {
+ return readPluginMeta(specifier, parentURL)
+ }
}
function packageDirFromParent(name: string, parentURL: string): string | undefined {
diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts
new file mode 100644
index 0000000000..07b9af374b
--- /dev/null
+++ b/packages/boot/app-boot/tests/package-meta.spec.ts
@@ -0,0 +1,327 @@
+/** Plugin locale resources resolve independently of entry execution and package display fields. */
+
+import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { syncBuiltinESMExports } from 'node:module'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { readPluginMeta, resolvePluginResource } from '../src/package-meta.ts'
+
+let root: string
+let dir: string
+let parentURL: string
+
+function file(path: string, text: string): void {
+ mkdirSync(dirname(path), { recursive: true })
+ writeFileSync(path, text)
+}
+
+function manifest(exports: object, extra: object = {}): void {
+ file(join(dir, 'package.json'), JSON.stringify({ name: 'localized', type: 'module', exports, ...extra }))
+}
+
+function dictionary(language: string, contents: unknown, directory = join(dir, 'locale')): void {
+ file(join(directory, `${language}.json`), JSON.stringify(contents))
+}
+
+beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'dsh-plugin-meta-'))
+ dir = join(root, 'node_modules', 'localized')
+ parentURL = pathToFileURL(join(root, 'entry.mjs')).href
+ manifest({ '.': './index.js', './locale/*.json': './locale/*.json' }, { description: 'Not local display text.' })
+ file(join(dir, 'index.js'), 'throw new Error("metadata must not execute the plugin")\n')
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ rmSync(root, { recursive: true, force: true })
+})
+
+describe('plugin locale display metadata', () => {
+ it.each([
+ 'C:\\plugins\\search.js',
+ 'C:/plugins/search.js',
+ '\\\\server\\share\\plugins\\search.js',
+ '//server/share/plugins/search.js',
+ '.\\plugins\\search.js',
+ './plugins/search.js',
+ '../plugins/search.js',
+ '/plugins/search.js',
+ 'file:///plugins/search.js',
+ 'file:///C:/plugins/search.js',
+ 'file://server/share/plugins/search.js',
+ ])('does not resolve metadata resources for plugin file address %s', (specifier) => {
+ const original = ModuleLoader.fromInternal
+ const resolver = vi.spyOn(ModuleLoader, 'fromInternal')
+ try {
+ expect(readPluginMeta(specifier, parentURL)).toBeUndefined()
+ expect(resolver).not.toHaveBeenCalled()
+ } finally {
+ resolver.mockRestore()
+ }
+ expect(ModuleLoader.fromInternal).toBe(original)
+ })
+
+ it('omits absent resources and absent meta fields without reading npm descriptions', () => {
+ expect(readPluginMeta('localized', parentURL)).toBeUndefined()
+ expect(readPluginMeta('absent', parentURL)).toBeUndefined()
+ expect(readPluginMeta('node:fs', parentURL)).toBeUndefined()
+ expect(readPluginMeta('fs', parentURL)).toBeUndefined()
+ expect(readPluginMeta('cordis:group', parentURL)).toBeUndefined()
+ dictionary('en', { other: { title: 'Not metadata' } })
+ expect(readPluginMeta('localized', parentURL)).toBeUndefined()
+ dictionary('en', { meta: {} })
+ expect(readPluginMeta('localized', parentURL)).toBeUndefined()
+ })
+
+ it('reads direct fields, retains per-field translations, and ignores other locale content', () => {
+ dictionary('en', { meta: { title: 'Team', description: 'Work together', ignored: false }, other: { nested: [1] } })
+ dictionary('zh', { meta: { title: '团队' } })
+ dictionary('pt-BR', { meta: { description: 'Trabalhar juntos' } })
+ file(join(dir, 'locale', 'ignored.txt'), 'not JSON')
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'Team', zh: '团队' },
+ description: { en: 'Work together', 'pt-br': 'Trabalhar juntos' },
+ })
+ })
+
+ it('treats percent markers as literal display text', () => {
+ dictionary('en', { meta: { title: '%title%', description: 'Plugin %name%' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: '%title%' }, description: { en: 'Plugin %name%' },
+ })
+ })
+
+ it('accepts description-only metadata and supplies the module name when a translated title has no English value', () => {
+ dictionary('en', { meta: { description: 'About it' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({ description: { en: 'About it' } })
+ dictionary('zh', { meta: { title: '标题' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'localized', zh: '标题' }, description: { en: 'About it' },
+ })
+ })
+
+ it('falls back to package fields when locale resources are absent', () => {
+ manifest({ '.': './index.js', './package.json': './package.json' }, { description: 'Package introduction' })
+ expect(readPluginMeta('localized', parentURL)).toEqual({ title: 'localized', description: 'Package introduction' })
+ })
+
+ it('falls back per field without replacing available locale translations', () => {
+ manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { description: 'Package introduction' })
+ dictionary('en', { meta: { title: 'English title' } })
+ dictionary('zh', { meta: { description: '中文介绍' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'English title' }, description: { en: 'Package introduction', zh: '中文介绍' },
+ })
+ dictionary('en', { meta: { description: 'English introduction' } })
+ dictionary('zh', { meta: { title: '中文标题' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'localized', zh: '中文标题' }, description: { en: 'English introduction' },
+ })
+ dictionary('en', {})
+ dictionary('zh', {})
+ expect(readPluginMeta('localized', parentURL)).toEqual({ title: 'localized', description: 'Package introduction' })
+ })
+
+ it('retains non-English descriptions with an empty final English fallback', () => {
+ dictionary('en', { meta: { title: 'Plugin' } })
+ dictionary('zh', { meta: { description: '中文介绍' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'Plugin' }, description: { en: '', zh: '中文介绍' },
+ })
+ })
+
+ it('uses only the package resource exported for the requested plugin address', () => {
+ manifest({
+ './package.json': './package.json',
+ './search/package.json': './search-manifest.json',
+ }, { description: 'Whole package' })
+ file(join(dir, 'search-manifest.json'), JSON.stringify({ name: 'Search plugin', description: 'Search introduction' }))
+ expect(readPluginMeta('localized/search', parentURL)).toEqual({ title: 'Search plugin', description: 'Search introduction' })
+ expect(readPluginMeta('localized/review', parentURL)).toBeUndefined()
+ })
+
+ it.each([{}, { name: '', description: ' ' }, { name: false, description: null }])('ignores unavailable package text: %j', (fields) => {
+ manifest({ './feature/package.json': './feature.json' })
+ file(join(dir, 'feature.json'), JSON.stringify(fields))
+ expect(readPluginMeta('localized/feature', parentURL)).toBeUndefined()
+ })
+
+ it('reports malformed fallback files', () => {
+ manifest({ './feature/package.json': './feature.json' })
+ file(join(dir, 'feature.json'), '{')
+ expect(readPluginMeta('localized/feature', parentURL)?.error).toContain('feature.json')
+ })
+
+ it('does not hide invalid locale fields behind package text', () => {
+ manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { description: 'Package description' })
+ dictionary('en', { meta: { title: false } })
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string')
+ })
+
+ it('does not inspect package fallback when both English fields are supplied', () => {
+ manifest({ './locale/*.json': './locale/*.json', './package.json': './fallback.json' })
+ file(join(dir, 'fallback.json'), '{')
+ dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } })
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'Plugin' }, description: { en: 'Locale introduction' },
+ })
+ })
+
+ it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => {
+ manifest({
+ './search': './lib/search.js', './review': './lib/review.js',
+ './search/locale/*.json': './resources/search/*.json',
+ './review/locale/*.json': './resources/review/*.json',
+ './locale/*.json': './locale/*.json',
+ })
+ file(join(dir, 'lib', 'search.js'), 'throw new Error("must not execute search")\n')
+ file(join(dir, 'lib', 'review.js'), 'throw new Error("must not execute review")\n')
+ dictionary('en', { meta: { title: 'Whole package' } })
+ dictionary('en', { meta: { title: 'Search' } }, join(dir, 'resources', 'search'))
+ dictionary('zh', { meta: { title: '搜索' } }, join(dir, 'resources', 'search'))
+ dictionary('en', { meta: { title: 'Review' } }, join(dir, 'resources', 'review'))
+ expect(readPluginMeta('localized/search', parentURL)).toEqual({ title: { en: 'Search', zh: '搜索' } })
+ expect(readPluginMeta('localized/review', parentURL)).toEqual({ title: { en: 'Review' } })
+ expect(readPluginMeta('localized/private', parentURL)).toBeUndefined()
+ })
+
+ it('reads scoped package roots and subexports at their complete addresses', () => {
+ const scoped = join(root, 'node_modules', '@scope', 'localized')
+ file(join(scoped, 'package.json'), JSON.stringify({
+ name: '@scope/localized',
+ exports: { './locale/*.json': './locale/*.json', './search/locale/*.json': './search-locale/*.json' },
+ }))
+ dictionary('en', { meta: { title: 'Scoped package' } }, join(scoped, 'locale'))
+ dictionary('en', { meta: { title: 'Scoped search' } }, join(scoped, 'search-locale'))
+ expect(readPluginMeta('@scope/localized', parentURL)).toEqual({ title: { en: 'Scoped package' } })
+ expect(readPluginMeta('@scope/localized/search', parentURL)).toEqual({ title: { en: 'Scoped search' } })
+ })
+
+ it('uses ESM import conditions for locale exports without loading JSON modules', () => {
+ manifest({ './locale/*.json': { import: './esm/*.json', require: './cjs/*.json' } })
+ dictionary('en', { meta: { title: 'ESM' } }, join(dir, 'esm'))
+ dictionary('en', { meta: { title: 'CJS' } }, join(dir, 'cjs'))
+ expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'ESM' } })
+ })
+
+ it('does not read unexported language files directly from the resolved English directory', () => {
+ manifest({ './locale/en.json': './locale/en.json' })
+ dictionary('en', { meta: { title: 'English' } })
+ dictionary('zh', { meta: { title: '中文' } })
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('./locale/zh.json')
+ })
+
+ it('reports language resources mapped outside their English directory', () => {
+ manifest({ './locale/*.json': './locale/*.json', './locale/zh.json': './separate/zh.json' })
+ dictionary('en', { meta: { title: 'English' } })
+ dictionary('zh', { meta: { title: 'Local Chinese' } })
+ dictionary('zh', { meta: { title: 'Mapped Chinese' } }, join(dir, 'separate'))
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('must share the English locale directory')
+ })
+
+ it('resolves same-named packages independently under each importing parent', () => {
+ dictionary('en', { meta: { title: 'First' } })
+ const second = join(root, 'second', 'node_modules', 'localized')
+ file(join(second, 'package.json'), JSON.stringify({ name: 'localized', exports: { './locale/*.json': './locale/*.json' } }))
+ dictionary('en', { meta: { title: 'Second' } }, join(second, 'locale'))
+ const secondParent = pathToFileURL(join(root, 'second', 'entry.mjs')).href
+ expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'First' } })
+ expect(readPluginMeta('localized', secondParent)).toEqual({ title: { en: 'Second' } })
+ })
+
+ it.each([null, [], false, 1, 'text'])('reports malformed meta: %j', (meta) => {
+ dictionary('en', { meta })
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('meta must be an object')
+ })
+
+ it.each([null, false, 1, {}, '', ' '])('reports a malformed display field: %j', (title) => {
+ dictionary('en', { meta: { title } })
+ const localeFile = resolvePluginResource('localized/locale/en.json', parentURL)
+ expect(readPluginMeta('localized', parentURL)?.error).toContain(localeFile + ': meta.title must be a non-empty string')
+ })
+
+ it.each([[], null, 'text'])('rejects a non-object locale document: %j', (contents) => {
+ dictionary('en', contents)
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('en.json must be an object')
+ })
+
+ it('reports malformed JSON and invalid language filenames', () => {
+ dictionary('en', { meta: { title: 'Title' } })
+ file(join(dir, 'locale', 'zh.json'), '{')
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('zh.json')
+ rmSync(join(dir, 'locale', 'zh.json'))
+ dictionary('not_a_language', { meta: { title: 'Title' } })
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('not_a_language.json must use a language id')
+ })
+
+ it('reports invalid export targets instead of treating them as absent metadata', () => {
+ manifest({ './locale/*.json': '../outside/*.json' })
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('Invalid "exports" target')
+ })
+
+ it('reports an unavailable Node resolver without changing plugin state', () => {
+ vi.spyOn(ModuleLoader, 'fromInternal').mockReturnValue(undefined)
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('requires the Node module resolver')
+ })
+
+ it.each(['v1', 'v2'] as const)('uses the %s Node resolver argument order', (version) => {
+ const loader = ModuleLoader.fromInternal()!
+ dictionary('en', { meta: { title: 'Resolved resource', description: 'Resolved introduction' } })
+ const resolveSync = vi.fn(() => ({ url: pathToFileURL(join(dir, 'locale', 'en.json')).href }))
+ const adapted = new Proxy(loader, {
+ get(target, property) {
+ if (property === 'version') return version
+ if (property === 'resolveSync') return resolveSync
+ const value: unknown = Reflect.get(target, property)
+ return value
+ },
+ })
+ vi.spyOn(ModuleLoader, 'fromInternal').mockReturnValue(adapted)
+ expect(readPluginMeta('localized', parentURL)).toEqual({
+ title: { en: 'Resolved resource' }, description: { en: 'Resolved introduction' },
+ })
+ const specifier = 'localized/locale/en.json'
+ expect(resolveSync.mock.calls).toEqual(version === 'v1'
+ ? [[specifier, parentURL, {}], [specifier, parentURL, {}]]
+ : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]])
+ })
+
+ it('rejects case-equivalent language names in a directory listing', () => {
+ dictionary('en', { meta: { title: 'Title' } })
+ const localeDir = dirname(resolvePluginResource('localized/locale/en.json', parentURL))
+ const original = fs.readdirSync
+ const english = original(localeDir, { withFileTypes: true })[0]!
+ const duplicate = original(localeDir, { withFileTypes: true })[0]!
+ duplicate.name = 'EN.json'
+ // Case-insensitive filesystems cannot store both names in the same directory.
+ const listing = vi.spyOn(fs, 'readdirSync')
+ try {
+ listing.mockImplementation(new Proxy(original, {
+ apply(target, receiver: unknown, args: unknown[]): unknown {
+ if (args[0] === localeDir) return [english, duplicate]
+ return Reflect.apply(target, receiver, args)
+ },
+ }))
+ syncBuiltinESMExports()
+ expect(fs.readdirSync(dir, { withFileTypes: true })).toEqual(original(dir, { withFileTypes: true }))
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('localized/locale/EN.json duplicates locale en')
+ } finally {
+ listing.mockRestore()
+ syncBuiltinESMExports()
+ }
+ expect(fs.readdirSync).toBe(original)
+ expect(readdirSync).toBe(original)
+ expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Title' } })
+ })
+
+ it('rejects case-equivalent language files on case-sensitive filesystems', (context) => {
+ dictionary('en', { meta: { title: 'Title' } })
+ dictionary('EN', { meta: { title: 'Other' } })
+ // Case-insensitive filesystems cannot hold both filenames.
+ if (readdirSync(join(dir, 'locale')).length !== 2) context.skip()
+ expect(readPluginMeta('localized', parentURL)?.error).toContain('duplicates locale en')
+ })
+})
diff --git a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts
index 5d7f34b876..1be59df4f1 100644
--- a/packages/boot/app-boot/tests/profile-resolution-service.spec.ts
+++ b/packages/boot/app-boot/tests/profile-resolution-service.spec.ts
@@ -29,7 +29,11 @@ function pkg(dir: string, version: string, name = 'metadata-lib'): string {
name,
version,
type: 'module',
- exports: { import: './index.js', require: './index.cjs' },
+ exports: {
+ '.': { import: './index.js', require: './index.cjs' },
+ './feature': './index.js',
+ './feature/locale/*.json': './feature-locale/*.json',
+ },
}))
file(join(dir, 'index.js'), `export const version = ${JSON.stringify(version)}\n`)
file(join(dir, 'index.cjs'), `exports.version = ${JSON.stringify(version)}\n`)
@@ -48,6 +52,27 @@ function resolution(
}
describe('profile package metadata service', () => {
+ it('reads translated metadata from the selected local package without importing its entry', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'dsh-localized-package-service-'))
+ roots.push(root)
+ const packageDir = join(root, 'node_modules', 'localized')
+ file(join(packageDir, 'package.json'), JSON.stringify({
+ name: 'localized', exports: { './private': './index.js', './private/locale/*.json': './locale/*.json' },
+ }))
+ file(join(packageDir, 'index.js'), 'throw new Error("must not execute")\n')
+ file(join(packageDir, 'locale', 'en.json'), '{"meta":{"title":"Local plugin"}}')
+ file(join(packageDir, 'locale', 'zh.json'), '{"meta":{"title":"本地插件"}}')
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(PluginPackages)
+ const parent = pathToFileURL(join(root, 'entry.mjs')).href
+ expect(ctx.pluginPackages.metaOf('localized/private', parent)).toEqual({ title: { en: 'Local plugin', zh: '本地插件' } })
+ expect(ctx.pluginPackages.metaOf('localized', parent)).toBeUndefined()
+ expect(ctx.pluginPackages.metaOf('node:fs', parent)).toBeUndefined()
+ file(join(root, 'node_modules', 'invalid', 'package.json'), '{')
+ expect(ctx.pluginPackages.metaOf('invalid', parent)?.error).toContain('invalid')
+ })
+
it('resolves module URLs and package metadata through the current resolution', async () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-profile-package-service-'))
roots.push(root)
@@ -55,6 +80,7 @@ describe('profile package metadata service', () => {
const profileDir = join(profilesDir, 'test')
const first = join(root, 'first')
const firstAnchor = pkg(first, '1.0.0')
+ file(join(first, 'feature-locale', 'en.json'), '{"meta":{"title":"Profile feature"}}')
file(join(profileDir, 'entry.mjs'), '')
const parentURL = pathToFileURL(join(profileDir, 'entry.mjs')).href
@@ -72,6 +98,7 @@ describe('profile package metadata service', () => {
})
const require = createRequire(join(profileDir, 'entry.cjs'))
expect(require.resolve('metadata-lib')).toBe(realpathSync(join(first, 'index.cjs')))
+ expect(ctx.pluginPackages.metaOf('metadata-lib/feature', parentURL)).toEqual({ title: { en: 'Profile feature' } })
expect(ctx.pluginPackages.packageOf('node:fs', parentURL)).toBeUndefined()
expect(ctx.pluginPackages.packageOf('./local.js', parentURL)).toBeUndefined()
diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml
index c0468fb558..a55cf74eee 100644
--- a/packages/boot/plugin-manager/README.i18n.yaml
+++ b/packages/boot/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/boot/plugin-manager/README.md
-README.md: d856a5db5d2dd05a375cbeb863758b66600fcd6e
-README.zh.md: 1ab2d4de95f9af27d113faa844cb3bf42b06418e
+README.md: d937d265a7942f824f5f4a02c0a5eaba62208994
+README.zh.md: 388a2e249613299753ae63c3c20d2ca89311dc4f
diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md
index d856a5db5d..d937d265a7 100644
--- a/packages/boot/plugin-manager/README.md
+++ b/packages/boot/plugin-manager/README.md
@@ -41,6 +41,8 @@ A plugin toggle updates only `disabled` in the last matching override in the pro
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
+`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. The `plugin_manager` tool omits UI display metadata from list results.
+
`inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale.
diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md
index 1ab2d4de95..388a2e2496 100644
--- a/packages/boot/plugin-manager/README.zh.md
+++ b/packages/boot/plugin-manager/README.zh.md
@@ -41,6 +41,8 @@ kind: "package-reference"
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
+`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。
+
`inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。
diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json
index b0a5065400..ccbb9439ef 100644
--- a/packages/boot/plugin-manager/package.json
+++ b/packages/boot/plugin-manager/package.json
@@ -86,6 +86,7 @@
}
},
"devDependencies": {
+ "@deepseek-ai/dsh-package-manifest": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts
index e470ccdc93..e03b361dc7 100644
--- a/packages/boot/plugin-manager/src/index.ts
+++ b/packages/boot/plugin-manager/src/index.ts
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { Context } from '@deepseek-ai/cordis'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -11,7 +12,8 @@ import z from '@deepseek-ai/schemastery'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
import {
- readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES,
+ readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
+ reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES,
} from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-hmr'
import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
@@ -232,8 +234,8 @@ export class PluginManager extends TypertRemoteService {
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
- * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
- * bundle, and removal availability.
+ * @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
+ * whether the installation offers the bundle, and removal availability.
*/
@Remote
listBundles(): Promise {
@@ -256,8 +258,11 @@ export class PluginManager extends TypertRemoteService {
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] })
continue
}
+ const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
+ const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href)
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
+ ...meta === undefined ? {} : { meta },
enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
...this.declaredRows(name, info) })
@@ -557,16 +562,23 @@ export class PluginManager extends TypertRemoteService {
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch))
// One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
- const live = new Map()
+ const live = new Map()
for (const entry of this.ctx.loader.entries()) {
/* v8 ignore next -- the Loader gives every entry an id before it is listed */
- if (typeof entry.options.id === 'string') live.set(entry.options.id, pluginEntryId(entry.id))
+ if (typeof entry.options.id === 'string') live.set(entry.options.id, {
+ entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl,
+ })
}
const rows: BundleRowInfo[] = []
+ const packages = this.ctx.get('pluginPackages')
for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
if (typeof row.id !== 'string' || typeof row.name !== 'string') continue
- const entryId = live.get(row.id)
- rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } })
+ const active = live.get(row.id)
+ const entryId = active?.entryId
+ const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href
+ const meta = packages?.metaOf(row.name, base)
+ rows.push({ rowId: row.id, moduleName: row.name,
+ ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } })
}
const declared = new Set(rows.map(row => row.rowId))
const overrides = [...new Set(patches.flatMap(item =>
diff --git a/packages/boot/plugin-manager/src/tools.ts b/packages/boot/plugin-manager/src/tools.ts
index a5b1cd6837..2b65dd8bc2 100644
--- a/packages/boot/plugin-manager/src/tools.ts
+++ b/packages/boot/plugin-manager/src/tools.ts
@@ -49,7 +49,10 @@ export function apply(ctx: Context): void {
throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100')
}
const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles()
- const entries = rows.slice(offset, offset + limit)
+ const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) =>
+ 'rows' in row
+ ? { ...row, rows: row.rows.map(({ meta: _rowMeta, ...declared }) => declared) }
+ : row)
return JSON.stringify({ entries, total: rows.length,
nextOffset: offset + entries.length < rows.length ? offset + entries.length : null })
}
diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts
index 9506dde749..7743b4f35f 100644
--- a/packages/boot/plugin-manager/src/types.ts
+++ b/packages/boot/plugin-manager/src/types.ts
@@ -1,5 +1,6 @@
/** Public plugin management records shared with clients. */
import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
@@ -25,6 +26,8 @@ export interface BundleRowInfo {
rowId: string
/** The module the row names. */
moduleName: string
+ /** Local package display metadata, including rows whose bundle is disabled. */
+ meta?: PluginLocalizedMeta
/** The Loader entry carrying this row, when exactly one live entry has its id. */
entryId?: PluginEntryId
}
@@ -33,7 +36,9 @@ export interface BundleRowInfo {
export interface BundleInfo {
name: string
version?: string
- /** `description` of the package manifest. */
+ /** Local display text with available translations or literal fallbacks, or a metadata diagnostic. */
+ meta?: PluginLocalizedMeta
+ /** Untranslated `description` of this bundle's package manifest. */
description?: string
/** Selected in the profile manifest; a load error means its layer was skipped. */
enabled: boolean
diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts
index 028c28917e..19966e47b8 100644
--- a/packages/boot/plugin-manager/tests/manager.spec.ts
+++ b/packages/boot/plugin-manager/tests/manager.spec.ts
@@ -1,5 +1,5 @@
/** Persistent manager behavior through a real profile Include and Loader. */
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -8,8 +8,8 @@ import type { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import {
boot, composeEntries, initProfile, loadProfileDirectory, readProfilePatches, readProfileManifest,
- reconcileProfilePatches, OPTIONAL_BUNDLES,
- type ProfileContext,
+ reconcileProfilePatches, OPTIONAL_BUNDLES, PluginPackages, readPluginMeta,
+ type ProfileContext, type RuntimeResolution,
} from '@deepseek-ai/dsh-app-boot'
import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
import Hmr from '@deepseek-ai/dsh-hmr'
@@ -20,8 +20,11 @@ import * as operations from '../src/operations.ts'
import { parse, parseDocument } from 'yaml'
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
+ const temporaryHome = mkdtempSync(join(tmpdir(), 'plugin-manager-'))
+ let owner: Context | undefined
+ onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(temporaryHome, { recursive: true, force: true }) })
// pnpm resolves workspace roots through native realpath, including Windows 8.3 aliases.
- const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-')))
+ const home = await realpath(temporaryHome)
const dir = join(home, 'profiles', 'test')
const anchor = join(home, 'package.json')
writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
@@ -49,12 +52,12 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
overlays, telemetryDisabledEnv: undefined,
}
const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => {
+ owner = ctx
ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } })
prepare?.(ctx)
ctx.provide('profileContext', profile)
ctx.loader.builtins.manager = PluginManager
})
- onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
// What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry.
const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/')
onTestFinished(() => { registry.mockRestore() })
@@ -136,10 +139,12 @@ it('lists bundle versions and current-profile plugin targets', async () => {
expect(await manager.listBundles()).toEqual([
{
name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required',
+ meta: { title: 'core' },
rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
},
{
name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true,
+ meta: { title: 'extra' },
rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [],
},
])
@@ -161,6 +166,7 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live
const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href
expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({
name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true,
+ meta: { title: 'described', description: 'Describes itself.' },
rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'],
})
await manager.setBundleEnabled('described', true)
@@ -172,6 +178,90 @@ it('describes a bundle by its manifest and patch: one-liner, rows without a live
expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }])
})
+it.each(['native', 'runtime'] as const)('reads a disabled bundle and its independent exported plugins without running them with %s resolution', async (mode) => {
+ const { ctx, manager, dir, bundle, profile } = await fixture('startup')
+ bundle('localized', [{ id: 'first', name: 'local-child/first' }, { id: 'second', name: 'local-child/second' }])
+ const root = join(dir, 'node_modules', 'localized')
+ const child = mode === 'runtime'
+ ? join(profile.home, 'installation', 'node_modules', 'local-child')
+ : join(root, 'node_modules', 'local-child')
+ mkdirSync(join(root, 'locale'), { recursive: true })
+ mkdirSync(join(child, 'first-locale'), { recursive: true })
+ mkdirSync(join(child, 'second-locale'), { recursive: true })
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'localized',
+ exports: { './locale/*.json': './locale/*.json' }, dsh: {
+ bundle: { patch: './cordis.patch.yml' },
+ } }))
+ writeFileSync(join(root, 'locale', 'en.json'), '{"meta":{"title":"Local bundle"}}')
+ writeFileSync(join(root, 'locale', 'zh.json'), '{"meta":{"title":"本地组合包"}}')
+ writeFileSync(join(child, 'package.json'), JSON.stringify({ name: 'local-child', exports: {
+ './first': './index.js', './second': './index.js',
+ './first/locale/*.json': './first-locale/*.json', './second/locale/*.json': './second-locale/*.json',
+ } }))
+ writeFileSync(join(child, 'index.js'), 'throw new Error("must not execute")\n')
+ writeFileSync(join(child, 'first-locale', 'en.json'), '{"meta":{"title":"First plugin"}}')
+ writeFileSync(join(child, 'second-locale', 'en.json'), '{"meta":{"title":"Second plugin"}}')
+ const manifest = readProfileManifest('test', dir)
+ manifest.dependencies = { ...manifest.dependencies, localized: '1.0.0' }
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+ const parentURL = pathToFileURL(join(root, 'package.json')).href
+ const readChildren = () => ['first', 'second'].map(part => readPluginMeta(`local-child/${part}`, parentURL))
+ const expectUnlinked = () => {
+ expect(lstatSync(child).isDirectory()).toBe(true)
+ for (const path of [
+ join(root, 'node_modules'), join(dir, 'node_modules', 'local-child'),
+ join(profile.home, 'profiles', 'node_modules'), join(profile.home, 'node_modules'),
+ join(dir, '.dsh-module-fallback'),
+ ]) expect(lstatSync(path, { throwIfNoEntry: false }), path).toBeUndefined()
+ }
+ const resolution: RuntimeResolution = {
+ profilesDir: join(profile.home, 'profiles'), profileDir: dir,
+ localPackageNames: Object.keys(manifest.dependencies),
+ entries: [{ name: 'local-child', packageDir: child, version: undefined,
+ declarer: join(child, 'package.json'), scope: 'installation' }],
+ }
+ if (mode === 'runtime') {
+ expectUnlinked()
+ expect(readChildren()).toEqual([undefined, undefined])
+ }
+ await ctx.plugin(PluginPackages, mode === 'runtime' ? { resolution } : {})
+ const result = (await manager.listBundles()).find(row => row.name === 'localized')
+ expect(result).toMatchObject({ enabled: false, meta: { title: { en: 'Local bundle', zh: '本地组合包' } }, rows: [
+ { rowId: 'first', moduleName: 'local-child/first', meta: { title: { en: 'First plugin' } } },
+ { rowId: 'second', moduleName: 'local-child/second', meta: { title: { en: 'Second plugin' } } },
+ ] })
+ expect(result?.rows[0]?.entryId).toBeUndefined()
+ expect(readPluginMeta('local-child/private', parentURL)).toBeUndefined()
+ if (mode === 'runtime') {
+ expectUnlinked()
+ await ctx.fiber.dispose()
+ expect(readChildren()).toEqual([undefined, undefined])
+ expectUnlinked()
+ }
+})
+
+it.each([
+ { resources: 'exported', exports: { './locale/*.json': './locale/*.json' }, expected: { meta: { title: { en: 'Nameless bundle' } } } },
+ { resources: 'private', exports: {}, expected: {} },
+])('lists a bundle without a manifest name with $resources locale resources', async ({ exports, expected }) => {
+ const { manager, dir, bundle } = await fixture('startup')
+ bundle('unnamed', [])
+ const root = join(dir, 'node_modules', 'unnamed')
+ mkdirSync(join(root, 'locale'))
+ writeFileSync(join(root, 'package.json'), JSON.stringify({
+ version: '1.0.0', exports, dsh: { bundle: { patch: './cordis.patch.yml' } },
+ }))
+ writeFileSync(join(root, 'locale', 'en.json'), '{"meta":{"title":"Nameless bundle"}}')
+ const manifest = readProfileManifest('test', dir)
+ manifest.dependencies = { ...manifest.dependencies, unnamed: '1.0.0' }
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+
+ expect((await manager.listBundles()).find(row => row.name === 'unnamed')).toEqual({
+ name: 'unnamed', version: '1.0.0', enabled: false, installed: true, optional: false, removable: true,
+ rows: [], overrides: [], ...expected,
+ })
+})
+
it('turns a plugin off and on without duplicating patch overrides', async () => {
const { manager, dir } = await fixture()
const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
@@ -767,6 +857,7 @@ it('offers the launcher\'s optional bundles switched off and never removable', a
writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { [offered]: '3.0.0' } }))
expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({
name: offered, version: '3.0.0', description: 'Package one-liner.',
+ meta: { title: offered, description: 'Package one-liner.' },
enabled: false, installed: false, optional: true, removable: false,
rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [],
})
diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts
index c3656f4fd5..da2221433d 100644
--- a/packages/boot/plugin-manager/tests/tools.spec.ts
+++ b/packages/boot/plugin-manager/tests/tools.spec.ts
@@ -185,6 +185,21 @@ it('paginates inventories with an explicit continuation and total', async () =>
expect(resultText(await call({ action: 'list_bundles' }))).toContain('"name":"bundle"')
})
+it('keeps UI translation metadata out of model-facing plugin and bundle lists', async () => {
+ const { call, manager } = await fixture()
+ const meta = { title: { en: 'Plugin', zh: '插件' }, error: 'UI-only diagnostic' }
+ manager.listPlugins.mockImplementationOnce(async () => [{ entryId: 'include:plugin', enabled: true, meta }])
+ expect(JSON.parse(resultText(await call({ action: 'list_plugins' })))).toEqual({
+ entries: [{ entryId: 'include:plugin', enabled: true }], total: 1, nextOffset: null,
+ })
+ manager.listBundles.mockImplementationOnce(async () => [{
+ name: 'bundle', enabled: true, meta, rows: [{ rowId: 'plugin', moduleName: 'plugin', meta }],
+ }])
+ expect(JSON.parse(resultText(await call({ action: 'list_bundles' })))).toEqual({
+ entries: [{ name: 'bundle', enabled: true, rows: [{ rowId: 'plugin', moduleName: 'plugin' }] }], total: 1, nextOffset: null,
+ })
+})
+
it('forwards all mutation actions and renders the returned outcome', async () => {
const { call, manager } = await fixture()
await call({ action: 'set_plugin', target: 'include:1', enabled: false })
diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json
index 32b49b766e..ba181795a1 100644
--- a/packages/boot/plugin-manager/tsconfig.json
+++ b/packages/boot/plugin-manager/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../util/package-manifest"
+ },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml
index 14b3339b26..4865a1b040 100644
--- a/packages/client/locale/README.i18n.yaml
+++ b/packages/client/locale/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/locale/README.md
-README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284
-README.zh.md: 1b7a7b7de48843961f3cbac3f4ee99cff641d212
+README.md: 3d3771bf1cf61adf7048b5660cab6394af9cd9a0
+README.zh.md: d427c388de1b3b1e82635fbe39d3bb43b7515488
diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md
index 56a9cff9c3..3d3771bf1c 100644
--- a/packages/client/locale/README.md
+++ b/packages/client/locale/README.md
@@ -35,6 +35,10 @@ Open Settings → General and select a registered language. The active locale is
Call `ctx.locale.register(ns, { zh, en })` with a namespace merged into `LocaleNamespaceMap`; the compiler checks every key against the namespace's typed key union and requires both shipped locales. Consumers translate through `ctx.locale.bind(ns)` or the framework-injected `t` seat. A dictionary registered after the UI is already mounted is picked up without a remount.
+### Resolving package text
+
+Use `ctx.locale.resolveText(text)` for [`LocalizedText`](../../util/package-manifest/README.md), such as installed plugin titles and descriptions. Literal strings are returned unchanged. Translation maps use lowercase language ids, require an `en` fallback, and follow the active language's declared fallback chain. They do not consult or register namespace dictionaries.
+
### Registering a language pack
An external client plugin registers the language definition and each translated namespace as owned effects; definitions and dictionaries may register in either order:
diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md
index 1b7a7b7de4..d427c388de 100644
--- a/packages/client/locale/README.zh.md
+++ b/packages/client/locale/README.zh.md
@@ -35,6 +35,10 @@ kind: "package-reference"
用已合并进 `LocaleNamespaceMap` 的命名空间调用 `ctx.locale.register(ns, { zh, en })`;编译器会对照该命名空间的类型化键并集检查每个键,并要求两个内置 locale 齐全。消费方通过 `ctx.locale.bind(ns)` 或框架注入的 `t` 席位翻译。UI 已挂载后再注册的字典无需重新挂载即可生效。
+### 解析包文本
+
+使用 `ctx.locale.resolveText(text)` 解析 [`LocalizedText`](../../util/package-manifest/README.zh.md),例如已安装插件的标题与描述。字面字符串原样返回。翻译映射使用小写语言 id,必须提供 `en` 回退值,并沿当前语言声明的回退链查找。它们不查询或注册命名空间字典。
+
### 注册语言包
外部 client 插件把语言定义和每个已翻译命名空间注册为自身拥有的 effect;定义与字典可以按任意顺序注册:
diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json
index b75b622d52..7a40d48b79 100644
--- a/packages/client/locale/package.json
+++ b/packages/client/locale/package.json
@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+ "@deepseek-ai/dsh-package-manifest": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"@deepseek-ai/dsh-client-connection": "workspace:^",
diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts
index 8370696886..cd1ef5cac1 100644
--- a/packages/client/locale/src/client/index.ts
+++ b/packages/client/locale/src/client/index.ts
@@ -5,6 +5,7 @@
* its own settings surface.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import {
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -200,6 +201,20 @@ export class LocaleRuntime {
return this.snapshot
}
+ /**
+ * Resolve package text through the active language's declared fallback chain.
+ * Plain strings stay verbatim; maps do not consult registered dictionaries.
+ * @param text - package text whose locale keys are lowercase and include English.
+ * @returns the first available translation, including an empty string.
+ */
+ resolveText(text: LocalizedText): string {
+ if (typeof text === 'string') return text
+ return this.fallbackChain(this.snapshot.active).reduceRight(
+ (resolved, locale) => text[localeKey(locale)] ?? resolved,
+ text.en,
+ )
+ }
+
/**
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
* reference between changes, uSES-safe).
diff --git a/packages/client/locale/tests/localized-text.client.spec.ts b/packages/client/locale/tests/localized-text.client.spec.ts
new file mode 100644
index 0000000000..a567962ae7
--- /dev/null
+++ b/packages/client/locale/tests/localized-text.client.spec.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { LocaleRuntime } from '../src/client/index.ts'
+
+function make() {
+ const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
+ return { ctx, locale: new LocaleRuntime(ctx) }
+}
+
+describe('package text resolution', () => {
+ it('keeps strings verbatim without common lookup or interpolation', () => {
+ const { ctx, locale } = make()
+ ctx.effect(() => locale.register('common', 'zh', { retry: '重试' }), 'test: common dictionary')
+ locale.setLocale('zh')
+
+ expect(locale.resolveText('retry')).toBe('retry')
+ expect(locale.resolveText('Hello, {name}')).toBe('Hello, {name}')
+ expect(locale.resolveText('')).toBe('')
+ expect(locale.resolveText({ en: 'retry' })).toBe('retry')
+ })
+
+ it('resolves each field in the current locale without registering map languages', () => {
+ const { locale } = make()
+ const title = { en: 'Tools', zh: '工具', ja: 'ツール' }
+ locale.setLocale('zh')
+ const snapshot = locale.getSnapshot()
+
+ expect(locale.resolveText(title)).toBe('工具')
+ expect(locale.resolveText({ en: 'English description' })).toBe('English description')
+ expect(locale.resolveText({ en: 'Fallback', zh: '' })).toBe('')
+ expect(locale.getSnapshot()).toBe(snapshot)
+ expect(snapshot.locales.map(item => item.id)).toEqual(['zh', 'en'])
+
+ locale.setLocale('en')
+ expect(locale.resolveText(title)).toBe('Tools')
+ })
+
+ it('walks registered regional fallbacks using lowercase map keys', () => {
+ const { ctx, locale } = make()
+ ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: French')
+ ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French')
+ locale.setLocale('FR-ca')
+
+ expect(locale.resolveText({ en: 'Tools', fr: 'Outils', 'fr-ca': 'Outils Québec' })).toBe('Outils Québec')
+ expect(locale.resolveText({ en: 'Tools', fr: 'Outils' })).toBe('Outils')
+ expect(locale.resolveText({ en: 'Tools', zh: '工具' })).toBe('Tools')
+ })
+
+ it('uses English across an unloaded fallback and follows its replacement', () => {
+ const { ctx, locale } = make()
+ const removeFrench = locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' })
+ ctx.effect(() => removeFrench, 'test: French')
+ ctx.effect(() => locale.addLanguage({ id: 'fr-CA', label: 'Français (Canada)', fallback: 'fr' }), 'test: Canadian French')
+ locale.setLocale('fr-CA')
+ const text = { en: 'Tools', fr: 'Outils' }
+
+ expect(locale.resolveText(text)).toBe('Outils')
+ removeFrench()
+ expect(locale.resolveText(text)).toBe('Tools')
+ ctx.effect(() => locale.addLanguage({ id: 'fr', label: 'Français', fallback: 'en' }), 'test: replacement French')
+ expect(locale.resolveText(text)).toBe('Outils')
+ })
+})
diff --git a/packages/client/locale/tsconfig.json b/packages/client/locale/tsconfig.json
index 8737cc0c01..0d4fa48c3f 100644
--- a/packages/client/locale/tsconfig.json
+++ b/packages/client/locale/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../util/package-manifest"
+ },
{
"path": "../../../vendor/schemastery"
},
diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml
index b5d3e468b8..dfbe7fcedb 100644
--- a/packages/client/ui-plugin-manager/README.i18n.yaml
+++ b/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: da6571598bbb2ef613b0f5d885339bb167e3df8d
-README.zh.md: 7c5bcaa12650a361b4b0e933ac40ea58a9222c42
+README.md: d7bcd2299d93dab90c8440565ac87112b37c9c10
+README.zh.md: 368058dabc27968b86088c34ee387dc5dd5bd364
diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md
index da6571598b..d7bcd2299d 100644
--- a/packages/client/ui-plugin-manager/README.md
+++ b/packages/client/ui-plugin-manager/README.md
@@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
-The Agent Teams and Auto Authorization Review packages have localized names and descriptions that follow the UI language. Their detail pages retain the full npm package name; other packages display their short package name and original description.
+Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information.
### Installing a bundle
@@ -41,11 +41,11 @@ A bundle's page shows its full package name under the title, the spec that insta
### Switching one row of a bundle
-A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids.
+A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over localized titles, descriptions, row ids, and module names.
### Configuration pages
-A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page asks every entry for two views through its owner props: `view: 'summary'` for the one-liner under the title, `view: 'page'` for the form with its own save control. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way:
+A plugin that carries its own configuration renders it on this page rather than in Settings, through three slots the page declares: `plugins.item` (list) for an official plugin, listed in the Official group by its `label`; `plugins.bundle.config` (keyed by the bundle's package name) for a bundle's own configuration, shown on the bundle's page between its description and its rows; and `plugins.row.config` (keyed by `#`) for one row's configuration, which gives that row a **Configure** control opening the row's page. The page renders `view: 'page'` for forms with their own save controls. Official plugin cards also render `view: 'summary'` under the title; a row's detail page uses that view only when its package description is absent. Only a save writes: the page draws the title, icon, and crumb, and the entry's form drops its staged edits when the page is left. The four host-plane pages the installation ships — the shell executor, the agent loop, subagent model selection, and the DeepSeek search provider — come from [ui-settings-plugins](../ui-settings-plugins/README.md), registered while the Host serves their namespaces. A bundle's browser half registers the same way:
```tsx ignore-check
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md
index 7c5bcaa126..368058dabc 100644
--- a/packages/client/ui-plugin-manager/README.zh.md
+++ b/packages/client/ui-plugin-manager/README.zh.md
@@ -27,7 +27,7 @@ kind: "package-reference"
在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
-Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换的本地化名称和描述。详情页保留完整 npm 包名;其他包显示简写包名和原始描述。
+已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。
### 安装一个组合包
@@ -41,11 +41,11 @@ Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换
### 切换组合包里的一行
-组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。
+组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按本地化标题、描述、行 id 和模块名筛选的输入框。
### 配置页
-自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面通过 owner props 向每个条目索取两种视图:`view: 'summary'` 是标题下的一句话简介,`view: 'page'` 是带自己保存控件的表单。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册:
+自带配置的插件把配置渲染在本页而不是设置里,通过本页声明的三个 slot:`plugins.item`(list)用于官方插件,按其 `label` 列在官方分组里;`plugins.bundle.config`(以组合包的包名为键)用于组合包自己的配置,显示在组合包页面的描述与行之间;`plugins.row.config`(以 `<包名>#<行 id>` 为键)用于某一行的配置,这一行由此多出一个**配置**控件,打开该行自己的页面。页面用 `view: 'page'` 渲染带自己保存控件的表单。官方插件卡片还在标题下渲染 `view: 'summary'`;行详情页只在缺少包描述时使用该视图。只有保存才写入:页面负责画标题、图标与面包屑,条目的表单在离开页面时丢弃暂存的修改。安装随附的四个宿主平面配置页——shell 执行器、agent loop、subagent 模型选择、DeepSeek 搜索提供方——来自 [ui-settings-plugins](../ui-settings-plugins/README.zh.md),在 Host 服务其命名空间期间注册。组合包的浏览器半侧用同样的方式注册:
```tsx ignore-check
ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json
index a5f08aa391..cc7db22e85 100644
--- a/packages/client/ui-plugin-manager/package.json
+++ b/packages/client/ui-plugin-manager/package.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
+ "@deepseek-ai/dsh-package-manifest": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
index a97e31e8ca..5235d53cd0 100644
--- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
+++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
@@ -26,10 +26,10 @@ import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
import type { PluginManagerLocaleKey } from './locales.ts'
import {
isInstallPending, offeredRegistries, rowKey,
- type ConfirmState, type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
+ type InstallInputError, type InstallState, type InstallSubject, type PackageRow, type PackageView,
type PluginManagerFace, type RegistryChoice,
} from './manager-store.ts'
-import { managementText, noticeText, packageText, registryText, type Translate } from './presentation.ts'
+import { managementText, noticeText, packageText, registryText, rowText, type Translate } from './presentation.ts'
import type {} from './slot-contract.ts'
import css from './PluginManagerPage.module.css'
@@ -42,6 +42,7 @@ export type PluginManagerPageProps =
/** The page's slot renderer, narrowed to the configuration slots. */
type RenderConfig = PluginManagerPageProps['renderSlot']
+type ResolveText = PluginManagerFace['resolveText']
/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */
type View =
@@ -147,8 +148,9 @@ function packageArtwork(name: string): ReactNode {
}
/** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */
-function RowSwitch({ row, t, busy, onChange }: {
+function RowSwitch({ row, title, t, busy, onChange }: {
readonly row: PackageRow
+ readonly title: string
readonly t: Translate
readonly busy: boolean
readonly onChange: (enabled: boolean) => void
@@ -157,7 +159,7 @@ function RowSwitch({ row, t, busy, onChange }: {
return (
{t('metadataError', { error })}
+}
+
/**
* A pack's rows as a list in the order the pack declares them: a state dot,
* the row id, one line saying its state, a configure control for a row that
* registered a page, and, when the pack is on, a switch. A pack like base
* carries close to a hundred rows, so a long list gets a filter.
*/
-function RowsSection({ rows, t, toggle, configure }: {
+function RowsSection({ rows, t, resolveText, toggle, configure }: {
readonly rows: readonly PackageRow[]
readonly t: Translate
+ readonly resolveText: ResolveText
readonly toggle?: RowToggles | undefined
readonly configure?: RowConfigure | undefined
}): ReactNode {
const [filter, setFilter] = useState('')
const query = filter.trim().toLowerCase()
- const shown = query === '' ? rows : rows.filter(row => row.rowId.toLowerCase().includes(query))
+ const localized = rows.map(row => ({ row, ...rowText(row, resolveText) }))
+ const shown = query === '' ? localized : localized.filter(({ row, title, description }) =>
+ [title, description, row.rowId, row.moduleName].some(value => value?.toLowerCase().includes(query)))
return (