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

feat(boot): isolate external bundles and expose the booted profile

Mount every external bundle layer as a cordis:contained-group entry
`bundle/<package>` whose rows carry a `<package>/<id>` prefix, so one
community bundle that no longer loads records a failure in the root
`pluginFailures` registry instead of rejecting the whole Loader
transaction, and two bundles may declare the same row id. Classify
layers by origin (`builtin` for the template bundle and
`dsh.profile.firstParty`, `external` for profile dependencies) and let
`dsh.bundle.stage: boot` or `dsh.profile.stages` opt a bundle out of
isolation when it provides a service built-in rows inject; a built-in
row left pending beside an isolated bundle still fails boot and names
the bundle.

Keep `installFailLoud` for the boot transaction only and install
runtime guards afterwards: an unhandled rejection is reported and
contained, an uncaught exception is reported and exits. Report nested
fiber failures under built-in entries as advisory lines.

Provide `ctx.profileRuntime` from the launcher (layers with trust and
stage, row origin, user-disabled rows, recomposition through the root
include) and probe installed packages in a child process
(`probePackage`, cached under `profiles/<name>/.dsh-plugins/`). Split
installation from enablement in `reconcileInstalledBundles`; the CLI
keeps install-means-enable. The plugin inventory now reports `trust`,
`package`, `disabledBy`, and `failure` per row and lists rows only the
failure registry knows.
Yichen Jiang 2 недель назад
Родитель
Сommit
d419e7e553
52 измененных файлов с 2455 добавлено и 99 удалено
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml
  2. 35 0
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
  3. 35 0
      .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml
  5. 37 0
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
  6. 37 0
      .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md
  7. 13 58
      apps/cli/src/plugin.ts
  8. 48 6
      apps/cli/src/profile-boot.ts
  9. 3 0
      apps/web/tests/scaffold.ts
  10. 2 2
      docs/architecture.i18n.yaml
  11. 2 0
      docs/architecture.md
  12. 2 0
      docs/architecture.zh.md
  13. 2 2
      docs/capability-seams.i18n.yaml
  14. 6 0
      docs/capability-seams.md
  15. 6 0
      docs/capability-seams.zh.md
  16. 2 2
      docs/module-graph.i18n.yaml
  17. 2 1
      docs/module-graph.md
  18. 2 1
      docs/module-graph.zh.md
  19. 2 2
      docs/subsystems/core.i18n.yaml
  20. 37 0
      docs/subsystems/core.md
  21. 37 0
      docs/subsystems/core.zh.md
  22. 2 2
      docs/user/develop/basic/publish.i18n.yaml
  23. 2 0
      docs/user/develop/basic/publish.md
  24. 2 0
      docs/user/develop/basic/publish.zh.md
  25. 2 2
      packages/boot/app-boot/README.i18n.yaml
  26. 17 3
      packages/boot/app-boot/README.md
  27. 17 3
      packages/boot/app-boot/README.zh.md
  28. 168 0
      packages/boot/app-boot/src/contained-group.ts
  29. 278 0
      packages/boot/app-boot/src/external-bundles.ts
  30. 144 4
      packages/boot/app-boot/src/index.ts
  31. 312 0
      packages/boot/app-boot/src/probe.ts
  32. 167 0
      packages/boot/app-boot/src/profile-runtime.ts
  33. 63 1
      packages/boot/app-boot/src/profile.ts
  34. 3 1
      packages/boot/app-boot/tests/app-boot.spec.ts
  35. 247 0
      packages/boot/app-boot/tests/contained-group.spec.ts
  36. 196 0
      packages/boot/app-boot/tests/external-bundles.spec.ts
  37. 191 0
      packages/boot/app-boot/tests/probe.spec.ts
  38. 113 0
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  39. 57 0
      packages/boot/app-boot/tests/profile.spec.ts
  40. 33 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  41. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  42. 3 1
      packages/host/plugin-inventory/README.md
  43. 3 1
      packages/host/plugin-inventory/README.zh.md
  44. 9 4
      packages/host/plugin-inventory/package.json
  45. 43 1
      packages/host/plugin-inventory/src/index.ts
  46. 32 0
      packages/host/plugin-inventory/src/types.ts
  47. 6 0
      packages/host/plugin-inventory/tests/inventory.spec.ts
  48. 3 0
      packages/host/plugin-inventory/tsconfig.json
  49. 3 0
      packages/test-support/loader-smoke/tests/fixtures/production-profile.ts
  50. 3 0
      pnpm-lock.yaml
  51. 4 0
      scripts/gen-cordis-catalog.ts
  52. 8 0
      scripts/gen-doc-graphs.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.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-04-boot-scoped-fail-loud-and-package-probe.md
+2026-09-04-boot-scoped-fail-loud-and-package-probe.md: b97bfb57ccda65aa69832db4a9772e03853328a6
+2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: 0dffeb0699d8fa847110f33e1579dd4ab1227767

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

@@ -0,0 +1,35 @@
+# Agent Note: Fail-loud is boot-scoped, and packages are probed in a child process
+
+Status: implemented
+
+English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md)
+
+## Problem
+
+`installFailLoud` registered a process-wide `unhandledRejection` handler that wrote `fatal load failure` and exited, and the launcher discarded the uninstaller it returned, so the handler lived for the whole process. That is right during startup, where an unhandled rejection is a load failure nobody else will report. After boot it meant that any plugin's stray continuation — one rejected promise a community bundle forgot to await — took every session down with it, and no `uncaughtException` handler existed at all, so a synchronous throw in a timer callback crashed the process with Node's default trace and no origin. Mounting third-party code at runtime, which the plugin manager exists to do, was a bet on the process with these two defaults in place.
+
+Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried.
+
+## Decision
+
+**Fail-loud ends when the tree is up.** The launcher keeps `installFailLoud`'s uninstaller and calls it once `boot()` returns, then installs `installRuntimeGuards`: an unhandled rejection after boot is reported to stderr as contained and the process keeps running; an uncaught exception is reported with its origin and the process exits, as Node would, because its state is unknown. Both handlers are removed on shutdown.
+
+**Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean.
+
+**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 that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and reports whether each is a plugin and what `Config.toJSON()` it carries. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; a package whose cordis resolves elsewhere than the harness's own is not enableable. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
+
+## 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 contains now; attribution needs an async-context seam.
+
+**Import the package in the host to learn its shape.** Rejected: import runs code with the host's privileges before the user enabled anything, and a hang or a `process.exit` in a package's module scope would be the host's.
+
+**Keep `installFailLoud` process-wide and ask plugin authors to be careful.** Rejected: the harness cannot review the code it lets users install, and the cost of one missed `await` was every session.
+
+## Consequences
+
+A stray rejection from any plugin after boot is a stderr line, not an exit; a misbehaving package is diagnosed by its probe record before it is enabled. The probe adds one child process per install and per version change, with a 20-second default bound. Runtime attribution of rejections to plugins remains open, so the plugin list cannot yet mark the plugin a reported rejection came from.
+
+## Testing
+
+`packages/boot/app-boot/tests/contained-group.spec.ts` pins `installRuntimeGuards` on a fake process (report without exit for a rejection, exit for an exception, uninstall) and against the real process, and `warnNestedFiberFailures` on a booted tree with a throwing `ctx.inject()` continuation under a built-in entry and under a contained one. `tests/probe.spec.ts` stages packages under a temporary profile: a bundle with gated, anonymous, and nested rows and an override, a plain plugin, a library, a package that throws at import, one that carries its own cordis copy, one whose addable module is missing, a hanging child, an exiting child, an unresolvable package, and a bad Node executable; and the cache round-trip with version mismatch and a corrupt record.

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

@@ -0,0 +1,35 @@
+# Agent Note:fail-loud 只在启动期,包在子进程里探测
+
+Status: implemented
+
+[English](2026-09-04-boot-scoped-fail-loud-and-package-probe.md) | 中文
+
+## 问题
+
+`installFailLoud` 注册了一个进程级的 `unhandledRejection` 处理器,写出 `fatal load failure` 后退出,而 launcher 丢弃了它返回的卸载函数,于是这个处理器活到进程结束。启动期间这是对的:未处理的 rejection 就是没人会报告的加载失败。启动之后它意味着任何插件的漏网延续——社区组合包忘了 await 的一个被拒 promise——都会把每个会话一起拖下去,而且根本没有 `uncaughtException` 处理器,定时器回调里的一次同步 throw 会让进程带着 Node 的默认堆栈崩掉,没有来源。插件管理器存在的目的正是在运行时挂载三方代码,在这两个默认行为之下这是拿进程赌。
+
+另外,不把包 import 进宿主就没法知道一个已安装的包是什么:它是声明了组合包层还是导出了插件,它的 patch 会插入哪些行,它把 `@deepseek-ai/cordis` 解析到 harness 的那份还是自己的一份——在 profile 的 hoisted linker 与 `autoInstallPeers: false` 之下这才是"依赖冲突"的真实形态——以及它的主导出带什么 `Config` schema。
+
+## 决定
+
+**fail-loud 在树起来时结束。** launcher 保留 `installFailLoud` 的卸载函数,在 `boot()` 返回后调用它,然后安装 `installRuntimeGuards`:启动后未处理的 rejection 以"已兜住"报告到 stderr,进程继续运行;未捕获的异常连同来源一起报告,进程退出,如 Node 所做,因为其状态未知。两个处理器在关闭时移除。
+
+**嵌套失败被报告,暂不致命。** `warnNestedFiberFailures` 遍历每个 runtime 的 fiber,报告属于内置条目却不是该条目根 fiber 的 `FAILED` fiber——抛错的 `ctx.inject()` 延续,Loader 给它盖了条目的章,而激活审计从未看见它。它在启动后以提示行运行;确认随附组合没有这类失败后再并入致命审计。
+
+**探针在伤不到宿主的地方运行包。** `probePackage` 在宿主里读取已安装包的 manifest——从 `dsh.bundle` 得到种类、其 patch 的行与覆盖、`dsh.plugins` 声明、`engines.dsh`、标题与描述——并生成一个 Node 子进程:从该包解析 `@deepseek-ai/cordis`,import 主导出与每个声明为可添加的模块,报告各自是否为插件以及携带的 `Config.toJSON()`。抛错、退出或挂起的子进程得到带原因的 `ok: false`,或点名超时的 rejection;cordis 解析到 harness 自有副本之外的包不可启用。记录缓存在 profile 的 `.dsh-plugins/` 下,按版本失效。
+
+## 考虑过的替代方案
+
+**把运行时 rejection 归属到产生它的插件并把该插件标为失败。** 正确的终态,但 promise 不携带 fiber,cordis 的 effect 包装也只覆盖插件经由它注册的东西。延后:守卫现在只报告并兜住;归属需要一个 async-context seam。
+
+**在宿主里 import 包来了解其形态。** 否决:import 会在用户启用任何东西之前以宿主权限运行代码,包的模块作用域里的一次挂起或 `process.exit` 就是宿主的。
+
+**保持 `installFailLoud` 进程级并要求插件作者小心。** 否决:harness 无法审查它允许用户安装的代码,而漏掉一个 `await` 的代价是每个会话。
+
+## 后果
+
+启动后任何插件的漏网 rejection 是一行 stderr 而不是退出;行为不当的包在启用前就由其探针记录诊断出来。探针每次安装及每次版本变化增加一个子进程,默认上限 20 秒。把运行时 rejection 归属到插件仍是开放问题,因此插件列表暂时不能标出报告的 rejection 来自哪个插件。
+
+## 测试
+
+`packages/boot/app-boot/tests/contained-group.spec.ts` 在伪进程上钉住 `installRuntimeGuards`(rejection 报告不退出、异常退出、卸载),也对真实进程验证;并在启动后的树上钉住 `warnNestedFiberFailures`:内置条目下与受控条目下各有一个抛错的 `ctx.inject()` 延续。`tests/probe.spec.ts` 在临时 profile 下布置包:带门控行、匿名行、嵌套行与一条覆盖的组合包,普通插件,库,import 时抛错的包,自带 cordis 副本的包,可添加模块缺失的包,挂起的子进程,退出的子进程,不可解析的包,以及错误的 Node 可执行文件;还有缓存往返、版本不匹配与损坏记录。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.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-04-external-bundles-as-contained-groups.md
+2026-09-04-external-bundles-as-contained-groups.md: e9bca676ed6d45ed88b1f4124c62c22c676b45c4
+2026-09-04-external-bundles-as-contained-groups.zh.md: bb360e61e6ec9aa71b84895514937e5465119c9c

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md

