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

Merge commit '2b1fbef125279026d8df67db7143b54e87279f9a' into worktree/pr3596-global-only

Yichen Jiang 1 неделя назад
Родитель
Сommit
e936a5245f

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-14-bundle-activation-selection.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-bundle-activation-selection.md
+2026-09-14-bundle-activation-selection.md: 060a102d0336812466cb6af2809ed9117cea225c
+2026-09-14-bundle-activation-selection.zh.md: 1453834d5e215d46d5adc00bdb89b2f9a5fe235d

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-14-bundle-activation-selection.md

@@ -0,0 +1,27 @@
+# Agent Note: Bundle activation selection
+
+Status: implemented
+
+English | [中文](2026-09-14-bundle-activation-selection.zh.md)
+
+## Problem
+
+An installed package can remain unused as a bundle, either because the user has never enabled it or because they turned it off. Dependency reconciliation must preserve that choice when another package is installed or an existing package gains a bundle patch.
+
+## Decision
+
+**The ordered bundle list owns activation intent.** `dependencies` records installed packages; `dsh.profile.bundles` selects whole patch layers. An installed bundle absent from the list is not enabled. Enable and disable operations only add or remove its name in that list. Runtime diagnostics still describe whether the selected rows actually activated.
+
+**Reconciliation preserves existing selections.** Only dependencies added by the current operation are candidates for its automatic-enable option. Existing dependencies stay out until explicitly enabled, including a package whose update first declares `dsh.bundle`. Removed or bundle-less dependencies leave the enabled list. Template layers are retained independently of profile dependencies.
+
+## Alternatives considered
+
+**Keep a separate disabled list to veto automatic activation.** This distinguishes never-enabled packages from explicitly disabled ones, but the management UI treats both as off. Scanning every installed dependency for activation makes unrelated package operations change that choice. Preserving the enabled list avoids a second persistent selection and its cleanup rules.
+
+## Consequences
+
+Bundle updates cannot implicitly opt an existing package into the application. A package that gains a bundle patch needs an explicit enable operation. The manifest does not retain whether an unused bundle was previously enabled; that history has no current consumer. Ordinary library dependencies are not treated as disabled bundles.
+
+## Verification
+
+`external-bundles.spec.ts` covers disabled and never-enabled packages during another installation, package updates that add a bundle patch, explicit re-enablement, dependency removal and template-layer preservation. The built CLI regression exercises reconciliation through a forwarded pnpm command with locally materialized package versions.

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-14-bundle-activation-selection.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 组合包启用选择
+
+Status: implemented
+
+[English](2026-09-14-bundle-activation-selection.md) | 中文
+
+## 问题
+
+已安装的包可能尚未作为组合包使用,原因既可能是用户从未启用,也可能是用户主动关闭。安装其他包或已有包新增组合包 patch 时,依赖对账必须保留这一选择。
+
+## 决定
+
+**有序组合包列表拥有启用意图。** `dependencies` 记录已安装的包;`dsh.profile.bundles` 选择完整的 patch 层。已安装的组合包不在列表中,即未启用。启用和禁用操作只在该列表中增删包名。运行诊断仍负责描述选中行是否实际激活。
+
+**对账保留已有选择。** 只有本次操作新增的依赖会成为自动启用选项的候选。已有依赖保持未启用,直到显式开启;升级后首次声明 `dsh.bundle` 的包也遵守此规则。已删除或不再提供组合包的依赖退出启用列表。模板层独立于 profile 依赖保留。
+
+## 考虑过的替代方案
+
+**用独立禁用列表阻止自动启用。** 它可以区分从未启用与主动禁用,但管理界面将两者都视为关闭。扫描全部已安装依赖并自动启用,会让无关包操作改变用户选择。保留启用列表即可避免维护第二份持久选择及其清理规则。
+
+## 后果
+
+更新组合包不会隐式地让已有包加入应用。新增组合包 patch 的包需要显式启用。manifest 不保存未使用的组合包是否曾经启用,因为目前没有消费者需要这段历史。普通库依赖不被视为已禁用的组合包。
+
+## 验证
+
+`external-bundles.spec.ts` 覆盖安装其他包时保留主动禁用与从未启用的包、更新包并新增组合包 patch、显式重新启用、移除依赖与保留模板层。构建后的 CLI 回归通过透传 pnpm 命令及本地构造的包版本验证对账。

