From bc0ecae21f0accaf3cddf58b861b094ce34f2e59 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 15 Sep 2026 20:51:12 +0800 Subject: [PATCH] feat(plugins): ship optional bundles switched off and guide the install dialog The dsh installation lists under `dsh.optionalBundles` the bundles it ships for a person to switch on. The plugin manager reports them as `optional`, and the Web plugin page shows them first, in a built-in group with an official tag, off until selected and never removable. Agent Teams ships this way, so a blank profile offers both of its layers. Default-product isolation, the workspace constraints, and the packed-install check accept that one dependency edge and nothing else experimental. A bundle's title and one-liner come from `dsh.title` and the new `dsh.description`, else from the package name and `description`. A dependency without a bundle patch is no longer listed unless the profile selects it. The install dialog asks for a package name or address; a guide under the field shows the three accepted forms with an example each and ends in a security warning, and the copy no longer speaks of a plugin ID. --- ...t-product-experimental-isolation.i18n.yaml | 4 +- ...-default-product-experimental-isolation.md | 2 +- ...fault-product-experimental-isolation.zh.md | 2 +- ...6-09-15-shipped-optional-bundles.i18n.yaml | 6 + .../2026-09-15-shipped-optional-bundles.md | 27 +++ .../2026-09-15-shipped-optional-bundles.zh.md | 27 +++ apps/cli/package.json | 7 +- .../cancelled.expected.md | 12 +- .../plugin-manager/live-enabled.expected.md | 11 ++ .../plugin-manager/manager.expected.md | 11 ++ apps/web/tests/plugin-manager.e2e.ts | 10 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 6 +- docs/subsystems/boot.zh.md | 6 +- docs/user/develop/basic/publish.i18n.yaml | 4 +- docs/user/develop/basic/publish.md | 8 +- docs/user/develop/basic/publish.zh.md | 8 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 2 +- packages/boot/plugin-manager/README.zh.md | 2 +- packages/boot/plugin-manager/src/index.ts | 31 ++-- packages/boot/plugin-manager/src/types.ts | 9 +- .../boot/plugin-manager/tests/manager.spec.ts | 35 +++- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 8 +- .../client/ui-plugin-manager/README.zh.md | 8 +- .../src/client/PluginManagerPage.module.css | 122 ++++++++++++- .../src/client/PluginManagerPage.tsx | 164 +++++++++++++----- .../ui-plugin-manager/src/client/locales.ts | 58 +++++-- .../src/client/manager-store.ts | 3 + .../tests/components.client.spec.tsx | 48 ++++- .../tests/manager-store.client.spec.ts | 9 +- .../agent-team-profile/README.i18n.yaml | 4 +- .../experimental/agent-team-profile/README.md | 4 +- .../agent-team-profile/README.zh.md | 4 +- .../agent-team-profile/package.json | 2 + .../agent-team-web-profile/README.i18n.yaml | 4 +- .../agent-team-web-profile/README.md | 4 +- .../agent-team-web-profile/README.zh.md | 4 +- .../agent-team-web-profile/package.json | 2 + .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/util/package-manifest/src/types.ts | 2 + pnpm-lock.yaml | 9 +- scripts/check-workspace-constraints.spec.ts | 19 ++ scripts/check-workspace-constraints.ts | 10 +- .../installed-product-isolation.spec.ts | 21 +++ .../release/installed-product-isolation.ts | 17 ++ .../verify-default-product-isolation.spec.ts | 36 ++++ scripts/verify-default-product-isolation.ts | 31 +++- 52 files changed, 703 insertions(+), 154 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md create mode 100644 .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md diff --git a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml index fe1e1ecf51..107142ce17 100644 --- a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md -2026-09-12-default-product-experimental-isolation.md: d2312d6061f372e910bfd625512230ccd48a586a -2026-09-12-default-product-experimental-isolation.zh.md: 6ea86f1f3e859719ffd8287e66ebe63df77d64c2 +2026-09-12-default-product-experimental-isolation.md: 5c0c0c1c3d194f792591c6eb54a56cea65519bbe +2026-09-12-default-product-experimental-isolation.zh.md: b8f3d64cb0db41f645ea8ca70bfc4c2a098c904f diff --git a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md index d2312d6061..5c0c0c1c3d 100644 --- a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md +++ b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md @@ -10,7 +10,7 @@ Public npm availability does not make an experimental package part of the defaul ## Decision -[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification. +[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification. The bundles the installation lists under `dsh.optionalBundles` are the one declared exception ([shipped optional bundles](2026-09-15-shipped-optional-bundles.md)). The source check also reads runtime imports in the selected packages, installation-owned profile bundle lists, bundle patches, shipped agent presets, and declared configuration trees. It loads the default Web layers with the production patch parser and composes them with the same patch engine used at boot. The effective rows and patched Include trees are checked, so an id-only patch cannot hide a replacement group's plugins. Disabled plugin rows remain checked; ordinary plugin configuration data is not interpreted as another Loader entry list. Missing default roots fail the check. diff --git a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md index 6ea86f1f3e..b8f3d64cb0 100644 --- a/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md +++ b/.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。 +[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。安装在 `dsh.optionalBundles` 下列出的组合包是唯一声明的例外([随安装提供的可选组合包](2026-09-15-shipped-optional-bundles.zh.md))。 源码检查还读取所选包的运行时导入、安装自带的 profile bundle 列表、bundle patch、随产品提供的 Agent preset,以及声明的配置树。它使用生产 patch 解析器加载默认 Web 各层,并使用启动时的同一个 patch 引擎完成组合。检查对象包括最终 entry 和应用 patch 后的 Include 树,因此仅按 id 覆盖 group 的 patch 也无法隐藏替换后的插件。禁用的插件行仍纳入检查;普通插件配置数据不会被解释为另一个 Loader entry 列表。默认入口缺失会使检查失败。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml new file mode 100644 index 0000000000..27da9d0374 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +2026-09-15-shipped-optional-bundles.md: cbacff975118f2b8e6187cb5c80d1ec702d64fde +2026-09-15-shipped-optional-bundles.zh.md: d0d2dc24f27697883d96934f473ff35db2e2ace7 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md new file mode 100644 index 0000000000..cbacff9751 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -0,0 +1,27 @@ +# Agent Note: Ship optional bundles with the installation + +Status: implemented + +English | [中文](2026-09-15-shipped-optional-bundles.zh.md) + +## Problem + +The Web plugin page manages only the bundles a person installed into the profile. An official experimental layer such as Agent Teams had to be found on npm and installed by name before it could be switched on, and [default-product isolation](2026-09-12-default-product-experimental-isolation.md) kept every experimental package out of the installation's runtime dependencies, so nothing shipped with dsh could offer it. + +## Decision + +The installation's manifest, `apps/cli/package.json`, lists under `dsh.optionalBundles` the bundles it ships for a person to switch on. Each must be a runtime dependency that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles. + +Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. + +Agent Teams ships this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. + +## Alternatives considered + +**A catalog of installable official bundles.** The page would offer names to install from the registry on demand. That keeps the installation unchanged but needs network access at the moment of switching on and a version pin per release. + +**A flag on the bundle package.** A `dsh.bundle.optional` declaration would let any published bundle claim a place in the installation; the installation's own list keeps the choice with the product. + +## Consequences + +Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md new file mode 100644 index 0000000000..d0d2dc24f2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Ship optional bundles with the installation + +Status: implemented + +[English](2026-09-15-shipped-optional-bundles.md) | 中文 + +## 问题 + +Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样的官方实验层,用户得先去 npm 找到包名并按名安装才能开启;而[默认产品隔离](2026-09-12-default-product-experimental-isolation.zh.md)又把所有实验包挡在安装的运行时依赖之外,所以随 dsh 一起交付的东西没有办法把它提供出来。 + +## 决策 + +安装的 manifest,即 `apps/cli/package.json`,在 `dsh.optionalBundles` 下列出它随附、供用户开启的组合包。每一个都必须是声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。 + +默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 + +Agent Teams 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。 + +## 考虑过的替代方案 + +**可安装官方组合包目录。** 页面按需提供从注册表安装的包名。安装本身不变,但开启那一刻需要网络,并且每次发布都要钉一个版本。 + +**组合包自身的标记。** `dsh.bundle.optional` 声明会让任何已发布的组合包都能自称随安装提供;由安装自己的列表来决定,选择权留在产品手里。 + +## 影响 + +可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。 diff --git a/apps/cli/package.json b/apps/cli/package.json index e5c13a5702..64526bead1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -25,6 +25,10 @@ "path": "../../packages/preset/agent-presets/presets", "scanRoster": true } + ], + "optionalBundles": [ + "@deepseek-ai/dsh-experimental-agent-team-profile", + "@deepseek-ai/dsh-experimental-agent-team-web-profile" ] }, "license": "MIT", @@ -47,6 +51,8 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", + "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", + "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", @@ -116,7 +122,6 @@ "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^", "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index 05f2524f6a..be08c280a3 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -2,10 +2,12 @@ - heading "添加插件" [level=2] - button "关闭": - img - - paragraph: 粘贴插件作者提供的插件 ID、Git 仓库地址(如 GitHub 链接)或本地绝对路径。 - - text: 插件 ID 或地址 - - textbox "插件 ID 或地址": - - /placeholder: dsh-xxx、github:作者/仓库 或 /绝对路径 + - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 + - text: 包名或地址 + - textbox "包名或地址": + - /placeholder: 例如 dsh-better-sidebar - text: slow-package - - paragraph: 插件 ID 是安装时使用的包名,不是插件显示的名称,常见形式是 dsh-xxx 或 @作者/插件名;加 @版本 可指定版本。 + - button "不知道该填什么?": + - img + - text: 不知道该填什么? - button "安装" diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 4062394f0e..299dad3620 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -4,6 +4,17 @@ - button "添加插件": - img - text: 添加插件 +- heading "内置" [level=3] +- text: "2" +- list: + - listitem: + - button "查看 Agent Teams": Agent Teams + - text: 官方 实验功能:把子代理委派换成 Agent Teams 的团队协作工具,这是宿主层,需与 Agent Teams Web 面板一起开启。 + - switch "启用 Agent Teams" + - listitem: + - button "查看 Agent Teams Web 面板": Agent Teams Web 面板 + - text: 官方 实验功能:在浏览器里显示 Agent Teams 的团队名单、任务板和成员导航,需先开启 Agent Teams。 + - switch "启用 Agent Teams Web 面板" - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index bbf5c30d75..0812fb83ed 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -4,6 +4,17 @@ - button "添加插件": - img - text: 添加插件 +- heading "内置" [level=3] +- text: "2" +- list: + - listitem: + - button "查看 Agent Teams": Agent Teams + - text: 官方 实验功能:把子代理委派换成 Agent Teams 的团队协作工具,这是宿主层,需与 Agent Teams Web 面板一起开启。 + - switch "启用 Agent Teams" + - listitem: + - button "查看 Agent Teams Web 面板": Agent Teams Web 面板 + - text: 官方 实验功能:在浏览器里显示 Agent Teams 的团队名单、任务板和成员导航,需先开启 Agent Teams。 + - switch "启用 Agent Teams Web 面板" - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index e4527d8760..3292ae31e7 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -71,8 +71,10 @@ describe('web e2e: plugin manager', () => { await panel.getByText('示例组合包', { exact: true }).waitFor({ timeout: 20_000 }) const toggle = panel.getByRole('switch', { name: '启用 示例组合包' }) expect(await toggle.getAttribute('aria-checked')).toBe('false') - // The shipped bundles are not the person's to manage here. - expect(await panel.locator('[data-plugin-package]').count()).toBe(1) + // The profile's own group holds its one bundle; the installation's optional bundles form the built-in + // group, and its other bundles stay off the page. + expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(1) + expect(await panel.locator('[data-plugin-group="builtin"] [data-plugin-package]').count()).toBe(2) // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 示例组合包' }).click() await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 }) @@ -92,7 +94,7 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('button', { name: '添加插件', exact: true }).click() const dialog = page.getByRole('dialog', { name: '添加插件' }) await dialog.waitFor({ timeout: 10_000 }) - const field = dialog.getByRole('textbox', { name: '插件 ID 或地址' }) + const field = dialog.getByRole('textbox', { name: '包名或地址' }) const install = dialog.getByRole('button', { name: '安装', exact: true }) expect(await install.isDisabled()).toBe(true) // A name the list already shows is refused without asking the Host. @@ -108,7 +110,7 @@ describe('web e2e: plugin manager', () => { // A name the registry would refuse never reaches it. await field.fill('Not A Package') await install.click() - await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toContain('无法识别这个插件 ID 或地址') + await expect.poll(() => dialog.getByRole('alert').textContent(), { timeout: 10_000 }).toContain('无法识别这个包名或地址') await dialog.getByRole('button', { name: '关闭' }).click() await expect.poll(() => page.getByRole('dialog', { name: '添加插件' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a5dc8e8748..fdd17fbf2d 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 96f739d0a5c5c2c948e05f49bc5998514f7fa0c9 -event-producer-consumer.zh.md: 1d1022b7ba4ef26e7e67d78071562dc8f348be32 +event-producer-consumer.md: 40ba4a648b58358829e049b4cbd28e79359229a4 +event-producer-consumer.zh.md: aa12b07e575b68b338ca08a811c6a6565935d31f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 96f739d0a5..40ba4a648b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -50,9 +50,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:178`](../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:184`](../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:190`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:185`](../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:191`](../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:197`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `profile/reconciled` | `emit` | [`packages/boot/app-boot/src/index.ts:255`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`plugin-manager`](../packages/boot/plugin-manager) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 1d1022b7ba..aa12b07e57 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -52,9 +52,9 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` | -| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:178`](../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:184`](../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:190`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:185`](../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:191`](../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:197`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `profile/reconciled` | `emit` | [`packages/boot/app-boot/src/index.ts:255`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`plugin-manager`](../packages/boot/plugin-manager) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 3a511eccb9..3d23c1ce5c 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/boot.md -boot.md: 054e2f0586d5c4627bcef17639e02b48b797100d -boot.zh.md: 4d5dda814da4086e862d98efcad2ab5f99479b81 +boot.md: 079fd9d705d43a85fb84cb3bd0983cd49169a4d6 +boot.zh.md: bc2f847be044670c726989c107664cf88206e9fa diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 054e2f0586..079fd9d705 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -69,8 +69,10 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote async listPlugins(): Promise -/** Read installed bundles and bundles supplied by this dsh installation. - * @returns Package versions, titles, rows, activation selections and removal availability. +/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, titles, one-liners, rows, activation selections, whether the installation offers the + * bundle, and removal availability. */ @Remote listBundles(): Promise diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 4d5dda814d..bc2f847be0 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -69,8 +69,10 @@ Manage profile files and apply their declared reload lifecycle. */ @Remote async listPlugins(): Promise -/** Read installed bundles and bundles supplied by this dsh installation. - * @returns Package versions, titles, rows, activation selections and removal availability. +/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, titles, one-liners, rows, activation selections, whether the installation offers the + * bundle, and removal availability. */ @Remote listBundles(): Promise diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index b3f4cd607a..741ee90b17 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 17b0cb80f9cf385773ae9e1f4d7061ec1df9c1a2 -publish.zh.md: 3cc65af58d34028a6bff03455451153e2c7f439b +publish.md: da3a80a809a2e01b5f5914178a8d34e8c4453fc1 +publish.zh.md: 3baa952d42c3a223b8ce1aeea326799e10cbe1f5 diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 17b0cb80f9..da3a80a809 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -39,10 +39,16 @@ Create `hello-plugin/package.json`: "type": "module", "main": "index.js", "files": ["index.js", "cordis.patch.yml"], - "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } + "dsh": { + "title": "Hello plugin", + "description": "Logs a greeting when it loads.", + "bundle": { "patch": "./cordis.patch.yml" } + } } ``` +`dsh.title` and `dsh.description` are what users see where bundles are listed, such as the Web sidebar's Plugins page; without them the page shows the package name stripped of its scope and `dsh-` prefix, and the package `description`. + Create `hello-plugin/index.js` with the plugin entry point: ```js diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 3cc65af58d..3baa952d42 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -39,10 +39,16 @@ hello-plugin/ "type": "module", "main": "index.js", "files": ["index.js", "cordis.patch.yml"], - "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } + "dsh": { + "title": "Hello plugin", + "description": "Logs a greeting when it loads.", + "bundle": { "patch": "./cordis.patch.yml" } + } } ``` +`dsh.title` 与 `dsh.description` 是用户在组合包列表(例如 Web 侧栏的插件页)里看到的名字和简介;没有它们时,页面显示去掉 scope 和 `dsh-` 前缀的包名,以及包的 `description`。 + 创建 `hello-plugin/index.js`,写入插件入口: ```js diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index c74e9b3aba..3e9029fdce 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md -README.md: 38d0737f1e3629ff1f33c90845ad1993eaabbeb0 -README.zh.md: e533542586aba80c80bbfa526fd2a3abdf9811bf +README.md: b9c9942c947557ccc4142504affb8a8f68508013 +README.zh.md: 7d8f78af5c23a6dd2b2e0e3316296dc1913d29c4 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 38d0737f1e..b9c9942c94 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -39,7 +39,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro `inspect(spec)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same registry and proxy settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form. The answer carries the name, version, description, `dsh.title`, and whether the package declares a bundle, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`. A caller's `signal` or `inspectTimeoutMs` ends the lookup. -`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's title, one-liner, the rows its patch declares with their live entries, and the built-in rows it overrides. Every completed operation, and every patch generation applied outside the manager, emits `plugin-manager/changed`. +`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies a failed run from its exit and output, and `bundle` names the package a finished run added. `listBundles` carries each bundle's title and one-liner (`dsh.title` and `dsh.description`, else the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the installation's manifest names under `dsh.optionalBundles` is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation, and every patch generation applied outside the manager, emits `plugin-manager/changed`. ### Configuration diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index e533542586..7d8f78af5c 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" `inspect(spec)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的注册表与代理设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式。答复携带名称、版本、描述、`dsh.title` 以及该包是否声明组合包,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 -`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的标题、一句话简介、其 patch 声明的行及其存活条目,以及它覆盖的内置行。每个完成的操作,以及在管理器之外应用的每一代 patch,都会发出 `plugin-manager/changed`。 +`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对失败运行分类,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的标题与一句话简介(`dsh.title` 与 `dsh.description`,没有则用包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。安装的 manifest 在 `dsh.optionalBundles` 下点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作,以及在管理器之外应用的每一代 patch,都会发出 `plugin-manager/changed`。 ### 配置 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index a9d2ff8af5..2a6e89e021 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -101,15 +101,21 @@ function stringField(manifest: object, field: string): string | undefined { return typeof value === 'string' ? value : undefined } +/** The fields of the dsh installation's own manifest the manager reads. */ +interface InstallationManifest { + dependencies?: Record + dsh?: { optionalBundles?: string[] } +} + /** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */ function inspectionOf(kind: 'registry' | 'path', manifest: object): Extract { const dsh = (manifest as { dsh?: unknown }).dsh - const declared = typeof dsh === 'object' && dsh !== null ? dsh as { title?: unknown; bundle?: unknown } : undefined + const declared = typeof dsh === 'object' && dsh !== null ? dsh as { title?: unknown; description?: unknown; bundle?: unknown } : undefined const title = declared === undefined ? undefined : stringField(declared, 'title') const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null const name = stringField(manifest, 'name') const version = stringField(manifest, 'version') - const description = stringField(manifest, 'description') + const description = (declared === undefined ? undefined : stringField(declared, 'description')) ?? stringField(manifest, 'description') return { status: 'accepted', kind, bundle, ...name === undefined ? {} : { name }, @@ -205,38 +211,43 @@ export class PluginManager extends TypertRemoteService { }) } - /** Read installed bundles and bundles supplied by this dsh installation. - * @returns Package versions, titles, rows, activation selections and removal availability. + /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. + * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected. + * @returns Package versions, titles, one-liners, rows, activation selections, whether the installation offers the + * bundle, and removal availability. */ @Remote listBundles(): Promise { const manifest = readProfileManifest('dsh', this.profile.dir) const selected = manifest.dsh?.profile?.bundles ?? [] const dependencies = Object.keys(manifest.dependencies ?? {}) - const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record } + const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest + const offered = installation.dsh?.optionalBundles ?? [] const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])] const bundles: BundleInfo[] = [] for (const name of names) { const installed = dependencies.includes(name) + const optional = offered.includes(name) const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name) const enabled = selected.includes(name) try { const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info === undefined) { - if (enabled || installed) bundles.push({ name, enabled, installed, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] }) + if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined const title = info.dsh?.title + const description = info.dsh?.description ?? info.description bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), ...(title === undefined ? {} : { title }), - ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), - enabled, installed, removable: removable && readOnlyReason === undefined, + ...(description === undefined || description === '' ? {} : { description }), + enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info) }) } catch (error) { if (enabled || installed) { - bundles.push({ name, enabled, installed, removable, error: managementError(error), rows: [], overrides: [] }) + bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] }) } } } @@ -259,7 +270,7 @@ export class PluginManager extends TypertRemoteService { return refused('invalid-spec', error.reason) } const manifest = readProfileManifest('dsh', this.profile.dir) - const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as { dependencies?: Record } + const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest const known = new Set([ ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}), ]) diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index d915bf31bc..56b5518aea 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -35,11 +35,16 @@ export interface BundleInfo { version?: string /** `dsh.title` of the package manifest. */ title?: string - /** `description` of the package manifest. */ + /** `dsh.description` of the package manifest, else its `description`. */ description?: string enabled: boolean /** Whether the profile's own dependencies hold the package; false for a bundle the dsh installation supplies. */ installed: boolean + /** + * Whether the installation ships the bundle for the person to switch on: named under `dsh.optionalBundles` in + * the installation's manifest, selected by no shipped template, and never removable. + */ + optional: boolean removable: boolean readOnlyReason?: ReadOnlyReason error?: ManagementError @@ -122,7 +127,9 @@ export type PluginSpecInspection = readonly kind: InstallSpecKind readonly name?: string readonly version?: string + /** `dsh.description` of the package manifest, else its `description`. */ readonly description?: string + /** `dsh.title` of the package manifest. */ readonly title?: string /** Whether the package declares a bundle patch; null when the spec's form does not say. */ readonly bundle: boolean | null diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 65162ac3a9..b9c90ec13c 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -69,11 +69,11 @@ it('lists bundle versions and current-profile plugin targets', async () => { expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required') expect(await manager.listBundles()).toEqual([ { - name: 'core', version: '1.0.0', enabled: true, installed: false, removable: false, readOnlyReason: 'management-required', + name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required', rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [], }, { - name: 'extra', version: '1.0.0', enabled: true, installed: true, removable: true, + name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true, rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [], }, ]) @@ -94,7 +94,7 @@ it('describes a bundle by its manifest and patch: title, one-liner, rows without writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({ - name: 'described', version: '2.0.0', title: 'Described', description: 'Describes itself.', enabled: false, installed: true, removable: true, + name: 'described', version: '2.0.0', title: 'Described', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true, rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'], }) await manager.setBundleEnabled('described', true) @@ -261,13 +261,14 @@ it('combines concurrent changes into durable notices without waking Agents', asy }) -it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => { +it('reports a selected plain dependency as a problem, omits an unselected one, and reports missing versions', async () => { const { manager, dir, profile } = await fixture() writeFileSync(profile.installAnchor, '{}') writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}') expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: { code: 'not-bundle' } }) expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' }) - expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } }) + // Switched off, a dependency without a bundle patch is a library the page has no business with. + expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false) expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' }) writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}') expect((await manager.listBundles())[0]?.version).toBeUndefined() @@ -512,9 +513,11 @@ it('reads what a spec names before installing it', async () => { const view = vi.spyOn(operations, 'viewProfilePackage') onTestFinished(() => { view.mockRestore() }) const answers = (stdout: string) => view.mockResolvedValueOnce({ exitCode: 0, stdout, stderr: '', timedOut: false }) - answers(JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { title: 'Sidebar', bundle: { patch: './cordis.patch.yml' } } })) + answers(JSON.stringify({ + name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { title: 'Sidebar', description: 'A better sidebar.', bundle: { patch: './cordis.patch.yml' } }, + })) expect(await manager.inspect('dsh-x')).toEqual({ - status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', title: 'Sidebar', bundle: true, + status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A better sidebar.', title: 'Sidebar', bundle: true, }) expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000 }) const signal = AbortSignal.abort() @@ -631,6 +634,24 @@ it('refuses removal of a hot-installed bundle after HMR is disabled', async () = expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' }) }) +it('offers the installation\'s optional bundles switched off and never removable, preferring dsh.description', async () => { + const { manager, dir, profile, bundle } = await fixture() + bundle('offered', [{ id: 'offered-row', name: './plugin.mjs', config: { service: 'offeredProbe' } }]) + writeFileSync(join(dir, 'node_modules', 'offered', 'package.json'), JSON.stringify({ + name: 'offered', version: '3.0.0', description: 'Package one-liner.', + dsh: { title: 'Offered', description: 'Display one-liner.', bundle: { patch: './cordis.patch.yml' } }, + })) + writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { offered: '3.0.0' }, dsh: { optionalBundles: ['offered'] } })) + expect((await manager.listBundles()).find(row => row.name === 'offered')).toEqual({ + name: 'offered', version: '3.0.0', title: 'Offered', description: 'Display one-liner.', + enabled: false, installed: false, optional: true, removable: false, + rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(dir, 'node_modules', 'offered', 'plugin.mjs')).href }], overrides: [], + }) + expect(await manager.setBundleEnabled('offered', true)).toMatchObject({ application: 'applied' }) + expect((await manager.listBundles()).find(row => row.name === 'offered')).toMatchObject({ enabled: true, optional: true, removable: false }) + expect(await manager.removeBundle('offered')).toMatchObject({ changed: false, application: 'failed' }) +}) + it('omits installation-owned plain packages from the bundle inventory', async () => { const { manager, dir, profile, bundle } = await fixture() bundle('installation-plain', []) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 88dc79c37b..23ae2d8a4f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 070ad1cf31ca649317c84b15f742b4d45768e3f6 -README.zh.md: a317be188efa6c8cc294a5c687740ef5de047623 +README.md: 10d67d405d7731112f3671dbce1a1a37e6476178 +README.zh.md: a26165d23f8b4567aa1289770d91006ed7565e2f diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 070ad1cf31..10d67d405d 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. Global configuration remains in Settings. +Use the **Plugins** entry in the Web sidebar to manage the profile's installed bundles and the official bundles the installation ships switched off. Switch bundles and their rows on and off, install a bundle after the Host has read what the spec names, watch pnpm's output, stop a run, and enable what it added. Uninstalling asks for confirmation. Global configuration remains in Settings. ## Table of Contents @@ -25,11 +25,11 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b ## Use this package -Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. Cards are listed by title, so switching a bundle on or off does not move its card. Global configuration remains in the Settings **Plugins** section. +Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built in** comes first and lists the bundles the installation ships for switching on, each tagged official, off until switched on and without an uninstall; **Installed** lists the bundles the profile holds. Cards are listed by title, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. ### Installing a bundle -**Add plugin** takes a plugin ID, which is the registry package name with an optional version, a Git address, a tarball, or an absolute local path; a hint under the field says what an ID looks like. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's title, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's title, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. A successful installation does not certify that a module can activate. During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again. @@ -90,7 +90,7 @@ None; this package neither assembles nor sends a provider request. These limits define the reach of the management view; they are current package constraints. -- **Only bundles are managed** — a dependency without a bundle patch is refused before it installs; one the profile already holds is listed as a problem that can only be uninstalled, and loading plain plugin modules stays a file operation. +- **Only bundles are managed** — a dependency without a bundle patch is refused before it installs; one the profile already holds is left off the page unless the profile selects it, and loading plain plugin modules stays a file operation. - **Rows show a phase, not a reason** — a failed row reads as failed without the Host's error text; the Host log has it. - **One install at a time** — the dialog runs one pnpm command; a second spec waits for the first to finish. - **No version picker** — the spec is typed as pnpm accepts it; the page neither lists registry versions nor offers upgrades. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index a317be188e..a26165d23f 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。全局配置仍在设置中编辑。 +使用 Web 侧栏的**插件**入口管理 profile 已安装的组合包,以及安装随附、默认关闭的官方组合包。可以启停组合包及其行、在 Host 读出 spec 指向什么之后安装组合包、查看 pnpm 输出、停止一次运行,并启用它新增的包。卸载会要求确认。全局配置仍在设置中编辑。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。卡片按标题排序,启停组合包不会挪动它的卡片。全局配置仍在设置的**插件**分区中编辑。 +在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置**排在前面,列出安装随附、供开启的组合包,每个带官方标签,开启前保持关闭,且没有卸载;**已安装**列出 profile 持有的组合包。卡片按标题排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 ### 安装一个组合包 -**添加插件**接受插件 ID(即注册表包名,可带版本)、Git 地址、压缩包或本地绝对路径,输入框下方提示插件 ID 的常见形式。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包标题、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包标题、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。安装成功不代表模块一定能够激活。 安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。 @@ -90,7 +90,7 @@ kind: "package-reference" 这些限制界定了管理视图的范围;它们是当前包的约束。 -- **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝;profile 里已有的这类依赖作为异常列出,只能卸载;加载普通插件模块仍是文件操作。 +- **只管理组合包**——没有组合包 patch 的依赖在安装前就被拒绝;profile 里已有的这类依赖不上页面,除非 profile 选中了它;加载普通插件模块仍是文件操作。 - **行只显示阶段,不显示原因**——失败的行只显示为失败,没有 Host 的错误文本;Host 日志里有。 - **一次只能安装一个**——对话框一次运行一个 pnpm 命令;第二个 spec 要等前一个完成。 - **没有版本选择器**——spec 按 pnpm 接受的写法输入;页面不列出注册表版本,也不提供升级。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 2172278d56..433691ad51 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -418,14 +418,130 @@ border-color: var(--dsw-alias-state-error-primary); } -/* What an ID is, under the field, for someone who only has a plugin's display name. */ -.installHint { - margin: -4px 0 0; +/* The guide under the field: opened by a text button, it says what an ID is and shows the three forms with examples. */ +.guideToggle { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 4px; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 12.5px; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.guideToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.guideChevron { + transition: transform 160ms ease; +} + +.guideToggle[aria-expanded='true'] .guideChevron { + transform: rotate(180deg); +} + +.guide { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-1); +} + +.guideIntro, +.guideHint { + margin: 0; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } +/* The security warning that closes the guide: plugins run with the person's own permissions. */ +.guideSafety { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 0; + padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent); + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); +} + +.guideSafety > svg { + flex: none; + margin-top: 2px; +} + +.guideNote { + margin: 0; + padding: 8px 10px; + border-radius: 8px; + background: var(--dsw-alias-bg-layer-3); + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.guideList { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.guideItem { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 0; + border-top: 0.5px solid var(--dsw-alias-border-l4); +} + +.guideIndex { + flex: none; + width: 20px; + height: 20px; + border-radius: 999px; + corner-shape: round; + background: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 20px; + text-align: center; + color: var(--dsw-alias-label-tertiary); +} + +.guideMain { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.guideTitle { + font-size: 12.5px; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.guideExample { + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12px; + color: var(--dsw-alias-label-secondary); + overflow-wrap: anywhere; +} + /* What the check refused, under the field. */ .inputError { margin: -4px 0 0; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index c9b37c92d6..835df51f09 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -1,8 +1,9 @@ /** - * Global plugin management: installed bundle cards, their row switches, the - * guided install dialog with its folded pnpm output, the uninstall - * confirmation, and the toasts an action's outcome becomes. A bundle's page - * lists the rows it contributes as the Host runs them. + * Global plugin management: cards for the profile's installed bundles and for + * the official bundles the installation ships switched off, their row + * switches, the install dialog with its guide and folded pnpm output, the + * uninstall confirmation, and the toasts an action's outcome becomes. A + * bundle's page lists the rows it contributes as the Host runs them. */ import { useEffect, useId, useState, type ReactNode } from 'react' @@ -203,7 +204,7 @@ function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' { return pkg.enabled ? 'running' : 'disabled' } -/** One installed package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ +/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { readonly pkg: PackageView readonly t: Translate @@ -226,6 +227,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
+ {pkg.optional ? {t('statusOfficial')} : null} {status === 'problem' ? {t('statusProblem')} : null}
{pkg.description === undefined ? null : {pkg.description}} @@ -239,8 +241,9 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { } /** - * One package's page: the crumb back to the list; its icon with uninstall - * and its switch; its title beside its version tag and problem tag; the + * One package's page: the crumb back to the list; its icon with its switch + * and, for a package the profile installed, uninstall; its title beside its + * version tag, its official tag, and its problem tag; the * package name the title stands for, which is what installs it elsewhere; * its one-liner; the Host's problem when it reports one; and its rows with * their switches. @@ -270,17 +273,21 @@ function PackageDetail({
- + {pkg.installed + ? ( + + ) + : null}
@@ -288,6 +295,7 @@ function PackageDetail({

{title}

{pkg.version === undefined ? null : {t('versionTag', { version: pkg.version })}} + {pkg.optional ? {t('statusOfficial')} : null} {status === 'problem' ? {t('statusProblem')} : null}

{pkg.name}

@@ -340,6 +348,21 @@ const INPUT_PROBLEM_KEYS = { 'unknown': 'installProblemUnknown', } satisfies Record +/** One row of the install guide: a spec form's title, its example, and where the person finds it. */ +interface GuideExample { + readonly key: string + readonly titleKey: PluginManagerLocaleKey + readonly exampleKey: PluginManagerLocaleKey + readonly hintKey: PluginManagerLocaleKey +} + +/** The spec forms the install guide shows, each with an example the person can drop into the field. */ +const GUIDE_EXAMPLES = [ + { key: 'id', titleKey: 'installGuideIdTitle', exampleKey: 'installGuideIdExample', hintKey: 'installGuideIdHint' }, + { key: 'git', titleKey: 'installGuideGitTitle', exampleKey: 'installGuideGitExample', hintKey: 'installGuideGitHint' }, + { key: 'path', titleKey: 'installGuidePathTitle', exampleKey: 'installGuidePathExample', hintKey: 'installGuidePathHint' }, +] as const satisfies readonly GuideExample[] + /** The one-line reading of a classified pnpm failure. */ const FAILURE_KIND_KEYS = { 'pnpm-missing': 'installFailurePnpmMissing', @@ -409,7 +432,8 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog readonly onEnableNow: () => void }): ReactNode { const errorId = useId() - const hintId = useId() + const guideId = useId() + const [guideOpen, setGuideOpen] = useState(false) const { phase } = install if (phase === 'idle' || phase === 'checking') { const checking = phase === 'checking' @@ -438,7 +462,7 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog placeholder={t('installSpecPlaceholder')} disabled={checking} aria-invalid={install.inputError !== null} - aria-describedby={install.inputError === null ? hintId : `${errorId} ${hintId}`} + aria-describedby={install.inputError === null ? undefined : errorId} onChange={(event) => { onEditSpec(event.currentTarget.value) }} onKeyDown={(event) => { if (event.key === 'Enter' && !empty && !checking) onRun() }} /> @@ -446,7 +470,49 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog {install.inputError === null ? null : } -

{t('installSpecHint')}

+ + {guideOpen + ? ( +
+

{t('installGuideIntro')}

+

{t('installGuideIdNote')}

+
    + {GUIDE_EXAMPLES.map(({ key, titleKey, exampleKey, hintKey }, index) => ( +
  1. + +
    + {t(titleKey)} + {t(exampleKey)} + {t(hintKey)} +
    + +
  2. + ))} +
+

+

+
+ ) + : null}
) @@ -580,15 +646,42 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { }, [highlight, clearHighlight]) const noticeLine = state.notice === null ? null : noticeText(state.notice, t) - // The page manages what the person installed; the bundles the dsh - // installation supplies are inspected in the Settings Plugins section's Plugin list tab. - const listed = state.packages.filter(pkg => pkg.installed) + // The page manages what the person installed, what the installation ships for them to switch on, and a + // selected name the Host cannot read; the installation's other bundles are inspected in the Settings + // Plugins section's Plugin list tab. + const listed = state.packages.filter(pkg => pkg.installed || pkg.optional || pkg.error !== undefined) + const mine = listed.filter(pkg => pkg.installed || !pkg.optional) + const builtin = listed.filter(pkg => pkg.optional && !pkg.installed) const loaded = state.status === 'ready' || state.status === 'error' const openPkg = openPackage === null ? undefined : listed.find(pkg => pkg.name === openPackage) const setRowEnabled = (row: PackageRow, enabled: boolean): void => { /* v8 ignore next -- a row without a live entry has its switch disabled */ if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled) } + // One group of cards under its heading and count; the built-in group comes first, and a group with nothing in it takes no room. + const renderGroup = (id: 'bundles' | 'builtin', heading: string, packages: readonly PackageView[]): ReactNode => packages.length === 0 + ? null + : ( +
+
+

