Преглед изворни кода

fix(app-boot): record user-disabled bundles in the profile manifest

`disableBundle` now moves a package from `dsh.profile.bundles` to
`dsh.profile.disabledBundles`, and `reconcileInstalledBundles` enables only
a bundle named in neither list, so an unrelated pnpm run never re-enables a
bundle the user turned off while an update that gives a dependency
`dsh.bundle` still activates it (the built-bin e2e scenario the previous
"new dependencies only" rule broke). The record goes when its dependency
does.

Also: `visitIdentifiedRows` in patch-rows owns the per-patch restatement
tracking that compose-stack and external-bundles had cloned (duplication
gate), and the probe spec's flood fixture retries short and EAGAIN writes
because the child's stderr pipe is non-blocking once tsx touched
process.stderr (the test raced the parent's drain).
Yichen Jiang пре 2 недеља
родитељ
комит
07878ae1d3

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
-2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 2b892cacf14a9f62921b0fce015f14970a2b5a06
-2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: f7004f83f417282670fb90effadf257716066e67
+2026-09-04-boot-scoped-fail-loud-and-package-probe.md: 015d822cfaf9e933654f19cf68fb16b95a7625d8
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: ce7b2ca0ddd25e600f605d348c2c355f062ccec3

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md

@@ -18,6 +18,8 @@ Separately, nothing could say what an installed package was without importing it
 
 **The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: a message that does not echo the run's token is the package's own, not the report, and an unrecognized record is probed again. The child gets the parent environment minus credential-shaped names, removes the token and `process.send` before importing, keeps at most 16 KiB of stderr for the failure text, and is awaited to `close` after the kill so nothing of it outlives the call. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
 
+**A disabled bundle is a manifest fact.** `disableBundle` moves the package from `dsh.profile.bundles` to `dsh.profile.disabledBundles`, and `reconcileInstalledBundles` enables only a bundle named in neither list — one the run installed, or a dependency whose update declared `dsh.bundle` — so an unrelated pnpm run never re-enables what the user turned off, and the record goes when its dependency does. Comparing the manifest before and after the run was rejected: the installed state alone cannot tell a bundle the user disabled from one that became a bundle after it was installed.
+
 ## Alternatives considered
 
 **Attribute a runtime rejection to the plugin that produced it and mark that plugin failed.** The right end state, but a promise carries no fiber, and cordis's effect wrappers cover only what plugins register through them. Deferred: the guard reports and lets the process continue; attribution needs an async-context seam.

+ 2 - 0
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md

@@ -18,6 +18,8 @@ Status: implemented
 
 **探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程——`probe-child.ts`,探针旁边的独立模块,源码启动时经 tsx 运行,构建后是 `lib/probe-child.js`——从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,经 IPC 通道发出一份报告。stdout 与 stderr 仍归被 import 的模块自己,所以在 import 时打印的包照样能报告;报告一到子进程就被杀掉,因此让定时器一直活着的包不再多花任何代价。报告与缓存记录按各自跨越的进程边界与文件边界逐字段校验:没有回显本次 token 的消息是包自己的、不算报告,无法识别的记录重新探测。子进程拿到的是剔除了密钥形态变量的父环境,import 之前先删掉 token 与 `process.send`,stderr 只留最后 16 KiB 作失败文本,kill 之后等到 `close` 才结算,所以子进程的任何东西都不会活过这次调用。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;`ok` 只表示主导出 import 成功且 cordis 不是第二份副本,能否启用或添加由 `kind` 与 `addable[].ok` 决定。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。
 
+**被禁用的 bundle 是 manifest 里的事实。** `disableBundle` 把包从 `dsh.profile.bundles` 移到 `dsh.profile.disabledBundles`,`reconcileInstalledBundles` 只启用两个列表都没有的 bundle——本次运行装上的,或更新后声明了 `dsh.bundle` 的依赖——所以无关的 pnpm 运行不会重新启用用户关掉的东西,依赖被移除时记录随之消失。曾考虑比较运行前后的 manifest,但被否决:仅凭已安装状态分不清一个 bundle 是用户禁用的,还是安装之后才变成 bundle 的。
+
 ## 考虑过的替代方案
 
 **把运行时 rejection 归属到产生它的插件并把该插件标为失败。** 正确的终态,但 promise 不携带 fiber,cordis 的 effect 包装也只覆盖插件经由它注册的东西。延后:守卫现在只报告并让进程继续;归属需要一个 async-context seam。

+ 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: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212
-README.zh.md: 85a75f4c025e1bfe1461b9ffdaca533d308318ba
+README.md: 92a5a07c3035e5814e28522c4df915f6931ad96e
+README.zh.md: 562fce3d0bd0dc2712d845e30b958f925ed6d5d4

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

+ 8 - 16
packages/boot/app-boot/src/compose-stack.ts

@@ -16,7 +16,7 @@
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
-import { visitPatchRows, visitRowTree } from './patch-rows.ts'
+import { visitIdentifiedRows, visitRowTree } from './patch-rows.ts'
 import type { ProfileLayer } from './profile.ts'
 
 /** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
@@ -114,29 +114,21 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const declaredUnder = new Map<string, string | undefined>()
   for (const layer of layers) {
     if (isContainedLayer(layer)) continue
-    let listed = new Set<string>()
-    let listIndex = -1
-    visitPatchRows(layer.patches, (row, source, place) => {
-      if (typeof row.id !== 'string') return
-      if (place.patch !== listIndex) {
-        listIndex = place.patch
-        listed = new Set()
-      }
-      const owner = owners.get(row.id)
+    visitIdentifiedRows(layer.patches, ({ id, source, place, listed }) => {
+      const owner = owners.get(id)
       if (owner === layer) {
         // Restating a row under the group that already holds it is the layer
         // keeping its child; inserting it again, listing it twice in one
         // config, or setting it under another group is declaring it twice.
-        if (source === 'insert' || listed.has(row.id) || declaredUnder.get(row.id) !== place.target) {
-          throw new Error(`row ${JSON.stringify(row.id)} is declared twice by ${layer.packageName}`)
+        if (source === 'insert' || listed.has(id) || declaredUnder.get(id) !== place.target) {
+          throw new Error(`row ${JSON.stringify(id)} is declared twice by ${layer.packageName}`)
         }
       } else if (owner !== undefined) {
-        throw new Error(`row ${JSON.stringify(row.id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
+        throw new Error(`row ${JSON.stringify(id)} is declared by both ${owner.packageName} and ${layer.packageName}`)
       } else {
-        owners.set(row.id, layer)
-        declaredUnder.set(row.id, place.target)
+        owners.set(id, layer)
+        declaredUnder.set(id, place.target)
       }
-      listed.add(row.id)
     })
   }
   const skipped = new Map<string, RowConflict[]>()

+ 43 - 36
packages/boot/app-boot/src/external-bundles.ts

@@ -16,7 +16,8 @@
 
 import { join } from 'node:path'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { visitPatchRows } from './patch-rows.ts'
+import type { DshProfileManifest } from '@deepseek-ai/dsh-package-manifest'
+import { visitIdentifiedRows } from './patch-rows.ts'
 import {
   readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
 } from './profile.ts'
@@ -112,30 +113,22 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
     rows.set(rowId, moduleName)
     declaredUnder.set(rowId, target)
   }
-  let listed = new Set<string>()
-  let listIndex = -1
-  visitPatchRows(layer.patches, (row, source, place) => {
-    if (typeof row.id !== 'string') return
-    if (place.patch !== listIndex) {
-      listIndex = place.patch
-      listed = new Set()
-    }
+  visitIdentifiedRows(layer.patches, ({ id, row, source, place, listed }) => {
     // A root insert lands in the bundle's group: that is the group it declares under.
     const target = place.target ?? groupId
     if (source === 'config') {
       // A config override restates the children it keeps: the same id declared
       // under the same group before is that row. Twice in one list, or under
       // another group, it would mount as a rejected duplicate or move the row.
-      if (listed.has(row.id) || (rows.has(row.id) && declaredUnder.get(row.id) !== target)) {
-        duplicates.push({ rowId: row.id, moduleName: row.name })
-      } else if (!rows.has(row.id)) {
-        rows.set(row.id, row.name)
-        declaredUnder.set(row.id, target)
+      if (listed.has(id) || (rows.has(id) && declaredUnder.get(id) !== target)) {
+        duplicates.push({ rowId: id, moduleName: row.name })
+      } else if (!rows.has(id)) {
+        rows.set(id, row.name)
+        declaredUnder.set(id, target)
       }
     } else {
-      claim(row.id, row.name, target)
+      claim(id, row.name, target)
     }
-    listed.add(row.id)
   })
   claim(groupId, CONTAINED_GROUP_MODULE)
   const wrappers = new Map<string, string>()
@@ -209,12 +202,14 @@ 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 dependency
- * the run added that resolves to a bundle joins the list only when
- * `autoEnable` is set — the CLI's install-and-enable semantics — and is
- * otherwise reported as installed-only, which is the plugin manager's install
- * step. A bundle installed before the run keeps its place in or out of the
- * list: one the user disabled stays disabled through an unrelated run.
+ * 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.
  * @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.
@@ -233,14 +228,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)) {
-      outcome.plain.push(packageName)
+      if (!beforeDeps.has(packageName)) outcome.plain.push(packageName)
       continue
     }
-    if (bundles.includes(packageName)) continue
+    if (bundles.includes(packageName) || disabled.includes(packageName)) continue
     if (options.autoEnable) {
       bundles.push(packageName)
       outcome.enabled.push(packageName)
@@ -249,29 +244,36 @@ export function reconcileInstalledBundles(
     }
   }
   const dependencySet = new Set(dependencies)
+  const stillBundle = (packageName: string): boolean => (
+    dependencySet.has(packageName) && exportsBundlePatch(binName, packageName, installAnchor, profileDir)
+  )
   for (const packageName of [...bundles]) {
     // Only dependency-managed entries are subject to removal; template
     // bundles are not dependencies.
     const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
-    const stillBundle = dependencySet.has(packageName) && exportsBundlePatch(binName, packageName, installAnchor, profileDir)
-    if (wasDependency && !stillBundle) {
+    if (wasDependency && !stillBundle(packageName)) {
       bundles.splice(bundles.indexOf(packageName), 1)
       outcome.removed.push(packageName)
     }
   }
-  if (outcome.enabled.length > 0 || outcome.removed.length > 0) {
-    writeProfileManifest(profileDir, withBundles(after, bundles))
+  // 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))
   }
   return outcome
 }
 
-/** The manifest with its bundle layer list replaced, every other field kept. */
-function withBundles(manifest: ProfileManifest, bundles: string[]): ProfileManifest {
-  return { ...manifest, dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } } }
+/** 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 } }
 }
 
 /**
- * Add one installed bundle to the profile's layer list.
+ * Add one installed bundle to the profile's layer list and drop its disabled record.
  * @param binName - the diagnostic prefix on thrown errors.
  * @param profileDir - the profile directory.
  * @param installAnchor - absolute path of the dsh app's package.json.
@@ -289,12 +291,14 @@ export function enableBundle(binName: string, profileDir: string, installAnchor:
   }
   const bundles = manifest.dsh?.profile?.bundles ?? []
   if (bundles.includes(packageName)) return false
-  writeProfileManifest(profileDir, withBundles(manifest, [...bundles, packageName]))
+  const disabled = (manifest.dsh?.profile?.disabledBundles ?? []).filter(name => name !== packageName)
+  writeProfileManifest(profileDir, withBundles(manifest, [...bundles, packageName], disabled))
   return true
 }
 
 /**
- * Remove one dependency-managed bundle from the profile's layer list.
+ * Remove one dependency-managed bundle from the profile's layer list and
+ * record it as disabled, so reconciliation leaves it out until `enableBundle`.
  * @param binName - the diagnostic prefix on thrown errors.
  * @param profileDir - the profile directory.
  * @param packageName - the bundle to disable.
@@ -308,6 +312,9 @@ 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`)
   }
-  writeProfileManifest(profileDir, withBundles(manifest, bundles.filter(name => name !== packageName)))
+  const disabled = manifest.dsh?.profile?.disabledBundles ?? []
+  writeProfileManifest(profileDir, withBundles(
+    manifest, bundles.filter(name => name !== packageName), [...new Set([...disabled, packageName])],
+  ))
   return true
 }

+ 36 - 0
packages/boot/app-boot/src/patch-rows.ts

@@ -76,3 +76,39 @@ export function visitPatchRows(
     }
   })
 }
+
+/** One id-carrying row `visitIdentifiedRows` reports. */
+export interface IdentifiedRow {
+  /** The row's id. */
+  id: string
+  /** The row as the patch wrote it. */
+  row: EntryOptions
+  /** How the patch introduced the row. */
+  source: PatchRowSource
+  /** Where the row mounts. */
+  place: PatchRowPlace
+  /** The ids the same patch listed before this row. */
+  listed: ReadonlySet<string>
+}
+
+/**
+ * Visit every row with an id a patch list introduces, in written order, with
+ * the ids the same patch listed before it: a config override lists the
+ * children it keeps, and an id it lists twice would mount as a rejected
+ * duplicate, which each caller reports in its own terms.
+ * @param patches - the patch list.
+ * @param visit - called once per row that carries an id.
+ */
+export function visitIdentifiedRows(patches: readonly PatchOptions[], visit: (visited: IdentifiedRow) => void): void {
+  let listed = new Set<string>()
+  let listIndex = -1
+  visitPatchRows(patches, (row, source, place) => {
+    if (typeof row.id !== 'string') return
+    if (place.patch !== listIndex) {
+      listIndex = place.patch
+      listed = new Set()
+    }
+    visit({ id: row.id, row, source, place, listed })
+    listed.add(row.id)
+  })
+}