+ 2 - 2
apps/cli/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 apps/cli/README.md
-README.md: f8d89bd8b3cfc077a46da37f9c23d5050743e4b1
-README.zh.md: 4a22cc1a399dc5d8c9103952786d908a52be7f3e
+README.md: 44dfb27275e29295f8c63134ab833a2bff86e1ed
+README.zh.md: b2158681d8ab1a4e11e31155e8f970539573d457

+ 1 - 1
apps/cli/README.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profiles
 
-A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list, the `disabledBundles` it keeps out of that list, and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile and home-level patch files; `startup` applies them once.
+A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list, and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile and home-level patch files; `startup` applies them once.
 
 The tree composes over an empty root:
 - each bundle's patch in `dsh.profile.bundles` order

+ 1 - 1
apps/cli/README.zh.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profile
 
-profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表、被排除在该列表之外的 `disabledBundles`,以及 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile 与 home 级 patch 文件,`startup` 则只应用一次。
+profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表,以及 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile 与 home 级 patch 文件,`startup` 则只应用一次。
 
 配置树以空根为起点,依次叠加以下配置层:
 - `dsh.profile.bundles` 中各组合包的 patch

+ 2 - 2
apps/cli/reference/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 apps/cli/reference/README.md
-README.md: 5a92052eb24c8af432c2ede371a2ff2bc84f57c0
-README.zh.md: a9268ad4920a7d5a72a4b0ead9b2672d22e10805
+README.md: 38b3dbb297a7cf2678678a1610bd4b1c2840ace1
+README.zh.md: 6f1558bc36c0e1753a7f73c3153ee85831b0dc4d

+ 1 - 1
apps/cli/reference/README.md

@@ -52,7 +52,7 @@ dsh --profile web --patch ./extra.yml --dump-config
 
 ## Plugin management
 
-`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack (so an `update` that gains the declaration activates it), a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack.
+`dsh plugin --profile <name> <args...>` initializes the profile when missing (shipped template, or `@deepseek-ai/dsh-base` alone for other names), then forwards `<args...>` to `pnpm` with the profile directory as working directory — `add`, `remove`, `why`, `update`, and every other pnpm verb work unchanged; pnpm must be on PATH. Relative path specs (`.`, `../plugin`, and their `file:`/`link:` forms) are anchored to the invoking directory first, so `add .` from a plugin checkout installs that checkout, not the profile. After every successful run, `dsh.profile.bundles` is reconciled against the installed state: each newly added dependency resolving to a package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` joins the layer stack, a bundle-less dependency stays plain with a one-time warning, and a removed dependency leaves the stack. Existing dependencies keep their current enabled selection, even when an update adds a bundle declaration.
 
 The Codex and Claude Code subagent providers are separate optional Bundles. Add either package, both in one command, or remove either package independently:
 

+ 1 - 1
apps/cli/reference/README.zh.md

@@ -54,7 +54,7 @@ dsh --profile web --patch ./extra.yml --dump-config
 
 ## 插件管理
 
-`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果某项依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;如果某项依赖在 `update` 后获得该声明,也会随即激活。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
+`dsh plugin --profile <name> <args...>` 在 profile 缺失时先初始化它(有随附模板的用模板,其他名称只装 `@deepseek-ai/dsh-base`),然后以 profile 目录为工作目录,把 `<args...>` 转发给 `pnpm`:`add`、`remove`、`why`、`update` 及其他所有 pnpm 子命令都照常可用;pnpm 必须在 PATH 上。相对路径 spec(`.`、`../plugin` 及其 `file:`/`link:` 形式)会先锚定到调用目录,因此在插件 checkout 中执行 `add .` 安装的是该 checkout,而不是 profile。每次成功运行后,系统都会根据当前安装状态更新 `dsh.profile.bundles`:如果本次新增的依赖解析到的包在 manifest 中声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`,该依赖就会加入配置层栈;已有依赖保持当前启用选择,即使在 `update` 后新增组合包声明,也不会自动启用。没有组合包声明的依赖仍作为普通依赖保留,并显示一次性警告;已移除的依赖则从配置层栈中删除。
 
 Codex 与 Claude Code subagent 提供方是两个彼此独立的可选组合包。可以只添加一个包、在同一命令中添加两个包,或独立移除任一包:
 