@@ -0,0 +1,37 @@
+# Agent Note: External bundles mount as contained, prefixed groups
+
+Status: implemented
+
+English | [中文](2026-09-04-external-bundles-as-contained-groups.zh.md)
+
+## Problem
+
+A bundle installed with `dsh plugin add` mounted its rows exactly like the installation's own: appended to the root entry list, under the ids its patch declared, inside the one Loader transaction that builds the tree. Three consequences followed from the vendored Loader's shape. `EntryGroup.update` is all-or-nothing — one rejected row rolls the group back and rethrows — so a community plugin that no longer compiled against the current harness stopped every `dsh` surface from starting, and the diagnostic named the row, not the bundle. Entry ids are unique per tree (`tree.store`), and a `create()` that finds an existing id moves that entry under the new group and replaces its options, so two bundles that both inserted `id: hello` made the second silently take over the first. Nothing recorded which layer had inserted a row, so the plugin list could not tell a built-in row from an installed one, nor a row the user disabled from one the composition gated.
+
+## Decision
+
+**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, holding the bundle's inserted rows with their ids prefixed `<package>/<id>`; inserts into a built-in group nest their own contained group inside the target, and the bundle's id-targeted patches are rewritten to the prefixed ids when they address its own rows and passed through when they address built-in rows. `/` rather than `:` because `:` is the Loader's nested-id separator.
+
+**The contained group isolates row failures.** `ContainedGroup extends Group` overrides `create()`, the one per-row step the transactional `update()` awaits: a rejected row is recorded on the root's `pluginFailures` registry — tree-wide id, declared row id, module, group, stage parsed from the Loader's wrapper, message — and the group activates without it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. One fail-safe closes the corner case where isolation would hide a broken core: a built-in row left pending while any bundle is isolated still fails the boot, and the diagnostic names the isolated bundles and the `stage: boot` escape.
+
+**`stage: boot` is the explicit opt-out.** A bundle whose rows provide a service built-in rows inject declares `dsh.bundle.stage: boot` in its manifest, or the deployer sets `dsh.profile.stages` in the profile manifest, which wins; such a layer mounts unwrapped and unprefixed with fatal semantics. An unknown stage value fails profile loading.
+
+**Installed and enabled are two facts.** `reconcileInstalledBundles` no longer appends every bundle-declaring dependency to `dsh.profile.bundles` unconditionally; `autoEnable` keeps the CLI's install-and-enable semantics, and `enableBundle`/`disableBundle` are the manifest operations a plugin manager calls. `dependencies` records the install, `bundles` the enabled layers.
+
+**Provenance is a launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the booted profile, attributes each row to the layer that inserted it (`originOf`, prefixed ids included), reads which rows the user patch files disable with a literal `disabled: true`, and recomposes the tree through the root include — the same path the patch watchers take. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing rows the registry alone knows.
+
+## Alternatives considered
+
+**Add an `optional` field to `EntryOptions` and teach the vendored `EntryGroup.update` to skip it.** Precise, but a vendored divergence to log and re-apply on every sync, a new metadata field for `verify-cordis-config` to admit, and it would still not give a bundle an identity in the tree. Rejected: the Loader's own `builtins` seat already lets the launcher substitute the group class, and one group per bundle is the identity every later feature — a package-level switch, per-package failure reporting — needs.
+
+**A second boot phase for external bundles, mounted by a runtime plugin after the built-in tree is up.** Cleanest isolation and no composition rewriting, but every bundle that provides a built-in seam service would need an explicit escape anyway, sessions could start before the second phase landed, and the browser roster would need recomputation. Rejected as the larger change for the same outcome; the contained group keeps one boot transaction.
+
+**Keep ids unprefixed and rely on authors choosing unique ids.** Rejected because the failure mode is silent takeover, not an error.
+
+## Consequences
+
+A community bundle that breaks on a harness upgrade no longer stops `dsh`; the plugin list shows the failed row with its stage and message, and the process keeps serving. Two bundles may declare the same row id. A user patch that targets an external bundle's row must name the prefixed id, and `--dump-config` shows the group; a `!!js` disabled expression that compared `e.options.id` to the bundle's own unprefixed id no longer matches — comparing `e.options.name` is the documented form. A bundle's override of a built-in row stays outside isolation, because it edits that row in place. The nested-fiber audit that would catch a failed `ctx.inject()` continuation under a built-in entry ships as advisory lines; making it fatal waits on a pass over shipped compositions.
+
+## Testing
+
+`packages/boot/app-boot/tests/external-bundles.spec.ts` pins the composition (grouping, prefixing, patch rewriting, nested inserts, no mutation of the layer's patches) and the manifest operations. `tests/contained-group.spec.ts` boots real trees: a failing contained row is recorded while its siblings and the built-in rows run, a pending contained row is recorded, a built-in failure still rejects, a built-in row left pending beside an isolated bundle rejects and names it, and a contained row that fails on a later reload is recorded by the audit. `tests/profile.spec.ts` pins trust and stage resolution including the deployer override and the unknown-stage rejection; `tests/profile-runtime.spec.ts` pins provenance and recomposition; `packages/host/plugin-inventory/tests/inventory.spec.ts` pins the new row fields.

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md

@@ -0,0 +1,37 @@
+# Agent Note:外部组合包挂载为受控的、带前缀的组
+
+Status: implemented
+
+[English](2026-09-04-external-bundles-as-contained-groups.md) | 中文
+
+## 问题
+
+用 `dsh plugin add` 安装的组合包,其行的挂载方式与安装自带的行完全一样:追加到根条目列表,用它的 patch 声明的 id,位于构建整棵树的那一个 Loader 事务里。vendored Loader 的形态由此带来三个后果。`EntryGroup.update` 是整组事务——一行被拒就回滚整组并重新抛出——于是一个不再能在当前 harness 上编译的社区插件会让每一个 `dsh` 表面都起不来,而诊断信息点名的是行,不是组合包。entry id 在整棵树内唯一(`tree.store`),`create()` 发现已有 id 时会把那个 entry 挪到新组之下并替换其 options,于是两个都插入 `id: hello` 的组合包,第二个会静默接管第一个。没有任何记录说明某一行由哪一层插入,因此插件列表既分不出内置行与已安装行,也分不出用户停用的行与组合门控的行。
+
+## 决定
+
+**每个外部组合包就是一个组。** profile launcher 按来源给每一层分类:作为 profile 的 pnpm 依赖存在的组合包是 `external`,模板组合包或 profile 在 `dsh.profile.firstParty` 下列出的是 `builtin`。`composeExternalLayer` 把 `runtime` 阶段的外部层渲染成一个 `cordis:contained-group` 条目 `bundle/<package>`,其中放着组合包插入的行,id 带 `<package>/<id>` 前缀;插入内置组的行在目标组内嵌套自己的受控组,组合包按 id 定位的 patch 在指向自己的行时改写为前缀后的 id,在指向内置行时原样通过。用 `/` 而不是 `:`,因为 `:` 是 Loader 的嵌套 id 分隔符。
+
+**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
+
+**`stage: boot` 是显式的退出隔离。** 若组合包的行提供内置行所注入的服务,作者在 manifest 里声明 `dsh.bundle.stage: boot`,或部署者在 profile manifest 里设置 `dsh.profile.stages`,后者优先;这样的层不包组、不加前缀、按致命语义挂载。未知的 stage 值让 profile 加载失败。
+
+**安装与启用是两件事。** `reconcileInstalledBundles` 不再无条件把每个声明了组合包的依赖追加进 `dsh.profile.bundles`;`autoEnable` 保留 CLI 装即启用的语义,`enableBundle`/`disableBundle` 是插件管理器调用的 manifest 操作。`dependencies` 记录安装,`bundles` 记录已启用的层。
+
+**来源是 launcher 的服务。** `ProfileRuntime`(`ctx.profileRuntime`)持有已启动的 profile,把每一行归属到插入它的层(`originOf`,含前缀 id),读取用户 patch 文件用字面量 `disabled: true` 停用了哪些行,并经根 include 重新组合整棵树——patch 监视器走的正是同一条路。插件清单读取它与失败注册表,为每一行提供 `trust`、`package`、`disabledBy` 与 `failure`,并列出只有注册表知道的行。
+
+## 考虑过的替代方案
+
+**给 `EntryOptions` 加 `optional` 字段并教 vendored 的 `EntryGroup.update` 跳过它。** 精确,但是一处每次同步都要登记并重新施加的 vendored 分歧,是 `verify-cordis-config` 要放行的新元数据字段,而且仍然不能给组合包在树里一个身份。否决:Loader 自己的 `builtins` 位置已经允许 launcher 替换组类,而"每个组合包一个组"正是之后每个功能——包级开关、按包报告失败——所需要的身份。
+
+**为外部组合包增加第二个启动阶段,由运行时插件在内置树起来之后挂载。** 隔离最干净,也不用改写组合,但每个提供内置 seam 服务的组合包反正都需要显式逃生口,会话可能在第二阶段落地前就开始,浏览器 roster 也要重算。否决:同样的结果却是更大的改动;受控组保住了单一启动事务。
+
+**保持 id 不加前缀,依赖作者自己选唯一 id。** 否决,因为失败形态是静默接管,不是报错。
+
+## 后果
+
+harness 升级后损坏的社区组合包不再让 `dsh` 停下;插件列表显示失败的行及其阶段与消息,进程继续服务。两个组合包可以声明同名行 id。针对外部组合包某行的用户 patch 必须写前缀后的 id,`--dump-config` 会显示该组;把 `e.options.id` 与组合包自己未加前缀 id 比较的 `!!js` disabled 表达式不再匹配——比较 `e.options.name` 是文档化的写法。组合包对内置行的覆盖留在隔离之外,因为它原地修改那一行。能抓住内置条目下失败的 `ctx.inject()` 延续的嵌套 fiber 审计以提示行交付;改为致命要等对随附组合做一轮检查。
+
+## 测试
+
+`packages/boot/app-boot/tests/external-bundles.spec.ts` 钉住组合(分组、前缀、patch 改写、嵌套插入、不改动层自己的 patch)与 manifest 操作。`tests/contained-group.spec.ts` 启动真实的树:受控行失败被记录而其兄弟行与内置行运行,pending 的受控行被记录,内置失败仍然 reject,被隔离组合包旁边停在 pending 的内置行 reject 并点名它,稍后重载时失败的受控行由审计记录。`tests/profile.spec.ts` 钉住 trust 与 stage 的解析,包括部署者覆盖与未知 stage 的拒绝;`tests/profile-runtime.spec.ts` 钉住来源与重新组合;`packages/host/plugin-inventory/tests/inventory.spec.ts` 钉住新的行字段。

+ 13 - 58
apps/cli/src/plugin.ts

@@ -18,9 +18,8 @@ import {
   initProfile,
   PROFILE_TEMPLATES,
   readProfileManifest,
-  resolveBundleDir,
+  reconcileInstalledBundles,
   resolveProfileDir,
-  writeProfileManifest,
   type ProfileManifest,
 } from '@deepseek-ai/dsh-app-boot'
 import { INSTALL_ANCHOR } from './profile-boot.ts'
@@ -28,66 +27,22 @@ import { INSTALL_ANCHOR } from './profile-boot.ts'
 const NAME = 'dsh'
 
 /**
- * Whether a resolved dependency exports a profile patch, i.e. is a bundle.
- * @param packageName - the dependency's package name.
- * @param profileDir - the profile directory (resolution anchor).
- * @returns true when the package manifest declares `dsh.bundle`.
- */
-function exportsPatch(packageName: string, profileDir: string): boolean {
-  let dir: string
-  try {
-    dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
-  } catch {
-    return false // pnpm reported success yet the package is unresolvable — treat as plain
-  }
-  const manifest = readProfileManifest(NAME, dir)
-  return manifest.dsh?.bundle?.patch !== undefined
-}
-
-/**
- * Reconcile `dsh.profile.bundles` against the installed state: pnpm has
- * already written the real installed names (so a git/path/tarball/alias spec
- * on the command line reconciles by its true package name) and materialized
- * the packages. A dependency that resolves to a `dsh.bundle`-declaring
- * package joins the layer stack (appended in dependency order); a
- * dependency-listed name that no longer does — removed, or the installed
- * version dropped the declaration — leaves it. In-box bundles from the
- * profile template are not dependencies and are never touched. Warns once
- * per newly-added bundle-less dependency (a plain library is fine; the
+ * Reconcile `dsh.profile.bundles` against the installed state with the CLI's
+ * install-and-enable semantics: pnpm has already written the real installed
+ * names (so a git/path/tarball/alias spec on the command line reconciles by
+ * its true package name) and materialized the packages, and every newly
+ * installed bundle joins the layer stack. Warns once per newly-added
+ * bundle-less dependency (a plain library or plugin module is fine; the
  * warning is orientation).
  */
 function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
-  const after = readProfileManifest(NAME, profileDir)
-  const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
-  const dependencies = Object.keys(after.dependencies ?? {})
-  const plugins = after.dsh?.profile?.bundles ?? []
-  let changed = false
-  for (const packageName of dependencies) {
-    const isBundle = exportsPatch(packageName, profileDir)
-    if (isBundle && !plugins.includes(packageName)) {
-      plugins.push(packageName)
-      changed = true
-    } else if (!isBundle && !beforeDeps.has(packageName)) {
-      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',
-      )
-    }
-  }
-  const dependencySet = new Set(dependencies)
-  for (const packageName of [...plugins]) {
-    // Only dependency-managed entries are subject to removal; template
-    // bundles (dsh-base and friends) are not dependencies.
-    const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
-    const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
-    if (wasDependency && !stillBundle) {
-      plugins.splice(plugins.indexOf(packageName), 1)
-      changed = true
-    }
+  const outcome = reconcileInstalledBundles(NAME, profileDir, INSTALL_ANCHOR, before, { autoEnable: true })
+  for (const packageName of outcome.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',
+    )
   }