+ 25 - 15
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -269,17 +269,25 @@ describe('reconcileInstalledBundles', () => {
     expect(exportsBundlePatch(NAME, 'missing', installAnchor, profileDir)).toBe(false)
   })
 
-  it('leaves a bundle installed before the run where the user left it, in or out of the list', () => {
-    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'new-bundle': { bundle: true } })
-    // ext-bundle was installed earlier and is not listed: the user disabled it.
-    const before = { dependencies: { 'ext-bundle': '1.0.0' } }
-    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: true }))
-      .toEqual({ enabled: ['new-bundle'], removed: [], plain: [], installedOnly: [] })
-    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'new-bundle'])
-    disableBundle(NAME, profileDir, 'new-bundle')
-    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, readProfileManifest(NAME, profileDir), { autoEnable: true }))
-      .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] })
-    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
+  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': {} })
+    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'] })
   })
 
   it('keeps a listed bundle listed once when the run brings its dependency back', () => {
@@ -291,16 +299,18 @@ 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 keeps template bundles', () => {
-    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } })
+  it('drops a layer whose dependency was removed, and the disabled record of one, 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 the dependency; the layer list still names it.
+    // pnpm removed both dependencies; the lists still name them.
     writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ ...manifest, dependencies: {} }))
 
     const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, manifest, { autoEnable: true })
     expect(outcome.removed).toEqual(['ext-bundle'])