+ 6 - 12
apps/cli/src/plugin.ts

@@ -1,15 +1,9 @@
 /**
- * `dsh plugin --profile <name> <args...>` — profile plugin management from
- * the terminal. `add <spec...>` and `remove <name...>` go through the plugin
- * installer the Web host shares: pnpm runs in the profile directory, every
- * new package is read statically, a conflicting bundle is removed with its
- * reason printed, undeclared packages remain installed, and every new bundle joins the
- * layer list — the CLI's install-and-enable semantics. Every other pnpm verb
- * is forwarded verbatim and followed by a reconcile of the
- * `dsh.profile.bundles` layer list against the installed state, so `update`
- * activates a package that gained its `dsh.bundle` declaration in a newer
- * version. Nothing here boots the profile: the plugins being managed never
- * start.
+ * `dsh plugin --profile <name> <args...>` installs and removes packages
+ * through the shared installer without booting the profile. New bundles
+ * join the enabled layer list; conflicting bundles are removed with a
+ * diagnostic. Other pnpm verbs pass through unchanged. Reconciliation
+ * preserves existing enablement choices through package updates.
  * @module @deepseek-ai/dsh/plugin
  */
 
@@ -61,7 +55,7 @@ function warnPlain(plain: readonly string[]): void {
   for (const packageName of plain) {
     process.stderr.write(
       `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
-      + '(a later update that gains one activates it automatically)\n',
+      + '(enable its bundle explicitly if a later update adds one)\n',
     )
   }
 }

+ 4 - 6
apps/cli/tests/built-bin.e2e.ts

@@ -972,11 +972,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 2 + 30_000)
 
-  it('activates a dependency that gained dsh.bundle in a later update', async () => {
-    // Reconcile runs against the INSTALLED state on every successful pnpm
-    // run, so `update` (not only `add`) activates a package whose newer
-    // version declares dsh.bundle. Simulated without a registry: hand-place
-    // the installed package, flip its manifest, and run a benign pnpm verb.
+  it('keeps an existing dependency off when an update adds dsh.bundle', async () => {
+    // Materialize both package versions locally; pnpm root exercises the
+    // forwarded-command reconciliation without contacting a registry.
     const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
     try {
       const profileDir = join(home, 'profiles', 'up')
@@ -1003,7 +1001,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
       expect(second.code).toBe(0)
       manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
-      expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
+      expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
     } finally {
       rmSync(home, { recursive: true, force: true })
     }

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: b34d38cc928e01c320b502560ea82fa7817fa5c7
-README.zh.md: 4ba7b4d34f3aa80e505ac61798ffcf10a078dcc2
+README.md: 30791a9893d0ed0da4d15b742aeb3de65c5a7983
+README.zh.md: 82e9ea92510f4467f5de618cbc1b173ddbc14749

+ 1 - 1
packages/boot/app-boot/README.md

@@ -58,7 +58,7 @@ Profiles with `patchReload: live` watch both user patch files and apply the [rel
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
-Bundle patches retain their declared ids, parents, and ordering. Layers claim row ids in manifest order; a bundle with a repeated or already claimed id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides. Package metadata does not select startup strictness.
+Bundle patches retain their declared ids, parents, and ordering. Layers claim row ids in manifest order; a bundle with a repeated or already claimed id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides. Existing dependencies retain this selection through installs and updates; absence from the list means the bundle is not enabled. See [bundle activation selection](../../../.agents/notes/implemented/architecture/2026-09-14-bundle-activation-selection.md). Package metadata does not select startup strictness.
 
 The launcher provides `ctx.profileRuntime` before any configuration entry mounts. The runtime requires Loader and supports calls through both root and plugin contexts. It owns row ownership, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. Observers can await `whenIdle()` before publishing a refreshed view of composition ownership.
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -58,7 +58,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
-组合包 patch 保留声明的 id、父组和顺序。各层按 manifest 顺序占有行 id;组合包重复声明或使用已占用的 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。包元数据不决定启动严格程度。
+组合包 patch 保留声明的 id、父组和顺序。各层按 manifest 顺序占有行 id;组合包重复声明或使用已占用的 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。安装和更新保留已有依赖的启用选择;不在列表中的组合包即未启用。参见[组合包启用选择](../../../.agents/notes/implemented/architecture/2026-09-14-bundle-activation-selection.zh.md)。包元数据不决定启动严格程度。
 
 launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。该运行时要求注入 Loader,支持从根上下文和插件上下文取得的句柄调用。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 观测方可等待 `whenIdle()` 后,再发布包含组合归属的刷新视图。
 

+ 18 - 31
packages/boot/app-boot/src/external-bundles.ts

@@ -1,7 +1,6 @@
 /** Bundle patch ownership and the profile manifest's installed/enabled layer lists. */
 
 import { join } from 'node:path'
-import type { DshProfileManifest } from '@deepseek-ai/dsh-package-manifest'
 import { visitIdentifiedRows } from './patch-rows.ts'
 import {
   readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
@@ -74,16 +73,11 @@ export interface BundleReconciliation {
 }
 
 /**
- * Reconcile `dsh.profile.bundles` against the installed state after a pnpm
- * run. A dependency that no longer resolves to a bundle leaves the layer
- * list; template bundles (never dependencies) are untouched. A bundle in
- * neither list — one the run added, or a dependency whose update declared
- * `dsh.bundle` — joins the layer list only when `autoEnable` is set, the
- * CLI's install-and-enable semantics, and is otherwise reported as
- * installed-only, the plugin manager's install step. A bundle the user
- * disabled is named in `dsh.profile.disabledBundles` and stays out through
- * every later run until enabled again; the record goes when its dependency
- * does.
+ * Reconcile the enabled layer list after a pnpm run. Existing dependencies
+ * retain their enabled selection, including packages whose update adds a
+ * bundle patch. Only new dependencies may join the list when `autoEnable`
+ * is set; otherwise they are reported as installed-only. Removed or
+ * bundle-less dependencies leave the list; template bundles are untouched.
  * @param binName - the diagnostic prefix used by manifest reads.
  * @param profileDir - the profile directory.
  * @param installAnchor - absolute path of the dsh app's package.json.
@@ -102,14 +96,14 @@ export function reconcileInstalledBundles(
   const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
   const dependencies = Object.keys(after.dependencies ?? {})
   const bundles = [...after.dsh?.profile?.bundles ?? []]
-  const disabled = after.dsh?.profile?.disabledBundles ?? []
   const outcome: BundleReconciliation = { enabled: [], removed: [], plain: [], installedOnly: [] }
   for (const packageName of dependencies) {
+    if (beforeDeps.has(packageName)) continue
     if (!exportsBundlePatch(binName, packageName, installAnchor, profileDir)) {
-      if (!beforeDeps.has(packageName)) outcome.plain.push(packageName)
+      outcome.plain.push(packageName)
       continue
     }
-    if (bundles.includes(packageName) || disabled.includes(packageName)) continue
+    if (bundles.includes(packageName)) continue
     if (options.autoEnable) {
       bundles.push(packageName)
       outcome.enabled.push(packageName)
@@ -130,24 +124,19 @@ export function reconcileInstalledBundles(
       outcome.removed.push(packageName)
     }
   }
-  // A disabled bundle whose dependency is gone, or no longer a bundle, has nothing left to keep out.
-  const stillDisabled = disabled.filter(stillBundle)
-  if (outcome.enabled.length > 0 || outcome.removed.length > 0 || stillDisabled.length !== disabled.length) {
-    writeProfileManifest(profileDir, withBundles(after, bundles, stillDisabled))
+  if (outcome.enabled.length > 0 || outcome.removed.length > 0) {
+    writeProfileManifest(profileDir, withBundles(after, bundles))
   }
   return outcome
 }
 
-/** The manifest with its bundle lists replaced, every other field kept; an empty disabled list leaves the field out. */
-function withBundles(manifest: ProfileManifest, bundles: string[], disabled: readonly string[]): ProfileManifest {
-  const profile: DshProfileManifest = { ...manifest.dsh?.profile, bundles }
-  if (disabled.length > 0) profile.disabledBundles = [...disabled]
-  else delete profile.disabledBundles
-  return { ...manifest, dsh: { ...manifest.dsh, profile } }
+/** Replace the ordered enabled layers while preserving the remaining manifest fields. */
+function withBundles(manifest: ProfileManifest, bundles: string[]): ProfileManifest {
+  return { ...manifest, dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } } }
 }
 
 /**
- * Add one installed bundle to the profile's layer list and drop its disabled record.
+ * Add one installed bundle to the profile's enabled layer list.
  * @param binName - the diagnostic prefix on thrown errors.
  * @param profileDir - the profile directory.
  * @param installAnchor - absolute path of the dsh app's package.json.
@@ -165,14 +154,13 @@ export function enableBundle(binName: string, profileDir: string, installAnchor:
   }
   const bundles = manifest.dsh?.profile?.bundles ?? []
   if (bundles.includes(packageName)) return false
-  const disabled = (manifest.dsh?.profile?.disabledBundles ?? []).filter(name => name !== packageName)
-  writeProfileManifest(profileDir, withBundles(manifest, [...bundles, packageName], disabled))
+  writeProfileManifest(profileDir, withBundles(manifest, [...bundles, packageName]))
   return true
 }
 
 /**
- * Remove one dependency-managed bundle from the profile's layer list and
- * record it as disabled, so reconciliation leaves it out until `enableBundle`.
+ * Remove one dependency-managed bundle from the enabled layer list.
+ * Its dependency remains installed and later reconciliation leaves it off.
  * @param binName - the diagnostic prefix on thrown errors.
  * @param profileDir - the profile directory.
  * @param packageName - the bundle to disable.
@@ -186,9 +174,8 @@ export function disableBundle(binName: string, profileDir: string, packageName:
   if (!(packageName in (manifest.dependencies ?? {}))) {
     throw new Error(`${binName}: ${packageName} is a template bundle of this profile and cannot be disabled`)
   }
-  const disabled = manifest.dsh?.profile?.disabledBundles ?? []
   writeProfileManifest(profileDir, withBundles(
-    manifest, bundles.filter(name => name !== packageName), [...new Set([...disabled, packageName])],
+    manifest, bundles.filter(name => name !== packageName),
   ))
   return true
 }

+ 26 - 16
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -85,25 +85,35 @@ describe('reconcileInstalledBundles', () => {
     expect(exportsBundlePatch(NAME, 'missing', installAnchor, profileDir)).toBe(false)
   })
 
-  it('keeps a bundle the user disabled out of the list through later runs, and enables one an update turned into a bundle', () => {
-    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'late-bundle': {} })
+  it('preserves disabled and never-enabled bundles while adding another dependency', () => {
+    const { profileDir, installAnchor } = stageProfile({
+      'off-bundle': { bundle: true }, 'unused-bundle': { bundle: true }, 'new-bundle': { bundle: true },
+    })
+    enableBundle(NAME, profileDir, installAnchor, 'off-bundle')
+    disableBundle(NAME, profileDir, 'off-bundle')
+    const manifest = readProfileManifest(NAME, profileDir)
+    expect(manifest.dsh?.profile).toEqual({ bundles: ['@deepseek-ai/dsh-base'] })
+    const before = { ...manifest, dependencies: { 'off-bundle': '1.2.3', 'unused-bundle': '1.2.3' } }
+
+    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: true }))
+      .toEqual({ enabled: ['new-bundle'], removed: [], plain: [], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile)
+      .toEqual({ bundles: ['@deepseek-ai/dsh-base', 'new-bundle'] })
+    expect(enableBundle(NAME, profileDir, installAnchor, 'off-bundle')).toBe(true)
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile)
+      .toEqual({ bundles: ['@deepseek-ai/dsh-base', 'new-bundle', 'off-bundle'] })
+  })
+
+  it('leaves an existing dependency unenabled when an update adds a bundle patch', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'late-bundle': {} })
     const installed = readProfileManifest(NAME, profileDir)
-    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, { dependencies: {} }, { autoEnable: true }))
-      .toEqual({ enabled: ['ext-bundle'], removed: [], plain: ['late-bundle'], installedOnly: [] })
-    expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(true)
-    expect(readProfileManifest(NAME, profileDir).dsh?.profile).toEqual({ bundles: ['@deepseek-ai/dsh-base'], disabledBundles: ['ext-bundle'] })
-    // An update made late-bundle a bundle; ext-bundle is still installed and still disabled.
     const late = join(profileDir, 'node_modules', 'late-bundle')
     writeFileSync(join(late, 'package.json'), JSON.stringify({ name: 'late-bundle', version: '2.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
     writeFileSync(join(late, 'cordis.patch.yml'), '[]\n')
-    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, installed, { autoEnable: false }))
-      .toEqual({ enabled: [], removed: [], plain: [], installedOnly: ['late-bundle'] })
+
     expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, installed, { autoEnable: true }))
-      .toEqual({ enabled: ['late-bundle'], removed: [], plain: [], installedOnly: [] })
-    expect(readProfileManifest(NAME, profileDir).dsh?.profile).toEqual({ bundles: ['@deepseek-ai/dsh-base', 'late-bundle'], disabledBundles: ['ext-bundle'] })
-    // Enabling again drops the record.
-    expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(true)
-    expect(readProfileManifest(NAME, profileDir).dsh?.profile).toEqual({ bundles: ['@deepseek-ai/dsh-base', 'late-bundle', 'ext-bundle'] })
+      .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile).toEqual({ bundles: ['@deepseek-ai/dsh-base'] })
   })
 
   it('keeps a listed bundle listed once when the run brings its dependency back', () => {
@@ -115,13 +125,13 @@ describe('reconcileInstalledBundles', () => {
     expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle'])
   })
 
-  it('drops a layer whose dependency was removed, and the disabled record of one, keeping template bundles', () => {
+  it('drops removed dependency layers while keeping template bundles', () => {
     const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'off-bundle': { bundle: true } })
     enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')
     enableBundle(NAME, profileDir, installAnchor, 'off-bundle')
     disableBundle(NAME, profileDir, 'off-bundle')
     const manifest = readProfileManifest(NAME, profileDir)
-    // pnpm removed both dependencies; the lists still name them.
+    // pnpm removed both dependencies; the enabled layer still names ext-bundle.
     writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ ...manifest, dependencies: {} }))
 
     const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, manifest, { autoEnable: true })

+ 2 - 3
packages/boot/plugin-manager/src/installer.ts

@@ -67,9 +67,8 @@ export type InstalledManifest = ProfileManifest & { description?: string; dsh?:
  * Every run reads the profile manifest afresh and writes it through the
  * app-boot helpers the `dsh plugin` command uses, so the CLI and the Web
  * host never disagree on the file: `dependencies` records what is installed,
- * `dsh.profile.bundles` what is enabled, and `dsh.profile.disabledBundles`
- * what the user turned off. Nothing here touches a running tree, which is
- * what lets the CLI install before any plugin starts.
+ * `dsh.profile.bundles` what is enabled. No operation here touches a running
+ * tree, so the CLI can install before any plugin starts.
  */
 export class PluginInstaller {
   private readonly spawn: SpawnLike

+ 0 - 6
packages/util/package-manifest/src/types.ts

@@ -60,12 +60,6 @@ export interface DshBundleManifest {
 export interface DshProfileManifest {
   /** Ordered bundle layer list, using installed package names. */
   bundles?: string[]
-  /**
-   * Installed bundles the user disabled: dependencies that declare
-   * `dsh.bundle` and stay out of `bundles`, through every later pnpm run,
-   * until enabled again.
-   */
-  disabledBundles?: string[]
   /** User patch lifecycle; omitted means `live` for custom profiles. */
   patchReload?: ProfilePatchReload
 }