diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
index d1e00c2cd5..511e290621 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
@@ -9,8 +9,8 @@
en: edefff7cd986a509
zh: a270839fa86976a2
/agent-note-guided-plugin-installation/decision:
- en: 30f1e636973df06a
- zh: 0f874b88a5d4d7f5
+ en: 5448b9f8aaa9a569
+ zh: 9024c5328c5da3bd
/agent-note-guided-plugin-installation/alternatives-considered:
en: be64b541f4d81e7c
zh: 8600531c08ab4288
diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
index c0e9e68325..40cbf6179b 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
@@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
-**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
+**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed.
**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
index 0d914869a3..c53c138059 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
@@ -20,7 +20,7 @@ Status: implemented
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
-**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
+**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml
new file mode 100644
index 0000000000..50c86c11f6
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-10-15-plugin-detail-documents.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md
+/agent-note-plugin-detail-documents:
+ en: 2cca350bb3d00987
+ zh: 02689ad1893d30fe
+/agent-note-plugin-detail-documents/problem:
+ en: 018296714f6bba40
+ zh: 5926db9dca265dd7
+/agent-note-plugin-detail-documents/decision:
+ en: 638ccade77cb14db
+ zh: c70a83e2ed0e72f7
+/agent-note-plugin-detail-documents/alternatives-considered:
+ en: 791b7839125f9f99
+ zh: f75e3abeb9777494
+/agent-note-plugin-detail-documents/consequences:
+ en: 0a37ee01fc43ff26
+ zh: 156571e1d3181015
diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md
new file mode 100644
index 0000000000..5ee84e08b9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md
@@ -0,0 +1,50 @@
+# Agent Note: Plugin detail documents
+
+Status: implemented
+
+English | [中文](2026-10-15-plugin-detail-documents.zh.md)
+
+## Problem
+
+Plugin cards and pages show a one-line description. A user choosing a plugin from a market, or checking what an installed bundle does, needs the longer description that package authors already write as a README, with screenshots, as extension marketplaces of code editors show it. The [market index](2026-10-02-plugin-market-index-format.md) carries list fields only, and an index document cannot carry a Markdown document and its images for every plugin.
+
+Package READMEs of DSH's own packages are maintainer references rather than user descriptions, so an author needs a way to name a separate user document per language. Packages come from local installations, public npm, other registries, public Git hosts, and private repositories that only the user's Git credentials can read.
+
+## Decision
+
+Detail documents are read in a fixed order. For every package the order begins with the Markdown file that `meta.detail` in its `locale/.json` names for the display language, falling back through shorter language prefixes to English, then the package README. A market entry page continues with the README of the repository directory the entry or its package declares, at the default branch. The [document order](../../../../packages/util/plugin-detail/README.md#document-order) of the stateless [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.md) library is the single definition of the package steps; [`plugin-manager`](../../../../packages/boot/plugin-manager/README.md#bundle-detail-documents) and [`plugin-market`](../../../../packages/boot/plugin-market/README.md#plugin-detail-documents) document how they apply it.
+
+`meta.detail` lives in the `locale/.json` files that [plugin display metadata](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) already uses, without a package-field fallback, and is read only for detail documents, so a malformed value fails the detail read and leaves the plugin's title, description, and icon intact. The package README stands in for the README npm shows, because npm shows the README file of the published version. A source that exists but fails stops the read with a reason instead of falling through, so a missing `meta.detail` file or an oversized repository is reported rather than replaced by a different document.
+
+Bundle pages of **Plugins** are owned by `plugin-manager` and need no market: `readBundleDetail` reads an installed bundle from its directory, without network access or caching, and an official bundle offered before installation from the npm file CDNs at its offered version. Its image route resolves the bundle and document on every request, so the Host keeps no state. The bundle page shows the document first among its sections.
+
+The Client chooses which npm file CDNs a read asks first: it asks the existing registry comparison of the Plugins page and passes `npmFileSources`, such as `['npmmirror']` when npmmirror answered fastest. The Host orders its configured CDNs by that list and asks no CDN the configuration omits, so the Host services do not depend on the Client package that owns the comparison.
+
+Market entry pages are owned by `plugin-market`, which reads the entry version's source: the npm file CDNs for a public npm version, or the pinned Git commit. Repository files come from GitHub's raw host and the GitLab repository files API; an anonymous request to the repository's smart HTTP endpoint tells a missing file from a repository that needs credentials, and such a repository, or one on a host without an API, is read from a cached partial clone made with the user's Git configuration. Clones are depth-1 bare repositories without file contents until a file is read, limited per command and in total, and deleted when an operation on them fails or is cancelled. Any host of an http(s) repository URL may be cloned, over https, http, or ssh when `url..insteadOf` rewrites it: Git's credential helpers answer per host, so a clone sends a host only the credentials the user stored for it. The entry page shows the document first, then versions, then source. Neither page gives the document a heading of its own; the document's own headings name it.
+
+The Host reads only addresses it derives from installed packages, configured indexes, package manifests, and fixed CDN and API hosts; a client names a bundle or an index entry, never a URL. Images beside a document are served by authenticated Host routes that accept only image types inside the document's root, with a sandbox CSP so an SVG opened directly cannot run on the API origin. Markdown renders through the shared `DetailDocument` primitive, which omits raw HTML instead of showing it as literal text, because a README's HTML is layout for HTML renderers and reads as noise in text form; absolute HTTP(S) images load in the browser without a referrer, and relative links stay text.
+
+## Alternatives considered
+
+**Bundle details through the plugin market.** An earlier design read bundle documents in `plugin-market` and contributed them through `plugins.detail.section`, so bundle pages showed nothing with the market bundle off and every bundle read went through the market's cache. Installed packages are local files that need no cache, so `plugin-manager` reads them directly with the shared library.
+
+**A clone host allowlist and https-only clones.** Credential helpers, keychains, and `.netrc` already scope credentials to their host, so a market naming an unknown host receives no credentials for other hosts; an allowlist only blocked legitimate intranet repositories.
+
+**Detail fields in the index format.** A generator could publish document URLs, but the Host would then fetch addresses a market chooses, and the index would still not carry images or private documents.
+
+**npm tarball downloads.** A tarball covers every registry but downloads the whole package to read one file; the CDNs serve single files by exact version. Packages of other registries fall back to the entry's repository.
+
+**Proxying absolute images through the Host.** The Host would fetch URLs a document author chooses from the user's machine, including intranet and loopback addresses. Browser loads keep that risk out of the Host, at the cost of disclosing the user's IP address to image hosts.
+
+**Rendering raw HTML, and clickable relative links.** Many READMEs use HTML for layout, but rendering it requires sanitizing untrusted markup, so it is omitted; relative links would need a navigation target for every source. Both are deferred.
+
+**Full or blob-limited clones.** A full clone downloads every file of large repositories, and a blob size limit still downloads every small file. A depth-1 partial clone downloads commits and trees and fetches only the files a document reads.
+
+## Consequences
+
+- Authors write a user document per language under `meta.detail`; other packages show their README.
+- An installed bundle without `meta.detail` or a README shows no document; its declared repository is read only on market entry pages.
+- A repository README is read at the default branch and can differ from the listed version.
+- The first missing file of a public GitHub or GitLab repository costs one anonymous smart HTTP request, cached for `cacheTtlMs`.
+- Private repositories work when the user's Git credentials reach the host.
+- Unit tests cover the document order, CDN fallback, API and clone reads, size limits, eviction, and image paths with real local repositories; real-composition tests read an installed bundle's localized document through `plugin-manager` and a market entry's document through `plugin-market`, and serve their images through the composed profiles.
diff --git a/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md
new file mode 100644
index 0000000000..de6587fe49
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.zh.md
@@ -0,0 +1,50 @@
+# Agent Note: 插件详细介绍文档
+
+Status: implemented
+
+[English](2026-10-15-plugin-detail-documents.md) | 中文
+
+## 问题
+
+插件卡片和页面只显示一行描述。用户从市场中选择插件,或查看某个已安装组合包(bundle)的用途时,需要包作者已经以 README 形式写好的、带截图的较长描述,与代码编辑器的扩展市场显示的内容相同。[市场索引](2026-10-02-plugin-market-index-format.zh.md)只携带列表字段,一份索引文档也无法为每个插件携带一份 Markdown 文档及其图片。
+
+DSH 自身各包的 README 是面向维护者的参考,而不是面向用户的介绍,因此作者需要一种方式,为每种语言指定一份单独的用户文档。包可能来自本地安装、公共 npm、其他注册表、公共 Git 主机,以及只有用户的 Git 凭据才能读取的私有仓库。
+
+## 决策
+
+详细介绍文档按固定顺序读取。对每个包,顺序首先是其 `locale/.json` 中 `meta.detail` 为显示语言指定的 Markdown 文件,该语言没有时依次回退到更短的语言前缀,最后回退到英文;然后是包的 README。市场条目页面在此之后继续读取条目或其包所声明仓库目录中的 README,读取默认分支上的文件。[文档顺序](../../../../packages/util/plugin-detail/README.zh.md#document-order)属于无状态库 [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.zh.md),是包内各步骤的唯一定义;[`plugin-manager`](../../../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 和 [`plugin-market`](../../../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents) 说明它们如何应用该顺序。
+
+`meta.detail` 位于[插件展示元信息](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)已经使用的 `locale/.json` 文件中,没有包字段回退,并且只在读取详细介绍文档时读取,因此格式错误的值只让详细介绍的读取失败,插件的标题、描述和图标不受影响。包的 README 代替 npm 显示的 README,因为 npm 显示的就是已发布版本中的 README 文件。存在但读取失败的来源会带着原因终止读取,而不是继续尝试下一个来源,因此缺失的 `meta.detail` 文件或过大的仓库会被报告,而不是被另一份文档替代。
+
+**插件**页的组合包页面由 `plugin-manager` 负责,不需要市场:`readBundleDetail` 从已安装组合包的目录读取文档,不访问网络,也不缓存;当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取。其图片路由在每次请求时解析组合包和文档,因此 Host 不保留状态。组合包页面在其各部分中首先显示该文档。
+
+由 Client 选择一次读取先请求哪些 npm 文件 CDN:它询问插件页现有的注册表比较,并传入 `npmFileSources`,例如 npmmirror 响应最快时传入 `['npmmirror']`。Host 按该列表排列其已配置的 CDN,且不请求配置未列出的 CDN,因此 Host 服务不依赖负责该比较的 Client 包。
+
+市场条目页面由 `plugin-market` 负责,它读取条目版本的来源:公共 npm 版本从 npm 文件 CDN 读取,Git 版本从其固定的提交读取。仓库文件通过 GitHub 的原始文件主机和 GitLab 仓库文件 API 读取;一次不带任何凭据、发往该仓库 smart HTTP 端点的请求区分文件不存在与仓库需要凭据这两种情况,需要凭据的仓库以及所在主机没有 API 的仓库,从使用用户 Git 配置创建并缓存的部分克隆中读取。克隆是深度为 1 的裸仓库,在读取文件前不包含文件内容,按单条命令和总量限制大小,对其执行的操作失败或被取消时即被删除。http(s) 仓库 URL 的任何主机都可以克隆,可通过 https、http,或在 `url..insteadOf` 改写该 URL 时通过 ssh:Git 的凭据助手按主机给出凭据,因此克隆只向一个主机发送用户为该主机保存的凭据。条目页面先显示文档,然后是版本,然后是来源信息。两个页面都不为文档提供单独的标题;文档由其自身的标题命名。
+
+Host 只读取它从已安装的包、已配置的索引、包 manifest 以及固定的 CDN 和 API 主机推导出的地址;客户端只指定一个组合包或一个索引条目,从不指定 URL。文档旁的图片由需要认证的 Host 路由提供,这些路由只接受文档根目录内的图片类型,并带有 sandbox CSP,使直接打开的 SVG 无法在 API origin 上运行。Markdown 通过共享的 `DetailDocument` 原语渲染,该原语省略原始 HTML,而不是将其显示为原样文本,因为 README 中的 HTML 是供 HTML 渲染器使用的排版,以文本形式显示时只会干扰阅读;绝对 HTTP(S) 地址的图片由浏览器不带 referrer 加载,相对链接保持为文本。
+
+## 考虑过的替代方案
+
+**通过插件市场提供组合包详细介绍。** 早先的设计在 `plugin-market` 中读取组合包文档,并通过 `plugins.detail.section` 提供它们,因此关闭市场组合包时组合包页面不显示任何内容,且每次组合包读取都经过市场的缓存。已安装的包是不需要缓存的本地文件,因此由 `plugin-manager` 使用共享库直接读取。
+
+**克隆主机允许列表和只允许 https 克隆。** 凭据助手、钥匙串和 `.netrc` 已经按主机限定凭据,因此指定未知主机的市场不会得到其他主机的凭据;允许列表只会阻止合法的内网仓库。
+
+**在索引格式中加入详细介绍字段。** 生成器可以发布文档 URL,但这样 Host 就会获取市场选择的地址,而且索引仍然无法携带图片或私有文档。
+
+**下载 npm tarball。** tarball 适用于所有注册表,但为了读取一个文件要下载整个包;CDN 按精确版本提供单个文件。其他注册表的包改为从条目的仓库读取。
+
+**通过 Host 代理绝对地址的图片。** Host 会从用户的机器获取文档作者选择的 URL,包括内网和回环地址。由浏览器加载可以让 Host 避开这一风险,代价是向图片主机暴露用户的 IP 地址。
+
+**渲染原始 HTML,以及可点击的相对链接。** 许多 README 使用 HTML 排版,但渲染它需要清理不可信的标记,因此省略它;相对链接需要为每种来源提供跳转目标。两者都已延后。
+
+**完整克隆或按 blob 大小限制的克隆。** 完整克隆会下载大型仓库的每个文件,按 blob 大小限制仍会下载每个小文件。深度为 1 的部分克隆只下载提交和树对象,并只获取文档读取的文件。
+
+## 后果
+
+- 作者在 `meta.detail` 下为每种语言编写一份用户文档;其他包显示其 README。
+- 没有 `meta.detail` 也没有 README 的已安装组合包不显示文档;其声明的仓库只在市场条目页面上读取。
+- 仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致。
+- 公开 GitHub 或 GitLab 仓库中第一次遇到缺失的文件时,需要发送一次匿名的 smart HTTP 请求,其结果缓存 `cacheTtlMs`。
+- 用户的 Git 凭据能访问该主机时,即可读取私有仓库。
+- 单元测试使用真实的本地仓库,覆盖文档顺序、CDN 回退、API 与克隆读取、大小限制、淘汰和图片路径;真实组合测试通过 `plugin-manager` 读取已安装组合包的本地化文档,通过 `plugin-market` 读取市场条目的文档,并通过组合后的 profile 提供其图片。
diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts
index 11b0dcce85..0954aa180a 100644
--- a/apps/web/tests/cot-translation-paid.e2e.ts
+++ b/apps/web/tests/cot-translation-paid.e2e.ts
@@ -195,6 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
expect(calls).toEqual([])
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
+ // The bundle's README is its detail document, shown between its components and its source.
+ await panel.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts
index 657c95fbdd..8419b55b79 100644
--- a/apps/web/tests/cot-translation.e2e.ts
+++ b/apps/web/tests/cot-translation.e2e.ts
@@ -212,6 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
+ // The bundle's README is its detail document, shown between its components and its source.
+ await panel.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
index 4a5c9b38ad..3e4ab96099 100644
--- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
+++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
@@ -6,6 +6,65 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
+- region "详细介绍":
+ - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
+ - paragraph: English | 中文
+ - heading "Summary" [level=2]
+ - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
+ - heading "Table of Contents" [level=2]
+ - list:
+ - listitem: Use this package
+ - listitem: Understand the implementation
+ - listitem: Further Exploration
+ - listitem: Model Experience
+ - listitem: Known Limitations and Deferred Work
+ - listitem: Dev Note
+ - separator
+ - paragraph
+ - heading "Use this package" [level=2]
+ - paragraph:
+ - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
+ - code: auto
+ - text: follows the interface language; an explicit language code such as
+ - code: zh
+ - text: ","
+ - code: en
+ - text: ", or"
+ - code: ja
+ - text: overrides it. Save applies the preferences.
+ - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
+ - paragraph:
+ - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
+ - code: deepseek-flash
+ - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
+ - separator
+ - paragraph
+ - heading "Understand the implementation" [level=2]
+ - paragraph:
+ - text: The bundle patch inserts
+ - code: translator
+ - text: and
+ - code: cot-translation
+ - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
+ - separator
+ - paragraph
+ - heading "Further Exploration" [level=2]
+ - paragraph: Text translation subsystem · Translator service · GUI consumer
+ - separator
+ - paragraph
+ - heading "Model Experience" [level=2]
+ - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
+ - heading "KV Cache effect" [level=4]
+ - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
+ - heading "Known Limitations and Deferred Work" [level=2]
+ - paragraph
+ - list:
+ - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
+ - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
+ - separator
+ - paragraph
+ - heading "Dev Note" [level=3]
+ - paragraph: None.
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求
- text: 翻译服务 已覆盖
diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md
index 29f85e915b..b2c7e3c3d7 100644
--- a/apps/web/tests/expected/cot-translation/preferences.expected.md
+++ b/apps/web/tests/expected/cot-translation/preferences.expected.md
@@ -6,6 +6,65 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
+- region "详细介绍":
+ - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
+ - paragraph: English | 中文
+ - heading "Summary" [level=2]
+ - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
+ - heading "Table of Contents" [level=2]
+ - list:
+ - listitem: Use this package
+ - listitem: Understand the implementation
+ - listitem: Further Exploration
+ - listitem: Model Experience
+ - listitem: Known Limitations and Deferred Work
+ - listitem: Dev Note
+ - separator
+ - paragraph
+ - heading "Use this package" [level=2]
+ - paragraph:
+ - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
+ - code: auto
+ - text: follows the interface language; an explicit language code such as
+ - code: zh
+ - text: ","
+ - code: en
+ - text: ", or"
+ - code: ja
+ - text: overrides it. Save applies the preferences.
+ - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
+ - paragraph:
+ - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
+ - code: deepseek-flash
+ - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
+ - separator
+ - paragraph
+ - heading "Understand the implementation" [level=2]
+ - paragraph:
+ - text: The bundle patch inserts
+ - code: translator
+ - text: and
+ - code: cot-translation
+ - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
+ - separator
+ - paragraph
+ - heading "Further Exploration" [level=2]
+ - paragraph: Text translation subsystem · Translator service · GUI consumer
+ - separator
+ - paragraph
+ - heading "Model Experience" [level=2]
+ - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
+ - heading "KV Cache effect" [level=4]
+ - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
+ - heading "Known Limitations and Deferred Work" [level=2]
+ - paragraph
+ - list:
+ - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
+ - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
+ - separator
+ - paragraph
+ - heading "Dev Note" [level=3]
+ - paragraph: None.
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求
- text: 翻译服务
diff --git a/apps/web/tests/expected/plugin-manager/preview.expected.md b/apps/web/tests/expected/plugin-manager/preview.expected.md
index 4f78082f46..b72a046686 100644
--- a/apps/web/tests/expected/plugin-manager/preview.expected.md
+++ b/apps/web/tests/expected/plugin-manager/preview.expected.md
@@ -6,6 +6,53 @@
- code: "@deepseek-ai/dsh-experimental-badge-skill-bundle"
- paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。
- img "DSH 徽章技能功能预览"
+- region "详细介绍":
+ - heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1]
+ - paragraph: English | 中文
+ - heading "Summary" [level=2]
+ - paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it.
+ - heading "Table of Contents" [level=2]
+ - list:
+ - listitem: Use this package
+ - listitem: Understand the implementation
+ - listitem: Further Exploration
+ - listitem: Model Experience
+ - listitem: Known Limitations and Deferred Work
+ - listitem: Dev Note
+ - separator
+ - paragraph
+ - heading "Use this package" [level=2]
+ - paragraph:
+ - text: In Web or Desktop, open Plugins and enable
+ - strong: DSH badge skill
+ - text: in Official. Switch it off to remove its profile layer.
+ - paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged.
+ - separator
+ - paragraph
+ - heading "Understand the implementation" [level=2]
+ - paragraph:
+ - code: cordis.patch.yml
+ - text: contributes the
+ - code: skill-badge
+ - text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors.
+ - separator
+ - paragraph
+ - heading "Further Exploration" [level=2]
+ - paragraph: Capability implementation
+ - separator
+ - paragraph
+ - heading "Model Experience" [level=2]
+ - paragraph: Indirectly, through the capability implementation, which owns model context and results.
+ - heading "KV Cache effect" [level=4]
+ - paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results.
+ - heading "Known Limitations and Deferred Work" [level=2]
+ - paragraph
+ - list:
+ - listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle.
+ - separator
+ - paragraph
+ - heading "Dev Note" [level=3]
+ - paragraph: None.
- heading "包含的组件" [level=4]
- text: 共 1 个 · 1 已停用
- list:
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index 3b376ce206..1f7ee7ee70 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -844,6 +844,8 @@ describe('web e2e: plugin manager', () => {
expect(fits).toBe(true)
await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor()
await detail.getByText(badgeVersion, { exact: true }).waitFor()
+ // The bundle's README is its detail document, shown between its components and its source.
+ await detail.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'),
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[badgeVersion, '{{version}}']],
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 05333653c2..1b7b3903b3 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -2482,7 +2482,8 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
-- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts)
+- `refs`: [`NpmFileSource`](subsystems/boot.md)
+- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2511,6 +2512,14 @@ export interface Config {
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
+ /** Largest bundle detail document, and largest locale file read for one, in bytes. */
+ detailMaxBytes?: number
+ /** Largest image served beside a bundle detail document, in bytes. */
+ detailImageMaxBytes?: number
+ /** Deadline of one npm file CDN request for an offered bundle's detail document. */
+ detailFetchTimeoutMs?: number
+ /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
+ detailNpmFileSources?: NpmFileSource[]
}
```
@@ -2521,8 +2530,8 @@ export interface Config {
## `@deepseek-ai/dsh-plugin-market`
- `inject`: `pluginManager`
-- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts)
+- `refs`: [`NpmFileSource`](subsystems/boot.md) · `Volatile` (`@deepseek-ai/cordis`)
+- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts)
```ts config-catalog
/** Index sources and fetch limits. */
@@ -2533,8 +2542,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
- /** Lifetime of a successfully read index document. */
+ /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
+ /** Largest detail document in bytes. */
+ detailMaxBytes: number
+ /** Largest image beside a detail document in bytes. */
+ detailImageMaxBytes: number
+ /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
+ detailRepositoryMaxBytes: number
+ /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
+ detailCacheMaxBytes: number
+ /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
+ detailCacheDir?: string
+ /** Deadline of one Git command of the clone fallback. */
+ detailGitTimeoutMs: number
+ /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
+ detailNpmFileSources: NpmFileSource[]
+ /** Hosts serving the GitLab repository files API. */
+ detailGitlabHosts: string[]
}
```
@@ -4850,6 +4875,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
+| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) |
| `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) |
| `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index b767052d30..bc917305a4 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -2484,7 +2484,8 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
-- `source`: [`packages/boot/plugin-manager/src/index.ts:45`](../packages/boot/plugin-manager/src/index.ts)
+- `refs`: [`NpmFileSource`](subsystems/boot.zh.md)
+- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2513,6 +2514,14 @@ export interface Config {
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
+ /** Largest bundle detail document, and largest locale file read for one, in bytes. */
+ detailMaxBytes?: number
+ /** Largest image served beside a bundle detail document, in bytes. */
+ detailImageMaxBytes?: number
+ /** Deadline of one npm file CDN request for an offered bundle's detail document. */
+ detailFetchTimeoutMs?: number
+ /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
+ detailNpmFileSources?: NpmFileSource[]
}
```
@@ -2523,8 +2532,8 @@ export interface Config {
## `@deepseek-ai/dsh-plugin-market`
- `inject`: `pluginManager`
-- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts)
+- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
+- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts)
```ts config-catalog
/** Index sources and fetch limits. */
@@ -2535,8 +2544,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
- /** Lifetime of a successfully read index document. */
+ /** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
+ /** Largest detail document in bytes. */
+ detailMaxBytes: number
+ /** Largest image beside a detail document in bytes. */
+ detailImageMaxBytes: number
+ /** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
+ detailRepositoryMaxBytes: number
+ /** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
+ detailCacheMaxBytes: number
+ /** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
+ detailCacheDir?: string
+ /** Deadline of one Git command of the clone fallback. */
+ detailGitTimeoutMs: number
+ /** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
+ detailNpmFileSources: NpmFileSource[]
+ /** Hosts serving the GitLab repository files API. */
+ detailGitlabHosts: string[]
}
```
@@ -4852,6 +4877,7 @@ export interface Config {
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
+| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) |
| `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) |
| `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) |
diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml
index 013ecb4344..3776cb0096 100644
--- a/docs/cookbook/adding-a-package.i18n.yaml
+++ b/docs/cookbook/adding-a-package.i18n.yaml
@@ -21,8 +21,8 @@
en: 0894bd9f35430753
zh: 2d28090e71134edd
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
- en: 2382017c7f84ace6
- zh: 03af99592baaab9f
+ en: 68b463cd3d2e5fb0
+ zh: 80ea797c5770c573
/cookbook-adding-a-workspace-package/6-verify:
en: 80c7fb90b1175aae
zh: 2ba37962dbc57299
diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md
index af3547ea1f..11cca9a60d 100644
--- a/docs/cookbook/adding-a-package.md
+++ b/docs/cookbook/adding-a-package.md
@@ -14,7 +14,7 @@ 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/en.json # optional display metadata: meta.title, meta.description, and meta.detail
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
@@ -147,6 +147,8 @@ Export `/locale/en.json` for locale lookup; expose `
For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork.
+A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
+
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.
diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md
index c45632d3e7..b22976fb9d 100644
--- a/docs/cookbook/adding-a-package.zh.md
+++ b/docs/cookbook/adding-a-package.zh.md
@@ -14,7 +14,7 @@ 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/en.json # optional display metadata: meta.title, meta.description, and meta.detail
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
@@ -147,6 +147,8 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex
要在组合包卡片、详情和组件行显示图片,包根插件在导出清单顶层设置 `"icon": "./icon.svg"`,或在省略该字段时导出 `./icon`。子路径插件导出自己的图标,例如 `"./search/icon": "./search.svg"`。将每张图片加入 `files`。清单 `icon` 相对于清单所在目录;绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。导出的图标像 locale 资源一样经 Node exports 解析,且必须位于所属包内。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。图片必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本,不再尝试导出的图标;图片缺失或无法解码时使用面板的默认插画。
+组合包还可以把 `meta.detail` 设为相对于包根目录的 Markdown 文件路径,例如在 `locale/en.json` 中设为 `"docs/DETAIL.md"`,在 `locale/zh.json` 中设为 `"docs/DETAIL.zh.md"`;读取时使用包根目录下的这些文件。路径使用 `/`,必须位于包内,以 `.md` 或 `.markdown` 结尾,且没有包字段回退;将该文件及其图片加入 `files`。组合包在**插件**中的页面,以及启用[自定义插件市场组合包](../../packages/experimental/plugin-market-bundle/README.zh.md)时其市场条目页面,把当前界面语言对应的该文件作为插件的详细介绍文档显示。没有 `meta.detail` 时,它们显示包 README;市场条目页面在此之后还会回退到所声明仓库的 README;规则由[文档顺序](../../packages/util/plugin-detail/README.zh.md#document-order)定义。该文档面向用户编写,图片使用相对于该文件的路径引用。
+
已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。
仅设置页会移除字面包名和模块名回退值的 npm scope 与 Cordis/DSH 前缀;插件管理页保留完整名称。locale 标题和描述保持原样。插件没有展示描述时,行配置页可以使用注册组件的摘要。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index dd565cacdc..50b9819555 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -56,11 +56,11 @@ 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), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`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:48`](../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:359`](../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:365`](../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:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../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:398`](../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:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index d51831e5c6..582feb2230 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -58,11 +58,11 @@
| `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), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`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:48`](../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:359`](../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:365`](../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:372`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:379`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:136`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:392`](../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:398`](../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:405`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:412`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 90537b1933..1eda478157 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -19,6 +19,7 @@ flowchart TD
pkg_native_command["native-command"]
pkg_output_retention["output-retention"]
pkg_package_manifest["package-manifest"]
+ pkg_plugin_detail["plugin-detail"]
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_code_language["util-code-language"]
@@ -1303,6 +1304,7 @@ flowchart TD
pkg_api_session_controller --> pkg_workspace
pkg_plugin_market --> pkg_plugin_manager
pkg_plugin_market --> pkg_settings
+ pkg_plugin_market --> pkg_subprocess
pkg_plugin_market --> pkg_tools
pkg_plugin_market --> pkg_typert_protocol
pkg_headless --> pkg_agent
@@ -1448,6 +1450,7 @@ flowchart TD
| [`native-command`](../packages/util/native-command) | `util` | — |
| [`output-retention`](../packages/util/output-retention) | `util` | — |
| [`package-manifest`](../packages/util/package-manifest) | `util` | — |
+| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — |
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
@@ -1763,7 +1766,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
-| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 148fb51478..928a955aef 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -21,6 +21,7 @@ flowchart TD
pkg_native_command["native-command"]
pkg_output_retention["output-retention"]
pkg_package_manifest["package-manifest"]
+ pkg_plugin_detail["plugin-detail"]
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_code_language["util-code-language"]
@@ -1305,6 +1306,7 @@ flowchart TD
pkg_api_session_controller --> pkg_workspace
pkg_plugin_market --> pkg_plugin_manager
pkg_plugin_market --> pkg_settings
+ pkg_plugin_market --> pkg_subprocess
pkg_plugin_market --> pkg_tools
pkg_plugin_market --> pkg_typert_protocol
pkg_headless --> pkg_agent
@@ -1450,6 +1452,7 @@ flowchart TD
| [`native-command`](../packages/util/native-command) | `util` | — |
| [`output-retention`](../packages/util/output-retention) | `util` | — |
| [`package-manifest`](../packages/util/package-manifest) | `util` | — |
+| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — |
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
@@ -1765,7 +1768,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
-| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml
index 1e25bfcbed..0d52b7d7e5 100644
--- a/docs/subsystems/boot.i18n.yaml
+++ b/docs/subsystems/boot.i18n.yaml
@@ -6,11 +6,11 @@
en: 962b4398c9052959
zh: 14471c340a9f6e60
/profile-management/management-records:
- en: 56c269c5f6dc107c
- zh: 535fe725b924b1e6
+ en: 2def418f04e9fc10
+ zh: af0bd1610bdc2f2b
/profile-management/market-records:
- en: a28421bc6481b0ed
- zh: 73142d6163a20f50
+ en: 3dfc62d837d61b9b
+ zh: ec223be315f3dc62
/profile-management/profile-reconciliation:
en: 42a093314af774d4
zh: 7c1784a3af02b31c
diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md
index 39b6dd3e4c..530af09e92 100644
--- a/docs/subsystems/boot.md
+++ b/docs/subsystems/boot.md
@@ -14,6 +14,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
`InstallBundleOptions.saveExact` passes `--save-exact` to the shared package installer. `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.
+`BundleDetail` is a profile bundle's long description: `{ status: 'found' }` with its `origin` (`locale` or `readme`), its `path` inside the package, and its `markdown`, or `{ status: 'none' }`; [`readBundleDetail`](../../packages/boot/plugin-manager/README.md#bundle-detail-documents) documents the order and the image route. `NpmFileSource` names a public npm file CDN, `jsdelivr`, `unpkg`, or `npmmirror`; a read's `npmFileSources` lists the ones to ask first.
+
`PluginRegistries` carries the configured first registry, `null` for the one pnpm's own configuration names, the fallbacks asked after it, and `resolved`, the URL pnpm's own configuration names or `null` while unread. `InspectOptions.registry` names the registry a lookup asks first.
`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. Optional `error` carries a localizable code and external diagnostic. `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path, plus `timedOut` when the manager terminated a run that stopped printing. A terminated run is classified `timeout` whatever exit status the signal left behind, so installation and removal report failure instead of success and no further registry is asked. `pendingBuilds` lists undecided packages across the profile; `approvedBuilds` records the names granted permission by this operation; `registries` lists the registries an installation asked, in order; `bundle` and `version` name the package a finished installation added and its manifest version; `failedAt` says whether the last failed run could not reach the registry it asked or the host a git or tarball spec is fetched from.
@@ -26,6 +28,8 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
`MarketQuery` selects one page of an index: fuzzy `text`, a `category`, `labels` that must all be present, a `MarketSort`, `offset`, `limit` up to 100, `refresh` to fetch again instead of using a cached document, and `fetchedAt` to continue the pages of one document. `MarketSearchResult` carries the index URL, `title`, optional `description`, market `metadata`, the document's `fetchedAt`, the page's `entries`, the `total` number of matches, and the `nextOffset`. Each `MarketListingEntry` adds `defaultVersion`, the version installed by default, and `installation`, the profile's status for the package name; each of its `versions` adds `install`, the exact spec and optional first registry for `installBundle`, and `compatibility`, the evaluation of its `engines.dsh` including a version exemption.
+`PluginDetail` is a market entry's long description: `{ status: 'found' }` with its `origin` (`locale`, `readme`, or `repository`), its `markdown`, and a `document` id whose images `GET /api/plugin-market/detail-file` serves, or `{ status: 'none' }`. The [plugin market documents the order](../../packages/boot/plugin-market/README.md#plugin-detail-documents) its sources are read in.
+
## Profile reconciliation
@@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
Manage profile files and apply their declared reload lifecycle.
```ts cordis-catalog
+/**
+ * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its
+ * README. An installed bundle is read from its directory without network access; an official bundle the installation
+ * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.
+ * @param name - the bundle's package name, as `listBundles()` lists it.
+ * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
+ * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one
+ * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.
+ * @returns the document with its origin and path, or `none`.
+ * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or
+ * `plugin-manager/detail-unavailable`.
+ */
+@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise
+
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
@@ -312,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with
* or `plugin-market/invalid-index`.
*/
async entry(url: string, name: string): Promise
+
+/**
+ * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file
+ * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names
+ * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm
+ * version without one, of its `package.json` `repository`, at the default branch. An npm version from another
+ * registry has no readable package files, so only the entry's `repository` is read.
+ * @param url - a configured index URL.
+ * @param name - the plugin's package name.
+ * @param version - one listed version.
+ * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
+ * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names
+ * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not
+ * configured are not asked.
+ * @returns the document with its origin and image document id, or `none`.
+ * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version,
+ * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.
+ */
+@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise
```
Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts)
diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md
index 5d563c041e..1cab38289f 100644
--- a/docs/subsystems/boot.zh.md
+++ b/docs/subsystems/boot.zh.md
@@ -14,6 +14,8 @@
`InstallBundleOptions.saveExact` 向共享包安装器传入 `--save-exact`。`InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。
+`BundleDetail` 是 profile 中组合包的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale` 或 `readme`)、在包内的 `path` 和 `markdown`;否则为 `{ status: 'none' }`。[`readBundleDetail`](../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 说明读取顺序和图片路由。`NpmFileSource` 指定一个公共 npm 文件 CDN:`jsdelivr`、`unpkg` 或 `npmmirror`;一次读取的 `npmFileSources` 列出先请求的 CDN。
+
`PluginRegistries` 携带配置的第一个注册表(`null` 即 pnpm 自身配置指定的那个)、随后依次询问的备选注册表,以及 `resolved`——pnpm 自身配置指向的 URL,未读到时为 `null`。`InspectOptions.registry` 指定一次查询首先询问的注册表。
`ChangeResult.changed` 报告磁盘修改,独立于 `application`:`applied`、`restart-required`、`overridden` 或 `failed`。可选的 `error` 包含可本地化的错误码和外部诊断。`packageResult` 记录 pnpm 退出码、有界输出、截断标志及完整诊断日志路径;当管理器终止了一个停止打印的运行,还记录 `timedOut`。被终止的运行不论信号留下什么退出状态都归类为 `timeout`,因此安装与删除都报告失败而非成功,也不会再询问下一个注册表。`pendingBuilds` 列出整个 profile 尚未决定的包;`approvedBuilds` 记录本次操作授予权限的包名;`registries` 按顺序列出一次安装问过的注册表;`bundle` 与 `version` 给出完成的安装新增的包及其清单版本;`failedAt` 说明最后一次失败的运行连不上的是所问的注册表,还是 git 或 tarball spec 自身拉取的主机。
@@ -26,6 +28,8 @@
`MarketQuery` 选择索引的一页:模糊匹配的 `text`、一个 `category`、必须全部具备的 `labels`、一个 `MarketSort`、`offset`、不超过 100 的 `limit`、表示重新获取而不使用缓存文档的 `refresh`,以及用于继续读取同一文档后续页的 `fetchedAt`。`MarketSearchResult` 携带索引 URL、`title`、可选的 `description`、市场 `metadata`、该文档的 `fetchedAt`、本页的 `entries`、匹配总数 `total` 和 `nextOffset`。每个 `MarketListingEntry` 额外携带 `defaultVersion`,即默认安装的版本,以及 `installation`,即该包名在当前 profile 中的状态;其 `versions` 中的每个版本额外携带 `install`,即交给 `installBundle` 的精确 spec 和可选的首选 registry,以及 `compatibility`,即包含版本豁免在内的 `engines.dsh` 评估结果。
+`PluginDetail` 是市场条目的详细介绍:`{ status: 'found' }` 携带其 `origin`(`locale`、`readme` 或 `repository`)、`markdown`,以及一个 `document` id,该文档中的图片由 `GET /api/plugin-market/detail-file` 提供;否则为 `{ status: 'none' }`。读取其来源的顺序见[插件市场的说明](../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents)。
+
## Profile 重新组合
@@ -151,6 +155,20 @@ Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
Manage profile files and apply their declared reload lifecycle.
```ts cordis-catalog
+/**
+ * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its
+ * README. An installed bundle is read from its directory without network access; an official bundle the installation
+ * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.
+ * @param name - the bundle's package name, as `listBundles()` lists it.
+ * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
+ * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one
+ * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.
+ * @returns the document with its origin and path, or `none`.
+ * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or
+ * `plugin-manager/detail-unavailable`.
+ */
+@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise
+
/** Read exact plugin-version exemptions saved in this profile.
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
* record or file problem the reader rejected, which the caller reports instead of failing.
@@ -312,6 +330,25 @@ Fetches, validates, and caches configured market indexes; annotates entries with
* or `plugin-market/invalid-index`.
*/
async entry(url: string, name: string): Promise
+
+/**
+ * Read the long description of one listed version of a plugin market entry, from the version's source: the npm file
+ * CDNs for a public npm version, or its pinned Git commit. The Markdown file the package's locale `meta.detail` names
+ * in `language` comes first, then the package's README, then the README of the entry's `repository`, or for an npm
+ * version without one, of its `package.json` `repository`, at the default branch. An npm version from another
+ * registry has no readable package files, so only the entry's `repository` is read.
+ * @param url - a configured index URL.
+ * @param name - the plugin's package name.
+ * @param version - one listed version.
+ * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
+ * @param options - `refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names
+ * npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not
+ * configured are not asked.
+ * @returns the document with its origin and image document id, or `none`.
+ * @throws {RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version,
+ * `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.
+ */
+@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise
```
Source: [`packages/boot/plugin-market/src/index.ts`](../../packages/boot/plugin-market/src/index.ts)
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index 6881156ef5..da6f814458 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -32,8 +32,8 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type {
- BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind, ManagementError,
- PackageResult,
+ BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, IncompatiblePlugin, InspectOptions, InstallBundleOptions, InstallSpecKind,
+ ManagementError, PackageResult,
PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallFailureKind,
PluginInstallLogChunk, PluginInstallProgress, PluginInstallRequestId, PluginRegistries, PluginSpecInspection,
PluginUpdateProgress, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, RegistryAttempt,
@@ -44,7 +44,7 @@ export type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/remote'
export type {} from '@deepseek-ai/dsh-plugin-market/remote'
export type {
MarketCompatibility, MarketIndexSummary, MarketInstallation, MarketInstallationStatus, MarketListingEntry, MarketListingVersion,
- MarketQuery, MarketSearchResult, MarketSort,
+ MarketQuery, MarketSearchResult, MarketSort, PluginDetail, PluginDetailDocumentId, PluginDetailOrigin,
} from '@deepseek-ai/dsh-plugin-market/types'
export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote'
diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts
index aa7422918e..48ba896f01 100644
--- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts
+++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts
@@ -1,4 +1,4 @@
-import { Context } from '@deepseek-ai/cordis'
+import { Context, FiberState } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -240,26 +240,39 @@ describe('Session file uploads', () => {
await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
})
- it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
+ it('resolves a cold ordinary Agent and releases the resolver with its registering fiber', async () => {
const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
await disposeAgent()
const resolveAgent = vi.fn(async () => {
await ctx.agents.register(agent)
return agent
})
- const disposeResolver = uploads.registerAgentResolver(resolveAgent)
- expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
+ const resolverOwner = {
+ name: 'resolver-owner',
+ inject: ['fileUploads'],
+ apply: (owner: Context) => {
+ owner.effect(() => owner.fileUploads.registerAgentResolver(resolveAgent), 'test: Agent resolver')
+ },
+ }
+ const first = ctx.plugin(resolverOwner)
+ await first
+ expect(() => { ctx.fileUploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
await expect(uploads.uploadStream({
sessionId: SESSION,
data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(),
})).resolves.toMatchObject({ file: { bytes: 1 } })
expect(resolveAgent).toHaveBeenCalledWith(SESSION)
+ await first.dispose()
+ const second = ctx.plugin(resolverOwner)
+ await second
+ expect(second.state).toBe(FiberState.ACTIVE)
+ await second.dispose()
+ const disposeResolver = ctx.fileUploads.registerAgentResolver(resolveAgent)
disposeResolver()
const replacement = vi.fn(async () => agent)
- const disposeReplacement = uploads.registerAgentResolver(replacement)
+ const disposeReplacement = ctx.fileUploads.registerAgentResolver(replacement)
disposeResolver()
- expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
- expect(disposeReplacement).toBeTypeOf('function')
+ expect(() => { ctx.fileUploads.registerAgentResolver(replacement) }).toThrow('already registered')
disposeReplacement()
})
diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml
index 0fdf4947a3..e16fd551f3 100644
--- a/packages/boot/plugin-manager/README.i18n.yaml
+++ b/packages/boot/plugin-manager/README.i18n.yaml
@@ -21,8 +21,11 @@
en: 483285658e267dd5
zh: 846bec2daac59a96
/deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
- en: 861333e423369690
- zh: 6d16604bd24766d3
+ en: 64089ca8084f5e0f
+ zh: 8328a53cd6420a82
+/deepseek-ai-dsh-plugin-manager/use-this-package/bundle-detail-documents:
+ en: 99b084ff497011d2
+ zh: 6c047796c1991784
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
en: 495dadf79c48c43c
zh: 9884dd04a5bb49dc
diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md
index e1d36046e9..0f1c691e9f 100644
--- a/packages/boot/plugin-manager/README.md
+++ b/packages/boot/plugin-manager/README.md
@@ -92,6 +92,21 @@ The CLI exposes `dsh plugin --profile version-exemptions`, `allow-vers
| `versionListLimit` | `20` | How many of the newest versions above the installed one a version listing reads and returns. |
| `versionLookupConcurrency` | `4` | How many single-version registry lookups a version listing runs at once. |
| `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. |
+| `detailMaxBytes` | `1048576` | Largest bundle detail document, and largest locale file read for one, in bytes. |
+| `detailImageMaxBytes` | `5242880` | Largest image served beside a bundle detail document, in bytes. |
+| `detailFetchTimeoutMs` | `15000` | Deadline of one npm file CDN request for an offered bundle's detail document. |
+| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | npm file CDNs offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. |
+
+
+### Bundle detail documents
+
+`readBundleDetail(name, language, { npmFileSources? })` reads the long description that a bundle's page in **Plugins** shows, for a bundle `listBundles()` lists: the locale `meta.detail` file, otherwise the README, as steps 1 and 2 of the [document order](../../util/plugin-detail/README.md#document-order) define them. Without either, the result is `{ status: 'none' }`; the repository a bundle declares is not read. `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order; configured CDNs it omits follow, and CDNs that `detailNpmFileSources` does not list are not asked. A found document carries its `origin` (`locale` or `readme`), its `path` inside the package, and its Markdown without a leading YAML front matter block.
+
+An installed bundle is read from its directory, without network access. An official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version; one offered as a development link has no document. Nothing is cached, so a reinstalled bundle shows its own document.
+
+`GET /api/plugin-manager/detail-file?bundle=&document=&path=`, with `&sources=,...` to ask those CDNs first, serves an image beside a bundle's document to authenticated owner connections. The destination is relative to the document, or to the package root when it starts with `/`. Only PNG, JPEG, GIF, WebP, AVIF, and SVG files inside the package are served, with `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, and `Cache-Control: no-store`; another path or an unlisted bundle answers 404.
+
+Failures: `plugin-manager/unknown-bundle` for a bundle `listBundles()` does not list or whose directory cannot be resolved, `plugin-manager/invalid-language`, and `plugin-manager/detail-unavailable` with `reason` `too-large`, `network`, or `invalid` for a malformed locale file or `meta.detail`, or a named file that does not exist. The image route answers them with 404, 413, 502, and 400.
-----
diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md
index 354b6bc89a..2f8a0cc3d0 100644
--- a/packages/boot/plugin-manager/README.zh.md
+++ b/packages/boot/plugin-manager/README.zh.md
@@ -92,6 +92,21 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version
| `versionListLimit` | `20` | 版本列表读取并返回的、比已安装版本新的最新版本个数。 |
| `versionLookupConcurrency` | `4` | 版本列表同时运行的单版本注册表查询个数。 |
| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 |
+| `detailMaxBytes` | `1048576` | 组合包详细介绍文档的最大大小,以及为读取它而读取的 locale 文件的最大大小,单位字节。 |
+| `detailImageMaxBytes` | `5242880` | 与组合包详细介绍文档一同提供的图片的最大大小,单位字节。 |
+| `detailFetchTimeoutMs` | `15000` | 为读取当前安装提供、尚未安装的组合包的详细介绍文档而发出的单次 npm 文件 CDN 请求的时限。 |
+| `detailNpmFileSources` | `['jsdelivr', 'unpkg', 'npmmirror']` | 读取当前安装提供、尚未安装的组合包时按顺序使用的 npm 文件 CDN;一次读取的 `npmFileSources` 把其中列出的 CDN 移到最前。 |
+
+
+### 组合包详细介绍文档
+
+`readBundleDetail(name, language, { npmFileSources? })` 为 `listBundles()` 列出的组合包读取**插件**页中该组合包页面显示的详细介绍:locale `meta.detail` 文件,否则为 README,即[文档顺序](../../util/plugin-detail/README.zh.md#document-order)第 1 步和第 2 步的定义。两者都没有时,结果为 `{ status: 'none' }`;不读取组合包声明的仓库。`npmFileSources` 按顺序列出读取当前安装提供、尚未安装的组合包时先请求的 npm 文件 CDN;它未列出的已配置 CDN 排在其后,`detailNpmFileSources` 未列出的 CDN 不会被请求。找到的文档携带其 `origin`(`locale` 或 `readme`)、在包内的 `path`,以及去掉开头 YAML front matter 块后的 Markdown 内容。
+
+已安装的组合包从其目录读取,不访问网络。当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取;以开发链接形式提供的组合包没有文档。结果不被缓存,因此重新安装的组合包显示其自身的文档。
+
+`GET /api/plugin-manager/detail-file?bundle=&document=&path=`(加上 `&sources=,...` 时先请求这些 CDN)向已通过认证的 owner 连接提供组合包文档旁的图片。目标路径相对于该文档;以 `/` 开头时相对于包根目录。只提供包内的 PNG、JPEG、GIF、WebP、AVIF 和 SVG 文件,响应带有 `Content-Security-Policy: sandbox`、`X-Content-Type-Options: nosniff` 和 `Cache-Control: no-store`;其他路径或未列出的组合包返回 404。
+
+失败:`listBundles()` 未列出或无法解析其目录的组合包为 `plugin-manager/unknown-bundle`;无效语言为 `plugin-manager/invalid-language`;`plugin-manager/detail-unavailable` 携带 `reason`,取值为 `too-large`、`network`,或在 locale 文件或 `meta.detail` 格式错误、指定的文件不存在时为 `invalid`。图片路由对这些失败依次返回 404、413、502 和 400。
-----
diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json
index 08a101ef5f..333f88f939 100644
--- a/packages/boot/plugin-manager/package.json
+++ b/packages/boot/plugin-manager/package.json
@@ -67,7 +67,9 @@
"semver": "^7.8.5",
"yaml": "^2.9.0",
"zod": "4.4.3",
- "@deepseek-ai/dsh-util-values": "workspace:*"
+ "@deepseek-ai/dsh-util-values": "workspace:*",
+ "@deepseek-ai/dsh-plugin-detail": "workspace:*",
+ "@deepseek-ai/dsh-timeout": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -109,6 +111,7 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
- "@deepseek-ai/dsh-user-approval": "workspace:*"
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
+ "@deepseek-ai/dsh-client-connection": "workspace:*"
}
}
diff --git a/packages/boot/plugin-manager/src/bundle-detail.ts b/packages/boot/plugin-manager/src/bundle-detail.ts
new file mode 100644
index 0000000000..5cd16f4e2c
--- /dev/null
+++ b/packages/boot/plugin-manager/src/bundle-detail.ts
@@ -0,0 +1,104 @@
+/**
+ * Detail documents of profile bundles: an installed bundle's `meta.detail` file or README from its directory, and an
+ * official bundle the installation offers, before it is installed, from the npm file CDNs at its offered version. Reads
+ * keep no state; an image request resolves its bundle and document again.
+ */
+import semver from 'semver'
+import { resolveBundleDir } from '@deepseek-ai/dsh-app-boot'
+import type { ProfileContext } from '@deepseek-ai/dsh-app-boot'
+import {
+ DetailFailure, detailImage, languageChain, localFiles, npmFileSourceOrder, npmFiles, readPackageDocument,
+ type DetailFiles, type NpmFileSource,
+} from '@deepseek-ai/dsh-plugin-detail'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import type { BundleDetail, BundleInfo } from './types.ts'
+
+/** Limits and sources of bundle detail reads. */
+export interface BundleDetailOptions {
+ readonly profile: ProfileContext
+ readonly maxDocumentBytes: number
+ readonly maxImageBytes: number
+ readonly npmFileSources: readonly NpmFileSource[]
+ /** Deadline of one CDN request. */
+ readonly timeoutMs: number
+ /** The plugin manager's disposal. */
+ readonly lifetime: AbortSignal
+}
+
+/**
+ * The files of a listed bundle: its directory when its files can be read, otherwise its offered npm version.
+ * @param bundle - the bundle as `listBundles()` reports it.
+ * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`.
+ * @param options - profile, sources, and limits.
+ * @returns the files, or undefined for a bundle offered at no published version.
+ * @throws {RemoteError} `plugin-manager/unknown-bundle` when the bundle's directory cannot be resolved.
+ */
+function filesOf(bundle: BundleInfo, preferred: readonly string[], options: BundleDetailOptions): DetailFiles | undefined {
+ if (bundle.availability !== 'missing') {
+ let dir: string
+ try {
+ dir = resolveBundleDir('dsh', bundle.name, options.profile.installAnchor, options.profile.dir)
+ } catch (error) {
+ throw new RemoteError('plugin-manager/unknown-bundle', (error as Error).message, { name: bundle.name }, { cause: error })
+ }
+ // `listBundles()` reports the version of every bundle whose manifest it read; one without is read as unversioned.
+ return localFiles(dir, String(bundle.version))
+ }
+ const version = bundle.installTarget?.version
+ if (version === undefined || semver.valid(version) !== version) return undefined
+ const sources = npmFileSourceOrder(options.npmFileSources, preferred)
+ return npmFiles(bundle.name, version, sources, { timeoutMs: options.timeoutMs, lifetime: options.lifetime })
+}
+
+/** Rethrow a detail failure as the Remote error clients read. */
+function remoteFailure(error: unknown): never {
+ if (error instanceof DetailFailure) {
+ throw new RemoteError('plugin-manager/detail-unavailable', error.message, { reason: error.reason }, { cause: error })
+ }
+ throw error
+}
+
+/**
+ * Read a bundle's detail document in a display language.
+ * @param bundle - the bundle as `listBundles()` reports it.
+ * @param language - a BCP 47 language id.
+ * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`.
+ * @param options - profile, sources, and limits.
+ * @returns the document, or `none` when the bundle has neither a `meta.detail` file nor a README.
+ * @throws {RemoteError} `plugin-manager/invalid-language`, `plugin-manager/unknown-bundle`, or
+ * `plugin-manager/detail-unavailable`.
+ */
+export async function readBundleDocument(
+ bundle: BundleInfo, language: string, preferred: readonly string[], options: BundleDetailOptions,
+): Promise {
+ let languages: string[]
+ try {
+ languages = languageChain(language)
+ } catch (error) {
+ throw new RemoteError('plugin-manager/invalid-language', (error as Error).message, { language }, { cause: error })
+ }
+ const files = filesOf(bundle, preferred, options)
+ if (files === undefined) return { status: 'none' }
+ const document = await readPackageDocument(files, '', languages, options.maxDocumentBytes, options.lifetime).catch(remoteFailure)
+ return document === undefined ? { status: 'none' } : { status: 'found', ...document }
+}
+
+/**
+ * Read an image beside a bundle's detail document.
+ * @param bundle - the bundle as `listBundles()` reports it.
+ * @param document - the document's path inside the bundle, as {@link readBundleDocument} returned it.
+ * @param destination - the image destination as the document writes it.
+ * @param preferred - npm file CDNs to ask first for an offered version; see `npmFileSourceOrder`.
+ * @param options - profile, sources, and limits.
+ * @returns the bytes and media type, or undefined for a path outside the bundle, an unsupported type, or a missing file.
+ * @throws {RemoteError} `plugin-manager/unknown-bundle` or `plugin-manager/detail-unavailable`.
+ */
+export async function readBundleImage(
+ bundle: BundleInfo, document: string, destination: string, preferred: readonly string[], options: BundleDetailOptions,
+): Promise<{ bytes: Uint8Array; mediaType: string } | undefined> {
+ const image = detailImage(document, destination)
+ const files = image === undefined ? undefined : filesOf(bundle, preferred, options)
+ if (image === undefined || files === undefined) return undefined
+ const bytes = await files.read(image.path, options.maxImageBytes, options.lifetime).catch(remoteFailure)
+ return bytes === undefined ? undefined : { bytes, mediaType: image.mediaType }
+}
diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts
index 1894e6b4da..16bc5ee932 100644
--- a/packages/boot/plugin-manager/src/index.ts
+++ b/packages/boot/plugin-manager/src/index.ts
@@ -10,7 +10,12 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import z from '@deepseek-ai/schemastery'
import semver from 'semver'
-import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
+import { TypertRemoteService, Remote, RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import { detailImageResponse, NPM_FILE_SOURCES } from '@deepseek-ai/dsh-plugin-detail'
+import type { NpmFileSource } from '@deepseek-ai/dsh-plugin-detail/types'
+import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
+import type {} from '@deepseek-ai/dsh-client-connection'
+import { readBundleDocument, readBundleImage, type BundleDetailOptions } from './bundle-detail.ts'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
import {
readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
@@ -32,8 +37,8 @@ import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts'
import type {
- BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError, PackageResult,
- PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
+ BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError,
+ PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
PluginRegistries, PluginSpecInspection, PluginUpdateResult, PluginUpdateStep, PluginVersion, PluginVersionList, Registry,
RegistryAttempt, UpdateBundleOptions,
} from './types.ts'
@@ -67,8 +72,19 @@ export interface Config {
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
+ /** Largest bundle detail document, and largest locale file read for one, in bytes. */
+ detailMaxBytes?: number
+ /** Largest image served beside a bundle detail document, in bytes. */
+ detailImageMaxBytes?: number
+ /** Deadline of one npm file CDN request for an offered bundle's detail document. */
+ detailFetchTimeoutMs?: number
+ /** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
+ detailNpmFileSources?: NpmFileSource[]
}
+/** The Fetch route serving images beside bundle detail documents. */
+const DETAIL_FILE_PATH = '/api/plugin-manager/detail-file'
+
/** An http(s) URL, as pnpm's `--registry` takes it. */
const REGISTRY_URL = /^https?:\/\/\S+$/
@@ -288,6 +304,10 @@ export class PluginManager extends TypertRemoteService {
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
versionListLimit: z.number().step(1).min(1).default(20),
versionLookupConcurrency: z.number().step(1).min(1).default(4),
+ detailMaxBytes: z.natural().min(1).default(1024 * 1024),
+ detailImageMaxBytes: z.natural().min(1).default(5 * 1024 * 1024),
+ detailFetchTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(15000),
+ detailNpmFileSources: z.array(z.union(NPM_FILE_SOURCES)).min(1).default([...NPM_FILE_SOURCES]),
})
/** Management bundles remain protected if their files become unreadable. */
private readonly managementBundles = new Set()
@@ -312,6 +332,7 @@ export class PluginManager extends TypertRemoteService {
private readonly abort = new AbortController()
/** Installations by request id, from their call until it settles. */
private readonly installs = new Map()
+ private readonly detail: BundleDetailOptions
constructor(ctx: Context, config: Config) {
super(ctx, 'pluginManager')
@@ -331,10 +352,64 @@ export class PluginManager extends TypertRemoteService {
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
fallbackRegistries: (config as Required).fallbackRegistries.map(normalizeRegistry),
}
+ const required = config as Required
+ this.detail = {
+ profile: this.profile, maxDocumentBytes: required.detailMaxBytes, maxImageBytes: required.detailImageMaxBytes,
+ npmFileSources: required.detailNpmFileSources, timeoutMs: required.detailFetchTimeoutMs, lifetime: this.abort.signal,
+ }
ctx.effect(() => async () => {
this.abort.abort()
await Promise.allSettled([...this.packageOperations])
}, 'plugin-manager: package cancellation')
+ ctx.inject(['connection'], (scope) => {
+ scope.effect(() => scope.connection.fetch.register({
+ path: DETAIL_FILE_PATH, methods: ['GET'], requestBody: 'buffered', fetch: request => this.serveDetailFile(request),
+ }), `plugin-manager: ${DETAIL_FILE_PATH}`)
+ })
+ }
+
+ /**
+ * Read a bundle's long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its
+ * README. An installed bundle is read from its directory without network access; an official bundle the installation
+ * offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.
+ * @param name - the bundle's package name, as `listBundles()` lists it.
+ * @param language - the display language, such as `zh-CN`; documents fall back through its prefixes to English.
+ * @param options - `npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one
+ * a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.
+ * @returns the document with its origin and path, or `none`.
+ * @throws {RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or
+ * `plugin-manager/detail-unavailable`.
+ */
+ @Remote
+ async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise {
+ return await readBundleDocument(await this.listedBundle(name), language, options?.npmFileSources ?? [], this.detail)
+ }
+
+ private async listedBundle(name: string): Promise {
+ const bundle = (await this.listBundles()).find(candidate => candidate.name === name)
+ if (bundle === undefined) throw new RemoteError('plugin-manager/unknown-bundle', `No such bundle: ${name}`, { name })
+ return bundle
+ }
+
+ /**
+ * Answer `GET ${DETAIL_FILE_PATH}?bundle=&document=&path=[&sources=,...]` with one image
+ * beside a bundle document; `sources` names npm file CDNs to ask first for an offered bundle, as `readBundleDetail` does.
+ */
+ private async serveDetailFile(request: Request): Promise {
+ const params = new URL(request.url).searchParams
+ const [bundle, document, path] = [params.get('bundle'), params.get('document'), params.get('path')]
+ // Images are read again on every request, so a reinstalled bundle shows its own.
+ const cache = 'no-store'
+ if (bundle === null || document === null || path === null) return detailImageResponse('invalid', cache)
+ try {
+ const image = await readBundleImage(await this.listedBundle(bundle), document, path, params.get('sources')?.split(',') ?? [], this.detail)
+ return detailImageResponse(image, cache)
+ } catch (error) {
+ const failure = remoteErrorOf(error)
+ if (failure?.code === 'plugin-manager/unknown-bundle') return detailImageResponse(undefined, cache)
+ if (failure?.code === 'plugin-manager/detail-unavailable') return detailImageResponse(failure.details.reason, cache)
+ throw error
+ }
}
/** Read exact plugin-version exemptions saved in this profile.
diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts
index c401217d49..e4909d5555 100644
--- a/packages/boot/plugin-manager/src/types.ts
+++ b/packages/boot/plugin-manager/src/types.ts
@@ -4,6 +4,23 @@ 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'
+import type {} from '@deepseek-ai/dsh-typert-protocol'
+import type { DetailFailureReason } from '@deepseek-ai/dsh-plugin-detail/types'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+ interface RemoteErrorDetailsMap {
+ /** The profile lists no such bundle, or its directory cannot be resolved. */
+ 'plugin-manager/unknown-bundle': { readonly name: string }
+ /** The display language is not a BCP 47 language id. */
+ 'plugin-manager/invalid-language': { readonly language: string }
+ /**
+ * A bundle's detail document or image exists but cannot be read: `too-large` beyond a size limit, `network` when the
+ * npm file CDNs cannot be reached, `invalid` for a malformed locale file or `meta.detail`, or a named file that does
+ * not exist. Bundle files come from local directories and public CDNs, which never answer `denied`.
+ */
+ 'plugin-manager/detail-unavailable': { readonly reason: DetailFailureReason }
+ }
+}
/** Reasons a profile control cannot modify its target. */
export type ReadOnlyReason = 'management-required' | 'unaddressable'
@@ -341,6 +358,22 @@ export interface PluginInstallLogChunk {
readonly exitCode?: number | null
}
+/**
+ * A bundle's long description, or `none` when it has neither a `meta.detail` file nor a README. Images resolve beside
+ * `path` through `GET /api/plugin-manager/detail-file?bundle=&document=&path=`.
+ */
+export type BundleDetail =
+ | {
+ readonly status: 'found'
+ /** `locale` for the file locale `meta.detail` names, `readme` for the package's README. */
+ readonly origin: 'locale' | 'readme'
+ /** `/`-separated path of the document inside the package. */
+ readonly path: string
+ /** Markdown text without a byte order mark or a leading YAML front matter block. */
+ readonly markdown: string
+ }
+ | { readonly status: 'none' }
+
/** What changed in the profile, for consumers that show it. */
export interface PluginChange {
/** The operation that changed it. */
diff --git a/packages/boot/plugin-manager/tests/bundle-detail.spec.ts b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts
new file mode 100644
index 0000000000..8f1de9641c
--- /dev/null
+++ b/packages/boot/plugin-manager/tests/bundle-detail.spec.ts
@@ -0,0 +1,177 @@
+/** Bundle detail documents and their images through a real profile Include and Loader. */
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { realpath } from 'node:fs/promises'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { Service, type Context } from '@deepseek-ai/cordis'
+import { boot, getDshRuntimeVersion, initProfile, loadProfileDirectory, OFFICIAL_ON_DEMAND_CATALOG, readProfileManifest, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+import PluginManager, { type Config } from '../src/index.ts'
+import * as operations from '../src/operations.ts'
+
+afterEach(() => { vi.unstubAllGlobals() })
+
+/** The `connection` Fetch route registry, recording the routes the manager registers. */
+class RecordingConnection extends Service {
+ /** The instance the latest fixture mounted. */
+ static current: RecordingConnection | undefined
+ readonly routes = new Map Promise>()
+ readonly fetch = {
+ register: (route: { path: string; fetch: (request: Request) => Promise }) => {
+ this.routes.set(route.path, route.fetch)
+ return () => Promise.resolve(void this.routes.delete(route.path))
+ },
+ }
+
+ constructor(ctx: Context) {
+ super(ctx, 'connection')
+ RecordingConnection.current = this
+ }
+}
+
+async function fixture(config: Config = {}) {
+ const home = await realpath(mkdtempSync(join(tmpdir(), 'plugin-manager-detail-')))
+ let owner: Context | undefined
+ onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
+ const dir = join(home, 'profiles', 'test')
+ writeFileSync(join(home, 'package.json'), JSON.stringify({ name: 'installation', dependencies: {} }))
+ initProfile(dir, ['core', 'documented'])
+ const bundle = (name: string, files: Record) => {
+ const path = join(dir, 'node_modules', name)
+ for (const [file, contents] of Object.entries({ 'cordis.patch.yml': '[]', ...files })) {
+ mkdirSync(join(path, file, '..'), { recursive: true })
+ writeFileSync(join(path, file), contents)
+ }
+ writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
+ }
+ bundle('core', { 'cordis.patch.yml': JSON.stringify([{ insert: [{ id: 'manager', name: 'cordis:manager', config }, { id: 'connection', name: 'cordis:connection' }] }]) })
+ bundle('documented', {
+ 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }),
+ 'locale/zh.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.zh.md' } }),
+ 'guide/DETAIL.md': '---\ntitle: x\n---\n# Documented\n\n\n',
+ 'guide/DETAIL.zh.md': '# 有说明\n',
+ 'guide/img/shot.svg': '',
+ })
+ bundle('plain', { 'readme.md': '# Plain' })
+ bundle('bare', {})
+ bundle('broken', { 'locale/en.json': JSON.stringify({ meta: { detail: '../outside.md' } }) })
+ const manifest = readProfileManifest('test', dir)
+ manifest.dependencies = { documented: '1.0.0', plain: '1.0.0', bare: '1.0.0', broken: '1.0.0' }
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+ writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+ const profile: ProfileContext = {
+ name: 'test', startedBundles: loadProfileDirectory('test', dir, join(home, 'package.json')).layers.map(layer => layer.packageName),
+ dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: join(home, 'package.json'), cwd: home, home, overlays: [], telemetryDisabledEnv: undefined,
+ }
+ const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (root) => {
+ owner = root
+ root.provide('profileContext', profile)
+ Object.assign(root.loader.builtins, { manager: PluginManager, connection: RecordingConnection })
+ })
+ const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/')
+ onTestFinished(() => { registry.mockRestore() })
+ const connection = RecordingConnection.current as RecordingConnection
+ const image = (query: string) => (connection.routes.get('/api/plugin-manager/detail-file') as (request: Request) => Promise)(
+ new Request(`http://dsh.local/api/plugin-manager/detail-file?${query}`))
+ return { manager: ctx.pluginManager, image, connection }
+}
+
+const failureOf = async (promise: Promise) => remoteErrorOf(await promise.catch((error: unknown) => error))
+
+it('reads an installed bundle\'s localized meta.detail file or README from its directory', async () => {
+ const fetch = vi.fn(() => Promise.reject(new Error('no network')))
+ vi.stubGlobal('fetch', fetch)
+ const { manager } = await fixture()
+ expect(await manager.readBundleDetail('documented', 'en-US')).toEqual({
+ status: 'found', origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Documented\n\n\n',
+ })
+ expect(await manager.readBundleDetail('documented', 'zh-CN')).toMatchObject({ origin: 'locale', markdown: '# 有说明\n' })
+ expect(await manager.readBundleDetail('plain', 'en')).toEqual({ status: 'found', origin: 'readme', path: 'readme.md', markdown: '# Plain' })
+ expect(await manager.readBundleDetail('bare', 'en')).toEqual({ status: 'none' })
+ expect(fetch).not.toHaveBeenCalled()
+ expect(await failureOf(manager.readBundleDetail('broken', 'en'))).toMatchObject({ code: 'plugin-manager/detail-unavailable', details: { reason: 'invalid' } })
+ expect(await failureOf(manager.readBundleDetail('absent', 'en'))).toMatchObject({ code: 'plugin-manager/unknown-bundle', details: { name: 'absent' } })
+ expect(await failureOf(manager.readBundleDetail('plain', 'x y'))).toMatchObject({ code: 'plugin-manager/invalid-language', details: { language: 'x y' } })
+})
+
+it('serves images beside a bundle document without caching and refuses other paths', async () => {
+ const { image, manager, connection } = await fixture({ detailImageMaxBytes: 20 })
+ const ok = await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')
+ expect([ok.status, ok.headers.get('content-type'), ok.headers.get('cache-control'), ok.headers.get('content-security-policy')?.startsWith('sandbox')])
+ .toEqual([413, 'text/plain; charset=utf-8', 'no-store', true])
+ expect(await ok.text()).toBe('too-large')
+ expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=..%2F..%2Fpackage.json')).status).toBe(404)
+ expect((await image('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fmissing.png')).status).toBe(404)
+ expect((await image('bundle=absent&document=README.md&path=a.png')).status).toBe(404)
+ expect((await image('bundle=documented&document=README.md')).status).toBe(400)
+ const read = vi.spyOn(manager, 'listBundles').mockRejectedValueOnce(new TypeError('bug'))
+ await expect(image('bundle=documented&document=README.md&path=a.png')).rejects.toThrow('bug')
+ read.mockRestore()
+ const { image: roomy } = await fixture()
+ const served = await roomy('bundle=documented&document=guide%2FDETAIL.md&path=img%2Fshot.svg')
+ expect([served.status, served.headers.get('content-type')]).toEqual([200, 'image/svg+xml'])
+ expect(await served.text()).toContain('
)}
+
+
+
+
+
{t('versionsTitle')}
+
+ {entry.versions.map(item => (
+
+ ))}
+
+
{t('factSource')}
@@ -259,14 +275,6 @@ function EntryDetail({ entry, listing, t, face, language, onBack, onInstall, onO
{t('factMarket')}
{face.resolveText(listing.title)}
-
-
{t('versionsTitle')}
-
- {entry.versions.map(item => (
-
- ))}
-
-
)
}
@@ -386,11 +394,12 @@ function unfiltered(query: DiscoverQuery): boolean {
return query.text.trim() === '' && query.category === null && query.labels.length === 0
}
-function Browser({ state, listing, t, face, language, onInstall, openPackage }: {
+function Browser({ state, listing, t, face, hooks, language, onInstall, openPackage }: {
readonly state: DiscoverState
readonly listing: ListingState
readonly t: Translate
readonly face: DiscoverActions
+ readonly hooks: Pick
readonly language: string
readonly onInstall: (choice: InstallChoice) => void
readonly openPackage: (name: string) => void
@@ -413,7 +422,7 @@ function Browser({ state, listing, t, face, language, onInstall, openPackage }:
const openEntry = opened === null ? undefined : value?.entries.find(entry => entry.name === opened.name) ?? opened
if (openEntry !== undefined && value !== null) {
return (
- { setOpened(null) }}
+ { setOpened(null) }}
onInstall={(version) => { onInstall({ entry: openEntry, version }) }} onOpenInstalled={() => { openPackage(openEntry.name) }} />
)
}
@@ -580,7 +589,8 @@ export function DiscoverSection(props: DiscoverSectionProps): ReactNode {
)
} else {
body = (
- { setConfirming(choice) }} openPackage={openPackage} />
)
}
diff --git a/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx b/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx
new file mode 100644
index 0000000000..a560f1674a
--- /dev/null
+++ b/packages/client/ui-plugin-market/src/client/PluginDetailSection.tsx
@@ -0,0 +1,47 @@
+/** A market entry's long description: the Markdown document the Host reads for one listed version. */
+import { useEffect, useMemo, type ReactNode } from 'react'
+import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import { detailKey, type DetailFace, type DetailFailureKind, type DetailRequest } from './market-store.ts'
+
+const FAILURE_KEYS = {
+ 'too-large': 'detailTooLarge',
+ 'network': 'detailNetwork',
+ 'denied': 'detailDenied',
+ 'git-unavailable': 'detailGitUnavailable',
+ 'invalid': 'detailInvalid',
+ 'other': 'detailFailed',
+} as const satisfies Record
+
+/** Props of the detail section: the entry version, the dictionary, and the injected detail reads. */
+export type PluginDetailSectionProps = { readonly request: DetailRequest } & PropsLocale<'pluginMarket'> & InjectFace
+
+/**
+ * Read and render an entry version's detail document in the active language. Relative images load from the Host beside
+ * the document.
+ * @param props - the entry version, the dictionary, and the injected detail reads.
+ * @returns the section, or null when the version has no document.
+ */
+export function PluginDetailSection(props: PluginDetailSectionProps): ReactNode {
+ const { request, t, loadDetail, retryDetail, detailImage } = props
+ const language = props.useLocale(snapshot => snapshot.active)
+ const key = detailKey(request, language)
+ const state = props.useDetails(details => details[key])
+ // The key names the request completely; a read the controller forgot, after a connection reset, starts again.
+ const missing = state === undefined
+ useEffect(() => { if (missing) loadDetail(request, language) }, [key, missing])
+ const labels = useDetailDocumentLabels({
+ title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'),
+ codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('footnotes'),
+ })
+ const detail = state?.status === 'ready' ? state.detail : undefined
+ const document = detail?.status === 'found' ? detail.document : undefined
+ const images = useMemo(
+ () => document === undefined ? undefined : { resolve: value => detailImage(document, value) },
+ [document, detailImage],
+ )
+ const shown = detailDocumentState(state?.status === 'error'
+ ? { status: 'error', message: t(FAILURE_KEYS[state.failure]) }
+ : state === undefined || state.status === 'loading' ? state : { status: 'ready', markdown: detail?.status === 'found' ? detail.markdown : undefined })
+ return { retryDetail(request, language) }} />
+}
diff --git a/packages/client/ui-plugin-market/src/client/index.ts b/packages/client/ui-plugin-market/src/client/index.ts
index 754267f2a5..9d92d5da7a 100644
--- a/packages/client/ui-plugin-market/src/client/index.ts
+++ b/packages/client/ui-plugin-market/src/client/index.ts
@@ -1,6 +1,7 @@
/**
* Discover, browser half: the Plugins page tab that browses the plugin market indexes the user configures through the
- * `pluginMarket` Remote and installs an entry through the page's install dialog.
+ * `pluginMarket` Remote and installs an entry through the page's install dialog; entry pages show each version's
+ * detail document.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -28,7 +29,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
const NS = 'pluginMarket'
/** Services the section reads. */
-export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket']
+export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket', 'remote.pluginRegistryProbe']
/**
* Contribute Discover to the Plugins page and keep it current on Host changes.
@@ -36,13 +37,16 @@ export const inject = ['slots', 'locale', 'remote', 'remote.pluginMarket']
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-market: dictionaries')
- const controller = new DiscoverController(ctx)
+ const controller = new DiscoverController(ctx, () => document.baseURI)
ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-market: controller')
ctx.effect(() => {
const disposers = [
ctx.remote.$on('plugin-market/changed', () => { controller.sourcesChanged() }),
ctx.remote.$on('plugin-manager/changed', () => { controller.bundlesChanged() }),
- ctx.on('connection/reset', () => { controller.sourcesChanged() }),
+ ctx.on('connection/reset', () => {
+ controller.sourcesChanged()
+ controller.detailsReset()
+ }),
]
return () => { for (const dispose of disposers) dispose() }
}, 'ui-plugin-market: host invalidations')
diff --git a/packages/client/ui-plugin-market/src/client/locales.ts b/packages/client/ui-plugin-market/src/client/locales.ts
index 63bc504cea..924ff10b43 100644
--- a/packages/client/ui-plugin-market/src/client/locales.ts
+++ b/packages/client/ui-plugin-market/src/client/locales.ts
@@ -80,6 +80,15 @@ export const zh = {
versionPrerelease: '预发布',
confirmTitle: '安装「{name}」?',
installTrust: '插件在本机以你的权限运行。请确认你信任它的来源',
+ detailTitle: '详细介绍',
+ detailLoading: '正在读取详细介绍',
+ detailTooLarge: '详细介绍或它所在的仓库超过大小限制,未加载',
+ detailNetwork: '无法连接到详细介绍的来源',
+ detailDenied: '无法访问详细介绍所在的仓库,请检查 Git 凭据',
+ detailGitUnavailable: '详细介绍所在的仓库需要用 Git 读取,但无法运行 Git。请安装 Git 后重试',
+ detailInvalid: '插件声明的详细介绍文件无效',
+ detailFailed: '无法读取详细介绍',
+ footnotes: '脚注',
} as const
/** Dictionary keys owned by this plugin. */
@@ -165,4 +174,13 @@ export const en = {
versionPrerelease: 'Prerelease',
confirmTitle: 'Install "{name}"?',
installTrust: 'Plugins run on this machine with your permissions. Make sure you trust where this one comes from.',
+ detailTitle: 'Details',
+ detailLoading: 'Reading details',
+ detailTooLarge: 'The details or their repository exceed the size limit and were not loaded.',
+ detailNetwork: 'Could not reach the source of the details.',
+ detailDenied: 'Could not access the repository of the details. Check your Git credentials.',
+ detailGitUnavailable: 'The details are in a repository that must be read with Git, and Git could not run. Install Git, then retry.',
+ detailInvalid: 'The plugin declares an invalid details file.',
+ detailFailed: 'Could not read the details.',
+ footnotes: 'Footnotes',
} satisfies Record
diff --git a/packages/client/ui-plugin-market/src/client/market-store.ts b/packages/client/ui-plugin-market/src/client/market-store.ts
index 110acc4773..43d5d2b721 100644
--- a/packages/client/ui-plugin-market/src/client/market-store.ts
+++ b/packages/client/ui-plugin-market/src/client/market-store.ts
@@ -1,9 +1,10 @@
/** Discover state: configured index sources, the selected index's query and loaded results, and source editing. */
+import { NPMMIRROR_REGISTRY } from '@deepseek-ai/dsh-plugin-manager/registry'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest'
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
-import type { MarketIndexSummary, MarketSearchResult, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import type { MarketIndexSummary, MarketSearchResult, PluginDetail, PluginDetailDocumentId, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { declaredQuery, INITIAL_QUERY, type DiscoverQuery } from './presentation.ts'
@@ -48,13 +49,61 @@ export interface DiscoverState {
readonly sourceFailure: SourceFailure | null
}
-/** What the Discover tab receives through injection. */
-export interface DiscoverFace {
+/** The market entry version a detail read is about. */
+export interface DetailRequest {
+ readonly url: string
+ readonly name: string
+ readonly version: string
+ /** Whether the version is an npm package; the Host reads those of the public registries from the npm file CDNs. */
+ readonly npm: boolean
+}
+
+/** Why a detail read failed: the Host's `plugin-market/detail-unavailable` reason, or `other` for any other failure. */
+export type DetailFailureKind = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid' | 'other'
+
+/** One detail read. */
+export type DetailState =
+ | { readonly status: 'loading' }
+ | { readonly status: 'ready'; readonly detail: PluginDetail }
+ | { readonly status: 'error'; readonly failure: DetailFailureKind }
+
+/** Detail reads by {@link detailKey}. */
+export type DetailsState = Readonly>
+
+/**
+ * The identity of a detail read.
+ * @param request - the plugin.
+ * @param language - the display language.
+ * @returns the key of the read in {@link DetailsState}.
+ */
+export function detailKey(request: DetailRequest, language: string): string {
+ return JSON.stringify([language, request])
+}
+
+/** What the entry detail section receives through injection. */
+export interface DetailFace {
readonly hooks: {
+ /** Detail reads, bound as `useDetails`. */
+ readonly details: SnapshotStore
+ /** The active locale, bound as `useLocale`. */
+ readonly locale: HostObservable
+ }
+ /** Read a plugin's detail document unless the read already ran or runs. */
+ readonly loadDetail: (request: DetailRequest, language: string) => void
+ /** Read a failed detail document again, bypassing the Host's cached result. */
+ readonly retryDetail: (request: DetailRequest, language: string) => void
+ /**
+ * The URL of an image beside a detail document.
+ * @returns the authenticated Host URL, or undefined for an absolute, protocol-relative, or fragment destination.
+ */
+ readonly detailImage: (document: PluginDetailDocumentId, destination: string) => string | undefined
+}
+
+/** What the Discover tab receives through injection. */
+export interface DiscoverFace extends DetailFace {
+ readonly hooks: DetailFace['hooks'] & {
/** Discover state, bound as `useDiscover`. */
readonly discover: SnapshotStore
- /** The active locale, bound as `useLocale`, for collation. */
- readonly locale: HostObservable
}
/** Resolve market text in the current language. */
readonly resolveText: (text: LocalizedText) => string
@@ -90,6 +139,13 @@ function sourceFailure(failure: RemoteFailure): SourceFailure {
return { kind: 'other', reason: failure.message }
}
+function detailFailure(failure: RemoteFailure): DetailFailureKind {
+ return failure.code === 'plugin-market/detail-unavailable' ? failure.details.reason : 'other'
+}
+
+/** The Host route of images beside detail documents, relative to the application base. */
+const DETAIL_FILE_ROUTE = 'api/plugin-market/detail-file'
+
function listingFailure(failure: RemoteFailure): ListingFailure {
return failure.code === 'plugin-market/invalid-index' ? 'invalid' : 'fetch'
}
@@ -98,7 +154,13 @@ function listingFailure(failure: RemoteFailure): ListingFailure {
export class DiscoverController {
/** Shared Discover state published to the tab. */
readonly store: SnapshotStore = createSnapshotStore(INITIAL)
+ /** Detail reads published to detail sections. */
+ readonly details: SnapshotStore = createSnapshotStore({})
private disposed = false
+ /** The latest detail read of each key; an older read's answer is dropped. */
+ private readonly detailReads = new Map()
+ /** Numbers detail reads across the controller's lifetime, so a read started before a reset never matches one after it. */
+ private detailSequence = 0
private indexRead = 0
private listingRead = 0
/** Select the last source once the list it was added to arrives. */
@@ -106,8 +168,11 @@ export class DiscoverController {
/** Runs the listing read that failed last. */
private failed: () => void = () => {}
- /** @param ctx - the plugin context whose `remote.pluginMarket` namespace answers. */
- constructor(private readonly ctx: ClientContext) {}
+ /**
+ * @param ctx - the plugin context whose `remote.pluginMarket` namespace answers.
+ * @param base - the application base URL that Host routes resolve against.
+ */
+ constructor(private readonly ctx: ClientContext, private readonly base: () => string) {}
/** Stop publishing late settlements. */
dispose(): void {
@@ -120,8 +185,10 @@ export class DiscoverController {
* @returns the tab's injected state sources and actions.
*/
face(resolveText: DiscoverFace['resolveText']): DiscoverFace {
+ const detail = this.detailFace()
return {
- hooks: { discover: this.store, locale: this.ctx.locale },
+ ...detail,
+ hooks: { ...detail.hooks, discover: this.store },
resolveText,
ensure: () => { if (this.state().status === 'idle') void this.loadIndexes() },
reload: () => { void this.loadIndexes() },
@@ -152,6 +219,30 @@ export class DiscoverController {
}
}
+ /** Detail reads and image addressing of the entry detail section. */
+ private detailFace(): DetailFace {
+ return {
+ hooks: { details: this.details, locale: this.ctx.locale },
+ loadDetail: (request, language) => {
+ if (this.details.getSnapshot()[detailKey(request, language)] === undefined) void this.readDetail(request, language)
+ },
+ retryDetail: (request, language) => { void this.readDetail(request, language, true) },
+ detailImage: (document, destination) => {
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/iu.test(destination)) return undefined
+ return new URL(`${DETAIL_FILE_ROUTE}?document=${encodeURIComponent(document)}&path=${encodeURIComponent(destination)}`, this.base()).href
+ },
+ }
+ }
+
+ /**
+ * Forget every detail read after the connection reset: a restarted Host no longer knows the document ids that address
+ * their images, and each shown section reads its document again.
+ */
+ detailsReset(): void {
+ this.detailReads.clear()
+ this.details.set({})
+ }
+
/** Re-read the sources after the Host announced a change or the connection reset; an unrendered tab stays idle. */
sourcesChanged(): void {
if (this.state().status !== 'idle') void this.loadIndexes()
@@ -164,6 +255,32 @@ export class DiscoverController {
if (listing?.status === 'ready') void this.load(listing.url, listing.query, Math.max((listing.value as MarketSearchResult).entries.length, PAGE_SIZE), false)
}
+ private async readDetail(request: DetailRequest, language: string, refresh = false): Promise {
+ const key = detailKey(request, language)
+ const read = ++this.detailSequence
+ this.detailReads.set(key, read)
+ this.publishDetail(key, { status: 'loading' })
+ // The remote is captured before any wait: the plugin's context may be disposed while the comparison runs.
+ const { remote } = this.ctx
+ const current = (): boolean => !this.disposed && this.detailReads.get(key) === read
+ // Only an npm version waits for the registry comparison; a Git version is read at once.
+ // npmmirror is asked first where its registry answered the Host's comparison fastest.
+ let npmFileSources: 'npmmirror'[] = []
+ if (request.npm) {
+ const fastest = await remote.pluginRegistryProbe.fastest()
+ // A retry or reset started while the comparison ran settles the key instead.
+ if (!current()) return
+ if (fastest.ok && fastest.value === NPMMIRROR_REGISTRY) npmFileSources = ['npmmirror']
+ }
+ const { url, name, version } = request
+ const answer = await remote.pluginMarket.readEntryDetail(url, name, version, language, { refresh, npmFileSources })
+ if (current()) this.publishDetail(key, answer.ok ? { status: 'ready', detail: answer.value } : { status: 'error', failure: detailFailure(answer.error) })
+ }
+
+ private publishDetail(key: string, state: DetailState): void {
+ this.details.set({ ...this.details.getSnapshot(), [key]: state })
+ }
+
private state(): DiscoverState {
return this.store.getSnapshot()
}
diff --git a/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts b/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts
index 760009b0d9..f17e52fabe 100644
--- a/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts
+++ b/packages/client/ui-plugin-market/tests/browser-plugin.client.spec.ts
@@ -6,6 +6,8 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
import { expect, it, onTestFinished, vi } from 'vitest'
import { apply, inject } from '../src/client/index.ts'
+import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client'
+import { brandString } from '@deepseek-ai/dsh-brand'
import type { DiscoverFace } from '../src/client/market-store.ts'
it('contributes Discover to the Plugins page, follows Host changes, and withdraws with its fiber', async () => {
@@ -18,7 +20,7 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw
read: vi.fn(),
setIndexes: vi.fn(),
}
- const remote = new TestRemote(ctx, { pluginMarket: market })
+ const remote = new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: { fastest: vi.fn() } })
const slots = ctx.get('slots') as SlotRegistry
slots.register({ name: 'root', children: { 'plugins.tab': { kind: 'list', scope: 'root' } } } as never, () => null)
const fiber = await ctx.plugin({ inject: [...inject], apply }).await()
@@ -32,6 +34,8 @@ it('contributes Discover to the Plugins page, follows Host changes, and withdraw
const injected: object = entry!.inject!()
const face = injected as DiscoverFace
expect(face.resolveText({ en: 'English' })).toBe('English')
+ expect(face.detailImage(brandString('d'), 'img/a b.png'))
+ .toBe(new URL('api/plugin-market/detail-file?document=d&path=img%2Fa%20b.png', document.baseURI).href)
face.ensure()
await vi.waitFor(() => { expect(face.hooks.discover.getSnapshot().status).toBe('ready') })
remote.emit('plugin-market/changed', [])
diff --git a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx
index f0e4796ea0..88ee3e6cd3 100644
--- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx
+++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx
@@ -6,7 +6,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
import { DiscoverSection, type DiscoverSectionProps } from '../src/client/DiscoverSection.tsx'
-import type { DiscoverState, ListingState } from '../src/client/market-store.ts'
+import type { DetailsState, DiscoverState, ListingState } from '../src/client/market-store.ts'
import { INITIAL_QUERY, type DiscoverQuery } from '../src/client/presentation.ts'
import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts'
@@ -86,6 +86,7 @@ function renderTab(state: Partial = {}, dictionary: Record undefined),
}
const install = vi.fn()
const openPackage = vi.fn()
@@ -94,6 +95,7 @@ function renderTab(state: Partial = {}, dictionary: Record) => typeof text === 'string' ? text : text[language] ?? text.en,
useDiscover: bindSnapshotSelector(store),
useLocale: bindSnapshotSelector(locale),
+ useDetails: bindSnapshotSelector(createSnapshotStore({})),
} as DiscoverSectionProps
render()
const set = (next: Partial): void => { act(() => { store.set({ ...store.getSnapshot(), ...next }) }) }
@@ -278,9 +280,11 @@ describe('DiscoverSection', () => {
})
it('opens an entry page with its source facts and actions, and returns to the list', () => {
- const { install, openPackage } = renderTab()
+ const { install, openPackage, actions } = renderTab()
fireEvent.click(card('dsh-a').getByRole('button', { name: 'View Alpha' }))
const detail = within(document.querySelector('[data-market-detail="dsh-a"]') as HTMLElement)
+ expect(actions.loadDetail).toHaveBeenCalledWith({ url: A, name: 'dsh-a', version: '1.0.0', npm: true }, 'en')
+ expect(detail.getByRole('status', { name: en.detailLoading })).toBeTruthy()
expect(detail.getByRole('heading', { name: 'Alpha' })).toBeTruthy()
expect(detail.getByText('dsh-a')).toBeTruthy()
for (const value of ['Jane', 'dsh-a@1.0.0', 'https://r.example/', 'Tools', 'Official', 'Beta', 'Alpha market']) expect(detail.getByText(value)).toBeTruthy()
@@ -297,6 +301,7 @@ describe('DiscoverSection', () => {
fireEvent.click(detail.getByRole('button', { name: en.backToList }))
fireEvent.click(card('dsh-b').getByRole('button', { name: 'View dsh-b' }))
const git = within(document.querySelector('[data-market-detail="dsh-b"]') as HTMLElement)
+ expect(actions.loadDetail).toHaveBeenLastCalledWith({ url: A, name: 'dsh-b', version: '1.0.0', npm: false }, 'en')
expect(git.queryByRole('link')).toBeNull()
for (const value of ['https://github.com/o/r', COMMIT, 'packages/b', 'Themes']) expect(git.getByText(value)).toBeTruthy()
fireEvent.click(git.getByRole('button', { name: 'View dsh-b in Installed' }))
diff --git a/packages/client/ui-plugin-market/tests/market-store.client.spec.ts b/packages/client/ui-plugin-market/tests/market-store.client.spec.ts
index 203fca4c37..4bb0646530 100644
--- a/packages/client/ui-plugin-market/tests/market-store.client.spec.ts
+++ b/packages/client/ui-plugin-market/tests/market-store.client.spec.ts
@@ -1,9 +1,12 @@
import { Context } from '@deepseek-ai/cordis'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
-import type { MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type {
+ MarketIndexSummary, MarketListingEntry, MarketQuery, MarketSearchResult, PluginDetail, PluginDetailDocumentId,
+} from '@deepseek-ai/dsh-api-remotes/client'
+import { brandString } from '@deepseek-ai/dsh-brand'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
-import { DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts'
+import { detailKey, DiscoverController, PAGE_SIZE } from '../src/client/market-store.ts'
import { INITIAL_QUERY } from '../src/client/presentation.ts'
const A = 'https://a.example/index.json'
@@ -45,11 +48,15 @@ function setup(indexes: MarketIndexSummary[] = [{ url: A }, { url: B }]) {
listIndexes: vi.fn(() => ok(state.indexes)),
search: vi.fn((url: string, _query: MarketQuery) => ok(listing(url, url === A ? 'A' : 'B', url === A ? 'Desc' : undefined))),
setIndexes: vi.fn((urls: string[]) => { state.indexes = urls.map(url => ({ url: url.trim() })); return ok(undefined) }),
+ readEntryDetail: vi.fn(
+ (_url: string, _name: string, _version: string, _language: string, _options: object): Promise> => ok({ status: 'none' }),
+ ),
}
- new TestRemote(ctx, { pluginMarket: market })
- const controller = new DiscoverController(ctx)
+ const probe = { fastest: vi.fn((): Promise> => ok('https://registry.npmmirror.com/')) }
+ new TestRemote(ctx, { pluginMarket: market, pluginRegistryProbe: probe })
+ const controller = new DiscoverController(ctx, () => 'https://dsh.example/prefix/')
const face = controller.face(text => typeof text === 'string' ? text : text.en)
- return { controller, face, market, state, snapshot: () => controller.store.getSnapshot() }
+ return { controller, face, market, probe, state, snapshot: () => controller.store.getSnapshot() }
}
describe('DiscoverController', () => {
@@ -343,3 +350,127 @@ describe('DiscoverController', () => {
expect(snapshot().listing?.status).toBe('more')
})
})
+
+describe('detail reads', () => {
+ const BUNDLE = { url: A, name: 'b', version: '1.0.0', npm: true } as const
+ const ENTRY = { url: B, name: 'e', version: '2.0.0', npm: false } as const
+ const FOUND: PluginDetail = { status: 'found', origin: 'readme', markdown: '# B', document: brandString('doc') }
+
+ it('reads each plugin once per language and publishes the result', async () => {
+ const { controller, face, market } = setup()
+ market.readEntryDetail.mockImplementationOnce(() => ok(FOUND))
+ face.loadDetail(BUNDLE, 'en')
+ face.loadDetail(BUNDLE, 'en')
+ expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' })
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) })
+ face.loadDetail(ENTRY, 'zh')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]).toEqual({ status: 'ready', detail: { status: 'none' } }) })
+ // An npm version asks the registry comparison and reads npmmirror first where it won; a Git version does not ask.
+ expect(market.readEntryDetail.mock.calls).toEqual([
+ [A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: ['npmmirror'] }], [B, 'e', '2.0.0', 'zh', { refresh: false, npmFileSources: [] }],
+ ])
+ })
+
+ it('reports failures by reason and reads again on retry', async () => {
+ const { controller, face, market } = setup()
+ // The Git entry's read starts at once; the npm one waits for the registry comparison, so answers go by name.
+ market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/unknown-plugin', 'gone', { name: 'e', version: '2.0.0' }))
+ market.readEntryDetail.mockImplementationOnce(() => fail('plugin-market/detail-unavailable', 'big', { reason: 'too-large' }))
+ face.loadDetail(BUNDLE, 'en')
+ face.loadDetail(ENTRY, 'en')
+ await vi.waitFor(() => {
+ expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'error', failure: 'too-large' })
+ expect(controller.details.getSnapshot()[detailKey(ENTRY, 'en')]).toEqual({ status: 'error', failure: 'other' })
+ })
+ face.retryDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) })
+ // A retry bypasses the Host's cached result.
+ expect(market.readEntryDetail.mock.calls.at(-1)).toEqual([A, 'b', '1.0.0', 'en', { refresh: true, npmFileSources: ['npmmirror'] }])
+ })
+
+ it('settles a retried read with the retry\'s answer', async () => {
+ const { controller, face, market } = setup()
+ let first: (value: Answer) => void = () => {}
+ market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { first = resolve }))
+ market.readEntryDetail.mockImplementationOnce(() => ok(FOUND))
+ face.loadDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledOnce() })
+ face.retryDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND }) })
+ first({ ok: true, value: { status: 'none' } })
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
+ expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: FOUND })
+ })
+
+ it('forgets every read after the connection reset and drops the answers of forgotten reads', async () => {
+ const { controller, face, market } = setup()
+ let answer: (value: Answer) => void = () => {}
+ face.loadDetail(ENTRY, 'zh')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(ENTRY, 'zh')]?.status).toBe('ready') })
+ market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
+ face.loadDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(2) })
+ controller.detailsReset()
+ expect(controller.details.getSnapshot()).toEqual({})
+ answer({ ok: true, value: FOUND })
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
+ expect(controller.details.getSnapshot()).toEqual({})
+ face.loadDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') })
+ // A read from before a reset that settles after the read that replaced it does not overwrite it.
+ let stale: (value: Answer) => void = () => {}
+ market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { stale = resolve }))
+ controller.detailsReset()
+ face.loadDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(market.readEntryDetail).toHaveBeenCalledTimes(3) })
+ controller.detailsReset()
+ face.loadDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } }) })
+ stale({ ok: true, value: FOUND })
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
+ expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'ready', detail: { status: 'none' } })
+ })
+
+ it('keeps the configured CDN order where npmmirror did not answer fastest', async () => {
+ const { controller, face, market, probe } = setup()
+ probe.fastest.mockImplementationOnce(() => ok('https://registry.npmjs.org/'))
+ face.loadDetail(BUNDLE, 'en')
+ await vi.waitFor(() => { expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]?.status).toBe('ready') })
+ expect(market.readEntryDetail.mock.calls).toEqual([[A, 'b', '1.0.0', 'en', { refresh: false, npmFileSources: [] }]])
+ })
+
+ it('drops a read whose registry comparison settles after a reset or disposal, without asking the Host', async () => {
+ const { controller, face, market, probe } = setup()
+ let settle: (value: Answer) => void = () => {}
+ probe.fastest.mockImplementation(() => new Promise((resolve) => { settle = resolve }))
+ face.loadDetail(BUNDLE, 'en')
+ controller.detailsReset()
+ settle({ ok: true, value: null })
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
+ face.loadDetail(BUNDLE, 'en')
+ controller.dispose()
+ settle({ ok: true, value: null })
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
+ expect(market.readEntryDetail).not.toHaveBeenCalled()
+ })
+
+ it('stops publishing detail reads after disposal', async () => {
+ const { controller, face, market } = setup()
+ let answer: (value: Answer) => void = () => {}
+ market.readEntryDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
+ face.loadDetail(BUNDLE, 'en')
+ controller.dispose()
+ answer({ ok: true, value: FOUND })
+ await new Promise((resolve) => { setTimeout(resolve, 0) })
+ expect(controller.details.getSnapshot()[detailKey(BUNDLE, 'en')]).toEqual({ status: 'loading' })
+ })
+
+ it('addresses relative images through the Host route and leaves other destinations alone', () => {
+ const { face } = setup()
+ const doc = brandString('d 1')
+ expect(face.detailImage(doc, 'img/a.png')).toBe('https://dsh.example/prefix/api/plugin-market/detail-file?document=d%201&path=img%2Fa.png')
+ for (const destination of ['https://example.com/a.png', 'data:image/png;base64,AA', '//cdn.example/a.png', '#top', 'mailto:a@b']) {
+ expect(face.detailImage(doc, destination)).toBeUndefined()
+ }
+ })
+})
diff --git a/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx
new file mode 100644
index 0000000000..5380c7b7c9
--- /dev/null
+++ b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx
@@ -0,0 +1,75 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { PluginDetailDocumentId } from '@deepseek-ai/dsh-api-remotes/client'
+import { brandString } from '@deepseek-ai/dsh-brand'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client'
+import { PluginDetailSection, type PluginDetailSectionProps } from '../src/client/PluginDetailSection.tsx'
+import { detailKey, type DetailRequest, type DetailsState } from '../src/client/market-store.ts'
+import { en, zh, type PluginMarketLocaleKey } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+const COMMON: Record = { 'copy': 'Copy', 'copied': 'Copied', 'codeBlock.title': 'Code block', 'codeBlock.wrap': 'Wrap', 'codeBlock.unwrap': 'Unwrap', 'retry': 'Retry' }
+
+function translator(dictionary: Record): PluginDetailSectionProps['t'] {
+ const strings: Record = dictionary
+ return (key: string) => strings[key] ?? COMMON[key] ?? key
+}
+
+const REQUEST: DetailRequest = { url: 'https://a.example/index.json', name: 'a', version: '1.0.0', npm: true }
+const DOCUMENT = brandString('doc')
+
+function setup(initial: DetailsState = {}, language = 'en') {
+ const details = createSnapshotStore(initial)
+ const locale = createSnapshotStore({ active: language, locales: [], revision: 0 })
+ const face = {
+ loadDetail: vi.fn(), retryDetail: vi.fn(),
+ detailImage: vi.fn((document: PluginDetailDocumentId, destination: string) => `https://dsh.example/api/plugin-market/detail-file?document=${document}&path=${destination}`),
+ useDetails: bindSnapshotSelector(details), useLocale: bindSnapshotSelector(locale),
+ }
+ const set = (key: string, state: DetailsState[string]): void => { act(() => { details.set({ ...details.getSnapshot(), [key]: state }) }) }
+ const forget = (): void => { details.set({}) }
+ return { face, set, locale, forget }
+}
+
+it('reads the document of the request in the active language and renders its Markdown with images beside it', () => {
+ const { face, set, locale, forget } = setup()
+ const view = render()
+ expect(face.loadDetail).toHaveBeenCalledWith(REQUEST, 'en')
+ expect(screen.getByRole('status', { name: en.detailLoading })).toBeTruthy()
+ set(detailKey(REQUEST, 'en'), { status: 'ready', detail: { status: 'found', origin: 'readme', markdown: '# Alpha\n\n [guide](docs/guide.md)', document: DOCUMENT } })
+ expect(screen.queryByRole('heading', { name: en.detailTitle })).toBeNull()
+ expect(screen.getByRole('heading', { name: 'Alpha' })).toBeTruthy()
+ expect(screen.getByRole('img', { name: 'shot' }).getAttribute('src')).toBe('https://dsh.example/api/plugin-market/detail-file?document=doc&path=img/shot.png')
+ // Relative links stay text.
+ expect(screen.queryByRole('link', { name: 'guide' })).toBeNull()
+ expect(screen.getByText('guide')).toBeTruthy()
+ act(() => { locale.set({ active: 'zh', locales: [], revision: 1 }) })
+ expect(face.loadDetail).toHaveBeenLastCalledWith(REQUEST, 'zh')
+ view.rerender()
+ expect(face.loadDetail).toHaveBeenCalledTimes(2)
+ // A read the controller forgot starts again; a settled one does not.
+ set(detailKey(REQUEST, 'zh'), { status: 'ready', detail: { status: 'none' } })
+ act(() => { forget() })
+ expect(face.loadDetail).toHaveBeenCalledTimes(3)
+})
+
+it('renders nothing without a document, and a reason with a retry when the read failed', () => {
+ const key = detailKey(REQUEST, 'en')
+ const { face, set } = setup({ [key]: { status: 'ready', detail: { status: 'none' } } })
+ const view = render()
+ expect(view.container.innerHTML).toBe('')
+ const reasons = [
+ ['too-large', en.detailTooLarge], ['network', en.detailNetwork], ['denied', en.detailDenied],
+ ['invalid', en.detailInvalid], ['other', en.detailFailed],
+ ] as const
+ for (const [failure, text] of reasons) {
+ set(key, { status: 'error', failure })
+ expect(screen.getByRole('alert').textContent).toBe(text)
+ }
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(face.retryDetail).toHaveBeenCalledWith(REQUEST, 'en')
+})
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 6a08129b2b..05aa2a8ab2 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/packages/client/ui-primitives/README.i18n.yaml
@@ -18,14 +18,14 @@
en: 7bae72c97d0449f5
zh: 4589ecc2b216edc5
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
- en: 55672e1a36c1ddec
- zh: c68b694e833cac98
+ en: 8fea4c25aa26fc31
+ zh: 7e9eebe7f712cf53
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: 28ac8f0b1edbcf81
zh: 5c4c324bf84a282f
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
- en: 12fe9d9b1410edef
- zh: f2dfdc01170d7cce
+ en: 4b6e7c1dd8d8c2a7
+ zh: 227747b459518e37
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
en: 4aef65dea11de083
zh: abc22cd7c65cdf38
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index bbbd9422fa..26b3cc2b71 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -66,6 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. |
| `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
| `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
+| `DetailDocument`, `detailDocumentState`, `useDetailDocumentLabels` | Section without a heading of its own for a long Markdown description, such as a plugin's detail document: three skeleton lines while loading, the document, the owner's failure message with a retry, or nothing when there is no document. A document taller than `min(560px, 60vh)` scrolls inside a keyboard-focusable region that the owner's title names. Raw HTML in the document is omitted. Relative links stay text and only images the owner's `images` resolver accepts, or absolute HTTP(S) images, load. `detailDocumentState` maps an owner's read to the shown state, and `useDetailDocumentLabels` builds reference-stable labels from translated strings. |
| `UserTextRun`, `ReferenceChip` | Uninterpreted inline text and business-resolved reference labels or activation buttons. |
| `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. |
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
@@ -99,7 +100,7 @@ The catalog above lists what each export is for; this section covers the behavio
The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file` route; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text).
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 744948886c..45dbaba8d0 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -66,6 +66,7 @@ kind: "package-library"
| `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 |
| `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
+| `DetailDocument`、`detailDocumentState`、`useDetailDocumentLabels` | 用于较长 Markdown 描述(例如插件的详细介绍文档)的区块,本身不带标题:加载期间显示三行骨架屏,随后显示文档、owner 提供的失败消息和重试,或在没有文档时不显示任何内容。文档高度超过 `min(560px, 60vh)` 时,在可通过键盘聚焦、由 owner 的标题命名的区域内滚动。文档中的原始 HTML 会被省略。相对链接保持为文本,只加载 owner 的 `images` 解析器接受的图片或绝对 HTTP(S) 地址的图片。`detailDocumentState` 把 owner 的读取结果映射为显示状态,`useDetailDocumentLabels` 根据翻译后的字符串构建引用稳定的标签。 |
| `UserTextRun`、`ReferenceChip` | 不做解析的行内文本,以及业务已解析的引用标签或激活按钮。 |
| `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 |
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
@@ -99,7 +100,7 @@ kind: "package-library"
最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
diff --git a/packages/client/ui-primitives/src/DetailDocument.module.css b/packages/client/ui-primitives/src/DetailDocument.module.css
new file mode 100644
index 0000000000..6e75b56606
--- /dev/null
+++ b/packages/client/ui-primitives/src/DetailDocument.module.css
@@ -0,0 +1,65 @@
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+
+.document {
+ min-width: 0;
+ /* A long README scrolls inside the section instead of pushing the page's later sections out of reach. */
+ max-height: min(560px, 60vh);
+ overflow-y: auto;
+ padding: 4px 16px;
+ border: 0.5px solid var(--dsw-alias-border-l1);
+ border-radius: var(--dsw-radius-lg);
+ overflow-wrap: anywhere;
+}
+
+.document:focus-visible {
+ outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
+ outline-offset: 3px;
+}
+
+.failure {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.error {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.skeleton {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.bar {
+ display: block;
+ height: 12px;
+ border-radius: var(--dsw-radius-xs);
+ background: var(--dsw-alias-bg-skeleton);
+ animation: dsh-plugin-detail-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite;
+}
+
+.long { width: min(480px, 90%); }
+.short { width: min(240px, 50%); }
+
+@keyframes dsh-plugin-detail-skeleton {
+ 0% { opacity: 1; }
+ 40% { opacity: 0.6; }
+ 80%, 100% { opacity: 1; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bar { animation: none; }
+}
diff --git a/packages/client/ui-primitives/src/DetailDocument.tsx b/packages/client/ui-primitives/src/DetailDocument.tsx
new file mode 100644
index 0000000000..d965fce410
--- /dev/null
+++ b/packages/client/ui-primitives/src/DetailDocument.tsx
@@ -0,0 +1,120 @@
+import { useMemo, type ReactNode } from 'react'
+import { Button } from './Button.tsx'
+import { MarkdownText, type MarkdownLabels, type MarkdownPathImages } from './markdown/MarkdownText.tsx'
+import { StateDot } from './StateDot.tsx'
+import css from './DetailDocument.module.css'
+
+/** One read of a long Markdown description, such as a plugin's detail document. */
+export type DetailDocumentState =
+ | { readonly status: 'loading' }
+ | { readonly status: 'found'; readonly markdown: string }
+ | { readonly status: 'none' }
+ | { readonly status: 'error'; readonly message: string }
+
+/** A read as owners track it: in flight, settled with a document or none, or failed with a localized message. */
+export type DetailDocumentRead =
+ | undefined
+ | { readonly status: 'loading' }
+ | { readonly status: 'ready'; readonly markdown: string | undefined }
+ | { readonly status: 'error'; readonly message: string }
+
+/**
+ * The state {@link DetailDocument} shows for an owner's read: loading until it settles, then the document, none, or the
+ * failure.
+ * @param read - the owner's read; undefined before it starts.
+ * @returns the state to show.
+ */
+export function detailDocumentState(read: DetailDocumentRead): DetailDocumentState {
+ if (read?.status === 'ready') return read.markdown === undefined ? { status: 'none' } : { status: 'found', markdown: read.markdown }
+ return read?.status === 'error' ? read : { status: 'loading' }
+}
+
+/** Translated strings of a detail document's chrome, as an owner's dictionary supplies them. */
+export interface DetailDocumentCopy {
+ readonly title: string
+ readonly loading: string
+ readonly retry: string
+ readonly copy: string
+ readonly copied: string
+ readonly codeBlock: string
+ readonly wrap: string
+ readonly unwrap: string
+ readonly footnotes: string
+}
+
+/**
+ * Assemble reference-stable labels for {@link DetailDocument} from translated strings.
+ * @param copy - the translated strings.
+ * @returns labels that keep their identity while the strings stay equal.
+ */
+export function useDetailDocumentLabels(copy: DetailDocumentCopy): DetailDocumentLabels {
+ const { title, loading, retry, footnotes } = copy
+ const { copy: copyLabel, copied: copiedLabel, codeBlock: codeLabel, wrap: wrapLabel, unwrap: unwrapLabel } = copy
+ const markdown = useMemo(() => ({ code: { copyLabel, copiedLabel, toolbarLabels: { codeLabel, wrapLabel, unwrapLabel } }, footnotes }),
+ [copyLabel, copiedLabel, codeLabel, wrapLabel, unwrapLabel, footnotes])
+ return useMemo(() => ({ title, loading, retry, markdown }), [title, loading, retry, markdown])
+}
+
+/** Localized chrome of a detail document. */
+export interface DetailDocumentLabels {
+ /** Accessible name of the scrollable document region; the section shows no heading of its own. */
+ readonly title: string
+ /** Accessible name of the loading placeholder. */
+ readonly loading: string
+ /** Retry button text. */
+ readonly retry: string
+ readonly markdown: MarkdownLabels
+}
+
+/**
+ * A section showing a long Markdown description, without a heading of its own: three skeleton lines while it loads, the
+ * document once found, or the owner's failure message with a retry. Nothing renders for `none`. A document taller than
+ * the section's height limit scrolls inside it, and the scroll region, named by `labels.title`, takes keyboard focus.
+ * Raw HTML is omitted, relative links stay text, and only images the owner's `images` resolver accepts, or absolute
+ * HTTP(S) images, load.
+ * @param props.state - the read to show.
+ * @param props.labels - localized region name, placeholder name, retry text, and Markdown chrome; keep `markdown` reference-stable.
+ * @param props.images - resolves the document's relative image destinations to displayable URLs.
+ * @param props.onRetry - reads the document again after a failure.
+ * @returns the section, or null.
+ */
+export function DetailDocument({ state, labels, images, onRetry }: {
+ readonly state: DetailDocumentState
+ readonly labels: DetailDocumentLabels
+ readonly images?: MarkdownPathImages | undefined
+ readonly onRetry: () => void
+}): ReactNode {
+ if (state.status === 'none') return null
+ let body: ReactNode
+ switch (state.status) {
+ case 'found':
+ body = (
+
+
+
+ )
+ break
+ case 'error':
+ body = (
+
+
{state.message}
+
+
+ )
+ break
+ case 'loading':
+ body = (
+
+
+
+
+
+ )
+ break
+ }
+ return (
+
+ {body}
+
+ )
+}
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 18f9544405..1083cf6827 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -106,6 +106,8 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
export type { MarkdownDelegate, MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
+export { DetailDocument, detailDocumentState, useDetailDocumentLabels } from './DetailDocument.tsx'
+export type { DetailDocumentCopy, DetailDocumentLabels, DetailDocumentRead, DetailDocumentState } from './DetailDocument.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index ab2c0b380a..c76ed4171f 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -32,6 +32,7 @@ function renderSettled(
labels: MarkdownLabels,
fileMentions: MarkdownFileMentions | undefined,
pathImages: MarkdownPathImages | undefined,
+ omitHtml: boolean,
): ReactNode[] {
const root = parseGfmWithMath(text)
const targets = createReferenceTargets()
@@ -41,6 +42,7 @@ function renderSettled(
labels,
fileMentions,
pathImages,
+ omitHtml,
targets,
footnoteOrder: [],
footnoteCounts: new Map(),
@@ -74,8 +76,11 @@ class StreamingRenderer {
private lastText: string | null = null
private lastRendered: ReactNode[] = []
- /** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */
- constructor(private readonly labels: MarkdownLabels) {}
+ /**
+ * @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes.
+ * @param omitHtml - Whether raw HTML renders nothing; the owner replaces the renderer when it changes.
+ */
+ constructor(private readonly labels: MarkdownLabels, readonly omitHtml: boolean) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
@@ -110,6 +115,7 @@ class StreamingRenderer {
labels: this.labels,
fileMentions: undefined,
pathImages: undefined,
+ omitHtml: this.omitHtml,
targets: frameTargets,
footnoteOrder: this.frozenFootnoteOrder,
footnoteCounts: this.frozenFootnoteCounts,
@@ -129,6 +135,7 @@ class StreamingRenderer {
labels: this.labels,
fileMentions: undefined,
pathImages: undefined,
+ omitHtml: this.omitHtml,
targets: frameTargets,
footnoteOrder: [...this.frozenFootnoteOrder],
footnoteCounts: new Map(this.frozenFootnoteCounts),
@@ -164,15 +171,17 @@ class StreamingRenderer {
* `MarkdownDelegateProvider` can delegate ordinary HTTP(S) activation while
* modified clicks retain native behavior. `variant="compact"` uses secondary
* text sizing, uniform bold headings, and tight block spacing; the default
- * `body` variant uses the full document typography.
+ * `body` variant uses the full document typography. `omitHtml` renders raw
+ * HTML as nothing instead of literal text, for documents written for HTML
+ * renderers, such as package READMEs.
* The provider's `openFile` enables local Markdown links in settled messages,
* including `#L24` and `#L24-L30` destinations (ranges open at their first line).
- * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and
- * unsafe protocols are disabled. Local links without an opener remain text;
+ * @returns A GFM document with TeX math rendered through KaTeX; raw HTML never
+ * renders as markup, and unsafe protocols are disabled. Local links without an opener remain text;
* absolute HTTP(S) images render directly.
*/
export const MarkdownText = memo(function MarkdownText({
- text, streaming = false, labels, fileMentions, pathImages, variant = 'body',
+ text, streaming = false, labels, fileMentions, pathImages, variant = 'body', omitHtml = false,
}: {
text: string
streaming?: boolean
@@ -180,20 +189,21 @@ export const MarkdownText = memo(function MarkdownText({
fileMentions?: MarkdownFileMentions | undefined
pathImages?: MarkdownPathImages | undefined
variant?: 'body' | 'compact'
+ omitHtml?: boolean
}) {
const streamRef = useRef(null)
const streamLabelsRef = useRef(labels)
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
- return renderSettled(text, labels, fileMentions, pathImages)
+ return renderSettled(text, labels, fileMentions, pathImages, omitHtml)
}
- if (streamRef.current === null || streamLabelsRef.current !== labels) {
- streamRef.current = new StreamingRenderer(labels)
+ if (streamRef.current === null || streamLabelsRef.current !== labels || streamRef.current.omitHtml !== omitHtml) {
+ streamRef.current = new StreamingRenderer(labels, omitHtml)
streamLabelsRef.current = labels
}
return streamRef.current.render(text)
- }, [text, streaming, labels, fileMentions, pathImages])
+ }, [text, streaming, labels, fileMentions, pathImages, omitHtml])
return
{children}
})
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index cdb9afcbda..c4ff4a2400 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -77,12 +77,17 @@ function remoteImageUrl(url: string): string | undefined {
}
}
-/** Rewritten images may use Web media protocols or the Desktop application's file route. */
+/** Authenticated Host media routes as the Desktop application addresses them. */
+const DESKTOP_MEDIA_ROUTES = [
+ 'dsh-app://app/api/file?', 'dsh-app://app/api/plugin-market/detail-file?', 'dsh-app://app/api/plugin-manager/detail-file?',
+]
+
+/** Rewritten images may use Web media protocols or the Desktop application's media routes. */
function vocabularyImageUrl(url: string): string | undefined {
try {
const protocol = new URL(url).protocol
return protocol === 'http:' || protocol === 'https:' || protocol === 'blob:' || protocol === 'data:'
- || url.startsWith('dsh-app://app/api/file?')
+ || DESKTOP_MEDIA_ROUTES.some(route => url.startsWith(route))
? url
: undefined
} catch {
@@ -193,6 +198,8 @@ export interface MarkdownRenderContext {
readonly fileMentions: MarkdownFileMentions | undefined
/** Local-path image vocabulary; absent wherever no rewriting owner exists. */
readonly pathImages: MarkdownPathImages | undefined
+ /** Raw HTML renders nothing instead of literal text. */
+ readonly omitHtml: boolean
/** Inside an anchor's children: interactive mentions must not nest there. */
readonly inLink?: boolean
/** Reference targets visible to this pass. */
@@ -331,8 +338,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
return {value}
}
case 'html':
- // No HTML parser enters the pipeline: raw HTML stays literal text.
- return node.value
+ // No HTML parser enters the pipeline: raw HTML is literal text, or nothing where the owner omits it.
+ return context.omitHtml ? null : node.value
case 'code':
return renderCode(node, key, context)
case 'math':
diff --git a/packages/client/ui-primitives/tests/detail-document.client.spec.tsx b/packages/client/ui-primitives/tests/detail-document.client.spec.tsx
new file mode 100644
index 0000000000..0e2ae34095
--- /dev/null
+++ b/packages/client/ui-primitives/tests/detail-document.client.spec.tsx
@@ -0,0 +1,62 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import { DetailDocument, detailDocumentState, useDetailDocumentLabels, type DetailDocumentCopy, type DetailDocumentLabels } from '../src/DetailDocument.tsx'
+
+afterEach(cleanup)
+
+const LABELS: DetailDocumentLabels = {
+ title: 'Details', loading: 'Reading details', retry: 'Retry',
+ markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied' }, footnotes: 'Footnotes' },
+}
+
+it('shows a placeholder, the document with resolved images, or a failure with a retry, and nothing without a document', () => {
+ const onRetry = vi.fn()
+ const view = render()
+ // The section has no heading of its own; its title names the document region.
+ expect(screen.queryByRole('heading', { name: 'Details' })).toBeNull()
+ expect(screen.getByRole('status', { name: 'Reading details' })).toBeTruthy()
+ view.rerender(bold kept\n\n
' }} labels={LABELS}
+ images={{ resolve: value => `https://host.example/${value}` }} onRetry={onRetry} />)
+ expect(screen.getByRole('heading', { name: 'A' })).toBeTruthy()
+ // The document scrolls inside a keyboard-focusable region named by the section heading.
+ const region = screen.getByRole('region', { name: 'Details' })
+ expect(region.tabIndex).toBe(0)
+ expect(region.contains(screen.getByRole('heading', { name: 'A' }))).toBe(true)
+ expect(screen.getByRole('img', { name: 'i' }).getAttribute('src')).toBe('https://host.example/a.png')
+ expect(screen.queryByRole('link', { name: 'doc' })).toBeNull()
+ // Raw HTML renders nothing: neither markup nor its literal text.
+ expect(view.container.textContent).not.toContain('<')
+ expect(view.container.textContent).toContain('bold kept')
+ expect(view.container.querySelector('div[align]')).toBeNull()
+ view.rerender()
+ expect(screen.getByRole('alert').textContent).toBe('Could not read')
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(onRetry).toHaveBeenCalledOnce()
+ view.rerender()
+ expect(view.container.innerHTML).toBe('')
+})
+
+it('maps owner reads to shown states', () => {
+ expect(detailDocumentState(undefined)).toEqual({ status: 'loading' })
+ expect(detailDocumentState({ status: 'loading' })).toEqual({ status: 'loading' })
+ expect(detailDocumentState({ status: 'ready', markdown: '# A' })).toEqual({ status: 'found', markdown: '# A' })
+ expect(detailDocumentState({ status: 'ready', markdown: undefined })).toEqual({ status: 'none' })
+ expect(detailDocumentState({ status: 'error', message: 'm' })).toEqual({ status: 'error', message: 'm' })
+})
+
+it('keeps label identity while the translated strings stay equal', () => {
+ const copy: DetailDocumentCopy = {
+ title: 'Details', loading: 'Reading', retry: 'Retry', copy: 'Copy', copied: 'Copied', codeBlock: 'Code', wrap: 'Wrap', unwrap: 'Unwrap', footnotes: 'Notes',
+ }
+ const { result, rerender } = renderHook((props: DetailDocumentCopy) => useDetailDocumentLabels(props), { initialProps: copy })
+ const first = result.current
+ expect(first).toEqual({
+ title: 'Details', loading: 'Reading', retry: 'Retry',
+ markdown: { code: { copyLabel: 'Copy', copiedLabel: 'Copied', toolbarLabels: { codeLabel: 'Code', wrapLabel: 'Wrap', unwrapLabel: 'Unwrap' } }, footnotes: 'Notes' },
+ })
+ rerender({ ...copy })
+ expect(result.current).toBe(first)
+ rerender({ ...copy, title: 'Detail' })
+ expect(result.current.markdown).toBe(first.markdown)
+})
diff --git a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx
index 85516cfd0a..11aa179572 100644
--- a/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx
@@ -74,14 +74,21 @@ describe('MarkdownText local-path images', () => {
}
})
- it('accepts only the Desktop file route from a local image resolver', () => {
+ it('accepts only the Desktop media routes from a local image resolver', () => {
const src = 'dsh-app://app/api/file?path=%2Ftmp%2Fgraph.png'
const view = render( src }} />)
expect(view.container.querySelector('img')).toBeNull()
view.rerender( src }} />)
expect(view.getByRole('img').getAttribute('src')).toBe(src)
+ const detail = 'dsh-app://app/api/plugin-market/detail-file?document=d&path=a.png'
+ const bundle = 'dsh-app://app/api/plugin-manager/detail-file?bundle=b&document=README.md&path=a.png'
+ for (const route of [detail, bundle]) {
+ view.rerender( route }} />)
+ expect(view.getByRole('img').getAttribute('src')).toBe(route)
+ }
for (const result of ['dsh-app://shell/api/file?path=x', 'dsh-app://app/assets/image.png',
- 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x']) {
+ 'dsh-app://app/api/file-other?path=x', 'dsh-app://app.example/api/file?path=x', 'dsh-app://app/api/plugin-market/other?path=x',
+ 'dsh-app://app/api/plugin-manager/other?path=x']) {
view.rerender( result }} />)
expect(view.container.querySelector('img')).toBeNull()
}
diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
index bf1877c348..3c4536b7e7 100644
--- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx
@@ -23,6 +23,7 @@ function makeContext(): MarkdownRenderContext {
labels: markdownLabels,
fileMentions: undefined,
pathImages: undefined,
+ omitHtml: false,
targets: createReferenceTargets(),
footnoteOrder: [],
footnoteCounts: new Map(),
diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx
index c655a007d2..553f60233a 100644
--- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx
@@ -332,6 +332,17 @@ describe('MarkdownText', () => {
}
})
+ it('omits raw HTML when asked, in settled and streaming renders, and rebuilds the stream when the choice changes', () => {
+ const text = 'Before bold after\n\n
')
+ })
+
it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
const markdown = [
'',
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 60a03011cf..4bb075eb7e 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.i18n.yaml
@@ -54,8 +54,8 @@
en: 83031e7505fc228a
zh: 03e17c9428fe29fd
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
- en: ea89ec5a3ee1f167
- zh: e11f350571bcfff7
+ en: 8bcb7bd90d7c5773
+ zh: 17663be926a099d2
/deepseek-ai-dsh-client-ui-workspace/further-exploration:
en: 2abbf27abbd71ba4
zh: ace33fa2ea042357
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index 2654447b77..ecfc8f52b5 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -184,7 +184,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu
### Hover cards
-Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it.
+Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card, including a blank New Session card, shows its working directory below the title, abbreviating a POSIX home prefix as `~`, and omits that line when the Host reports no directory or an empty one. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index c655995453..2b51da8011 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -184,7 +184,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序
### 悬浮卡片
-Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。
+Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。Session 卡片(包括空白「新会话」卡片)在标题下方显示其工作目录,POSIX 主目录前缀缩写为 `~`;Host 未报告目录或目录为空时省略该行。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
index 216a6fc00f..7005cb418c 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
+++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
@@ -420,12 +420,14 @@ function PinnedIndicator({ t }: { t: RowTranslate }) {
}
/**
- * Hover-card body: full title, relative time, the Session's own scheduled-task
+ * Hover-card body: full title, display working directory, relative time, the Session's own scheduled-task
* section, and every relevant live status. The task section sits above the
* status lines so they stay the card's trailing status line.
*/
-function SessionHoverContent({ node, now, renderSlot, t }: {
+function SessionHoverContent({ node, cwd, now, renderSlot, t }: {
node: SessionNode
+ /** Display working directory; absent omits the line. */
+ cwd: string | undefined
now: number
renderSlot: RowRenderSlots
t: RowTranslate
@@ -437,6 +439,7 @@ function SessionHoverContent({ node, now, renderSlot, t }: {
return (
{displayTitle(node, t)}
+ {cwd !== undefined &&
{cwd}
}
{/* Same placeholder rule as the row's trailing cell: no timestamp
before the first prompt. */}
{!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
}
@@ -539,11 +542,12 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
* its leading decoration, and its hover-card section.
* @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
* @param props.drag - optional row-drag target wiring; blank rows cannot start a drag.
+ * @param props.home - host account home for POSIX hover-path abbreviation.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({
- node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, t,
+ node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, t,
}: {
node: SessionNode
currentId: string | undefined
@@ -555,6 +559,8 @@ export function SessionNodeItem({
onReveal?: (() => void) | undefined
/** Present on reorderable-list rows so every row can remain a drop target. */
drag?: RowDragProps | undefined
+ /** Host account home; POSIX home-rooted hover paths display as `~`. */
+ home?: string | undefined
t: RowTranslate
} & PropsRenderSlots<
| 'sidebar.workspaces.session.menu.item'
@@ -693,7 +699,13 @@ export function SessionNodeItem({
return (
}
+ content={}
openDelayMs={800}
grouped
disabled={menuOpen || drag?.active === true}
diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
index df871654ee..be4c78a9ef 100644
--- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
+++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
@@ -633,6 +633,7 @@ function SessionTree({
? () => { onSessionRevealed(node.id) }
: undefined}
drag={dragProps}
+ home={home}
t={t}
/>
)
@@ -689,7 +690,7 @@ function SessionTree({
function FlatList({
list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest,
usePanelInfo, setSessionOrder, workspaceReady, animationResetKey,
- revealSessionId, onSessionRevealed, renderSlot, t,
+ revealSessionId, onSessionRevealed, renderSlot, home, t,
}: Pick<
SessionTreeProps,
| 'useSessionStatus'
@@ -704,6 +705,7 @@ function FlatList({
| 'onSessionRevealed'
| 'rowState'
| 'onLeaveArchivedOnly'
+ | 'home'
| 't'
> & {
list: SessionListState
@@ -778,6 +780,7 @@ function FlatList({
dropCommitted.current = false
},
}}
+ home={home}
t={t}
/>
)
@@ -1434,6 +1437,7 @@ export function WorkspaceBrowser({
setSessionOrder={saveSessionOrder}
revealSessionId={revealSessionId}
onSessionRevealed={acknowledgeSessionReveal}
+ home={home}
t={t}
/>
)
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index eb67d0ebe3..608c0b2a58 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -59,6 +59,8 @@ export interface SessionNode {
/** In the registry-global archive set: shown grayed in place and not openable. */
archived: boolean
updatedAt: number
+ /** Session working directory; absent when the Host reports none or an empty path. */
+ cwd?: string
}
/** Session order selected by the Workspace browser. */
@@ -413,6 +415,7 @@ function sessionNode(
pinned: !archived.has(s.id) && pinned.has(s.id),
archived: archived.has(s.id),
updatedAt: s.updatedAt,
+ ...(s.cwd === undefined || s.cwd === '' ? {} : { cwd: s.cwd }),
...(pendingInteraction === undefined ? {} : { pendingInteraction }),
}
}
diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx
index f02cf3ca85..d7b1587b43 100644
--- a/packages/client/ui-workspace/tests/rows.client.spec.tsx
+++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
@@ -685,6 +685,39 @@ describe('workspace browser rows', () => {
}
})
+ describe('Session hover-card working directory', () => {
+ const located: SessionNode = {
+ id: sid('s-cwd'), title: 'Located', blank: false, running: false,
+ runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
+ cwd: '/home/u/project',
+ }
+ function hoverCard(node: SessionNode): void {
+ render()
+ fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
+ act(() => { vi.advanceTimersByTime(800) })
+ }
+ beforeEach(() => { vi.useFakeTimers() })
+ afterEach(() => { vi.useRealTimers() })
+
+ it('shows a home-rooted directory as ~', () => {
+ hoverCard(located)
+ expect(screen.getByText('~/project')).toBeTruthy()
+ })
+
+ it('omits the line when the Host reports no directory', () => {
+ const { cwd: _cwd, ...withoutCwd } = located
+ hoverCard(withoutCwd)
+ expect(screen.getAllByText('Located')).toHaveLength(2)
+ expect(screen.queryByText(/project/)).toBeNull()
+ })
+
+ it('shows the directory on a blank New Session card', () => {
+ hoverCard({ ...located, blank: true })
+ expect(screen.getAllByText('新会话')).toHaveLength(2)
+ expect(screen.getByText('~/project')).toBeTruthy()
+ })
+ })
+
it('session row menu opens without opening the session and Escape closes it', () => {
const onOpen = vi.fn()
const node: SessionNode = {
diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
index 4b62826cbc..f53d63b275 100644
--- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
+++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
@@ -297,6 +297,27 @@ describe('WorkspaceBrowser', () => {
}
})
+ it.each(['workspace', 'flat', 'ungrouped'] as const)('%s Session hover card shows the Session directory with home as ~', (mode) => {
+ vi.useFakeTimers()
+ try {
+ localStorage.clear()
+ const preferences = createWorkspaceViewStore().create()
+ preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
+ preferences.actions.setGroupExpanded(mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha', true)
+ localStorage.setItem('dsh.workspace.view.v5', JSON.stringify(preferences.getSnapshot()))
+ mount({
+ useSessions: hook(sessionState([summary('located', 1, { cwd: '/home/u/Documents/project' })])),
+ useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['located'])])),
+ useHostInfo: selector => selector({ home: '/home/u', isLoopback: true }),
+ })
+ fireEvent.pointerEnter(screen.getByText('located').closest('[role="treeitem"]')?.parentElement as HTMLElement)
+ act(() => { vi.advanceTimersByTime(800) })
+ expect(screen.getByText('~/Documents/project')).toBeTruthy()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => {
const pending = {
...workspaceState([]),
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index 50bcbe529a..aa1f24bc8d 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -2443,8 +2443,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
key: 'plugins.detail.section',
kind: 'list',
scope: 'root',
- summary: 'Sections under a detail page\'s own content: after the rows on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.',
- doc: 'Sections under a detail page\'s own content: after the rows on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.',
+ summary: 'Sections under a detail page\'s own content: after the source on a bundle\'s page, after the configuration on a row\'s or an official plugin\'s page.',
+ doc: 'Sections under a detail page\'s own content: after the source on a bundle\'s\npage, after the configuration on a row\'s or an official plugin\'s page.\nAn entry draws its own section chrome and renders null for a subject it\nhas nothing for.',
registerOptions: [
{
name: 'id',
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 6dee8f4661..6aa344b3b8 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1751,6 +1751,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'Manage profile files and apply their declared reload lifecycle.',
description: 'Manage profile files and apply their declared reload lifecycle.',
methods: [
+ {
+ signature: '@Remote async readBundleDetail(name: string, language: string, options?: { npmFileSources?: NpmFileSource[] }): Promise',
+ description: 'Read a bundle\'s long description: the Markdown file its locale `meta.detail` names in `language`, otherwise its README. An installed bundle is read from its directory without network access; an official bundle the installation offers before it is installed is read from the npm file CDNs at its offered version. Nothing is cached.',
+ parameters: [{ name: 'name', description: 'the bundle\'s package name, as `listBundles()` lists it.' }, { name: 'language', description: 'the display language, such as `zh-CN`; documents fall back through its prefixes to English.' }, { name: 'options', description: '`npmFileSources` names npm file CDNs to ask first for an offered bundle, in order, such as the one a Client measured as fastest; configured CDNs it omits follow, and CDNs that are not configured are not asked.' }],
+ returns: 'the document with its origin and path, or `none`.',
+ throws: ['{RemoteError} `plugin-manager/unknown-bundle`, `plugin-manager/invalid-language`, or `plugin-manager/detail-unavailable`.'],
+ },
{
signature: '@Remote listVersionExemptions(): { exemptions: Record; warnings: string[] }',
description: 'Read exact plugin-version exemptions saved in this profile.',
@@ -1868,6 +1875,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the plugin with its default version, installation status, and annotated versions; undefined when the index does not list it.',
throws: ['{RemoteError} `plugin-market/unknown-index`, `plugin-market/invalid-index-url`, `plugin-market/fetch-failed`, or `plugin-market/invalid-index`.'],
},
+ {
+ signature: '@Remote async readEntryDetail( url: string, name: string, version: string, language: string, options?: { refresh?: boolean; npmFileSources?: NpmFileSource[] }, ): Promise',
+ description: 'Read the long description of one listed version of a plugin market entry, from the version\'s source: the npm file CDNs for a public npm version, or its pinned Git commit. The Markdown file the package\'s locale `meta.detail` names in `language` comes first, then the package\'s README, then the README of the entry\'s `repository`, or for an npm version without one, of its `package.json` `repository`, at the default branch. An npm version from another registry has no readable package files, so only the entry\'s `repository` is read.',
+ parameters: [{ name: 'url', description: 'a configured index URL.' }, { name: 'name', description: 'the plugin\'s package name.' }, { name: 'version', description: 'one listed version.' }, { name: 'language', description: 'the display language, such as `zh-CN`; documents fall back through its prefixes to English.' }, { name: 'options', description: '`refresh` reads again instead of reusing a result cached for `cacheTtlMs`; `npmFileSources` names npm file CDNs to ask first for an npm version, in order; configured CDNs it omits follow, and CDNs that are not configured are not asked.' }],
+ returns: 'the document with its origin and image document id, or `none`.',
+ throws: ['{RemoteError} the failures of `search`, `plugin-market/unknown-plugin` for an unlisted plugin or version, `plugin-market/invalid-query`, or `plugin-market/detail-unavailable`.'],
+ },
],
},
{
@@ -5050,6 +5064,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'BrowserUseProviderName',
declaration: 'export type BrowserUseProviderName = Branded<\'BrowserUseProviderName\'>;',
},
+ {
+ name: 'BundleDetail',
+ declaration: 'export type BundleDetail = {\n readonly status: \'found\';\n readonly origin: \'locale\' | \'readme\';\n readonly path: string;\n readonly markdown: string;\n} | {\n readonly status: \'none\';\n};',
+ },
{
name: 'BundleInfo',
declaration: 'export interface BundleInfo {\n name: string;\n official: boolean;\n availability: \'installation\' | \'profile\' | \'missing\';\n installTarget?: {\n spec: string;\n version: string;\n };\n version?: string;\n meta?: PluginLocalizedMeta;\n description?: string;\n enabled: boolean;\n installed: boolean;\n source?: string;\n optional: boolean;\n removable: boolean;\n updatable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}',
@@ -6366,6 +6384,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'NotFutureError',
declaration: 'export interface NotFutureError {\n readonly code: \'not_future\';\n readonly message: string;\n}',
},
+ {
+ name: 'NpmFileSource',
+ declaration: 'export type NpmFileSource = \'jsdelivr\' | \'unpkg\' | \'npmmirror\';',
+ },
{
name: 'ObjectJsonSchema',
declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};',
@@ -6466,6 +6488,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PluginChange',
declaration: 'export interface PluginChange {\n readonly reason: \'plugin\' | \'bundle\' | \'install\' | \'remove\';\n}',
},
+ {
+ name: 'PluginDetail',
+ declaration: 'export type PluginDetail = {\n readonly status: \'found\';\n readonly origin: PluginDetailOrigin;\n readonly markdown: string;\n readonly document: PluginDetailDocumentId;\n} | {\n readonly status: \'none\';\n};',
+ },
+ {
+ name: 'PluginDetailDocumentId',
+ declaration: 'export type PluginDetailDocumentId = Branded<\'PluginDetailDocumentId\'>;',
+ },
+ {
+ name: 'PluginDetailOrigin',
+ declaration: 'export type PluginDetailOrigin = \'locale\' | \'readme\' | \'repository\';',
+ },
{
name: 'PluginEntryId',
declaration: 'export type PluginEntryId = Branded<\'PluginEntryId\'>;',
diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml
index a0c881f1b6..fcd63ec242 100644
--- a/packages/util/README.i18n.yaml
+++ b/packages/util/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/util-shared-utilities/packages:
- en: 2143dc15a12c830c
- zh: c94fb851bac20a74
+ en: 6f788656548ee4e0
+ zh: 227695dafaa9476d
/util-shared-utilities/related-documentation:
en: 432ae81110914571
zh: c5bf4efc007311f3
diff --git a/packages/util/README.md b/packages/util/README.md
index 41a1988bb8..c305e1fb69 100644
--- a/packages/util/README.md
+++ b/packages/util/README.md
@@ -28,6 +28,7 @@ Each package provides one primitive; open a package page for how to use it.
|---|---|
| [`brand/`](brand/README.md) | Nominal string types and their stateless constructor |
| [`package-manifest/`](package-manifest/README.md) | Shared TypeScript declarations for package manifests |
+| [`plugin-detail/`](plugin-detail/README.md) | Selects plugin detail documents and reads them from local packages and npm file CDNs |
| [`plugin-market-index/`](plugin-market-index/README.md) | Validates plugin market index format 1 and builds pinned install arguments |
| [`crypto/`](crypto/README.md) | Mints RFC 9562 v4 UUIDs from the cross-runtime `crypto.getRandomValues` primitive |
| [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage |
diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md
index cd8cdcf3e8..5dc7a201a6 100644
--- a/packages/util/README.zh.md
+++ b/packages/util/README.zh.md
@@ -28,6 +28,7 @@ kind: "package-group"
|---|---|
| [`brand/`](brand/README.zh.md) | 提供名义字符串类型及其无状态构造函数 |
| [`package-manifest/`](package-manifest/README.zh.md) | 包 manifest(元数据清单)的共享 TypeScript 声明 |
+| [`plugin-detail/`](plugin-detail/README.zh.md) | 选择插件详细介绍文档,并从本地包和 npm 文件 CDN 读取它们 |
| [`plugin-market-index/`](plugin-market-index/README.zh.md) | 校验插件市场索引格式 1,并生成锁定版本的安装参数 |
| [`crypto/`](crypto/README.zh.md) | 基于跨运行时 `crypto.getRandomValues` 原语生成 RFC 9562 v4 UUID |
| [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 |
diff --git a/packages/util/plugin-detail/README.i18n.yaml b/packages/util/plugin-detail/README.i18n.yaml
new file mode 100644
index 0000000000..cb12152f41
--- /dev/null
+++ b/packages/util/plugin-detail/README.i18n.yaml
@@ -0,0 +1,46 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/util/plugin-detail/README.md
+/:
+ en: 505fd12717a89948
+ zh: 2ad120714356f61b
+/dsh-plugin-detail:
+ en: 9ef51ea35aa9ea60
+ zh: 341593614dac3945
+/dsh-plugin-detail/summary:
+ en: 9a5bdafd69ed0208
+ zh: d436f289724bab19
+/dsh-plugin-detail/table-of-contents:
+ en: cd2a60a905c978d6
+ zh: cc71d387d0cbd442
+/dsh-plugin-detail/use-this-package:
+ en: ccdf3fdf80317f76
+ zh: 023b893b30d1dd5b
+/dsh-plugin-detail/use-this-package/document-order:
+ en: fe28217d182b9174
+ zh: 0a0b4a61ee4009eb
+/dsh-plugin-detail/use-this-package/images:
+ en: abeb7bb1f7695e5e
+ zh: 59b87c9abea17dfb
+/dsh-plugin-detail/use-this-package/file-sources:
+ en: 9542401a7d104851
+ zh: 99f80d2960b77bc8
+/dsh-plugin-detail/use-this-package/image-responses:
+ en: d6e7da207ee7f2fb
+ zh: c7b6acaa4d2839a2
+/dsh-plugin-detail/api:
+ en: ed9c0c624558d944
+ zh: 9373180d9edb1012
+/dsh-plugin-detail/model-experience:
+ en: 60eeeeff73bf48b4
+ zh: cf3e6e1885719d6a
+/dsh-plugin-detail/model-experience/kv-cache-effect:
+ en: 4f0eadf94828e66e
+ zh: 4f8a6ec170ebbfb6
+/dsh-plugin-detail/known-limitations-and-deferred-work:
+ en: 5d8abfbb06761f7f
+ zh: 8515079f96e513bf
+/dsh-plugin-detail/known-limitations-and-deferred-work/dev-note:
+ en: e67f633dc6fe7773
+ zh: 3bb0c20c93f16f98
diff --git a/packages/util/plugin-detail/README.md b/packages/util/plugin-detail/README.md
new file mode 100644
index 0000000000..b80404055c
--- /dev/null
+++ b/packages/util/plugin-detail/README.md
@@ -0,0 +1,101 @@
+---
+description: "Plugin detail document selection without state: the meta.detail or README order, language fallback, image paths, and local and npm CDN file sources."
+kind: "package-library"
+---
+
+# dsh-plugin-detail
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Library without state for plugin detail documents, the long Markdown description a plugin's page shows. It owns the order in which a package's document is chosen, the language fallback, image destinations beside a document, and two read-only file sources: a local package directory and a published npm version on public file CDNs. [`plugin-manager`](../../boot/plugin-manager/README.md#bundle-detail-documents) reads profile bundles with it, and [`plugin-market`](../../boot/plugin-market/README.md#plugin-detail-documents) reads market entries with it and adds repository sources.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [API](#api)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+
+## Use this package
+
+It is a library, not a service or plugin: no `ctx`, no registrations, no state. Reads go through the file source the caller passes.
+
+
+### Document order
+
+A plugin's detail document is the first that exists, in this order:
+
+1. **Locale `meta.detail`.** For each language of the chain, the package's `locale/.json` is read, or, when that file does not exist, the same name with an upper-case two-letter region such as `locale/zh-CN.json`. When the file read declares `meta.detail`, that Markdown file is the document; otherwise the next language is tried.
+2. **Package README.** `README.md`, `readme.md`, `Readme.md`, `README.markdown`, or `README`. A source that lists directories takes any letter case of `README`, `README.md`, and `README.markdown`.
+3. **Repository README, market entries only.** The README of the repository directory that the market entry's `repository` names, otherwise the one that the package's `package.json` `repository` names, at the default branch. [`plugin-market`](../../boot/plugin-market/README.md#plugin-detail-documents) reads it; bundle pages in **Plugins** never read a repository.
+
+`readPackageDocument(files, dir, languages, maxBytes, signal)` implements steps 1 and 2 and returns `undefined` when neither applies. `meta.detail` must be a `/`-separated path relative to the package root that ends in `.md` or `.markdown` and has no empty, `.`, or `..` segment, backslash, or URL scheme. A malformed locale file, a malformed `meta.detail`, and a named file that does not exist fail with `DetailFailure` `invalid`; the README is not read in their place.
+
+`languageChain(language)` builds the chain: the language in lower case, each shorter prefix, then English, so `zh-Hans-CN` reads `zh-hans-cn`, `zh-hans`, `zh`, and `en`.
+
+The returned Markdown has no byte order mark and no leading YAML front matter block.
+
+### Images
+
+`detailImage(document, destination)` resolves an image a document writes: relative to the document, or to the root of its files when it starts with `/`; a query or fragment is ignored. It returns the image's path and media type, or `undefined` for a destination that leaves the root, is malformed, or names a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG.
+
+### File sources
+
+- `localFiles(root, revision)` reads a package directory. A path whose real location leaves the directory reads as absent. `revision`, such as the installed version, becomes part of the source's `key`.
+- `npmFiles(name, version, sources, options)` reads a published version from jsDelivr, unpkg, and npmmirror in the given order. A 404 means the package has no such file; a network failure or another status asks the next CDN. `npmFileSourceOrder(configured, preferred)` puts the preferred CDNs that are configured first, in the preferred order, then the other configured ones.
+- `fetchBody(url, maxBytes, signal, options)` reads one HTTP body up to `maxBytes`, following redirects within the URL's origin.
+
+Every read is bounded by the caller's `maxBytes` and fails with `DetailFailure` `too-large` beyond it, or `network` when a source cannot be reached.
+
+### Image responses
+
+`detailImageResponse(outcome, cacheControl)` answers an image route: the image with `cacheControl`, 404 when there is none, or the failure's status (`too-large` 413, `network` 502, `denied` 403, `git-unavailable` 503, `invalid` 400) with the reason as its body and `Cache-Control: no-store`. Every response carries `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`, because an SVG opened directly would otherwise run scripts on the authenticated API origin.
+
+-----
+
+
+## API
+
+```ts
+import {
+ DetailFailure, detailImage, detailImageResponse, languageChain, localFiles, NPM_FILE_SOURCES, npmFiles, npmFileSourceOrder,
+ readPackageDocument,
+} from '@deepseek-ai/dsh-plugin-detail'
+```
+
+`DetailFailureReason` and `NpmFileSource`, which Remote methods of other packages use, are also exported type-only from `@deepseek-ai/dsh-plugin-detail/types`.
+
+`DetailFiles` is the source interface: `read(path, maxBytes, signal)` returns a file's bytes or `undefined`, and `readme(dir, maxBytes, signal)` returns a directory's README. Repository sources implement it in `plugin-market`.
+
+-----
+
+
+## Model Experience
+
+None, as this library selects documents for plugin pages and adds nothing to model requests.
+
+#### KV Cache effect
+
+None.
+
+## Known Limitations and Deferred Work
+
+
+
+- **Public npm only** — the file CDNs serve packages of the public npm registry; packages of other registries have no npm file source.
+
+
+### Dev Note
+
+
+Working context for maintainers — click to expand
+
+None.
+
+
diff --git a/packages/util/plugin-detail/README.zh.md b/packages/util/plugin-detail/README.zh.md
new file mode 100644
index 0000000000..2faa23c4e8
--- /dev/null
+++ b/packages/util/plugin-detail/README.zh.md
@@ -0,0 +1,101 @@
+---
+description: "无状态的插件详细介绍文档选择:meta.detail 或 README 的顺序、语言回退、图片路径,以及本地和 npm CDN 文件来源。"
+kind: "package-library"
+---
+
+# dsh-plugin-detail
+
+[English](README.md) | 中文
+
+## 概述
+
+用于插件详细介绍文档的无状态库。详细介绍是插件页面显示的较长 Markdown 描述。本包负责选择包文档的顺序、语言回退、文档旁图片的目标路径,以及两种只读文件来源:本地包目录,以及公共文件 CDN 上已发布的 npm 版本。[`plugin-manager`](../../boot/plugin-manager/README.zh.md#bundle-detail-documents) 用它读取 profile 中的组合包,[`plugin-market`](../../boot/plugin-market/README.zh.md#plugin-detail-documents) 用它读取市场条目,并额外提供仓库来源。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [API](#api)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+
+## 使用本包
+
+本包是库,不是服务或插件:没有 `ctx`、没有注册,也没有状态。所有读取都经由调用方传入的文件来源进行。
+
+
+### 文档顺序
+
+插件的详细介绍文档是按以下顺序第一个存在的文档:
+
+1. **locale 中的 `meta.detail`。** 对语言链中的每种语言,读取包的 `locale/.json`;该文件不存在时,读取地区部分为大写两字母的同名文件,例如 `locale/zh-CN.json`。读取到的文件声明 `meta.detail` 时,该 Markdown 文件即为文档;否则尝试下一种语言。
+2. **包 README。** `README.md`、`readme.md`、`Readme.md`、`README.markdown` 或 `README`。能列出目录内容的来源接受任意大小写的 `README`、`README.md` 和 `README.markdown`。
+3. **仓库 README,仅限市场条目。** 市场条目的 `repository` 所指仓库目录中的 README;条目没有 `repository` 时,使用包 `package.json` 的 `repository` 所指仓库目录中的 README;均读取默认分支上的文件。[`plugin-market`](../../boot/plugin-market/README.zh.md#plugin-detail-documents) 读取此步;**插件**中的组合包页面从不读取仓库。
+
+`readPackageDocument(files, dir, languages, maxBytes, signal)` 实现第 1 步和第 2 步,两者都不适用时返回 `undefined`。`meta.detail` 必须是相对于包根目录、以 `/` 分隔、以 `.md` 或 `.markdown` 结尾的路径,且不含空段、`.` 或 `..` 段、反斜杠或 URL scheme。locale 文件格式错误、`meta.detail` 格式错误,或指定的文件不存在时,以 `DetailFailure` `invalid` 失败;不会改为读取 README。
+
+`languageChain(language)` 构建语言链:先是小写形式的该语言,然后是每个更短的前缀,最后是英文,因此 `zh-Hans-CN` 依次读取 `zh-hans-cn`、`zh-hans`、`zh` 和 `en`。
+
+返回的 Markdown 不含字节顺序标记(BOM),也不含开头的 YAML front matter 块。
+
+### 图片
+
+`detailImage(document, destination)` 解析文档中引用的图片:相对于该文档;以 `/` 开头时相对于其文件的根目录;查询和片段被忽略。它返回图片的路径和媒体类型;目标路径超出根目录、格式错误,或指定 PNG、JPEG、GIF、WebP、AVIF 和 SVG 之外的类型时,返回 `undefined`。
+
+### 文件来源
+
+- `localFiles(root, revision)` 读取一个包目录。实际位置超出该目录的路径按不存在处理。`revision`(例如已安装的版本)成为该来源 `key` 的一部分。
+- `npmFiles(name, version, sources, options)` 按给定顺序从 jsDelivr、unpkg 和 npmmirror 读取一个已发布版本。404 表示该包没有此文件;网络失败或其他状态码时改为请求下一个 CDN。`npmFileSourceOrder(configured, preferred)` 把已配置的优先 CDN 按优先顺序排在最前,其后是其他已配置的 CDN。
+- `fetchBody(url, maxBytes, signal, options)` 读取一个 HTTP 响应体,最多 `maxBytes`,并跟随该 URL 的 origin 内的重定向。
+
+每次读取都受调用方 `maxBytes` 的限制,超过时以 `DetailFailure` `too-large` 失败;来源无法访问时以 `network` 失败。
+
+### 图片响应
+
+`detailImageResponse(outcome, cacheControl)` 生成图片路由的响应:有图片时返回该图片并带 `cacheControl`;没有图片时返回 404;失败时返回该失败的状态码(`too-large` 为 413、`network` 为 502、`denied` 为 403、`git-unavailable` 为 503、`invalid` 为 400),以失败原因作为响应体,并带 `Cache-Control: no-store`。每个响应都带有 `X-Content-Type-Options: nosniff` 和 `Content-Security-Policy: sandbox`,因为否则直接打开的 SVG 会在已通过认证的 API origin 上运行脚本。
+
+-----
+
+
+## API
+
+```ts
+import {
+ DetailFailure, detailImage, detailImageResponse, languageChain, localFiles, NPM_FILE_SOURCES, npmFiles, npmFileSourceOrder,
+ readPackageDocument,
+} from '@deepseek-ai/dsh-plugin-detail'
+```
+
+`DetailFailureReason` 和 `NpmFileSource` 供其他包的 Remote 方法使用,也从 `@deepseek-ai/dsh-plugin-detail/types` 以仅类型方式导出。
+
+`DetailFiles` 是来源接口:`read(path, maxBytes, signal)` 返回文件的字节或 `undefined`,`readme(dir, maxBytes, signal)` 返回目录的 README。仓库来源在 `plugin-market` 中实现该接口。
+
+-----
+
+
+## 模型体验
+
+无,因为本库为插件页面选择文档,不向模型请求添加任何内容。
+
+#### KV Cache 影响
+
+无。
+
+## 已知限制与延期工作
+
+
+
+- **只支持公共 npm**——文件 CDN 提供公共 npm 注册表中的包;其他注册表中的包没有 npm 文件来源。
+
+
+### 开发备注
+
+
+维护者的工作上下文——点击展开
+
+无。
+
+
diff --git a/packages/util/plugin-detail/package.json b/packages/util/plugin-detail/package.json
new file mode 100644
index 0000000000..db24c51bce
--- /dev/null
+++ b/packages/util/plugin-detail/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@deepseek-ai/dsh-plugin-detail",
+ "description": "Plugin detail documents: meta.detail or README selection, image paths, and local and npm CDN file sources",
+ "version": "0.2.1-rc.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/util/plugin-detail"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./types": {
+ "types": "./lib/types/types.d.ts",
+ "default": "./lib/types/types.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/types/**/*.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:~"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:~"
+ }
+}
diff --git a/packages/util/plugin-detail/src/document.ts b/packages/util/plugin-detail/src/document.ts
new file mode 100644
index 0000000000..2752afd9f2
--- /dev/null
+++ b/packages/util/plugin-detail/src/document.ts
@@ -0,0 +1,137 @@
+/**
+ * Plugin detail document selection: the Markdown file a package's locale `meta.detail` names in a display language,
+ * otherwise the package's README; and the images beside a selected document.
+ */
+import { posix } from 'node:path'
+import { DetailFailure, joinPath, type DetailFiles } from './files.ts'
+
+const LANGUAGE_ID = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/u
+
+/**
+ * The languages a document is chosen in: the language, each shorter prefix, then English.
+ * @param language - a BCP 47 language id such as `zh-CN`.
+ * @returns lower-case language ids, most specific first.
+ * @throws when `language` is not a language id.
+ */
+export function languageChain(language: string): string[] {
+ const id = language.toLowerCase()
+ if (!LANGUAGE_ID.test(id)) throw new Error(`Not a language id: ${language}`)
+ const parts = id.split('-')
+ const chain = parts.map((_part, index) => parts.slice(0, parts.length - index).join('-'))
+ return [...new Set([...chain, 'en'])]
+}
+
+/**
+ * A locale `meta.detail` value: a `/`-separated Markdown path relative to the package root that cannot leave it.
+ * @throws {DetailFailure} `invalid` for another value.
+ */
+function detailPath(value: unknown, label: string): string | undefined {
+ if (value === undefined) return undefined
+ const invalid = (rule: string): DetailFailure => new DetailFailure('invalid', `${label} ${rule}`)
+ if (typeof value !== 'string' || value.trim() === '') throw invalid('must be a non-empty string')
+ if (value.includes('\\') || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(value) || value.split('/').some(segment => segment === '' || segment === '.' || segment === '..')) {
+ throw invalid('must be a relative path inside the package')
+ }
+ if (!/\.(?:md|markdown)$/iu.test(value)) throw invalid('must name a Markdown file')
+ return value
+}
+
+/** `meta.detail` of the first `locale/.json` along the chain that declares one. */
+async function localeDetail(
+ files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal,
+): Promise {
+ for (const language of languages) {
+ // Locale files are named by language id: lower case, and also with an upper-case two-letter region.
+ const names = [...new Set([language, language.replace(/^([a-z]{2,3})-([a-z]{2})$/u, (_match, base: string, region: string) => `${base}-${region.toUpperCase()}`)])]
+ for (const name of names) {
+ const path = joinPath(dir, `locale/${name}.json`)
+ const bytes = await files.read(path, maxBytes, signal)
+ if (bytes === undefined) continue
+ let meta: unknown
+ try {
+ meta = (JSON.parse(new TextDecoder().decode(bytes)) as { meta?: unknown } | null)?.meta
+ } catch (error) {
+ throw new DetailFailure('invalid', `${path}: ${(error as SyntaxError).message}`, { cause: error })
+ }
+ const detail = detailPath(typeof meta === 'object' && meta !== null ? (meta as { detail?: unknown }).detail : undefined, `${path}: meta.detail`)
+ if (detail !== undefined) return detail
+ break
+ }
+ }
+ return undefined
+}
+
+/**
+ * Decode a Markdown document: UTF-8 without a byte order mark, without a leading YAML front matter block, which is file
+ * metadata rather than description text.
+ * @param bytes - the file's bytes.
+ * @returns the Markdown text.
+ */
+export function markdownOf(bytes: Uint8Array): string {
+ return new TextDecoder().decode(bytes).replace(/^\uFEFF/u, '').replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/u, '')
+}
+
+/** A package's detail document. */
+export interface PackageDocument {
+ /** `locale` for the file `meta.detail` names, `readme` for the package's README. */
+ readonly origin: 'locale' | 'readme'
+ /** `/`-separated path of the document inside the files. */
+ readonly path: string
+ /** The document, decoded by {@link markdownOf}. */
+ readonly markdown: string
+}
+
+/**
+ * Read a package's detail document: the Markdown file that `meta.detail` in its `locale/.json` names for the
+ * first language of the chain that declares one, otherwise its README.
+ * @param files - the package's files.
+ * @param dir - package directory inside `files`, `''` for its root.
+ * @param languages - the chain from {@link languageChain}.
+ * @param maxBytes - largest accepted locale file and document.
+ * @param signal - cancellation of the reads.
+ * @returns the document, or undefined when the package has neither.
+ * @throws {DetailFailure} `invalid` for a malformed locale file or `meta.detail` or a named file that does not exist, and
+ * the failures of `files`.
+ */
+export async function readPackageDocument(
+ files: DetailFiles, dir: string, languages: readonly string[], maxBytes: number, signal: AbortSignal,
+): Promise {
+ const detail = await localeDetail(files, dir, languages, maxBytes, signal)
+ if (detail !== undefined) {
+ const path = joinPath(dir, detail)
+ const bytes = await files.read(path, maxBytes, signal)
+ if (bytes === undefined) throw new DetailFailure('invalid', `${path}, named by meta.detail, does not exist`)
+ return { origin: 'locale', path, markdown: markdownOf(bytes) }
+ }
+ const readme = await files.readme(dir, maxBytes, signal)
+ return readme === undefined ? undefined : { origin: 'readme', path: readme.path, markdown: markdownOf(readme.bytes) }
+}
+
+/** Image file types served beside a document, by lower-case extension. */
+const IMAGE_TYPES = new Map([
+ ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
+ ['.webp', 'image/webp'], ['.avif', 'image/avif'], ['.svg', 'image/svg+xml'],
+])
+
+/**
+ * Resolve an image destination a document writes.
+ * @param document - `/`-separated path of the document inside its files.
+ * @param authored - the destination as written: relative to the document, or to the root of its files when it starts
+ * with `/`; a query or fragment is ignored.
+ * @returns the image's path inside the files and its media type, or undefined for a malformed destination, a path that
+ * leaves the root, or a type other than PNG, JPEG, GIF, WebP, AVIF, and SVG.
+ */
+export function detailImage(document: string, authored: string): { path: string; mediaType: string } | undefined {
+ let decoded: string
+ try {
+ decoded = decodeURIComponent(authored.replace(/[?#].*$/su, ''))
+ } catch (_malformed) {
+ // A destination with a malformed percent escape names no file.
+ return undefined
+ }
+ if (decoded.includes('\\') || decoded.includes('\0')) return undefined
+ const path = posix.normalize(decoded.startsWith('/') ? decoded.slice(1) : posix.join(posix.dirname(document), decoded))
+ if (path === '.' || path.startsWith('../') || path === '..' || posix.isAbsolute(path)) return undefined
+ const mediaType = IMAGE_TYPES.get(posix.extname(path).toLowerCase())
+ return mediaType === undefined ? undefined : { path, mediaType }
+}
diff --git a/packages/util/plugin-detail/src/files.ts b/packages/util/plugin-detail/src/files.ts
new file mode 100644
index 0000000000..0ace2e2d3f
--- /dev/null
+++ b/packages/util/plugin-detail/src/files.ts
@@ -0,0 +1,283 @@
+/**
+ * Read-only file sources for plugin detail documents: a local package directory and published npm package versions on
+ * public file CDNs, with bounded HTTP reads. Sources keep no state between calls.
+ */
+import { lstat, readdir, readFile, realpath } from 'node:fs/promises'
+import { join, relative, sep } from 'node:path'
+import type { DetailFailureReason, NpmFileSource } from './types.ts'
+
+
+/** A read that must not fall back: the source exists but refused, failed, or exceeded a limit. */
+export class DetailFailure extends Error {
+ /**
+ * @param reason - the failure kind a reader reports.
+ * @param message - the diagnostic, naming the source.
+ * @param options - the underlying error.
+ */
+ constructor(readonly reason: DetailFailureReason, message: string, options?: ErrorOptions) {
+ super(message, options)
+ this.name = 'DetailFailure'
+ }
+}
+
+/** One file found by name lookup. */
+export interface FoundFile {
+ /** `/`-separated path relative to the source root. */
+ readonly path: string
+ readonly bytes: Uint8Array
+}
+
+/** Files below one root, read by `/`-separated relative paths that never start with `/` or contain `.` or `..` segments. */
+export interface DetailFiles {
+ /** Identity of the files' content: two sources with the same key read the same bytes. */
+ readonly key: string
+ /**
+ * Read one regular file.
+ * @param path - path relative to the root.
+ * @param maxBytes - largest accepted size.
+ * @param signal - cancellation of the read.
+ * @returns the file's bytes, or undefined when it does not exist.
+ * @throws {DetailFailure} when the source fails or the file exceeds `maxBytes`.
+ */
+ read(path: string, maxBytes: number, signal: AbortSignal): Promise
+ /**
+ * Read the README of a directory: `README.md`, `README.markdown`, or `README` in any letter case.
+ * @param dir - directory relative to the root; `''` for the root.
+ * @param maxBytes - largest accepted size.
+ * @param signal - cancellation of the read.
+ * @returns the file, or undefined when the directory has none.
+ * @throws {DetailFailure} when the source fails or the file exceeds `maxBytes`.
+ */
+ readme(dir: string, maxBytes: number, signal: AbortSignal): Promise
+}
+
+/** README names tried in order where a source cannot list a directory. */
+export const README_NAMES: readonly string[] = ['README.md', 'readme.md', 'Readme.md', 'README.markdown', 'README']
+const README_PATTERN = /^readme(?:\.md|\.markdown)?$/iu
+
+/**
+ * Join a directory and a file name into a root-relative path.
+ * @param dir - directory relative to the root, `''` for the root.
+ * @param name - path relative to `dir`.
+ * @returns the joined path.
+ */
+export function joinPath(dir: string, name: string): string {
+ return dir === '' ? name : `${dir}/${name}`
+}
+
+/**
+ * Pick the README entry of a directory listing by the order of {@link README_NAMES}, then by name.
+ * @param names - file names of the directory.
+ * @returns the README name, or undefined.
+ */
+export function readmeName(names: readonly string[]): string | undefined {
+ const rank = (name: string): number => {
+ const index = README_NAMES.indexOf(name)
+ return index === -1 ? README_NAMES.length : index
+ }
+ return names.filter(name => README_PATTERN.test(name)).sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : 1))[0]
+}
+
+/** Reads one file: its bytes, or undefined when it is absent. */
+export type ReadFile = (path: string, maxBytes: number, signal: AbortSignal) => Promise
+
+/**
+ * Read the first README of `names` that exists, for sources that cannot list a directory.
+ * @param read - reads one file.
+ * @param dir - directory relative to the root; `''` for the root.
+ * @param maxBytes - largest accepted size.
+ * @param signal - cancellation of the reads.
+ * @param names - the names to try, in order.
+ * @returns the file, or undefined when none exists.
+ * @throws {DetailFailure} what `read` throws.
+ */
+export async function readmeByName(
+ read: ReadFile, dir: string, maxBytes: number, signal: AbortSignal, names: readonly string[] = README_NAMES,
+): Promise {
+ for (const name of names) {
+ const path = joinPath(dir, name)
+ const bytes = await read(path, maxBytes, signal)
+ if (bytes !== undefined) return { path, bytes }
+ }
+ return undefined
+}
+
+function tooLarge(label: string, maxBytes: number): DetailFailure {
+ return new DetailFailure('too-large', `${label} exceeds ${String(maxBytes)} bytes`)
+}
+
+/**
+ * Files of a local package directory. A path whose real location leaves the directory reads as absent.
+ * @param root - absolute package directory.
+ * @param revision - the installed version, so a reinstalled package reads as different files.
+ * @returns the files.
+ */
+export function localFiles(root: string, revision: string): DetailFiles {
+ const files: DetailFiles = {
+ key: `local:${root}@${revision}`,
+ async read(path, maxBytes) {
+ let file: string
+ try {
+ const base = await realpath(root)
+ file = await realpath(join(base, ...path.split('/')))
+ const inside = relative(base, file)
+ if (inside === '' || inside === '..' || inside.startsWith(`..${sep}`)) return undefined
+ const info = await lstat(file)
+ if (!info.isFile()) return undefined
+ if (info.size > maxBytes) throw tooLarge(path, maxBytes)
+ } catch (error) {
+ if (error instanceof DetailFailure) throw error
+ // A missing file or directory, or one the process may not read, is a file the package does not provide.
+ return undefined
+ }
+ const bytes = await readFile(file)
+ /* v8 ignore next -- the file grew between its size check and its read; no test controls that timing */
+ if (bytes.byteLength > maxBytes) throw tooLarge(path, maxBytes)
+ return bytes
+ },
+ async readme(dir, maxBytes, signal) {
+ let names: string[]
+ try {
+ names = (await readdir(join(root, ...dir === '' ? [] : dir.split('/')), { withFileTypes: true }))
+ .filter(entry => entry.isFile() || entry.isSymbolicLink()).map(entry => entry.name)
+ } catch (_missing) {
+ // An absent or unreadable directory has no README.
+ return undefined
+ }
+ const name = readmeName(names)
+ if (name === undefined) return undefined
+ const path = joinPath(dir, name)
+ const bytes = await files.read(path, maxBytes, signal)
+ return bytes === undefined ? undefined : { path, bytes }
+ },
+ }
+ return files
+}
+
+/** Request settings of a remote file source. */
+export interface RemoteFileOptions {
+ readonly timeoutMs: number
+ /** Cancellation that outlives one read, such as the owning service's disposal. */
+ readonly lifetime: AbortSignal
+}
+
+/** One HTTP answer: the body of a 2xx response, or the status of any other. */
+export type FetchedBody = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false; readonly status: number }
+
+/** Redirects followed within one origin, such as a renamed repository's raw files; a redirect to another origin is not followed. */
+const MAX_REDIRECTS = 3
+
+/**
+ * GET a URL, following redirects within its origin, and read the body up to `maxBytes`.
+ * @param url - the request URL.
+ * @param maxBytes - largest accepted body.
+ * @param signal - cancellation of the read.
+ * @param options - deadline and service lifetime.
+ * @returns the body, or the status of a non-2xx response, including a redirect to another origin.
+ * @throws {DetailFailure} `network` when no response arrives or the body fails, `too-large` over `maxBytes`.
+ */
+export async function fetchBody(url: string, maxBytes: number, signal: AbortSignal, options: RemoteFileOptions): Promise {
+ const combined = AbortSignal.any([signal, options.lifetime, AbortSignal.timeout(options.timeoutMs)])
+ let response: Response | undefined
+ let target = url
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
+ try {
+ response = await fetch(target, { signal: combined, redirect: 'manual' })
+ } catch (error) {
+ // A cancelled read says nothing about the source, so it keeps the caller's reason.
+ signal.throwIfAborted()
+ options.lifetime.throwIfAborted()
+ throw new DetailFailure('network', `Could not fetch ${target}: ${error instanceof Error ? error.message : String(error)}`, { cause: error })
+ }
+ const location = response.status >= 300 && response.status < 400 ? response.headers.get('location') : null
+ if (location === null || !URL.canParse(location, target) || new URL(location, target).origin !== new URL(url).origin) break
+ await response.body?.cancel()
+ target = new URL(location, target).href
+ }
+ // The loop runs at least once.
+ const answer = response as Response
+ if (!answer.ok) {
+ await answer.body?.cancel()
+ return { ok: false, status: answer.status }
+ }
+ if (Number(answer.headers.get('content-length') ?? 0) > maxBytes) {
+ await answer.body?.cancel()
+ throw tooLarge(url, maxBytes)
+ }
+ const chunks: Uint8Array[] = []
+ let size = 0
+ try {
+ for await (const chunk of answer.body ?? []) {
+ size += chunk.byteLength
+ if (size > maxBytes) throw tooLarge(url, maxBytes)
+ chunks.push(chunk)
+ }
+ } catch (error) {
+ if (error instanceof DetailFailure) throw error
+ throw new DetailFailure('network', `Could not read ${url}: ${error instanceof Error ? error.message : String(error)}`, { cause: error })
+ }
+ return { ok: true, bytes: Buffer.concat(chunks) }
+}
+
+/** Every npm file CDN, in the default order. */
+export const NPM_FILE_SOURCES = ['jsdelivr', 'unpkg', 'npmmirror'] as const satisfies readonly NpmFileSource[]
+
+/**
+ * Order the configured npm file CDNs by a caller's preference: the preferred ones that are configured, in the preferred
+ * order, then the other configured ones in configured order. A preferred CDN that is not configured is not asked.
+ * @param configured - the configured CDNs in order.
+ * @param preferred - CDNs to ask first, such as those a Client measured as fastest; names other than CDNs are ignored.
+ * @returns the CDNs to ask, first to last.
+ */
+export function npmFileSourceOrder(configured: readonly NpmFileSource[], preferred: readonly string[] = []): NpmFileSource[] {
+ const first = configured.filter(source => preferred.includes(source)).sort((a, b) => preferred.indexOf(a) - preferred.indexOf(b))
+ return [...first, ...configured.filter(source => !first.includes(source))]
+}
+
+/**
+ * Percent-encode each segment of a `/`-separated path for a URL.
+ * @param path - the path.
+ * @returns the encoded path.
+ */
+export function encodePath(path: string): string {
+ return path.split('/').map(encodeURIComponent).join('/')
+}
+
+const NPM_FILE_URLS: Record string> = {
+ jsdelivr: (name, version, path) => `https://cdn.jsdelivr.net/npm/${name}@${version}/${encodePath(path)}`,
+ unpkg: (name, version, path) => `https://unpkg.com/${name}@${version}/${encodePath(path)}`,
+ npmmirror: (name, version, path) => `https://registry.npmmirror.com/${name}/${version}/files/${encodePath(path)}`,
+}
+
+/**
+ * Files of one published npm package version, read from public file CDNs in order. A 404 from a CDN means the package
+ * does not contain the file; a network failure or another status asks the next CDN.
+ * @param name - package name.
+ * @param version - exact version.
+ * @param sources - the CDNs to ask, first to last; never empty.
+ * @param options - deadline and service lifetime.
+ * @returns the files.
+ */
+export function npmFiles(name: string, version: string, sources: readonly NpmFileSource[], options: RemoteFileOptions): DetailFiles {
+ const files: DetailFiles = {
+ key: `npm:${name}@${version}`,
+ async read(path, maxBytes, signal) {
+ const failures: string[] = []
+ for (const source of sources) {
+ const url = NPM_FILE_URLS[source](name, version, path)
+ try {
+ const body = await fetchBody(url, maxBytes, signal, options)
+ if (body.ok) return body.bytes
+ if (body.status === 404) return undefined
+ failures.push(`${url}: HTTP ${String(body.status)}`)
+ } catch (error) {
+ if (!(error instanceof DetailFailure) || error.reason !== 'network') throw error
+ failures.push(error.message)
+ }
+ }
+ throw new DetailFailure('network', failures.join('; '))
+ },
+ readme: (dir, maxBytes, signal) => readmeByName((path, max, readSignal) => files.read(path, max, readSignal), dir, maxBytes, signal),
+ }
+ return files
+}
diff --git a/packages/util/plugin-detail/src/index.ts b/packages/util/plugin-detail/src/index.ts
new file mode 100644
index 0000000000..e715ca8090
--- /dev/null
+++ b/packages/util/plugin-detail/src/index.ts
@@ -0,0 +1,13 @@
+/**
+ * Plugin detail documents without state: language chains, the `meta.detail` or README selection of a package, image
+ * destinations beside a document, and read-only file sources for local packages and published npm versions.
+ * @module @deepseek-ai/dsh-plugin-detail
+ */
+
+export {
+ DetailFailure, encodePath, fetchBody, joinPath, localFiles, NPM_FILE_SOURCES, npmFileSourceOrder, npmFiles, README_NAMES,
+ readmeByName, readmeName, type DetailFiles, type FetchedBody, type FoundFile, type ReadFile, type RemoteFileOptions,
+} from './files.ts'
+export { detailImage, languageChain, markdownOf, readPackageDocument, type PackageDocument } from './document.ts'
+export { detailImageResponse, type DetailImage } from './response.ts'
+export type { DetailFailureReason, NpmFileSource } from './types.ts'
diff --git a/packages/util/plugin-detail/src/response.ts b/packages/util/plugin-detail/src/response.ts
new file mode 100644
index 0000000000..08cdf2af5d
--- /dev/null
+++ b/packages/util/plugin-detail/src/response.ts
@@ -0,0 +1,37 @@
+/** HTTP responses of Host routes that serve images beside detail documents. */
+import type { DetailFailureReason } from './types.ts'
+
+/** An image read beside a detail document. */
+export interface DetailImage {
+ readonly bytes: Uint8Array
+ readonly mediaType: string
+}
+
+/** HTTP status of each detail failure. */
+const FAILURE_STATUS = {
+ 'too-large': 413, 'network': 502, 'denied': 403, 'git-unavailable': 503, 'invalid': 400,
+} as const satisfies Record
+
+/**
+ * The response of an image route: the image, 404 when there is none, or the failure's status with its reason as the
+ * body. Every response carries `X-Content-Type-Options: nosniff` and a sandbox content security policy, because an SVG
+ * opened directly would otherwise run scripts on the authenticated API origin; only an image gets `cacheControl`.
+ * @param outcome - the image, undefined when none exists, or why it cannot be read; `invalid` also answers a malformed request.
+ * @param cacheControl - the `Cache-Control` value of an image.
+ * @returns the response.
+ */
+export function detailImageResponse(outcome: DetailImage | DetailFailureReason | undefined, cacheControl: string): Response {
+ const [status, body, type] = typeof outcome === 'object'
+ // A copy into a fresh buffer is a body `Response` accepts whatever buffer the bytes view.
+ ? [200, new Uint8Array(outcome.bytes), outcome.mediaType]
+ : outcome === undefined ? [404, 'not found', undefined] : [FAILURE_STATUS[outcome], outcome, undefined]
+ return new Response(body, {
+ status,
+ headers: {
+ 'Content-Type': type ?? 'text/plain; charset=utf-8',
+ 'Cache-Control': status === 200 ? cacheControl : 'no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ 'Content-Security-Policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'",
+ },
+ })
+}
diff --git a/packages/util/plugin-detail/src/types.ts b/packages/util/plugin-detail/src/types.ts
new file mode 100644
index 0000000000..4631b64154
--- /dev/null
+++ b/packages/util/plugin-detail/src/types.ts
@@ -0,0 +1,12 @@
+/** Plugin detail records that Remote methods and their clients share. */
+
+/**
+ * Why a detail document or one of its images cannot be read: `too-large` beyond a size limit, `network` when a source
+ * cannot be reached, `denied` when Git refuses the repository with the user's credentials, `git-unavailable` when a
+ * repository must be cloned and Git cannot run, and `invalid` for a malformed locale file or `meta.detail`, or a named
+ * file that does not exist.
+ */
+export type DetailFailureReason = 'too-large' | 'network' | 'denied' | 'git-unavailable' | 'invalid'
+
+/** A public CDN that serves single files of published npm package versions. */
+export type NpmFileSource = 'jsdelivr' | 'unpkg' | 'npmmirror'
diff --git a/packages/util/plugin-detail/tests/document.spec.ts b/packages/util/plugin-detail/tests/document.spec.ts
new file mode 100644
index 0000000000..d22719f7de
--- /dev/null
+++ b/packages/util/plugin-detail/tests/document.spec.ts
@@ -0,0 +1,92 @@
+/** Package document selection, language chains, and image destinations. */
+import { expect, it } from 'vitest'
+import { detailImage, languageChain, markdownOf, readPackageDocument } from '../src/document.ts'
+import { DetailFailure, readmeName, type DetailFiles } from '../src/files.ts'
+
+const signal = new AbortController().signal
+
+/** Await a rejection that matches `fields` and whose message contains `message`. */
+async function expectRejection(promise: Promise, fields: object, message: string): Promise {
+ await expect(promise).rejects.toMatchObject(fields)
+ await expect(promise).rejects.toThrow(message)
+}
+
+/** In-memory files. */
+function memory(contents: Record): DetailFiles & { reads: string[] } {
+ const reads: string[] = []
+ const read = (path: string, maxBytes: number) => {
+ reads.push(path)
+ const value = contents[path]
+ if (value === undefined) return Promise.resolve(undefined)
+ if (value.length > maxBytes) return Promise.reject(new DetailFailure('too-large', path))
+ return Promise.resolve(new TextEncoder().encode(value))
+ }
+ return {
+ key: 'memory', reads, read,
+ async readme(dir, maxBytes) {
+ const prefix = dir === '' ? '' : `${dir}/`
+ const name = readmeName(Object.keys(contents).filter(path => path.startsWith(prefix)).map(path => path.slice(prefix.length)))
+ if (name === undefined) return undefined
+ return { path: `${prefix}${name}`, bytes: (await read(`${prefix}${name}`, maxBytes)) as Uint8Array }
+ },
+ }
+}
+
+it('derives language chains from a language id', () => {
+ expect(languageChain('zh-Hans-CN')).toEqual(['zh-hans-cn', 'zh-hans', 'zh', 'en'])
+ expect(languageChain('en-US')).toEqual(['en-us', 'en'])
+ expect(languageChain('en')).toEqual(['en'])
+ expect(() => languageChain('../x')).toThrow('Not a language id')
+})
+
+it('decodes Markdown without a byte order mark or a leading front matter block', () => {
+ expect(markdownOf(new TextEncoder().encode('\uFEFF---\ntitle: x\n---\n# A\n\n---\nkept: rule\n---\n'))).toBe('# A\n\n---\nkept: rule\n---\n')
+ expect(markdownOf(new TextEncoder().encode('---\r\nk: v\r\n---'))).toBe('')
+ expect(markdownOf(new TextEncoder().encode('# B'))).toBe('# B')
+})
+
+it('reads the locale document first, falling back through languages and failing on a missing named file', async () => {
+ const files = memory({
+ 'locale/zh.json': JSON.stringify({ meta: { title: '无详情' } }),
+ 'locale/en.json': JSON.stringify({ meta: { detail: 'guide/DETAIL.md' } }),
+ 'locale/zh-CN.json': JSON.stringify({ meta: { detail: 'guide/详情.md' } }),
+ 'guide/DETAIL.md': '# Detail',
+ 'guide/详情.md': '# 详情',
+ 'README.md': '# Readme',
+ })
+ expect(await readPackageDocument(files, '', ['zh', 'en'], 100, signal)).toEqual({ origin: 'locale', path: 'guide/DETAIL.md', markdown: '# Detail' })
+ expect(await readPackageDocument(files, '', languageChain('zh-CN'), 100, signal)).toMatchObject({ origin: 'locale', markdown: '# 详情' })
+ expect(files.reads).toContain('locale/zh-cn.json')
+ await readPackageDocument(files, '', languageChain('zh-Hant'), 100, signal)
+ expect(files.reads).not.toContain('locale/zh-HANT.json')
+ const missing = memory({ 'locale/en.json': JSON.stringify({ meta: { detail: 'GONE.md' } }) })
+ await expectRejection(readPackageDocument(missing, '', ['en'], 100, signal), { reason: 'invalid' }, 'GONE.md')
+})
+
+it('reports malformed locale files and invalid detail paths, and falls back to the README without meta.detail', async () => {
+ const read = (contents: Record) => readPackageDocument(memory(contents), 'pkg', ['en'], 100, signal)
+ await expectRejection(read({ 'pkg/locale/en.json': '{' }), { reason: 'invalid' }, 'pkg/locale/en.json')
+ const invalid = [
+ ['/README.md', 'must be a relative path inside the package'], ['../x.md', 'must be a relative path inside the package'],
+ ['guide/./x.md', 'must be a relative path inside the package'], ['guide//x.md', 'must be a relative path inside the package'],
+ ['guide\\x.md', 'must be a relative path inside the package'], ['https://example.com/x.md', 'must be a relative path inside the package'],
+ ['README.txt', 'must name a Markdown file'], [' ', 'must be a non-empty string'], [7, 'must be a non-empty string'],
+ ] as const
+ for (const [detail, message] of invalid) {
+ await expectRejection(read({ 'pkg/locale/en.json': JSON.stringify({ meta: { detail } }) }), { reason: 'invalid' }, `meta.detail ${message}`)
+ }
+ expect(await read({ 'pkg/locale/en.json': 'null', 'pkg/README': 'plain' })).toEqual({ origin: 'readme', path: 'pkg/README', markdown: 'plain' })
+ expect(await read({ 'pkg/locale/en.json': '{"meta":"x"}' })).toBeUndefined()
+ expect(await read({ 'pkg/locale/en.json': JSON.stringify({ meta: { detail: 'D.markdown' } }), 'pkg/D.markdown': '# D' }))
+ .toMatchObject({ origin: 'locale', markdown: '# D' })
+})
+
+it('resolves image destinations beside a document and refuses other paths', () => {
+ expect(detailImage('guide/DETAIL.md', 'img/a.png?raw=1#x')).toEqual({ path: 'guide/img/a.png', mediaType: 'image/png' })
+ expect(detailImage('guide/DETAIL.md', '/logo.SVG')).toEqual({ path: 'logo.SVG', mediaType: 'image/svg+xml' })
+ expect(detailImage('guide/DETAIL.md', './a%20b.webp')).toEqual({ path: 'guide/a b.webp', mediaType: 'image/webp' })
+ expect(detailImage('README.md', 'b.jpeg')).toEqual({ path: 'b.jpeg', mediaType: 'image/jpeg' })
+ for (const destination of ['../../etc/passwd.png', '..', '/', 'notes.txt', 'img\\a.png', '%E0%A4%A.png', 'a\0.png']) {
+ expect(detailImage('guide/DETAIL.md', destination)).toBeUndefined()
+ }
+})
diff --git a/packages/util/plugin-detail/tests/files.spec.ts b/packages/util/plugin-detail/tests/files.spec.ts
new file mode 100644
index 0000000000..288b4057a7
--- /dev/null
+++ b/packages/util/plugin-detail/tests/files.spec.ts
@@ -0,0 +1,160 @@
+/** Detail file sources: local package directories, bounded HTTP reads, and npm file CDNs. */
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { DetailFailure, fetchBody, localFiles, npmFileSourceOrder, npmFiles, readmeByName, readmeName } from '../src/files.ts'
+
+/** Await a rejection that matches `fields` and whose message contains `message`. */
+async function expectRejection(promise: Promise, fields: object, message: string): Promise {
+ await expect(promise).rejects.toMatchObject(fields)
+ await expect(promise).rejects.toThrow(message)
+}
+
+
+let root: string
+const signal = new AbortController().signal
+const lifetime = new AbortController().signal
+const options = { timeoutMs: 5000, lifetime }
+
+beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-plugin-market-detail-files-')) })
+afterEach(() => {
+ rmSync(root, { recursive: true, force: true })
+ vi.unstubAllGlobals()
+})
+
+function file(path: string, contents: string): void {
+ mkdirSync(dirname(join(root, path)), { recursive: true })
+ writeFileSync(join(root, path), contents)
+}
+
+const text = (bytes: Uint8Array | undefined): string | undefined => bytes === undefined ? undefined : new TextDecoder().decode(bytes)
+
+/** Answer requests by URL with a fresh response each time; any other URL fails the test. */
+function serve(routes: Record Response)>) {
+ const fetch = vi.fn((url: string) => {
+ const route = routes[url]
+ if (route === undefined) throw new Error(`unexpected fetch ${url}`)
+ if (route instanceof Error) return Promise.reject(route)
+ return Promise.resolve(route())
+ })
+ vi.stubGlobal('fetch', fetch)
+ return fetch
+}
+
+it('reads package files and README names, and refuses paths whose real location leaves the package', async () => {
+ file('pkg/README.md', '# Pkg')
+ file('pkg/docs/readme', 'docs')
+ file('pkg/big.md', 'x'.repeat(20))
+ file('outside.md', 'secret')
+ symlinkSync(join(root, 'outside.md'), join(root, 'pkg', 'link.md'))
+ file('escaping/x', '')
+ symlinkSync(join(root, 'outside.md'), join(root, 'escaping', 'README.md'))
+ const files = localFiles(join(root, 'pkg'), '1.0.0')
+ expect(files.key).toBe(`local:${join(root, 'pkg')}@1.0.0`)
+ expect(text(await files.read('README.md', 100, signal))).toBe('# Pkg')
+ expect(await files.read('missing.md', 100, signal)).toBeUndefined()
+ expect(await files.read('docs', 100, signal)).toBeUndefined()
+ expect(await files.read('link.md', 100, signal)).toBeUndefined()
+ await expect(files.read('big.md', 10, signal)).rejects.toMatchObject({ reason: 'too-large' })
+ expect(await files.readme('', 100, signal)).toMatchObject({ path: 'README.md' })
+ expect(await files.readme('docs', 100, signal)).toMatchObject({ path: 'docs/readme' })
+ expect(await files.readme('missing', 100, signal)).toBeUndefined()
+ expect(await localFiles(join(root, 'pkg', 'docs'), '1.0.0').readme('', 100, signal)).toMatchObject({ path: 'readme' })
+ expect(await localFiles(join(root, 'empty'), '1.0.0').readme('', 100, signal)).toBeUndefined()
+ file('noreadme/index.js', '')
+ expect(await localFiles(join(root, 'noreadme'), '1.0.0').readme('', 100, signal)).toBeUndefined()
+ expect(await localFiles(join(root, 'escaping'), '1.0.0').readme('', 100, signal)).toBeUndefined()
+})
+
+it('orders README names', () => {
+ expect(readmeName(['readme.markdown', 'README', 'Readme.md', 'readme.txt'])).toBe('Readme.md')
+ expect(readmeName(['readme.MD', 'README.markdown'])).toBe('README.markdown')
+ expect(readmeName(['readme.MD', 'Readme.MD'])).toBe('Readme.MD')
+ expect(readmeName(['Readme.MD', 'readme.MD'])).toBe('Readme.MD')
+ expect(readmeName(['index.md'])).toBeUndefined()
+})
+
+it('reads bodies under a limit and reports failures', async () => {
+ serve({
+ 'https://a.example/ok': () => new Response('ok'),
+ 'https://a.example/empty': () => new Response(null),
+ 'https://a.example/missing': () => new Response('no', { status: 404 }),
+ 'https://a.example/declared': () => new Response('x', { headers: { 'content-length': '99' } }),
+ 'https://a.example/long': () => new Response('x'.repeat(20)),
+ 'https://a.example/down': new Error('refused'),
+ 'https://a.example/broken': () => new Response(new ReadableStream({ pull(controller) { controller.error(new Error('reset')) } })),
+ 'https://a.example/odd': () => new Response(new ReadableStream({ pull(controller) { controller.error('reset') } })),
+ })
+ expect(await fetchBody('https://a.example/ok', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.from('ok') })
+ expect(await fetchBody('https://a.example/empty', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.alloc(0) })
+ expect(await fetchBody('https://a.example/missing', 10, signal, options)).toEqual({ ok: false, status: 404 })
+ await expect(fetchBody('https://a.example/declared', 10, signal, options)).rejects.toMatchObject({ reason: 'too-large' })
+ await expect(fetchBody('https://a.example/long', 10, signal, options)).rejects.toMatchObject({ reason: 'too-large' })
+ await expectRejection(fetchBody('https://a.example/down', 10, signal, options), { reason: 'network' }, 'refused')
+ await expectRejection(fetchBody('https://a.example/broken', 10, signal, options), { reason: 'network' }, 'reset')
+ await expectRejection(fetchBody('https://a.example/odd', 10, signal, options), { reason: 'network' }, 'reset')
+ // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- a non-Error rejection keeps its text in the failure
+ vi.stubGlobal('fetch', vi.fn(() => Promise.reject('plain')))
+ await expectRejection(fetchBody('https://a.example/plain', 10, signal, options), { reason: 'network' }, 'plain')
+})
+
+it('rejects a cancelled read with the cancellation reason instead of a network failure', async () => {
+ vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('aborted'))))
+ const cancelled = new AbortController()
+ cancelled.abort(new Error('closed by caller'))
+ await expect(fetchBody('https://a.example/x', 10, cancelled.signal, options)).rejects.toThrow('closed by caller')
+ const disposed = new AbortController()
+ disposed.abort(new Error('service disposed'))
+ await expect(fetchBody('https://a.example/x', 10, signal, { ...options, lifetime: disposed.signal })).rejects.toThrow('service disposed')
+})
+
+it('follows redirects within the origin only', async () => {
+ serve({
+ 'https://a.example/old': () => new Response(null, { status: 301, headers: { location: '/new' } }),
+ 'https://a.example/new': () => new Response('moved'),
+ 'https://a.example/away': () => new Response(null, { status: 302, headers: { location: 'https://b.example/x' } }),
+ 'https://a.example/loop': () => new Response(null, { status: 302, headers: { location: '/loop' } }),
+ 'https://a.example/bare': () => new Response(null, { status: 302 }),
+ })
+ expect(await fetchBody('https://a.example/old', 10, signal, options)).toEqual({ ok: true, bytes: Buffer.from('moved') })
+ expect(await fetchBody('https://a.example/away', 10, signal, options)).toEqual({ ok: false, status: 302 })
+ expect(await fetchBody('https://a.example/loop', 10, signal, options)).toEqual({ ok: false, status: 302 })
+ expect(await fetchBody('https://a.example/bare', 10, signal, options)).toEqual({ ok: false, status: 302 })
+})
+
+it('reads npm files from the CDNs in order, trusting a 404 and asking the next CDN after a failure', async () => {
+ const fetch = serve({
+ 'https://cdn.jsdelivr.net/npm/@a/b@1.0.0/docs/A%20B.md': new Error('offline'),
+ 'https://unpkg.com/@a/b@1.0.0/docs/A%20B.md': () => new Response('fail', { status: 502 }),
+ 'https://registry.npmmirror.com/@a/b/1.0.0/files/docs/A%20B.md': () => new Response('# AB'),
+ 'https://registry.npmmirror.com/@a/b/1.0.0/files/README.md': () => new Response('no', { status: 404 }),
+ 'https://registry.npmmirror.com/@a/b/1.0.0/files/readme.md': () => new Response('# readme'),
+ 'https://registry.npmmirror.com/@a/b/1.0.0/files/big.md': () => new Response('x'.repeat(20)),
+ 'https://cdn.jsdelivr.net/npm/@a/b@1.0.0/down.md': new Error('offline'),
+ })
+ const files = npmFiles('@a/b', '1.0.0', ['jsdelivr', 'unpkg', 'npmmirror'], options)
+ expect(files.key).toBe('npm:@a/b@1.0.0')
+ expect(text(await files.read('docs/A B.md', 100, signal))).toBe('# AB')
+ const mirror = npmFiles('@a/b', '1.0.0', ['npmmirror'], options)
+ expect(await mirror.readme('', 100, signal)).toMatchObject({ path: 'readme.md' })
+ await expect(mirror.read('big.md', 10, signal)).rejects.toMatchObject({ reason: 'too-large' })
+ const failure = npmFiles('@a/b', '1.0.0', ['jsdelivr'], options).read('down.md', 10, signal)
+ await expect(failure).rejects.toBeInstanceOf(DetailFailure)
+ await expectRejection(failure, { reason: 'network' }, 'offline')
+ expect(fetch).toHaveBeenCalledTimes(7)
+})
+
+it('reads the first README name that exists', async () => {
+ const read = (path: string) => Promise.resolve(path === 'd/readme.md' ? Buffer.from('r') : undefined)
+ expect(await readmeByName(read, 'd', 10, signal)).toMatchObject({ path: 'd/readme.md' })
+ expect(await readmeByName(read, 'd', 10, signal, ['README.md'])).toBeUndefined()
+})
+
+it('orders the configured npm CDNs by a preference, ignoring CDNs that are not configured', () => {
+ const all = ['jsdelivr', 'unpkg', 'npmmirror'] as const
+ expect(npmFileSourceOrder(all, ['npmmirror'])).toEqual(['npmmirror', 'jsdelivr', 'unpkg'])
+ expect(npmFileSourceOrder(all, ['unpkg', 'npmmirror'])).toEqual(['unpkg', 'npmmirror', 'jsdelivr'])
+ expect(npmFileSourceOrder(all)).toEqual(['jsdelivr', 'unpkg', 'npmmirror'])
+ expect(npmFileSourceOrder(['unpkg'], ['npmmirror', 'other'])).toEqual(['unpkg'])
+})
diff --git a/packages/util/plugin-detail/tsconfig.json b/packages/util/plugin-detail/tsconfig.json
new file mode 100644
index 0000000000..0b174e83f6
--- /dev/null
+++ b/packages/util/plugin-detail/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0701c9d0a1..8ca82ea3af 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2052,6 +2052,12 @@ importers:
'@deepseek-ai/dsh-atomic-write':
specifier: workspace:*
version: link:../../util/atomic-write
+ '@deepseek-ai/dsh-plugin-detail':
+ specifier: workspace:*
+ version: link:../../util/plugin-detail
+ '@deepseek-ai/dsh-timeout':
+ specifier: workspace:*
+ version: link:../../util/timeout
'@deepseek-ai/dsh-util-values':
specifier: workspace:*
version: link:../../util/values
@@ -2086,6 +2092,9 @@ importers:
'@deepseek-ai/dsh-brand':
specifier: workspace:*
version: link:../../util/brand
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:*
+ version: link:../../client/connection
'@deepseek-ai/dsh-hmr':
specifier: workspace:*
version: link:../hmr
@@ -2134,9 +2143,18 @@ importers:
'@deepseek-ai/dsh-app-boot':
specifier: workspace:*
version: link:../app-boot
+ '@deepseek-ai/dsh-atomic-write':
+ specifier: workspace:*
+ version: link:../../util/atomic-write
+ '@deepseek-ai/dsh-brand':
+ specifier: workspace:*
+ version: link:../../util/brand
'@deepseek-ai/dsh-package-manifest':
specifier: workspace:*
version: link:../../util/package-manifest
+ '@deepseek-ai/dsh-plugin-detail':
+ specifier: workspace:*
+ version: link:../../util/plugin-detail
'@deepseek-ai/dsh-plugin-market-index':
specifier: workspace:*
version: link:../../util/plugin-market-index
@@ -2149,6 +2167,12 @@ importers:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
+ execa:
+ specifier: ^10.0.1
+ version: 10.0.1
+ hosted-git-info:
+ specifier: ^9.0.3
+ version: 9.0.3
minisearch:
specifier: ^7.2.0
version: 7.2.0
@@ -2168,6 +2192,9 @@ importers:
'@deepseek-ai/cosmokit':
specifier: link:../../../vendor/cosmokit
version: link:../../../vendor/cosmokit
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:*
+ version: link:../../client/connection
'@deepseek-ai/dsh-config-editor':
specifier: workspace:*
version: link:../config-editor
@@ -2180,6 +2207,9 @@ importers:
'@deepseek-ai/dsh-settings':
specifier: workspace:*
version: link:../../settings/settings
+ '@deepseek-ai/dsh-subprocess':
+ specifier: workspace:*
+ version: link:../../subprocess/subprocess
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:*
version: link:../../core/system-prompt
@@ -2189,6 +2219,9 @@ importers:
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
+ '@types/hosted-git-info':
+ specifier: ^3.0.5
+ version: 3.0.5
'@types/semver':
specifier: ^7.8.0
version: 7.8.0
@@ -15220,6 +15253,12 @@ importers:
specifier: workspace:~
version: link:../../../vendor/cordis
+ packages/util/plugin-detail:
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:~
+ version: link:../../../vendor/cordis
+
packages/util/plugin-market-index:
dependencies:
'@deepseek-ai/dsh-package-manifest':
diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts
index 1040b5f18b..e30512816e 100644
--- a/scripts/ci-workflow.spec.ts
+++ b/scripts/ci-workflow.spec.ts
@@ -671,15 +671,20 @@ describe('CI workflow', () => {
expect(uploadIndex).toBeGreaterThan(benchmarkIndex)
})
- it('generates metadata before the Wine Host TypeScript compile and retains its heap budget', () => {
+ it('splits the Wine Host TypeScript compile and retains its heap budget', () => {
const wineGates = readFileSync(resolve(root, 'scripts/wine-windows-gates.sh'), 'utf8')
- expect(wineGates).toContain(
- 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false',
+ const references = wineGates.indexOf(
+ 'wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host',
+ )
+ const aggregate = wineGates.indexOf(
+ 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false',
)
const metadata = wineGates.indexOf('wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts')
expect(metadata).toBeGreaterThan(-1)
- expect(metadata).toBeLessThan(wineGates.indexOf('wine_node "$scratch/logs/host-tsc.log"'))
+ expect(references).toBeGreaterThan(metadata)
+ expect(aggregate).toBeGreaterThan(references)
+ expect(wineGates).not.toContain('-b tsconfig.host.json')
})
it('cancels superseded master runs without changing the post-merge job inventory', () => {
diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts
index 215048297e..3ad1e0ac77 100644
--- a/scripts/doc-standard.spec.ts
+++ b/scripts/doc-standard.spec.ts
@@ -100,6 +100,7 @@ const PACKAGE_LIBRARIES: Readonly> = {
'packages/util/native-command': 'Host-side subprocess runner utility.',
'packages/util/output-retention': 'Zero-dependency retention utility.',
'packages/util/package-manifest': 'Shared package manifest declarations with type-only exports.',
+ 'packages/util/plugin-detail': 'Plugin detail document library.',
'packages/util/plugin-market-index': 'Plugin market index validation library.',
'packages/util/time': 'Zero-dependency time-zone canonicalization utility.',
'packages/util/timeout': 'Zero-dependency timeout utility.',
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 6bf4c51bbd..44c305f342 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -309,6 +309,9 @@ export const LINK_MAP: Readonly> = {
MarketQuery: 'boot.md',
MarketSearchResult: 'boot.md',
MarketSort: 'boot.md',
+ PluginDetail: 'boot.md',
+ BundleDetail: 'boot.md',
+ NpmFileSource: 'boot.md',
ChangeResult: 'boot.md',
InstallBundleOptions: 'boot.md',
PluginEntryId: 'boot.md',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 2c313a3dea..925717fb66 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -92,6 +92,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/util/chunked-list': { kind: 'none', reason: 'Immutable collection primitive; registers nothing model-facing.' },
'packages/util/package-manifest': { kind: 'none', reason: 'Type declarations only; registers nothing model-facing.' },
'packages/util/lazy-require': { kind: 'none', reason: 'Host module-loading primitive; registers nothing model-facing.' },
+ 'packages/util/plugin-detail': { kind: 'none', reason: 'Pure document selection for plugin detail pages; registers nothing model-facing.' },
'packages/util/plugin-market-index': { kind: 'indirect', reason: 'Pure index validation; the plugin_market tool owns the entries it returns to the model.' },
'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh
index 785d6b1d2d..3e2c0ef291 100755
--- a/scripts/wine-windows-gates.sh
+++ b/scripts/wine-windows-gates.sh
@@ -243,12 +243,17 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga
# ---- the two blocking surfaces, concurrently ------------------------------
# The build mirrors the root build:lib:host and build:lib:client scripts:
-# generate metadata, compile and bundle the Host face, bundle apps/desktop from
-# the Host lib/ outputs, then compile and bundle the Client face.
+# generate metadata, compile the Host package projects, type-check the Host
+# aggregate program, bundle the Host face, bundle apps/desktop from the Host
+# lib/ outputs, then compile and bundle the Client face. The Host compile is two
+# invocations because that is how the supported build splits it; a single
+# `tsc -b` holds the reference graph and the aggregate program in one process
+# and exceeds the heap the Host face is budgeted.
# Both statuses are captured so one failure cannot hide the other's result.
build_gate() {
wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts || return $?
- wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -b tsconfig.host.json --pretty false || return $?
+ wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host || return $?
+ wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false || return $?
wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $?
(cd apps/desktop && wine_node "$scratch/logs/desktop-tsdown.log" "../../$tsdown_js") || return $?
wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $?
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 2a92208c57..e39c17aa4b 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -231,6 +231,7 @@
"@deepseek-ai/dsh-plugin-manager/tools": ["./packages/boot/plugin-manager/src/tools.ts"],
"@deepseek-ai/dsh-plugin-market/types": ["./packages/boot/plugin-market/src/types.ts"],
"@deepseek-ai/dsh-plugin-market/tools": ["./packages/boot/plugin-market/src/tools.ts"],
+ "@deepseek-ai/dsh-plugin-detail/types": ["./packages/util/plugin-detail/src/types.ts"],
"@deepseek-ai/dsh-tool-cordis/host": ["./packages/extensions/tool-cordis/src/host.ts"],
"@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],
"@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"],
@@ -441,6 +442,7 @@
"@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"],
"@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"],
"@deepseek-ai/dsh-plan-mode": ["./packages/plan/plan-mode/src"],
+ "@deepseek-ai/dsh-plugin-detail": ["./packages/util/plugin-detail/src"],
"@deepseek-ai/dsh-plugin-market": ["./packages/boot/plugin-market/src"],
"@deepseek-ai/dsh-plugin-market-index": ["./packages/util/plugin-market-index/src"],
"@deepseek-ai/dsh-plugin-package-inventory-deepseek": ["./packages/llm/plugin-package-inventory-deepseek/src"],
diff --git a/tsconfig.host.json b/tsconfig.host.json
index deca327660..3d4f8fbaa0 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -297,6 +297,7 @@
{ "path": "./vendor/logger-console" },
{ "path": "./packages/util/brand" },
{ "path": "./packages/util/package-manifest" },
+ { "path": "./packages/util/plugin-detail" },
{ "path": "./packages/util/plugin-market-index" },
{ "path": "./packages/util/http-proxy" },
{ "path": "./packages/util/launch-environment" },