-  if (!changed) return
-  after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
-  writeProfileManifest(profileDir, after)
 }
 
 /**

+ 48 - 6
apps/cli/src/profile-boot.ts

@@ -20,14 +20,20 @@ import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
   composeEntries,
+  composeExternalLayer,
   healProfilesModuleFallback,
   installFailLoud,
+  installRuntimeGuards,
   loadOptionalPatches,
   loadOverlayPatches,
   loadProfile,
   PROFILE_PATCH_FILENAME,
+  ProfileRuntime,
+  rootIncludeEntry,
+  warnNestedFiberFailures,
   watchUserPatches,
   type Profile,
+  type ProfileLayer,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
@@ -143,6 +149,18 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
   ]
 }
 
+/**
+ * The patches one bundle layer contributes. A built-in layer, or an external
+ * layer the profile stages at boot, mounts its patches as written; every other
+ * external layer mounts as one contained, id-prefixed group.
+ * @param layer - the resolved layer.
+ * @returns the layer's patches in application order.
+ */
+export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] {
+  if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches
+  return layer.patches
+}
+
 /**
  * Load `name` and compose its effective patch stack: bundle layers in
  * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
@@ -162,7 +180,7 @@ async function composeProfile(
   await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile })
   const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
-  const bundlePatches = profile.layers.flatMap(layer => layer.patches)
+  const bundlePatches = profile.layers.flatMap(bundleLayerPatches)
   const rows = new Map<string, EntryOptions>()
   for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
     if (typeof row.id === 'string') rows.set(row.id, row)
@@ -218,9 +236,11 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   )
 
   const composed = await composeProfile(options.profile, options.patchFiles)
-  const app: { current?: Context } = {}
+  const app: { current?: Context; runtime?: ProfileRuntime } = {}
   const appReady = createAppReady()
+  let uninstallRuntimeGuards = (): void => {}
   const shutdown = createProcessShutdown(async () => {
+    uninstallRuntimeGuards()
     await app.current?.fiber.dispose()
     await disposeProxy()
   })
@@ -236,7 +256,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // complete; SIGINT is a user interrupt and reports 130.
   process.on('SIGTERM', () => { interrupt(0) })
   process.on('SIGINT', () => { interrupt(130) })
-  installFailLoud(NAME, process, async () => {
+  const uninstallFailLoud = installFailLoud(NAME, process, async () => {
     await app.current?.fiber.dispose()
   })
 
@@ -253,12 +273,15 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   // objects in place. Reusing one parsed patch object across applications
   // would bake a user override into the bundle's in-memory insert row, so
   // removing the override could never revert the row to the bundle default.
-  const composeLive = (): PatchOptions[] => structuredClone([
-    ...composed.bundlePatches,
-    ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
+  const composeFor = (profile: Profile): PatchOptions[] => structuredClone([
+    ...profile.layers.flatMap(bundleLayerPatches),
+    ...loadOptionalPatches(NAME, profile.patchPath) ?? [],
     ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
     ...composed.overlays,
   ])
+  // Once the profile runtime is mounted its profile is the current one: a
+  // bundle enabled since boot lives only there.
+  const composeLive = (): PatchOptions[] => composeFor(app.runtime?.current ?? composed.profile)
   // Cloned for the same insert-aliasing reason as composeLive: the boot
   // application must not mutate the objects later reloads recompose from.
   const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
@@ -275,6 +298,25 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     })
   })
   app.current = ctx
+  // The tree is up: a later unhandled rejection is a plugin's stray
+  // continuation, not a load failure, and must not take every session down.
+  uninstallFailLoud()
+  uninstallRuntimeGuards = installRuntimeGuards(NAME, (line) => { process.stderr.write(`${line}\n`) })
+  if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) {
+    warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
+    await ctx.plugin(ProfileRuntime, {
+      profile: composed.profile,
+      loadProfile: () => prepareProfile(options.profile),
+      compose: composeFor,
+      rootEntry: () => rootIncludeEntry(ctx),
+      readUserPatches: () => [
+        ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
+        ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
+      ],
+    })
+    const runtime = ctx.get('profileRuntime')
+    if (runtime !== undefined) app.runtime = runtime
+  }
   // A live-reload profile can dispose the whole tree while post-boot watcher
   // setup is in flight — a signal or appExit. Loader presence and fiber state
   // own liveness; the initial check skips a tree that already exited, and the

+ 3 - 0
apps/web/tests/scaffold.ts

@@ -635,8 +635,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       const packageDir = dirname(anchor)
       return {
         packageName: manifest.name,
+        version: undefined,
         packageDir,
         patchPath: join(packageDir, 'cordis.patch.yml'),
+        trust: 'builtin' as const,
+        stage: 'runtime' as const,
         patches: [],
       }
     }))

+ 2 - 2
docs/architecture.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/architecture.md
-architecture.md: 959ea9edf8f337b1ebe20df8b998f11fe35beb2e
-architecture.zh.md: 9666f3589ce3710f84306d685ec1c5e7a49d1b78
+architecture.md: d054e99bd988f1555985f80ddc6ee3f3011251d6
+architecture.zh.md: 246546518fea0fbde58f95e09802b814daab4b30

+ 2 - 0
docs/architecture.md

@@ -26,6 +26,8 @@ Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile
 
 Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.
 
+A bundle installed into the profile with `dsh plugin` is external. Its rows mount under one contained group, `bundle/<package>`, with ids prefixed `<package>/<id>`, and a row that fails to start is isolated and reported in the plugin list while built-in rows keep failing the boot. A bundle that provides a service built-in rows inject opts out of isolation with `dsh.bundle.stage: boot`, and an isolated failure that leaves a built-in row waiting still stops the boot and names the bundle. Installing a package and enabling its layer are separate facts: `dependencies` records the install, `dsh.profile.bundles` records the enabled layers.
+
 Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle.
 
 To see the tree your machine boots:

+ 2 - 0
docs/architecture.zh.md

@@ -26,6 +26,8 @@
 
 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。
 
+用 `dsh plugin` 装进 profile 的组合包是外部的。它的行挂在一个受控组 `bundle/<package>` 下,id 带 `<package>/<id>` 前缀;启动失败的行被隔离并在插件列表里报告,而内置行仍然让启动失败。提供内置行所注入服务的组合包用 `dsh.bundle.stage: boot` 退出隔离;隔离的失败若让某个内置行停在等待状态,启动仍会失败并点名该组合包。安装一个包和启用它的层是两件事:`dependencies` 记录安装,`dsh.profile.bundles` 记录已启用的层。
+
 自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。
 
 要查看你的机器启动的配置树:

+ 2 - 2
docs/capability-seams.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/capability-seams.md
-capability-seams.md: 83868afe952c5dbc179114ff52228fc1dc8b8fdb
-capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029
+capability-seams.md: 409315b7f15a124511db1baf9e28ed440006ad72
+capability-seams.zh.md: 33338ea631df091a16c7a5fea79a6cb12d5af092

+ 6 - 0
docs/capability-seams.md

@@ -106,6 +106,9 @@ flowchart LR
   svc_planMode["ctx.planMode<br/>Plan collaboration state"]
   pkg_agent_presets["agent-presets"]
   svc_agentPresets["ctx.agentPresets<br/>Per-session agent composition"]
+  pkg_app_boot["app-boot"]
+  svc_profileRuntime["ctx.profileRuntime<br/>Booted profile facts and recomposition"]
+  pkg_host_plugin_inventory["host-plugin-inventory"]
   pkg_commands["commands"]
   svc_commands["ctx.commands<br/>Human command registry"]
   pkg_session_projection["session-projection"]
@@ -230,6 +233,7 @@ flowchart LR
   pkg_api_settings_controller --> svc_settingsController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
+  pkg_app_boot --> svc_profileRuntime
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
   pkg_authorization --> svc_authorization
@@ -376,6 +380,7 @@ flowchart LR
   svc_llm --> pkg_agent_loop
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
+  svc_profileRuntime --> pkg_host_plugin_inventory
   svc_sandbox --> pkg_bash_sandbox
   svc_sandbox --> pkg_terminal_bash
   svc_sandboxPolicy --> pkg_bash_sandbox
@@ -500,6 +505,7 @@ flowchart LR
 | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. |
+| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. |
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |

+ 6 - 0
docs/capability-seams.zh.md

@@ -108,6 +108,9 @@ flowchart LR
   svc_planMode["ctx.planMode<br/>Plan collaboration state"]
   pkg_agent_presets["agent-presets"]
   svc_agentPresets["ctx.agentPresets<br/>Per-session agent composition"]
+  pkg_app_boot["app-boot"]
+  svc_profileRuntime["ctx.profileRuntime<br/>Booted profile facts and recomposition"]
+  pkg_host_plugin_inventory["host-plugin-inventory"]
   pkg_commands["commands"]
   svc_commands["ctx.commands<br/>Human command registry"]
   pkg_session_projection["session-projection"]
@@ -232,6 +235,7 @@ flowchart LR
   pkg_api_settings_controller --> svc_settingsController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
+  pkg_app_boot --> svc_profileRuntime
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
   pkg_authorization --> svc_authorization
@@ -378,6 +382,7 @@ flowchart LR
   svc_llm --> pkg_agent_loop
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
+  svc_profileRuntime --> pkg_host_plugin_inventory
   svc_sandbox --> pkg_bash_sandbox
   svc_sandbox --> pkg_terminal_bash
   svc_sandboxPolicy --> pkg_bash_sandbox
@@ -502,6 +507,7 @@ flowchart LR
 | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 |
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 |
+| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 |
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 6a5e4232f5a4da309b48c2e7c00dadf09e8b308a
-module-graph.zh.md: 2388a1d9843e5e2fe308e372f9d7db8150b48160
+module-graph.md: 7dfe9a3b312c141e4df4c74f96a78deeade9b718
+module-graph.zh.md: 4e70a6a9baeda7d379672d4aa102806f9e816d3e

+ 2 - 1
docs/module-graph.md

@@ -920,6 +920,7 @@ flowchart TD
   pkg_tool_cordis --> pkg_system_prompt
   pkg_tool_cordis --> pkg_tools
   pkg_host_plugin_inventory --> pkg_agent_presets
+  pkg_host_plugin_inventory --> pkg_app_boot
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_typert_protocol
   pkg_tool_bash --> pkg_agent
@@ -1374,7 +1375,7 @@ flowchart TD
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |

+ 2 - 1
docs/module-graph.zh.md

@@ -922,6 +922,7 @@ flowchart TD
   pkg_tool_cordis --> pkg_system_prompt
   pkg_tool_cordis --> pkg_tools
   pkg_host_plugin_inventory --> pkg_agent_presets
+  pkg_host_plugin_inventory --> pkg_app_boot
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_typert_protocol
   pkg_tool_bash --> pkg_agent
@@ -1376,7 +1377,7 @@ flowchart TD
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |

+ 2 - 2
docs/subsystems/core.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/core.md
-core.md: 2f907062e89b9af12cd1b47d52d195866df62254
-core.zh.md: a5649032b81cf4adf49253f74c46c46beb800969
+core.md: 70d12007ba1675cece6f0ed9c86ac89fcdfebf4e
+core.zh.md: 2bc19d2d1dbea704567de86494da3696d02705bd

+ 37 - 0
docs/subsystems/core.md

@@ -838,6 +838,43 @@ roots(): Agent[]
 
 Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
 
+<a id="ctxprofileruntime--profileruntime"></a>
+
+### `ctx.profileRuntime` — `ProfileRuntime`
+
+Facts and recomposition of the booted profile.
+
+```ts cordis-catalog
+/**
+ * Where one mounted row came from.
+ * @param rowId - the row's tree-wide id.
+ * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row).
+ */
+originOf(rowId: string): RowOrigin | undefined
+
+/**
+ * Row ids the user patch layers disable with a literal `disabled: true`.
+ * A `!!js` gate in a user file is a condition, not a user decision, and is
+ * left to the composition.
+ * @returns the ids, re-read from disk on every call.
+ */
+userDisabledRowIds(): Set<string>
+
+/**
+ * Recompose the host tree from the profile's layers and the user patch files
+ * as they stand now. The root Include re-applies the stack transactionally:
+ * a row whose options changed is updated in place, a row that appeared is
+ * created, a row that vanished is disposed, and a failure rolls the whole
+ * update back with the previous tree still running.
+ * @param options - `reloadBundles` re-reads the profile manifest first, so a
+ * bundle enabled or installed since boot joins the stack.
+ * @throws when the root include is not mounted, or the Loader rejected the update.
+ */
+async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>
+```
+
+Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)
+
 <a id="agent-events"></a>
 
 ### `agent/*` events

+ 37 - 0
docs/subsystems/core.zh.md

@@ -848,6 +848,43 @@ roots(): Agent[]
 
 Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
 
+<a id="ctxprofileruntime--profileruntime"></a>
+
+### `ctx.profileRuntime` — `ProfileRuntime`
+
+Facts and recomposition of the booted profile.
+
+```ts cordis-catalog
+/**
+ * Where one mounted row came from.
+ * @param rowId - the row's tree-wide id.
+ * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row).
+ */
+originOf(rowId: string): RowOrigin | undefined
+
+/**
+ * Row ids the user patch layers disable with a literal `disabled: true`.
+ * A `!!js` gate in a user file is a condition, not a user decision, and is
+ * left to the composition.
+ * @returns the ids, re-read from disk on every call.
+ */
+userDisabledRowIds(): Set<string>
+
+/**
+ * Recompose the host tree from the profile's layers and the user patch files
+ * as they stand now. The root Include re-applies the stack transactionally:
+ * a row whose options changed is updated in place, a row that appeared is
+ * created, a row that vanished is disposed, and a failure rolls the whole
+ * update back with the previous tree still running.
+ * @param options - `reloadBundles` re-reads the profile manifest first, so a
+ * bundle enabled or installed since boot joins the stack.
+ * @throws when the root include is not mounted, or the Loader rejected the update.
+ */
+async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>
+```
+
+Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)
+
 <a id="agent-events"></a>
 
 ### `agent/*` events

+ 2 - 2
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: 89a28e2e44a5171a3f5c48c003f5af5ec2e9e750
-publish.zh.md: 590fee6f035af3009e25bf3669ba4321951f7d07
+publish.md: 816b36ac99695c95332ae99439c1ccfc3c4a8732
+publish.zh.md: c3918913d6cb09b212eae35902a375539bb047cb

+ 2 - 0
docs/user/develop/basic/publish.md

@@ -109,6 +109,8 @@ dsh --profile demo
 
 `dsh plugin --profile demo remove dsh-hello-plugin` removes both the dependency and the layer.
 
+An installed bundle mounts as an external layer: the dump shows its rows inside one group named `bundle/dsh-hello-plugin`, and each row id carries the package prefix, so the row above is `dsh-hello-plugin/hello` in the mounted tree — a user patch that targets it names that id. A row of yours that fails to start is isolated and reported in the plugin list instead of stopping `dsh`; if your bundle provides a service the built-in rows inject, declare `dsh.bundle.stage: boot` so it mounts like a built-in one and fails loud. Prefixing does not rewrite string literals, so a `!!js` disabled expression that compares `e.options.id` to your own id should compare `e.options.name` instead.
+
 ## The loading order
 
 The effective configuration composes over an empty root by applying, in order:

+ 2 - 0
docs/user/develop/basic/publish.zh.md

@@ -109,6 +109,8 @@ dsh --profile demo
 
 `dsh plugin --profile demo remove dsh-hello-plugin` 会同时移除依赖和对应的层。
 
+已安装的组合包作为外部层挂载:dump 会把它的行显示在一个名为 `bundle/dsh-hello-plugin` 的组里,每个行 id 带上包名前缀,因此上面那一行在挂载后的树里是 `dsh-hello-plugin/hello`——针对它的用户 patch 要写这个 id。你的某一行启动失败时会被隔离并在插件列表里报告,而不是让 `dsh` 停下;如果你的组合包提供内置行所注入的服务,请声明 `dsh.bundle.stage: boot`,让它像内置行一样挂载并明确失败。前缀不会改写字符串字面量,因此用 `e.options.id` 与自己 id 比较的 `!!js` disabled 表达式应改为比较 `e.options.name`。
+
 ## 加载顺序
 
 生效配置在空根之上按以下顺序逐层组合:

+ 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: ddbcc900fcbb38e83b35f517f9d38cf0e04927f3
-README.zh.md: 184e1a086d09bf6807fd8fb2d17f7157ba8deaa6
+README.md: 0190a9e1d4c4b6d775aa84ee608c763555acbcce
+README.zh.md: 6b3da08c8cbf5dcc68bf3d4991dda06c170c72af

+ 17 - 3
packages/boot/app-boot/README.md

@@ -54,6 +54,10 @@ Your machine-local preferences also live in the Harness home:
 
 Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
+A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>`, every row id is prefixed `<package>/<id>`, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled.
+
+After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one a runtime bundle enable or install uses. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits.
+
 ### Previewing the effective configuration
 
 Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump.
@@ -81,7 +85,10 @@ This section explains how the outcomes above are realized and points at the code
 ### Design notes
 
 - **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution.
-- **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
+- **Three Loader builtins.** `mountRootInclude` registers `cordis:include`, `cordis:group`, and `cordis:contained-group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name, and the contained group is where external bundles mount. All load through the ambient module pipeline rather than the included tree's own specifier resolution.
+- **External bundles are groups.** The vendored `EntryGroup.update` is all-or-nothing and entry ids are unique per tree, so two external bundles that insert the same id would otherwise make the second silently take over the first's entry. `composeExternalLayer` wraps each `runtime`-stage external layer's inserts in one `cordis:contained-group`, prefixes its ids with the package name, and rewrites the bundle's own patches to the prefixed ids; the group's `create()` records a failed row on the root's `pluginFailures` registry instead of rejecting, and `assertEntriesActivated` exempts recorded rows while still failing a built-in row left pending.
+- **Fail-loud is boot-scoped.** `installFailLoud` exits on any unhandled rejection because during startup one is a load failure; the launcher uninstalls it once the tree is up and installs `installRuntimeGuards`, which reports a rejection and keeps running and exits on an uncaught exception. Nested fibers (a `ctx.inject()` continuation) that fail under a built-in entry are reported by `warnNestedFiberFailures` as advisory lines.
+- **The probe never runs a package in the host.** `probePackage` reads an installed package's manifest here and imports it in a child process, so a package that throws, exits, hangs, or brings its own copy of cordis costs one child and yields a record with the reason.
 - **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links.
 - **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain.
@@ -94,8 +101,12 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section |
-| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback |
+| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit, patch parsing, config dump, harness-source section |
+| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with trust and stage, module fallback |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, id prefixing, patch rewriting) and the manifest operations behind install, enable, and disable |
+| [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry |
+| [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: profile facts, row provenance, user-disabled rows, recomposition |
+| [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache |
 | — | No runtime invariant companion is published; this presentation adapter owns no durable package-local event stream; boundary and replay tests cover its protocol mapping. |
 
 </details>
@@ -137,6 +148,9 @@ These limits describe when this boot library is a poor fit or needs special care
 - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
 - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins.
 - **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps.
+- **An external bundle's overrides are not isolated** — a patch it applies to a built-in row edits that row in place, so its effect stays when the bundle's own rows fail and it is the one thing a bundle can break outside its group.
+- **Id prefixing does not rewrite `!!js` literals** — a bundle whose disabled expression compares `e.options.id` to its own unprefixed id keeps the literal; compare `options.name` instead.
+- **The nested-fiber audit is advisory** — a failed `ctx.inject()` continuation under a built-in entry is reported, not fatal, until shipped compositions are known clean.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 17 - 3
packages/boot/app-boot/README.zh.md

@@ -54,6 +54,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
 
+用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,每个行 id 都加上 `<package>/<id>` 前缀,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。
+
+树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,运行时启用或安装组合包也走它。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
+
 ### 预览生效配置
 
 启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。
@@ -81,7 +85,10 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 ### 设计说明
 
 - **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。
-- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
+- **三个 Loader builtin。** `mountRootInclude` 把 `cordis:include`、`cordis:group` 与 `cordis:contained-group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`,受控组则是外部组合包挂载的位置。三者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
+- **外部组合包即组。** vendored 的 `EntryGroup.update` 是整组事务,entry id 在整棵树内唯一,因此两个插入同名 id 的外部组合包会让第二个静默接管第一个的 entry。`composeExternalLayer` 把每个 `runtime` 阶段外部层的插入行包进一个 `cordis:contained-group`,给 id 加上包名前缀,并把组合包自己的 patch 改写到前缀后的 id;该组的 `create()` 把失败的行记录到根上的 `pluginFailures` 注册表而不是 reject,`assertEntriesActivated` 豁免已记录的行,但内置行停在 pending 时仍然失败。
+- **fail-loud 只在启动期。** `installFailLoud` 对任何未处理 rejection 退出,因为启动期间它就是加载失败;树起来后 launcher 卸载它并安装 `installRuntimeGuards`:rejection 被报告并继续运行,未捕获异常被报告并退出。内置条目下失败的嵌套 fiber(`ctx.inject()` 的延续)由 `warnNestedFiberFailures` 以提示行报告。
+- **探针从不在宿主内运行包。** `probePackage` 在本进程读取已安装包的 manifest,在子进程里 import 它,因此抛错、退出、挂起或自带 cordis 副本的包只消耗一个子进程,得到一条带原因的记录。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
 - **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。
@@ -94,8 +101,12 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 |
-| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 |
+| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计、patch 解析、配置 dump、harness 源码段落 |
+| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)与安装、启用、停用背后的 manifest 操作 |
+| [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
+| [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:profile 事实、行来源、用户停用的行、重新组合 |
+| [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 
 </details>
@@ -137,6 +148,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
 - **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
 - **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
+- **外部组合包的覆盖不被隔离**——它对内置行施加的 patch 原地修改那一行,所以即使该组合包自己的行失败,效果仍然保留;这是组合包唯一能在自己的组之外造成影响的地方。
+- **id 前缀不改写 `!!js` 字面量**——组合包的 disabled 表达式若拿 `e.options.id` 与自己未加前缀的 id 比较,字面量保持原样;请改为比较 `options.name`。
+- **嵌套 fiber 审计只是提示**——内置条目下失败的 `ctx.inject()` 延续会被报告而非致命,直到确认随附组合都没有这类失败。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 168 - 0
packages/boot/app-boot/src/contained-group.ts

@@ -0,0 +1,168 @@
+/**
+ * The contained group: a Loader group whose rows fail individually instead of
+ * failing the group. The vendored `EntryGroup.update` is all-or-nothing — one
+ * rejected row rolls the whole group back and rethrows, which at boot unwinds
+ * the whole tree. External bundles mount under this group so their startup
+ * failures are recorded and reported rather than fatal, while built-in rows
+ * keep the fatal path.
+ * @module @deepseek-ai/dsh-app-boot/contained-group
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+
+/** The lifecycle step at which a contained row failed. */
+export type ContainedFailureStage = 'import' | 'apply' | 'inject-pending' | 'unknown'
+
+/** One recorded failure of a row inside a contained group. */
+export interface ContainedFailure {
+  /** The row's tree-wide id, as `Entry.id` spells it (`include:<row id>` under the root include). */
+  readonly entryId: string
+  /** The row id as the composition declares it — the key patches and provenance use. */
+  readonly rowId: string
+  /** The module specifier the row named. */
+  readonly moduleName: string
+  /** The contained group the row belongs to. */
+  readonly groupId: string
+  /** Which lifecycle step failed. */
+  readonly stage: ContainedFailureStage
+  /** The failure text, with the Loader's per-row wrapper folded in. */
+  readonly message: string
+}
+
+/**
+ * Failures recorded by contained groups of one runtime. Rows are keyed by
+ * their tree-wide id; recording a row again replaces its earlier record, and a
+ * row that later mounts clears it.
+ */
+export class ContainedFailureRegistry {
+  private readonly failures = new Map<string, ContainedFailure>()
+
+  /**
+   * Record one failure, replacing an earlier record of the same row.
+   * @param failure - the failure to record.
+   */
+  record(failure: ContainedFailure): void {
+    this.failures.set(failure.entryId, failure)
+  }
+
+  /**
+   * Forget one row's failure, for a row that mounted on a later attempt.
+   * @param entryId - the row's tree-wide id.
+   */
+  clear(entryId: string): void {
+    this.failures.delete(entryId)
+  }
+
+  /**
+   * Every recorded failure in record order.
+   * @returns the failures.
+   */
+  list(): ContainedFailure[] {
+    return [...this.failures.values()]
+  }
+
+  /**
+   * One row's failure.
+   * @param entryId - the row's tree-wide id.
+   * @returns the record, or undefined when the row is not failed.
+   */
+  get(entryId: string): ContainedFailure | undefined {
+    return this.failures.get(entryId)
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Failures recorded by contained groups; provided on the root by the boot glue. */
+    pluginFailures?: ContainedFailureRegistry
+  }
+}
+
+/**
+ * Parse the Loader's `failed to <stage> loader entry` wrapper into a stage.
+ * Every row failure reaches {@link ContainedGroup.create} through that
+ * wrapper, so `unknown` is the guard for a message shape this build has not
+ * seen rather than a path a row can take.
+ */
+function stageOf(message: string): ContainedFailureStage {
+  const match = /^failed to (import|apply) loader entry /.exec(message)
+  /* v8 ignore next -- the Loader wraps every row failure with one of the two stages, so the `unknown` arm is unreachable */
+  return match === null ? 'unknown' : match[1] === 'import' ? 'import' : 'apply'
+}
+
+/**
+ * A group that contains its rows' startup failures. `create()` is the one
+ * per-row step `EntryGroup.update` awaits, so catching there is what turns a
+ * row failure from a group rejection into a record: the group activates, the
+ * failed row is absent from the tree, and the record names it.
+ */
+export class ContainedGroup extends Group {
+  override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
+    try {
+      const id = await super.create(options)
+      this.registry()?.clear(id)
+      return id
+    } catch (error) {
+      const registry = this.registry()
+      // `ensureId` assigned the id before the row started, so a failed row
+      // still carries the id the tree would have used.
+      const rowId = (options as EntryOptions).id
+      // The same spelling `Entry.id` gives a live row: prefixed with the
+      // owning include's id when this tree is an included one.
+      const treeOwner = this.tree.ctx.fiber.entry
+      const entryId = treeOwner === undefined ? rowId : `${treeOwner.id}:${rowId}`
+      /* v8 ignore next -- the Loader wraps a row's thrown value in an Error before it reaches here */
+      const message = error instanceof Error ? error.message : String(error)
+      registry?.record({
+        entryId,
+        rowId,
+        moduleName: options.name,
+        groupId: this.groupId(),
+        stage: stageOf(message),
+        message,
+      })
+      this.ctx.logger.warn(`contained group ${this.groupId()}: row ${rowId} failed and was isolated: ${message}`)
+      return rowId
+    }
+  }
+
+  /** The registry provided on the runtime root, if the boot glue provided one. */
+  private registry(): ContainedFailureRegistry | undefined {
+    return this.ctx.get('pluginFailures')
+  }
+
+  /** This group's own entry id, as the tree names it. */
+  private groupId(): string {
+    /* v8 ignore next -- a group always runs as a Loader entry; the fallback keeps a direct `ctx.plugin(ContainedGroup)` readable */
+    return this.ctx.fiber.entry?.id ?? '(unknown group)'
+  }
+}
+
+/**
+ * Whether an entry sits inside a contained group, at any depth.
+ * @param entry - the Loader entry to classify.
+ * @returns true when some owning group is a {@link ContainedGroup}.
+ */
+export function isContainedEntry(entry: Entry): boolean {
+  let group: EntryGroup | undefined = entry.parent
+  while (group !== undefined) {
+    if (group instanceof ContainedGroup) return true
+    group = group.ctx.fiber.entry?.parent
+  }
+  return false
+}
+
+/**
+ * The contained failure registry of a runtime, creating and providing one on
+ * the root the first time it is asked for.
+ * @param ctx - any context of the runtime.
+ * @returns the registry.
+ */
+export function ensurePluginFailures(ctx: Context): ContainedFailureRegistry {
+  const existing = ctx.get('pluginFailures')
+  if (existing !== undefined) return existing
+  const registry = new ContainedFailureRegistry()
+  ctx.root.provide('pluginFailures', registry)
+  return registry
+}

+ 278 - 0
packages/boot/app-boot/src/external-bundles.ts