{heading}

+ {packages.length} +
+
    + {packages.map(pkg => ( + { setOpenPackage(pkg.name) }} + onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} + /> + ))} +
+
+ ) return (
@@ -647,25 +740,10 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { ? listed.length === 0 ?

{t('empty')}

: ( -
-
-

{t('bundlesTitle')}

- {listed.length} -
-
    - {listed.map(pkg => ( - { setOpenPackage(pkg.name) }} - onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }} - /> - ))} -
-
+ <> + {renderGroup('builtin', t('builtinTitle'), builtin)} + {renderGroup('bundles', t('bundlesTitle'), mine)} + ) : null} = {}): PackageView { name: 'dsh-better-sidebar', version: '0.16.0', installed: true, + optional: false, enabled: true, rows: [], ...overrides, @@ -96,7 +97,7 @@ describe('PluginManagerPage', () => { expect(actions.openInstall).toHaveBeenCalledTimes(1) }) - it('lists the installed bundles as cards, leaves the installation\'s own to Settings, and tags a problem the Host reports', () => { + it('lists the installed bundles as cards, the installation\'s offered ones as built in, and tags a problem the Host reports', () => { const { actions } = renderTab({ packages: [ pkg({ title: 'Better sidebar', description: 'A sidebar.' }), @@ -104,19 +105,27 @@ describe('PluginManagerPage', () => { pkg({ name: '@deepseek-ai/dsh-web-app', installed: false }), pkg({ name: 'dsh-protected', readOnlyReason: 'management-required' }), pkg({ name: '@acme/dsh-tool', enabled: false }), + // Selected by the profile but not a bundle: a problem the person can switch off, in the profile's own group. + pkg({ name: 'dsh-selected', installed: false, error: { code: 'not-bundle' } }), + pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', title: 'Agent Teams', installed: false, optional: true, enabled: false }), ], busy: ['dsh-protected'], }) const cards = screen.getAllByRole('listitem') - expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual(['dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool']) - expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['running', 'problem', 'running', 'disabled']) - // The group heads with its title and its bare count. + // The built-in group comes first. + expect(cards.map(card => card.getAttribute('data-plugin-package'))).toEqual([ + '@deepseek-ai/dsh-experimental-agent-team-profile', 'dsh-better-sidebar', 'dsh-broken', 'dsh-protected', '@acme/dsh-tool', 'dsh-selected', + ]) + expect(cards.map(card => card.getAttribute('data-plugin-status'))).toEqual(['disabled', 'running', 'problem', 'running', 'disabled', 'problem']) + // Each group heads with its title and its bare count; the built-in card carries the official tag. expect(screen.getByRole('heading', { name: en.bundlesTitle })).toBeTruthy() - expect(document.querySelector('[data-plugin-count]')?.textContent).toBe('4') + expect(screen.getByRole('heading', { name: en.builtinTitle })).toBeTruthy() + expect([...document.querySelectorAll('[data-plugin-count]')].map(count => count.textContent)).toEqual(['1', '5']) + expect(screen.getAllByText(en.statusOfficial)).toHaveLength(1) // A scoped name reads without its scope and harness prefix. expect(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'tool') })).toHaveProperty('disabled', false) expect(screen.getByText('A sidebar.')).toBeTruthy() - expect(screen.getAllByText(en.statusProblem)).toHaveLength(1) + expect(screen.getAllByText(en.statusProblem)).toHaveLength(2) // The switch acts on the bundle; a bundle the Host cannot read stays off, a protected one stays as it is. fireEvent.click(screen.getByRole('switch', { name: en.enableToggle.replace('{name}', 'Better sidebar') })) expect(actions.setEnabled).toHaveBeenCalledWith('dsh-better-sidebar', false) @@ -126,6 +135,33 @@ describe('PluginManagerPage', () => { expect(locked.getAttribute('title')).toBe(en.reasonManagementRequired) }) + it('opens a built-in bundle\'s page with its official tag and no uninstall, and switches it on', () => { + const { actions } = renderTab({ + packages: [pkg({ name: '@deepseek-ai/dsh-experimental-agent-team-profile', title: 'Agent Teams', installed: false, optional: true, enabled: false })], + }) + fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Agent Teams') })) + const detail = document.querySelector('[data-plugin-detail]') as HTMLElement + expect(within(detail).getByText(en.statusOfficial)).toBeTruthy() + expect(within(detail).queryByRole('button', { name: en.uninstallLabel.replace('{name}', 'Agent Teams') })).toBeNull() + fireEvent.click(within(detail).getByRole('switch', { name: en.enableToggle.replace('{name}', 'Agent Teams') })) + expect(actions.setEnabled).toHaveBeenCalledExactlyOnceWith('@deepseek-ai/dsh-experimental-agent-team-profile', true) + }) + + it('opens a guide under the field and drops an example into it', () => { + const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } }) + expect(screen.queryByText(en.installGuideIntro)).toBeNull() + const toggle = screen.getByRole('button', { name: en.installGuideToggle }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(toggle) + expect(screen.getByRole('button', { name: en.installGuideHide }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText(en.installGuideIdNote)).toBeTruthy() + expect(screen.getByText(en.installGuideGitExample)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: en.installGuideFillAria.replace('{example}', en.installGuideIdExample) })) + expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith(en.installGuideIdExample) + fireEvent.click(screen.getByRole('button', { name: en.installGuideHide })) + expect(screen.queryByText(en.installGuideIntro)).toBeNull() + }) + it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => { const { actions, set } = renderTab({ packages: [pkg({ diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index 0f962f3ce7..84667981f6 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -17,6 +17,7 @@ const BUNDLE: BundleInfo = { description: 'A sidebar.', enabled: false, installed: true, + optional: false, removable: true, rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }], overrides: [], @@ -84,7 +85,7 @@ describe('packageView', () => { it('joins a bundle with the entries its rows run as', () => { expect(packageView(BUNDLE, PLUGINS)).toEqual({ name: 'dsh-better-sidebar', version: '0.16.0', title: 'Better sidebar', description: 'A sidebar.', - installed: true, enabled: false, + installed: true, optional: false, enabled: false, rows: [ { rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null }, @@ -92,13 +93,13 @@ describe('packageView', () => { }) // A row the inventory no longer lists, a protected row, and a bundle the Host cannot read. const protectedBundle: BundleInfo = { - name: '@deepseek-ai/dsh-base', enabled: true, installed: false, removable: false, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }], overrides: [], } expect(packageView(protectedBundle, PLUGINS)).toEqual({ - name: '@deepseek-ai/dsh-base', installed: false, enabled: true, readOnlyReason: 'management-required', + name: '@deepseek-ai/dsh-base', installed: false, optional: false, enabled: true, readOnlyReason: 'management-required', error: { code: 'operation-error', diagnostic: 'broken' }, rows: [ { rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' }, @@ -110,7 +111,7 @@ describe('packageView', () => { describe('sortPackages', () => { it('orders packages by the title a person reads, not by the Host order or enablement', async () => { - const plain = { enabled: true, installed: true, removable: true, rows: [], overrides: [] } + const plain = { enabled: true, installed: true, optional: false, removable: true, rows: [], overrides: [] } const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' } const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', title: 'Alpha tools', enabled: false } const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS)) diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index addd6193d7..362d87d9f8 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-profile/README.md -README.md: 86ac08c5d2278013ecbc371301bbb6a3a01a65d9 -README.zh.md: 973f5f2342b32109d26291d762f4ad7b9b4c3306 +README.md: 9aa294991d76d63a596c1d9afed2f9991f424f20 +README.zh.md: 98db275bbf583b02d1f370008345fa68882dbba1 diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 86ac08c5d2..9aa294991d 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools and disables ordinary subagent delegation and the overlapping global continuable-child controls. Workflow remains available with fresh children. Add it explicitly to an initialized profile; no shipped profile enables it by default. +`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools and disables ordinary subagent delegation and the overlapping global continuable-child controls. Workflow remains available with fresh children. The dsh installation ships it as an optional bundle that no shipped profile enables; switch it on from the Web sidebar's Plugins page, or add it explicitly to an initialized profile. ## Table of Contents @@ -91,7 +91,7 @@ The bundle's composition is prefix-stable while its patch, Team identity, and co -- **Opt-in only** — the package is public, but no shipped CLI, Web, SDK, ACP, or Python profile enables it. +- **Opt-in only** — the package ships with the installation switched off; no shipped CLI, Web, SDK, ACP, or Python profile enables it. - **Workflow child tools** — the [Team tool visibility limitation](../tool-agent-team/README.md#known-limitations-and-deferred-work) also applies to workflow children. - **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking. - **Base profile required** — the patch depends on row ids and Subagent providers supplied by `dsh-base`; it is not a standalone profile. diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index 973f5f2342..98db275bbf 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具,并禁用普通 subagent 委派和名称重叠的全局 continuable-child control。Workflow 仍可创建 fresh 子代理。必须将本包显式添加到已初始化的 profile;随附 profile 默认都不会启用它。 +`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具,并禁用普通 subagent 委派和名称重叠的全局 continuable-child control。Workflow 仍可创建 fresh 子代理。dsh 安装随附本包作为可选组合包,随附 profile 都不会启用它;可在 Web 侧栏的插件页开启,或显式添加到已初始化的 profile。 ## 目录 @@ -91,7 +91,7 @@ Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../t -- **仅显式启用**——本包公开发布,但随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。 +- **仅显式启用**——本包随安装提供但默认关闭;随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。 - **Workflow 子代理工具**——[Team 工具可见性限制](../tool-agent-team/README.zh.md#known-limitations-and-deferred-work)也适用于 workflow 子代理。 - **共享 checkout**——所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 - **需要 base profile**——本 patch 依赖 `dsh-base` 提供的配置行 id 与 Subagent 提供方;它不是独立 profile。 diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 400b82db62..b675c03c3c 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -29,6 +29,8 @@ ], "license": "MIT", "dsh": { + "title": "Agent Teams", + "description": "实验功能:把子代理委派换成 Agent Teams 的团队协作工具,这是宿主层,需与 Agent Teams Web 面板一起开启。", "bundle": { "patch": "./cordis.patch.yml" } diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/packages/experimental/agent-team-web-profile/README.i18n.yaml index 7cfcbc8da1..9febb49882 100644 --- a/packages/experimental/agent-team-web-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-web-profile/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/agent-team-web-profile/README.md -README.md: 16d71b5b30f5b18cb35e3eb289757d48a5271385 -README.zh.md: dd6c99453f9dacde19c299ab30c3a89d9aca02a3 +README.md: e21a7eeebc83a97d3e4630c95a9aeaa4213bc227 +README.zh.md: 9966697cb7225083a6fff515675b9dd9ecc51c82 diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md index 16d71b5b30..e21a7eeebc 100644 --- a/packages/experimental/agent-team-web-profile/README.md +++ b/packages/experimental/agent-team-web-profile/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-web-profile` is the published experimental Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. No shipped Web profile enables it by default. +`dsh-experimental-agent-team-web-profile` is the published experimental Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. The dsh installation ships it as an optional bundle that no shipped Web profile enables; switch it on from the Web sidebar's Plugins page after the Host layer. ## Table of Contents @@ -85,7 +85,7 @@ This Web bundle adds no model request content; the Host-side Team tools own prom - **Ordered composition** — `dsh-base`, `dsh-web-app`, `dsh-experimental-agent-team-profile`, and this package must remain in that order. - **Preset-scoped legacy controls** — stable Web presets still mount continuable Subagent controls inside the preset scope. Top-level Host profile overrides do not replace those scoped registrations, so the Team roster and legacy child controls can both appear until Web has a Team-aware preset. The [Web Agent Teams decision](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md) records this deferred composition work. -- **Opt-in only** — the package is public, but no shipped Web profile enables either Agent Teams layer. +- **Opt-in only** — the package ships with the installation switched off; no shipped Web profile enables either Agent Teams layer. ### Dev Note diff --git a/packages/experimental/agent-team-web-profile/README.zh.md b/packages/experimental/agent-team-web-profile/README.zh.md index dd6c99453f..9966697cb7 100644 --- a/packages/experimental/agent-team-web-profile/README.zh.md +++ b/packages/experimental/agent-team-web-profile/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 公开发布的实验性 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。随附 Web profile 默认不会启用它。 +`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 公开发布的实验性 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。dsh 安装随附本包作为可选组合包,随附 Web profile 不会启用它;在开启 Host 层后可在 Web 侧栏的插件页开启。 ## 目录 @@ -85,7 +85,7 @@ dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-web-profil - **有序组合**——`dsh-base`、`dsh-web-app`、`dsh-experimental-agent-team-profile` 与本包必须保持这个顺序。 - **Preset-scoped 旧控制项**——稳定 Web preset 仍会在 preset scope 内挂载 continuable Subagent 控制项。顶层 Host profile override 不会替换这些 scoped registration,因此在 Web 获得 Team-aware preset 前,Team roster 与旧 child 控制项可能同时出现。[Web Agent Teams 决策](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md)记录了这项暂缓的 composition 工作。 -- **仅显式启用**——本包公开发布,但随附 Web profile 默认不会启用任何 Agent Teams 层。 +- **仅显式启用**——本包随安装提供但默认关闭;随附 Web profile 不会启用任何 Agent Teams 层。 ### 开发备注 diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index ad91bced8d..4c6d8601e8 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -29,6 +29,8 @@ ], "license": "MIT", "dsh": { + "title": "Agent Teams Web 面板", + "description": "实验功能:在浏览器里显示 Agent Teams 的团队名单、任务板和成员导航,需先开启 Agent Teams。", "bundle": { "patch": "./cordis.patch.yml" } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ff53337d68..bff47c0028 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1446,9 +1446,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote listBundles(): Promise', - description: 'Read installed bundles and bundles supplied by this dsh installation.', + description: 'Read the profile\'s installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles. A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.', parameters: [], - returns: 'Package versions, titles, rows, activation selections and removal availability.', + returns: 'Package versions, titles, one-liners, rows, activation selections, whether the installation offers the bundle, and removal availability.', }, { signature: '@Remote async inspect(spec: string, signal?: AbortSignal): Promise', @@ -4135,7 +4135,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BundleInfo', - declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n title?: string;\n description?: string;\n enabled: boolean;\n installed: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', + declaration: 'export interface BundleInfo {\n name: string;\n version?: string;\n title?: string;\n description?: string;\n enabled: boolean;\n installed: boolean;\n optional: boolean;\n removable: boolean;\n readOnlyReason?: ReadOnlyReason;\n error?: ManagementError;\n rows: BundleRowInfo[];\n overrides: string[];\n}', }, { name: 'BundleRowInfo', diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index 1f3fe9e14d..3f1ade6a3d 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -30,6 +30,8 @@ export interface DshManifest { manifestVersion?: 1 /** Display title of the package, read without executing package code. */ title?: string + /** Display one-liner of the package, read without executing package code; shown instead of the package `description`. */ + description?: string /** Bundle metadata consumed by the profile launcher. */ bundle?: DshBundleManifest /** Profile metadata consumed by the profile launcher. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c86f498218..881ddb9f5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,12 @@ importers: '@deepseek-ai/dsh-cordis-client-runner': specifier: workspace:^ version: link:../../packages/extensions/cordis-client-runner + '@deepseek-ai/dsh-experimental-agent-team-profile': + specifier: workspace:^ + version: link:../../packages/experimental/agent-team-profile + '@deepseek-ai/dsh-experimental-agent-team-web-profile': + specifier: workspace:^ + version: link:../../packages/experimental/agent-team-web-profile '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local @@ -414,9 +420,6 @@ importers: '@deepseek-ai/dsh-experimental-agent-team': specifier: workspace:^ version: link:../../packages/experimental/agent-team - '@deepseek-ai/dsh-experimental-agent-team-profile': - specifier: workspace:^ - version: link:../../packages/experimental/agent-team-profile '@deepseek-ai/dsh-experimental-ptc-runtime-python': specifier: workspace:^ version: link:../../packages/experimental/ptc-runtime-python diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 165ffdeb3d..4c98f97433 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -88,6 +88,25 @@ describe('experimental workspace constraints', () => { }, ) + it('allows the dsh installation to ship the optional bundles it lists, and nothing else experimental', () => { + const installation = (dependencies: Record, optionalBundles: unknown) => ({ + dir: 'apps/cli', + manifest: { name: '@deepseek-ai/dsh', dependencies, dsh: { optionalBundles } }, + }) satisfies WorkspaceManifest + const listed = { '@deepseek-ai/dsh-experimental-prototype': 'workspace:^' } + expect(checkExperimentalDependencyIsolation([experimental, installation(listed, ['@deepseek-ai/dsh-experimental-prototype'])])).toEqual([]) + expect(checkExperimentalDependencyIsolation([experimental, installation(listed, [])])).toEqual([ + '@deepseek-ai/dsh: dependencies.@deepseek-ai/dsh-experimental-prototype must not reference an experimental package', + ]) + // Only a plain dependency edge is offered; a peer would make the bundle a requirement of every consumer. + expect(checkExperimentalDependencyIsolation([experimental, { + dir: 'apps/cli', + manifest: { name: '@deepseek-ai/dsh', peerDependencies: listed, dsh: { optionalBundles: ['@deepseek-ai/dsh-experimental-prototype'] } }, + }])).toEqual([ + '@deepseek-ai/dsh: peerDependencies.@deepseek-ai/dsh-experimental-prototype must not reference an experimental package', + ]) + }) + it('allows development and experimental consumers but rejects the Python release runtime', () => { const manifests: WorkspaceManifest[] = [experimental, { dir: 'packages/core/test-only', diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index e24f783e87..963d711d41 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -99,6 +99,8 @@ export interface PackageManifest { bundle?: { patch?: string } + /** The bundles the dsh installation ships switched off; the one runtime edge to experimental packages it may hold. */ + optionalBundles?: unknown } } @@ -500,7 +502,9 @@ const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies const runtimeDependencySections = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const /** - * Prevent an official runtime from requiring a package its release omits. + * Prevent an official runtime from requiring an experimental package. The dsh installation's `dependencies` + * may hold the bundles its `dsh.optionalBundles` lists: shipped switched off, they are not a requirement + * ([rationale](../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). * @param manifests - release, private experimental, and deployment-root manifests. * @returns One error for each forbidden runtime dependency. */ @@ -512,9 +516,13 @@ export function checkExperimentalDependencyIsolation(manifests: readonly Workspa const errors: string[] = [] for (const { dir, manifest } of manifests) { if (!standardReleaseMemberDirectory.test(dir) && dir !== 'python/sdk-runtime') continue + const offered = manifest.name === '@deepseek-ai/dsh' && Array.isArray(manifest.dsh?.optionalBundles) + ? new Set(manifest.dsh.optionalBundles.filter((name): name is string => typeof name === 'string')) + : new Set() for (const section of runtimeDependencySections) { for (const name of Object.keys(manifest[section] ?? {})) { if (!experimentalNames.has(name)) continue + if (section === 'dependencies' && offered.has(name)) continue errors.push(`${manifest.name ?? dir}: ${section}.${name} must not reference an experimental package`) } } diff --git a/scripts/release/installed-product-isolation.spec.ts b/scripts/release/installed-product-isolation.spec.ts index 7c90ce64ef..e59d9b6320 100644 --- a/scripts/release/installed-product-isolation.spec.ts +++ b/scripts/release/installed-product-isolation.spec.ts @@ -52,6 +52,27 @@ describe('installed default-product isolation', () => { }, ) + it('skips the entry package\'s optional bundles, which must be installed, and still rejects other experimental edges', () => { + const root = fixture() + const entry = writePackage(root, '@deepseek-ai/dsh', { + dependencies: { core: '1.0.0', [experimental]: '1.0.0' }, + dsh: { optionalBundles: [experimental] }, + }) + writePackage(root, 'core', {}) + writePackage(root, experimental, { dependencies: { '@deepseek-ai/dsh-experimental-inner': '1.0.0' } }) + expect(verifyInstalledProductIsolation(entry)).toBe(2) + + writePackage(root, 'core', { dependencies: { [experimental]: '1.0.0' } }) + expect(() => verifyInstalledProductIsolation(entry)).toThrow(`core -> ${experimental}`) + + const missing = writePackage(root, '@deepseek-ai/dsh-missing', { + dependencies: { absent: '1.0.0' }, dsh: { optionalBundles: ['absent'] }, + }) + expect(() => verifyInstalledProductIsolation(missing)).toThrow('optional bundle is missing: @deepseek-ai/dsh-missing -> absent') + const malformed = writePackage(root, '@deepseek-ai/dsh-malformed', { dsh: { optionalBundles: 'absent' } }) + expect(() => verifyInstalledProductIsolation(malformed)).toThrow('dsh.optionalBundles must be a list of package names') + }) + it('rejects experimental identities hidden behind an installed alias', () => { const root = fixture() const entry = writePackage(root, '@deepseek-ai/dsh', { dependencies: { safeName: 'file:../prototype' } }) diff --git a/scripts/release/installed-product-isolation.ts b/scripts/release/installed-product-isolation.ts index 931c8828a9..19537e4d60 100644 --- a/scripts/release/installed-product-isolation.ts +++ b/scripts/release/installed-product-isolation.ts @@ -9,6 +9,7 @@ interface InstalledManifest { dependencies?: Record optionalDependencies?: Record peerDependencies?: Record + dsh?: { optionalBundles?: unknown } peerDependenciesMeta?: Record } @@ -28,8 +29,14 @@ export function verifyInstalledProductIsolation(directory: string): number { const manifest = JSON.parse(readFileSync(join(canonical, 'package.json'), 'utf8')) as InstalledManifest const chain = [...item.chain, manifest.name] rejectExperimental(manifest.name, chain) + // The bundles the entry package ships switched off are installed beside the product, not required by it. + const optionalBundles = item.chain.length === 0 ? optionalBundlesOf(manifest) : new Set() for (const section of ['dependencies', 'optionalDependencies', 'peerDependencies'] as const) { for (const [name, range] of Object.entries(manifest[section] ?? {})) { + if (section === 'dependencies' && optionalBundles.has(name)) { + if (installedPackage(canonical, name) === undefined) throw new Error(`optional bundle is missing: ${[...chain, name].join(' -> ')}`) + continue + } rejectExperimental(name, [...chain, name]) if (range.startsWith('npm:')) { rejectExperimental(range.slice(4), [...chain, `${name} (${range})`]) @@ -48,6 +55,16 @@ export function verifyInstalledProductIsolation(directory: string): number { return visited.size } +/** The names under the entry package's `dsh.optionalBundles`, the shipped bundles no default composition selects. */ +function optionalBundlesOf(manifest: InstalledManifest): Set { + const offered = manifest.dsh?.optionalBundles + if (offered === undefined) return new Set() + if (!Array.isArray(offered) || !offered.every(name => typeof name === 'string')) { + throw new Error(`${manifest.name}: dsh.optionalBundles must be a list of package names`) + } + return new Set(offered) +} + function rejectExperimental(name: string, chain: readonly string[]): void { if (name.startsWith('@deepseek-ai/dsh-experimental-')) { throw new Error(`default product includes an experimental package: ${chain.join(' -> ')}`) diff --git a/scripts/verify-default-product-isolation.spec.ts b/scripts/verify-default-product-isolation.spec.ts index 41d26ed9ea..0ddda448b0 100644 --- a/scripts/verify-default-product-isolation.spec.ts +++ b/scripts/verify-default-product-isolation.spec.ts @@ -61,6 +61,42 @@ describe('default product isolation', () => { expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 5, configCount: 2 }) }) + it('ships an optional bundle switched off: its graph is outside the product, its name stays out of imports and defaults', () => { + const root = fixture() + const layer = '@deepseek-ai/dsh-experimental-layer' + write(root, 'packages/experimental/layer/package.json', { + name: layer, dependencies: { [experimental]: 'workspace:^' }, dsh: { bundle: { patch: './cordis.patch.yml' } }, + }) + write(root, 'packages/experimental/layer/cordis.patch.yml', [{ insert: [{ name: experimental }] }]) + manifest(root, 'apps/cli/package.json', { + dependencies: { [core]: 'workspace:^', [layer]: 'workspace:^' }, dsh: { optionalBundles: [layer] }, + }) + expect(verifyDefaultProductIsolation(root)).toMatchObject({ failures: [], packageCount: 5 }) + + // The exception covers the dependency edge alone: a runtime import or a default template still names the product. + write(root, 'apps/cli/src/bin.ts', `import '${layer}'\n`) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`apps/cli/src/bin.ts -> ${layer}`) + write(root, 'apps/cli/src/bin.ts', 'export {}\n') + write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}', '${layer}'] } }\n` + + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`optional bundle ${layer} must not be a default bundle`) + }) + + it('requires each optional bundle to be a runtime dependency that declares a bundle patch', () => { + const root = fixture() + manifest(root, 'apps/cli/package.json', { dsh: { optionalBundles: [experimental] } }) + const failures = verifyDefaultProductIsolation(root).failures.join('\n') + expect(failures).toContain(`optional bundle ${experimental} must be a runtime dependency`) + expect(failures).toContain(`optional bundle ${experimental} must declare dsh.bundle.patch`) + + manifest(root, 'apps/cli/package.json', { dsh: { optionalBundles: 'not-a-list' } }) + expect(verifyDefaultProductIsolation(root).failures).toContain('apps/cli/package.json: dsh.optionalBundles must be a list of package names') + + // An experimental runtime dependency the list does not name is still a product requirement. + manifest(root, 'apps/cli/package.json', { dependencies: { [core]: 'workspace:^', [experimental]: 'workspace:^' }, dsh: {} }) + expect(verifyDefaultProductIsolation(root).failures.join('\n')).toContain(`@deepseek-ai/dsh dependencies -> ${experimental}`) + }) + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( 'rejects transitive experimental %s', (section) => { diff --git a/scripts/verify-default-product-isolation.ts b/scripts/verify-default-product-isolation.ts index 5c3b09f462..8d0e642a4e 100644 --- a/scripts/verify-default-product-isolation.ts +++ b/scripts/verify-default-product-isolation.ts @@ -1,4 +1,8 @@ -/** Keep experimental packages outside default installations, runtime imports, and shipped compositions. */ +/** + * Keep experimental packages outside default installations, runtime imports, and shipped compositions. + * The one declared exception is a bundle the installation lists under `dsh.optionalBundles`: shipped for the + * person to switch on, selected by no shipped template, its own dependency graph outside the default product's. + */ import { existsSync, globSync, readFileSync, statSync } from 'node:fs' import { basename, dirname, extname, relative, resolve } from 'node:path' @@ -26,7 +30,7 @@ interface Manifest { optionalDependencies?: Record peerDependencies?: Record devDependencies?: Record - dsh?: { bundle?: { patch?: string }; configTrees?: Array<{ path: string }> } + dsh?: { bundle?: { patch?: string }; configTrees?: Array<{ path: string }>; optionalBundles?: unknown } } interface Package { @@ -68,9 +72,28 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes for (const path of ['apps/cli/package.json', 'apps/web/package.json', 'python/sdk-runtime/package.json']) { if (!existsSync(resolve(root, path))) failures.push(`missing default product root ${path}`) } - if (directories.get(resolve(root, 'apps/cli'))?.manifest.name !== '@deepseek-ai/dsh') { + const cli = directories.get(resolve(root, 'apps/cli')) + if (cli?.manifest.name !== '@deepseek-ai/dsh') { failures.push('apps/cli/package.json must identify @deepseek-ai/dsh') } + // The bundles the installation ships switched off: each a runtime dependency that is a bundle, none a default. + const optionalBundles = new Set() + const offered = cli?.manifest.dsh?.optionalBundles + if (offered !== undefined) { + if (!Array.isArray(offered) || !offered.every(name => typeof name === 'string')) { + failures.push('apps/cli/package.json: dsh.optionalBundles must be a list of package names') + } else { + for (const name of offered) { + if (cli?.manifest.dependencies?.[name] === undefined) { + failures.push(`apps/cli/package.json: optional bundle ${name} must be a runtime dependency`) + } + if (packages.get(name)?.manifest.dsh?.bundle?.patch === undefined) { + failures.push(`apps/cli/package.json: optional bundle ${name} must declare dsh.bundle.patch`) + } + optionalBundles.add(name) + } + } + } const queue: Package[] = [] const visited = new Set() @@ -216,6 +239,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes if (packages.get(name)?.manifest.dsh?.bundle?.patch === undefined) { failures.push(`${PROFILE_SOURCE}: default bundle ${name} must declare dsh.bundle.patch`) } + if (optionalBundles.has(name)) failures.push(`${PROFILE_SOURCE}: optional bundle ${name} must not be a default bundle`) } const webLayers = selection.webBundles.flatMap((name) => { const pkg = packages.get(name) @@ -262,6 +286,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes const { manifest } = pkg for (const section of RUNTIME_SECTIONS) { for (const [name, range] of Object.entries(manifest[section] ?? {})) { + if (pkg === cli && section === 'dependencies' && optionalBundles.has(name)) continue dependency(name, range, pkg, `${manifest.name} ${section}`) } }