-    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile).toEqual({ bundles: ['@deepseek-ai/dsh-base'] })
   })
 })
 

+ 8 - 1
packages/boot/app-boot/tests/probe.spec.ts

@@ -232,7 +232,14 @@ describe('probePackage', () => {
       'peeks': {
         main: 'throw new Error("env=" + Object.keys(process.env).filter(k => k.startsWith("PROBE_TEST") || k === "DSH_PROBE_REPORT").sort().join(","))\n',
       },
-      'floods': { main: 'import { writeSync } from "node:fs"\nwriteSync(2, "x".repeat(200_000))\nwriteSync(2, "tail-marker")\nprocess.exit(3)\n' },
+      // The child's stderr is a pipe Node put in non-blocking mode (tsx touches
+      // process.stderr at start), so one raw write returns short or throws EAGAIN
+      // once the pipe is full: the flood retries until the parent has drained it.
+      'floods': {
+        main: 'import { writeSync } from "node:fs"\n'
+          + 'const put = (text) => { let rest = Buffer.from(text); while (rest.length > 0) { try { rest = rest.subarray(writeSync(2, rest)) } catch (error) { if (error.code !== "EAGAIN") throw error } } }\n'
+          + 'put("x".repeat(200_000))\nput("tail-marker")\nprocess.exit(3)\n',
+      },
     })
     const forged = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'forges' })
     expect(forged).toMatchObject({ kind: 'library', ok: true })

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

@@ -57,6 +57,12 @@ 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
   /** Deployer overrides of each external bundle's mount stage, by package name. */