@@ -0,0 +1,278 @@
+/**
+ * External bundle composition and the profile-manifest operations behind
+ * installing, enabling, and disabling bundles.
+ *
+ * An external (`runtime` stage) bundle never mounts its rows directly into the
+ * built-in tree. Its inserted rows are wrapped in one contained group per
+ * bundle, each row id is prefixed with the package name, and the bundle's own
+ * patches that address those rows are rewritten to the prefixed ids. Two
+ * facts of the vendored Loader make this necessary: entry ids are unique per
+ * tree (`tree.store`), so two bundles inserting the same id would otherwise
+ * make the second silently take over the first's entry; and a group is the
+ * unit the Loader rolls back, so one group per bundle is what makes a bundle
+ * fail as a whole rather than half-mount.
+ * @module @deepseek-ai/dsh-app-boot/external-bundles
+ */
+
+import { join } from 'node:path'
+import { isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+
+/**
+ * Whether a `disabled` node is a `!!js` expression rather than a literal.
+ * @param value - the raw `disabled` node of a row or patch.
+ * @returns true for an expression node.
+ */
+export function isJsDisabled(value: unknown): boolean {
+  return isJsExpr(value)
+}
+import {
+  readProfileManifest, resolveBundleDir, writeProfileManifest, type ProfileLayer, type ProfileManifest,
+} from './profile.ts'
+
+/** Loader builtin name of the contained group every `runtime` bundle mounts under. */
+export const CONTAINED_GROUP_MODULE = 'cordis:contained-group'
+
+/** Prefix of the group id one external bundle's rows mount under. */
+export const BUNDLE_GROUP_PREFIX = 'bundle/'
+
+/**
+ * The group id one external bundle's rows mount under. `/` rather than `:`
+ * because `:` is the Loader's nested-id separator (`EntryTree.sep`), and an
+ * id containing it would not resolve through `loader.update(id, …)`.
+ * @param packageName - the bundle's package name.
+ * @returns the group entry id.
+ */
+export function bundleGroupId(packageName: string): string {
+  return `${BUNDLE_GROUP_PREFIX}${packageName}`
+}
+
+/**
+ * The tree-wide id of one row an external bundle inserted.
+ * @param packageName - the bundle's package name.
+ * @param rowId - the id the bundle's own patch declares.
+ * @returns the prefixed id the mounted tree uses.
+ */
+export function externalRowId(packageName: string, rowId: string): string {
+  return `${packageName}/${rowId}`
+}
+
+/** Where one mounted row came from, keyed by its tree-wide id. */
+export interface ExternalRowOrigin {
+  /** The bundle that inserted the row. */
+  packageName: string
+  /** The id the bundle's own patch declared, before prefixing. */
+  originalId: string
+}
+
+/** One external layer rendered as the patches the tree mounts. */
+export interface ComposedExternalLayer {
+  /** Patches in application order: the group insert first, then the bundle's rewritten patches. */
+  patches: PatchOptions[]
+  /** Every row the bundle inserted, by prefixed id. */
+  rows: Map<string, ExternalRowOrigin>
+  /** Built-in row ids the bundle overrides; not containable, reported for visibility. */
+  overrides: string[]
+}
+
+/** Deep-clone one entry row and prefix its id and, for a group, its children's ids. */
+function prefixRow(packageName: string, row: EntryOptions, rows: Map<string, ExternalRowOrigin>, ownIds: Set<string>): EntryOptions {
+  const cloned = structuredClone(row)
+  if (typeof cloned.id === 'string') {
+    ownIds.add(cloned.id)
+    const prefixed = externalRowId(packageName, cloned.id)
+    rows.set(prefixed, { packageName, originalId: cloned.id })
+    cloned.id = prefixed
+  }
+  if (cloned.group && Array.isArray(cloned.config)) {
+    cloned.config = (cloned.config as EntryOptions[]).map(child => prefixRow(packageName, child, rows, ownIds))
+  }
+  return cloned
+}
+
+/**
+ * Render one external bundle layer as contained, prefixed patches. Root
+ * inserts become the children of the bundle's group; inserts into a named
+ * group the bundle itself introduced follow the prefixed id; inserts into a
+ * built-in group are nested in their own contained group inside that target;
+ * an id-targeted patch is rewritten when it addresses a row this bundle
+ * inserted and passed through unchanged when it addresses a built-in row.
+ * @param layer - the resolved external layer.
+ * @returns the patches to mount and the provenance of every inserted row.
+ */
+export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
+  const { packageName } = layer
+  const rows = new Map<string, ExternalRowOrigin>()
+  const ownIds = new Set<string>()
+  const groupRows: EntryOptions[] = []
+  const trailing: PatchOptions[] = []
+  const overrides: string[] = []
+  // First pass: inserts, so the ids this bundle introduces are known before
+  // its id-targeted patches are classified.
+  for (const patch of layer.patches) {
+    if (patch.insert === undefined) continue
+    const inserted = patch.insert.map(row => prefixRow(packageName, row, rows, ownIds))
+    if (patch.id === undefined) {
+      groupRows.push(...inserted)
+      continue
+    }
+    if (ownIds.has(patch.id)) {
+      trailing.push({ id: externalRowId(packageName, patch.id), insert: inserted })
+      continue
+    }
+    const nestedId = `${bundleGroupId(packageName)}/in/${patch.id}`
+    rows.set(nestedId, { packageName, originalId: nestedId })
+    trailing.push({
+      id: patch.id,
+      insert: [{ id: nestedId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }],
+    })
+  }
+  for (const patch of layer.patches) {
+    if (patch.insert !== undefined || patch.id === undefined) continue
+    if (ownIds.has(patch.id)) {
+      trailing.push({ ...structuredClone(patch), id: externalRowId(packageName, patch.id) })
+      continue
+    }
+    overrides.push(patch.id)
+    trailing.push(structuredClone(patch))
+  }
+  const groupId = bundleGroupId(packageName)
+  rows.set(groupId, { packageName, originalId: groupId })
+  const group: EntryOptions = { id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: groupRows }
+  return { patches: [{ insert: [group] }, ...trailing], rows, overrides }
+}
+
+/**
+ * Whether an installed dependency exports a profile patch, i.e. is a bundle.
+ * @param binName - the diagnostic prefix used by manifest reads.
+ * @param packageName - the dependency's package name.
+ * @param installAnchor - absolute path of the dsh app's package.json.
+ * @param profileDir - the profile directory (second resolution anchor).
+ * @returns true when the package manifest declares `dsh.bundle`.
+ */
+export function exportsBundlePatch(
+  binName: string, packageName: string, installAnchor: string, profileDir: string,
+): boolean {
+  let dir: string
+  try {
+    dir = resolveBundleDir(binName, packageName, installAnchor, profileDir)
+  } catch {
+    return false // pnpm reported success yet the package is unresolvable — treat as plain
+  }
+  return readProfileManifest(binName, dir).dsh?.bundle?.patch !== undefined
+}
+
+/** Outcome of one reconciliation of the bundle layer list against installed state. */
+export interface BundleReconciliation {
+  /** Bundles newly enabled (appended to `dsh.profile.bundles`). */
+  enabled: string[]
+  /** Bundles removed from the layer list because their dependency is gone or no longer a bundle. */
+  removed: string[]
+  /** Newly added dependencies that declare no `dsh.bundle` (plain libraries or plugin modules). */
+  plain: string[]
+  /** Installed bundles left out of the layer list because `autoEnable` was off. */
+  installedOnly: string[]
+}
+
+/**
+ * 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
+ * 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.
+ * @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.
+ * @param before - the manifest as read before the pnpm run.
+ * @param options - whether newly installed bundles join the layer list.
+ * @returns what changed, after the manifest was written when anything did.
+ */
+export function reconcileInstalledBundles(
+  binName: string,
+  profileDir: string,
+  installAnchor: string,
+  before: ProfileManifest,
+  options: { autoEnable: boolean },
+): BundleReconciliation {
+  const after = readProfileManifest(binName, profileDir)
+  const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
+  const dependencies = Object.keys(after.dependencies ?? {})
+  const bundles = [...after.dsh?.profile?.bundles ?? []]
+  const outcome: BundleReconciliation = { enabled: [], removed: [], plain: [], installedOnly: [] }
+  for (const packageName of dependencies) {
+    const isBundle = exportsBundlePatch(binName, packageName, installAnchor, profileDir)
+    if (isBundle && !bundles.includes(packageName)) {
+      if (options.autoEnable) {
+        bundles.push(packageName)
+        outcome.enabled.push(packageName)
+      } else {
+        outcome.installedOnly.push(packageName)
+      }
+    } else if (!isBundle && !beforeDeps.has(packageName)) {
+      outcome.plain.push(packageName)
+    }
+  }
+  const dependencySet = new Set(dependencies)
+  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) {
+      bundles.splice(bundles.indexOf(packageName), 1)
+      outcome.removed.push(packageName)
+    }
+  }
+  if (outcome.enabled.length > 0 || outcome.removed.length > 0) {
+    writeProfileManifest(profileDir, withBundles(after, bundles))
+  }
+  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 } } }
+}
+
+/**
+ * Add one installed bundle to the profile's 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.
+ * @param packageName - the installed dependency to enable.
+ * @returns true when the list changed; false when the bundle was already enabled.
+ * @throws when the package is not an installed dependency or declares no `dsh.bundle`.
+ */
+export function enableBundle(binName: string, profileDir: string, installAnchor: string, packageName: string): boolean {
+  const manifest = readProfileManifest(binName, profileDir)
+  if (!(packageName in (manifest.dependencies ?? {}))) {
+    throw new Error(`${binName}: ${packageName} is not installed in profile ${join(profileDir, 'package.json')}`)
+  }
+  if (!exportsBundlePatch(binName, packageName, installAnchor, profileDir)) {
+    throw new Error(`${binName}: ${packageName} declares no dsh.bundle; add its plugin modules to a composition instead of enabling it`)
+  }
+  const bundles = manifest.dsh?.profile?.bundles ?? []
+  if (bundles.includes(packageName)) return false
+  writeProfileManifest(profileDir, withBundles(manifest, [...bundles, packageName]))
+  return true
+}
+
+/**
+ * Remove one dependency-managed bundle from the profile's layer list.
+ * @param binName - the diagnostic prefix on thrown errors.
+ * @param profileDir - the profile directory.
+ * @param packageName - the bundle to disable.
+ * @returns true when the list changed; false when the bundle was not enabled.
+ * @throws when the bundle is a template bundle, which is not a dependency and cannot be disabled.
+ */
+export function disableBundle(binName: string, profileDir: string, packageName: string): boolean {
+  const manifest = readProfileManifest(binName, profileDir)
+  const bundles = manifest.dsh?.profile?.bundles ?? []
+  if (!bundles.includes(packageName)) return false
+  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)))
+  return true
+}

+ 144 - 4
packages/boot/app-boot/src/index.ts

@@ -19,6 +19,7 @@ import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import type {} from '@deepseek-ai/dsh-system-prompt'
+import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -41,6 +42,8 @@ export {
   resolveBundleDir,
   resolveProfileDir,
   writeProfileManifest,
+  type BundleStage,
+  type BundleTrust,
   type DshBundleManifest,
   type DshManifestSection,
   type DshProfileManifest,
@@ -51,6 +54,22 @@ export {
   type ProfilePatchReload,
   type ProfileTemplate,
 } from './profile.ts'
+export {
+  ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, isContainedEntry,
+  type ContainedFailure, type ContainedFailureStage,
+} from './contained-group.ts'
+export {
+  BUNDLE_GROUP_PREFIX, bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle,
+  exportsBundlePatch, externalRowId, isJsDisabled, reconcileInstalledBundles,
+  type BundleReconciliation, type ComposedExternalLayer, type ExternalRowOrigin,
+} from './external-bundles.ts'
+export {
+  ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
+} from './profile-runtime.ts'
+export {
+  PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache,
+  type PluginProbe, type PluginProbeRow, type ProbeOptions,
+} from './probe.ts'
 
 /**
  * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
@@ -505,6 +524,15 @@ function groupedDump(
   return lines.join('\n') + '\n'
 }
 
+/**
+ * The root Include entry {@link mountRootInclude} created for a context.
+ * @param ctx - the booted root context.
+ * @returns the entry, or undefined before the root include mounted.
+ */
+export function rootIncludeEntry(ctx: Context): Entry | undefined {
+  return bootstrapIncludes.get(ctx)
+}
+
 /**
  * Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
  * @param ctx - context carrying an initialized Loader service.
@@ -541,6 +569,10 @@ export async function mountRootInclude(
   // by name. Both builtins load through the ambient module pipeline, so neither
   // depends on the included tree's own specifier resolution.
   ctx.loader.builtins.group = Group
+  // `cordis:contained-group`: the group external bundles mount under, whose
+  // rows fail individually and are recorded on the root's failure registry.
+  ctx.loader.builtins['contained-group'] = ContainedGroup
+  ensurePluginFailures(ctx)
   // Pinned id: the bootstrap include is app glue, not a config row, and its
   // id appears in Loader failure chains — a random id would make startup
   // diagnostics unstable across runs (and snapshot fixtures).
@@ -681,6 +713,45 @@ export function installFailLoud(
   return uninstall
 }
 
+/** The slice of `process` {@link installRuntimeGuards} needs. */
+export interface RuntimeGuardProcess {
+  on(event: 'unhandledRejection' | 'uncaughtException', handler: (err: unknown) => void): unknown
+  off(event: 'unhandledRejection' | 'uncaughtException', handler: (err: unknown) => void): unknown
+  exit(code: number): unknown
+}
+
+/**
+ * The post-boot replacement for {@link installFailLoud}. Once the tree is up,
+ * an unhandled rejection is no longer a load failure: it is most likely a
+ * plugin's stray continuation, and exiting would take every session down for
+ * it. The rejection is reported and the process keeps running. An uncaught
+ * exception leaves the process in an unknown state, so it is reported and
+ * the process exits, as Node would — but with the origin named.
+ * @param binName - the diagnostic prefix on each report.
+ * @param report - sink for the report lines.
+ * @param proc - the process slice to register on; defaults to `process`.
+ * @returns a disposer removing both handlers.
+ */
+export function installRuntimeGuards(
+  binName: string,
+  report: (line: string) => void,
+  proc: RuntimeGuardProcess = process,
+): () => void {
+  const onRejection = (err: unknown): void => {
+    report(`${binName}: unhandled rejection after boot (contained; the process keeps running): ${formatActivationError(err)}`)
+  }
+  const onException = (err: unknown): void => {
+    report(`${binName}: uncaught exception after boot; exiting: ${formatActivationError(err)}`)
+    proc.exit(1)
+  }
+  proc.on('unhandledRejection', onRejection)
+  proc.on('uncaughtException', onException)
+  return () => {
+    proc.off('unhandledRejection', onRejection)
+    proc.off('uncaughtException', onException)
+  }
+}
+
 /**
  * After the tree settles, reject entries with no fiber and name every plugin
  * whose module failed to resolve. Disabled entries are the only valid
@@ -689,7 +760,8 @@ export function installFailLoud(
  * @param binName - the diagnostic prefix on the thrown error.
  */
 export function assertEntriesLoaded(ctx: Context, binName: string): void {
-  const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
+  const failed = [...ctx.loader.entries()]
+    .filter(entry => entry.fiber === undefined && !entry.disabled && !isContainedEntry(entry))
   if (failed.length > 0) {
     const names = failed.map(entry => entry.options.name).join(', ')
     throw new Error(`${binName}: plugin(s) failed to load: ${names}; Cordis startup failed because these plugin(s) could not be resolved (see the error(s) logged above)`)
@@ -710,6 +782,12 @@ function formatActivationError(error: unknown): string {
   return error instanceof Error ? error.stack ?? error.message : String(error)
 }
 
+/** The tree-wide id of the group entry that owns a contained row. */
+function owningGroupId(entry: Entry): string {
+  /* v8 ignore next -- a contained entry is by definition inside a group entry; the fallback keeps the type total */
+  return entry.parent.ctx.fiber.entry?.id ?? ''
+}
+
 /**
  * Reject a settled Loader tree when an enabled entry failed or remains inactive.
  * Plugin failures include the original thrown stack; pending entries name their
@@ -726,15 +804,28 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
   assertEntriesLoaded(ctx, binName)
   const failures: string[] = []
   const rejectionReasons: unknown[] = []
+  const registry = ctx.get('pluginFailures')
+  let builtinPending = false
   for (const entry of ctx.loader.entries()) {
     const fiber = entry.fiber
     if (fiber === undefined || entry.disabled) continue
     const state = fiber.state
-    if (state === FIBER_ACTIVE) continue
+    if (state === FIBER_ACTIVE) {
+      registry?.clear(entry.id)
+      continue
+    }
+    const contained = isContainedEntry(entry)
     if (state === FIBER_FAILED) {
       try {
         await fiber.await()
       } catch (error) {
+        if (contained) {
+          registry?.record({
+            entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name,
+            groupId: owningGroupId(entry), stage: 'apply', message: formatActivationError(error),
+          })
+          continue
+        }
         rejectionReasons.push(error)
         failures.push(`${entry.options.name}: ${formatActivationError(error)}`)
       }
@@ -743,7 +834,16 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
     if (state === FIBER_PENDING) {
       const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
       const subject = missing.length === 1 ? 'service' : 'services'
-      failures.push(`${entry.options.name}: pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`)
+      const line = `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`
+      if (contained) {
+        registry?.record({
+          entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name,
+          groupId: owningGroupId(entry), stage: 'inject-pending', message: line,
+        })
+        continue
+      }
+      builtinPending = true
+      failures.push(`${entry.options.name}: ${line}`)
     } else {
       failures.push(`${entry.options.name}: fiber state ${String(state)}`)
     }
@@ -753,8 +853,48 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
       await observeLoaderRejectionCheckpoint(rejectionReasons)
     }
     const noun = failures.length === 1 ? 'entry' : 'entries'
-    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}`)
+    // A contained failure is only harmless while nothing built-in depends on
+    // it: a built-in row left waiting for a service names the isolated
+    // bundles, because one of them is the likely missing provider.
+    const isolated = builtinPending ? registry?.list() ?? [] : []
+    const hint = isolated.length === 0
+      ? ''
+      : `\n${binName}: isolated bundle failure(s) may be the missing provider — `
+        + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} `
+        + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud'
+    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`)
+  }
+}
+
+/**
+ * Nested plugin fibers (a `ctx.inject()` continuation inside a plugin) fail
+ * without touching their entry's root fiber, so the activation audit above
+ * cannot see them. Report every failed nested fiber under a built-in entry
+ * through `warn`, one line per fiber. Advisory in this release; it becomes
+ * part of the fatal audit once shipped compositions are known clean.
+ * @param ctx - the settled context whose runtimes to inspect.
+ * @param binName - the diagnostic prefix on each warning.
+ * @param warn - sink for the warning lines; defaults to the context logger.
+ * @returns the number of failed nested fibers reported.
+ */
+export function warnNestedFiberFailures(
+  ctx: Context,
+  binName: string,
+  warn: (line: string) => void = (line) => { ctx.logger.warn(line) },
+): number {
+  let count = 0
+  for (const runtime of ctx.registry.values()) {
+    for (const fiber of runtime.fibers) {
+      // The Loader stamps every fiber created inside an entry's context with
+      // that entry; the entry's own root fiber is the one the entry holds.
+      const owner = fiber.entry
+      if (fiber.state !== FIBER_FAILED || owner === undefined || owner.fiber === fiber) continue
+      if (isContainedEntry(owner)) continue
+      count += 1
+      warn(`${binName}: nested fiber under ${owner.options.name} failed (${runtime.name}); the entry itself stays active`)
+    }
   }
+  return count
 }
 
 /**

+ 312 - 0
packages/boot/app-boot/src/probe.ts

@@ -0,0 +1,312 @@
+/**
+ * The install-time probe: what an installed package is and whether this
+ * harness can load it, answered in a child process so a package that throws,
+ * hangs, or brings its own copy of cordis never runs inside the host. The
+ * manifest facts (kind, rows, declared modules) are read here; the child only
+ * imports.
+ * @module @deepseek-ai/dsh-app-boot/probe
+ */
+
+import { spawn } from 'node:child_process'
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { loadOverlayPatches } from './index.ts'
+import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
+
+/** Directory under a profile holding one probe record per package. */
+export const PLUGIN_PROBE_DIR = '.dsh-plugins'
+
+/** One row a bundle's patch inserts, as its own patch declares it. */
+export interface PluginProbeRow {
+  /** The row id the bundle declares, or undefined when the row leaves it to the Loader. */
+  readonly id?: string
+  /** The module specifier the row names. */
+  readonly name: string
+  /** Whether the row carries a `disabled` gate. */
+  readonly gated: boolean
+}
+
+/** One agent-plane module a package declares addable to a composition. */
+export interface PluginProbeAddable {
+  /** The subpath or bare specifier, as declared in `dsh.plugins[].name`. */
+  readonly name: string
+  /** Display title, when declared. */
+  readonly title?: string
+  /** Default row config, when declared. */
+  readonly config?: unknown
+  /** Whether the child process imported the module and found a plugin. */
+  readonly ok: boolean
+  /** The import failure, when `ok` is false. */
+  readonly error?: string
+  /** The module's `Config` schema envelope, when it declares one. */
+  readonly configSchema?: unknown
+}
+
+/** What the probe learned about one installed package. */
+export interface PluginProbe {
+  /** The package name. */
+  readonly packageName: string
+  /** The package version as installed. */
+  readonly version?: string
+  /** Free text from the manifest. */
+  readonly description?: string
+  /** Display title from `dsh.title`, when declared. */
+  readonly title?: string
+  /** `bundle` (declares `dsh.bundle`), `plugin` (its main export is a cordis plugin), else `library`. */
+  readonly kind: 'bundle' | 'plugin' | 'library'
+  /** Whether the package can be enabled or added: it imported and shares the harness's cordis. */
+  readonly ok: boolean
+  /** Why it cannot, when `ok` is false. */
+  readonly reason?: string
+  /** Whether the package resolves `@deepseek-ai/cordis` to the harness's own copy; null when unknown. */
+  readonly cordisSameCopy: boolean | null
+  /** The harness version range the package declares in `engines.dsh`. */
+  readonly enginesDsh?: string
+  /** Rows the bundle's patch inserts (unprefixed), empty for a non-bundle. */
+  readonly rows: readonly PluginProbeRow[]
+  /** Ids of rows outside the bundle that its patch overrides. */
+  readonly overrides: readonly string[]
+  /** Agent-plane modules the package declares addable. */
+  readonly addable: readonly PluginProbeAddable[]
+  /** The main export's `Config` schema envelope, for a `plugin` kind. */
+  readonly configSchema?: unknown
+  /** ISO time of the probe. */
+  readonly checkedAt: string
+}
+
+/** Inputs of {@link probePackage}. */
+export interface ProbeOptions {
+  /** The diagnostic prefix on thrown errors. */
+  binName: string
+  /** The profile directory the package is installed in. */
+  profileDir: string
+  /** Absolute path of the dsh app's package.json (first resolution anchor). */
+  installAnchor: string
+  /** The package to probe. */
+  packageName: string
+  /** Bound on the child process; default 20 seconds. */
+  timeoutMs?: number
+  /** The Node executable; defaults to the running one. */
+  nodeExecutable?: string
+}
+
+/** The manifest slice the probe reads beyond the profile launcher's. */
+interface ProbedManifest extends ProfileManifest {
+  main?: string
+  exports?: unknown
+  engines?: Record<string, string>
+  dsh?: ProfileManifest['dsh'] & {
+    title?: string
+    plugins?: { name: string; title?: string; config?: unknown }[]
+  }
+}
+
+/** What the child process reports. */
+interface ChildReport {
+  cordis: string | null
+  main: { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string }
+  addable: Record<string, { ok: boolean; isPlugin: boolean; configSchema: unknown; error?: string }>
+}
+
+/**
+ * The script the child runs: resolve cordis from the package, import the main
+ * export and every declared addable module, and report. Parameters arrive
+ * as argv so no value is interpolated into code.
+ */
+const CHILD_SCRIPT = `
+import { pathToFileURL } from 'node:url'
+const [dir, mainSpecifier, addableJson] = process.argv.slice(1)
+const base = pathToFileURL(dir + '/package.json').href
+const report = { cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
+try { report.cordis = import.meta.resolve('@deepseek-ai/cordis', base) } catch { report.cordis = null }
+const inspect = async (specifier) => {
+  try {
+    const mod = await import(import.meta.resolve(specifier, base))
+    const plugin = mod.default ?? mod
+    const schema = plugin?.Config ?? mod.Config
+    return {
+      ok: true,
+      isPlugin: typeof plugin === 'function' || typeof plugin?.apply === 'function',
+      configSchema: typeof schema?.toJSON === 'function' ? schema.toJSON() : null,
+    }
+  } catch (error) {
+    return { ok: false, isPlugin: false, configSchema: null, error: String(error?.stack ?? error) }
+  }
+}
+if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
+for (const name of JSON.parse(addableJson)) report.addable[name] = await inspect(name)
+process.stdout.write(JSON.stringify(report))
+`
+
+/** Run the child and parse its report. */
+function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
+  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
+  return new Promise((resolve, reject) => {
+    const child = spawn(
+      options.nodeExecutable ?? process.execPath,
+      ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
+      { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
+    )
+    const out: Buffer[] = []
+    const err: Buffer[] = []
+    child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
+    child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
+    // One settlement: a spawn failure emits `error` and then `close`, and a
+    // timeout kill emits `close` after the rejection below.
+    let settled = false
+    const settle = (outcome: () => void): void => {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      outcome()
+    }
+    const timer = setTimeout(() => {
+      child.kill('SIGKILL')
+      settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
+    }, timeoutMs)
+    child.on('error', (error) => { settle(() => { reject(error) }) })
+    child.on('close', (code) => {
+      settle(() => {
+        const stdout = Buffer.concat(out).toString('utf8')
+        try {
+          resolve(JSON.parse(stdout) as ChildReport)
+        } catch {
+          reject(new Error(
+            `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
+          ))
+        }
+      })
+    })
+  })
+}
+
+const DEFAULT_TIMEOUT_MS = 20_000
+
+/** The rows a bundle patch inserts, flattened from nested groups, with the ids of overrides on other rows. */
+function describeBundlePatch(binName: string, patchPath: string): { rows: PluginProbeRow[]; overrides: string[] } {
+  const rows: PluginProbeRow[] = []
+  const own = new Set<string>()
+  const visit = (row: EntryOptions): void => {
+    if (typeof row.id === 'string') own.add(row.id)
+    rows.push({ ...typeof row.id === 'string' ? { id: row.id } : {}, name: row.name, gated: row.disabled !== undefined })
+    if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
+  }
+  const patches = loadOverlayPatches(binName, patchPath)
+  for (const patch of patches) patch.insert?.forEach(visit)
+  const overrides = patches
+    .filter(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id))
+    .map(patch => patch.id as string)
+  return { rows, overrides }
+}
+
+/**
+ * Probe one installed package: read its manifest, then import it in a child
+ * process. The child never runs inside the host, so a package that throws at
+ * import, exits, or hangs costs one child process and yields a `ok: false`
+ * record with the reason.
+ * @param options - where the package lives and how to run the child.
+ * @returns the probe record.
+ * @throws when the package is not resolvable from the profile, or the child produced no report.
+ */
+export async function probePackage(options: ProbeOptions): Promise<PluginProbe> {
+  const packageDir = resolveBundleDir(options.binName, options.packageName, options.installAnchor, options.profileDir)
+  const manifest = readProfileManifest(options.binName, packageDir) as ProbedManifest
+  const declaredBundle = manifest.dsh?.bundle?.patch
+  const declaredAddable = manifest.dsh?.plugins ?? []
+  const { rows, overrides } = declaredBundle === undefined
+    ? { rows: [], overrides: [] }
+    : describeBundlePatch(options.binName, join(packageDir, declaredBundle))
+  const hasMain = manifest.main !== undefined || manifest.exports !== undefined
+  const report = await runChild(options, packageDir, hasMain ? options.packageName : '', declaredAddable.map(entry => entry.name))
+  const harnessCordis = resolveHarnessCordis()
+  const cordisSameCopy = report.cordis === null || harnessCordis === undefined ? null : report.cordis === harnessCordis
+  const kind: PluginProbe['kind'] = declaredBundle !== undefined ? 'bundle' : report.main.isPlugin ? 'plugin' : 'library'
+  const addable: PluginProbeAddable[] = declaredAddable.map((entry) => {
+    // The child reports every declared name; a missing report is a child
+    // that did not run the loop, which the report parse already rejected.
+    /* v8 ignore next */
+    const inspected = report.addable[entry.name] ?? { ok: false, isPlugin: false, configSchema: null, error: 'no report' }
+    return {
+      name: entry.name,
+      ...entry.title === undefined ? {} : { title: entry.title },
+      ...entry.config === undefined ? {} : { config: entry.config },
+      ok: inspected.ok && inspected.isPlugin,
+      ...inspected.error === undefined ? {} : { error: inspected.error },
+      ...inspected.configSchema === null ? {} : { configSchema: inspected.configSchema },
+    }
+  })
+  let reason: string | undefined
+  // The child attaches the thrown value to every failed import; the fallback keeps the type total.
+  /* v8 ignore next */
+  if (hasMain && !report.main.ok) reason = `the package failed to import: ${report.main.error ?? 'unknown error'}`
+  else if (cordisSameCopy === false) reason = 'the package resolves its own copy of @deepseek-ai/cordis instead of the harness\'s; declare cordis as a peer dependency'
+  return {
+    packageName: options.packageName,
+    ...manifest.version === undefined ? {} : { version: manifest.version },
+    ...manifest.description === undefined ? {} : { description: manifest.description },
+    ...manifest.dsh?.title === undefined ? {} : { title: manifest.dsh.title },
+    kind,
+    ok: reason === undefined,
+    ...reason === undefined ? {} : { reason },
+    cordisSameCopy,
+    ...manifest.engines?.dsh === undefined ? {} : { enginesDsh: manifest.engines.dsh },
+    rows,
+    overrides,
+    addable,
+    ...report.main.configSchema === null || report.main.configSchema === undefined ? {} : { configSchema: report.main.configSchema },
+    checkedAt: new Date().toISOString(),
+  }
+}
+
+/**
+ * The copy of `@deepseek-ai/cordis` this harness runs: the one this module
+ * resolves, which is the one every built-in plugin shares. Undefined only in
+ * an environment that cannot resolve it at all.
+ */
+function resolveHarnessCordis(): string | undefined {
+  try {
+    return import.meta.resolve('@deepseek-ai/cordis')
+  } catch {
+    // Only an embedder without cordis on its module path lands here; the
+    // harness itself always resolves its own peer.
+    /* v8 ignore next */
+    return undefined
+  }
+}
+
+/** The cache file for one package under a profile. */
+function probeCachePath(profileDir: string, packageName: string): string {
+  return join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`)
+}
+
+/**
+ * Read one package's cached probe record.
+ * @param profileDir - the profile directory.
+ * @param packageName - the package.
+ * @param version - when given, a record for a different version is treated as absent.
+ * @returns the record, or undefined when none is cached.
+ */
+export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
+  const path = probeCachePath(profileDir, packageName)
+  if (!existsSync(path)) return undefined
+  let record: PluginProbe
+  try {
+    record = JSON.parse(readFileSync(path, 'utf8')) as PluginProbe
+  } catch {
+    return undefined
+  }
+  if (version !== undefined && record.version !== version) return undefined
+  return record
+}
+
+/**
+ * Persist one package's probe record under the profile.
+ * @param profileDir - the profile directory.
+ * @param probe - the record to persist.
+ */
+export function writeProbeCache(profileDir: string, probe: PluginProbe): void {
+  const path = probeCachePath(profileDir, probe.packageName)
+  mkdirSync(join(profileDir, PLUGIN_PROBE_DIR), { recursive: true })
+  writeFileSync(path, JSON.stringify(probe, undefined, 2) + '\n')
+}

+ 167 - 0
packages/boot/app-boot/src/profile-runtime.ts

@@ -0,0 +1,167 @@
+/**
+ * The `profileRuntime` service: the booted profile's facts and the one
+ * recomposition entry point every live change to the host tree goes through —
+ * user patch-file reloads, bundle enable/disable, and hot install. Before
+ * this service the composition closure lived in the launcher and bundle
+ * layers were frozen at boot, so nothing in the tree could learn which
+ * profile it ran in or add a layer while running.
+ * @module @deepseek-ai/dsh-app-boot/profile-runtime
+ */
+
+import { Context, Service } from '@deepseek-ai/cordis'
+import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type Include from '@deepseek-ai/cordis-plugin-include'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { composeExternalLayer, isJsDisabled } from './external-bundles.ts'
+import type { BundleTrust, Profile, ProfileLayer, ProfilePatchReload } from './profile.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** The booted profile's facts and recomposition; provided by the profile launcher. */
+    profileRuntime: ProfileRuntime
+  }
+}
+
+/** Where one mounted row came from. */
+export interface RowOrigin {
+  /** Who supplied the layer that inserted the row. */
+  readonly trust: BundleTrust
+  /** The bundle package that inserted the row. */
+  readonly packageName: string
+  /** The package's version, when its manifest declares one. */
+  readonly version?: string
+  /** For an external row: the id the bundle's own patch declared, before prefixing. */
+  readonly originalId?: string
+}
+
+/** What the launcher hands the service. */
+export interface ProfileRuntimeOptions {
+  /** The profile as booted. */
+  profile: Profile
+  /** Re-read the profile from disk, re-resolving its bundle layers. */
+  loadProfile: () => Profile
+  /** The complete patch stack for a profile: bundle layers, user layers, overlays. */
+  compose: (profile: Profile) => PatchOptions[]
+  /** The root Include entry, once mounted. */
+  rootEntry: () => Entry | undefined
+  /** The user patch layers as they stand on disk (profile file, then home file). */
+  readUserPatches: () => PatchOptions[]
+}
+
+/** Collect every id a patch list inserts, recursing into inserted groups. */
+function insertedIds(patches: readonly PatchOptions[], into: Set<string>): void {
+  const visit = (row: EntryOptions): void => {
+    if (typeof row.id === 'string') into.add(row.id)
+    if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
+  }
+  for (const patch of patches) patch.insert?.forEach(visit)
+}
+
+/** Facts and recomposition of the booted profile. */
+export class ProfileRuntime extends Service {
+  private profile: Profile
+  private origins: Map<string, RowOrigin> | undefined
+
+  constructor(ctx: Context, private readonly options: ProfileRuntimeOptions) {
+    super(ctx, 'profileRuntime')
+    this.profile = options.profile
+  }
+
+  /** The profile as currently composed; re-read by a `recompose({ reloadBundles: true })`. */
+  get current(): Profile {
+    return this.profile
+  }
+
+  /** The profile name (`dsh --profile <name>`). */
+  get profileName(): string {
+    return this.profile.name
+  }
+
+  /** Absolute profile directory. */
+  get dir(): string {
+    return this.profile.dir
+  }
+
+  /** Absolute path of the profile's own user patch file. */
+  get patchPath(): string {
+    return this.profile.patchPath
+  }
+
+  /** Whether user patch files reload while the profile runs. */
+  get patchReload(): ProfilePatchReload {
+    return this.profile.patchReload
+  }
+
+  /** The bundle layers currently composed, in application order. */
+  get layers(): readonly ProfileLayer[] {
+    return this.profile.layers
+  }
+
+  /**
+   * Where one mounted row came from.
+   * @param rowId - the row's tree-wide id.
+   * @returns the origin, or undefined for a row no bundle layer inserted (a user or overlay row).
+   */
+  originOf(rowId: string): RowOrigin | undefined {
+    this.origins ??= this.computeOrigins()
+    return this.origins.get(rowId)
+  }
+
+  /**
+   * Row ids the user patch layers disable with a literal `disabled: true`.
+   * A `!!js` gate in a user file is a condition, not a user decision, and is
+   * left to the composition.
+   * @returns the ids, re-read from disk on every call.
+   */
+  userDisabledRowIds(): Set<string> {
+    const ids = new Set<string>()
+    for (const patch of this.options.readUserPatches()) {
+      if (patch.insert !== undefined || patch.id === undefined) continue
+      if (patch.disabled === true && !isJsDisabled(patch.disabled)) ids.add(patch.id)
+    }
+    return ids
+  }
+
+  /**
+   * Recompose the host tree from the profile's layers and the user patch files
+   * as they stand now. The root Include re-applies the stack transactionally:
+   * a row whose options changed is updated in place, a row that appeared is
+   * created, a row that vanished is disposed, and a failure rolls the whole
+   * update back with the previous tree still running.
+   * @param options - `reloadBundles` re-reads the profile manifest first, so a
+   * bundle enabled or installed since boot joins the stack.
+   * @throws when the root include is not mounted, or the Loader rejected the update.
+   */
+  async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
+    const entry = this.options.rootEntry()
+    if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
+    if (options.reloadBundles === true) {
+      this.profile = this.options.loadProfile()
+      this.origins = undefined
+    }
+    const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
+    await entry.update({
+      config: {
+        ...includeConfig,
+        patches: this.options.compose(this.profile),
+      },
+    })
+  }
+
+  private computeOrigins(): Map<string, RowOrigin> {
+    const origins = new Map<string, RowOrigin>()
+    for (const layer of this.profile.layers) {
+      const base = { trust: layer.trust, packageName: layer.packageName, ...layer.version === undefined ? {} : { version: layer.version } }
+      if (layer.trust === 'external' && layer.stage === 'runtime') {
+        for (const [id, origin] of composeExternalLayer(layer).rows) {
+          origins.set(id, { ...base, originalId: origin.originalId })
+        }
+        continue
+      }
+      const ids = new Set<string>()
+      insertedIds(layer.patches, ids)
+      for (const id of ids) origins.set(id, base)
+    }
+    return origins
+  }
+}

+ 63 - 1
packages/boot/app-boot/src/profile.ts

@@ -46,10 +46,21 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
 /** Profile-private package links projected into its pnpm-managed node_modules. */
 const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback'
 
+/**
+ * When an external bundle's rows mount relative to the built-in tree.
+ * `runtime` (the default) wraps the bundle's inserted rows in a contained
+ * group whose failure is isolated and reported; `boot` mounts them like
+ * built-in rows, so a failure stops the process — the choice for a bundle
+ * that provides a service built-in rows inject.
+ */
+export type BundleStage = 'boot' | 'runtime'
+
 /** The bundle half of the `dsh` manifest section: what a bundle package exports. */
 export interface DshBundleManifest {
   /** The patch layer this bundle exports, relative to its package root. */
   patch: string
+  /** Mount stage the bundle author asks for; the profile's `stages` overrides it. */
+  stage?: BundleStage
 }
 
 /** The profile half of the `dsh` manifest section: what a profile directory composes. */
@@ -58,8 +69,23 @@ export interface DshProfileManifest {
   bundles?: string[]
   /** Whether user patch files reload while this profile remains active. */
   patchReload?: ProfilePatchReload
+  /** Deployer overrides of each external bundle's mount stage, by package name. */
+  stages?: Record<string, BundleStage>
+  /**
+   * Installed packages treated as built-in: not wrapped, not prefixed, and
+   * fatal on failure. For first-party packages linked into a profile during
+   * development, where provenance alone would classify them external.
+   */
+  firstParty?: string[]
 }
 
+/**
+ * Who supplied a bundle layer. `builtin` layers come with the installation
+ * (template bundles) or are declared first-party by the profile; `external`
+ * layers are pnpm-managed dependencies the user installed.
+ */
+export type BundleTrust = 'builtin' | 'external'
+
 /** User patch-file lifecycle selected by a profile. */
 export type ProfilePatchReload = 'live' | 'startup'
 
@@ -85,6 +111,8 @@ export interface DshManifestSection {
 /** The slice of package.json both profiles and bundles use. */
 export interface ProfileManifest {
   name?: string
+  version?: string
+  description?: string
   dependencies?: Record<string, string>
   peerDependencies?: Record<string, string>
   dsh?: DshManifestSection
@@ -94,10 +122,16 @@ export interface ProfileManifest {
 export interface ProfileLayer {
   /** The bundle's package name, as listed in `dsh.profile.bundles`. */
   packageName: string
+  /** The bundle package's version, as its manifest declares it. */
+  version: string | undefined
   /** Absolute directory of the resolved bundle package. */
   packageDir: string
   /** Absolute path of the bundle's patch file. */
   patchPath: string
+  /** Who supplied the layer; decides isolation, id prefixing, and failure semantics. */
+  trust: BundleTrust
+  /** Effective mount stage: the profile's override, else the bundle's declaration, else `runtime`. */
+  stage: BundleStage
   /** The parsed patch list. */
   patches: PatchOptions[]
 }
@@ -707,6 +741,18 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo
   writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
 }
 
+/**
+ * Validate one bundle stage value read from a manifest; an unknown value is a
+ * misconfiguration and fails at load.
+ */
+function readBundleStage(binName: string, packageName: string, value: unknown): BundleStage {
+  if (value === undefined) return 'runtime'
+  if (value === 'boot' || value === 'runtime') return value
+  throw new Error(
+    `${binName}: bundle ${JSON.stringify(packageName)} declares stage ${JSON.stringify(value)}; expected "boot" or "runtime"`,
+  )
+}
+
 /** Return whether two bundle lists have the same values in the same order. */
 function sameBundles(left: readonly string[], right: readonly string[]): boolean {
   return left.length === right.length && left.every((value, index) => value === right[index])
@@ -826,6 +872,9 @@ export function loadProfile(
     )
   }
   const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD
+  const dependencies = new Set(Object.keys(manifest.dependencies ?? {}))
+  const firstParty = new Set(manifest.dsh?.profile?.firstParty ?? [])
+  const stages = manifest.dsh?.profile?.stages ?? {}
   const layers = bundles.map((packageName): ProfileLayer => {
     const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
     const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
@@ -834,7 +883,20 @@ export function loadProfile(
       throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
     }
     const patchPath = join(packageDir, declared)
-    return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
+    // Provenance, not the package name, decides trust: a template bundle is
+    // never a dependency, and everything pnpm added is out-of-tree — a fork
+    // that kept a first-party name still lands in `dependencies`.
+    const trust: BundleTrust = dependencies.has(packageName) && !firstParty.has(packageName) ? 'external' : 'builtin'
+    const stage = readBundleStage(binName, packageName, stages[packageName] ?? bundleManifest.dsh?.bundle?.stage)
+    return {
+      packageName,
+      version: bundleManifest.version,
+      packageDir,
+      patchPath,
+      trust,
+      stage,
+      patches: loadOverlayPatches(binName, patchPath),
+    }
   })
   const patchPath = join(dir, PROFILE_PATCH_FILENAME)
   const patches = options.userLayer !== false && existsSync(patchPath)

+ 3 - 1
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -411,6 +411,7 @@ describe('installFailLoud', () => {
     installFailLoud(NAME, proc)
     const error = new Error('assembled activation failure')
     const audit = assertEntriesActivated({
+      get: () => undefined,
       loader: {
         entries: () => ['broken-a', 'broken-b'].map(name => ({
           options: { name },
@@ -494,7 +495,7 @@ describe('installFailLoud', () => {
 
 describe('assertEntriesLoaded', () => {
   const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
-    ({ loader: { entries: () => entries } }) as unknown as Context
+    ({ loader: { entries: () => entries }, get: () => undefined }) as unknown as Context
 
   it('passes when every enabled entry has a fiber', () => {
     expect(() => { assertEntriesLoaded(ctxWith([
@@ -522,6 +523,7 @@ describe('assertEntriesActivated', () => {
 
   const ctxWith = (entries: Array<{ fiber?: FakeFiber; disabled?: boolean; options: { name: string } }>): Context => ({
     loader: { entries: () => entries },
+    get: () => undefined,
   }) as unknown as Context
 
   const fiber = (

+ 247 - 0
packages/boot/app-boot/tests/contained-group.spec.ts

@@ -0,0 +1,247 @@
+/**
+ * Startup isolation: a row inside a `cordis:contained-group` fails on its
+ * own and is recorded, the group and its siblings stay up, and the boot
+ * audit treats the record as a report rather than a fatal. Built-in rows keep
+ * the fatal path, and a built-in row left waiting because an isolated bundle
+ * failed still fails the boot while naming that bundle.
+ */
+
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context, type Plugin } from '@deepseek-ai/cordis'
+import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import {
+  assertEntriesActivated, boot, ContainedFailureRegistry, ContainedGroup, ensurePluginFailures, installRuntimeGuards,
+  isContainedEntry, rootIncludeEntry, warnNestedFiberFailures, type RuntimeGuardProcess,
+} from '../src/index.ts'
+
+const NAME = 'dsh-test-bin'
+
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+const good: Plugin.Function = () => {}
+const throws: Plugin.Function = () => { throw new Error('boom at apply') }
+const pending: Plugin.Object = { inject: ['neverReady'], apply() {} }
+const nestedThrows: Plugin.Function = (ctx) => {
+  ctx.inject([], () => { throw new Error('nested boom') })
+}
+let flakyCalls = 0
+const flaky: Plugin.Function = () => {
+  flakyCalls += 1
+  if (flakyCalls > 1) throw new Error('flaky on reload')
+}
+
+const prepare = (ctx: Context): void => {
+  ctx.loader.builtins.good = good
+  ctx.loader.builtins.throws = throws
+  ctx.loader.builtins.pending = pending
+  ctx.loader.builtins['nested-throws'] = nestedThrows
+  ctx.loader.builtins.flaky = flaky
+}
+
+function stage(config: string): string {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-contained-'))
+  const path = join(dir, 'cordis.yml')
+  writeFileSync(path, config)
+  return path
+}
+
+const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 30))
+
+describe('cordis:contained-group', () => {
+  it('isolates a failing row, keeps its siblings, and records the failure', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/ok
+      name: cordis:good
+    - id: ext/bad
+      name: cordis:throws
+- id: builtin-ok
+  name: cordis:good
+`), [], prepare)
+    contexts.push(ctx)
+    // Tree-wide ids carry the root include's prefix; the composition's own ids do not.
+    const ids = [...ctx.loader.entries()].map(entry => entry.id)
+    expect(ids).toEqual(expect.arrayContaining(['include:bundle/ext', 'include:ext/ok', 'include:builtin-ok']))
+    expect(ids).not.toContain('include:ext/bad')
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.list()).toEqual([expect.objectContaining({
+      entryId: 'include:ext/bad', rowId: 'ext/bad', moduleName: 'cordis:throws', groupId: 'include:bundle/ext', stage: 'apply',
+    })])
+    expect(registry.get('include:ext/bad')?.message).toContain('boom at apply')
+    expect(isContainedEntry(ctx.loader.resolve('include:ext/ok'))).toBe(true)
+    expect(isContainedEntry(ctx.loader.resolve('include:builtin-ok'))).toBe(false)
+    expect(rootIncludeEntry(ctx)?.id).toBe('include')
+  })
+
+  it('records a contained row that waits forever for a service instead of failing the boot', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/waiting
+      name: cordis:pending
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', rowId: 'ext/waiting' }))
+    expect(registry.get('include:ext/waiting')?.message).toContain('neverReady')
+  })
+
+  it('still fails the boot for a built-in row, and names isolated bundles when a built-in row is left waiting', async () => {
+    await expect(boot(NAME, stage(`
+- id: builtin-bad
+  name: cordis:throws
+`), [], prepare)).rejects.toThrow(/boom at apply/)
+
+    await expect(boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/bad
+      name: cordis:throws
+- id: builtin-waiting
+  name: cordis:pending
+`), [], prepare)).rejects.toThrow(/isolated bundle failure\(s\) may be the missing provider — check include:bundle\/ext/)
+  })
+
+  it('re-running the audit clears a record whose row later mounted', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/ok
+      name: cordis:good
+`), [], prepare)
+    contexts.push(ctx)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    registry.record({ entryId: 'include:ext/ok', rowId: 'ext/ok', moduleName: 'cordis:good', groupId: 'include:bundle/ext', stage: 'unknown', message: 'stale' })
+    await assertEntriesActivated(ctx, NAME)
+    expect(registry.get('include:ext/ok')).toBeUndefined()
+  })
+
+  it('records an import failure, and spells ids without a prefix in a tree with no include', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins['contained-group'] = ContainedGroup
+    ensurePluginFailures(ctx)
+    const group: EntryOptions = {
+      id: 'bundle/ext', name: 'cordis:contained-group', group: true,
+      config: [{ id: 'ext/missing', name: 'no-such-package-for-dsh-tests' }],
+    }
+    await ctx.loader.create(group)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.get('ext/missing')).toEqual(expect.objectContaining({ entryId: 'ext/missing', rowId: 'ext/missing', stage: 'import', groupId: 'bundle/ext' }))
+  })
+
+  it('records a contained row whose fiber fails on a later reload', async () => {
+    flakyCalls = 0
+    const ctx = await boot(NAME, stage(`
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/flaky
+      name: cordis:flaky
+`), [], prepare)
+    contexts.push(ctx)
+    const entry = ctx.loader.resolve('include:ext/flaky')
+    // The restart rethrows the reload failure; the fiber stays mounted as FAILED.
+    await entry.fiber?.restart().catch(() => undefined)
+    await settle()
+    await assertEntriesActivated(ctx, NAME)
+    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
+    expect(registry.get('include:ext/flaky')).toEqual(expect.objectContaining({ stage: 'apply', rowId: 'ext/flaky' }))
+    expect(registry.get('include:ext/flaky')?.message).toContain('flaky on reload')
+  })
+
+  it('provides one registry per runtime', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    const registry = ensurePluginFailures(ctx)
+    expect(ensurePluginFailures(ctx.extend({}))).toBe(registry)
+    registry.record({ entryId: 'a', rowId: 'a', moduleName: 'm', groupId: 'g', stage: 'import', message: 'x' })
+    registry.record({ entryId: 'a', rowId: 'a', moduleName: 'm', groupId: 'g', stage: 'apply', message: 'y' })
+    expect(registry.list()).toHaveLength(1)
+    registry.clear('a')
+    expect(registry.list()).toEqual([])
+  })
+})
+
+describe('warnNestedFiberFailures', () => {
+  it('reports a failed nested fiber under a built-in entry and ignores contained ones', async () => {
+    const ctx = await boot(NAME, stage(`
+- id: builtin-nested
+  name: cordis:nested-throws
+- id: bundle/ext
+  name: cordis:contained-group
+  group: true
+  config:
+    - id: ext/nested
+      name: cordis:nested-throws
+`), [], prepare)
+    contexts.push(ctx)
+    await settle()
+    const lines: string[] = []
+    expect(warnNestedFiberFailures(ctx, NAME, line => lines.push(line))).toBe(1)
+    expect(lines).toEqual([expect.stringContaining('cordis:nested-throws')])
+    // The default sink is the context logger; it must accept the call too.
+    expect(warnNestedFiberFailures(ctx, NAME)).toBe(1)
+  })
+
+  it('reports nothing for a clean tree', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    expect(warnNestedFiberFailures(ctx, NAME, () => { throw new Error('unexpected') })).toBe(0)
+  })
+})
+
+describe('installRuntimeGuards', () => {
+  function fakeProc(): RuntimeGuardProcess & { handlers: Map<string, (err: unknown) => void>; exits: number[] } {
+    const handlers = new Map<string, (err: unknown) => void>()
+    const exits: number[] = []
+    return {
+      handlers,
+      exits,
+      on: (event, handler) => { handlers.set(event, handler) },
+      off: (event) => { handlers.delete(event) },
+      exit: (code) => { exits.push(code) },
+    }
+  }
+
+  it('reports a rejection without exiting, exits on an exception, and uninstalls both', () => {
+    const proc = fakeProc()
+    const lines: string[] = []
+    const uninstall = installRuntimeGuards(NAME, line => lines.push(line), proc)
+    proc.handlers.get('unhandledRejection')?.(new Error('stray'))
+    expect(proc.exits).toEqual([])
+    expect(lines[0]).toContain('unhandled rejection after boot')
+    expect(lines[0]).toContain('stray')
+    proc.handlers.get('uncaughtException')?.('plain')
+    expect(proc.exits).toEqual([1])
+    expect(lines[1]).toContain('uncaught exception after boot')
+    uninstall()
+    expect(proc.handlers.size).toBe(0)
+  })
+
+  it('defaults to the real process', () => {
+    const before = process.listenerCount('uncaughtException')
+    const uninstall = installRuntimeGuards(NAME, () => {})
+    expect(process.listenerCount('uncaughtException')).toBe(before + 1)
+    uninstall()
+    expect(process.listenerCount('uncaughtException')).toBe(before)
+  })
+})

+ 196 - 0
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -0,0 +1,196 @@
+/**
+ * External bundle composition (one contained, id-prefixed group per bundle)
+ * and the manifest operations behind installing, enabling, and disabling.
+ */
+
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import {
+  bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle, exportsBundlePatch,
+  externalRowId, isJsDisabled, reconcileInstalledBundles, readProfileManifest, type ProfileLayer,
+} from '../src/index.ts'
+
+const NAME = 'dsh-test-bin'
+
+const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-external-bundles-'))
+
+function layer(packageName: string, patches: PatchOptions[]): ProfileLayer {
+  return {
+    packageName, version: '1.0.0', packageDir: '/nowhere', patchPath: '/nowhere/cordis.patch.yml',
+    trust: 'external', stage: 'runtime', patches,
+  }
+}
+
+describe('composeExternalLayer', () => {
+  it('wraps root inserts in one contained group and prefixes every row id', () => {
+    const composed = composeExternalLayer(layer('pkg-a', [
+      { insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' } as EntryOptions] },
+      { insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] },
+    ]))
+    expect(composed.patches).toEqual([{
+      insert: [{
+        id: 'bundle/pkg-a',
+        name: CONTAINED_GROUP_MODULE,
+        group: true,
+        config: [
+          { id: 'pkg-a/tool', name: 'pkg-a' },
+          { name: 'pkg-a/anonymous' },
+          { id: 'pkg-a/grp', name: 'cordis:group', group: true, config: [{ id: 'pkg-a/inner', name: 'pkg-a/inner' }] },
+        ],
+      }],
+    }])
+    expect([...composed.rows.keys()]).toEqual(['pkg-a/tool', 'pkg-a/grp', 'pkg-a/inner', 'bundle/pkg-a'])
+    expect(composed.rows.get('pkg-a/inner')).toEqual({ packageName: 'pkg-a', originalId: 'inner' })
+    expect(composed.overrides).toEqual([])
+  })
+
+  it('rewrites patches that address the bundle\'s own rows and passes overrides of built-in rows through', () => {
+    const composed = composeExternalLayer(layer('pkg-b', [
+      { insert: [{ id: 'own', name: 'pkg-b' }] },
+      { id: 'own', config: { flag: true } },
+      { id: 'settings', config: { path: '/x' } },
+      { id: 'own', insert: [{ id: 'child', name: 'pkg-b/child' }] },
+    ]))
+    expect(composed.patches.slice(1)).toEqual([
+      { id: 'pkg-b/own', insert: [{ id: 'pkg-b/child', name: 'pkg-b/child' }] },
+      { id: 'pkg-b/own', config: { flag: true } },
+      { id: 'settings', config: { path: '/x' } },
+    ])
+    expect(composed.overrides).toEqual(['settings'])
+  })
+
+  it('nests rows inserted into a built-in group inside their own contained group', () => {
+    const composed = composeExternalLayer(layer('pkg-c', [
+      { id: 'persistent-shell', insert: [{ id: 'extra', name: 'pkg-c/extra' }] },
+    ]))
+    expect(composed.patches).toEqual([
+      { insert: [{ id: 'bundle/pkg-c', name: CONTAINED_GROUP_MODULE, group: true, config: [] }] },
+      {
+        id: 'persistent-shell',
+        insert: [{ id: 'bundle/pkg-c/in/persistent-shell', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'pkg-c/extra', name: 'pkg-c/extra' }] }],
+      },
+    ])
+    expect(composed.rows.has('bundle/pkg-c/in/persistent-shell')).toBe(true)
+  })
+
+  it('never mutates the layer\'s own patch objects', () => {
+    const patches: PatchOptions[] = [{ insert: [{ id: 'row', name: 'pkg-d' }] }, { id: 'row', config: { a: 1 } }]
+    const snapshot = structuredClone(patches)
+    composeExternalLayer(layer('pkg-d', patches))
+    expect(patches).toEqual(snapshot)
+  })
+
+  it('spells ids without the Loader\'s nested-id separator', () => {
+    expect(bundleGroupId('@scope/pkg')).toBe('bundle/@scope/pkg')
+    expect(externalRowId('@scope/pkg', 'row')).toBe('@scope/pkg/row')
+    expect(bundleGroupId('x')).not.toContain(':')
+  })
+
+  it('tells a !!js disabled node from a literal', () => {
+    expect(isJsDisabled({ __jsExpr: 'true' })).toBe(true)
+    expect(isJsDisabled(true)).toBe(false)
+  })
+})
+
+/** Stage a profile directory whose node_modules holds the named packages. */
+function stageProfile(packages: Record<string, { bundle?: boolean; dependency?: boolean }>): { profileDir: string; installAnchor: string } {
+  const root = tmp()
+  const appDir = join(root, 'app')
+  mkdirSync(join(appDir, 'node_modules'), { recursive: true })
+  writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', version: '0.0.0' }))
+  const profileDir = join(root, 'profile')
+  mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
+  const dependencies: Record<string, string> = {}
+  for (const [name, spec] of Object.entries(packages)) {
+    const dir = join(profileDir, 'node_modules', name)
+    mkdirSync(dir, { recursive: true })
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({
+      name, version: '1.2.3', ...spec.bundle === true ? { dsh: { bundle: { patch: './cordis.patch.yml' } } } : {},
+    }))
+    if (spec.bundle === true) writeFileSync(join(dir, 'cordis.patch.yml'), `- insert:\n    - id: row\n      name: ${name}\n`)
+    if (spec.dependency !== false) dependencies[name] = '1.2.3'
+  }
+  writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
+    name: 'dsh-profile-test', private: true, dependencies, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } },
+  }))
+  return { profileDir, installAnchor: join(appDir, 'package.json') }
+}
+
+describe('reconcileInstalledBundles', () => {
+  it('tells bundles from plain packages and enables new bundles only when asked', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true }, 'plain-lib': {} })
+    const before = { dependencies: {} }
+
+    const installedOnly = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
+    expect(installedOnly).toEqual({ enabled: [], removed: [], plain: ['plain-lib'], installedOnly: ['ext-bundle'] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
+
+    const enabled = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: true })
+    expect(enabled).toEqual({ enabled: ['ext-bundle'], removed: [], plain: ['plain-lib'], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle'])
+    expect(exportsBundlePatch(NAME, 'plain-lib', installAnchor, profileDir)).toBe(false)
+    expect(exportsBundlePatch(NAME, 'missing', installAnchor, profileDir)).toBe(false)
+  })
+
+  it('drops a layer whose dependency was removed and keeps template bundles', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } })
+    enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')
+    const manifest = readProfileManifest(NAME, profileDir)
+    // pnpm removed the dependency; the layer list still names it.
+    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'])
+  })
+})
+
+describe('reconcileInstalledBundles on a minimal manifest', () => {
+  it('tolerates manifests without dependencies or a bundle list and skips already-known plain packages', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'plain-lib': {}, 'ext-bundle': { bundle: true } })
+    const manifest = readProfileManifest(NAME, profileDir)
+    // No dsh section and no bundle list at all: the layer list starts empty.
+    writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'bare', dependencies: manifest.dependencies }))
+    const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, { dependencies: { 'plain-lib': '1.2.3' } }, { autoEnable: true })
+    expect(outcome).toEqual({ enabled: ['ext-bundle'], removed: [], plain: [], installedOnly: [] })
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['ext-bundle'])
+    // A manifest with neither section reconciles to nothing.
+    writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'empty' }))
+    expect(reconcileInstalledBundles(NAME, profileDir, installAnchor, {}, { autoEnable: true }))
+      .toEqual({ enabled: [], removed: [], plain: [], installedOnly: [] })
+  })
+
+  it('enables and disables against a manifest that never had a bundle list', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } })
+    writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'bare', dependencies: { 'ext-bundle': '1.2.3' } }))
+    expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(false)
+    expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(true)
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['ext-bundle'])
+    writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'bare', dsh: { profile: { bundles: ['ext-bundle'] } } }))
+    expect(() => disableBundle(NAME, profileDir, 'ext-bundle')).toThrow(/template bundle/)
+    expect(() => enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toThrow(/is not installed/)
+  })
+})
+
+describe('enableBundle / disableBundle', () => {
+  it('appends and removes dependency-managed bundles, reporting no-ops', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'ext-bundle': { bundle: true } })
+    expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(true)
+    expect(enableBundle(NAME, profileDir, installAnchor, 'ext-bundle')).toBe(false)
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', 'ext-bundle'])
+    expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(true)
+    expect(disableBundle(NAME, profileDir, 'ext-bundle')).toBe(false)
+    expect(readProfileManifest(NAME, profileDir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
+  })
+
+  it('refuses a package that is not installed, not a bundle, or a template bundle', () => {
+    const { profileDir, installAnchor } = stageProfile({ 'plain-lib': {} })
+    expect(() => enableBundle(NAME, profileDir, installAnchor, 'ghost')).toThrow(/is not installed in profile/)
+    expect(() => enableBundle(NAME, profileDir, installAnchor, 'plain-lib')).toThrow(/declares no dsh\.bundle/)
+    expect(() => disableBundle(NAME, profileDir, '@deepseek-ai/dsh-base')).toThrow(/template bundle/)
+  })
+})

+ 191 - 0
packages/boot/app-boot/tests/probe.spec.ts

@@ -0,0 +1,191 @@
+/**
+ * The install-time probe: manifest facts read in-process, imports checked in
+ * a child process, and the per-profile cache.
+ */
+
+import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache, type PluginProbe } from '../src/index.ts'
+
+const NAME = 'dsh-test-bin'
+
+interface StagedPackage {
+  main?: string
+  manifest?: Record<string, unknown>
+  files?: Record<string, string>
+}
+
+/** Stage a profile with packages under its node_modules and an empty app anchor. */
+function stage(packages: Record<string, StagedPackage>): { profileDir: string; installAnchor: string } {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-probe-'))
+  const appDir = join(root, 'app')
+  mkdirSync(join(appDir, 'node_modules'), { recursive: true })
+  writeFileSync(join(appDir, 'package.json'), JSON.stringify({ name: 'dsh-app', version: '0.0.0' }))
+  const profileDir = join(root, 'profile')
+  mkdirSync(join(profileDir, 'node_modules'), { recursive: true })
+  writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-test', private: true }))
+  for (const [name, spec] of Object.entries(packages)) {
+    const dir = join(profileDir, 'node_modules', name)
+    mkdirSync(dir, { recursive: true })
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({
+      name, version: '1.0.0', type: 'module', ...spec.main === undefined ? {} : { main: './index.js' }, ...spec.manifest,
+    }))
+    if (spec.main !== undefined) writeFileSync(join(dir, 'index.js'), spec.main)
+    for (const [file, content] of Object.entries(spec.files ?? {})) {
+      mkdirSync(join(dir, file, '..'), { recursive: true })
+      writeFileSync(join(dir, file), content)
+    }
+  }
+  return { profileDir, installAnchor: join(appDir, 'package.json') }
+}
+
+const PLUGIN_MAIN = `
+export const name = 'probe-plugin'
+export const Config = { toJSON: () => ({ type: 'object', properties: { flag: { type: 'boolean' } } }) }
+export function apply() {}
+`
+
+describe('probePackage', () => {
+  it('classifies a bundle, lists its rows and overrides, and keeps the config schema', async () => {
+    const { profileDir, installAnchor } = stage({
+      'ext-bundle': {
+        main: PLUGIN_MAIN,
+        manifest: {
+          description: 'A test bundle',
+          engines: { dsh: '>=0.1.0' },
+          dsh: { title: 'Ext', bundle: { patch: './cordis.patch.yml' }, plugins: [{ name: 'ext-bundle', title: 'Main as row', config: { a: 1 } }] },
+        },
+        files: {
+          'cordis.patch.yml': [
+            '- insert:',
+            '    - id: seam',
+            '      name: other-pkg',
+            "      disabled: !!js 'false'",
+            '    - id: ext',
+            '      name: ext-bundle',
+            '    - name: ext-bundle/anonymous',
+            '    - id: grp',
+            '      name: cordis:group',
+            '      group: true',
+            '      config:',
+            '        - id: nested',
+            '          name: ext-bundle/nested',
+            '- id: ext',
+            '  config: { flag: true }',
+            '- id: settings',
+            '  config: { path: /x }',
+            '',
+          ].join('\n'),
+        },
+      },
+    })
+    const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ext-bundle' })
+    expect(probe).toMatchObject({
+      packageName: 'ext-bundle', version: '1.0.0', description: 'A test bundle', title: 'Ext', kind: 'bundle', ok: true,
+      enginesDsh: '>=0.1.0', overrides: ['settings'],
+      rows: [
+        { id: 'seam', name: 'other-pkg', gated: true },
+        { id: 'ext', name: 'ext-bundle', gated: false },
+        { name: 'ext-bundle/anonymous', gated: false },
+        { id: 'grp', name: 'cordis:group', gated: false },
+        { id: 'nested', name: 'ext-bundle/nested', gated: false },
+      ],
+      configSchema: { type: 'object', properties: { flag: { type: 'boolean' } } },
+    })
+    expect(probe.addable).toEqual([{ name: 'ext-bundle', title: 'Main as row', config: { a: 1 }, ok: true, configSchema: { type: 'object', properties: { flag: { type: 'boolean' } } } }])
+    expect(probe.cordisSameCopy).toBeNull()
+    expect(Date.parse(probe.checkedAt)).not.toBeNaN()
+  })
+
+  it('classifies a plain plugin package and a library', async () => {
+    const { profileDir, installAnchor } = stage({
+      'plain-plugin': { main: 'export function apply() {}\n' },
+      'plain-lib': { main: 'export const helper = 1\n' },
+    })
+    expect((await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-plugin' })).kind).toBe('plugin')
+    const lib = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'plain-lib' })
+    expect(lib.kind).toBe('library')
+    expect(lib.ok).toBe(true)
+  })
+
+  it('reports a package that fails to import, and one that brings its own cordis', async () => {
+    const { profileDir, installAnchor } = stage({
+      'broken': { main: 'throw new Error("import-time explosion")\n' },
+      'own-cordis': {
+        main: 'export function apply() {}\n',
+        files: {
+          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', main: './index.js' }),
+          'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
+        },
+      },
+    })
+    const broken = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'broken' })
+    expect(broken.ok).toBe(false)
+    expect(broken.reason).toContain('import-time explosion')
+    expect(broken.kind).toBe('library')
+
+    const own = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'own-cordis' })
+    expect(own.cordisSameCopy).toBe(false)
+    expect(own.ok).toBe(false)
+    expect(own.reason).toContain('own copy of @deepseek-ai/cordis')
+  })
+
+  it('probes a package with the minimal manifest and no main export', async () => {
+    const { profileDir, installAnchor } = stage({
+      'minimal': { manifest: { version: undefined } },
+    })
+    const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'minimal' })
+    // No version, description, title, engines, or main: only the facts every package has.
+    expect(probe).toEqual({
+      packageName: 'minimal', kind: 'library', ok: true, cordisSameCopy: null,
+      rows: [], overrides: [], addable: [], checkedAt: expect.any(String) as string,
+    })
+  })
+
+  it('records an addable module that fails to import without failing the package', async () => {
+    const { profileDir, installAnchor } = stage({
+      'partial': {
+        main: 'export function apply() {}\n',
+        manifest: { dsh: { plugins: [{ name: 'partial/missing' }] } },
+      },
+    })
+    const probe = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'partial' })
+    expect(probe.ok).toBe(true)
+    expect(probe.addable).toEqual([expect.objectContaining({ name: 'partial/missing', ok: false })])
+    expect(probe.addable[0]?.error).toBeDefined()
+  })
+
+  it('kills a child that never reports and refuses an unresolvable package', async () => {
+    const { profileDir, installAnchor } = stage({
+      'hangs': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n' },
+      'exits': { main: 'process.stderr.write("refusing to report"); process.exit(3)\n' },
+    })
+    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'hangs', timeoutMs: 300 }))
+      .rejects.toThrow(/timed out after 300ms/)
+    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits' }))
+      .rejects.toThrow(/exited with 3 without a report: refusing to report/)
+    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'ghost' }))
+      .rejects.toThrow(/cannot resolve profile bundle "ghost"/)
+    await expect(probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'exits', nodeExecutable: '/no/such/node' }))
+      .rejects.toThrow(/ENOENT/)
+  })
+})
+
+describe('probe cache', () => {
+  it('round-trips a record per package and treats a different version as absent', () => {
+    const profileDir = mkdtempSync(join(tmpdir(), 'dsh-probe-cache-'))
+    const record: PluginProbe = {
+      packageName: '@scope/pkg', version: '1.0.0', kind: 'plugin', ok: true, cordisSameCopy: null,
+      rows: [], overrides: [], addable: [], checkedAt: '2026-09-04T00:00:00.000Z',
+    }
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+    writeProbeCache(profileDir, record)
+    expect(readProbeCache(profileDir, '@scope/pkg')).toEqual(record)
+    expect(readProbeCache(profileDir, '@scope/pkg', '1.0.0')).toEqual(record)
+    expect(readProbeCache(profileDir, '@scope/pkg', '2.0.0')).toBeUndefined()
+    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), '{ not json')
+    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
+  })
+})

+ 113 - 0
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -0,0 +1,113 @@
+/**
+ * The `profileRuntime` service: profile facts, row provenance, user-disabled
+ * rows, and recomposition through the root include entry.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { ProfileRuntime, type Profile, type ProfileLayer } from '../src/index.ts'
+
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+function layer(packageName: string, trust: ProfileLayer['trust'], patches: PatchOptions[], stage: ProfileLayer['stage'] = 'runtime'): ProfileLayer {
+  return { packageName, version: '2.0.0', packageDir: '/nowhere', patchPath: '/nowhere/p.yml', trust, stage, patches }
+}
+
+function profile(layers: ProfileLayer[]): Profile {
+  return { name: 'web', dir: '/profiles/web', layers, patchPath: '/profiles/web/cordis.patch.yml', patches: [], patchReload: 'live' }
+}
+
+async function harness(
+  layers: ProfileLayer[],
+  options: { rootEntry?: () => Entry | undefined; userPatches?: PatchOptions[]; reloaded?: Profile } = {},
+): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
+  const ctx = new Context()
+  contexts.push(ctx)
+  const compose = vi.fn((current: Profile) => [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[])
+  await ctx.plugin(ProfileRuntime, {
+    profile: profile(layers),
+    loadProfile: () => options.reloaded ?? profile(layers),
+    compose,
+    rootEntry: options.rootEntry ?? (() => undefined),
+    readUserPatches: () => options.userPatches ?? [],
+  })
+  return { ctx, runtime: ctx.profileRuntime, compose }
+}
+
+describe('ProfileRuntime', () => {
+  it('exposes the booted profile\'s facts', async () => {
+    const { runtime } = await harness([layer('@deepseek-ai/dsh-base', 'builtin', [])])
+    expect(runtime.profileName).toBe('web')
+    expect(runtime.dir).toBe('/profiles/web')
+    expect(runtime.patchPath).toBe('/profiles/web/cordis.patch.yml')
+    expect(runtime.patchReload).toBe('live')
+    expect(runtime.layers.map(l => l.packageName)).toEqual(['@deepseek-ai/dsh-base'])
+    expect(runtime.current.name).toBe('web')
+  })
+
+  it('attributes rows to the layer that inserted them, prefixed for external layers', async () => {
+    const { runtime } = await harness([
+      layer('@deepseek-ai/dsh-base', 'builtin', [{ insert: [{ id: 'settings', name: 'x' }, { name: 'anonymous' } as EntryOptions, { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'y' }] }] }]),
+      layer('ext', 'external', [{ insert: [{ id: 'tool', name: 'ext' }] }]),
+      layer('boot-ext', 'external', [{ insert: [{ id: 'svc', name: 'boot-ext' }] }], 'boot'),
+    ])
+    expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('child')).toEqual({ trust: 'builtin', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('ext/tool')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0', originalId: 'tool' })
+    expect(runtime.originOf('bundle/ext')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0', originalId: 'bundle/ext' })
+    // A boot-stage external layer mounts unprefixed, like a built-in one.
+    expect(runtime.originOf('svc')).toEqual({ trust: 'external', packageName: 'boot-ext', version: '2.0.0' })
+    expect(runtime.originOf('user-row')).toBeUndefined()
+  })
+
+  it('does not descend into a group row whose config is not a list', async () => {
+    const { runtime } = await harness([
+      layer('odd', 'builtin', [{ insert: [{ id: 'g', name: 'cordis:group', group: true, config: {} as never }] }]),
+    ])
+    expect(runtime.originOf('g')?.packageName).toBe('odd')
+  })
+
+  it('omits the version when the layer has none', async () => {
+    const { runtime } = await harness([{ ...layer('local', 'builtin', [{ insert: [{ id: 'r', name: 'local' }] }]), version: undefined }])
+    expect(runtime.originOf('r')).toEqual({ trust: 'builtin', packageName: 'local' })
+  })
+
+  it('reads user-disabled rows from literal disabled: true items only', async () => {
+    const { runtime } = await harness([], {
+      userPatches: [
+        { id: 'a', disabled: true },
+        { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
+        { id: 'c', config: {} },
+        { insert: [{ id: 'd', name: 'x', disabled: true }] },
+      ],
+    })
+    expect([...runtime.userDisabledRowIds()]).toEqual(['a'])
+  })
+
+  it('recomposes through the root include, optionally re-reading the profile first', async () => {
+    const update = vi.fn(async () => {})
+    const entry = { options: { config: { path: 'file:///root/cordis.yml', patches: [{ id: 'old' }] } }, update } as unknown as Entry
+    const reloaded = profile([layer('a', 'builtin', []), layer('b', 'external', [])])
+    const { runtime, compose } = await harness([layer('a', 'builtin', [])], { rootEntry: () => entry, reloaded })
+
+    await runtime.recompose()
+    expect(compose).toHaveBeenLastCalledWith(expect.objectContaining({ layers: expect.any(Array) as ProfileLayer[] }))
+    expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-1' }] } })
+
+    await runtime.recompose({ reloadBundles: true })
+    expect(runtime.layers).toHaveLength(2)
+    expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
+    // Provenance follows the reloaded profile.
+    expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0', originalId: 'bundle/b' })
+  })
+
+  it('refuses to recompose before the root include is mounted', async () => {
+    const { runtime } = await harness([])
+    await expect(runtime.recompose()).rejects.toThrow(/root include is not mounted/)
+  })
+})

+ 57 - 0
packages/boot/app-boot/tests/profile.spec.ts

@@ -72,6 +72,9 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile
       packageDir: join(bundleAnchor, '..'),
       patchPath: join(bundleAnchor, '..', 'cordis.patch.yml'),
       patches: [],
+      version: undefined,
+      trust: 'builtin',
+      stage: 'runtime',
     }],
     patchPath: join(dir, PROFILE_PATCH_FILENAME),
     patches: [],
@@ -181,6 +184,51 @@ describe('loadProfile', () => {
     expect(bare.patchReload).toBe('live')
   })
 
+  it('classifies layers by provenance and resolves each bundle\'s mount stage', () => {
+    const anchor = stageInstallation({
+      'in-box': { patch: '[]\n' },
+      'ext-runtime': { patch: '[]\n' },
+      'ext-boot': { patch: '[]\n' },
+      'ext-overridden': { patch: '[]\n' },
+      'ext-first-party': { patch: '[]\n' },
+    })
+    // The bundle author asks for boot stage on one package.
+    const bootManifest = join(anchor, '..', 'node_modules', 'ext-boot', 'package.json')
+    writeFileSync(bootManifest, JSON.stringify({ ...JSON.parse(readFileSync(bootManifest, 'utf8')) as object, dsh: { bundle: { patch: './cordis.patch.yml', stage: 'boot' } } }))
+    const home = tmp()
+    const dir = resolveProfileDir('demo', home)
+    initProfile(dir, ['in-box', 'ext-runtime', 'ext-boot', 'ext-overridden', 'ext-first-party'])
+    writeProfileManifest(dir, {
+      ...readProfileManifest('t', dir),
+      dependencies: { 'ext-runtime': '0.0.0', 'ext-boot': '0.0.0', 'ext-overridden': '0.0.0', 'ext-first-party': '0.0.0' },
+      dsh: {
+        profile: {
+          bundles: ['in-box', 'ext-runtime', 'ext-boot', 'ext-overridden', 'ext-first-party'],
+          stages: { 'ext-overridden': 'boot' },
+          firstParty: ['ext-first-party'],
+        },
+      },
+    })
+    const profile = loadProfile('t', 'demo', anchor, home)
+    expect(profile.layers.map(layer => [layer.packageName, layer.trust, layer.stage, layer.version])).toEqual([
+      ['in-box', 'builtin', 'runtime', '0.0.0'],
+      ['ext-runtime', 'external', 'runtime', '0.0.0'],
+      ['ext-boot', 'external', 'boot', '0.0.0'],
+      ['ext-overridden', 'external', 'boot', '0.0.0'],
+      ['ext-first-party', 'builtin', 'runtime', '0.0.0'],
+    ])
+  })
+
+  it('fails loud on a stage value it does not know', () => {
+    const anchor = stageInstallation({ 'ext-odd': { patch: '[]\n' } })
+    const oddManifest = join(anchor, '..', 'node_modules', 'ext-odd', 'package.json')
+    writeFileSync(oddManifest, JSON.stringify({ ...JSON.parse(readFileSync(oddManifest, 'utf8')) as object, dsh: { bundle: { patch: './cordis.patch.yml', stage: 'later' } } }))
+    const home = tmp()
+    const dir = resolveProfileDir('demo', home)
+    initProfile(dir, ['ext-odd'])
+    expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('declares stage "later"; expected "boot" or "runtime"')
+  })
+
   it('auto-initializes only shipped templates and fails loud otherwise', () => {
     const anchor = stageInstallation({})
     const home = tmp()
@@ -419,6 +467,9 @@ describe('healProfilesModuleFallback', () => {
         packageDir: bundleLink,
         patchPath: join(bundleLink, 'cordis.patch.yml'),
         patches: [],
+        version: undefined,
+        trust: 'builtin' as const,
+        stage: 'runtime' as const,
       }],
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
@@ -464,6 +515,9 @@ describe('healProfilesModuleFallback', () => {
         packageDir,
         patchPath: join(packageDir, 'cordis.patch.yml'),
         patches: [],
+        version: undefined,
+        trust: 'builtin' as const,
+        stage: 'runtime' as const,
       })),
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
@@ -504,6 +558,9 @@ describe('healProfilesModuleFallback', () => {
         packageName,
         patchPath: join(packageDir, 'cordis.patch.yml'),
         patches: [],
+        version: undefined,
+        trust: 'builtin' as const,
+        stage: 'runtime' as const,
       })),
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],

+ 33 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1287,6 +1287,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'profileRuntime',
+    summary: 'Facts and recomposition of the booted profile.',
+    description: 'Facts and recomposition of the booted profile.',
+    methods: [
+      {
+        signature: 'originOf(rowId: string): RowOrigin | undefined',
+        description: 'Where one mounted row came from.',
+        parameters: [{ name: 'rowId', description: 'the row\'s tree-wide id.' }],
+        returns: 'the origin, or undefined for a row no bundle layer inserted (a user or overlay row).',
+      },
+      {
+        signature: 'userDisabledRowIds(): Set<string>',
+        description: 'Row ids the user patch layers disable with a literal `disabled: true`. A `!!js` gate in a user file is a condition, not a user decision, and is left to the composition.',
+        parameters: [],
+        returns: 'the ids, re-read from disk on every call.',
+      },
+      {
+        signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<void>',
+        description: 'Recompose the host tree from the profile\'s layers and the user patch files as they stand now. The root Include re-applies the stack transactionally: a row whose options changed is updated in place, a row that appeared is created, a row that vanished is disposed, and a failure rolls the whole update back with the previous tree still running.',
+        parameters: [{ name: 'options', description: '`reloadBundles` re-reads the profile manifest first, so a bundle enabled or installed since boot joins the stack.' }],
+        throws: ['when the root include is not mounted, or the Loader rejected the update.'],
+      },
+    ],
+  },
   {
     key: 'sandbox',
     summary: 'Abstract process-sandbox service.',
@@ -3594,6 +3619,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'BrandedNumber',
     declaration: 'export type BrandedNumber<B extends string> = number & {\n    readonly [BRAND]: B;\n};',
   },
+  {
+    name: 'BundleTrust',
+    declaration: 'export type BundleTrust = \'builtin\' | \'external\';',
+  },
   {
     name: 'ClientArtifactBaseline',
     declaration: 'export interface ClientArtifactBaseline {\n    readonly path: string;\n    readonly mtimeMs: number;\n    readonly size: number;\n}',
@@ -4690,6 +4719,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'ResumeAgentOptions',
     declaration: 'export interface ResumeAgentOptions {\n    readonly resumeSessionId: SessionId;\n    readonly agentOptions?: AgentOptions;\n    readonly signal?: AbortSignal;\n    readonly setup?: AgentSetup;\n}',
   },
+  {
+    name: 'RowOrigin',
+    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly packageName: string;\n    readonly version?: string;\n    readonly originalId?: string;\n}',
+  },
   {
     name: 'RunnerFailureRule',
     declaration: 'export interface RunnerFailureRule {\n    allowedExitCodes?: readonly number[];\n    fatalSignatures: readonly string[];\n    informationalLines?: readonly string[];\n}',

+ 2 - 2
packages/host/plugin-inventory/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/host/plugin-inventory/README.md
-README.md: c5d8bfc094571709007a440ead4a701f127e0e18
-README.zh.md: 9b6d4ac72c2e5eca4b16c9fa12fcce7cccc1544f
+README.md: 0eaf395ec9ecbdaf56c26c9adc8498b0e6ebaaec
+README.zh.md: 60140b175c7f4f7b28cd35e710b92af4c2deaa5d

+ 3 - 1
packages/host/plugin-inventory/README.md

@@ -31,6 +31,8 @@ Call `pluginInventory/list` when a client or settings page needs to show what is
 
 Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped.
 
+When the profile launcher composed the tree, each row also says who supplied it: `trust` is `builtin` for a row of the installation's own bundles and `external` for a row of a bundle the user installed, `package` names that bundle with its version and, for an external row, the id the bundle's own patch declared before the launcher prefixed it, and a disabled row carries `disabledBy` — `user` when a user patch file disabled it with a literal `disabled: true`, `composition` for a bundle's own gate or tombstone. A row an isolated external bundle failed to start is gone from the tree; it is still listed, with `fiberPhase: 'failed'` and a `failure` naming the stage and message, from the launcher's failure registry. Without the launcher every row reads `builtin` and none carries a package or failure.
+
 ### Per-preset compositions
 
 With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent.
@@ -97,7 +99,7 @@ None; this package neither assembles nor sends a provider request.
 These limits define what a point-in-time inventory cannot tell a client. They are current package constraints, not a task backlog.
 
 - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists.
-- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane.
+- **No mutation** — the service cannot enable, disable, add, or remove plugins in either plane; provenance stops at the bundle layer, so a row a user patch or a `--patch` overlay inserted carries no package.
 - **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty.
 
 <a id="dev-note"></a>

+ 3 - 1
packages/host/plugin-inventory/README.zh.md

@@ -31,6 +31,8 @@ kind: "package-reference"
 
 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。
 
+当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
+
 ### 每个预设的组合
 
 组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新 standing 世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。
@@ -97,7 +99,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产
 这些限制说明一个点时刻清单无法告诉客户端什么。它们是当前包约束,不是任务积压。
 
 - **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。
-- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。
+- **无修改能力**——服务不能在任一平面启用、停用、添加或移除插件;来源只追溯到组合包层,用户 patch 或 `--patch` overlay 插入的行不带 package
 - **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。
 
 <a id="dev-note"></a>

+ 9 - 4
packages/host/plugin-inventory/package.json

@@ -47,22 +47,27 @@
     "zod": "^4.4.3"
   },
   "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
-    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   },
   "peerDependenciesMeta": {
     "@deepseek-ai/dsh-agent-presets": {
       "optional": true
+    },
+    "@deepseek-ai/dsh-app-boot": {
+      "optional": true
     }
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
-    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   }
 }

+ 43 - 1
packages/host/plugin-inventory/src/index.ts

@@ -4,6 +4,9 @@ import type { Context, FiberState } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/cordis-plugin-loader'
 // Type-only: the optional agent-preset roster resolved through `ctx.get`.
 import type {} from '@deepseek-ai/dsh-agent-presets'
+// Type-only: the optional profile runtime and contained-failure registry the
+// boot glue provides, both resolved through `ctx.get`.
+import type {} from '@deepseek-ai/dsh-app-boot'
 import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
 // Typert-generated ./typert and ./remote artifacts import Zod at runtime.
 import type {} from 'zod'
@@ -13,6 +16,7 @@ import type {
   PluginFiberPhase,
   PluginInventoryEntry,
   PluginInventorySnapshot,
+  PluginPackageRef,
 } from './types.ts'
 
 export type * from './types.ts'
@@ -22,6 +26,15 @@ function pluginEntryId(value: string): PluginEntryId {
   return value as PluginEntryId
 }
 
+/** The wire view of a row's package origin. */
+function packageRef(origin: { packageName: string; version?: string; originalId?: string }): PluginPackageRef {
+  return {
+    name: origin.packageName,
+    ...origin.version === undefined ? {} : { version: origin.version },
+    ...origin.originalId === undefined ? {} : { originalId: origin.originalId },
+  }
+}
+
 /** Runtime mirror: FiberState is a cross-package const enum. */
 const FIBER_STATE = {
   PENDING: 0 as FiberState.PENDING,
@@ -65,13 +78,42 @@ export class PluginInventoryGateway extends TypertRemoteService {
   @Remote('list')
   async list(): Promise<PluginInventorySnapshot> {
     const entries: PluginInventoryEntry[] = []
+    const runtime = this.ctx.get('profileRuntime')
+    const failures = this.ctx.get('pluginFailures')
+    const userDisabled = runtime?.userDisabledRowIds() ?? new Set<string>()
+    const listed = new Set<string>()
     for (const entry of this.ctx.loader.entries()) {
       if (entry.options.group) continue
+      listed.add(entry.id)
+      // Provenance and user patches address rows by the id the composition
+      // declares; the tree-wide `entry.id` adds the owning include's prefix.
+      const origin = runtime?.originOf(entry.options.id)
+      const enabled = !entry.disabled
+      const failure = failures?.get(entry.id)
       entries.push({
         entryId: pluginEntryId(entry.id),
         moduleName: entry.options.name,
-        enabled: !entry.disabled,
+        enabled,
         fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
+        trust: origin?.trust ?? 'builtin',
+        ...origin === undefined ? {} : { package: packageRef(origin) },
+        ...enabled ? {} : { disabledBy: userDisabled.has(entry.options.id) ? 'user' as const : 'composition' as const },
+        ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
+      })
+    }
+    // A row an isolated bundle failed to start is gone from the tree; the
+    // registry is its only record, and the list must still show it.
+    for (const failure of failures?.list() ?? []) {
+      if (listed.has(failure.entryId)) continue
+      const origin = runtime?.originOf(failure.rowId)
+      entries.push({
+        entryId: pluginEntryId(failure.entryId),
+        moduleName: failure.moduleName,
+        enabled: true,
+        fiberPhase: 'failed',
+        trust: origin?.trust ?? 'external',
+        ...origin === undefined ? {} : { package: packageRef(origin) },
+        failure: { stage: failure.stage, message: failure.message },
       })
     }
     const presets = this.ctx.get('agentPresets')

+ 32 - 0
packages/host/plugin-inventory/src/types.ts

@@ -12,6 +12,30 @@ export type PluginFiberPhase =
   | 'unloading'
   | null
 
+/** Who supplied a Loader row: the installation itself or an installed external bundle. */
+export type PluginTrust = 'builtin' | 'external'
+
+/** Why a row is disabled: the composition's own gate or tombstone, or the user's patch layer. */
+export type PluginDisabledBy = 'composition' | 'user'
+
+/** The package a row belongs to, when a bundle layer inserted it. */
+export interface PluginPackageRef {
+  /** The bundle's package name. */
+  readonly name: string
+  /** The package version, when its manifest declares one. */
+  readonly version?: string
+  /** For an external row: the id the bundle's own patch declared, before prefixing. */
+  readonly originalId?: string
+}
+
+/** A recorded startup failure of a row inside an isolated external bundle. */
+export interface PluginFailure {
+  /** The lifecycle step that failed. */
+  readonly stage: 'import' | 'apply' | 'inject-pending' | 'unknown'
+  /** The failure text. */
+  readonly message: string
+}
+
 /** One non-group Loader entry exposed to trusted clients. */
 export interface PluginInventoryEntry {
   readonly entryId: PluginEntryId
@@ -20,6 +44,14 @@ export interface PluginInventoryEntry {
   /** Effective Loader enablement, including disabled ancestor groups. */
   readonly enabled: boolean
   readonly fiberPhase: PluginFiberPhase
+  /** Who supplied the row; `builtin` when no profile runtime is composed. */
+  readonly trust: PluginTrust
+  /** The bundle package that inserted the row, when one did. */
+  readonly package?: PluginPackageRef
+  /** Present exactly when `enabled` is false. */
+  readonly disabledBy?: PluginDisabledBy
+  /** Present for a row an isolated bundle failed to start. */
+  readonly failure?: PluginFailure
 }
 
 /** Effective enablement of one preset composition row. */

+ 6 - 0
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -63,18 +63,22 @@ describe('PluginInventoryGateway', () => {
         moduleName: 'cordis:active',
         enabled: true,
         fiberPhase: 'active',
+        trust: 'builtin',
       },
       {
         entryId: pendingId,
         moduleName: 'cordis:pending',
         enabled: true,
         fiberPhase: 'pending',
+        trust: 'builtin',
       },
       {
         entryId: disabledId,
         moduleName: 'cordis:not-installed',
         enabled: false,
         fiberPhase: null,
+        trust: 'builtin',
+        disabledBy: 'composition',
       },
     ]))
 
@@ -84,6 +88,8 @@ describe('PluginInventoryGateway', () => {
       moduleName: 'cordis:active',
       enabled: false,
       fiberPhase: null,
+      trust: 'builtin',
+      disabledBy: 'composition',
     })
 
     await ctx.loader.remove(pendingId)

+ 3 - 0
packages/host/plugin-inventory/tsconfig.json

@@ -17,6 +17,9 @@
     {
       "path": "../../preset/agent-presets"
     },
+    {
+      "path": "../../boot/app-boot"
+    },
     {
       "path": "../../util/brand"
     },

+ 3 - 0
packages/test-support/loader-smoke/tests/fixtures/production-profile.ts

@@ -43,8 +43,11 @@ function overlayModuleLayers(path: string, patches: readonly PatchOptions[]): Pr
   }
   return [...packages].map(([name, packageDir], index) => ({
     packageName: `test-overlay:${index}:${name}`,
+    version: undefined,
     packageDir,
     patchPath: path,
+    trust: 'builtin' as const,
+    stage: 'runtime' as const,
     patches: [],
   }))
 }

+ 3 - 0
pnpm-lock.yaml

@@ -5792,6 +5792,9 @@ importers:
       '@deepseek-ai/dsh-agent-presets':
         specifier: workspace:^
         version: link:../../preset/agent-presets
+      '@deepseek-ai/dsh-app-boot':
+        specifier: workspace:^
+        version: link:../../boot/app-boot
       '@deepseek-ai/dsh-brand':
         specifier: workspace:^
         version: link:../../util/brand

+ 4 - 0
scripts/gen-cordis-catalog.ts

@@ -57,6 +57,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   agentLoop: 'core.md',
   agentDefaultModel: 'core.md',
   agentPresets: 'core.md',
+  profileRuntime: 'core.md',
   agents: 'core.md',
   approval: 'approval.md',
   attachments: 'attachment.md',
@@ -151,6 +152,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
   configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract',
   launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract',
   dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
+  pluginFailures: 'not a service: boot-provided contained-failure registry (ContainedFailureRegistry | undefined) the plugin inventory reads — packages/boot/app-boot/README.md owns the boot contract',
   launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract',
   connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API',
   uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
@@ -652,6 +654,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
   'Promise',
   'Record',
   'Readonly',
+  'Set',
   'Uint8Array',
 ])
 
@@ -664,6 +667,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   InsertTextRequest: 'event-local request contract is owned by packages/client/ui-input-trigger/src/types.ts',
   AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
   AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
+  RowOrigin: 'row provenance record of the profile runtime is owned by packages/boot/app-boot/README.md',
   AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md',
   AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md',
   AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md',

+ 8 - 0
scripts/gen-doc-graphs.ts

@@ -377,6 +377,14 @@ const SERVICE_ROLES: ServiceRole[] = [
     mode: 'core',
     note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.',
   },
+  {
+    key: 'profileRuntime',
+    pkg: 'app-boot',
+    title: 'Booted profile facts and recomposition',
+    mode: 'core',
+    consumers: ['host-plugin-inventory'],
+    note: 'Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row\'s inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share.',
+  },
   {
     key: 'commands',
     pkg: 'commands',