Bläddra i källkod

merge: adapt plugin management UI to native entry outcomes

Yichen Jiang 1 månad sedan
förälder
incheckning
8c3ddf0971
100 ändrade filer med 1770 tillägg och 2886 borttagningar
  1. 2 2
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml
  2. 1 0
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
  3. 2 1
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md
  4. 2 2
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml
  5. 2 1
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.md
  6. 3 2
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md
  7. 6 0
      .agents/notes/archived/manifest.json
  8. 2 2
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml
  9. 1 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
  10. 2 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml
  12. 3 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
  13. 3 1
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md
  14. 6 0
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.i18n.yaml
  15. 43 0
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md
  16. 43 0
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md
  17. 1 1
      apps/cli/src/dump-config.ts
  18. 6 7
      apps/cli/src/plugin.ts
  19. 11 19
      apps/cli/src/profile-boot.ts
  20. 19 21
      apps/cli/tests/plugin.spec.ts
  21. 6 1
      apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json
  22. 1 1
      apps/web/tests/plugin-manager.e2e.ts
  23. 9 10
      apps/web/tests/scaffold.ts
  24. 2 2
      docs/config-catalog.i18n.yaml
  25. 1 2
      docs/config-catalog.md
  26. 1 2
      docs/config-catalog.zh.md
  27. 2 2
      docs/event-producer-consumer.i18n.yaml
  28. 6 3
      docs/event-producer-consumer.md
  29. 6 3
      docs/event-producer-consumer.zh.md
  30. 2 2
      docs/subsystems/core.i18n.yaml
  31. 26 18
      docs/subsystems/core.md
  32. 26 18
      docs/subsystems/core.zh.md
  33. 2 2
      packages/boot/app-boot/README.i18n.yaml
  34. 14 25
      packages/boot/app-boot/README.md
  35. 14 25
      packages/boot/app-boot/README.zh.md
  36. 0 1
      packages/boot/app-boot/package.json
  37. 20 12
      packages/boot/app-boot/src/compose-stack.ts
  38. 0 279
      packages/boot/app-boot/src/contained-group.ts
  39. 114 0
      packages/boot/app-boot/src/entry-issues.ts
  40. 24 135
      packages/boot/app-boot/src/external-bundles.ts
  41. 27 175
      packages/boot/app-boot/src/index.ts
  42. 127 0
      packages/boot/app-boot/src/package-metadata.ts
  43. 1 1
      packages/boot/app-boot/src/patch-rows.ts
  44. 0 50
      packages/boot/app-boot/src/probe-child.ts
  45. 0 63
      packages/boot/app-boot/src/probe-report.ts
  46. 0 475
      packages/boot/app-boot/src/probe.ts
  47. 48 34
      packages/boot/app-boot/src/profile-runtime.ts
  48. 1 1
      packages/boot/app-boot/src/profile.ts
  49. 27 16
      packages/boot/app-boot/tests/app-boot.spec.ts
  50. 6 7
      packages/boot/app-boot/tests/compose-stack.spec.ts
  51. 0 360
      packages/boot/app-boot/tests/contained-group.spec.ts
  52. 211 0
      packages/boot/app-boot/tests/entry-issues.spec.ts
  53. 43 223
      packages/boot/app-boot/tests/external-bundles.spec.ts
  54. 116 0
      packages/boot/app-boot/tests/package-metadata.spec.ts
  55. 0 361
      packages/boot/app-boot/tests/probe.spec.ts
  56. 16 10
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  57. 0 12
      packages/boot/app-boot/tsdown.config.ts
  58. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  59. 12 12
      packages/boot/plugin-manager/README.md
  60. 12 12
      packages/boot/plugin-manager/README.zh.md
  61. 1 1
      packages/boot/plugin-manager/package.json
  62. 0 2
      packages/boot/plugin-manager/src/helpers.ts
  63. 2 5
      packages/boot/plugin-manager/src/index.ts
  64. 27 46
      packages/boot/plugin-manager/src/installer.ts
  65. 43 40
      packages/boot/plugin-manager/src/manager.ts
  66. 5 29
      packages/boot/plugin-manager/src/modules.ts
  67. 29 25
      packages/boot/plugin-manager/src/types.ts
  68. 56 57
      packages/boot/plugin-manager/src/view.ts
  69. 169 64
      packages/boot/plugin-manager/tests/plugin-manager.spec.ts
  70. 1 0
      packages/bundle/web-app/tests/web-app.spec.ts
  71. 2 2
      packages/client/ui-plugin-manager/README.i18n.yaml
  72. 4 4
      packages/client/ui-plugin-manager/README.md
  73. 4 4
      packages/client/ui-plugin-manager/README.zh.md
  74. 54 40
      packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx
  75. 1 1
      packages/client/ui-plugin-manager/src/client/PresetPluginsSection.tsx
  76. 12 8
      packages/client/ui-plugin-manager/src/client/locales.ts
  77. 5 1
      packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx
  78. 50 19
      packages/client/ui-plugin-manager/tests/components.client.spec.tsx
  79. 8 7
      packages/client/ui-plugin-manager/tests/preset-section.client.spec.tsx
  80. 2 1
      packages/client/ui-settings-general/tests/apply.client.spec.ts
  81. 4 3
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  82. 37 12
      packages/extensions/tool-cordis/src/api-catalog.ts
  83. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  84. 4 4
      packages/host/plugin-inventory/README.md
  85. 4 4
      packages/host/plugin-inventory/README.zh.md
  86. 7 25
      packages/host/plugin-inventory/src/index.ts
  87. 3 3
      packages/host/plugin-inventory/src/types.ts
  88. 15 15
      packages/host/plugin-inventory/tests/inventory.spec.ts
  89. 2 2
      packages/host/plugin-manager/README.i18n.yaml
  90. 1 2
      packages/host/plugin-manager/README.md
  91. 1 2
      packages/host/plugin-manager/README.zh.md
  92. 1 1
      packages/host/plugin-manager/package.json
  93. 23 9
      packages/host/plugin-manager/src/index.ts
  94. 2 2
      packages/host/plugin-manager/src/types.ts
  95. 87 7
      packages/host/plugin-manager/tests/plugin-manager.spec.ts
  96. 5 5
      packages/test-support/remote-mock/tests/proxy.client.spec.ts
  97. 7 7
      packages/util/package-manifest/src/types.ts
  98. 2 2
      scripts/check-workspace-constraints.ts
  99. 1 1
      scripts/gen-cordis-catalog.ts
  100. 2 2
      vendor/README.md

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

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

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

@@ -1,6 +1,7 @@
 # Agent Note: Fail-loud is boot-scoped, and packages are probed in a child process
 
 Status: implemented
+Archived: 2026-09-11
 
 English | [中文](2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md)
 

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

@@ -1,6 +1,7 @@
-# Agent Note:fail-loud 只在启动期,包在子进程里探测
+# Agent Note: fail-loud 只在启动期,包在子进程里探测
 
 Status: implemented
+Archived: 2026-09-11
 
 [English](2026-09-04-boot-scoped-fail-loud-and-package-probe.md) | 中文
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml → .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
-2026-09-04-external-bundles-as-contained-groups.md: de6ac509518ca0ccbebb630f1c0afdf16842d8c3
-2026-09-04-external-bundles-as-contained-groups.zh.md: 1a9e4d5a0a3977265c838650ef023e2ef272eddc
+2026-09-04-external-bundles-as-contained-groups.md: 28e6f6b2bcf07455fdc8f297b6820569ff856ee4
+2026-09-04-external-bundles-as-contained-groups.zh.md: daa00851d264a90b8b8fd6719b797842bdc35074

+ 2 - 1
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md → .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.md

@@ -1,6 +1,7 @@
 # Agent Note: External bundles mount as contained groups with owned row ids
 
 Status: implemented
+Archived: 2026-09-11
 
 English | [中文](2026-09-04-external-bundles-as-contained-groups.zh.md)
 
@@ -14,7 +15,7 @@ A bundle installed with `dsh plugin add` mounted its rows exactly like the insta
 
 **Row ids are owned, never rewritten.** `composeProfileStack` decides ownership before anything mounts: a layer introduces the rows it inserts and the rows its config overrides set as a group's children; built-in and boot-staged layers claim their ids first and a duplicate among them fails the boot, while a config override restating a row the same layer declared is that layer keeping its own child; a contained bundle that declares an id another layer owns, or declares one of its own ids twice, is left out whole; a user-layer insert of a taken id is dropped, and a user override's rows are the user's own to restate. The rows left out are the composition's conflicts, each carrying its message: printed on stderr at boot, held by `ProfileRuntime` as part of the committed composition, and shown per package in the plugin list. They never enter `pluginFailures`, whose records name rows that reached the Loader. Boot, live recomposition, and `--dump-config` compose through the one function, which renders each contained layer once and returns the patches, the owner of every id, and the conflicts together.
 
-**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. A record names the contained group that isolates the row, whatever plain groups sit between. When the group updates, the records of rows its configuration dropped go; when it unmounts — its bundle disabled or uninstalled — all its rows' records go with it, so no failure outlives the composition that produced it. `assertEntriesActivated` exempts contained rows (a failed or pending one becomes a record) and keeps the fatal path for built-in rows. A pending row's record is dropped once its fiber activates in place — the service it waited for switched back on, say — since a wait that is over is not a failure to report. 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.
+**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. A record names the contained group that isolates the row, whatever plain groups sit between. When the group updates, the records of rows its configuration dropped go; when it unmounts — its bundle disabled or uninstalled — all its rows' records go with it, so no failure outlives the composition that produced 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 with fatal semantics. An unknown stage value fails profile loading.
 

+ 3 - 2
.agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md → .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md

@@ -1,6 +1,7 @@
-# Agent Note:外部组合包挂载为受控组,行 id 有归属
+# Agent Note: 外部组合包挂载为受控组,行 id 有归属
 
 Status: implemented
+Archived: 2026-09-11
 
 [English](2026-09-04-external-bundles-as-contained-groups.md) | 中文
 
@@ -14,7 +15,7 @@ Status: implemented
 
 **行 id 有归属,不改写。** `composeProfileStack` 在任何行挂载之前判定归属:一层引入的行包括它插入的行和它的 config 覆盖设为某个组子行的行;内置层与 boot 阶段的层先占有 id,它们之间重复即启动失败,而 config 覆盖重述同一层已声明的行只是这一层保留自己的子行;受控组合包声明了别的层已占有的 id、或把自己的某个 id 声明了两次时整层排除;用户层插入已被占用的 id 时该行丢弃,用户覆盖里的行由用户自己重述。被排除的行就是这次组合的冲突,每条自带消息:启动时打到 stderr,由 `ProfileRuntime` 作为已提交组合的一部分持有,在插件列表里按包显示。它们从不进入 `pluginFailures`,那里的记录只指真正到达 Loader 的行。启动、运行时重组与 `--dump-config` 走同一个函数,它把每个受控层只渲染一次,并一并返回 patch、每个 id 的归属与冲突。
 
-**受控组隔离行的失败。** `ContainedGroup extends Group` 覆盖 `create()`——这是事务性 `update()` 逐行等待的那一步:被拒的行记录到根上的 `pluginFailures` 注册表——树内 id、声明的行 id、模块、组、从 Loader 包装信息解析出的阶段、消息——组在没有它的情况下激活。记录写明隔离该行的受控组,中间隔着多少普通组都一样。组更新时,配置里不再有的行的记录随之丢掉;组卸载时——它的组合包被停用或卸载——它所有行的记录一并丢掉,因此没有失败会比产生它的组合活得更久。`assertEntriesActivated` 豁免受控行(失败或 pending 的行变成一条记录),内置行保留致命路径。挂起的行一旦其 fiber 就地激活——比如它等待的服务被重新打开——记录随即清除,因为已经结束的等待不是需要报告的失败。一条兜底规则封住"隔离反而藏起核心已坏"的 corner case:只要有组合包被隔离,而某个内置行停在 pending,启动仍然失败,诊断点名被隔离的组合包以及 `stage: boot` 这条出路。
+**受控组隔离行的失败。** `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 加载失败。
 

+ 6 - 0
.agents/notes/archived/manifest.json

@@ -361,6 +361,12 @@
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml": "sha256:ba8a62a9fa3263de4162709cb1aaf81f6ad4feee5011d7a5d8b24eb92ad81d30",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.md": "sha256:9db9c47559c17f0e2931b9e646f1d2d17b590df4d5699d4e779ed6d7f65fc371",
     "architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md": "sha256:9a266590d49093a097bcdb5d09865757cdd07625cfaaddce1303046d580551f1",
+    "architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml": "sha256:4836b212b8448187a8a5f5ed58c31fe408ff6f0fb5d0939029f9f7a378cab291",
+    "architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md": "sha256:f8aba8765ded42963ede65390e26a0b2ac410831f516440cd1069b26680078d1",
+    "architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md": "sha256:1d9ac5ac4afec95abeb121ef6e29049097d90de6a25f6395edb7cb31c24e3dbc",
+    "architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml": "sha256:e47c077cbca4f981ce472506723271be1c12a097c8ebadac8f371162d085bc8d",
+    "architecture/2026-09-04-external-bundles-as-contained-groups.md": "sha256:72e175d5927c8a80d2fb84f34f995349a4da79b6f9772d2ac2fcf54840ee2967",
+    "architecture/2026-09-04-external-bundles-as-contained-groups.zh.md": "sha256:332a5eaa768cd3dd3a8f040253a7126e7c0eaf3045c1eeaeaf910500120c1183",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
     "bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
-2026-09-04-plugin-management-in-web-settings.md: ea0af030e89c5e352c71fe9be0daaaf3beb5af83
-2026-09-04-plugin-management-in-web-settings.zh.md: 571e6df39551b6b592ac5e03c72c23d1d7f7d34d
+2026-09-04-plugin-management-in-web-settings.md: 51833d98ebda06d735de994e8ed551379065c12f
+2026-09-04-plugin-management-in-web-settings.zh.md: 7b5ac292d49b53046c277ea642c173a3a574d23d

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md


+ 2 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md

@@ -24,6 +24,8 @@ Status: implemented
 
 **web e2e 脚手架可以挂 profile runtime。** `launchWebScaffold({ profileRuntime })` 把 fixture 包链接进脚手架 profile,并以 `patchReload: 'startup'` 在其上挂载 `ProfileRuntime`,于是管理器有 profile 可管,而启动好的树在场景之下绝不重新组合。
 
+包发现与运行健康遵循[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)。每个声明模块均可添加,不依赖执行预检结论;未知包保持已安装。活跃行仍显示更新失败,失败的覆盖会标识目标条目而不转移归属。
+
 ## 考虑过的替代方案
 
 **在 `ui-settings` 内部放一个全局的 scope 选择。** 否决:插件标签页之外的表面绑定的是仅全局的命名空间,页面级选择会把它们改指向别处。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
-2026-09-04-plugin-manager-over-the-profile-runtime.md: af44e57a6b1be94cd0df24c3dfeede50a1c8285d
-2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 5754187f79e167fe9f4a54dbccc8c6dc782edff2
+2026-09-04-plugin-manager-over-the-profile-runtime.md: cd3fd51cebd0c1d7d9fc36d211c8b1c799abf7c1
+2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 7f80b271687716032439f99c2d7046cf61b91bf3

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md

@@ -4,6 +4,8 @@ Status: implemented
 
 English | [中文](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)
 
+Enablement transactions, contained failure records and execution probes in this record are superseded by [native entry diagnostics and static declarations](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md). The manager/Remote separation, pnpm process choice, mutation exclusion and patch-file ownership remain applicable.
+
 ## Problem
 
 Installing a plugin was a terminal-only act: `dsh plugin --profile web add <spec>` ran pnpm, appended every bundle it found to `dsh.profile.bundles`, and the next start composed it. Nothing running could learn what was installed but not enabled, switch a bundle off without editing `package.json` by hand, add one package's module to the profile's user layer or to one agent preset, or say which rows a package's service kept alive. The Web surface could list rows through `pluginInventory/list` and nothing more, while the launcher's `profileRuntime` (previous note) already recomposed the tree on a user patch reload and `reconcileInstalledBundles` already separated installation from enablement. The missing piece was the host service that performs the operations and reports each package as one thing.
@@ -38,4 +40,4 @@ A running Web host can install, enable, disable, retry, and remove third-party b
 
 ## Testing
 
-`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` boots a temporary profile through `boot()` with the profile runtime the launcher provides and a fake pnpm that edits the manifest the way the real one does: the view fold (installed, enabled, probed, waiting, user-disabled, first-party, hand-written manifests), install with and without enabling and its failures (exit code, spawn error, timeout, log tail, the manifest restored after a failed run), the post-install removal of a library and of a bundle whose row id another layer owns while a refused probe keeps its package, the one-mutation-at-a-time refusal and the running-session refusal, enable and disable live and on a `startup` profile, the boot-stage rollback, retry of a flaky isolated row, rows in the global and a preset's layer with conflicts, dependents by provided service and by user-layer reference, and uninstall. `packages/host/plugin-manager/tests/plugin-manager.spec.ts` pins the relay: every Remote method with its arguments, each `plugins/*` code crossing as a Remote error with the failure as its cause, the generic refusal as `gateway/bad-request`, and the readers into the context. `apps/cli/tests/plugin.spec.ts` pins `dsh plugin add` and `remove` through the installer with a fake pnpm and probe: the new bundle enabled, a plain library removed again with its reason, and the exit codes of a failed or missing pnpm. `packages/boot/app-boot/tests/contained-group.spec.ts` pins the waiting-row record across a reload.
+`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` covers manager operations over real profile files and a native Loader tree. The Host adapter tests cover direct delegation, error codes and dependency-recovery notifications; `apps/cli/tests/plugin.spec.ts` covers the shared installer. Lifecycle and static-discovery verification follows the superseding note.

+ 3 - 1
.agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md

@@ -4,6 +4,8 @@ Status: implemented
 
 [English](2026-09-04-plugin-manager-over-the-profile-runtime.md) | 中文
 
+本文中的启用事务、contained 失败记录和执行 probe 已由[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)取代。管理器与 Remote 的拆分、pnpm 进程选择、修改互斥和 patch 文件归属仍然适用。
+
 ## 问题
 
 安装插件曾是只有终端能做的事:`dsh plugin --profile web add <spec>` 运行 pnpm,把找到的每个组合包追加进 `dsh.profile.bundles`,下次启动再组合。运行中的任何东西都无法得知哪些包装了但没启用,无法不手改 `package.json` 就关掉一个组合包,无法把某个包的模块加进 profile 的用户层或某个 agent preset,也说不出一个包的服务撑着哪些行。Web 界面能经 `pluginInventory/list` 列出行,仅此而已;而 launcher 的 `profileRuntime`(前一篇笔记)已经能在用户 patch 重载时重新组合树,`reconcileInstalledBundles` 也已经把安装与启用分开。缺的是执行这些操作并把每个包报告成一个整体的宿主服务。
@@ -38,4 +40,4 @@ Status: implemented
 
 ## 测试
 
-`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 经 `boot()` 启动一个临时 profile,带上 launcher 提供的 profile runtime 与一个按真实 pnpm 的方式编辑 manifest 的假 pnpm:视图折叠(已安装、已启用、已探测、等待中、用户停用、一方包、手写 manifest),带与不带启用的安装及其失败(退出码、spawn 错误、超时、日志尾部、失败后恢复的 manifest),装后移除库包与行 id 被别的层占有的组合包而探针拒绝的包保留,一次只跑一个变更的拒绝与会话运行中的拒绝,live 与 `startup` profile 上的启用与停用,boot 阶段的回滚,不稳定隔离行的重试,全局层与 preset 层里的行及其冲突,按提供服务与按用户层引用的依赖检测,以及卸载。`packages/host/plugin-manager/tests/plugin-manager.spec.ts` 钉住转接层:每个 Remote 方法及其参数、每个 `plugins/*` 码以失败为 cause 过线成 Remote 错误、通用拒绝过线成 `gateway/bad-request`,以及读向上下文的读取器。`apps/cli/tests/plugin.spec.ts` 用假 pnpm 与假探针钉住经安装器的 `dsh plugin add` 与 `remove`:新组合包被启用、普通库带原因被再移除,以及 pnpm 失败或缺失时的退出码。`packages/boot/app-boot/tests/contained-group.spec.ts` 钉住等待中的行记录跨重载保留。
+`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 覆盖实际 profile 文件与原生 Loader 树上的管理操作。Host 适配器测试覆盖直接转接、错误码与依赖恢复通知;`apps/cli/tests/plugin.spec.ts` 覆盖共用安装器。生命周期与静态发现的验证遵循取代这些机制的新笔记。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.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-11-native-entry-diagnostics-and-static-plugin-declarations.md
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: db2d9bc211b896a8ae8bbb230c7ab3a1e5183fd0
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: 416d3c769a9474b9be85209ebaa55305a6eea1ac

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md

@@ -0,0 +1,43 @@
+# Agent Note: Native entry diagnostics and static plugin declarations
+
+Status: implemented
+
+English | [中文](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)
+
+## Problem
+
+Third-party plugin failures must leave the application’s management endpoints available without declaring a broken required composition ready. Native Loader groups retain successful siblings after a row fails. A separate group implementation would duplicate that behavior, while checking package exports during discovery would execute third-party code before the user mounted it. Requested options can also differ from a still-active fiber’s validated config after an update fails.
+
+## Decision
+
+**Startup strictness belongs to row provenance.** Only rows introduced by an external runtime bundle are optional. Built-in, boot-staged, unowned rows and the bootstrap Include are required. Profile stage overrides author stage, which defaults to runtime; first-party declarations and template provenance determine trust. Stage changes failure policy, not execution timing. A config override retains the target row’s owner. Nested Includes inherit their owning entry’s provenance; their local ids are never resolved against unrelated root rows.
+
+**Bundle enablement selects a whole patch layer.** Native rows and author-written groups retain their explicit ids and parents. Anonymous bundle rows receive deterministic ids on detached execution copies. Disabling a bundle removes its inserts and overrides across every target group; persistent user row overrides survive re-enablement. Ownership is checked before loading: strict layers claim ids first, optional collisions omit the whole losing bundle, and conflicting user inserts are omitted per row. Prefixing explicit ids would break author expressions and user patches; relying on authors to avoid collisions permits silent Loader reparenting.
+
+**Diagnostics describe attempts and running instances separately.** `Entry.lastFailure` passively retains import, activation or update errors where the Loader already catches or propagates them. It does not restore transactions or change native continuation. Asynchronous activation failures come from `fiber.await()`, pending dependencies from injection state, and disabled-expression failures from evaluating the effective gate. An active old fiber can coexist with an update failure. Removed entries need no separate failure registry. Nested continuation failures remain advisory; readiness does not promise every nested plugin callback succeeded.
+
+**Recomposition is serialized and nontransactional.** The launcher provides `ProfileRuntime` before startup auditing. Preparation rejects malformed files before mutation. Accepted updates settle current Loader work and await removed fibers before publishing composition facts and returning issues. Old fiber references are necessary because removed entries are absent from Loader task enumeration. Successful sibling changes remain applied after another entry fails. Fresh patch copies prevent a later override from mutating data reused by another composition.
+
+**Discovery reads declarations without executing modules.** `readPackageMetadata` reads package identity, bundle patches and explicit `dsh.plugins` entries. `.` names the main export; subpaths and default configs remain available for composition editors. Undeclared packages remain installed and unknown. A declaration does not certify importability, Config validity or activation. Physical Cordis peer resolution is advisory and cannot detect an inlined copy. Runtime validation belongs to each actual mounted row, including separate mounts of one module in different presets.
+
+**Unhandled process failures stay fatal.** `installFailLoud` remains until shutdown. Only duplicate rejections already observed by an entry audit are coalesced through its process checkpoint. Unrelated detached rejections retain master’s teardown-and-exit policy. No in-process group protects against `process.exit`, a blocked event loop, native crashes or OOM. Preset generation owners retain their own strict mount-and-cleanup behavior.
+
+## Alternatives considered
+
+**Keep contained groups and child probes.** The [group design](../../archived/architecture/2026-09-04-external-bundles-as-contained-groups.md) protected against transactional Loader rollback and supplied failure records for removed rows. Native entries retain both siblings and failed rows, so wrapping adds identities without supplying bundle-wide enablement that layer composition lacks. The [probe design](../../archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md) bounded discovery-time execution and discovered undeclared exports and schemas. Static declarations avoid that execution; automatic main-export detection and pre-mount schemas are given up. A future execution sandbox needs its own process ownership and teardown design, not a discovery probe presented as runtime isolation.
+
+**Import inside the Host with try/catch.** Rejected because discovery could execute irreversible side effects, exit the Host or block its event loop. Catching exceptions cannot restore that isolation. Parsing source to infer plugin exports would add another JavaScript interpreter with incomplete results.
+
+**Use a global list of required plugin ids.** Rejected because ids do not describe provenance, external boot providers, custom profiles or nested Include trees. Existing profile trust and stage declarations already own those distinctions.
+
+**Recover by rolling back every live update or ignoring all post-boot rejections.** Neither can establish a safe restored state after plugin effects run. Per-row outcomes and retained user choices are observable; process-level detached failures cannot be attributed reliably to a row and remain fatal.
+
+## Consequences
+
+Installed metadata, enabled layer selection and current runtime health are independent. An enabled bundle may be partially running, pending or failed; updating a row may leave old behavior active with a visible diagnostic. Whole-bundle disable removes its patch effects without requiring a synthetic parent group. Startup can still fail when an optional provider leaves a required consumer pending. Old probe records are not activation evidence, and plugins need explicit `dsh.plugins` declarations for discovery as addable modules.
+
+## Verification
+
+`packages/boot/app-boot/tests/entry-issues.spec.ts` covers trust and stage, import/config/apply/disabled/pending failures, nested and anonymous provenance, active old config after failed updates, recovery, and awaited removed-fiber teardown. Composition tests cover duplicate ownership and unchanged parents. Static metadata tests use an import-time file-writing fixture and assert no execution, retain unknown packages, and reject malformed declarations. Inventory tests verify actual Loader failures and composition conflicts without a historical failure registry. Process-guard and user-patch tests retain fatal detached failures and best-effort live reload diagnostics.
+
+The [manager/Remote split](2026-09-04-plugin-manager-over-the-profile-runtime.md) remains in force. Manager integration tests cover retained unknown packages, static declarations, per-entry enable results, failed overrides with original ownership, and runtime-change notifications after dependency recovery.

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 原生条目诊断与静态插件声明
+
+Status: implemented
+
+[English](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md) | 中文
+
+## Problem
+
+第三方插件失败后,应用管理入口需要保持可用,同时不能把损坏的必需组合宣布为已就绪。原生 Loader 组会在某行失败后保留成功的其他行。独立的组实现会重复这一行为,而在发现包时检查导出会在用户挂载前执行第三方代码。更新失败后,请求的选项还可能与仍然活跃的 fiber 所使用的已校验配置不同。
+
+## Decision
+
+**启动严格程度由行来源决定。** 只有外部 runtime 组合包引入的行是可选行。内置、boot 阶段、无归属行以及启动 Include 均为必需。profile 的 stage 覆盖作者声明,默认值为 runtime;firstParty 声明与模板来源决定 trust。stage 改变失败策略,不改变执行时间。配置覆盖保留目标行的所有者。嵌套 Include 继承所属条目的来源,不能用其局部 id 查询无关的根级行。
+
+**组合包启停选择整份 patch 层。** 原生行和作者声明的组保留显式 id 与父组。匿名组合包行在独立执行副本中获得确定性的 id。禁用组合包会从所有目标组中移除其插入与覆盖;持久化的用户行覆盖在重新启用后保留。加载前检查所有权:严格层优先占有 id,可选层冲突时整包排除,用户插入冲突时逐行排除。为显式 id 加前缀会破坏作者表达式与用户 patch;依赖作者自行避免冲突则允许 Loader 静默改变父组。
+
+**诊断分别描述操作尝试与运行实例。** `Entry.lastFailure` 在 Loader 已有的捕获或传播位置被动保留导入、激活或更新错误。它不恢复事务,也不改变原生继续加载行为。异步激活失败取自 `fiber.await()`,等待依赖取自注入状态,disabled 表达式失败取自有效条件的求值。活跃的旧 fiber 可以与更新失败同时存在。移除的条目无需独立失败注册表。嵌套延续任务失败仍为提示信息;就绪不保证每个嵌套插件回调均成功。
+
+**重组串行执行且不具备事务性。** launcher 在启动审计前提供 `ProfileRuntime`。准备阶段在修改运行树前拒绝无效文件。已接受的更新等待当前 Loader 工作与已移除 fiber 完成后,发布组合信息并返回问题。必须保留旧 fiber 引用,因为已移除的条目不在 Loader 任务枚举中。某行失败后,其他行已成功的变化保留。新的 patch 副本避免后续覆盖修改其他重组会复用的数据。
+
+**发现阶段只读声明,不执行模块。** `readPackageMetadata` 读取包身份、组合包 patch 和显式 `dsh.plugins` 条目。`.` 表示主入口;子路径与默认配置继续供组合编辑器使用。未声明的包保持已安装和未知状态。声明不证明可导入、Config 有效或激活成功。Cordis 物理 peer 解析仅供提示,无法识别内联副本。运行校验属于每个实际挂载行,包括同一模块在不同预设中的独立挂载。
+
+**未处理的进程级失败仍然致命。** `installFailLoud` 保持到应用关闭。只有条目审计已观测到的重复 rejection 会在其进程检查点内合并。无关的脱离管理 rejection 保留 master 的清理退出策略。进程内的组无法防护 `process.exit`、事件循环阻塞、原生崩溃或 OOM。预设代际的拥有者保留独立的严格挂载与清理行为。
+
+## Alternatives considered
+
+**保留 contained group 和子进程 probe。** [原组设计](../../archived/architecture/2026-09-04-external-bundles-as-contained-groups.md) 防止事务式 Loader 回滚,并为已移除行提供失败记录。原生条目会同时保留成功行与失败行,包装只增加身份,并未提供整层组合所缺少的整包启停能力。[原 probe 设计](../../archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md) 为发现阶段的执行设定边界,并发现未声明导出与 schema。静态声明避免这次执行,同时放弃自动主入口识别和挂载前 schema。未来的执行沙箱需要独立设计进程归属与清理,不能把发现 probe 视为运行隔离。
+
+**在 Host 内 import 并 try/catch。** 拒绝,因为发现阶段可能产生不可逆副作用、退出 Host 或阻塞事件循环。捕获异常无法恢复隔离。通过解析源码推断插件导出会增加一个结果不完整的 JavaScript 解释器。
+
+**使用全局必需插件 id 列表。** 拒绝,因为 id 无法描述来源、外部 boot 提供方、自定义 profile 或嵌套 Include 树。已有的 profile trust 与 stage 声明负责这些区别。
+
+**通过回滚全部实时更新或忽略所有启动后 rejection 恢复。** 插件副作用发生后,两者都不能保证恢复安全状态。逐行结果与保留的用户选择可以被观测;进程级脱离管理失败无法可靠归属到某行,因此仍然致命。
+
+## Consequences
+
+已安装元信息、启用层选择与当前运行健康相互独立。启用的组合包可能部分运行、等待依赖或失败;行更新可能保留旧行为并显示诊断。整包禁用移除其 patch 效果,无需合成父组。可选提供方失败导致必需消费方等待时,启动仍可能失败。旧 probe 记录不是激活证据,插件需要显式 `dsh.plugins` 声明才能被发现为可添加模块。
+
+## Verification
+
+`packages/boot/app-boot/tests/entry-issues.spec.ts` 覆盖 trust 与 stage、导入/配置/apply/disabled/pending 失败、嵌套与匿名来源、失败更新后的活跃旧配置、恢复及已移除 fiber 的清理等待。组合测试覆盖重复归属与父组保持。静态元信息测试使用导入时写文件的 fixture,确认未执行、保留未知包并拒绝无效声明。inventory 测试直接验证 Loader 失败与组合冲突,不依赖历史失败注册表。进程处理与用户 patch 测试保留脱离管理失败的致命策略,以及尽力执行的实时重载诊断。
+
+[管理器与 Remote 的拆分](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)继续适用。管理器集成测试覆盖未知包保留、静态声明、逐行启用结果、保留原归属的失败覆盖,以及依赖恢复后的运行变化通知。

+ 1 - 1
apps/cli/src/dump-config.ts

@@ -53,7 +53,7 @@ export function runDumpConfig(
       userLayers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
     }
   }
-  // The same composition boot mounts: external bundles as contained groups,
+  // The same composition boot mounts: bundle layers in their declared order,
   // a bundle or row left out by an id conflict reported, not shown.
   const stack = composeProfileStack(NAME, loaded.layers, userLayers)
   for (const conflict of stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)

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

@@ -2,9 +2,8 @@
  * `dsh plugin --profile <name> <args...>` — profile plugin management from
  * the terminal. `add <spec...>` and `remove <name...>` go through the plugin
  * installer the Web host shares: pnpm runs in the profile directory, every
- * new package is probed, a package that declares neither a bundle nor a
- * plugin module (or whose row id a composed layer already owns) is removed
- * again with the reason printed, and every newly installed bundle joins the
+ * new package is read statically, a conflicting bundle is removed with its
+ * reason printed, undeclared packages remain installed, and every new bundle joins the
  * layer list — the CLI's install-and-enable semantics. Every other pnpm verb
  * is forwarded verbatim and followed by a reconcile of the
  * `dsh.profile.bundles` layer list against the installed state, so `update`
@@ -26,7 +25,7 @@ import {
   readProfileManifest,
   reconcileInstalledBundles,
   resolveProfileDir,
-  type probePackage,
+  type readPackageMetadata,
   type ProfileManifest,
 } from '@deepseek-ai/dsh-app-boot'
 import {
@@ -37,12 +36,12 @@ import { INSTALL_ANCHOR } from './profile-boot.ts'
 const NAME = 'dsh'
 
 /** The tooling bounds the command runs with; the Web host reads the same values from its config. */
-const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
+const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installLogTailBytes: 16_384 }
 
-/** Test seams: the child spawner and the package probe, so no pnpm or probe child runs. */
+/** Test seams: the child spawner and the static metadata reader. */
 export interface PluginCommandInternals {
   spawn?: SpawnLike
-  probe?: typeof probePackage
+  metadata?: typeof readPackageMetadata
 }
 
 /**

+ 11 - 19
apps/cli/src/profile-boot.ts

@@ -24,7 +24,6 @@ import {
   healProfilesModuleFallback,
   initProfile,
   installFailLoud,
-  installRuntimeGuards,
   loadOptionalPatches,
   loadOverlayPatches,
   loadProfile,
@@ -307,9 +306,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   const composed = await composeProfile(options.profile, options.patchFiles, options.fromDefaultProfile)
   const app: { current?: Context; runtime?: ProfileRuntime } = {}
   const appReady = createAppReady()
-  let uninstallRuntimeGuards = (): void => {}
   const shutdown = createProcessShutdown(async () => {
-    uninstallRuntimeGuards()
     await app.current?.fiber.dispose()
     await disposeProxy()
   })
@@ -325,7 +322,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) })
-  const uninstallFailLoud = installFailLoud(NAME, process, async () => {
+  installFailLoud(NAME, process, async () => {
     await app.current?.fiber.dispose()
   })
 
@@ -358,8 +355,17 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   for (const conflict of composed.stack.conflicts) process.stderr.write(`${NAME}: ${formatRowConflict(conflict)}\n`)
   // Cloned for the same insert-aliasing reason as composeFor: the boot
   // application must not mutate the objects later reloads recompose from.
-  const ctx = await boot(NAME, rootConfig, structuredClone(composed.stack.patches), (hostCtx) => {
+  const ctx = await boot(NAME, rootConfig, structuredClone(composed.stack.patches), async (hostCtx) => {
     app.current = hostCtx
+    await hostCtx.plugin(ProfileRuntime, {
+      profile: composed.profile,
+      installAnchor: INSTALL_ANCHOR,
+      stack: composed.stack,
+      loadProfile: () => prepareProfile(options.profile),
+      compose: composeFor,
+      rootEntry: () => rootIncludeEntry(hostCtx),
+    })
+    app.runtime = hostCtx.profileRuntime
     // Before any config-tree entry mounts, so plugins resolve all launch-time
     // environment values from the same immutable provenance snapshot.
     hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
@@ -372,22 +378,8 @@ 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,
-      installAnchor: INSTALL_ANCHOR,
-      stack: composed.stack,
-      loadProfile: () => prepareProfile(options.profile),
-      compose: composeFor,
-      rootEntry: () => rootIncludeEntry(ctx),
-    })
-    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

+ 19 - 21
apps/cli/tests/plugin.spec.ts

@@ -1,7 +1,7 @@
 /**
  * `dsh plugin add` and `remove` through the shared installer: a temporary
  * harness home, a fake pnpm that stages packages and edits the manifest the
- * way the real one does, and a fake probe. Nothing boots.
+ * way the real one does, and a static metadata reader. Nothing boots.
  */
 
 import { EventEmitter } from 'node:events'
@@ -11,7 +11,7 @@ import { join } from 'node:path'
 import { PassThrough } from 'node:stream'
 import type { ChildProcess } from 'node:child_process'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { readProfileManifest, resolveProfileDir, type probePackage } from '@deepseek-ai/dsh-app-boot'
+import { readProfileManifest, resolveProfileDir, type readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
 import type { SpawnLike } from '@deepseek-ai/dsh-plugin-manager'
 import { runPlugin } from '../src/plugin.ts'
 
@@ -93,19 +93,17 @@ function fakePnpm(calls: string[][], failWith?: { code: number } | { error: Node
   }
 }
 
-/** A probe that reads the staged manifest: a package with `dsh.bundle` is a bundle, anything else a library. */
-const fakeProbe: typeof probePackage = (options) => {
+/** Static fixture declarations: a bundle or an unknown package. */
+const fakeMetadata: typeof readPackageMetadata = (options) => {
   const manifest = JSON.parse(readFileSync(join(options.profileDir, 'node_modules', options.packageName, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
-  return Promise.resolve({
+  return {
     packageName: options.packageName,
-    kind: manifest.dsh?.bundle === undefined ? 'library' : 'bundle',
-    ok: true,
+    kind: manifest.dsh?.bundle === undefined ? 'unknown' : 'bundle',
     cordisSameCopy: null,
     rows: [],
     overrides: [],
     addable: [],
-    checkedAt: new Date().toISOString(),
-  })
+  }
 }
 
 describe('dsh plugin', () => {
@@ -114,7 +112,7 @@ describe('dsh plugin', () => {
     const descriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY')
     Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
     try {
-      expect(await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })).toBe(0)
+      expect(await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })).toBe(0)
     } finally {
       if (descriptor === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY
       else Object.defineProperty(process.stdout, 'isTTY', descriptor)
@@ -123,32 +121,32 @@ describe('dsh plugin', () => {
     expect(stdout).toContain('\u001b[32m+\u001b[39m ext-bundle')
   })
 
-  it('add initializes the profile, installs through the installer, enables the new bundle, and removes a plain library again', async () => {
+  it('add enables the new bundle and retains an undeclared dependency', async () => {
     const calls: string[][] = []
 
-    const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), probe: fakeProbe })
+    const code = await runPlugin('web', ['add', 'ext-bundle', 'ext-lib'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
 
     expect(code).toBe(0)
     const profileDir = resolveProfileDir('web', home)
     expect(stderr).toContain(`dsh: initialized profile web at ${profileDir}`)
-    expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib'], ['remove', 'ext-lib']])
+    expect(calls).toEqual([['add', 'ext-bundle'], ['add', 'ext-lib']])
     const manifest = readProfileManifest('dsh', profileDir)
     expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-bundle')
-    expect(Object.keys(manifest.dependencies ?? {})).not.toContain('ext-lib')
+    expect(Object.keys(manifest.dependencies ?? {})).toContain('ext-lib')
     expect(manifest.dsh?.profile?.bundles).toContain('ext-bundle')
     // The log reaches its readers plain: colours are off for the child and stripped from what it still prints.
-    expect(spawnEnvs).toEqual(['0', '0', '0'])
+    expect(spawnEnvs).toEqual(['0', '0'])
     expect(stdout).toContain('+ ext-bundle')
     expect(stdout).not.toContain('\u001b[')
-    expect(stderr).toContain('dsh: removed ext-lib again: declares neither a dsh bundle nor a plugin module')
-    expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
+    expect(stderr).toContain('ext-lib declares no dsh.bundle')
+    expect(existsSync(join(profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
   })
 
   it('remove goes through the installer and forgets the probe record', async () => {
     const calls: string[][] = []
-    await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
+    await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
 
-    const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), probe: fakeProbe })
+    const code = await runPlugin('web', ['remove', 'ext-bundle'], { spawn: fakePnpm(calls), metadata: fakeMetadata })
 
     expect(code).toBe(0)
     expect(calls).toEqual([['add', 'ext-bundle'], ['remove', 'ext-bundle']])
@@ -160,13 +158,13 @@ describe('dsh plugin', () => {
   })
 
   it('reports a failed pnpm run with its exit code, the git hint, and a missing pnpm as 127', async () => {
-    const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), probe: fakeProbe })
+    const failed = await runPlugin('web', ['add', 'github:acme/plugin'], { spawn: fakePnpm([], { code: 1 }), metadata: fakeMetadata })
     expect(failed).toBe(1)
     expect(stderr).toContain('dsh: pnpm failed in profile directory')
     expect(stderr).toContain('git-hosted plugins build on install via their prepare script')
 
     const error = Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' })
-    const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), probe: fakeProbe })
+    const missing = await runPlugin('web', ['add', 'ext-bundle'], { spawn: fakePnpm([], { error }), metadata: fakeMetadata })
     expect(missing).toBe(127)
     expect(stderr).toContain('dsh: pnpm not found on PATH')
   })

+ 6 - 1
apps/web/tests/fixtures/plugins/fixture-plain-plugin/package.json

@@ -6,6 +6,11 @@
   "type": "module",
   "main": "index.js",
   "dsh": {
-    "title": "示例插件"
+    "title": "示例插件",
+    "plugins": [
+      {
+        "name": "."
+      }
+    ]
   }
 }

+ 1 - 1
apps/web/tests/plugin-manager.e2e.ts

@@ -146,7 +146,7 @@ describe('web e2e: plugin manager', () => {
     await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true')
     await panel.getByText('以下更改会在下次启动生效:示例组合包').waitFor({ timeout: 10_000 })
     expect(await panel.getByText('需重启', { exact: true }).count()).toBe(1)
-    // The pack's page lists its rows from the probe. A profile that applies
+    // The pack's page lists its rows from its static declarations. A profile that applies
     // patches at its next start keeps them read-only: no row switch.
     await panel.getByRole('button', { name: '查看 示例组合包' }).click()
     await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })

+ 9 - 10
apps/web/tests/scaffold.ts

@@ -741,17 +741,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     // and a preset resolving package names from its own directory cannot reach
     // `@deepseek-ai/cordis-plugin-group` by name.
     ctx.loader.builtins.group = Group
-    const rootIncludeId = await ctx.loader.create({
-      name: 'cordis:include',
-      config: { path: pathToFileURL(rootConfig).href, patches },
-    })
-    await ctx.loader.await()
-    await auditStartupEntries(ctx, 'web e2e scaffold')
+    let rootIncludeId: string | undefined = undefined
     if (options.profileRuntime !== undefined) {
-      // The launcher mounts the runtime once the tree is up; the scaffold
-      // profile is read from the harness home this boot pinned. Its
-      // composition stays the scaffold's own: the runtime only ever
-      // recomposes a live-reload profile, and this one applies at startup.
+      // Provenance is available before any configuration entry activates.
+      // The scaffold profile applies layer changes at startup.
       const readProfile = (): Profile => loadProfile('dsh', 'scaffold', INSTALL_ANCHOR, harnessHome)
       const profile = readProfile()
       // The scaffold's own patches, with the id ownership the runtime answers `originOf` from.
@@ -768,6 +761,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
         rootEntry: () => [...ctx.loader.entries()].find(entry => entry.id === rootIncludeId),
       })
     }
+    rootIncludeId = await ctx.loader.create({
+      name: 'cordis:include',
+      config: { path: pathToFileURL(rootConfig).href, patches },
+    })
+    await ctx.loader.await()
+    await auditStartupEntries(ctx, 'web e2e scaffold')
     if (options.welcomeNoticePending !== true) {
       await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
         op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: 613165f059f04662511f17cf5fb90b7082dad99b
-config-catalog.zh.md: 10a177cd05d9bf7a2e73ff4f0c7342261a5a80d0
+config-catalog.md: 1b7f5e6f8247718dacd2f71486799a2c030618b5
+config-catalog.zh.md: 329f6d844fd7f1c1a1b79286557e126c29b72123

+ 1 - 2
docs/config-catalog.md

@@ -965,8 +965,6 @@ export interface Config {
   pnpmCommand: string
   /** Bound on one install or remove run, in milliseconds. */
   installTimeoutMs: number
-  /** Bound on one package probe, in milliseconds. */
-  probeTimeoutMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   installLogTailBytes: number
 }
@@ -3606,6 +3604,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
 - `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
 - `@deepseek-ai/dsh-plugin-manager` ([`packages/boot/plugin-manager/src/index.ts`](../packages/boot/plugin-manager/src/index.ts))
+- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl` ([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-sdk-client` ([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))

+ 1 - 2
docs/config-catalog.zh.md

@@ -967,8 +967,6 @@ export interface Config {
   pnpmCommand: string
   /** Bound on one install or remove run, in milliseconds. */
   installTimeoutMs: number
-  /** Bound on one package probe, in milliseconds. */
-  probeTimeoutMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   installLogTailBytes: number
 }
@@ -3608,6 +3606,7 @@ export interface Config {
 - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
 - `@deepseek-ai/dsh-package-manifest` ([`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts))
 - `@deepseek-ai/dsh-plugin-manager`([`packages/boot/plugin-manager/src/index.ts`](../packages/boot/plugin-manager/src/index.ts))
+- `@deepseek-ai/dsh-remote-mock` ([`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts))
 - `@deepseek-ai/dsh-sandbox-windows-acl`([`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts))
 - `@deepseek-ai/dsh-scope`([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts))
 - `@deepseek-ai/dsh-sdk-client`([`packages/sdk/client/src/index.ts`](../packages/sdk/client/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: c7ff34f0ea7b7abce78c0852f009f5bc22192490
-event-producer-consumer.zh.md: fe473c2714472a1e7986faefd504b2fc0fafc49a
+event-producer-consumer.md: 8676b4122dc3c144c7d2f337fedd38d8c619768b
+event-producer-consumer.zh.md: 2f5dfcd9773b8677ffa8886c928b585b975bd9d9

+ 6 - 3
docs/event-producer-consumer.md

@@ -47,8 +47,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
-| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:244`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:250`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
@@ -85,6 +85,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
-| `internal/status` | - | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), `inspector` |
+| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`plugin-manager`](../packages/boot/plugin-manager) |
+| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
+| `loader/entry-init` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
+| `loader/partial-dispose` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 6 - 3
docs/event-producer-consumer.zh.md

@@ -49,8 +49,8 @@
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
-| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:244`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:250`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:248`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:254`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:50`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:60`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
@@ -87,6 +87,9 @@
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
-| `internal/status` | - | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), `inspector` |
+| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`plugin-manager`](../packages/boot/plugin-manager) |
+| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
+| `loader/entry-init` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
+| `loader/partial-dispose` | - | [`plugin-manager`](../packages/boot/plugin-manager) |
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

+ 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: c4f18a016fff0bb7d0142d38d3c54ad19efd2e85
-core.zh.md: 3121dcbce154e48e858690ad7a25a0568d623006
+core.md: 0e96a41ec96b246fd104a9625783211753eac429
+core.zh.md: 1b02373d427e297763114ff9c34af2f957d84ab7

+ 26 - 18
docs/subsystems/core.md

@@ -936,7 +936,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
 @Remote('list') async list(): Promise<PluginPackageView[]>
 
 /**
- * Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
+ * Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
  * @param spec - what to install, in pnpm's own vocabulary.
  * @param options - `enable` puts every newly installed bundle into the layer list at once.
  * @returns what the run installed and enabled.
@@ -944,7 +944,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
 @Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 
 /**
- * Remove a package from the profile with its user-layer rows and probe record.
+ * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
  * @param packageName - the installed dependency to remove.
  */
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>
@@ -1019,6 +1019,13 @@ Facts and recomposition of the booted profile.
  */
 originOf(rowId: string): RowOrigin | undefined
 
+/**
+ * Resolve provenance within its Loader tree; nested includes inherit their owning entry.
+ * @param entry - the live entry, including an entry inside another Include.
+ * @returns its supplying bundle, or undefined for a user-owned entry.
+ */
+originOfEntry(entry: Entry): RowOrigin | undefined
+
 /**
  * Row ids the user patch layers disable with a literal `disabled: true`,
  * as the committed composition read them. The set describes the running
@@ -1039,22 +1046,23 @@ userDisabledRowIds(): ReadonlySet<string>
 userDisables(entry: Entry): boolean
 
 /**
- * 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. The candidate profile,
- * its ownership, and its conflicts become the committed composition only
- * once the update holds; until then, and after a rejection, `current`,
- * `layers`, `originOf`, and `conflicts` keep describing the running tree.
- * Calls queue: one that arrives while another is in flight starts after it
- * settled and reads what it committed. A rejection is that call's outcome
- * alone and does not stop the ones behind it.
- * @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>
+ * Apply a fresh profile stack and wait for live entries and removed fibers to settle.
+ * Parse/composition failures leave the applied stack unchanged. Accepted options
+ * can coexist with failed entries or fibers running their previous valid config.
+ * Calls serialize; a failed call does not block later changes.
+ * @param options - whether to reread installed bundle layers from disk.
+ * @returns current entry issues after application, without rolling back successful siblings.
+ * @throws when preparation fails or the root Include cannot accept the update.
+ */
+async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>
+
+/**
+ * Wait for recompositions already queued when called, including removed-fiber cleanup.
+ * Observers may read accepted composition facts afterwards; a failed operation
+ * still reports its error to its caller and does not reject this observation.
+ * @returns after the current recomposition queue settles.
+ */
+whenIdle(): Promise<void>
 ```
 
 Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)

+ 26 - 18
docs/subsystems/core.zh.md

@@ -946,7 +946,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
 @Remote('list') async list(): Promise<PluginPackageView[]>
 
 /**
- * Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
+ * Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
  * @param spec - what to install, in pnpm's own vocabulary.
  * @param options - `enable` puts every newly installed bundle into the layer list at once.
  * @returns what the run installed and enabled.
@@ -954,7 +954,7 @@ The row injects only the Loader; the profile runtime, the preset roster, and the
 @Remote('add') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
 
 /**
- * Remove a package from the profile with its user-layer rows and probe record.
+ * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
  * @param packageName - the installed dependency to remove.
  */
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>
@@ -1029,6 +1029,13 @@ Facts and recomposition of the booted profile.
  */
 originOf(rowId: string): RowOrigin | undefined
 
+/**
+ * Resolve provenance within its Loader tree; nested includes inherit their owning entry.
+ * @param entry - the live entry, including an entry inside another Include.
+ * @returns its supplying bundle, or undefined for a user-owned entry.
+ */
+originOfEntry(entry: Entry): RowOrigin | undefined
+
 /**
  * Row ids the user patch layers disable with a literal `disabled: true`,
  * as the committed composition read them. The set describes the running
@@ -1049,22 +1056,23 @@ userDisabledRowIds(): ReadonlySet<string>
 userDisables(entry: Entry): boolean
 
 /**
- * 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. The candidate profile,
- * its ownership, and its conflicts become the committed composition only
- * once the update holds; until then, and after a rejection, `current`,
- * `layers`, `originOf`, and `conflicts` keep describing the running tree.
- * Calls queue: one that arrives while another is in flight starts after it
- * settled and reads what it committed. A rejection is that call's outcome
- * alone and does not stop the ones behind it.
- * @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>
+ * Apply a fresh profile stack and wait for live entries and removed fibers to settle.
+ * Parse/composition failures leave the applied stack unchanged. Accepted options
+ * can coexist with failed entries or fibers running their previous valid config.
+ * Calls serialize; a failed call does not block later changes.
+ * @param options - whether to reread installed bundle layers from disk.
+ * @returns current entry issues after application, without rolling back successful siblings.
+ * @throws when preparation fails or the root Include cannot accept the update.
+ */
+async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>
+
+/**
+ * Wait for recompositions already queued when called, including removed-fiber cleanup.
+ * Observers may read accepted composition facts afterwards; a failed operation
+ * still reports its error to its caller and does not reject this observation.
+ * @returns after the current recomposition queue settles.
+ */
+whenIdle(): Promise<void>
 ```
 
 Source: [`packages/boot/app-boot/src/profile-runtime.ts`](../../packages/boot/app-boot/src/profile-runtime.ts)

+ 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: 02709d2079ddc7aeaa2e2ed112beacf65a8d7c3c
-README.zh.md: b044a8820ac873c96a945017c15a3ed3c71025d8
+README.md: 27fa2d9039b95e39631a574e02359b312f3d9312
+README.zh.md: a9de7f1b4048adbf4d7b3e502ca5c6811e129af4

+ 14 - 25
packages/boot/app-boot/README.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
+Startup policy follows the bundle that introduced each row. Only `external` / `runtime` rows may fail with a warning while their successful siblings remain active. Built-in, boot-staged, and unowned rows are required: an enabled failure disposes the app and rejects startup. Disabled rows are ignored; the bootstrap Include is always required. Direct calls without profile provenance are strict.
 
 <a id="profiles"></a>
 ### Profiles
@@ -58,9 +58,9 @@ Profiles with `patchReload: live` watch both user patch files and apply the [rel
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
-A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>` with the ids its patch declares, 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. Row ids share one namespace across the stack, counting both the rows a layer inserts and the rows its patches set as a group's config: built-in layers own theirs first, an external bundle that declares an id another layer already owns, or declares one of its own ids twice, is left out whole and reported on stderr and in the plugin list, and a user-layer insert of a taken id is dropped and reported the same way.
+Installed dependencies default to `external`; template bundles and packages in `dsh.profile.firstParty` are `builtin`. Effective stage is `dsh.profile.stages[package]`, then the package’s `dsh.bundle.stage`, then `runtime`. Stage selects startup failure policy, not a later execution phase. Bundle patches retain their declared ids, parents, and ordering. Built-in and boot-staged layers claim ids first; an optional bundle with a duplicate id is omitted whole and reported, while a conflicting user insert is omitted per row. `dependencies` records installation; `dsh.profile.bundles` selects enabled layers, including all their inserts and overrides.
 
-After the tree is up the launcher provides `ctx.profileRuntime`, which holds the composition the tree runs — the profile, the installation anchor, the layer that owns each row, and the rows the composition left out — reads which rows the user patch files disable, and is the one entry point that recomposes the tree: the patch watchers and the [plugin manager](../../host/plugin-manager/README.md), enabling or retrying a bundle, all call it, recompositions run one at a time, and a rejected update leaves its facts describing the tree still running; `recordContainedStates` is what such a caller runs afterwards, because the boot audit does not run again. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and the process keeps running, with nothing stopped or attributed to a plugin; an uncaught exception is reported and exits.
+The launcher provides `ctx.profileRuntime` before any configuration entry mounts. It owns row provenance, accepted composition, conflicts, and user-disabled rows. Watchers and management operations share its serial recomposition queue. Recomposition waits for current entries and removed fibers, then publishes accepted options and reports per-entry issues; a failed update can leave a fiber running its previous valid config. `installFailLoud` remains installed until shutdown for process-level unhandled rejections. Observers can await `whenIdle()` before publishing a refreshed view of composition ownership.
 
 <a id="patch-files"></a>
 ### Patch files
@@ -111,7 +111,7 @@ After the Loader settles, app-boot reports optional failures as warnings and rej
 | Detached asynchronous work outside the `apply()` return Promise produces an unhandled rejection | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero | Fatal: dispose the app and exit nonzero, regardless of entry id |
 | Entry is absent or explicitly disabled | Ignore it | Ignore it | Do not activate it; no required-startup audit |
 
-The required list above includes `modules` and `connection`; Web startup cannot succeed when either enabled entry fails. Failure of an optional provider can also prevent a required consumer from activating. Schema rejection before an existing entry updates is not a transactional rollback of sibling changes.
+An optional provider failure can leave a required consumer pending and prevent readiness. Overriding an existing row does not change its owner or startup policy. Schema rejection before an existing entry updates does not roll back successful sibling changes.
 
 The [Web process matrix](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) and [startup acceptance](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) verify these outcomes through the shipped Web profile; [app-boot tests](tests/app-boot.spec.ts) also exercise root Include failures.
 
@@ -135,35 +135,27 @@ This section explains how the outcomes above are realized and points at the code
 
 - **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.
-- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate generation when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
+- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot audits provenance after startup settlement; agent presets own and clean up generations that require every configured entry to activate. Entry failures are read from the Loader and Fiber, with duplicate rejection notifications coalesced through one process checkpoint.
 - **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.
-- **Update completion.** App boot observes restart failures through the `internal/update` waterfall. Live patch reloads wait for the tree's fibers before auditing activation; `Fiber.update()` and `Entry.update()` alone do not establish restart success.
+- **Update completion.** Live reload waits for Loader work before inspecting entry issues. Profile recomposition also waits for removed fibers, whose cleanup is absent from the current Loader tree. `Fiber.update()` and `Entry.update()` alone do not establish restart success.
 - **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`. Plugin diagnostics include original stacks, nested causes, and aggregate member failures. Cyclic causes terminate diagnostic traversal without replacing the original cause.
 
 ### Helper behavior
 
 The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, live patch watching, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts).
 
-### Two parsers, one dialect
-
-Reading a patch file uses `js-yaml` with the include's `entryListSchema`, so `!!js` scalars become the expression nodes the Loader interpolates, exactly as the include mounts them. Writing uses the `yaml` package's comment-preserving `Document`: it keeps the unresolved `!!js` tag on the scalar it decorates (reported as a `TAG_RESOLVE_FAILED` warning, not an error) and prints it back verbatim, so an edit to one key never rewrites another key's expression. The written text is parsed back with the reading parser, which is the readback the writer's contract promises.
-
-### Addressing
-
-An id-targeted patch is the top-level item whose `id` matches and that carries no `insert`. An inserted row is searched in every `insert` list, recursing into inserted groups (`group: true` with a `config` list). A top-level item that is not a mapping, or an `insert` whose value is not a list, fails the parse.
-
 ### Source map
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit and `recordContainedStates`, patch-file loading through `./patch-file`, config dump, harness-source section |
-| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with `layerTrust` and stage, module fallback |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, overrides), `bundleLayerPatches`, and the manifest operations behind install, enable, and disable |
+| [`src/index.ts`](src/index.ts) | Boot, environment layers, fail-loud guard, startup audit, patch parsing and watching, config dump |
+| [`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) | Bundle ownership analysis and installed/enabled manifest lists |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | Row-id ownership across the stack: `claimLayerIds`, `composeProfileStack`, conflict records |
-| [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry |
+| [`src/entry-issues.ts`](src/entry-issues.ts) | Current entry failures, unresolved services, and diagnostic formatting |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: the committed composition (profile, row provenance, conflicts), user-disabled rows, recomposition |
-| [`src/patch-file.ts`](src/patch-file.ts) | The `./patch-file` export: `parsePatchList` and `readPatchListFile`, the comment-preserving `PatchDocument`, and `mutatePatchFile` under the writer lock |
-| [`src/probe.ts`](src/probe.ts) | The package probe and its per-profile cache; [`src/probe-child.ts`](src/probe-child.ts) is the child entry it spawns and [`src/probe-report.ts`](src/probe-report.ts) the report it validates |
+| [`src/package-metadata.ts`](src/package-metadata.ts) | Static manifest and patch declarations; no module execution or probe cache |
+| [`src/patch-file.ts`](src/patch-file.ts) | Public patch-file parser and atomic user-layer editor |
 | — | 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>
@@ -206,13 +198,10 @@ 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.
+- **Overrides retain the target’s owner** — changing a built-in row can make that required row fail; successful updates to other rows remain applied. Disabling the bundle removes its whole patch layer, including overrides.
 - **A conflict is decided by order, not merit** — among external bundles the earlier layer in `dsh.profile.bundles` keeps a contested id, so uninstalling that bundle lets the later one mount on the next boot; the plugin list shows which bundle lost and to whom.
 - **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.
-- **Patch edits merge at the key level, not the row level** — `setRowField('x', 'config', value)` replaces the whole `config` mapping of that patch; a caller that wants one nested field changed reads the current value with `rowField` and writes the merged mapping back.
-- **The patch writer authors no expressions** — it emits plain data only; a `!!js` gate is something an author types into the file, never something an API call produces.
-- **Lock orphans are an operator action** — a lock file left by a crashed patch writer is never removed by a contender, which fails after the wait instead; `dsh-atomic-write` documents the same choice.
-- **The probe cannot run from a packaged executable** — `probe-child.js` sits inside the executable's `/snapshot` tree, which the plain Node child cannot read, so `probePackage` rejects there until the child entry is materialized outside the executable the way module proxies are.
+- **Declarations are not activation results** — `readPackageMetadata` reads `dsh.plugins` without importing modules. Declare `plugins: [{ name: "." }]` for a main export, or subpaths such as `./tools`; undeclared packages remain `unknown`. Config validation and execution diagnostics appear only after an actual mount. Physical Cordis resolution cannot detect an inlined copy.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 14 - 25
packages/boot/app-boot/README.zh.md

@@ -40,7 +40,7 @@ installFailLoud('dsh')
 const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
 ```
 
-有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
+启动策略由引入该行的组合包决定。只有 `external` / `runtime` 行可以在失败后输出警告,同时保留成功的其他行。内置、boot 阶段和无归属行均为必需行:启用但失败时会清理应用并拒绝启动。已禁用的行被忽略;启动 Include 始终为必需。未提供 profile 来源信息的直接调用采用严格策略。
 
 <a id="profiles"></a>
 ### Profile
@@ -58,9 +58,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
-用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,id 保持它的 patch 所声明的样子,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。行 id 在整叠层里共用一个命名空间,既算一层插入的行也算它的 patch 设为某个组 config 的行:内置层先占有自己的 id,外部组合包若声明了别的层已占有的 id,或把自己的某个 id 声明了两次,就整层被排除,并在 stderr 与插件列表里报告;用户层插入已被占用的 id 时该行被丢弃,同样报告。
+已安装依赖默认为 `external`;模板组合包和 `dsh.profile.firstParty` 中的包为 `builtin`。有效 stage 依次取 `dsh.profile.stages[package]`、包的 `dsh.bundle.stage`,最后默认为 `runtime`。stage 决定启动失败策略,不表示稍后执行。组合包 patch 保留声明的 id、父组和顺序。内置与 boot 阶段层优先占有 id;可选组合包有重复 id 时整层被排除并报告,冲突的用户插入则逐行排除。`dependencies` 记录安装;`dsh.profile.bundles` 选择启用的层,包括它们的全部插入和覆盖。
 
-树起来之后 launcher 提供 `ctx.profileRuntime`:它持有树正在运行的组合——profile、安装锚点、每一行的归属层、被组合排除的行——读取用户 patch 文件停用了哪些行,并且是重组整棵树的唯一入口:patch 监视器与启用或重试组合包的[插件管理器](../../host/plugin-manager/README.zh.md)都调用它,重组一次只跑一个,被拒的更新留下的事实仍然描述正在运行的树;这样的调用方之后会运行 `recordContainedStates`,因为启动审计不会再跑一次。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告,进程继续运行,不停止任何任务也不归属到任何插件;未捕获的异常会被报告并退出。
+launcher 在任何配置行挂载前提供 `ctx.profileRuntime`。它拥有行来源、已接受的组合、冲突和用户禁用行信息。文件监听与管理操作共用它的串行重组队列。重组等待当前条目和已移除 fiber 完成后,发布已接受的选项并报告逐行问题;更新失败时,fiber 可能仍使用先前的有效配置运行。`installFailLoud` 保持到应用关闭,处理进程级未处理 rejection。 观测方可等待 `whenIdle()` 后,再发布包含组合归属的刷新视图。
 
 <a id="patch-files"></a>
 ### 补丁文件
@@ -111,7 +111,7 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 | 脱离 `apply()` 返回 Promise 的异步任务产生未处理 rejection | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出 | 致命错误:释放应用并以非零码退出,与条目 id 无关 |
 | 条目缺失或被显式禁用 | 忽略 | 忽略 | 不激活该条目;不执行 required 启动审计 |
 
-上面的 required 列表包含 `modules` 与 `connection`;只要其中一个已启用条目失败,Web 就无法成功启动。Optional 提供方失败也可能使 required 消费方无法激活。现有条目的新配置在更新前被 schema 校验拒绝,并不等于对兄弟插件的变更做事务回滚。
+可选提供方失败可能使必需消费方等待依赖,从而阻止应用就绪。覆盖已有行不会改变其所有者或启动策略。已有行在更新前校验失败,不会回滚其他行已成功的变化。
 
 [Web 进程矩阵](../../../apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)和[启动验收测试](../../../apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)通过随附 Web profile 验证这些结果;[app-boot 测试](tests/app-boot.spec.ts)还覆盖根 Include 失败。
 
@@ -135,35 +135,27 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 
 - **与渠道无关的库。** 此包不包含 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`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
-- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 generation。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
+- **消费方决定严格程度。** 普通 Loader 组保留成功的其他行。app-boot 在启动完成后按来源审计;agent preset 拥有并清理要求全部配置行激活的代际。行失败从 Loader 与 Fiber 读取,重复的 rejection 通知在一个进程检查点内合并。
 - **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部组合包若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。
-- **更新完成。** App boot 通过 `internal/update` waterfall 观察重启失败。实时 patch 重载在检查激活状态前等待配置树中的 fiber;单独调用 `Fiber.update()` 或 `Entry.update()` 不能确定重启成功。
+- **更新完成。** 实时重载等待 Loader 工作后检查逐行问题。profile 重组还等待已移除 fiber 的清理,这些工作已不在当前 Loader 树中。单独完成 `Fiber.update()` 和 `Entry.update()` 不代表重启成功。
 - **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`。插件诊断包含原始堆栈、嵌套原因和聚合错误中的各项失败。原因链出现循环时,诊断遍历会终止,不会替换原始原因。
 
 ### Helper 行为
 
 每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染、活动 patch 监视、profile 组合,以及 harness 源码段落。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。
 
-### 两个解析器,一种方言
-
-读取补丁文件用 `js-yaml` 配 include 的 `entryListSchema`,于是 `!!js` 标量成为 Loader 插值的表达式节点,与 include 挂载时完全一致。写入用 `yaml` 包保留注释的 `Document`:它在所修饰的标量上保留未解析的 `!!js` 标签(报告为 `TAG_RESOLVE_FAILED` 警告而非错误)并原样打印回去,因此对一个键的编辑绝不会改写另一个键的表达式。写出的文本再用读取解析器解析一遍,这就是写入器契约承诺的回读。
-
-### 寻址
-
-按 id 定位的补丁是 `id` 匹配且不带 `insert` 的顶层项。插入的行在每个 `insert` 列表中查找,并递归进入插入的组(`group: true` 且带 `config` 列表)。顶层项不是映射,或 `insert` 的值不是列表,都会让解析失败。
-
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计与 `recordContainedStates`、经 `./patch-file` 加载 patch 文件、配置 dump、harness 源码段落 |
-| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 `layerTrust` 与 stage 的组合包解析、模块后备机制 |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、覆盖报告)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 |
+| [`src/index.ts`](src/index.ts) | 启动、环境层、fail-loud 处理、启动审计、patch 解析与监听、配置导出 |
+| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | 组合包归属分析及安装、启用 manifest 列表 |
 | [`src/compose-stack.ts`](src/compose-stack.ts) | 整叠层的行 id 归属:`claimLayerIds`、`composeProfileStack`、冲突记录 |
-| [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
+| [`src/entry-issues.ts`](src/entry-issues.ts) | 当前条目失败、未满足服务和诊断格式化 |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:已提交的组合(profile、行来源、冲突)、用户停用的行、重新组合 |
-| [`src/patch-file.ts`](src/patch-file.ts) | `./patch-file` 导出:`parsePatchList` 与 `readPatchListFile`、保留注释的 `PatchDocument`,以及持写入锁的 `mutatePatchFile` |
-| [`src/probe.ts`](src/probe.ts) | 包探针及其按 profile 的缓存;[`src/probe-child.ts`](src/probe-child.ts) 是它生成的子进程入口,[`src/probe-report.ts`](src/probe-report.ts) 是它校验的报告 |
+| [`src/package-metadata.ts`](src/package-metadata.ts) | 静态 manifest 与 patch 声明;不执行模块,不使用 probe 缓存 |
+| [`src/patch-file.ts`](src/patch-file.ts) | 公开 patch 文件解析器与用户层原子编辑器 |
 | — | 不发布运行时不变式伴生入口;边界与回放测试覆盖其协议映射。 |
 
 </details>
@@ -206,13 +198,10 @@ Loader 结算后,app-boot 将 optional 失败报告为警告;若已启用的
 - **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
 - **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。
 - **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。
-- **外部组合包的覆盖不被隔离**——它对内置行施加的 patch 原地修改那一行,所以即使该组合包自己的行失败,效果仍然保留;这是组合包唯一能在自己的组之外造成影响的地方。
+- **覆盖保留目标的所有者**——修改内置行可能使该必需行失败;其他行成功的更新仍然生效。禁用组合包移除其整份 patch 层,包括覆盖。
 - **冲突按顺序判定,不看是非**——外部组合包之间,`dsh.profile.bundles` 里靠前的那层保住争议 id,卸掉它之后靠后的那层在下次启动时挂上;插件列表显示谁输给了谁。
 - **嵌套 fiber 审计只是提示**——内置条目下失败的 `ctx.inject()` 延续会被报告而非致命,直到确认随附组合都没有这类失败。
-- **补丁编辑按键级而非行级合并**——`setRowField('x', 'config', value)` 替换该补丁的整个 `config` 映射;只想改一个嵌套字段的调用方先用 `rowField` 读出当前值,再把合并后的映射写回。
-- **补丁写入器不生成表达式**——它只输出普通数据;`!!js` 门是作者敲进文件的东西,从不是某个 API 调用的产物。
-- **锁孤儿由操作者处理**——补丁写入器崩溃留下的锁文件不会被竞争者移除,竞争者等待超时后失败;`dsh-atomic-write` 记录了同样的选择。
-- **探针无法从打包可执行文件运行**——`probe-child.js` 位于可执行文件的 `/snapshot` 树内,普通 Node 子进程读不到它,因此 `probePackage` 在那里会 reject,直到子进程入口像模块代理那样被物化到可执行文件之外。
+- **声明不代表激活结果**——`readPackageMetadata` 读取 `dsh.plugins`,不导入模块。主入口声明为 `plugins: [{ name: "." }]`,子路径可写为 `./tools`;未声明的包保持 `unknown`。Config 校验和执行诊断只在实际挂载后产生。Cordis 物理路径解析无法识别被内联打包的副本。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 0 - 1
packages/boot/app-boot/package.json

@@ -27,7 +27,6 @@
   },
   "files": [
     "lib/index.js",
-    "lib/probe-child.js",
     "lib/patch-file.js",
     "lib/types/**/*.d.ts"
   ],

+ 20 - 12
packages/boot/app-boot/src/compose-stack.ts

@@ -7,16 +7,16 @@
  * bundle whose id is already claimed, or which declares one of its own ids
  * twice, is skipped whole and recorded as a conflict; a user layer's insert
  * of a claimed id drops that row and records it. One composition yields the
- * patches, the owner of every id, and the conflicts, and each contained
- * layer is rendered once. Boot, live recomposition, and the config dump all
+ * patches, the owner of every id, and the conflicts, and each optional
+ * layer is analyzed once. Boot, live recomposition, and the config dump all
  * compose through here, so they agree.
  * @module @deepseek-ai/dsh-app-boot/compose-stack
  */
 
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { composeExternalLayer, isContainedLayer, type ComposedExternalLayer } from './external-bundles.ts'
-import { visitIdentifiedRows, visitRowTree } from './patch-rows.ts'
+import { analyzeBundleLayer, isOptionalRuntimeLayer, type AnalyzedBundleLayer } from './external-bundles.ts'
+import { visitIdentifiedRows, visitPatchRows, visitRowTree } from './patch-rows.ts'
 import type { ProfileLayer } from './profile.ts'
 
 /** One user-owned patch list in the stack: the profile file, the home file, or a `--patch` overlay. */
@@ -72,8 +72,8 @@ export interface LayerOwnership {
   readonly owners: Map<string, ProfileLayer>
   /** The conflicts of each external bundle left out, by package name. */
   readonly skipped: Map<string, RowConflict[]>
-  /** The composition of each contained layer that owns its ids, by package name; rendered once and mounted as is. */
-  readonly composed: Map<string, ComposedExternalLayer>
+  /** The analysis of each optional layer that owns its ids, by package name; rendered once and mounted as is. */
+  readonly composed: Map<string, AnalyzedBundleLayer>
 }
 
 /** One conflict with its message: the id's other declarer, or the losing layer itself declaring it twice. */
@@ -113,7 +113,7 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
   const owners = new Map<string, ProfileLayer>()
   const declaredUnder = new Map<string, string | undefined>()
   for (const layer of layers) {
-    if (isContainedLayer(layer)) continue
+    if (isOptionalRuntimeLayer(layer)) continue
     visitIdentifiedRows(layer.patches, ({ id, source, place, listed }) => {
       const owner = owners.get(id)
       if (owner === layer) {
@@ -132,11 +132,11 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
     })
   }
   const skipped = new Map<string, RowConflict[]>()
-  const composed = new Map<string, ComposedExternalLayer>()
+  const composed = new Map<string, AnalyzedBundleLayer>()
   for (const layer of layers) {
-    if (!isContainedLayer(layer)) continue
+    if (!isOptionalRuntimeLayer(layer)) continue
     const { packageName } = layer
-    const composition = composeExternalLayer(layer)
+    const composition = analyzeBundleLayer(layer)
     const conflicts: RowConflict[] = composition.duplicates.map(({ rowId, moduleName }) => (
       rowConflict({ rowId, moduleName, layer: packageName, packageName, declaredBy: packageName })
     ))
@@ -159,8 +159,8 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
 /**
  * Compose the stack the root include mounts: every bundle layer that owns its
  * ids, in manifest order, then the user layers with any insert of an already
- * owned id dropped. Patches are passed by reference; callers that mount them
- * clone, because the include pushes inserted rows into the tree as they are.
+ * owned id dropped. Bundle patches are copied before assigning anonymous ids;
+ * user patches remain shared input, so callers mount a detached execution copy.
  * @param binName - the diagnostic prefix on a thrown built-in duplicate.
  * @param layers - the profile's bundle layers, in manifest order.
  * @param userLayers - the user-owned layers, in application order.
@@ -170,6 +170,14 @@ export function claimLayerIds(layers: readonly ProfileLayer[]): LayerOwnership {
 export function composeProfileStack(
   binName: string, layers: readonly ProfileLayer[], userLayers: readonly StackUserLayer[],
 ): ComposedStack {
+  // Only anonymous bundle rows need an assigned id. The execution copy keeps
+  // provenance available after loading without modifying author-owned patches.
+  layers = layers.map((layer) => {
+    const patches = structuredClone(layer.patches)
+    let anonymous = 0
+    visitPatchRows(patches, (row) => { row.id ||= `anonymous/${layer.packageName}/${String(anonymous++)}` })
+    return { ...layer, patches }
+  })
   let ownership: LayerOwnership
   try {
     ownership = claimLayerIds(layers)

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

@@ -1,279 +0,0 @@
-/**
- * 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, Fiber, FiberState } from '@deepseek-ai/cordis'
-import { EntryUpdateError, Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { visitRowTree } from './patch-rows.ts'
-
-/** Runtime mirror: FiberState is a cross-package const enum. */
-const FIBER_PENDING = 0 as FiberState.PENDING
-const FIBER_ACTIVE = 2 as FiberState.ACTIVE
-
-/**
- * The diagnostic line for a fiber waiting on services, naming the ones its
- * context cannot resolve: no plugin error exists for that state.
- * @param fiber - the pending fiber.
- * @returns one line, `pending (waiting for service: x)`.
- */
-export function pendingMessage(fiber: Fiber): string {
-  const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
-  const subject = missing.length === 1 ? 'service' : 'services'
-  return `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`
-}
-
-/** 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, as the tree names it. */
-  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, a
- * row that later mounts — re-created, or activated in place once the service
- * it waited for appears — clears it, a group that updates drops the records of
- * rows it no longer configures, and a group that unmounts clears its rows'.
- * A record's `groupId` names the contained group that isolates the row, at
- * any nesting depth, so those two cleanups reach every row of a bundle.
- * Rows the composition left out never reach a group and are not recorded
- * here; `ProfileRuntime.conflicts` holds them.
- */
-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)
-  }
-
-  /**
-   * Forget every record of one contained group, when the group unmounts.
-   * @param groupId - the group's tree-wide id.
-   */
-  clearGroup(groupId: string): void {
-    for (const [entryId, failure] of this.failures) {
-      if (failure.groupId === groupId) this.failures.delete(entryId)
-    }
-  }
-
-  /**
-   * Forget the records of one contained group's rows that its configuration
-   * no longer names, after the group updated.
-   * @param groupId - the group's tree-wide id.
-   * @param rowIds - the row ids the group still configures, at any depth.
-   */
-  retain(groupId: string, rowIds: ReadonlySet<string>): void {
-    for (const [entryId, failure] of this.failures) {
-      if (failure.groupId === groupId && !rowIds.has(failure.rowId)) 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
-  }
-}
-
-/**
- * The stage a row failure reached, from the Loader's typed update error.
- * A fresh row fails at import or apply; the wrapper's other stages replace
- * an existing entry, which a contained row does not go through, and a value
- * the Loader did not wrap has no stage.
- */
-function stageOf(error: unknown): ContainedFailureStage {
-  /* v8 ignore next -- the Loader wraps every row failure; the arm keeps the type total */
-  if (!(error instanceof EntryUpdateError)) return 'unknown'
-  /* v8 ignore next -- dispose and rollback replace an existing entry, which a fresh contained row does not go through */
-  return error.stage === 'import' || error.stage === 'apply' ? error.stage : 'unknown'
-}
-
-/**
- * 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. When the group
- * updates, the records of rows its configuration dropped go; when it unmounts
- * — its bundle disabled or uninstalled — all its rows' records go with it, so
- * no failure outlives the composition that produced it.
- */
-export class ContainedGroup extends Group {
-  /** Whether the activation watcher is registered. */
-  private watching = false
-
-  override async update(config: EntryOptions[]): Promise<void> {
-    await super.update(config)
-    const configured = new Set<string>()
-    // `ensureId` named every row, nested ones included, while the update mounted it.
-    for (const row of config) visitRowTree(row, (entry) => { configured.add(entry.id) })
-    this.registry()?.retain(this.groupId(), configured)
-  }
-
-  override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
-    try {
-      const id = await super.create(options)
-      // A created row is not a mounted row: one waiting for a service resolves
-      // `create` in the pending state, and a later reload re-creates every row
-      // of the group, so the record must follow what the row came to. The
-      // store is keyed by the row id `ensureId` assigned; `id` is tree-wide.
-      const rowId = (options as EntryOptions).id
-      const fiber = this.tree.store[rowId]?.fiber
-      if (fiber !== undefined && fiber.state === FIBER_PENDING) {
-        this.watchActivation()
-        this.registry()?.record({
-          entryId: id,
-          rowId,
-          moduleName: options.name,
-          groupId: this.groupId(),
-          stage: 'inject-pending',
-          message: pendingMessage(fiber),
-        })
-      } else {
-        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(error),
-        message,
-      })
-      this.ctx.logger.warn(`contained group ${this.groupId()}: row ${rowId} failed and was isolated: ${message}`)
-      return rowId
-    }
-  }
-
-  override async stop(): Promise<void> {
-    await super.stop()
-    this.registry()?.clearGroup(this.groupId())
-  }
-
-  /**
-   * A row recorded as waiting comes to life in place once its service
-   * appears — a sibling row switched back on, say — with no `create` to
-   * clear the record, so the group watches fiber states for that moment.
-   * Registered once per group; the group's context disposal drops it.
-   */
-  private watchActivation(): void {
-    if (this.watching) return
-    this.watching = true
-    this.ctx.on('internal/status', (fiber: Fiber) => {
-      if (fiber.state !== FIBER_ACTIVE) return
-      const entryId = fiber.entry?.id
-      if (entryId === undefined) return
-      const registry = this.registry()
-      if (registry?.get(entryId)?.stage === 'inject-pending') registry.clear(entryId)
-    })
-  }
-
-  /** 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)'
-  }
-}
-
-/**
- * The contained group that isolates an entry: the nearest owning group, at
- * any depth, that is a {@link ContainedGroup}.
- * @param entry - the Loader entry to classify.
- * @returns the group, or undefined for an entry outside every contained group.
- */
-export function containingGroup(entry: Entry): ContainedGroup | undefined {
-  let group: EntryGroup | undefined = entry.parent
-  while (group !== undefined) {
-    if (group instanceof ContainedGroup) return group
-    group = group.ctx.fiber.entry?.parent
-  }
-  return undefined
-}
-
-/**
- * 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 {
-  return containingGroup(entry) !== undefined
-}
-
-/**
- * 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
-}

+ 114 - 0
packages/boot/app-boot/src/entry-issues.ts

@@ -0,0 +1,114 @@
+/** Entry-owned load/update failures and missing services, independent of bundle grouping. */
+import type { Context, FiberState } from '@deepseek-ai/cordis'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+
+const PENDING = 0 as FiberState.PENDING
+const ACTIVE = 2 as FiberState.ACTIVE
+const FAILED = 3 as FiberState.FAILED
+
+/** A failed attempt or unresolved dependency of one configured entry. */
+export interface EntryIssue {
+  /** Entry whose options and fiber describe the requested and running configuration. */
+  readonly entry: Entry
+  readonly stage: 'import' | 'activation' | 'update' | 'disabled-expression' | 'inject-pending'
+  readonly message: string
+  /** Original rejection, retained only in process for duplicate rejection handling. */
+  readonly error?: unknown
+}
+
+const observedRejections = new Map<unknown, number>()
+
+/**
+ * Whether a rejection is already being reported by an entry audit.
+ * @param reason - the process rejection reason.
+ * @returns true during the audit's rejection checkpoint.
+ */
+export function isObservedEntryRejection(reason: unknown): boolean {
+  return observedRejections.has(reason)
+}
+
+/**
+ * Render nested causes and aggregate members without cycling or dropping plugin stacks.
+ * @param error - the thrown value.
+ * @returns the diagnostic text.
+ */
+export function formatEntryError(error: unknown): string {
+  const details: string[] = []
+  const seen = new Set<Error>()
+  const visit = (value: unknown): void => {
+    if (!(value instanceof Error)) {
+      details.push(String(value))
+      return
+    }
+    if (seen.has(value)) return
+    seen.add(value)
+    details.push(value.stack ?? value.message)
+    if (value.cause !== undefined) visit(value.cause)
+    if (value instanceof AggregateError) value.errors.forEach(visit)
+  }
+  visit(error)
+  return details.join('\n')
+}
+
+/**
+ * Read one entry's latest failed attempt or unresolved services without activating it.
+ * @param entry - the current Loader entry.
+ * @returns its issue, or undefined when disabled or active without a failed update.
+ */
+export async function entryIssue(entry: Entry): Promise<EntryIssue | undefined> {
+  try {
+    if (entry.disabled) return undefined
+  } catch (error) {
+    return { entry, stage: 'disabled-expression', message: formatEntryError(error), error }
+  }
+  if (entry.lastFailure !== undefined) {
+    const { stage, error } = entry.lastFailure
+    return { entry, stage, message: `${stage === 'import' ? 'failed to import: ' : ''}${formatEntryError(error)}`, error }
+  }
+  const fiber = entry.fiber
+  if (fiber === undefined) return { entry, stage: 'import', message: 'failed to import' }
+  if (fiber.state === ACTIVE) return undefined
+  if (fiber.state === FAILED) {
+    try {
+      await fiber.await()
+    } catch (error) {
+      return { entry, stage: 'activation', message: formatEntryError(error), error }
+    }
+    return undefined
+  }
+  if (fiber.state === PENDING) {
+    const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
+    return {
+      entry, stage: 'inject-pending',
+      message: `pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
+    }
+  }
+  return { entry, stage: 'activation', message: `fiber state ${String(fiber.state)}` }
+}
+
+/**
+ * Collect current entry issues and coalesce Loader's duplicate process rejections.
+ * @param ctx - the context owning the Loader.
+ * @returns current issues; no historical failure registry is retained.
+ */
+export async function inspectEntryIssues(ctx: Context): Promise<EntryIssue[]> {
+  const issues: EntryIssue[] = []
+  for (const entry of ctx.loader.entries()) {
+    const issue = await entryIssue(entry)
+    if (issue !== undefined) issues.push(issue)
+  }
+  const reasons = issues.filter(issue => 'error' in issue).map(issue => issue.error)
+  if (reasons.length > 0) {
+    for (const reason of reasons) observedRejections.set(reason, (observedRejections.get(reason) ?? 0) + 1)
+    try {
+      await new Promise<void>(resolve => setImmediate(resolve))
+    } finally {
+      for (const reason of reasons) {
+        const count = observedRejections.get(reason)
+        if (count !== undefined && count > 1) observedRejections.set(reason, count - 1)
+        else observedRejections.delete(reason)
+      }
+    }
+  }
+  return issues
+}

+ 24 - 135
packages/boot/app-boot/src/external-bundles.ts

@@ -1,18 +1,4 @@
-/**
- * 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, under the ids its patch declares. A group is the unit the Loader
- * updates transactionally, and the contained variant catches each row's
- * failure inside that transaction: the failing row is recorded, its siblings
- * mount, and the built-in tree never sees a rejection. Row ids stay as
- * declared; entry ids are unique per tree (`tree.store`), and
- * `compose-stack.ts` owns the tree-wide ownership check that shared id
- * namespace requires.
- * @module @deepseek-ai/dsh-app-boot/external-bundles
- */
+/** Bundle patch ownership and the profile manifest's installed/enabled layer lists. */
 
 import { join } from 'node:path'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -22,149 +8,52 @@ 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}`
-}
-
-/** One id an external bundle's patch introduces twice: a row inserted again, or a row spelling the group's or a wrapper's id. */
+/** One row declared twice within a bundle layer. */
 export interface DuplicateRow {
-  /** The repeated id. */
   readonly rowId: string
-  /** The module the repeat names. */
   readonly moduleName: string
 }
 
-/** One external layer rendered as the patches the tree mounts. */
-export interface ComposedExternalLayer {
-  /**
-   * Patches in application order: the empty group insert, then the bundle's
-   * patches as written, each insert re-targeted into the group or a wrapper.
-   */
-  patches: PatchOptions[]
-  /**
-   * Every id the layer introduces — its inserted rows, the rows its config
-   * overrides set, its group, and each wrapper group — with the module each
-   * names; a repeated id keeps its first module.
-   */
-  rows: Map<string, string>
-  /**
-   * Ids the layer introduces more than once, in order of repetition. A config
-   * override restating a row under the group that already holds it is not
-   * one; the same id twice in one config list, or set under another group, is.
-   */
-  duplicates: DuplicateRow[]
-  /** Ids outside the bundle that its patch overrides; not containable, reported for visibility. */
-  overrides: string[]
+/** Static row ownership and overrides of one unmodified bundle patch list. */
+export interface AnalyzedBundleLayer {
+  readonly patches: PatchOptions[]
+  readonly rows: Map<string, string>
+  readonly duplicates: DuplicateRow[]
+  readonly overrides: string[]
 }
 
 /**
- * Whether a layer mounts isolated: an external bundle the profile does not
- * stage at boot.
- * @param layer - the resolved layer.
- * @returns true when the layer mounts as a contained group.
+ * Whether an enabled bundle may fail without rejecting application startup.
+ * @param layer - the resolved provenance and stage.
+ * @returns true only for external runtime layers.
  */
-export function isContainedLayer(layer: ProfileLayer): boolean {
+export function isOptionalRuntimeLayer(layer: ProfileLayer): boolean {
   return layer.trust === 'external' && layer.stage === 'runtime'
 }
 
 /**
- * Render one external bundle layer as contained patches in the order written.
- * The bundle's group is inserted empty first; each root insert becomes an
- * insert into that group, an insert into a row the bundle itself introduces
- * passes through, and every insert into one built-in group lands in one
- * wrapper group nested inside that target — the first insert creates it,
- * later ones insert into it, and a patch that replaces the target's config
- * has removed the wrapper, so the next insert creates a new one under the
- * same id. An id-targeted patch passes through unchanged and is reported as
- * an override when it addresses a row the bundle did not introduce; the rows
- * it sets as a group's config count as the bundle's own, since they mount
- * as children like inserted ones. Every id, declared or generated, goes
- * through one registration, so a row spelling a wrapper's id is a duplicate,
- * and so is a config row the bundle already declared under another group,
- * or listed twice: the Loader would move the first and reject the second.
- * Ids are indexed before any patch is emitted, so an insert into a group the
- * bundle introduces later in its list still counts as its own.
- * @param layer - the resolved external layer.
- * @returns the patches to mount, the ids the layer introduces, and the ids it repeats.
+ * Inspect the rows a layer introduces without changing their ids or parents.
+ * @param layer - the bundle patch list.
+ * @returns its declared rows, duplicates, and external override targets.
  */
-export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer {
-  const groupId = bundleGroupId(layer.packageName)
+export function analyzeBundleLayer(layer: ProfileLayer): AnalyzedBundleLayer {
   const rows = new Map<string, string>()
   const declaredUnder = new Map<string, string | undefined>()
   const duplicates: DuplicateRow[] = []
-  const claim = (rowId: string, moduleName: string, target?: string): void => {
-    if (rows.has(rowId)) {
-      duplicates.push({ rowId, moduleName })
-      return
-    }
-    rows.set(rowId, moduleName)
-    declaredUnder.set(rowId, target)
-  }
   visitIdentifiedRows(layer.patches, ({ id, row, source, place, listed }) => {
-    // A root insert lands in the bundle's group: that is the group it declares under.
-    const target = place.target ?? groupId
-    if (source === 'config') {
-      // A config override restates the children it keeps: the same id declared
-      // under the same group before is that row. Twice in one list, or under
-      // another group, it would mount as a rejected duplicate or move the row.
-      if (listed.has(id) || (rows.has(id) && declaredUnder.get(id) !== target)) {
+    if (rows.has(id)) {
+      if (source === 'insert' || listed.has(id) || declaredUnder.get(id) !== place.target) {
         duplicates.push({ rowId: id, moduleName: row.name })
-      } else if (!rows.has(id)) {
-        rows.set(id, row.name)
-        declaredUnder.set(id, target)
       }
     } else {
-      claim(id, row.name, target)
+      rows.set(id, row.name)
+      declaredUnder.set(id, place.target)
     }
   })
-  claim(groupId, CONTAINED_GROUP_MODULE)
-  const wrappers = new Map<string, string>()
-  const generated = new Set<string>()
-  const overrides: string[] = []
-  const patches: PatchOptions[] = [{ insert: [{ id: groupId, name: CONTAINED_GROUP_MODULE, group: true, config: [] }] }]
-  for (const patch of layer.patches) {
-    if (patch.insert === undefined) {
-      if (patch.id !== undefined) {
-        if (!rows.has(patch.id)) overrides.push(patch.id)
-        // The target's children are replaced, wrapper included.
-        if (patch.config !== undefined) wrappers.delete(patch.id)
-      }
-      patches.push(structuredClone(patch))
-      continue
-    }
-    const inserted = structuredClone(patch.insert)
-    const target = patch.id ?? groupId
-    if (rows.has(target)) {
-      patches.push({ id: target, insert: inserted })
-      continue
-    }
-    const wrapper = wrappers.get(target)
-    if (wrapper !== undefined) {
-      patches.push({ id: wrapper, insert: inserted })
-      continue
-    }
-    const wrapperId = `${groupId}/in/${target}`
-    if (!generated.has(wrapperId)) {
-      generated.add(wrapperId)
-      claim(wrapperId, CONTAINED_GROUP_MODULE)
-    }
-    wrappers.set(target, wrapperId)
-    patches.push({ id: target, insert: [{ id: wrapperId, name: CONTAINED_GROUP_MODULE, group: true, config: inserted }] })
-  }
-  return { patches, rows, duplicates, overrides }
+  const overrides = layer.patches.flatMap(patch => (
+    patch.insert === undefined && patch.id !== undefined && !rows.has(patch.id) ? [patch.id] : []
+  ))
+  return { patches: layer.patches, rows, duplicates, overrides }
 }
 
 /**

+ 27 - 175
packages/boot/app-boot/src/index.ts

@@ -21,7 +21,7 @@ import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import { watchConfig } from './watch-config.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
+import { formatEntryError, inspectEntryIssues, isObservedEntryRejection } from './entry-issues.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -54,10 +54,10 @@ export {
   type ProfileModuleFallbackOptions,
   type ProfileTemplate,
 } from './profile.ts'
-export { ensurePluginFailures } from './contained-group.ts'
+export { entryIssue, inspectEntryIssues, type EntryIssue } from './entry-issues.ts'
 export {
-  bundleGroupId, disableBundle, enableBundle, reconcileInstalledBundles,
-  type BundleReconciliation, type ComposedExternalLayer, type DuplicateRow,
+  disableBundle, enableBundle, reconcileInstalledBundles,
+  type BundleReconciliation, type AnalyzedBundleLayer, type DuplicateRow,
 } from './external-bundles.ts'
 export {
   claimLayerIds, composeProfileStack, formatRowConflict,
@@ -65,10 +65,7 @@ export {
 } from './compose-stack.ts'
 export { ProfileRuntime, type RowOrigin } from './profile-runtime.ts'
 export { awaitChildClose } from './child-close.ts'
-export {
-  PLUGIN_PROBE_DIR, probePackage, readProbeCache, writeProbeCache,
-  type PluginProbe, type PluginProbeRow, type ProbeOptions,
-} from './probe.ts'
+export { readPackageMetadata, type PackageMetadata, type PackageMetadataOptions, type PackageMetadataRow } from './package-metadata.ts'
 
 /**
  * Resolve the config to boot. Replay swaps a `cordis.yml` basename for
@@ -270,7 +267,7 @@ export async function watchUserPatches(
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
-  const reapply = options.reapply ?? (async (): Promise<void> => {
+  const apply = options.reapply ?? (async (): Promise<void> => {
     // Re-read the include's non-patch options per refresh so a writer that
     // updates another option between refreshes is not silently reverted.
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
@@ -280,11 +277,14 @@ export async function watchUserPatches(
         patches: loadOptionalPatches(binName, filename) ?? [],
       },
     })
+  })
+  const reapply = async (): Promise<void> => {
+    await apply()
     await ctx.loader.await()
     await Promise.allSettled([...ctx.loader.entries()].map(entry => Promise.resolve(entry.fiber?.await())))
     const failures = await inactiveEntries(ctx)
     if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
-  })
+  }
   const register = watchConfig(ctx, filename, hmr.config, reapply)
   try {
     return await register
@@ -519,10 +519,6 @@ 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).
@@ -559,33 +555,6 @@ export interface FailLoudProcess {
   exit(code: number): void
 }
 
-// Loader rc.5 derives and drops a rejected promise after a fiber fails. Keep
-// exact reasons already folded into the boot diagnostic visible through the
-// next process rejection checkpoint so the process guard can coalesce them.
-const assembledActivationRejections = new Map<unknown, number>()
-
-function retainAssembledRejection(reason: unknown): void {
-  assembledActivationRejections.set(reason, (assembledActivationRejections.get(reason) ?? 0) + 1)
-}
-
-function releaseAssembledRejection(reason: unknown): void {
-  const count = assembledActivationRejections.get(reason)
-  if (count === undefined || count === 1) {
-    assembledActivationRejections.delete(reason)
-  } else {
-    assembledActivationRejections.set(reason, count - 1)
-  }
-}
-
-async function observeLoaderRejectionCheckpoint(reasons: readonly unknown[]): Promise<void> {
-  for (const reason of reasons) retainAssembledRejection(reason)
-  try {
-    await new Promise<void>(resolve => setImmediate(resolve))
-  } finally {
-    for (const reason of reasons) releaseAssembledRejection(reason)
-  }
-}
-
 /**
  * How long {@link installFailLoud} waits for its `release` hook before exiting
  * anyway. A wedged disposer must delay the fatal exit, never cancel it.
@@ -628,7 +597,7 @@ export function installFailLoud(
 ): () => void {
   let exiting = false
   const handler = (err: unknown): void => {
-    if (assembledActivationRejections.has(err)) return
+    if (isObservedEntryRejection(err)) return
     // A release in flight already owns the exit. Swallow later rejections
     // (teardown's own included) rather than reporting a second failure over the
     // real one or letting Node kill the process before the terminal is back.
@@ -663,93 +632,13 @@ 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; nothing is
- * stopped or attributed to a plugin, built-in or external, so this is a
- * process-level policy chosen over exiting, not an isolation of the plugin
- * that produced it. 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 (not attributed to a plugin; 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)
-  }
-}
-
 /**
  * Value mirrors used because Cordis's const enum has no runtime object to import.
  * Keep aligned with `packages/extensions/tool-cordis/src/fiber-state.ts` and
  * `packages/client/web/src/loader-status.ts`.
  */
-const FIBER_PENDING = 0 as FiberState.PENDING
-const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const FIBER_FAILED = 3 as FiberState.FAILED
 
-/**
- * Entry ids whose presence defines a usable DSH application.
- *
- * The list is global rather than profile metadata. Missing or disabled ids do
- * not affect startup; an enabled listed entry must activate. The list covers
- * shared Agent execution, application endpoints, and Web bootstrap/transport.
- */
-const requiredStartupEntryIds = new Set<string>([
-  'agent-loop',
-  'webserver',
-  'modules',
-  'connection',
-  'headless-runner',
-  'acp',
-  'sdk-jsonrpc-server',
-])
-
-/** Render plugin stacks, nested causes, and aggregate member failures once per error. */
-function formatActivationError(error: unknown): string {
-  const details: string[] = []
-  const seen = new Set<Error>()
-  function visit(value: unknown): void {
-    if (!(value instanceof Error)) {
-      details.push(String(value))
-      return
-    }
-    if (seen.has(value)) return
-    seen.add(value)
-    details.push(value.stack ?? value.message)
-    if (value.cause !== undefined) visit(value.cause)
-    if (value instanceof AggregateError) value.errors.forEach(visit)
-  }
-  visit(error)
-  return details.join('\n')
-}
-
 interface InactiveEntry {
   /** Loader entry used to identify the bootstrap Include and required ids. */
   entry: Entry
@@ -763,44 +652,16 @@ interface InactiveEntry {
  * duplicate Loader notifications through the next process rejection checkpoint.
  */
 async function inactiveEntries(ctx: Context): Promise<InactiveEntry[]> {
-  const failures: InactiveEntry[] = []
-  const rejectionReasons: unknown[] = []
-  for (const entry of ctx.loader.entries()) {
-    const subject = `${entry.options.id} (${entry.options.name})`
-    try {
-      if (entry.disabled) continue
-    } catch (error) {
-      failures.push({ entry, diagnostic: `${subject}: disabled expression failed: ${formatActivationError(error)}` })
-      continue
-    }
-    const fiber = entry.fiber
-    if (fiber === undefined) {
-      failures.push({ entry, diagnostic: `${subject}: failed to import` })
-      continue
-    }
-    const state = fiber.state
-    if (state === FIBER_ACTIVE) continue
-    if (state === FIBER_FAILED) {
-      try {
-        await fiber.await()
-      } catch (error) {
-        rejectionReasons.push(error)
-        failures.push({ entry, diagnostic: `${subject}: ${formatActivationError(error)}` })
-      }
-      continue
-    }
-    if (state === FIBER_PENDING) {
-      const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
-      failures.push({
-        entry,
-        diagnostic: `${subject}: pending (waiting for ${missing.length === 1 ? 'service' : 'services'}: ${missing.join(', ') || 'unknown'})`,
-      })
-    } else {
-      failures.push({ entry, diagnostic: `${subject}: fiber state ${String(state)}` })
-    }
-  }
-  if (rejectionReasons.length > 0) await observeLoaderRejectionCheckpoint(rejectionReasons)
-  return failures
+  return (await inspectEntryIssues(ctx)).map(issue => ({
+    entry: issue.entry,
+    diagnostic: `${issue.entry.options.id} (${issue.entry.options.name}): ${issue.stage === 'disabled-expression' ? 'disabled expression failed: ' : ''}${issue.message}`,
+  }))
+}
+
+/** The policy follows row provenance, including the owner of nested Includes. */
+function isOptionalEntry(ctx: Context, entry: Entry): boolean {
+  const origin = ctx.get('profileRuntime')?.originOfEntry(entry)
+  return origin?.trust === 'external' && origin.stage === 'runtime'
 }
 
 /** Render an inactive-entry diagnostic with a count and severity label. */
@@ -817,9 +678,9 @@ function activationDiagnostic(
 /**
  * Apply DSH startup policy to a settled Loader tree.
  *
- * Inactive entries from the global required list reject startup. Other
+ * Inactive built-in, boot-staged, and unowned entries reject startup. External runtime
  * inactive entries produce one warning and leave successful siblings running.
- * Required ids absent from the tree, and disabled required entries, are ignored.
+ * Disabled entries are ignored. Provenance is read from the prepared profile runtime.
  * A throwing disabled expression is an entry failure, not a disabled entry.
  * The bootstrap Include must activate so unreadable or invalid root config is fatal.
  * @param ctx - the settled context whose Loader entries to audit.
@@ -838,7 +699,7 @@ export async function auditStartupEntries(
   const optional: InactiveEntry[] = []
   for (const failure of failures) {
     const target = failure.entry === bootstrapIncludes.get(ctx)
-      || requiredStartupEntryIds.has(failure.entry.options.id) ? required : optional
+      || !isOptionalEntry(ctx, failure.entry) ? required : optional
     target.push(failure)
   }
   if (optional.length > 0) warn(activationDiagnostic(binName, 'warning', optional))
@@ -847,15 +708,6 @@ export async function auditStartupEntries(
   }
 }
 
-/**
- * Read activation diagnostics after a runtime update.
- * @param ctx - the settled Loader context.
- * @returns diagnostics for inactive entries.
- */
-export async function recordContainedStates(ctx: Context): Promise<string[]> {
-  return (await inactiveEntries(ctx)).map(failure => failure.diagnostic)
-}
-
 /**
  * Nested plugin fibers (a `ctx.inject()` continuation inside a plugin) fail
  * without touching their entry's root fiber, so the activation audit above
@@ -879,7 +731,7 @@ export function warnNestedFiberFailures(
       // 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
+      if (isOptionalEntry(ctx, owner)) continue
       count += 1
       warn(`${binName}: nested fiber under ${owner.options.name} failed (${runtime.name}); the entry itself stays active`)
     }
@@ -897,7 +749,7 @@ export function warnNestedFiberFailures(
  * embeds Include while leaving Loader external, so the built include tree and
  * host share one Loader peer. Loader settlement drains entry work without
  * rejecting the whole tree. The final {@link auditStartupEntries} call rejects
- * failures in the global required list and warns about other failed, missing,
+ * failures in required profile rows and warns about other failed, missing,
  * and pending entries while successful siblings remain active. Later unhandled
  * rejections remain covered by {@link installFailLoud}. Built bins need the Loader's native
  * helper for bare plugin specifiers; relative specifiers do not.
@@ -963,7 +815,7 @@ export async function boot(
       deepest = deepest.cause
     }
     const stack = deepest instanceof AggregateError
-      ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
+      ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatEntryError).join('\n')}`
       : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
     throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
   }

+ 127 - 0
packages/boot/app-boot/src/package-metadata.ts

@@ -0,0 +1,127 @@
+/** Installed package declarations read without importing or executing their modules. */
+import { createRequire } from 'node:module'
+import { existsSync, readFileSync, realpathSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { loadOverlayPatches } from './index.ts'
+import { readProfileManifest, resolveBundleDir } from './profile.ts'
+import { visitPatchRows } from './patch-rows.ts'
+import type { DshPluginDeclaration } from '@deepseek-ai/dsh-package-manifest'
+
+/** One configured bundle row; conditions remain unevaluated until it mounts. */
+export interface PackageMetadataRow {
+  readonly id?: string
+  readonly name: string
+  readonly gated: boolean
+}
+
+/** Static facts; a declaration is not an activation verdict. */
+export interface PackageMetadata {
+  readonly packageName: string
+  readonly version?: string
+  readonly description?: string
+  readonly title?: string
+  readonly kind: 'bundle' | 'plugin' | 'unknown'
+  readonly enginesDsh?: string
+  /** Physical resolution only; null when unavailable, not an execution guarantee. */
+  readonly cordisSameCopy: boolean | null
+  readonly rows: readonly PackageMetadataRow[]
+  readonly overrides: readonly string[]
+  readonly addable: readonly DshPluginDeclaration[]
+}
+
+/** Locations needed to read one installed package. */
+export interface PackageMetadataOptions {
+  readonly binName: string
+  readonly packageName: string
+  readonly profileDir: string
+  readonly installAnchor: string
+}
+
+/**
+ * Read package identity, bundle rows and explicitly addable modules without loading code.
+ * @param options - installation and profile resolution anchors.
+ * @returns declared metadata; an undeclared main export remains unknown.
+ * @throws when the package or its declaration cannot be read or parsed.
+ */
+export function readPackageMetadata(options: PackageMetadataOptions): PackageMetadata {
+  const { binName, packageName, installAnchor, profileDir } = options
+  const dir = resolveBundleDir(binName, packageName, installAnchor, profileDir)
+  const manifest = readProfileManifest(binName, dir)
+  const declarations: unknown = manifest.dsh?.plugins
+  const addable: DshPluginDeclaration[] = []
+  if (declarations !== undefined) {
+    if (!Array.isArray(declarations)) throw new TypeError(`${packageName}: dsh.plugins must be an array`)
+    const names = new Set<string>()
+    for (const value of declarations as unknown[]) {
+      if (!isRecord(value) || typeof value.name !== 'string' || value.name.length === 0
+        || (value.title !== undefined && typeof value.title !== 'string')) {
+        throw new TypeError(`${packageName}: dsh.plugins entries require a name and an optional string title`)
+      }
+      const name = value.name
+      const subpath = name.replace(/^\.\//, '')
+      if (name !== '.' && (subpath.startsWith('/') || subpath.split('/').some(part => part === '..' || part === '.' || part === '')
+        || /[:\\]/.test(subpath))) throw new TypeError(`${packageName}: invalid plugin subpath ${JSON.stringify(name)}`)
+      const key = name === '.' ? '.' : subpath
+      if (names.has(key)) throw new TypeError(`${packageName}: repeated plugin subpath ${JSON.stringify(name)}`)
+      names.add(key)
+      addable.push({
+        name,
+        ...value.title === undefined ? {} : { title: value.title },
+        ...value.config === undefined ? {} : { config: value.config },
+      })
+    }
+  }
+  const rows: PackageMetadataRow[] = []
+  const own = new Set<string>()
+  const declaredBundle = manifest.dsh?.bundle?.patch
+  const patches = declaredBundle === undefined ? [] : loadOverlayPatches(binName, join(dir, declaredBundle))
+  visitPatchRows(patches, (row) => {
+    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 && row.disabled !== false })
+  })
+  const overrides = patches.flatMap(patch => patch.insert === undefined && typeof patch.id === 'string' && !own.has(patch.id) ? [patch.id] : [])
+  const cordisSameCopy = compareCordisCopies(dir)
+  return {
+    packageName,
+    ...manifest.version === undefined ? {} : { version: manifest.version },
+    ...manifest.description === undefined ? {} : { description: manifest.description },
+    ...manifest.dsh?.title === undefined ? {} : { title: manifest.dsh.title },
+    ...manifest.engines?.dsh === undefined ? {} : { enginesDsh: manifest.engines.dsh },
+    kind: declaredBundle !== undefined ? 'bundle' : addable.length > 0 ? 'plugin' : 'unknown',
+    cordisSameCopy, rows, overrides, addable,
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Resolve physical package identity through a packaged-runtime module proxy. */
+function cordisPackageDir(resolved: string): string | undefined {
+  let dir = dirname(resolved.startsWith('file:') ? fileURLToPath(resolved) : resolved)
+  for (;;) {
+    const path = join(dir, 'package.json')
+    if (existsSync(path)) {
+      const manifest = JSON.parse(readFileSync(path, 'utf8')) as { name?: string; dsh?: { moduleFallback?: { targets?: Record<string, string> } } }
+      if (manifest.name === '@deepseek-ai/cordis') {
+        const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0]
+        return target === undefined ? realpathSync(dir) : cordisPackageDir(target)
+      }
+    }
+    const parent = dirname(dir)
+    if (parent === dir) return undefined
+    dir = parent
+  }
+}
+
+/** Compare resolvable physical peers; a missing or unreadable peer has no verdict. */
+function compareCordisCopies(dir: string): boolean | null {
+  try {
+    const installed = cordisPackageDir(createRequire(join(dir, 'package.json')).resolve('@deepseek-ai/cordis'))
+    const host = cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis'))
+    return installed === undefined || host === undefined ? null : installed === host
+  } catch {
+    return null
+  }
+}

+ 1 - 1
packages/boot/app-boot/src/patch-rows.ts

@@ -1,6 +1,6 @@
 /**
  * The one walk over the rows a patch list introduces. Composition, ownership,
- * the package probe, and patch loading all read those rows and their group
+ * static package metadata, and patch loading all read those rows and their group
  * children; sharing the walk keeps them reading the same tree.
  * @module @deepseek-ai/dsh-app-boot/patch-rows
  */

+ 0 - 50
packages/boot/app-boot/src/probe-child.ts

@@ -1,50 +0,0 @@
-/**
- * The probe's child process: resolve cordis from the probed package, import
- * its main export and every declared addable module, and send one report
- * over the IPC channel the parent opened. stdout and stderr stay the
- * imported modules' own, so a package that prints at import still reports.
- * Arguments: the package directory, the main specifier (empty for none), and
- * the addable specifiers as a JSON array; the environment carries the token
- * the report echoes. Nothing here runs inside the host.
- * @module @deepseek-ai/dsh-app-boot/probe-child
- */
-
-import { pathToFileURL } from 'node:url'
-import type { ChildInspection, ChildReport } from './probe-report.ts'
-
-const [dir = '', mainSpecifier = '', addableJson = '[]'] = process.argv.slice(2)
-const base = pathToFileURL(`${dir}/package.json`).href
-// The report is the parent's to receive: its token leaves the environment and
-// the channel's `send` leaves `process` before any of the package's code runs,
-// so nothing that code sends at import can pass as the report.
-const token = process.env.DSH_PROBE_REPORT ?? ''
-delete process.env.DSH_PROBE_REPORT
-const send = process.send?.bind(process)
-Reflect.deleteProperty(process, 'send')
-
-/** Import one module from the package and describe what it exports. */
-async function inspect(specifier: string): Promise<ChildInspection> {
-  try {
-    const mod = await import(import.meta.resolve(specifier, base)) as Record<string, unknown>
-    const plugin = mod.default ?? mod
-    const schema = (plugin as { Config?: unknown }).Config ?? mod.Config
-    const toJSON = (schema as { toJSON?: unknown } | null | undefined)?.toJSON
-    return {
-      ok: true,
-      isPlugin: typeof plugin === 'function' || typeof (plugin as { apply?: unknown }).apply === 'function',
-      configSchema: typeof toJSON === 'function' ? (toJSON as () => unknown).call(schema) : null,
-    }
-  } catch (error) {
-    return { ok: false, isPlugin: false, configSchema: null, error: String((error as { stack?: unknown } | null)?.stack ?? error) }
-  }
-}
-
-const report: ChildReport = { token, cordis: null, main: { ok: false, isPlugin: false, configSchema: null }, addable: {} }
-try {
-  report.cordis = import.meta.resolve('@deepseek-ai/cordis', base)
-} catch {
-  report.cordis = null // the package resolves no cordis at all: a library, or a plugin without the peer installed
-}
-if (mainSpecifier !== '') report.main = await inspect(mainSpecifier)
-for (const name of JSON.parse(addableJson) as string[]) report.addable[name] = await inspect(name)
-send?.(report, undefined, undefined, () => { process.disconnect() })

+ 0 - 63
packages/boot/app-boot/src/probe-report.ts

@@ -1,63 +0,0 @@
-/**
- * What the probe's child process reports, and the check the parent runs on
- * it. The report crosses a process boundary as an IPC message, so the parent
- * validates every field before trusting it; the child only imports the
- * types.
- * @module @deepseek-ai/dsh-app-boot/probe-report
- */
-
-/** What importing one module in the child found. */
-export interface ChildInspection {
-  /** Whether the import succeeded. */
-  ok: boolean
-  /** Whether the module's default or namespace export is a cordis plugin. */
-  isPlugin: boolean
-  /** The plugin's `Config.toJSON()`, or null when it declares none. */
-  configSchema: unknown
-  /** The import failure, when `ok` is false. */
-  error?: string
-}
-
-/** The child's one message: where cordis resolves from the package, and what each module imported as. */
-export interface ChildReport {
-  /** The token the parent handed the child for this run; a message without it is not the report. */
-  token: string
-  /** The URL the package resolves `@deepseek-ai/cordis` to, or null when it does not resolve it. */
-  cordis: string | null
-  /** The main export's inspection; the not-imported default when the package declares no main. */
-  main: ChildInspection
-  /** The inspection of each declared addable module, by its specifier. */
-  addable: Record<string, ChildInspection>
-}
-
-/**
- * Whether a value is a plain object: the only JSON value with named fields.
- * @param value - the value to test.
- * @returns true for a non-null, non-array object.
- */
-export function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
-/** Whether a value has the fields of one inspection. */
-function isInspection(value: unknown): value is ChildInspection {
-  return isRecord(value)
-    && typeof value.ok === 'boolean'
-    && typeof value.isPlugin === 'boolean'
-    && 'configSchema' in value
-    && (value.error === undefined || typeof value.error === 'string')
-}
-
-/**
- * Validate a message the child sent as its report.
- * @param value - the message as received.
- * @returns the report, or undefined when the message is not one.
- */
-export function parseChildReport(value: unknown): ChildReport | undefined {
-  if (!isRecord(value)) return undefined
-  if (typeof value.token !== 'string') return undefined
-  if (value.cordis !== null && typeof value.cordis !== 'string') return undefined
-  if (!isInspection(value.main) || !isRecord(value.addable)) return undefined
-  if (!Object.values(value.addable).every(isInspection)) return undefined
-  return value as unknown as ChildReport
-}

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

@@ -1,475 +0,0 @@
-/**
- * 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
- * (`probe-child.ts`) only imports and reports over IPC, and the report and
- * the cached record are validated as the process and file boundaries they
- * cross.
- * @module @deepseek-ai/dsh-app-boot/probe
- */
-
-import { spawn } from 'node:child_process'
-import { randomUUID } from 'node:crypto'
-import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { withoutSensitiveEnv } from '@deepseek-ai/dsh-launch-environment'
-import { loadOverlayPatches } from './index.ts'
-import { readProfileManifest, resolveBundleDir, type ProfileManifest } from './profile.ts'
-import { visitPatchRows } from './patch-rows.ts'
-import { isRecord, parseChildReport, type ChildReport } from './probe-report.ts'
-
-/** Directory under a profile holding one probe record per package. */
-export const PLUGIN_PROBE_DIR = '.dsh-plugins'
-
-/**
- * The probe record format. Bumped when the probe's verdicts change meaning,
- * so a record an older probe wrote is re-probed instead of trusted: 2 made a
- * bare function export a `library` rather than a `plugin`.
- */
-export const PLUGIN_PROBE_FORMAT = 2
-
-/** 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` (declares itself to dsh — a
-   * `dsh` section or a dependency on `@deepseek-ai/cordis` — and its main
-   * export is a cordis plugin); else `library`. A function export alone is
-   * not a plugin: `lodash` exports one too.
-   */
-  readonly kind: 'bundle' | 'plugin' | 'library'
-  /**
-   * Whether the main export imported and cordis is not a second copy. True
-   * for a `library`, for a package whose cordis resolution is unknown, and
-   * for one whose addable modules failed: `kind` and `addable[].ok` decide
-   * what can be enabled or added.
-   */
-  readonly ok: boolean
-  /** Why the import or the cordis check failed, 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 introduces, inserted or set as a group's config, 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>
-}
-
-/**
- * The child entry beside this module: the TypeScript source under a source
- * launch, run through tsx; the bundled `lib/probe-child.js` otherwise.
- */
-function childEntryArgs(): string[] {
-  /* v8 ignore next 3 -- the built-output arm: tests run from src */
-  if (!import.meta.url.endsWith('.ts')) {
-    return [fileURLToPath(new URL('./probe-child.js', import.meta.url))]
-  }
-  return ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./probe-child.ts', import.meta.url))]
-}
-
-/**
- * Run the child and take its report from the IPC channel. The report is all
- * the probe needs, so the child is killed once it arrived: a package that
- * keeps a timer alive after import costs nothing more. stdout is not read
- * at all, so whatever the imported modules print cannot corrupt the report.
- * The child gets the parent environment minus credential-shaped names, plus
- * a per-run token it echoes in its report — a message without the token is
- * the package's own, not the report — and the probe settles only once the
- * child closed, so its pipes and channel are gone when the caller continues.
- */
-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 token = randomUUID()
-    const child = spawn(
-      options.nodeExecutable ?? process.execPath,
-      ['--experimental-import-meta-resolve', ...childEntryArgs(), packageDir, mainSpecifier, JSON.stringify(addable)],
-      {
-        cwd: options.profileDir,
-        stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
-        env: { ...withoutSensitiveEnv(process.env), NODE_NO_WARNINGS: '1', [PROBE_REPORT_VARIABLE]: token },
-      },
-    )
-    // The tail of stderr, for the failure text: a package that floods stderr at
-    // import must not grow this process's heap by as much.
-    const err = new StreamTail(STDERR_TAIL_BYTES)
-    child.stderr?.on('data', (chunk: Buffer) => { err.push(chunk) })
-    // The outcome lands on `close`. A kill — after the report, or at the
-    // timeout — records its outcome and waits for the close it causes, so the
-    // child's pipes and channel are gone when the caller continues; a spawn
-    // failure emits `error` with no process to wait for.
-    let outcome: (() => void) | undefined
-    const finish = (next: () => void): void => {
-      /* v8 ignore next -- a report the kill still let through after the timeout decided, or the reverse: the first outcome stands */
-      if (outcome !== undefined) return
-      outcome = next
-      clearTimeout(timer)
-      child.kill('SIGKILL')
-    }
-    const timer = setTimeout(() => {
-      finish(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
-    }, timeoutMs)
-    child.on('message', (message) => {
-      const report = parseChildReport(message)
-      /* v8 ignore next -- the imported code finds no process.send; a message through the raw channel would still lack the token */
-      if (report === undefined || report.token !== token) return
-      finish(() => { resolve(report) })
-    })
-    child.on('error', (error) => {
-      clearTimeout(timer)
-      reject(error)
-    })
-    child.on('close', (code) => {
-      clearTimeout(timer)
-      if (outcome !== undefined) {
-        outcome()
-        return
-      }
-      const tail = err.text().trim()
-      reject(new Error(`${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${tail}`))
-    })
-  })
-}
-
-const DEFAULT_TIMEOUT_MS = 20_000
-
-/** How much of the child's stderr the failure text keeps: the end, where the cause usually is. */
-const STDERR_TAIL_BYTES = 16 * 1024
-
-/**
- * The last `limit` bytes a stream delivered, kept as the chunks it arrived in:
- * as more arrives, whole chunks leave the front and the oldest kept chunk is
- * trimmed, so one chunk larger than the limit keeps its end and a small chunk
- * after it never drops what was kept.
- */
-export class StreamTail {
-  private readonly chunks: Buffer[] = []
-  private bytes = 0
-
-  /** @param limit - how many bytes of the end to keep. */
-  constructor(private readonly limit: number) {}
-
-  /**
-   * Take one more chunk from the stream.
-   * @param chunk - the bytes as delivered.
-   */
-  push(chunk: Buffer): void {
-    this.chunks.push(chunk)
-    this.bytes += chunk.length
-    while (this.bytes > this.limit) {
-      const excess = this.bytes - this.limit
-      const first = this.chunks[0] as Buffer
-      if (first.length <= excess) {
-        this.chunks.shift()
-        this.bytes -= first.length
-      } else {
-        this.chunks[0] = first.subarray(excess)
-        this.bytes = this.limit
-      }
-    }
-  }
-
-  /**
-   * The kept end of the stream.
-   * @returns the last bytes delivered, at most the limit, as UTF-8 text.
-   */
-  text(): string {
-    return Buffer.concat(this.chunks).toString('utf8')
-  }
-}
-
-/** The environment name carrying the run's report token; the child removes it before importing anything. */
-const PROBE_REPORT_VARIABLE = 'DSH_PROBE_REPORT'
-
-/** The rows a bundle patch introduces, 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 patches = loadOverlayPatches(binName, patchPath)
-  visitPatchRows(patches, (row) => {
-    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 })
-  })
-  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 }
-}
-
-/**
- * Whether a package declares itself to dsh: a `dsh` section in its manifest,
- * or a dependency on `@deepseek-ai/cordis`. A cordis plugin needs at least
- * cordis's types, and the harness's plugin conventions put display and
- * composition metadata under `dsh`; a package with neither that happens to
- * export a function (`lodash`, `debug`) is a library.
- * @param manifest - the package manifest.
- * @returns true for a package that declares itself to dsh.
- */
-function declaresDsh(manifest: ProbedManifest): boolean {
-  if (manifest.dsh !== undefined) return true
-  return '@deepseek-ai/cordis' in (manifest.peerDependencies ?? {}) || '@deepseek-ai/cordis' in (manifest.dependencies ?? {})
-}
-
-/**
- * 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 cordisSameCopy = sameCordisCopy(report.cordis, resolveHarnessCordis())
-  const kind: PluginProbe['kind'] = declaredBundle !== undefined
-    ? 'bundle'
-    : report.main.isPlugin && declaresDsh(manifest) ? '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 package directory of the `@deepseek-ai/cordis` copy this harness runs:
- * the one this module resolves, which every built-in plugin shares. Undefined
- * only in an environment that cannot resolve it at all.
- */
-function resolveHarnessCordis(): string | undefined {
-  try {
-    return cordisPackageDir(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
-  }
-}
-
-/**
- * Whether the package's cordis is the harness's: the two package directories
- * compare equal. Null when the package resolves no cordis, when the harness
- * cannot resolve its own, or when no cordis manifest encloses what the
- * package resolved, since none of those says the package brought a copy.
- */
-function sameCordisCopy(reported: string | null, harness: string | undefined): boolean | null {
-  if (reported === null) return null
-  /* v8 ignore next -- the harness resolves its own peer; only an embedder without cordis lands here */
-  if (harness === undefined) return null
-  const dir = cordisPackageDir(reported)
-  return dir === undefined ? null : dir === harness
-}
-
-/** The manifest fields that tell a dsh module proxy from the package it stands for. */
-interface ProxyAwareManifest {
-  name?: unknown
-  dsh?: { moduleFallback?: { targets?: Record<string, string> } }
-}
-
-/**
- * The directory of the `@deepseek-ai/cordis` package a resolved module URL
- * belongs to, with symlinks resolved and a dsh module proxy followed to the
- * package it re-exports. Two resolutions of one installed package compare
- * equal this way whether they landed on `src` or `lib`, on a link or its
- * target, or on the proxy a packaged executable writes for its peers; the
- * URLs themselves differ in every one of those cases.
- * @param resolved - the URL `import.meta.resolve('@deepseek-ai/cordis')` gave.
- * @returns the real package directory, or undefined when no cordis manifest encloses the URL.
- */
-export function cordisPackageDir(resolved: string): string | undefined {
-  let dir = dirname(fileURLToPath(resolved))
-  for (;;) {
-    const manifestPath = join(dir, 'package.json')
-    if (existsSync(manifestPath)) {
-      const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as ProxyAwareManifest
-      if (manifest.name === '@deepseek-ai/cordis') {
-        const target = Object.values(manifest.dsh?.moduleFallback?.targets ?? {})[0]
-        return target === undefined ? realpathSync(dir) : cordisPackageDir(target)
-      }
-    }
-    const parent = dirname(dir)
-    if (parent === dir) return undefined
-    dir = parent
-  }
-}
-
-/** 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, the cached one was written by another probe format, or it is not a record.
- */
-export function readProbeCache(profileDir: string, packageName: string, version?: string): PluginProbe | undefined {
-  const path = probeCachePath(profileDir, packageName)
-  if (!existsSync(path)) return undefined
-  let stored: unknown
-  try {
-    stored = JSON.parse(readFileSync(path, 'utf8'))
-  } catch {
-    return undefined // not JSON: a truncated or hand-edited file is probed again
-  }
-  if (!isRecord(stored) || stored.format !== PLUGIN_PROBE_FORMAT) return undefined
-  const { format: _format, ...fields } = stored
-  const record = parseProbeRecord(fields)
-  if (record === undefined) return undefined
-  if (version !== undefined && record.version !== version) return undefined
-  return record
-}
-
-const PROBE_KINDS: ReadonlySet<string> = new Set<PluginProbe['kind']>(['bundle', 'plugin', 'library'])
-
-/** Whether a value is absent or a string. */
-function optionalString(value: unknown): boolean {
-  return value === undefined || typeof value === 'string'
-}
-
-/** Whether a value has the fields of one probed row. */
-function isProbeRow(value: unknown): value is PluginProbeRow {
-  return isRecord(value) && optionalString(value.id) && typeof value.name === 'string' && typeof value.gated === 'boolean'
-}
-
-/** Whether a value has the fields of one addable module. */
-function isProbeAddable(value: unknown): value is PluginProbeAddable {
-  return isRecord(value) && typeof value.name === 'string' && optionalString(value.title) && typeof value.ok === 'boolean' && optionalString(value.error)
-}
-
-/**
- * Validate a stored probe record, as the cache file is a boundary this
- * process does not control.
- * @param value - the parsed file without its `format` field.
- * @returns the record, or undefined when a field is missing or mistyped.
- */
-export function parseProbeRecord(value: unknown): PluginProbe | undefined {
-  if (!isRecord(value)) return undefined
-  if (typeof value.packageName !== 'string' || typeof value.checkedAt !== 'string') return undefined
-  if (![value.version, value.description, value.title, value.reason, value.enginesDsh].every(optionalString)) return undefined
-  if (typeof value.kind !== 'string' || !PROBE_KINDS.has(value.kind) || typeof value.ok !== 'boolean') return undefined
-  if (value.cordisSameCopy !== null && typeof value.cordisSameCopy !== 'boolean') return undefined
-  if (!Array.isArray(value.rows) || !value.rows.every(isProbeRow)) return undefined
-  if (!Array.isArray(value.overrides) || !value.overrides.every(item => typeof item === 'string')) return undefined
-  if (!Array.isArray(value.addable) || !value.addable.every(isProbeAddable)) return undefined
-  return value as unknown as PluginProbe
-}
-
-/**
- * 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({ format: PLUGIN_PROBE_FORMAT, ...probe }, undefined, 2) + '\n')
-}

+ 48 - 34
packages/boot/app-boot/src/profile-runtime.ts

@@ -1,26 +1,12 @@
-/**
- * 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. A
- * recomposition composes a candidate stack, applies it through the root
- * include, and publishes the profile, the stack's id ownership, and its
- * conflicts only once the include accepted it; a rejected update leaves the
- * committed composition in place, which describes the tree still running.
- * Recompositions run one at a time: each reads the committed composition
- * only after the previous one settled, so a watcher firing while a bundle is
- * being enabled recomposes the enabled tree instead of the one before it.
- * 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
- */
+/** Serialized profile recomposition with declared provenance and observed per-entry outcomes. */
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import type Include from '@deepseek-ai/cordis-plugin-include'
-import type { ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { BundleStage, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
 import type { ComposedStack, RowConflict } from './compose-stack.ts'
 import type { BundleTrust, Profile, ProfileLayer } from './profile.ts'
+import { inspectEntryIssues, type EntryIssue } from './entry-issues.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -33,6 +19,8 @@ declare module '@deepseek-ai/cordis' {
 export interface RowOrigin {
   /** Who supplied the layer that inserted the row. */
   readonly trust: BundleTrust
+  /** Effective startup-failure policy of the supplying bundle. */
+  readonly stage: BundleStage
   /** The bundle package that inserted the row. */
   readonly packageName: string
   /** The package's version, when its manifest declares one. */
@@ -122,11 +110,27 @@ export class ProfileRuntime extends Service {
     if (layer === undefined) return undefined
     return {
       trust: layer.trust,
+      stage: layer.stage,
       packageName: layer.packageName,
       ...layer.version === undefined ? {} : { version: layer.version },
     }
   }
 
+  /**
+   * Resolve provenance within its Loader tree; nested includes inherit their owning entry.
+   * @param entry - the live entry, including an entry inside another Include.
+   * @returns its supplying bundle, or undefined for a user-owned entry.
+   */
+  originOfEntry(entry: Entry): RowOrigin | undefined {
+    const rootTree = this.options.rootEntry()?.subtree
+    let current: Entry | undefined = entry
+    while (current !== undefined) {
+      if (current.parent.tree === rootTree) return this.originOf(current.options.id)
+      current = current.parent.tree.ctx.fiber.entry
+    }
+    return undefined
+  }
+
   /**
    * Row ids the user patch layers disable with a literal `disabled: true`,
    * as the committed composition read them. The set describes the running
@@ -151,40 +155,50 @@ export class ProfileRuntime extends Service {
   }
 
   /**
-   * 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. The candidate profile,
-   * its ownership, and its conflicts become the committed composition only
-   * once the update holds; until then, and after a rejection, `current`,
-   * `layers`, `originOf`, and `conflicts` keep describing the running tree.
-   * Calls queue: one that arrives while another is in flight starts after it
-   * settled and reads what it committed. A rejection is that call's outcome
-   * alone and does not stop the ones behind it.
-   * @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.
+   * Apply a fresh profile stack and wait for live entries and removed fibers to settle.
+   * Parse/composition failures leave the applied stack unchanged. Accepted options
+   * can coexist with failed entries or fibers running their previous valid config.
+   * Calls serialize; a failed call does not block later changes.
+   * @param options - whether to reread installed bundle layers from disk.
+   * @returns current entry issues after application, without rolling back successful siblings.
+   * @throws when preparation fails or the root Include cannot accept the update.
    */
-  async recompose(options: { reloadBundles?: boolean } = {}): Promise<void> {
+  async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]> {
     const run = this.queue.then(() => this.recomposeNow(options))
     this.queue = run.then(() => undefined, () => undefined)
     return run
   }
 
-  private async recomposeNow(options: { reloadBundles?: boolean }): Promise<void> {
+  private async recomposeNow(options: { reloadBundles?: boolean }): Promise<readonly EntryIssue[]> {
     const entry = this.options.rootEntry()
     if (entry === undefined) throw new Error('profileRuntime: the root include is not mounted')
     const profile = options.reloadBundles === true ? this.options.loadProfile() : this.committed.profile
     const stack = this.options.compose(profile)
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
+    const before = [...this.ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
     await entry.update({
       config: {
         ...includeConfig,
-        patches: stack.patches,
+        patches: structuredClone(stack.patches),
       },
     })
+    await this.ctx.loader.await()
+    const current = new Set([...this.ctx.loader.entries()].map(row => row.fiber))
+    // Removed entries are absent from Loader.getTasks(), but still own teardown.
+    await Promise.allSettled(before.filter(fiber => fiber.uid === null || !current.has(fiber)).map(fiber => fiber.await()))
+    await this.ctx.loader.await()
     this.committed = { profile, stack }
+    return inspectEntryIssues(this.ctx)
+  }
+
+  /**
+   * Wait for recompositions already queued when called, including removed-fiber cleanup.
+   * Observers may read accepted composition facts afterwards; a failed operation
+   * still reports its error to its caller and does not reject this observation.
+   * @returns after the current recomposition queue settles.
+   */
+  whenIdle(): Promise<void> {
+    return this.queue
   }
 }
 

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

@@ -75,7 +75,7 @@ export interface ProfileLayer {
   packageDir: string
   /** Absolute path of the bundle's patch file. */
   patchPath: string
-  /** Who supplied the layer; decides isolation, id prefixing, and failure semantics. */
+  /** Who supplied the layer; decides row ownership priority and startup failure policy. */
   trust: BundleTrust
   /** Effective mount stage: the profile's override, else the bundle's declaration, else `runtime`. */
   stage: BundleStage

+ 27 - 16
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -8,7 +8,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
   addHarnessSourceSection, auditStartupEntries, boot,
   FAIL_LOUD_RELEASE_TIMEOUT_MS, HARNESS_SOURCE_SECTION,
-  installFailLoud, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
+  installFailLoud, ProfileRuntime, loadEnv, loadLayeredEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
@@ -420,7 +420,16 @@ describe('installFailLoud', () => {
     installFailLoud(NAME, proc)
     const error = new Error('assembled activation failure')
     const warn = vi.fn()
+    let checkpoint!: () => void
+    const atCheckpoint = new Promise<void>((resolve) => { checkpoint = resolve })
+    let release!: () => void
+    const schedule = vi.spyOn(globalThis, 'setImmediate').mockImplementationOnce(((callback: () => void) => {
+      release = callback
+      checkpoint()
+      return {} as NodeJS.Immediate
+    }))
     const audit = auditStartupEntries({
+      get: () => ({ originOfEntry: () => ({ trust: 'external', stage: 'runtime' }) }),
       loader: {
         entries: () => ['broken-a', 'broken-b'].map(name => ({
           options: { id: name, name },
@@ -433,11 +442,15 @@ describe('installFailLoud', () => {
         })),
       },
     } as unknown as Context, NAME, warn)
-    await Promise.resolve()
-    await Promise.resolve()
-    proc.handlers[0]!(error)
-    expect(proc.written).toEqual([])
-    expect(proc.exits).toEqual([])
+    await atCheckpoint
+    try {
+      proc.handlers[0]!(error)
+      expect(proc.written).toEqual([])
+      expect(proc.exits).toEqual([])
+    } finally {
+      schedule.mockRestore()
+      release()
+    }
     await audit
     expect(warn).toHaveBeenCalledWith(expect.stringContaining('assembled activation failure'))
     proc.handlers[0]!(error)
@@ -504,15 +517,7 @@ describe('installFailLoud', () => {
 })
 
 describe('auditStartupEntries', () => {
-  const requiredIds = [
-    'agent-loop',
-    'webserver',
-    'modules',
-    'connection',
-    'headless-runner',
-    'acp',
-    'sdk-jsonrpc-server',
-  ]
+  const requiredIds = ['required-a', 'webserver', 'headless-runner']
 
   interface FakeEntry {
     fiber?: {
@@ -526,6 +531,7 @@ describe('auditStartupEntries', () => {
   }
 
   const ctxWith = (entries: FakeEntry[]): Context => ({
+    get: () => ({ originOfEntry: (entry: FakeEntry) => requiredIds.includes(entry.options.id) ? undefined : { trust: 'external', stage: 'runtime' } }),
     loader: { entries: () => entries.values() },
   }) as unknown as Context
 
@@ -551,6 +557,9 @@ describe('auditStartupEntries', () => {
         options: { id, name: './required.mjs' },
       }))), NAME, warn)).resolves.toBeUndefined()
     }
+    await expect(auditStartupEntries(ctxWith([{
+      options: { id: 'recovered', name: './plugin.mjs' }, fiber: fiber(3),
+    }]), NAME, warn)).resolves.toBeUndefined()
     expect(warn).not.toHaveBeenCalled()
   })
 
@@ -915,7 +924,9 @@ describe('boot', () => {
     const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
     let ctx: Context | undefined
     try {
-      ctx = await boot(NAME, configPath)
+      ctx = await boot(NAME, configPath, [], (ctx) => {
+        ctx.provide('profileRuntime', { originOfEntry: () => ({ trust: 'external', stage: 'runtime' }) } as unknown as ProfileRuntime)
+      })
       expect(ctx.get('goodStarted')).toBe(true)
       const entries = [...ctx.loader.entries()]
       expect(entries.find(entry => entry.options.id === 'good')?.fiber?.state).toBe(2)

+ 6 - 7
packages/boot/app-boot/tests/compose-stack.spec.ts

@@ -10,7 +10,6 @@ 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 { claimLayerIds, composeProfileStack, formatRowConflict, type ProfileLayer } from '../src/index.ts'
-import { CONTAINED_GROUP_MODULE } from '../src/external-bundles.ts'
 
 const NAME = 'dsh-test-bin'
 
@@ -102,8 +101,8 @@ describe('claimLayerIds', () => {
 
   it('leaves out a bundle whose config override of its own group lists an id twice', () => {
     const self = layer('self', 'external', [
-      { insert: [{ id: 'row', name: 'self/row' }] },
-      { id: 'bundle/self', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
+      { insert: [{ id: 'self-group', name: 'cordis:group', group: true, config: [{ id: 'row', name: 'self/row' }] }] },
+      { id: 'self-group', config: [{ id: 'row', name: 'self/row' }, { id: 'row', name: 'self/row-again' }] },
     ])
     const { skipped, composed } = claimLayerIds([base, self])
     expect(skipped.get('self')?.map(conflict => conflict.message)).toEqual(['row "row" is declared twice by self'])
@@ -120,7 +119,7 @@ describe('claimLayerIds', () => {
     expect(owners.has('x')).toBe(false)
     expect(owners.get('y')?.packageName).toBe('clean')
     expect([...composed.keys()]).toEqual(['clean'])
-    expect(composed.get('clean')?.patches[1]).toEqual({ id: 'bundle/clean', insert: [{ id: 'y', name: 'clean' }] })
+    expect(composed.get('clean')?.patches[0]).toEqual({ insert: [{ id: 'y', name: 'clean' }] })
   })
 
   it('gives the earlier external bundle the id and leaves the later one out whole', () => {
@@ -128,7 +127,7 @@ describe('claimLayerIds', () => {
     const second = layer('second', 'external', [{ insert: [{ id: 'hello', name: 'second' }, { id: 'only-second', name: 'second/x' }] }])
     const { owners, skipped } = claimLayerIds([base, first, second])
     expect(owners.get('hello')?.packageName).toBe('first')
-    expect(owners.get('bundle/first')?.packageName).toBe('first')
+    expect(owners.has('bundle/first')).toBe(false)
     expect(owners.has('only-second')).toBe(false)
     expect(skipped.get('second')?.map(conflict => conflict.rowId)).toEqual(['hello'])
   })
@@ -160,8 +159,8 @@ describe('composeProfileStack', () => {
       ] },
     ])
     expect(stack.layers.map(current => current.label)).toEqual(['@deepseek-ai/dsh-base', 'ext', '/p/cordis.patch.yml', '/home/cordis.patch.yml'])
-    expect(stack.layers[1]?.patches[0]?.insert?.[0]).toMatchObject({ id: 'bundle/ext', name: CONTAINED_GROUP_MODULE })
-    expect([...stack.owners.keys()]).toEqual(['settings', 'tools', 'tool-bash', 'ext-tool', 'bundle/ext'])
+    expect(stack.layers[1]?.patches[0]?.insert?.[0]).toMatchObject({ id: 'ext-tool', name: 'ext' })
+    expect([...stack.owners.keys()]).toEqual(['settings', 'tools', 'tool-bash', 'ext-tool'])
     expect(stack.layers[2]?.patches).toEqual([
       { id: 'settings', config: { path: '/x' } },
       { insert: [{ id: 'mine', name: 'mine' }] },

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

@@ -1,360 +0,0 @@
-/**
- * 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, vi } from 'vitest'
-import { Context, type Plugin } from '@deepseek-ai/cordis'
-import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { assertEntriesActivated, boot, ensurePluginFailures, installRuntimeGuards, rootIncludeEntry, warnNestedFiberFailures, type RuntimeGuardProcess } from '../src/index.ts'
-import { ContainedFailureRegistry, ContainedGroup, isContainedEntry } from '../src/contained-group.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('clears a waiting row\'s record once the service it waits for appears', 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')?.stage).toBe('inject-pending')
-    // A fiber outside the tree — no entry, no record — changes state without touching the registry.
-    await ctx.plugin(() => {})
-    expect(registry.get('include:ext/waiting')?.stage).toBe('inject-pending')
-    // The service arrives in place, with no re-creation of the row: the fiber
-    // activates where it stands, and the record goes with the wait.
-    ctx.provide('neverReady', {})
-    await vi.waitFor(() => { expect(registry.get('include:ext/waiting')).toBeUndefined() })
-  })
-
-  it('keeps recording a waiting row when a reload re-creates its group', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/waiting
-      name: cordis:pending
-- id: other
-  name: cordis:good
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/waiting')?.stage).toBe('inject-pending')
-
-    const root = rootIncludeEntry(ctx)
-    if (root === undefined) throw new Error('no root include')
-    const { patches: _patches, ...config } = root.options.config as { path: string; patches?: unknown }
-    await root.update({ config: { ...config, patches: [{ id: 'other', disabled: true }] } })
-
-    expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', rowId: 'ext/waiting' }))
-    expect(registry.get('include:ext/waiting')?.message).toBe('pending (waiting for service: 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('forgets its rows\' records when the group unmounts', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/bad
-      name: cordis:throws
-    - id: ext/ok
-      name: cordis:good
-- id: bundle/other
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: other/bad
-      name: cordis:throws
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.list().map(failure => failure.entryId).sort()).toEqual(['include:ext/bad', 'include:other/bad'])
-    // The group's row id keys the tree store; `Entry.id` carries the include prefix.
-    const group = ctx.loader.resolve('include:bundle/ext')
-    await group.parent.remove(group.options.id)
-    expect([...ctx.loader.entries()].some(entry => entry.id === 'include:ext/ok')).toBe(false)
-    expect(registry.list().map(failure => failure.entryId)).toEqual(['include:other/bad'])
-  })
-
-  it('drops the record of a row its configuration no longer names, while the group stays', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/bad
-      name: cordis:throws
-    - id: ext/ok
-      name: cordis:good
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/bad')).toBeDefined()
-    const group = ctx.loader.resolve('include:bundle/ext')
-    // The bundle updates to a version without the failing row: the group is updated, not unmounted.
-    await group.update({ config: [{ id: 'ext/ok', name: 'cordis:good' }] })
-    expect(registry.get('include:ext/bad')).toBeUndefined()
-    expect([...ctx.loader.entries()].some(entry => entry.id === 'include:ext/ok')).toBe(true)
-  })
-
-  it('records a pending row of a nested plain group under the contained group that isolates it, so unmounting the bundle clears it', async () => {
-    const ctx = await boot(NAME, stage(`
-- id: bundle/ext
-  name: cordis:contained-group
-  group: true
-  config:
-    - id: ext/inner
-      name: cordis:group
-      group: true
-      config:
-        - id: ext/waiting
-          name: cordis:pending
-        - name: cordis:good
-`), [], prepare)
-    contexts.push(ctx)
-    const registry = ctx.get('pluginFailures') as ContainedFailureRegistry
-    expect(registry.get('include:ext/waiting')).toEqual(expect.objectContaining({ stage: 'inject-pending', groupId: 'include:bundle/ext' }))
-    const group = ctx.loader.resolve('include:bundle/ext')
-    await group.parent.remove(group.options.id)
-    expect(registry.get('include:ext/waiting')).toBeUndefined()
-  })
-
-  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)
-  })
-})

+ 211 - 0
packages/boot/app-boot/tests/entry-issues.spec.ts

@@ -0,0 +1,211 @@
+/** Native entries preserve siblings and report requested options separately from live fibers. */
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context, type Plugin } from '@deepseek-ai/cordis'
+import Loader, { type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { boot, composeProfileStack, entryIssue, inspectEntryIssues, ProfileRuntime, rootIncludeEntry, warnNestedFiberFailures, type Profile, type ProfileLayer } from '../src/index.ts'
+
+const roots: string[] = []
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) })
+})
+const good: Plugin.Function = () => {}
+const bad: Plugin.Function = () => { throw new Error('apply failed') }
+const asyncBad: Plugin.Function = async () => { await Promise.resolve(); throw new Error('async apply failed') }
+const configured: Plugin.Object<{ value: number }> = {
+  Config: { '~standard': { version: 1, vendor: 'entry-test', validate: (input) => {
+    const config = input as { value: number }
+    return config.value < 0 ? { issues: [{ message: 'negative value' }] } : { value: config }
+  } } },
+  apply() {},
+}
+const nested: Plugin.Function = (ctx) => { ctx.inject([], () => { throw new Error('nested failure') }) }
+const waiting: Plugin.Object = { inject: ['testService'], apply() {} }
+function layer(trust: ProfileLayer['trust'], stage: ProfileLayer['stage'], patches: PatchOptions[]): ProfileLayer {
+  return { packageName: 'pkg', version: undefined, packageDir: '/pkg', patchPath: '/pkg/patch.yml', trust, stage, patches }
+}
+async function profileBoot(layers: ProfileLayer[], user: PatchOptions[] = []): Promise<Context> {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-entry-policy-'))
+  roots.push(dir)
+  const path = join(dir, 'cordis.yml')
+  writeFileSync(path, '[]\n')
+  const profile: Profile = { name: 'test', dir, layers: [...layers], patches: [], patchPath: join(dir, 'cordis.patch.yml'), patchReload: 'live' }
+  const compose = (current: Profile) => composeProfileStack('test', current.layers, [{ label: profile.patchPath, patches: user }])
+  const stack = compose(profile)
+  const ctx = await boot('test', path, stack.patches, async (ctx) => {
+    Object.assign(ctx.loader.builtins, { good, bad, asyncBad, configured, waiting, nested })
+    await ctx.plugin(ProfileRuntime, {
+      profile, stack, compose, installAnchor: join(dir, 'package.json'), loadProfile: () => ({ ...profile, layers: [...layers] }), rootEntry: () => rootIncludeEntry(ctx),
+    })
+  })
+  contexts.push(ctx)
+  return ctx
+}
+const failures: EntryOptions[] = [
+  { id: 'missing', name: './dsh-missing-fixture.mjs' },
+  { id: 'bad', name: 'cordis:bad' },
+  { id: 'asyncBad', name: 'cordis:asyncBad' },
+  { id: 'configured', name: 'cordis:configured', config: { value: -1 } },
+  { id: 'waiting', name: 'cordis:waiting' },
+  { id: 'gate', name: 'cordis:good', disabled: { __jsExpr: 'missing.disabled' } as unknown as boolean },
+]
+describe('entry startup policy', () => {
+  it.each(failures)('keeps a failing external runtime row and its successful sibling: $id', async (row) => {
+    const ctx = await profileBoot([layer('external', 'runtime', [{ insert: [row, { id: 'ok', name: 'cordis:good' }] }])])
+    expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
+    const entry = ctx.loader.resolve(`include:${row.id}`)
+    expect((await entryIssue(entry))?.message).toBeTruthy()
+    expect(ctx.profileRuntime.originOfEntry(entry)).toMatchObject({ trust: 'external', stage: 'runtime' })
+    expect([...ctx.loader.entries()].some(e => e.options.name === 'cordis:contained-group')).toBe(false)
+  })
+  it.each([
+    ['builtin', 'boot'], ['builtin', 'runtime'], ['external', 'boot'],
+  ] as const)('rejects %s/%s failures regardless of module id', async (trust, stage) => {
+    for (const row of failures) {
+      await expect(profileBoot([layer(trust, stage, [{ insert: [row] }])])).rejects.toThrow('required startup failure')
+    }
+  })
+  it('keeps user rows strict and does not transfer ownership with a config override', async () => {
+    await expect(profileBoot([], [{ insert: [{ id: 'user', name: 'cordis:bad' }] }])).rejects.toThrow('required startup failure')
+    const builtin = layer('builtin', 'runtime', [{ insert: [{ id: 'webserver', name: 'cordis:configured', config: { value: 1 } }] }])
+    const ext = { ...layer('external', 'runtime', [{ id: 'webserver', config: { value: -1 } }]), packageName: 'ext' }
+    await expect(profileBoot([builtin, ext])).rejects.toThrow('negative value')
+  })
+  it('rejects a builtin consumer left pending by an optional provider failure', async () => {
+    await expect(profileBoot([
+      layer('builtin', 'boot', [{ insert: [{ id: 'consumer', name: 'cordis:waiting' }] }]),
+      { ...layer('external', 'runtime', [{ insert: [{ id: 'provider', name: 'cordis:bad' }] }]), packageName: 'ext' },
+    ])).rejects.toThrow('consumer')
+  })
+  it('preserves anonymous provenance and nested Include provenance without matching unrelated root ids', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-nested-entry-'))
+    roots.push(dir)
+    const path = join(dir, 'nested.yml')
+    writeFileSync(path, '- id: shared\n  name: cordis:bad\n')
+    const ctx = await profileBoot([
+      layer('builtin', 'boot', [{ insert: [{ id: 'shared', name: 'cordis:good' }] }]),
+      { ...layer('external', 'runtime', [{ insert: [
+        { name: 'cordis:good' } as EntryOptions,
+        { id: 'nested', name: 'cordis:include', config: { path } },
+      ] }]), packageName: 'ext' },
+    ])
+    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:shared'))?.trust).toBe('builtin')
+    expect(ctx.profileRuntime.originOfEntry(ctx.loader.resolve('include:nested:shared'))?.trust).toBe('external')
+    const anonymous = [...ctx.loader.entries()].find(e => e.options.id.startsWith('anonymous/'))!
+    expect(ctx.profileRuntime.originOfEntry(anonymous)?.packageName).toBe('ext')
+  })
+})
+describe('entry diagnostics', () => {
+  it('disables every part of a bundle layer and restores overrides while preserving user row choices', async () => {
+    const base = layer('builtin', 'boot', [{ insert: [
+      { id: 'webserver', name: 'cordis:configured', config: { value: 1 } },
+      { id: 'tools', name: 'cordis:group', group: true, config: [] },
+      { id: 'ui', name: 'cordis:group', group: true, config: [] },
+    ] }])
+    const ext = { ...layer('external', 'runtime', [
+      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'cordis:good' }] }] },
+      { id: 'tools', insert: [{ id: 'tool', name: 'cordis:good' }] },
+      { id: 'ui', insert: [{ id: 'panel', name: 'cordis:good' }] },
+      { id: 'webserver', config: { value: 2 } },
+    ]), packageName: 'ext' }
+    const layers = [base, ext]
+    const ctx = await profileBoot(layers, [{ id: 'tool', disabled: true }])
+    expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 2 })
+    expect(ctx.loader.resolve('include:tool').disabled).toBe(true)
+    layers.pop()
+    expect(await ctx.profileRuntime.recompose({ reloadBundles: true })).toEqual([])
+    const ids = [...ctx.loader.entries()].map(entry => entry.options.id)
+    for (const id of ['own', 'child', 'tool', 'panel']) expect(ids).not.toContain(id)
+    expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 1 })
+    layers.push(ext)
+    expect(await ctx.profileRuntime.recompose({ reloadBundles: true })).toEqual([])
+    expect(ctx.loader.resolve('include:panel').fiber?.state).toBe(2)
+    expect(ctx.loader.resolve('include:child').fiber?.state).toBe(2)
+    expect(ctx.loader.resolve('include:tool').disabled).toBe(true)
+    expect(ctx.loader.resolve('include:webserver').fiber?.config).toEqual({ value: 2 })
+  })
+
+  it('retains an active old config on invalid update, reports the failure, and clears it on a valid change', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.configured = configured
+    const id = await ctx.loader.create({ name: 'cordis:configured', config: { value: 1 } })
+    const entry = ctx.loader.resolve(id)
+    await expect(entry.update({ config: { value: -1 } })).rejects.toThrow('negative value')
+    expect(entry.options.config).toEqual({ value: -1 })
+    expect(entry.fiber?.config).toEqual({ value: 1 })
+    expect(entry.fiber?.state).toBe(2)
+    expect((await entryIssue(entry))?.stage).toBe('update')
+    expect((await entryIssue(entry))?.message).toContain('negative value')
+    await entry.update({ config: { value: 2 } })
+    await ctx.loader.await()
+    expect(entry.fiber?.config).toEqual({ value: 2 })
+    expect(await entryIssue(entry)).toBeUndefined()
+  })
+  it('clears import and pending issues after recovery and ignores disabled failures', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.waiting = waiting
+    const broken = await ctx.loader.create({ name: './dsh-missing-fixture.mjs' })
+    const pending = await ctx.loader.create({ name: 'cordis:waiting' })
+    expect((await inspectEntryIssues(ctx)).map(issue => issue.stage).sort()).toEqual(['import', 'inject-pending'])
+    ctx.loader.builtins.later = good
+    await ctx.loader.resolve(broken).update({ disabled: true })
+    expect(await entryIssue(ctx.loader.resolve(broken))).toBeUndefined()
+    await ctx.loader.resolve(broken).update({ name: 'cordis:later', disabled: false })
+    ctx.provide('testService', {})
+    await ctx.loader.await()
+    expect(await entryIssue(ctx.loader.resolve(pending))).toBeUndefined()
+    expect(await inspectEntryIssues(ctx)).toEqual([])
+  })
+  it('reports partial recompose, preserves successful siblings, and waits for removed fibers', async () => {
+    const patches: PatchOptions[] = [{ insert: [
+      { id: 'config', name: 'cordis:configured', config: { value: 1 } },
+      { id: 'ok', name: 'cordis:good' },
+    ] }]
+    const ctx = await profileBoot([layer('external', 'runtime', patches)])
+    let release!: () => void
+    let started!: () => void
+    const removing = new Promise<void>((resolve) => { started = resolve })
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    ctx.loader.builtins.slow = (ctx: Context) => { ctx.effect(() => async () => { started(); await gate }) }
+    patches[0]!.insert!.push({ id: 'slow', name: 'cordis:slow' })
+    await ctx.profileRuntime.recompose({ reloadBundles: true })
+    patches[0]!.insert![0]!.config = { value: -1 }
+    patches[0]!.insert!.pop()
+    const done = vi.fn()
+    const updating = ctx.profileRuntime.recompose({ reloadBundles: true }).then((issues) => { done(); return issues })
+    try {
+      await removing
+      expect(done).not.toHaveBeenCalled()
+    } finally { release() }
+    expect((await updating).map(issue => issue.stage)).toContain('update')
+    expect(ctx.loader.resolve('include:ok').fiber?.state).toBe(2)
+    expect(ctx.loader.resolve('include:config').fiber?.config).toEqual({ value: 1 })
+  })
+})
+
+describe('nested continuation diagnostics', () => {
+  it('reports required nested failures without treating them as entry activation failures', async () => {
+    const ctx = await profileBoot([
+      layer('builtin', 'boot', [{ insert: [{ id: 'required', name: 'cordis:nested' }] }]),
+      { ...layer('external', 'runtime', [{ insert: [
+        { id: 'optional', name: 'cordis:nested' }, { id: 'failed-entry', name: 'cordis:bad' },
+      ] }]), packageName: 'ext' },
+    ])
+    await expect(ctx.plugin(() => { throw new Error('unowned failure') })).rejects.toThrow('unowned failure')
+    await Promise.allSettled([...ctx.registry.values()].flatMap(runtime => [...runtime.fibers].map(fiber => fiber.await())))
+    const lines: string[] = []
+    expect(warnNestedFiberFailures(ctx, 'test', (line) => { lines.push(line) })).toBe(1)
+    expect(lines[0]).toContain('cordis:nested')
+    expect(ctx.loader.resolve('include:required').fiber?.state).toBe(2)
+    expect(warnNestedFiberFailures(ctx, 'test')).toBe(1)
+  })
+})

+ 43 - 223
packages/boot/app-boot/tests/external-bundles.spec.ts

@@ -1,231 +1,51 @@
-/**
- * External bundle composition (one contained group per bundle, ids as
- * declared) and the manifest operations behind installing, enabling, and
- * disabling.
- */
-
-import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
+/** Bundle ownership analysis and installed/enabled manifest lists. */
+import { mkdirSync, mkdtempSync, rmSync, 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 { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { bundleGroupId, disableBundle, enableBundle, reconcileInstalledBundles, readProfileManifest, type ProfileLayer } from '../src/index.ts'
-import { composeExternalLayer, CONTAINED_GROUP_MODULE, exportsBundlePatch, isContainedLayer } from '../src/external-bundles.ts'
-
+import { afterEach, describe, expect, it } from 'vitest'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { disableBundle, enableBundle, reconcileInstalledBundles, readProfileManifest, type ProfileLayer } from '../src/index.ts'
+import { analyzeBundleLayer, exportsBundlePatch, isOptionalRuntimeLayer } from '../src/external-bundles.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,
-  }
+const roots: string[] = []
+const tmp = (): string => {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-external-bundles-'))
+  roots.push(root)
+  return root
 }
-
-describe('composeExternalLayer', () => {
-  it('inserts an empty contained group first and re-targets root inserts into it, ids as declared', () => {
-    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: 'bundle/pkg-a', insert: [{ id: 'tool', name: 'pkg-a' }, { name: 'pkg-a/anonymous' }] },
-      { id: 'bundle/pkg-a', insert: [{ id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] }] },
-    ])
-    expect(applyEntryPatches([], composed.patches, () => {})).toEqual([{
-      id: 'bundle/pkg-a',
-      name: CONTAINED_GROUP_MODULE,
-      group: true,
-      config: [
-        { id: 'tool', name: 'pkg-a' },
-        { name: 'pkg-a/anonymous' },
-        { id: 'grp', name: 'cordis:group', group: true, config: [{ id: 'inner', name: 'pkg-a/inner' }] },
-      ],
-    }])
-    expect([...composed.rows]).toEqual([
-      ['tool', 'pkg-a'], ['grp', 'cordis:group'], ['inner', 'pkg-a/inner'], ['bundle/pkg-a', CONTAINED_GROUP_MODULE],
-    ])
-    expect(composed.duplicates).toEqual([])
-    expect(composed.overrides).toEqual([])
-  })
-
-  it('keeps the written order of inserts and id-targeted patches, reporting patches on other rows as overrides', () => {
-    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' }] },
-      { id: 'own', disabled: true },
-      { disabled: true },
-    ]))
-    // A patch with no config, or no id at all, passes through as written; the include reports the latter.
-    expect(composed.patches.slice(1)).toEqual([
-      { id: 'bundle/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' }] },
-      { id: 'own', disabled: true },
-      { disabled: true },
-    ])
-    expect(composed.overrides).toEqual(['settings'])
-  })
-
-  it('mounts a group config replaced and then appended to the same way a built-in layer would', () => {
-    const written = (): PatchOptions[] => [
-      { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'x', name: 'pkg-o/x' }] }] },
-      { id: 'g', config: [{ id: 'a', name: 'pkg-o/a' }] },
-      { id: 'g', insert: [{ id: 'b', name: 'pkg-o/b' }] },
+afterEach(() => { roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) }) })
+function layer(patches: PatchOptions[]): ProfileLayer {
+  return { packageName: 'ext', version: undefined, packageDir: '/nowhere', patchPath: '/nowhere/patch.yml', trust: 'external', stage: 'runtime', patches }
+}
+describe('analyzeBundleLayer', () => {
+  it('preserves parents, explicit ids and overrides across several groups', () => {
+    const patches: PatchOptions[] = [
+      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'ext/child' }] }] },
+      { id: 'tools', insert: [{ id: 'tool', name: 'ext/tool' }] },
+      { id: 'ui', insert: [{ id: 'panel', name: 'ext/panel' }] },
+      { id: 'webserver', config: { port: 0 } },
+      { id: 'child', disabled: true },
     ]
-    const asBuiltin = applyEntryPatches([], written(), () => {})
-    const contained = applyEntryPatches([], composeExternalLayer(layer('pkg-o', written())).patches, () => {})
-    const childrenOf = (rows: EntryOptions[]): string[] => (rows.find(row => row.id === 'g')?.config as EntryOptions[]).map(row => row.id)
-    expect(childrenOf(asBuiltin)).toEqual(['a', 'b'])
-    expect(childrenOf(contained[0]?.config as EntryOptions[])).toEqual(['a', 'b'])
-  })
-
-  it('nests rows inserted into a built-in group inside one contained group per target', () => {
-    const composed = composeExternalLayer(layer('pkg-c', [
-      { id: 'persistent-shell', insert: [{ id: 'extra', name: 'pkg-c/extra' }] },
-      { id: 'tools', insert: [{ id: 'tool-a', name: 'pkg-c/a' }] },
-      { id: 'persistent-shell', insert: [{ id: 'more', name: 'pkg-c/more' }] },
-    ]))
-    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: 'extra', name: 'pkg-c/extra' }] }],
-      },
-      { id: 'tools', insert: [{ id: 'bundle/pkg-c/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'tool-a', name: 'pkg-c/a' }] }] },
-      { id: 'bundle/pkg-c/in/persistent-shell', insert: [{ id: 'more', name: 'pkg-c/more' }] },
-    ])
-    expect([...composed.rows.keys()].filter(id => id.includes('/in/'))).toEqual(['bundle/pkg-c/in/persistent-shell', 'bundle/pkg-c/in/tools'])
-    const tree = applyEntryPatches([
-      { id: 'persistent-shell', name: 'cordis:group', group: true, config: [] },
-      { id: 'tools', name: 'cordis:group', group: true, config: [] },
-    ], composed.patches, () => {})
-    const shell = tree.find(row => row.id === 'persistent-shell')?.config as EntryOptions[]
-    expect(shell.map(row => row.id)).toEqual(['bundle/pkg-c/in/persistent-shell'])
-    expect((shell[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['extra', 'more'])
-  })
-
-  it('counts the rows a config override sets as the bundle\'s own, without calling a restated row a repeat', () => {
-    const composed = composeExternalLayer(layer('pkg-v', [
-      { insert: [{ id: 'own', name: 'cordis:group', group: true, config: [{ id: 'kept', name: 'pkg-v/kept' }] }] },
-      { id: 'own', config: [{ id: 'kept', name: 'pkg-v/kept' }, { id: 'core', name: 'pkg-v/impostor' }, 'not a row' as never] },
-      { id: 'own', insert: [{ id: 'child', name: 'pkg-v/child' }] },
-    ]))
-    expect([...composed.rows.keys()]).toEqual(['own', 'kept', 'core', 'child', 'bundle/pkg-v'])
-    expect(composed.duplicates).toEqual([])
-    expect(composed.overrides).toEqual([])
-  })
-
-  it('creates a new wrapper after a patch replaced the target group\'s config', () => {
-    const composed = composeExternalLayer(layer('pkg-w', [
-      { id: 'tools', insert: [{ id: 'first', name: 'pkg-w/a' }] },
-      { id: 'tools', config: [] },
-      { id: 'tools', insert: [{ id: 'last', name: 'pkg-w/b' }] },
-    ]))
-    expect(composed.patches.slice(1)).toEqual([
-      { id: 'tools', insert: [{ id: 'bundle/pkg-w/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'first', name: 'pkg-w/a' }] }] },
-      { id: 'tools', config: [] },
-      { id: 'tools', insert: [{ id: 'bundle/pkg-w/in/tools', name: CONTAINED_GROUP_MODULE, group: true, config: [{ id: 'last', name: 'pkg-w/b' }] }] },
-    ])
-    expect(composed.duplicates).toEqual([])
-    const tree = applyEntryPatches([{ id: 'tools', name: 'cordis:group', group: true, config: [] }], composed.patches, () => {})
-    const tools = tree.find(row => row.id === 'tools')?.config as EntryOptions[]
-    expect(tools.map(row => row.id)).toEqual(['bundle/pkg-w/in/tools'])
-    expect((tools[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['last'])
-  })
-
-  it('reports a declared row that spells a wrapper\'s id as a repeat', () => {
-    const composed = composeExternalLayer(layer('pkg-i', [
-      { insert: [{ id: 'bundle/pkg-i/in/tools', name: 'pkg-i/impostor' }] },
-      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-i/tool' }] },
-    ]))
-    expect(composed.duplicates).toEqual([{ rowId: 'bundle/pkg-i/in/tools', moduleName: CONTAINED_GROUP_MODULE }])
-  })
-
-  it('reports an id the bundle inserts twice and keeps the first module for it', () => {
-    const composed = composeExternalLayer(layer('pkg-r', [
-      { insert: [{ id: 'dup', name: 'pkg-r/one' }] },
-      { insert: [{ id: 'g', name: 'cordis:group', group: true, config: [{ id: 'dup', name: 'pkg-r/two' }] }] },
-      { insert: [{ id: 'bundle/pkg-r', name: 'pkg-r/steals-the-group-id' }] },
-    ]))
-    expect(composed.duplicates).toEqual([
-      { rowId: 'dup', moduleName: 'pkg-r/two' },
-      { rowId: 'bundle/pkg-r', moduleName: CONTAINED_GROUP_MODULE },
-    ])
-    expect(composed.rows.get('dup')).toBe('pkg-r/one')
-  })
-
-  it('reports a config override that lists one id twice or sets a row under a group other than the one holding it', () => {
-    const composed = composeExternalLayer(layer('pkg-o', [
-      { insert: [
-        { id: 'a', name: 'cordis:group', group: true, config: [{ id: 'shared', name: 'pkg-o/x' }] },
-        { id: 'b', name: 'cordis:group', group: true, config: [] },
-      ] },
-      { id: 'b', config: [{ id: 'shared', name: 'pkg-o/moved' }] },
-      { id: 'a', config: [{ id: 'twice', name: 'pkg-o/one' }, { id: 'twice', name: 'pkg-o/two' }] },
-      // The bundle's own group is a predictable target: a duplicate under it
-      // would fail the group's update before any row is contained.
-      { id: 'bundle/pkg-o', config: [{ id: 'own', name: 'pkg-o/own' }, { id: 'own', name: 'pkg-o/own-again' }] },
-    ]))
-    expect(composed.duplicates).toEqual([
-      { rowId: 'shared', moduleName: 'pkg-o/moved' },
-      { rowId: 'twice', moduleName: 'pkg-o/two' },
-      { rowId: 'own', moduleName: 'pkg-o/own-again' },
-    ])
-    expect(composed.overrides).toEqual([])
-  })
-
-  it('lets a config override restate a row under the built-in group it was inserted into', () => {
-    const composed = composeExternalLayer(layer('pkg-s', [
-      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-s/tool' }] },
-      { id: 'tools', config: [{ id: 'tool', name: 'pkg-s/tool' }] },
-    ]))
-    expect(composed.duplicates).toEqual([])
-    expect(composed.overrides).toEqual(['tools'])
-  })
-
-  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('mounts the same tree when its patches are applied twice, as a rolled-back update re-applies them', () => {
-    const composed = composeExternalLayer(layer('pkg-t', [
-      { insert: [{ id: 'row', name: 'pkg-t' }] },
-      { id: 'tools', insert: [{ id: 'tool', name: 'pkg-t/tool' }] },
-      { id: 'tools', config: [] },
-      { id: 'tools', insert: [{ id: 'later', name: 'pkg-t/later' }] },
-    ]))
-    const snapshot = structuredClone(composed.patches)
-    const base = (): EntryOptions[] => [{ id: 'tools', name: 'cordis:group', group: true, config: [] }]
-    const first = applyEntryPatches(base(), composed.patches, () => {})
-    const second = applyEntryPatches(base(), composed.patches, () => {})
-    expect(second).toEqual(first)
-    // Neither the inserted rows nor the override values were mutated by either application.
-    expect(composed.patches).toEqual(snapshot)
-    expect((first[1]?.config as EntryOptions[]).map(row => row.id)).toEqual(['row'])
-    const tools = first[0]?.config as EntryOptions[]
-    expect((tools[0]?.config as EntryOptions[]).map(row => row.id)).toEqual(['later'])
-  })
-
-  it('spells the group id without the Loader\'s nested-id separator', () => {
-    expect(bundleGroupId('@scope/pkg')).toBe('bundle/@scope/pkg')
-    expect(bundleGroupId('x')).not.toContain(':')
-  })
-
-  it('contains only runtime-staged external layers', () => {
-    const contained = layer('pkg-e', [{ insert: [{ id: 'row', name: 'pkg-e' }] }])
-    expect(isContainedLayer(contained)).toBe(true)
-    expect(isContainedLayer({ ...contained, stage: 'boot' })).toBe(false)
-    expect(isContainedLayer({ ...contained, trust: 'builtin' })).toBe(false)
+    const result = analyzeBundleLayer(layer(patches))
+    expect(result.patches).toBe(patches)
+    expect([...result.rows.keys()]).toEqual(['own', 'child', 'tool', 'panel'])
+    expect(result.overrides).toEqual(['webserver'])
+    expect(result.duplicates).toEqual([])
+  })
+  it('rejects repeated insert ids but permits restating children in the same group', () => {
+    const patches: PatchOptions[] = [
+      { insert: [{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: 'ext/child' }] }] },
+      { id: 'group', config: [{ id: 'child', name: 'ext/child' }] },
+      { insert: [{ id: 'child', name: 'ext/duplicate' }] },
+    ]
+    expect(analyzeBundleLayer(layer(patches)).duplicates).toEqual([{ rowId: 'child', moduleName: 'ext/duplicate' }])
+  })
+  it('makes only external runtime layers optional at startup', () => {
+    const ext = layer([])
+    expect(isOptionalRuntimeLayer(ext)).toBe(true)
+    expect(isOptionalRuntimeLayer({ ...ext, stage: 'boot' })).toBe(false)
+    expect(isOptionalRuntimeLayer({ ...ext, trust: 'builtin' })).toBe(false)
+    expect(isOptionalRuntimeLayer({ ...ext, trust: 'builtin', stage: 'boot' })).toBe(false)
   })
 })
 

+ 116 - 0
packages/boot/app-boot/tests/package-metadata.spec.ts

@@ -0,0 +1,116 @@
+/** Static package declarations never execute installed code. */
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { readPackageMetadata } from '../src/index.ts'
+const NAME = 'dsh-test-bin'
+const roots: string[] = []
+afterEach(() => { roots.splice(0).forEach((root) => { rmSync(root, { recursive: true, force: true }) }) })
+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-metadata-'))
+  roots.push(root)
+  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') }
+}
+
+
+describe('readPackageMetadata', () => {
+  it('preserves anonymous rows and absent package versions as static declarations', () => {
+    const staged = stage({ pkg: {
+      manifest: { version: undefined, dsh: { bundle: { patch: './patch.yml' } } },
+      files: { 'patch.yml': '- insert:\n    - name: pkg/tool\n      disabled: false\n' },
+    } })
+    const metadata = readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })
+    expect(metadata.version).toBeUndefined()
+    expect(metadata.rows).toEqual([{ name: 'pkg/tool', gated: false }])
+  })
+  it.each(['foreign', 'proxy', 'unidentified', 'broken'] as const)('reports physical Cordis resolution for a %s peer without executing it', (kind) => {
+    const peerManifest = {
+      name: kind === 'unidentified' ? 'wrong-package' : '@deepseek-ai/cordis',
+      exports: kind === 'broken' ? './absent.js' : './lib/index.js',
+      ...kind === 'proxy' ? { dsh: { moduleFallback: { targets: { '.': import.meta.resolve('@deepseek-ai/cordis') } } } } : {},
+    }
+    const staged = stage({ pkg: { files: {
+      'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify(peerManifest),
+      'node_modules/@deepseek-ai/cordis/lib/index.js': 'throw new Error("must not execute")',
+    } } })
+    const metadata = readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })
+    expect(metadata.cordisSameCopy).toBe(kind === 'foreign' ? false : kind === 'proxy' ? true : null)
+  })
+
+  it('reads explicit main and subpath entries without importing them or reading a probe cache', () => {
+    const staged = stage({ pkg: {
+      main: "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'bad'); throw new Error('must never execute')",
+      manifest: { dsh: { title: 'Package', plugins: [{ name: '.', title: 'Main', config: { a: 1 } }, { name: './tools', title: 'Tools' }] } },
+    } })
+    const options = { ...staged, binName: NAME, packageName: 'pkg' }
+    const result = readPackageMetadata(options)
+    expect(result).toMatchObject({ kind: 'plugin', title: 'Package', version: '1.0.0',
+      addable: [{ name: '.', title: 'Main', config: { a: 1 } }, { name: './tools', title: 'Tools' }],
+    })
+    const dir = join(staged.profileDir, 'node_modules/pkg')
+    expect(existsSync(join(dir, 'executed'))).toBe(false)
+    writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'pkg', version: '2.0.0' }))
+    expect(readPackageMetadata(options)).toMatchObject({ kind: 'unknown', version: '2.0.0', addable: [] })
+  })
+  it('lists bundle rows, nested groups, unevaluated gates and external overrides', () => {
+    const staged = stage({ pkg: {
+      manifest: { description: 'Bundle', engines: { dsh: '>=0.1.0' }, dsh: { bundle: { patch: './patch.yml' } } },
+      files: { 'patch.yml': `- insert:
+    - id: group
+      name: cordis:group
+      group: true
+      config:
+        - id: tool
+          name: pkg/tool
+          disabled: !!js 'missingService.disabled'
+- id: tool
+  config: { a: 1 }
+- id: webserver
+  config: { port: 0 }
+` },
+    } })
+    expect(readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })).toMatchObject({
+      kind: 'bundle', description: 'Bundle', enginesDsh: '>=0.1.0',
+      rows: [{ id: 'group', name: 'cordis:group', gated: false }, { id: 'tool', name: 'pkg/tool', gated: true }],
+      overrides: ['webserver'], addable: [],
+    })
+  })
+  it.each(['export default () => {}', 'export function apply() {}', 'throw new Error("broken")'])('keeps an undeclared package unknown: %s', (main) => {
+    const staged = stage({ pkg: { main } })
+    expect(readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' }).kind).toBe('unknown')
+    expect(existsSync(join(staged.profileDir, 'node_modules/pkg/package.json'))).toBe(true)
+  })
+  it.each([
+    'invalid', [{}], [{ name: '' }], [{ name: '../outside' }], [{ name: '/absolute' }],
+    [{ name: './tools' }, { name: 'tools' }], [{ name: '.', title: 1 }],
+  ])('rejects malformed or ambiguous plugin declarations: %j', (plugins) => {
+    const staged = stage({ pkg: { manifest: { dsh: { plugins } } } })
+    expect(() => readPackageMetadata({ ...staged, binName: NAME, packageName: 'pkg' })).toThrow()
+  })
+})

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

@@ -1,361 +0,0 @@
-/**
- * The install-time probe: manifest facts read in-process, imports checked in
- * a child process, and the per-profile cache.
- */
-
-import { mkdirSync, mkdtempSync, symlinkSync, 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'
-import { PLUGIN_PROBE_FORMAT } from '../src/probe.ts'
-import { cordisPackageDir, parseProbeRecord, StreamTail } from '../src/probe.ts'
-import { parseChildReport, type ChildReport } from '../src/probe-report.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 plugin by its declaration to dsh, and a library by the lack of one', async () => {
-    const { profileDir, installAnchor } = stage({
-      'peer-plugin': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } },
-      'titled-plugin': { main: 'export default { apply() {} }\n', manifest: { dsh: { title: 'Titled' } } },
-      'plain-lib': { main: 'export const helper = 1\n' },
-      // A function export alone is not a plugin: lodash exports one too.
-      'function-lib': { main: 'export default function _() {}\n' },
-    })
-    const kindOf = async (packageName: string): Promise<string> =>
-      (await probePackage({ binName: NAME, profileDir, installAnchor, packageName })).kind
-    expect(await kindOf('peer-plugin')).toBe('plugin')
-    expect(await kindOf('titled-plugin')).toBe('plugin')
-    expect(await kindOf('function-lib')).toBe('library')
-    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('recognizes the harness\'s cordis through a link and through a packaged executable\'s proxy', async () => {
-    const harnessCordis = cordisPackageDir(import.meta.resolve('@deepseek-ai/cordis')) as string
-    const harnessEntry = import.meta.resolve('@deepseek-ai/cordis')
-    const { profileDir, installAnchor } = stage({
-      'linked': { main: 'export function apply() {}\n', manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } } },
-      // A cordis directory whose manifest names no package: an unknown copy, not a second one.
-      'unnamed': {
-        main: 'export function apply() {}\n',
-        manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } },
-        files: {
-          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({ version: '0.0.0', type: 'module', main: './index.js' }),
-          'node_modules/@deepseek-ai/cordis/index.js': 'export const Context = class {}\n',
-        },
-      },
-      'proxied': {
-        main: 'export function apply() {}\n',
-        manifest: { peerDependencies: { '@deepseek-ai/cordis': '*' } },
-        files: {
-          // The proxy a packaged executable writes: a manifest naming its targets, and an entry that re-exports one.
-          'node_modules/@deepseek-ai/cordis/package.json': JSON.stringify({
-            name: '@deepseek-ai/cordis', version: '0.0.0', type: 'module', exports: { '.': './entry-0.js' },
-            dsh: { moduleFallback: { targets: { '.': harnessEntry } } },
-          }),
-          'node_modules/@deepseek-ai/cordis/entry-0.js': `export * from ${JSON.stringify(harnessEntry)}\n`,
-        },
-      },
-    })
-    // The profile links the harness's own cordis, as plain Node installs do.
-    mkdirSync(join(profileDir, 'node_modules', '@deepseek-ai'), { recursive: true })
-    symlinkSync(harnessCordis, join(profileDir, 'node_modules', '@deepseek-ai', 'cordis'), 'dir')
-    const linked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'linked' })
-    expect(linked).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true })
-    const proxied = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'proxied' })
-    expect(proxied).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: true })
-    const unnamed = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'unnamed' })
-    expect(unnamed).toMatchObject({ kind: 'plugin', ok: true, cordisSameCopy: null })
-    // A URL no cordis manifest encloses is an unknown copy, not a different one.
-    expect(cordisPackageDir(`file://${tmpdir()}/nowhere/index.js`)).toBeUndefined()
-  })
-
-  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('keeps probing a package that prints at import, and kills one that lingers once it reported', async () => {
-    const { profileDir, installAnchor } = stage({
-      'chatty': { main: 'console.log("initializing logging-plugin")\nexport function apply() {}\n', manifest: { dsh: { title: 'Chatty' } } },
-      'lingers': { main: 'setInterval(() => {}, 1000)\nexport function apply() {}\n', manifest: { dsh: { title: 'Lingers' } } },
-    })
-    const chatty = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'chatty' })
-    expect(chatty).toMatchObject({ kind: 'plugin', ok: true })
-    const lingers = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'lingers', timeoutMs: 5_000 })
-    expect(lingers).toMatchObject({ kind: 'plugin', ok: true })
-    // The probe settled on the child's close: nothing of the killed child is still open.
-    expect(process.getActiveResourcesInfo()).not.toContain('ChildProcess')
-  })
-
-  it('takes only the message that echoes its token, hides credentials from the child, and keeps a stderr tail', async () => {
-    const { profileDir, installAnchor } = stage({
-      // A report forged at import, complete with the token's name: the token is
-      // gone from the environment and `process.send` from `process` by then.
-      'forges': {
-        main: 'process.send?.({ token: process.env.DSH_PROBE_REPORT ?? "", cordis: null, main: { ok: true, isPlugin: true, configSchema: null }, addable: {} })\n'
-          + 'export const notAPlugin = 1\n',
-        manifest: { dsh: { title: 'Forges' } },
-      },
-      'peeks': {
-        main: 'throw new Error("env=" + Object.keys(process.env).filter(k => k.startsWith("PROBE_TEST") || k === "DSH_PROBE_REPORT").sort().join(","))\n',
-      },
-      // The child's stderr is a pipe Node put in non-blocking mode (tsx touches
-      // process.stderr at start), so one raw write returns short or throws EAGAIN
-      // once the pipe is full: the flood retries until the parent has drained it.
-      'floods': {
-        main: 'import { writeSync } from "node:fs"\n'
-          + 'const put = (text) => { let rest = Buffer.from(text); while (rest.length > 0) { try { rest = rest.subarray(writeSync(2, rest)) } catch (error) { if (error.code !== "EAGAIN") throw error } } }\n'
-          + 'put("x".repeat(200_000))\nput("tail-marker")\nprocess.exit(3)\n',
-      },
-    })
-    const forged = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'forges' })
-    expect(forged).toMatchObject({ kind: 'library', ok: true })
-    process.env.PROBE_TEST_SECRET = 'hidden'
-    process.env.PROBE_TEST_PLAIN = 'visible'
-    try {
-      const peeked = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'peeks' })
-      expect(peeked.reason).toContain('env=PROBE_TEST_PLAIN')
-    } finally {
-      delete process.env.PROBE_TEST_SECRET
-      delete process.env.PROBE_TEST_PLAIN
-    }
-    const flood = await probePackage({ binName: NAME, profileDir, installAnchor, packageName: 'floods' }).then(() => undefined, (error: unknown) => error as Error)
-    expect(flood?.message).toMatch(/exited with 3 without a report: x+tail-marker$/)
-    expect(flood?.message.length).toBeLessThan(17_000)
-  })
-
-  it('kills a child that never reports, rejects an unrecognized report, and refuses an unresolvable package', async () => {
-    const { profileDir, installAnchor } = stage({
-      // Blocks the child's thread inside the import: an unsettled top-level await would make Node exit instead.
-      'hangs': { main: 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0)\nexport function apply() {}\n' },
-      'exits': { main: 'process.stderr.write("refusing to report"); process.exit(3)\n' },
-      // `process.send` is gone by the time the package runs; a message it could send would not carry the token anyway.
-      'spoofs': { main: 'process.send?.({ nope: true }); process.exit(0)\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: 'spoofs' }))
-      .rejects.toThrow(/exited with 0 without a 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()
-    // A record an older probe wrote — no format, or another one — is probed again, not trusted.
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify(record))
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT - 1, ...record }))
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    // A file with the right format but not the fields of a record is probed again, not returned as one.
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT }))
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify({ format: PLUGIN_PROBE_FORMAT, ...record, kind: 'weird' }))
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-    writeFileSync(join(profileDir, PLUGIN_PROBE_DIR, '@scope__pkg.json'), JSON.stringify([record]))
-    expect(readProbeCache(profileDir, '@scope/pkg')).toBeUndefined()
-  })
-
-  it('accepts only a complete record', () => {
-    const record: PluginProbe = {
-      packageName: 'pkg', version: '1.0.0', description: 'd', title: 't', kind: 'bundle', ok: false, reason: 'r',
-      cordisSameCopy: false, enginesDsh: '>=1', rows: [{ id: 'a', name: 'm', gated: false }, { name: 'n', gated: true }],
-      overrides: ['x'], addable: [{ name: 'p', title: 'u', ok: false, error: 'e' }, { name: 'q', ok: true }],
-      configSchema: { type: 'object' }, checkedAt: '2026-09-05T00:00:00.000Z',
-    }
-    expect(parseProbeRecord(record)).toBe(record)
-    const broken: Record<string, unknown>[] = [
-      { packageName: 1 }, { checkedAt: 1 }, { version: 1 }, { kind: 1 }, { kind: 'weird' }, { ok: 'yes' },
-      { cordisSameCopy: 'no' }, { rows: {} }, { rows: [1] }, { rows: [{ id: 1, name: 'm', gated: false }] },
-      { rows: [{ name: 1, gated: false }] }, { rows: [{ name: 'm', gated: 'no' }] }, { overrides: 'x' }, { overrides: [1] },
-      { addable: {} }, { addable: [1] }, { addable: [{ name: 1, ok: true }] }, { addable: [{ name: 'p', title: 1, ok: true }] },
-      { addable: [{ name: 'p', ok: 'yes' }] }, { addable: [{ name: 'p', ok: true, error: 1 }] },
-    ]
-    for (const fields of broken) expect(parseProbeRecord({ ...record, ...fields }), JSON.stringify(fields)).toBeUndefined()
-    expect(parseProbeRecord('record')).toBeUndefined()
-  })
-})
-
-describe('parseChildReport', () => {
-  it('accepts the child\'s message only with every field in place', () => {
-    const inspection = { ok: true, isPlugin: true, configSchema: null }
-    const report: ChildReport = { token: 't', cordis: null, main: inspection, addable: { 'pkg/x': { ...inspection, ok: false, error: 'boom' } } }
-    expect(parseChildReport(report)).toBe(report)
-    expect(parseChildReport({ ...report, cordis: 'file:///cordis/index.js' })).toBeDefined()
-    const broken: Record<string, unknown>[] = [
-      { token: undefined }, { token: 1 }, { cordis: 1 }, { main: undefined }, { main: { ...inspection, ok: 'yes' } }, { main: { ...inspection, isPlugin: 'no' } },
-      { main: { ok: true, isPlugin: true } }, { main: { ...inspection, error: 1 } }, { addable: [] }, { addable: { 'pkg/x': 1 } },
-    ]
-    for (const fields of broken) expect(parseChildReport({ ...report, ...fields }), JSON.stringify(fields)).toBeUndefined()
-    expect(parseChildReport('report')).toBeUndefined()
-  })
-})
-
-describe('StreamTail', () => {
-  it('keeps the end of one oversized chunk and what a later small chunk pushes out of it', () => {
-    // Linux hands the parent a flood as one read; the marker written after it arrives alone.
-    const tail = new StreamTail(16)
-    tail.push(Buffer.from('x'.repeat(200)))
-    tail.push(Buffer.from('tail-marker'))
-    expect(tail.text()).toBe('xxxxxtail-marker')
-  })
-
-  it('drops whole leading chunks before trimming the oldest kept one', () => {
-    // macOS drains a flood in small reads: the kept chunks are the last few.
-    const tail = new StreamTail(10)
-    for (const piece of ['abcd', 'efgh', 'ijkl', 'mn']) tail.push(Buffer.from(piece))
-    expect(tail.text()).toBe('efghijklmn')
-    tail.push(Buffer.from('opq'))
-    expect(tail.text()).toBe('hijklmnopq')
-  })
-})

+ 16 - 10
packages/boot/app-boot/tests/profile-runtime.spec.ts

@@ -5,7 +5,7 @@
 
 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 Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { claimLayerIds, ProfileRuntime, type ComposedStack, type Profile, type ProfileLayer } from '../src/index.ts'
 
@@ -28,6 +28,7 @@ async function harness(
 ): Promise<{ ctx: Context; runtime: ProfileRuntime; compose: ReturnType<typeof vi.fn> }> {
   const ctx = new Context()
   contexts.push(ctx)
+  await ctx.plugin(Loader)
   // The conflicts, when given, belong to the reloaded profile only.
   const compose = vi.fn((current: Profile): ComposedStack => {
     const patches = [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[]
@@ -72,11 +73,11 @@ describe('ProfileRuntime', () => {
       // Declares an id the first external layer owns: left out whole, so none of its rows has an origin.
       layer('late', 'external', [{ insert: [{ id: 'tool', name: 'late' }, { id: 'late-only', name: 'late/x' }] }]),
     ])
-    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('tool')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0' })
-    expect(runtime.originOf('bundle/ext')).toEqual({ trust: 'external', packageName: 'ext', version: '2.0.0' })
-    expect(runtime.originOf('svc')).toEqual({ trust: 'external', packageName: 'boot-ext', version: '2.0.0' })
+    expect(runtime.originOf('settings')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('child')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: '@deepseek-ai/dsh-base', version: '2.0.0' })
+    expect(runtime.originOf('tool')).toEqual({ trust: 'external', stage: 'runtime', packageName: 'ext', version: '2.0.0' })
+    expect(runtime.originOf('bundle/ext')).toBeUndefined()
+    expect(runtime.originOf('svc')).toEqual({ trust: 'external', stage: 'boot', packageName: 'boot-ext', version: '2.0.0' })
     expect(runtime.originOf('late-only')).toBeUndefined()
     expect(runtime.originOf('user-row')).toBeUndefined()
   })
@@ -90,7 +91,7 @@ describe('ProfileRuntime', () => {
 
   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' })
+    expect(runtime.originOf('r')).toEqual({ trust: 'builtin', stage: 'runtime', packageName: 'local' })
   })
 
   it('reports the user-disabled rows of the committed composition and keeps them when the include rejects an update', async () => {
@@ -129,7 +130,7 @@ describe('ProfileRuntime', () => {
     expect(runtime.layers).toHaveLength(2)
     expect(update).toHaveBeenLastCalledWith({ config: { path: 'file:///root/cordis.yml', patches: [{ id: 'composed-for-2' }] } })
     // Provenance, conflicts, and the user-disabled rows follow the reloaded profile once the update holds.
-    expect(runtime.originOf('bundle/b')).toEqual({ trust: 'external', packageName: 'b', version: '2.0.0' })
+    expect(runtime.originOf('bundle/b')).toBeUndefined()
     expect(runtime.conflicts).toEqual(conflicts)
     expect([...runtime.userDisabledRowIds()]).toEqual(['reloaded-off'])
   })
@@ -148,10 +149,14 @@ describe('ProfileRuntime', () => {
     // Enabling a bundle re-reads the profile and waits on the tree; a watcher fires meanwhile.
     const enabling = runtime.recompose({ reloadBundles: true })
     const watching = runtime.recompose()
+    const observed = vi.fn()
+    const idle = runtime.whenIdle().then(observed)
     await Promise.resolve()
     expect(applied).toEqual(['composed-for-2'])
+    expect(observed).not.toHaveBeenCalled()
     release()
-    await Promise.all([enabling, watching])
+    await Promise.all([enabling, watching, idle])
+    expect(observed).toHaveBeenCalledOnce()
     // The watcher composed from the profile the enable committed, not the one before it.
     expect(applied).toEqual(['composed-for-2', 'composed-for-2'])
     expect(compose.mock.calls.at(-1)?.[0]).toBe(reloaded)
@@ -169,7 +174,8 @@ describe('ProfileRuntime', () => {
     const first = runtime.recompose()
     const second = runtime.recompose()
     await expect(first).rejects.toThrow('rejected')
-    await expect(second).resolves.toBeUndefined()
+    await expect(second).resolves.toEqual([])
+    await expect(runtime.whenIdle()).resolves.toBeUndefined()
   })
 
   it('keeps the committed profile, provenance, and conflicts when the root include rejects the update', async () => {

+ 0 - 12
packages/boot/app-boot/tsdown.config.ts

@@ -18,18 +18,6 @@ export default defineConfig([
       alwaysBundle: ['@deepseek-ai/cordis-plugin-include'],
     },
   },
-  {
-    // The package probe's child entry ships beside the lib as its own
-    // bundle; the probe path-loads it relative to its own module.
-    entry: { 'probe-child': 'lib/types/probe-child.js' },
-    outDir: 'lib',
-    format: ['esm'],
-    platform: 'node',
-    target: 'es2024',
-    fixedExtension: false,
-    dts: false,
-    clean: false,
-  },
   {
     // The patch-file parser and writer ship as the `./patch-file` export,
     // so the agent-preset roster and the plugin manager load them without

+ 2 - 2
packages/boot/plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
-README.md: 2353390c0b45983e50542fdd15affbe9bd2294fa
-README.zh.md: c6b04f480b301a1840832fa05b4905e5ca00edea
+README.md: 490c0522eaf27136a5650cfdbe4fe672820948da
+README.zh.md: b31f461c8cdd83428f4e1107ad636d3658cee4d2

+ 12 - 12
packages/boot/plugin-manager/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-plugin-manager` changes a profile's plugins. `PluginInstaller` needs the profile on disk: it runs pnpm there, probes every package a run added in a child process, and removes what has no place in a profile — `dsh plugin add` uses it before any plugin starts. `PluginManager` needs the booted tree: it enables and disables bundles through `profileRuntime`, retries failed ones, adds and removes rows in the global or a preset's user layer, reports dependents, and folds manifest, probe record, and live tree into one view per package. Failures carry `plugins/*` codes; [`dsh-host-plugin-manager`](../../host/plugin-manager/README.md) exposes the manager as the `plugins` Remote.
+`dsh-plugin-manager` installs packages and manages their declared plugin rows through the CLI and Web host. Installation reads metadata without executing modules. The manager enables whole bundle layers, edits global or preset user patches, retries failures, and reports current runtime issues. Failures carry `plugins/*` codes; the [Host adapter](../../host/plugin-manager/README.md) exposes the operations through Remote.
 
 ## Table of Contents
 
@@ -39,7 +39,7 @@ declare const installAnchor: string
 const installer = new PluginInstaller({
   profileDir, profileName: 'web', installAnchor,
   loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
-  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 },
+  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installLogTailBytes: 16_384 },
   installLog: (chunk) => process.stdout.write(chunk.text),
   color: process.stdout.isTTY,
 })
@@ -47,7 +47,7 @@ const outcome = await installer.add('@acme/dsh-sql-tool')
 console.log(outcome.installed, outcome.removed)
 ```
 
-`add` takes a pnpm spec — a registry name, a `github:` or git URL, a tarball, an absolute path — runs `pnpm add`, records what pnpm wrote to `dependencies`, and probes every new package. A successful `pnpm add` is not yet an installed plugin: a package that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns, is removed again with `pnpm remove` and listed under `removed` with the reason; a package the probe refused stays installed for a view to explain. New bundles are left disabled and listed under `installedOnly`, so the caller decides whether to enable them — the CLI always does, the Web host only when asked. A non-zero exit, a spawn failure, or the timeout fails the call with `plugins/install-failed` and the tail of the log, and the profile manifest is restored to what it was before the run.
+`add` runs pnpm in the profile, reconciles `dependencies`, and statically checks new bundle declarations against current row ownership. Conflicting bundles are removed with a reason; undeclared and unreadable packages remain installed. New bundles stay disabled unless the caller enables them. A failed pnpm run restores the pre-run manifest and reports `plugins/install-failed` with the log tail.
 
 ### Managing the booted profile
 
@@ -72,11 +72,11 @@ const manager = new PluginManager(ctx, {
 console.log(await manager.list())
 ```
 
-`list` returns one view per package the profile knows: its template and installed bundles and every other installed dependency. A view carries the manifest facts (name, version, title, description, `engines.dsh`), what the package is (`bundle`, `plugin`, or `library`), who supplied it (`builtin` or `external`), when its rows mount (`boot` or `runtime`), whether it is installed and enabled, and a folded `status`: `running`, `partial`, or `failed` for an enabled bundle by how many of its rows are active; `disabled` for an installed bundle outside the layer list; `not-enableable` when the probe refused it, with the reason; `restart-required` when the manifest and the live tree disagree on a profile that applies changes at its next start; `plain` for a library or plugin module, which is added to a composition rather than enabled. Rows come from the live tree while the bundle is composed — phase, disabled-by, and the recorded failure of an isolated row — and from the probe record otherwise, under the ids their patches declare. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict, and for a plugin module its main export as `.` — the entry `addRow` accepts without a declaration.
+`list` reports package identity, `bundle` / `plugin` / `unknown` classification, trust, stage, installation and enablement. Runtime rows carry actual phases and failures; disabled bundles show static patch declarations. `addable` comes only from `dsh.plugins`, including `.` for the main export and declared defaults. An active row can retain its previous config after an update fails. Package `issues` also names failed rows its patch overrides, without transferring their ownership.
 
-`add` is the installer's `add` guarded by the running-agent count, with `enable` putting every newly installed bundle into the layer list at once. `enable` puts an installed bundle into the layer list and, on a live profile, recomposes the tree with it through the profile runtime. The recomposition is the Loader's own transaction: a bundle the tree rejects — a `boot`-stage bundle whose row throws — rolls back, the layer list is restored, and the call fails with `plugins/enable-failed` naming the reason, while the tree that was running keeps running. A `runtime`-stage bundle whose row fails is isolated instead: the call succeeds, the view reports the row's failure, and `retry` composes the bundle again from scratch. `disable` is the reverse; a template bundle, which is not a dependency, cannot be disabled. On a profile whose `patchReload` is `startup`, both write the manifest and report `effect: 'restart'`. `uninstall` disables the bundle when enabled, drops every user-layer row that names one of the package's modules, runs `pnpm remove`, and forgets the probe record.
+`enable` selects the whole bundle layer and recomposes live profiles. Per-row failures retain the enabled choice and successful siblings; results report `issues`, and the list can show `partial` or `failed`. Preparation failures revert the enable selection and raise `plugins/enable-failed`. `disable` removes the whole layer, including overrides. `retry` disables, awaits cleanup, and enables again. Startup-only profiles report `effect: restart`. `uninstall` disables the bundle, removes user-inserted references, runs pnpm remove, and cleans obsolete discovery records.
 
-`addRow` inserts a row naming one of the package's modules — its main export for a `plugin` package, or a `dsh.plugins` entry — into the profile's global `cordis.patch.yml` (`target: { kind: 'global' }`) or an agent preset's user layer (`{ kind: 'preset', preset }`, through the roster's `overlayPathFor`). The row id derives from the package name and subpath unless given; a taken id fails with `plugins/row-conflict`. `removeRow` removes an inserted row and `setRowDisabled` writes or removes a `disabled: true` for any row — deny-only, so a bundle's own `!!js` gate is restored rather than overridden. The global layer is recomposed live on the spot; a preset's layer reaches its next standing generation. `dependents` says what disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.
+`addRow` writes an explicitly declared `dsh.plugins` module to the profile’s global `cordis.patch.yml` or a preset user layer. It preserves declared defaults, checks the target row id, and performs no pre-mount import. `removeRow` removes a user insert. `setRowDisabled` writes or removes `disabled: true`, preserving the bundle’s own condition. Global edits recompose immediately on live profiles; preset edits apply to subsequent generations. `dependents` reports injection dependents and user-layer module references.
 
 The manager runs one mutation at a time — a second call while one runs fails with `plugins/busy` naming the operation in flight — and `add` and `uninstall` refuse to change `node_modules` while a session is running, with `plugins/agents-running`. Every change is followed by a `plugins/changed` event on the context, and an install run emits pnpm's output as `plugins/install-log` chunks, each naming the command line it ran and the profile directory it ran in, and, with colour on, carrying pnpm's SGR escapes.
 
@@ -102,25 +102,25 @@ The subprocess seam scrubs secret-shaped variables and has no shell mode, and pn
 
 ### Retry is disable then enable
 
-The Loader's transactional update leaves an unchanged row alone, so a failed isolated row would not restart on a plain recomposition. Retry takes the bundle out of the layer list and puts it back: two recompositions, and the manifest ends as it began.
+Retry removes the entire layer and awaits removed-fiber cleanup before adding it again. It does not rely on changing a synthetic group or silently compensating for failed plugin effects.
 
 ### What the manager reads and what it is handed
 
-The manager reads the Loader tree, the reflect store, and the `pluginFailures` registry through the context it is built over. Everything that belongs to another package — the profile runtime, the preset roster, the agent registry — arrives through `PluginManagerOptions` as a reader called per call, and the roster only as `PresetLayers`: the layer path, the preset list, and the composition rows the row operations need. The package therefore depends on app-boot alone, whose `./patch-file` export reads and writes the layers, and on nothing that composes presets or agents.
+The manager reads Loader entries, the reflect store and entry-owned diagnostics. Profile, preset and agent facts arrive through per-call readers. The [Host adapter](../../host/plugin-manager/README.md) converts Loader lifecycle changes into `plugins/changed` notifications after settlement, including a pending row whose provider becomes available.
 
 ### Source map
 
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | The package API: re-exports of the classes, options, types, and failure codes |
-| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`: pnpm runs with streamed output, the probe record cache, and the post-install checks |
+| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`: pnpm runs with streamed output, static declarations, and the post-install checks |
 | [`src/manager.ts`](src/manager.ts) | `PluginManager`: every operation over the booted profile, the one-at-a-time mutex, user-layer edits, and dependents |
-| [`src/view.ts`](src/view.ts) | The view fold: manifest, probe, and live-tree rows into one `PluginPackageView`, and the row-ownership walk |
+| [`src/view.ts`](src/view.ts) | The view fold: manifest, declarations, and live-tree rows into one `PluginPackageView`, and the row-ownership walk |
 | [`src/modules.ts`](src/modules.ts) | Declared `dsh.plugins` modules: row naming, derived row ids, and their wire view |
 | [`src/helpers.ts`](src/helpers.ts) | Shared vocabulary: the diagnostic prefix, tooling bounds, the spawn seam, and the manifest readers |
 | [`src/types.ts`](src/types.ts) | Payloads, the `plugins/changed` and `plugins/install-log` events, and the `plugins/*` failure codes with their details |
 | [`src/errors.ts`](src/errors.ts) | `PluginOperationError` and the code-discriminated failure union |
-| — | No runtime invariant companion is published; every view is folded from the manifest, the probe cache, and Loader-owned state on each call. |
+| — | No runtime invariant companion is published; every view is folded from the manifest, static declarations, and Loader-owned state on each call. |
 
 </details>
 
@@ -131,7 +131,7 @@ The manager reads the Loader tree, the reflect store, and the `pluginFailures` r
 
 Read these when the manager's contract is not enough: the runtime it drives, the files it edits, and the surfaces that call it.
 
-- [App boot](../app-boot/README.md) — the profile runtime, external bundle isolation, and the package probe.
+- [App boot](../app-boot/README.md) — the profile runtime, external bundle isolation, and static package declarations.
 - [Patch files](../app-boot/README.md#patch-files) — how user-layer rows are read and written.
 - [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer a preset target writes.
 - [Host plugin manager](../../host/plugin-manager/README.md) — the `plugins` Remote over this manager.

+ 12 - 12
packages/boot/plugin-manager/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-plugin-manager` 负责改动 profile 的插件。`PluginInstaller` 只需要磁盘上的 profile:在那里运行 pnpm,在子进程里探测本次新增的每个包,并把在 profile 里没有位置的包再移除——`dsh plugin add` 在任何插件启动之前就用它。`PluginManager` 需要启动好的树:经 `profileRuntime` 启用与停用组合包、重试失败的组合包、在全局或某个预设的用户层里增删行、报告依赖方,并把 manifest、探针记录与实时树折叠成每个包的一份视图。失败带 `plugins/*` 代码;[`dsh-host-plugin-manager`](../../host/plugin-manager/README.zh.md) 把管理器暴露为 `plugins` Remote。
+`dsh-plugin-manager` 通过 CLI 与 Web 宿主安装包并管理它们声明的插件行。安装读取元信息,不执行模块。管理器启停整份组合包层,编辑全局或预设用户 patch,重试失败并报告当前运行问题。失败携带 `plugins/*` 错误码;[Host 适配器](../../host/plugin-manager/README.zh.md) 通过 Remote 暴露这些操作。
 
 ## 目录
 
@@ -39,7 +39,7 @@ declare const installAnchor: string
 const installer = new PluginInstaller({
   profileDir, profileName: 'web', installAnchor,
   loadProfile: () => loadProfile('dsh', 'web', installAnchor, undefined, { userLayer: false }),
-  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 },
+  config: { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, installLogTailBytes: 16_384 },
   installLog: (chunk) => process.stdout.write(chunk.text),
   color: process.stdout.isTTY,
 })
@@ -47,7 +47,7 @@ const outcome = await installer.add('@acme/dsh-sql-tool')
 console.log(outcome.installed, outcome.removed)
 ```
 
-`add` 接受一个 pnpm spec——registry 名字、`github:` 或 git URL、tarball、绝对路径——运行 `pnpm add`,记录 pnpm 写进 `dependencies` 的内容,并探测每个新包。`pnpm add` 成功还不等于装好了插件:既不声明组合包也不声明插件模块的包,或者某个行 id 已被已组合层占有的组合包,会再以 `pnpm remove` 移除并连同原因列在 `removed` 里;探针拒绝的包保留在原处,留给视图说明。新组合包保持停用并列在 `installedOnly` 里,由调用方决定是否启用——CLI 一律启用,Web 宿主只在被要求时启用。非零退出、spawn 失败或超时都以 `plugins/install-failed` 与日志尾部让调用失败,并把 profile manifest 恢复到运行前的样子。
+`add` 在 profile 中执行 pnpm,核对 `dependencies`,并按当前行归属静态检查新组合包声明。冲突组合包会被移除并给出原因;未声明或声明不可读的包保持已安装。新组合包保持禁用,除非调用方启用。pnpm 执行失败会恢复运行前的 manifest,并通过 `plugins/install-failed` 报告日志尾部。
 
 ### 管理已启动的 profile
 
@@ -72,11 +72,11 @@ const manager = new PluginManager(ctx, {
 console.log(await manager.list())
 ```
 
-`list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,id 保持各自 patch 声明的样子。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定;插件模块还以 `.` 列出它的主导出——`addRow` 无需声明即接受的那一项。
+`list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
 
-`add` 就是安装器的 `add` 加上运行中 agent 数的守卫,`enable` 让每个新装的组合包一并进入层列表。`enable` 把已安装的组合包放进层列表,并在 live profile 上经 profile runtime 带着它重新组合树。这次重新组合就是 Loader 自己的事务:树拒绝的组合包——`boot` 阶段而行抛错的组合包——回滚,层列表恢复,调用以点名原因的 `plugins/enable-failed` 失败,而原本运行的树继续运行。`runtime` 阶段而行失败的组合包则被隔离:调用成功,视图报告该行的失败,`retry` 从头重新组合它。`disable` 是反向操作;模板组合包不是依赖,无法停用。在 `patchReload` 为 `startup` 的 profile 上,两者只写 manifest 并报告 `effect: 'restart'`。`uninstall` 在组合包已启用时先停用它,删除每一条点名该包模块的用户层行,运行 `pnpm remove`,并忘掉探针记录。
+`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove 并清理过期发现记录。
 
-`addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`removeRow` 移除一条插入的行,`setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。`dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
+`addRow` 将显式声明的 `dsh.plugins` 模块写入 profile 的全局 `cordis.patch.yml` 或预设用户层。它保留声明的默认配置,检查目标行 id,不在挂载前 import。`removeRow` 删除用户插入。`setRowDisabled` 写入或删除 `disabled: true`,保留组合包自己的条件。全局编辑在实时 profile 中立即重组;预设编辑应用于后续代际。`dependents` 报告注入依赖方与用户层模块引用。
 
 管理器一次只跑一个变更——上一个还在跑时再调用会以 `plugins/busy` 失败并点名正在进行的操作——`add` 与 `uninstall` 在有会话运行时拒绝改动 `node_modules`,报 `plugins/agents-running`。每次变更之后在上下文上发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 分块发出,每块都写明所跑的命令行与所在的 profile 目录,开了颜色时还带着 pnpm 的 SGR 转义。
 
@@ -102,25 +102,25 @@ subprocess seam 会清洗形似密钥的变量且没有 shell 模式,而 pnpm
 
 ### 重试即先停用再启用
 
-Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离行在普通的重新组合中不会重新启动。重试把组合包移出层列表再放回去:两次重新组合,manifest 首尾如一。
+重试移除整层,并等待已移除 fiber 清理完成后再添加。它不依赖合成 group,也不会静默补偿失败插件的副作用。
 
 ### 管理器自己读什么,别人交给它什么
 
-管理器经它所在的上下文读取 Loader 树、reflect store 与 `pluginFailures` 注册表。属于别的包的东西——profile runtime、preset roster、agent 注册表——都经 `PluginManagerOptions` 以按调用读取的读取器交进来,roster 只以 `PresetLayers` 的形态交进来:层文件路径、preset 列表,以及行操作需要的组合行。因此本包只依赖 app-boot(其 `./patch-file` 导出负责读写各层),不依赖任何组合 preset 或 agent 的包。
+管理器读取 Loader 条目、reflect 存储与条目自身诊断。profile、预设和 agent 信息通过逐次调用的读取器提供。[Host 适配器](../../host/plugin-manager/README.zh.md) 在状态稳定后将 Loader 生命周期变化转换为 `plugins/changed` 通知,包括等待中的行在提供方出现后恢复运行。
 
 ### 源码地图
 
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 包 API:类、选项、类型与失败码的 re-export |
-| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`:流式输出的 pnpm 运行、探针记录缓存与装后检查 |
+| [`src/installer.ts`](src/installer.ts) | `PluginInstaller`:pnpm 流式输出、静态声明与装后检查 |
 | [`src/manager.ts`](src/manager.ts) | `PluginManager`:已启动 profile 上的每项操作、一次一个的互斥、用户层编辑与依赖查询 |
-| [`src/view.ts`](src/view.ts) | 视图折叠:把 manifest、探针与在线树的行折成一份 `PluginPackageView`,以及行归属遍历 |
+| [`src/view.ts`](src/view.ts) | 将 manifest、声明与实际行状态组合为 `PluginPackageView`,并查询行归属 |
 | [`src/modules.ts`](src/modules.ts) | 声明的 `dsh.plugins` 模块:行命名、派生行 id 及其 wire 视图 |
 | [`src/helpers.ts`](src/helpers.ts) | 共享词汇:诊断前缀、工具边界、spawn 测试缝与 manifest 读取器 |
 | [`src/types.ts`](src/types.ts) | 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码及其 details |
 | [`src/errors.ts`](src/errors.ts) | `PluginOperationError` 与按码区分的失败联合 |
-| — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 |
+| — | 不发布独立运行不变量模块;每次调用直接从 manifest、静态声明与 Loader 状态生成视图。 |
 
 </details>
 
@@ -131,7 +131,7 @@ Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离
 
 当管理器的契约还不够时读这些:它驱动的运行时、它编辑的文件,以及调用它的表面。
 
-- [App boot](../app-boot/README.zh.md)——profile runtime、外部组合包隔离与包探针。
+- [App boot](../app-boot/README.zh.md)——profile runtime、外部组合包隔离与静态包声明。
 - [补丁文件](../app-boot/README.zh.md#patch-files)——用户层的行如何读写。
 - [Agent presets](../../preset/agent-presets/README.zh.md)——preset 目标所写的每预设用户层。
 - [宿主插件管理器](../../host/plugin-manager/README.zh.md)——本管理器之上的 `plugins` Remote。

+ 1 - 1
packages/boot/plugin-manager/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-plugin-manager",
   "description": "Plugin management over a dsh profile: install, enable, disable, retry, user-layer rows, dependents, and one view per package, shared by the dsh plugin command and the Web host",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 0 - 2
packages/boot/plugin-manager/src/helpers.ts

@@ -47,8 +47,6 @@ export interface PluginToolingConfig {
   readonly pnpmCommand: string
   /** Bound on one install or remove run, in milliseconds. */
   readonly installTimeoutMs: number
-  /** Bound on one package probe, in milliseconds. */
-  readonly probeTimeoutMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   readonly installLogTailBytes: number
 }

+ 2 - 5
packages/boot/plugin-manager/src/index.ts

@@ -3,8 +3,8 @@
  * and the Web host's `plugins` Remote.
  *
  * {@link PluginInstaller} changes what a profile has installed: it runs pnpm
- * in the profile directory, probes every new package in a child process, and
- * removes what has no place in a profile. It needs the profile on disk, not
+ * in the profile directory, reads static package declarations, and rejects
+ * conflicting bundle layers while retaining undeclared packages. It needs the profile on disk, not
  * a booted tree, so the CLI uses it before any plugin starts.
  * {@link PluginManager} adds the operations that need the booted tree —
  * enabling, disabling, and retrying bundles through the profile runtime,
@@ -21,6 +21,3 @@ export type * from './types.ts'
 export type { PluginToolingConfig, SpawnLike } from './helpers.ts'
 export { PluginInstaller, type PluginInstallerOptions, type PluginInstallOutcome } from './installer.ts'
 export { PluginManager, type PluginManagerOptions, type PresetLayers } from './manager.ts'
-
-/** Group id of an isolated bundle, for callers that address its rows in the tree. */
-export { bundleGroupId } from '@deepseek-ai/dsh-app-boot'

+ 27 - 46
packages/boot/plugin-manager/src/installer.ts

@@ -1,6 +1,6 @@
 /**
- * Installing and removing packages in one profile with pnpm, and probing
- * them, over the profile on disk: nothing here touches a running tree, which
+ * Installing and removing packages with pnpm and reading static declarations
+ * from the profile on disk: nothing here touches a running tree, which
  * is what lets the `dsh plugin` command install before any plugin starts.
  * @module @deepseek-ai/dsh-plugin-manager/installer
  */
@@ -13,15 +13,12 @@ import {
   awaitChildClose,
   claimLayerIds,
   healProfilesModuleFallback,
-  PLUGIN_PROBE_DIR,
-  probePackage,
-  readProbeCache,
+  readPackageMetadata,
   readProfileManifest,
   reconcileInstalledBundles,
   resolveBundleDir,
   resolveProfileLayer,
-  writeProbeCache,
-  type PluginProbe,
+  type PackageMetadata,
   type Profile,
   type ProfileManifest,
 } from '@deepseek-ai/dsh-app-boot'
@@ -57,15 +54,15 @@ export interface PluginInstallerOptions {
   readonly color: boolean
   /** Test seam: the child spawner; defaults to `node:child_process`. */
   readonly spawn?: SpawnLike
-  /** Test seam: the package probe; defaults to app-boot's. */
-  readonly probe?: typeof probePackage
+  /** Test seam: the static metadata reader; defaults to app-boot’s. */
+  readonly metadata?: typeof readPackageMetadata
 }
 
 /** The installed package's manifest slice the view reads. */
 export type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
 
 /**
- * Installs and removes packages in one profile with pnpm, and probes them.
+ * Installs and removes packages in one profile with pnpm, and reads their declarations.
  *
  * Every run reads the profile manifest afresh and writes it through the
  * app-boot helpers the `dsh plugin` command uses, so the CLI and the Web
@@ -76,14 +73,14 @@ export type InstalledManifest = ProfileManifest & { description?: string; dsh?:
  */
 export class PluginInstaller {
   private readonly spawn: SpawnLike
-  private readonly probeRunner: typeof probePackage
+  private readonly metadataReader: typeof readPackageMetadata
 
   /**
    * @param options - the profile, the tooling bounds, and the output sink.
    */
   constructor(private readonly options: PluginInstallerOptions) {
     this.spawn = options.spawn ?? spawnChild
-    this.probeRunner = options.probe ?? probePackage
+    this.metadataReader = options.metadata ?? readPackageMetadata
   }
 
   /**
@@ -128,29 +125,20 @@ export class PluginInstaller {
   }
 
   /**
-   * The package's probe record, from the profile cache or a fresh probe
-   * written to it. A record for another installed version is not reused.
+   * Read the installed manifest and patch without executing modules or caching runtime verdicts.
    * @param packageName - the installed package.
-   * @returns the record.
+   * @returns its current static declarations.
    */
-  async probe(packageName: string): Promise<PluginProbe> {
-    const version = this.readInstalledManifest(packageName)?.version
-    const cached = readProbeCache(this.options.profileDir, packageName, version)
-    if (cached !== undefined) return cached
-    const probe = await this.probeRunner({
-      binName: NAME,
-      profileDir: this.options.profileDir,
-      installAnchor: this.options.installAnchor,
-      packageName,
-      timeoutMs: this.options.config.probeTimeoutMs,
+  metadata(packageName: string): PackageMetadata {
+    return this.metadataReader({
+      binName: NAME, profileDir: this.options.profileDir,
+      installAnchor: this.options.installAnchor, packageName,
     })
-    writeProbeCache(this.options.profileDir, probe)
-    return probe
   }
 
   /**
-   * Install a package with pnpm, probe every package the run added, and
-   * remove what has no place in a profile. New bundles are left disabled.
+   * Install with pnpm and reject bundles whose declarations conflict with the profile.
+   * Undeclared packages remain installed; new bundles are left disabled.
    * @param spec - what to install, in pnpm's own vocabulary: a registry
    * name, a `github:` or git URL, a tarball, or an absolute path.
    * @returns what the run installed and what it removed again.
@@ -182,7 +170,7 @@ export class PluginInstaller {
     const installed: string[] = []
     const removed: PluginInstallRejection[] = []
     for (const name of added) {
-      const reason = await this.rejection(name)
+      const reason = this.rejection(name)
       if (reason === undefined) {
         installed.push(name)
         continue
@@ -201,7 +189,7 @@ export class PluginInstaller {
   }
 
   /**
-   * Run `pnpm remove`, reconcile the layer list, and forget the probe record.
+   * Run `pnpm remove`, reconcile the layer list, and remove any obsolete discovery record.
    * @param packageName - the dependency to remove.
    * @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
    */
@@ -210,29 +198,22 @@ export class PluginInstaller {
     const before = readProfileManifest(NAME, profileDir)
     await this.runPnpm(['remove', packageName], packageName)
     reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
-    rmSync(join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true })
+    rmSync(join(profileDir, '.dsh-plugins', `${packageName.replaceAll('/', '__')}.json`), { force: true })
   }
 
   /**
-   * The post-install check of one package pnpm added: a package that is
-   * neither a bundle nor a plugin module has no place in a profile, and a
-   * bundle whose row id another layer already owns could only mount as a
-   * conflict record. A package the probe cannot run stays installed: the
-   * view reports it as not enableable with the probe's own reason.
+   * Check a new bundle against the profile’s row ownership and stage rules.
+   * Undeclared or unreadable packages remain installed for repair or removal.
    * @returns why the package is removed again, or undefined to keep it.
    */
-  private async rejection(packageName: string): Promise<string | undefined> {
-    let probe: PluginProbe
+  private rejection(packageName: string): string | undefined {
+    let metadata: PackageMetadata
     try {
-      probe = await this.probe(packageName)
+      metadata = this.metadata(packageName)
     } catch {
-      return undefined // the failure is re-derived on every list and shown there
+      return undefined // Keep unreadable declarations installed so the user can repair or remove the package.
     }
-    // A refused probe (an import that throws, another cordis copy) keeps the
-    // package: the view shows it as not enableable with the probe's reason.
-    if (!probe.ok) return undefined
-    if (probe.kind === 'library') return 'declares neither a dsh bundle nor a plugin module'
-    if (probe.kind !== 'bundle') return undefined
+    if (metadata.kind !== 'bundle') return undefined
     const { profileDir, installAnchor } = this.options
     try {
       const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, profileDir), packageName, installAnchor, profileDir)

+ 43 - 40
packages/boot/plugin-manager/src/manager.ts

@@ -13,8 +13,10 @@ import {
   healProfilesModuleFallback,
   loadProfile,
   readProfileManifest,
-  recordContainedStates,
-  type probePackage,
+  rootIncludeEntry,
+  inspectEntryIssues,
+  type EntryIssue,
+  type readPackageMetadata,
   type ProfileRuntime,
 } from '@deepseek-ai/dsh-app-boot'
 import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-app-boot/patch-file'
@@ -30,6 +32,7 @@ import type {
   PluginInstallResult,
   PluginPackageView,
   PluginRowAddition,
+  PluginRowIssue,
   PluginRowReference,
   PluginRowTarget,
   PluginServiceDependent,
@@ -72,8 +75,8 @@ export interface PluginManagerOptions {
   readonly runningAgents: () => number
   /** Test seam: the child spawner; defaults to `node:child_process`. */
   readonly spawn?: SpawnLike
-  /** Test seam: the package probe; defaults to app-boot's. */
-  readonly probe?: typeof probePackage
+  /** Test seam: the static metadata reader; defaults to app-boot’s. */
+  readonly metadata?: typeof readPackageMetadata
 }
 
 /**
@@ -157,7 +160,7 @@ export class PluginManager {
       // The chunks feed the Web install dialog's terminal, which draws SGR colour.
       color: true,
       ...this.options.spawn === undefined ? {} : { spawn: this.options.spawn },
-      ...this.options.probe === undefined ? {} : { probe: this.options.probe },
+      ...this.options.metadata === undefined ? {} : { metadata: this.options.metadata },
     })
   }
 
@@ -172,13 +175,14 @@ export class PluginManager {
     const manifest = readProfileManifest(NAME, runtime.dir)
     const names = [...new Set([...bundlesOf(manifest), ...Object.keys(dependenciesOf(manifest))])]
     const views: PluginPackageView[] = []
-    for (const name of names) views.push(await packageView(this.ctx, runtime, installer, manifest, name))
+    const issues = await inspectEntryIssues(this.ctx)
+    for (const name of names) views.push(packageView(this.ctx, runtime, installer, manifest, name, issues))
     return views
   }
 
 
   /**
-   * Install a package into the profile with pnpm, probe it, and leave it
+   * Install a package into the profile with pnpm, read its declarations, and leave it
    * disabled unless asked otherwise. The run's output is emitted as
    * `plugins/install-log` chunks carrying the returned `jobId`.
    * @param spec - what to install, in pnpm's own vocabulary: a registry
@@ -215,7 +219,7 @@ export class PluginManager {
 
   /**
    * Remove a package from the profile: disable it when enabled, drop every
-   * user-layer row that names it, run `pnpm remove`, and forget its probe.
+   * user-layer row that names it, run `pnpm remove`, and clean obsolete discovery metadata.
    * @param packageName - the installed dependency to remove.
    * @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
    * or `plugins/install-failed` when pnpm exits non-zero.
@@ -245,12 +249,12 @@ export class PluginManager {
 
   /**
    * Put an installed bundle into the layer list and, on a live profile,
-   * recompose the tree with it. A rejected recomposition restores the list
-   * and reports the tree's reason; the tree that was running keeps running.
+   * recompose the tree. Per-row failures retain enabled selection and successful siblings.
+   * Preparation failures revert enable selection and report their reason.
    * @param packageName - the installed bundle.
    * @returns whether the list changed and whether the change is live.
    * @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
-   * for a package that declares no bundle or whose probe refused it, or
+   * for a package without a readable bundle declaration, or
    * `plugins/enable-failed`.
    */
   async enable(packageName: string): Promise<PluginEnableResult> {
@@ -261,15 +265,11 @@ export class PluginManager {
     const runtime = this.runtime()
     const installer = this.installer(runtime)
     installer.assertInstalled(packageName)
-    const probe = await installer.probe(packageName).catch((error: unknown) => {
-      const reason = `cannot be probed: ${messageOf(error)}`
+    try {
+      installer.metadata(packageName)
+    } catch (error) {
+      const reason = `cannot read package declarations: ${messageOf(error)}`
       throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} ${reason}`, { packageName, reason })
-    })
-    if (!probe.ok) {
-      // The probe states a reason with every refusal; the fallback keeps the type total.
-      /* v8 ignore next */
-      const reason = probe.reason ?? 'the probe refused it'
-      throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${packageName} cannot be enabled: ${reason}`, { packageName, reason })
     }
     let changed: boolean
     try {
@@ -291,26 +291,19 @@ export class PluginManager {
           profile: loadProfile(NAME, runtime.profileName, runtime.installAnchor, undefined, { userLayer: false }),
         })
         await runtime.recompose({ reloadBundles: true })
-        // The boot audit does not run again; record what the bundle's rows
-        // came to, so the list shows a waiting or failed row with its reason.
-        await recordContainedStates(this.ctx)
       } catch (error) {
         const reason = messageOf(error)
         disableBundle(NAME, runtime.dir, packageName)
-        // The tree without the bundle is the tree that was running a moment
-        // ago; recomposing back to it is the update that already succeeded.
-        /* v8 ignore next */
-        await runtime.recompose({ reloadBundles: true }).catch(() => undefined)
         throw new PluginOperationError(
           'plugins/enable-failed',
-          `${NAME}: enabling ${packageName} failed and the layer list was restored: ${reason}`,
+          `${NAME}: enabling ${packageName} could not apply the layer; its enable selection was reverted: ${reason}`,
           { packageName, reason },
           { cause: error },
         )
       }
     }
     this.changed('enable', packageName)
-    return { changed, effect: 'live' }
+    return { changed, effect: 'live', ...this.issueResult(await inspectEntryIssues(this.ctx)) }
   }
 
   /**
@@ -338,12 +331,12 @@ export class PluginManager {
     }
     if (changed) await runtime.recompose({ reloadBundles: true })
     this.changed('disable', packageName)
-    return { changed, effect: 'live' }
+    return { changed, effect: 'live', ...this.issueResult(await inspectEntryIssues(this.ctx)) }
   }
 
   /**
-   * Compose an enabled bundle again from scratch: its group leaves the tree
-   * and returns, so rows that failed at boot get another start.
+   * Remove the whole bundle layer, await its cleanup, and enable it again.
+   * Every owned row gets a fresh activation attempt.
    * @param packageName - the enabled bundle.
    * @returns the enable outcome of the second step.
    * @throws {PluginOperationError} `plugins/bad-request` when the bundle is not enabled, or the enable failures.
@@ -372,7 +365,7 @@ export class PluginManager {
    * `id` overrides the derived row id, `config` overrides the declared default.
    * @returns where the row landed.
    * @throws {PluginOperationError} `plugins/not-installed`, `plugins/not-enableable`
-   * when the module is not one the probe found addable, `plugins/row-conflict`,
+   * when the module is absent from `dsh.plugins`, `plugins/row-conflict`,
    * or `plugins/unavailable` for a preset target without a roster.
    */
   async addRow(
@@ -391,18 +384,20 @@ export class PluginManager {
     const runtime = this.runtime()
     const installer = this.installer(runtime)
     installer.assertInstalled(packageName)
-    const probe = await installer.probe(packageName)
+    const metadata = installer.metadata(packageName)
     const declared = options?.module ?? '.'
-    const addable = declared === '.' && probe.kind === 'plugin'
-      ? { ok: true, config: undefined, error: undefined }
-      : probe.addable.find(entry => entry.name === declared)
-    if (addable === undefined || !addable.ok) {
-      const reason = addable?.error ?? `${declared} is not a module ${packageName} declares addable`
+    const addable = metadata.addable.find(entry => entry.name === declared)
+    if (addable === undefined) {
+      const reason = `${declared} is not a module ${packageName} declares addable`
       throw new PluginOperationError('plugins/not-enableable', `${NAME}: ${reason}`, { packageName, reason })
     }
     const moduleName = moduleSpecifier(packageName, declared)
     const rowId = options?.id ?? derivedRowId(packageName, declared)
-    const row: PatchRow = { id: rowId, name: moduleName, config: options?.config ?? addable.config ?? {} }
+    if (rowId.trim() === '' || rowId.includes(':')) {
+      throw new PluginOperationError('plugins/bad-request', `${NAME}: row ids must be nonempty and cannot contain ':'`, {})
+    }
+    const config = options?.config !== undefined ? options.config : addable.config !== undefined ? addable.config : {}
+    const row: PatchRow = { id: rowId, name: moduleName, config }
     if (await this.rowExists(runtime, target, rowId)) {
       throw new PluginOperationError('plugins/row-conflict', `${NAME}: a row ${JSON.stringify(rowId)} already exists`, { rowId, target })
     }
@@ -522,7 +517,8 @@ export class PluginManager {
   /** Whether the target layer's composition already carries a row with `rowId`. */
   private async rowExists(runtime: ProfileRuntime, target: PluginRowTarget, rowId: string): Promise<boolean> {
     if (target.kind === 'global') {
-      return [...this.ctx.loader.entries()].some(entry => entry.options.id === rowId)
+      const tree = rootIncludeEntry(this.ctx)?.subtree
+      return [...this.ctx.loader.entries()].some(entry => entry.parent.tree === tree && entry.options.id === rowId)
         || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
     }
     const composition = (await this.presets().compositionInventory()).find(candidate => candidate.id === target.preset)
@@ -553,6 +549,13 @@ export class PluginManager {
     return file
   }
 
+  /** Project runtime objects into the operation's JSON result. */
+  private issueResult(issues: readonly EntryIssue[]): { issues?: PluginRowIssue[] } {
+    return issues.length === 0 ? {} : { issues: issues.map(issue => ({
+      entryId: issue.entry.id, moduleName: issue.entry.options.name, stage: issue.stage, message: issue.message,
+    })) }
+  }
+
   private changed(reason: PluginChangeReason, packageName?: string): void {
     this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) })
   }

+ 5 - 29
packages/boot/plugin-manager/src/modules.ts

@@ -4,7 +4,7 @@
  * @module @deepseek-ai/dsh-plugin-manager/modules
  */
 
-import type { PluginProbe } from '@deepseek-ai/dsh-app-boot'
+import type { PackageMetadata } from '@deepseek-ai/dsh-app-boot'
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import { optional } from './helpers.ts'
 import type { PluginPackageAddableView } from './types.ts'
@@ -32,41 +32,17 @@ export function derivedRowId(packageName: string, declared: string): string {
 }
 
 /**
- * The modules a package offers a composition: what it declares in
- * `dsh.plugins`, and — for a plugin module — its main export as `.`, the
- * implicit entry `PluginManager.addRow` accepts without a declaration.
+ * The wire view of one declared addable module.
  * @param packageName - the package.
- * @param probe - the package's probe record, when one exists.
- * @returns the declared modules, with `.` first for a plugin module that does not declare it.
- */
-export function addableViews(packageName: string, probe: PluginProbe | undefined): PluginPackageAddableView[] {
-  const declared = (probe?.addable ?? []).map(entry => addableView(packageName, entry))
-  if (probe?.kind !== 'plugin' || declared.some(entry => entry.declaredName === '.')) return declared
-  return [{
-    moduleName: packageName,
-    declaredName: '.',
-    ...optional('title', probe.title),
-    ok: probe.ok,
-    ...optional('error', probe.reason),
-    ...optional('configSchema', probe.configSchema as JsonValue | undefined),
-  }, ...declared]
-}
-
-/**
- * The wire view of one probed addable module.
- * @param packageName - the package.
- * @param entry - the probe's record of the declared module.
+ * @param entry - the installed manifest’s module declaration.
  * @returns the view.
  */
-export function addableView(packageName: string, entry: PluginProbe['addable'][number]): PluginPackageAddableView {
+export function addableView(packageName: string, entry: PackageMetadata['addable'][number]): PluginPackageAddableView {
   return {
     moduleName: moduleSpecifier(packageName, entry.name),
     declaredName: entry.name,
     ...optional('title', entry.title),
-    // The probe read both from JSON: a manifest field and a child's report.
+    // Config comes from the installed JSON manifest.
     ...optional('config', entry.config as JsonValue | undefined),
-    ok: entry.ok,
-    ...optional('error', entry.error),
-    ...optional('configSchema', entry.configSchema as JsonValue | undefined),
   }
 }

+ 29 - 25
packages/boot/plugin-manager/src/types.ts

@@ -7,26 +7,25 @@
 
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 
-/** What an installed package is: a bundle layer, a plugin module, or a plain library. */
-export type PluginPackageKind = 'bundle' | 'plugin' | 'library'
+/** What an installed package is: a bundle layer, declared plugin modules, or an unknown package. */
+export type PluginPackageKind = 'bundle' | 'plugin' | 'unknown'
 
 /** Who supplied a package: the installation's own bundles, or a dependency the user installed. */
 export type PluginPackageTrust = 'builtin' | 'external'
 
-/** When a bundle's rows mount: isolated at `runtime` (the default), or with the built-in rows at `boot`. */
+/** Startup failure policy: external runtime rows are optional; boot rows are required. */
 export type PluginPackageStage = 'boot' | 'runtime'
 
 /**
- * The state one package is in, folded from the profile manifest, the probe
- * record, and the live tree:
+ * The state one package is in, folded from the manifest, declarations, and current entries:
  *
- * - `running`: enabled and every row active;
- * - `partial`: enabled with at least one row failed or waiting;
+ * - `running`: enabled, all enabled rows active, and no current issue;
+ * - `partial`: at least one active row, with a failed attempt or unresolved dependency;
  * - `failed`: enabled and no row active;
  * - `disabled`: installed and not in the layer list;
- * - `not-enableable`: installed but the probe refused it (import failure, foreign cordis copy);
+ * - `not-enableable`: bundle declarations cannot be read;
  * - `restart-required`: its manifest state and the live tree disagree, which a profile without live reload resolves at the next start;
- * - `plain`: a library or plugin module, which is added to a composition rather than enabled.
+ * - `plain`: a bundle-less package; explicitly declared modules can be added to a composition.
  */
 export type PluginPackageStatus =
   | 'running'
@@ -54,7 +53,7 @@ export interface PluginPackageRowView {
   readonly disabledBy?: 'user' | 'composition'
   /** Root-fiber phase, or null when the row has no live fiber. */
   readonly phase: PluginRowPhase
-  /** The recorded startup failure of an isolated row, when one is recorded. */
+  /** The current row issue, which can coexist with an active old fiber after an update fails. */
   readonly failure?: { readonly stage: string; readonly message: string }
 }
 
@@ -68,17 +67,12 @@ export interface PluginPackageAddableView {
   readonly title?: string
   /** Default row config, when declared. */
   readonly config?: JsonValue
-  /** Whether the probe imported the module and found a plugin. */
-  readonly ok: boolean
-  /** The probe's import failure, when `ok` is false. */
-  readonly error?: string
-  /** The module's `Config` schema envelope, when it declares one. */
-  readonly configSchema?: JsonValue
+
 }
 
 /** One package as the manager sees it. */
 export interface PluginPackageView {
-  /** The package name; also the row-id prefix and group id of an isolated bundle. */
+  /** The installed package name. */
   readonly name: string
   /** The installed version, when the manifest declares one. */
   readonly version?: string
@@ -98,16 +92,16 @@ export interface PluginPackageView {
   readonly reason?: string
   /** The harness version range the package declares in `engines.dsh`. */
   readonly enginesDsh?: string
-  /** Whether the package shares the harness's cordis copy; null when the probe could not tell. */
+  /** Whether physical Cordis resolution matches the harness; null when unavailable. */
   readonly cordisSameCopy: boolean | null
-  /** The rows the bundle contributes, from the live tree when enabled, else from the probe. */
+  /** The rows the bundle contributes, from the live tree when enabled, else from its static patch. */
   readonly rows: readonly PluginPackageRowView[]
+  /** Current issues in owned rows or existing rows this bundle overrides; ownership is unchanged. */
+  readonly issues?: readonly PluginRowIssue[]
   /** Ids of built-in rows the bundle's patch overrides. */
   readonly overrides: readonly string[]
   /** Agent-plane modules the package declares addable. */
   readonly addable: readonly PluginPackageAddableView[]
-  /** ISO time of the probe record this view was folded from, when one exists. */
-  readonly probedAt?: string
   /** Whether the profile applies user patch files while running; false means changes wait for a restart. */
   readonly liveReload: boolean
 }
@@ -121,7 +115,7 @@ export type PluginRowTarget =
 export interface PluginInstallRejection {
   /** The package name. */
   readonly name: string
-  /** Why it was removed: not a dsh package, or a row id another layer already owns. */
+  /** Why the bundle was rejected, such as a conflicting row id or invalid stage. */
   readonly reason: string
 }
 
@@ -147,6 +141,16 @@ export interface PluginEnableResult {
   readonly changed: boolean
   /** `live` when the tree was recomposed, `restart` when the profile applies changes at its next start. */
   readonly effect: 'live' | 'restart'
+  /** Current row issues after a live application; absent when no issues were observed or application waits for restart. */
+  readonly issues?: readonly PluginRowIssue[]
+}
+
+/** Serializable outcome for an entry whose requested behavior is not fully active. */
+export interface PluginRowIssue {
+  readonly entryId: string
+  readonly moduleName: string
+  readonly stage: string
+  readonly message: string
 }
 
 /** One row outside a package that injects a service one of its rows provides. */
@@ -182,7 +186,7 @@ export interface PluginRowAddition {
 }
 
 /** Why the manager changed something, for a listener deciding what to refresh. */
-export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row'
+export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row' | 'runtime'
 
 /** One chunk of an install run's output. */
 export interface PluginInstallLogChunk {
@@ -211,9 +215,9 @@ export interface PluginOperationDetailsMap {
   'plugins/unavailable': { readonly reason: string }
   /** The package is not a profile dependency. */
   'plugins/not-installed': { readonly packageName: string }
-  /** The package cannot be enabled or added, with the probe's reason. */
+  /** The package cannot be enabled or added, with the declaration or request error. */
   'plugins/not-enableable': { readonly packageName: string; readonly reason: string }
-  /** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
+  /** Preparation or the root Include rejected enablement; the layer selection was reverted. */
   'plugins/enable-failed': { readonly packageName: string; readonly reason: string }
   /** pnpm exited non-zero, could not be spawned, or timed out. */
   'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string }

+ 56 - 57
packages/boot/plugin-manager/src/view.ts

@@ -1,5 +1,5 @@
 /**
- * The view fold: one package's manifest facts, probe record, and live-tree
+ * The view fold: one package's manifest facts, static declarations, and live-tree
  * rows folded into the `PluginPackageView` the manager lists, plus the row
  * ownership walk the dependents query shares.
  * @module @deepseek-ai/dsh-plugin-manager/view
@@ -7,11 +7,11 @@
 
 import type { Context, FiberState } from '@deepseek-ai/cordis'
 import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
-import { layerTrust, type PluginProbe, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
+import { rootIncludeEntry, layerTrust, type EntryIssue, type PackageMetadata, type ProfileManifest, type ProfileRuntime } from '@deepseek-ai/dsh-app-boot'
 import type { BundleStage } from '@deepseek-ai/dsh-package-manifest'
 import { bundlesOf, dependenciesOf, messageOf, optional } from './helpers.ts'
 import type { PluginInstaller } from './installer.ts'
-import { addableViews } from './modules.ts'
+import { addableView } from './modules.ts'
 import type { PluginPackageRowView, PluginPackageStatus, PluginPackageView, PluginRowPhase } from './types.ts'
 
 /** Runtime mirror: FiberState is a cross-package const enum. */
@@ -41,46 +41,56 @@ export interface RowFacts {
 }
 
 /**
- * Fold one package's manifest, probe, and tree facts into its view.
- * @param ctx - the context whose Loader tree and failure registry the rows come from.
+ * Fold one package's manifest, declarations, and tree facts into its view.
+ * @param ctx - the context whose Loader tree and current entry diagnostics the rows come from.
  * @param runtime - the booted profile.
- * @param installer - the installer over the same profile, for the probe record and the installed manifest.
+ * @param installer - the installer over the same profile, for static declarations and the installed manifest.
  * @param manifest - the profile manifest, read once per list.
  * @param name - the package.
+ * @param allIssues - issues observed once for this list request.
  * @returns the view.
  */
-export async function packageView(
+export function packageView(
   ctx: Context,
   runtime: ProfileRuntime,
   installer: PluginInstaller,
   manifest: ProfileManifest,
   name: string,
-): Promise<PluginPackageView> {
+  allIssues: readonly EntryIssue[],
+): PluginPackageView {
   const installed = name in dependenciesOf(manifest)
   const enabled = bundlesOf(manifest).includes(name)
   const layer = runtime.layers.find(candidate => candidate.packageName === name)
   const trust = layer?.trust ?? layerTrust(manifest, name)
   const liveReload = runtime.patchReload === 'live'
-  let probe: PluginProbe | undefined
-  let probeFailure: string | undefined
+  let metadata: PackageMetadata | undefined
+  let metadataFailure: string | undefined
   if (installed) {
     try {
-      probe = await installer.probe(name)
+      metadata = installer.metadata(name)
     } catch (error) {
-      probeFailure = messageOf(error)
+      metadataFailure = messageOf(error)
     }
   }
-  const packageManifest = installer.readInstalledManifest(name)
+  let packageManifest: ReturnType<PluginInstaller['readInstalledManifest']>
+  try {
+    packageManifest = installer.readInstalledManifest(name)
+  } catch (error) {
+    metadataFailure = messageOf(error)
+  }
   const stage: BundleStage = layer?.stage
     ?? (manifest.dsh?.profile?.stages?.[name] ?? packageManifest?.dsh?.bundle?.stage ?? 'runtime')
-  const kind = probe?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'library')
+  const kind = metadata?.kind ?? (layer !== undefined || packageManifest?.dsh?.bundle !== undefined ? 'bundle' : 'unknown')
   const composed = layer !== undefined
-  const rows = composed ? composedRows(ctx, runtime, name) : probedRows(probe)
-  const status = packageStatus({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
-  const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
+  const rootTree = rootIncludeEntry(ctx)?.subtree
+  const affected = allIssues.filter(issue => runtime.originOfEntry(issue.entry)?.packageName === name
+    || (issue.entry.parent.tree === rootTree && metadata?.overrides.includes(issue.entry.options.id)))
+  const rows = composed ? composedRows(ctx, runtime, name, allIssues) : declaredRows(metadata)
+  const status = packageStatus({ kind, installed, enabled, composed, liveReload, metadataFailure, rows, affected })
+  const reason = metadataFailure ?? (status === 'restart-required'
     ? 'the profile applies layer changes at its next start'
     : status === 'failed' || status === 'partial'
-      ? rows.find(row => row.failure !== undefined)?.failure?.message
+      ? affected[0]?.message ?? rows.find(row => row.failure !== undefined)?.failure?.message
       : undefined)
   return {
     name,
@@ -94,12 +104,14 @@ export async function packageView(
     enabled,
     status,
     ...optional('reason', reason),
-    ...optional('enginesDsh', probe?.enginesDsh),
-    cordisSameCopy: probe?.cordisSameCopy ?? null,
+    ...optional('enginesDsh', metadata?.enginesDsh),
+    cordisSameCopy: metadata?.cordisSameCopy ?? null,
     rows,
-    overrides: probe?.overrides ?? [],
-    addable: addableViews(name, probe),
-    ...optional('probedAt', probe?.checkedAt),
+    overrides: metadata?.overrides ?? [],
+    addable: (metadata?.addable ?? []).map(entry => addableView(name, entry)),
+    ...affected.length === 0 ? {} : { issues: affected.map(issue => ({
+      entryId: issue.entry.id, moduleName: issue.entry.options.name, stage: issue.stage, message: issue.message,
+    })) },
     liveReload,
   }
 }
@@ -111,53 +123,40 @@ function packageStatus(facts: {
   enabled: boolean
   composed: boolean
   liveReload: boolean
-  probe: PluginProbe | undefined
-  probeFailure: string | undefined
+  metadataFailure: string | undefined
+  affected: readonly EntryIssue[]
   rows: readonly PluginPackageRowView[]
 }): PluginPackageStatus {
   if (facts.kind !== 'bundle') return 'plain'
-  if (facts.installed && (facts.probeFailure !== undefined || facts.probe?.ok === false)) return 'not-enableable'
+  if (facts.installed && facts.metadataFailure !== undefined) return 'not-enableable'
   if (facts.enabled !== facts.composed) return 'restart-required'
   if (!facts.enabled) return 'disabled'
   const live = facts.rows.filter(row => row.enabled)
-  if (live.length === 0) return 'running'
+  if (live.length === 0) return facts.affected.length === 0 ? 'running' : 'failed'
   const active = live.filter(row => row.phase === 'active').length
-  if (active === live.length) return 'running'
+  if (active === live.length && facts.affected.length === 0) return 'running'
   return active === 0 ? 'failed' : 'partial'
 }
 
-/** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
-function composedRows(ctx: Context, runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
-  const failures = ctx.get('pluginFailures')
+/** Current native entries and failed groups belonging to the bundle. */
+function composedRows(ctx: Context, runtime: ProfileRuntime, name: string, issues: readonly EntryIssue[]): PluginPackageRowView[] {
+  const failures = new Map(issues.map(issue => [issue.entry, issue]))
   const rows: PluginPackageRowView[] = []
-  const listed = new Set<string>()
-  for (const { entry, rowId } of ownedEntries(ctx, runtime, name)) {
-    listed.add(entry.id)
-    const failure = failures?.get(entry.id)
+  for (const entry of ctx.loader.entries()) {
+    if (runtime.originOfEntry(entry)?.packageName !== name) continue
+    const failure = failures.get(entry)
+    if (entry.options.group && failure === undefined) continue
+    const enabled = failure?.stage === 'disabled-expression' || !entry.disabled
     rows.push({
       entryId: entry.id,
-      rowId,
+      rowId: entry.options.id,
       moduleName: entry.options.name,
-      enabled: !entry.disabled,
-      // The runtime walks the groups holding the entry: a row inside a group the user disabled is the user's doing.
-      ...entry.disabled ? { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const } : {},
-      phase: entry.fiber === undefined ? null : ROW_PHASE[entry.fiber.state],
+      enabled,
+      ...enabled ? {} : { disabledBy: runtime.userDisables(entry) ? 'user' as const : 'composition' as const },
+      phase: entry.fiber === undefined ? (failure === undefined ? null : 'failed') : ROW_PHASE[entry.fiber.state],
       ...failure === undefined ? {} : { failure: { stage: failure.stage, message: failure.message } },
     })
   }
-  /* v8 ignore next -- the root include provides the registry on every boot; the guard answers its optional type */
-  for (const failure of failures?.list() ?? []) {
-    if (listed.has(failure.entryId)) continue
-    if (runtime.originOf(failure.rowId)?.packageName !== name) continue
-    rows.push({
-      entryId: failure.entryId,
-      rowId: failure.rowId,
-      moduleName: failure.moduleName,
-      enabled: true,
-      phase: 'failed',
-      failure: { stage: failure.stage, message: failure.message },
-    })
-  }
   // A row the composition left out never reached the tree; the runtime's
   // conflicts name the bundle that lost, so nothing is looked up by id.
   for (const conflict of runtime.conflicts) {
@@ -186,15 +185,15 @@ export function ownedEntries(ctx: Context, runtime: ProfileRuntime, name: string
   for (const entry of ctx.loader.entries()) {
     if (entry.options.group) continue
     const rowId = entry.options.id
-    if (runtime.originOf(rowId)?.packageName !== name) continue
+    if (runtime.originOfEntry(entry)?.packageName !== name) continue
     found.push({ entry, rowId })
   }
   return found
 }
 
-/** The rows a bundle would contribute, from its probe record, when it is not composed. */
-function probedRows(probe: PluginProbe | undefined): PluginPackageRowView[] {
-  return (probe?.rows ?? []).map(row => ({
+/** The rows a bundle would contribute, from its patch declarations, when it is not composed. */
+function declaredRows(metadata: PackageMetadata | undefined): PluginPackageRowView[] {
+  return (metadata?.rows ?? []).map(row => ({
     entryId: row.id ?? row.name,
     rowId: row.id ?? row.name,
     moduleName: row.name,

+ 169 - 64
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -18,8 +18,8 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { Context, type Plugin } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import {
-  boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
-  type ComposedStack, type Profile, type probePackage,
+  boot, composeProfileStack, loadOptionalPatches, loadProfile, ProfileRuntime, rootIncludeEntry,
+  type ComposedStack, type Profile, type readPackageMetadata,
 } from '@deepseek-ai/dsh-app-boot'
 import {
   PluginManager, PluginOperationError, pluginOperationFailureOf,
@@ -32,13 +32,13 @@ const NAME = 'dsh-test'
 
 /** A complete tooling config: the host's schema fills these defaults at load, the type does not. */
 function managerConfig(overrides: Partial<PluginToolingConfig> = {}): PluginToolingConfig {
-  return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384, ...overrides }
+  return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installLogTailBytes: 16_384, ...overrides }
 }
 
-/** Test seams: the child spawner and the package probe. */
+/** Test seams: the child spawner and the static metadata reader. */
 interface Internals {
   spawn?: SpawnLike
-  probe?: typeof probePackage
+  metadata?: typeof readPackageMetadata
 }
 
 const contexts: Context[] = []
@@ -58,6 +58,13 @@ const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provid
 const lonelyProvider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('lonelySvc', { ready: true })) }
 const consumer: Plugin.Object = { inject: ['fixtureSvc'], apply() {} }
 const prepare = (ctx: Context): void => {
+  ctx.loader.builtins.configured = {
+    Config: { '~standard': { version: 1, vendor: 'manager-test', validate(input: unknown) {
+      const config = input as { value: number }
+      return config.value < 0 ? { issues: [{ message: 'negative config' }] } : { value: config }
+    } } },
+    apply() {},
+  }
   ctx.loader.builtins.good = good
   ctx.loader.builtins.throws = throws
   ctx.loader.builtins.flaky = flaky
@@ -210,16 +217,14 @@ async function bootProfile(staged: StagedHome, internals: Internals = {}, config
     return { ...stack, patches: structuredClone(stack.patches) }
   }
   const profile = load()
-  const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, prepare)
-  contexts.push(ctx)
-  await ctx.plugin(ProfileRuntime, {
-    profile,
-    stack: composeFor(profile),
-    installAnchor: staged.anchor,
-    loadProfile: load,
-    compose: composeFor,
-    rootEntry: () => rootIncludeEntry(ctx),
+  const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile).patches, async (ctx) => {
+    prepare(ctx)
+    await ctx.plugin(ProfileRuntime, {
+      profile, stack: composeFor(profile), installAnchor: staged.anchor,
+      loadProfile: load, compose: composeFor, rootEntry: () => rootIncludeEntry(ctx),
+    })
   })
+  contexts.push(ctx)
   const changes: Booted['changes'] = []
   const log: PluginInstallLogChunk[] = []
   ctx.on('plugins/changed', (change) => { changes.push(change) })
@@ -244,6 +249,110 @@ const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry
 const BUNDLE_ONE_ROW = '- insert:\n    - id: hello\n      name: cordis:good\n'
 
 describe('PluginManager', () => {
+  it('keeps an installed package with malformed metadata manageable', async () => {
+    const staged = await stageHome()
+    stagePackage(staged.profileDir, 'broken', {})
+    addDependency(staged.profileDir, 'broken')
+    const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
+    writeFileSync(join(staged.profileDir, 'node_modules/broken/package.json'), '{ invalid json')
+    const view = (await manager.list())[0]!
+    expect(view.name).toBe('broken')
+    expect(view.installed).toBe(true)
+    expect(view.reason).toBeTruthy()
+    await manager.uninstall('broken')
+    expect(await manager.list()).toEqual([])
+  })
+  it('shows failed group imports while omitting healthy structural groups', async () => {
+    const staged = await stageHome()
+    stagePackage(staged.profileDir, 'groups', { patch: `- insert:
+    - id: healthy
+      name: cordis:group
+      group: true
+      config: []
+    - id: broken-group
+      name: ./missing.mjs
+      group: true
+      config: []
+` })
+    addDependency(staged.profileDir, 'groups')
+    const { manager } = await bootProfile(staged)
+    await manager.enable('groups')
+    const view = (await manager.list())[0]!
+    expect(view.status).toBe('failed')
+    expect(view.rows.map(row => row.rowId)).toEqual(['broken-group'])
+    expect(view.rows[0]?.phase).toBe('failed')
+    expect(view.rows[0]?.failure?.stage).toBe('import')
+  })
+
+  it('shows a failed config update beside the still-active old instance and attributes overrides to their owner', async () => {
+    const staged = await stageHome()
+    stagePackage(staged.profileDir, 'base', {
+      patch: '- insert:\n    - id: core-config\n      name: cordis:configured\n      config: { value: 1 }\n',
+    })
+    const manifest = manifestOf(staged.profileDir)
+    manifest.dsh.profile.bundles.push('base')
+    writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
+    stagePackage(staged.profileDir, 'ext', { patch: '- insert:\n    - id: ext-ok\n      name: cordis:good\n- id: core-config\n  config: { value: -1 }\n' })
+    addDependency(staged.profileDir, 'ext')
+    const { ctx, manager } = await bootProfile(staged)
+    const result = await manager.enable('ext')
+    expect(result.issues?.[0]?.entryId).toBe('include:core-config')
+    const core = ctx.loader.resolve('include:core-config')
+    expect(core.fiber?.state).toBe(2)
+    expect(core.fiber?.config).toEqual({ value: 1 })
+    expect(core.options.config).toEqual({ value: -1 })
+    const views = await manager.list()
+    const ext = views.find(view => view.name === 'ext')!
+    expect(ext.status).toBe('partial')
+    expect(ext.issues?.[0]?.stage).toBe('update')
+    expect(ext.rows.map(row => row.rowId)).toEqual(['ext-ok'])
+    expect(ctx.profileRuntime.originOfEntry(core)?.packageName).toBe('base')
+    expect(views.find(view => view.name === 'base')?.rows[0]).toMatchObject({ phase: 'active', failure: { stage: 'update' } })
+    writeFileSync(join(staged.profileDir, 'node_modules/ext/cordis.patch.yml'), '- id: core-config\n  config: { value: -1 }\n')
+    await ctx.profileRuntime.recompose({ reloadBundles: true })
+    expect((await manager.list()).find(view => view.name === 'ext')?.status).toBe('failed')
+    await manager.disable('ext')
+    expect(core.fiber?.config).toEqual({ value: 1 })
+    expect((await manager.list()).find(view => view.name === 'base')?.status).toBe('running')
+  })
+
+  it('keeps a rejected preparation out of the layer list without promising rollback of live plugin effects', async () => {
+    const staged = await stageHome()
+    stagePackage(staged.profileDir, 'base', { patch: BUNDLE_ONE_ROW })
+    const manifest = manifestOf(staged.profileDir)
+    manifest.dsh.profile.bundles.push('base')
+    writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
+    stagePackage(staged.profileDir, 'clash', { patch: BUNDLE_ONE_ROW, stage: 'boot' })
+    addDependency(staged.profileDir, 'clash')
+    const { ctx, manager } = await bootProfile(staged)
+    await expect(manager.enable('clash')).rejects.toMatchObject({ code: 'plugins/enable-failed' })
+    expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['base'])
+    expect(ctx.loader.resolve('include:hello').fiber?.state).toBe(2)
+  })
+
+  it('does not execute a declared main module during install or list and refuses an undeclared entry', async () => {
+    const staged = await stageHome()
+    const marker = join(staged.profileDir, 'executed')
+    stagePackage(staged.profileDir, 'declared', {
+      plugins: [{ name: '.', config: { custom: 1 } }],
+      main: `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'bad'); export function apply() {}`,
+    })
+    stagePackage(staged.profileDir, 'unknown', { main: 'export function apply() {}' })
+    const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
+    await manager.add('declared')
+    await manager.add('unknown')
+    const views = await manager.list()
+    expect(views.find(view => view.name === 'declared')?.addable[0]).toMatchObject({ moduleName: 'declared', config: { custom: 1 } })
+    expect(existsSync(marker)).toBe(false)
+    for (const id of ['', 'nested:child']) {
+      await expect(manager.addRow('declared', { kind: 'global' }, { id })).rejects.toMatchObject({ code: 'plugins/bad-request' })
+    }
+    const added = await manager.addRow('declared', { kind: 'global' }, { config: null })
+    expect(readFileSync(added.file, 'utf8')).toContain('config: null')
+    expect(views.find(view => view.name === 'unknown')?.kind).toBe('unknown')
+    await expect(manager.addRow('unknown', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
+  })
+
   it('reports plugins/unavailable without a profile runtime', async () => {
     const ctx = new Context()
     contexts.push(ctx)
@@ -252,11 +361,11 @@ describe('PluginManager', () => {
   })
 
   describe('list', () => {
-    it('folds installed, enabled, and probed facts into one view per package', async () => {
+    it('folds installed, enabled, declared and running facts into one view per package', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js', title: 'Extra' }], files: { 'extra.js': 'export const name = "extra"\nexport function apply() {}\n' } })
       stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
-      stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
+      stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
       addDependency(staged.profileDir, 'ext-bundle')
       addDependency(staged.profileDir, 'ext-lib')
       addDependency(staged.profileDir, 'ext-plugin')
@@ -266,22 +375,18 @@ describe('PluginManager', () => {
 
       expect(views.map(view => [view.name, view.kind, view.status, view.installed, view.enabled, view.trust])).toEqual([
         ['ext-bundle', 'bundle', 'disabled', true, false, 'external'],
-        ['ext-lib', 'library', 'plain', true, false, 'external'],
+        ['ext-lib', 'unknown', 'plain', true, false, 'external'],
         ['ext-plugin', 'plugin', 'plain', true, false, 'external'],
       ])
       const bundle = views[0]
       expect(bundle).toMatchObject({ version: '1.0.0', title: 'Title of ext-bundle', description: 'staged ext-bundle', stage: 'runtime', liveReload: true })
-      // Rows come from the probe while the bundle is not composed, under the ids the patch declares.
+      // Rows come from static declarations while the bundle is not composed, under the ids the patch declares.
       expect(bundle?.rows).toEqual([{ entryId: 'hello', rowId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
-      expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }])
-      // A plugin module offers its main export as `.`, the entry addRow accepts without a declaration.
-      expect(views[2]?.addable).toMatchObject([{ moduleName: 'ext-plugin', declaredName: '.', ok: true }])
-      expect(views[1]?.addable).toEqual([])
-      expect(bundle?.probedAt).toEqual(expect.any(String) as string)
-      expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle')
+      expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra' }])
+      expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
     })
 
-    it('lists a plugin module\'s main export as "." ahead of its declared modules, unless it declares "." itself', async () => {
+    it('lists only declared modules, including an explicitly declared main export', async () => {
       const staged = await stageHome()
       const main = 'export const name = "p"\nexport function apply() {}\n'
       stagePackage(staged.profileDir, 'ext-plugin', {
@@ -296,7 +401,7 @@ describe('PluginManager', () => {
 
       const views = await manager.list()
 
-      expect(views.find(view => view.name === 'ext-plugin')?.addable.map(entry => entry.declaredName)).toEqual(['.', './tools/sql.js'])
+      expect(views.find(view => view.name === 'ext-plugin')?.addable.map(entry => entry.declaredName)).toEqual(['./tools/sql.js'])
       // A declared "." is the package's own word on its main export; nothing is prepended.
       expect(views.find(view => view.name === 'ext-explicit')?.addable.map(entry => entry.declaredName)).toEqual(['.'])
     })
@@ -316,7 +421,7 @@ describe('PluginManager', () => {
       expect(view?.reason).toContain('boom at apply')
       expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
         ['include:ok', 'active', undefined],
-        ['include:bad', 'failed', 'apply'],
+        ['include:bad', 'failed', 'activation'],
       ])
     })
 
@@ -344,7 +449,7 @@ describe('PluginManager', () => {
       }])
     })
 
-    it('reports a package the probe refuses as not enableable, and a stale layer as restart-required', async () => {
+    it('does not import a disabled bundle and reports a stale layer as restart-required', async () => {
       const staged = await stageHome('startup')
       stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
       addDependency(staged.profileDir, 'ext-broken')
@@ -358,28 +463,29 @@ describe('PluginManager', () => {
 
       const views = await manager.list()
 
-      expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable', reason: expect.stringContaining('no import for you') as string })
+      expect(views.find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
       expect(views.find(view => view.name === 'ext-later')).toMatchObject({ status: 'restart-required', liveReload: false })
     })
 
-    it('reports a package whose probe cannot run with the probe\'s failure', async () => {
+    it('reports unreadable declarations and treats physical peer identity as advisory', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW })
       addDependency(staged.profileDir, 'ext-odd')
       stagePackage(staged.profileDir, 'ext-refused', { patch: BUNDLE_ONE_ROW })
       addDependency(staged.profileDir, 'ext-refused')
       const { manager } = await bootProfile(staged, {
-        probe: ({ packageName }) => packageName === 'ext-odd'
-          ? Promise.reject(new Error('probe exploded'))
-          : Promise.resolve({ packageName, kind: 'bundle', ok: false, reason: 'foreign cordis', cordisSameCopy: false, rows: [], overrides: [], addable: [], checkedAt: 'now' }),
+        metadata: ({ packageName }) => {
+          if (packageName === 'ext-odd') throw new Error('invalid declarations')
+          return { packageName, kind: 'bundle', cordisSameCopy: false, rows: [], overrides: [], addable: [] }
+        },
       })
 
       const views = await manager.list()
 
-      expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'probe exploded', cordisSameCopy: null, rows: [] })
-      expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'not-enableable', reason: 'foreign cordis', cordisSameCopy: false })
-      await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot be probed: probe exploded' } })
-      await expect(manager.enable('ext-refused')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'foreign cordis' } })
+      expect(views.find(view => view.name === 'ext-odd')).toMatchObject({ status: 'not-enableable', reason: 'invalid declarations', cordisSameCopy: null, rows: [] })
+      expect(views.find(view => view.name === 'ext-refused')).toMatchObject({ status: 'disabled', cordisSameCopy: false })
+      await expect(manager.enable('ext-odd')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: 'cannot read package declarations: invalid declarations' } })
+      await expect(manager.enable('ext-refused')).resolves.toMatchObject({ effect: 'live' })
     })
 
     it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
@@ -399,7 +505,7 @@ describe('PluginManager', () => {
       expect(views.map(view => [view.name, view.trust, view.kind, view.status])).toEqual([
         ['tpl', 'builtin', 'bundle', 'running'],
         ['tpl-later', 'builtin', 'bundle', 'restart-required'],
-        ['ghost', 'builtin', 'library', 'plain'],
+        ['ghost', 'builtin', 'unknown', 'plain'],
       ])
       await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
       // Retrying takes the bundle out first, which a template bundle refuses.
@@ -433,7 +539,7 @@ describe('PluginManager', () => {
       expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
     })
 
-    it('reads anonymous and gated probe rows', async () => {
+    it('reads anonymous and gated static patch rows', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-anon', { patch: '- insert:\n    - name: cordis:good\n    - id: gated\n      name: cordis:good\n      disabled: true\n' })
       addDependency(staged.profileDir, 'ext-anon')
@@ -469,13 +575,12 @@ describe('PluginManager', () => {
       const result = await manager.add('ext-lib')
 
       expect(result).toMatchObject({
-        installed: [], plain: [], installedOnly: [],
-        removed: [{ name: 'ext-lib', reason: 'declares neither a dsh bundle nor a plugin module' }],
+        installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [], removed: [],
       })
-      expect(calls).toEqual([['pnpm', 'add', 'ext-lib'], ['pnpm', 'remove', 'ext-lib']])
-      expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-lib')
+      expect(calls).toEqual([['pnpm', 'add', 'ext-lib']])
+      expect(manifestOf(staged.profileDir).dependencies).toHaveProperty('ext-lib')
       expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-lib.json'))).toBe(false)
-      expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
+      expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(true)
     })
 
     it('removes an installed bundle whose row id another layer already owns', async () => {
@@ -502,7 +607,7 @@ describe('PluginManager', () => {
 
     it('removes an installed bundle the profile cannot resolve and says why', async () => {
       const staged = await stageHome()
-      // The probe reads the manifest without judging the stage; resolving the
+      // The static reader reads the manifest without judging the stage; resolving the
       // layer is what refuses it, and that refusal is the removal's reason.
       stagePackage(staged.profileDir, 'ext-odd', { patch: BUNDLE_ONE_ROW, stage: 'weird' })
       const calls: string[][] = []
@@ -518,7 +623,7 @@ describe('PluginManager', () => {
       expect(manifestOf(staged.profileDir).dependencies).not.toHaveProperty('ext-odd')
     })
 
-    it('keeps a package whose probe refused it, for the view to explain', async () => {
+    it('keeps an unexecuted package installed with its bundle disabled', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
       const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
@@ -526,7 +631,7 @@ describe('PluginManager', () => {
       const result = await manager.add('ext-broken')
 
       expect(result).toMatchObject({ installed: ['ext-broken'], removed: [] })
-      expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'not-enableable' })
+      expect((await manager.list()).find(view => view.name === 'ext-broken')).toMatchObject({ status: 'disabled' })
     })
 
     it('restores the manifest when pnpm fails after writing it', async () => {
@@ -586,7 +691,7 @@ describe('PluginManager', () => {
       await expect(manager.enable('ext-bundle')).resolves.toMatchObject({ changed: true })
     })
 
-    it('runs pnpm add, records the dependency, probes the package, and leaves it disabled', async () => {
+    it('runs pnpm add, records the dependency and leaves its bundle disabled', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
       const calls: string[][] = []
@@ -596,7 +701,7 @@ describe('PluginManager', () => {
 
       expect(calls).toEqual([['pnpm', 'add', 'github:acme/ext-new']])
       // The fake pnpm records the spec itself as the dependency name, which
-      // resolves to nothing: a plain dependency whose probe cannot run.
+      // resolves to nothing: an installed dependency whose declarations cannot be read.
       expect(result).toEqual({ installed: ['github:acme/ext-new'], removed: [], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
       expect(log.map(chunk => [chunk.argv, chunk.cwd, chunk.stream, chunk.text, chunk.exitCode])).toEqual([
         [['pnpm', 'add', 'github:acme/ext-new'], staged.profileDir, 'stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
@@ -633,8 +738,8 @@ describe('PluginManager', () => {
 
       expect(result).toMatchObject({ installed: ['ext-new'], enabled: ['ext-new'], installedOnly: [], plain: [] })
       expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-new'])
-      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:bundle/ext-new', 'include:hello']))
-      expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined()
+      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello']))
+      expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
       expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
       expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
     })
@@ -733,7 +838,7 @@ describe('PluginManager', () => {
 
       await expect(manager.enable('absent')).rejects.toMatchObject({ code: 'plugins/not-installed' })
       await expect(manager.enable('ext-lib')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('declares no dsh.bundle') as string } })
-      await expect(manager.enable('ext-broken')).rejects.toMatchObject({ code: 'plugins/not-enableable', details: { reason: expect.stringContaining('no import for you') as string } })
+      await expect(manager.enable('ext-broken')).resolves.toMatchObject({ effect: 'live' })
       // A template bundle is not a dependency and cannot be disabled.
       const manifest = manifestOf(staged.profileDir)
       manifest.dsh.profile.bundles.push('template')
@@ -742,7 +847,7 @@ describe('PluginManager', () => {
       await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'plugins/bad-request' })
     })
 
-    it('restores the layer list when the tree rejects a boot-stage bundle', async () => {
+    it('retains live enablement and reports failed boot-stage rows after startup', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-fatal', { patch: '- insert:\n    - id: bad\n      name: cordis:throws\n', stage: 'boot' })
       addDependency(staged.profileDir, 'ext-fatal')
@@ -751,16 +856,16 @@ describe('PluginManager', () => {
       const { ctx, manager } = await bootProfile(staged)
       await manager.enable('ext-fine')
 
-      await expect(manager.enable('ext-fatal')).rejects.toMatchObject({
-        code: 'plugins/enable-failed', details: { packageName: 'ext-fatal', reason: expect.stringContaining('boom at apply') as string },
-      })
+      const result = await manager.enable('ext-fatal')
+      expect(result.effect).toBe('live')
+      expect(result.issues?.[0]?.message).toContain('boom at apply')
 
-      expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine'])
+      expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine', 'ext-fatal'])
       expect(entryIds(ctx)).toContain('include:hello')
-      expect(entryIds(ctx)).not.toContain('include:bad')
+      expect(entryIds(ctx)).toContain('include:bad')
     })
 
-    it('retries an isolated bundle by composing it again', async () => {
+    it('retries a bundle by removing and re-adding its layer', async () => {
       const staged = await stageHome()
       stagePackage(staged.profileDir, 'ext-flaky', { patch: '- insert:\n    - id: once\n      name: cordis:flaky\n' })
       addDependency(staged.profileDir, 'ext-flaky')
@@ -779,7 +884,7 @@ describe('PluginManager', () => {
   describe('rows in user layers', () => {
     it('adds, disables, re-enables, and removes a row in the live global layer', async () => {
       const staged = await stageHome()
-      stagePackage(staged.profileDir, '@acme/ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
+      stagePackage(staged.profileDir, '@acme/ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
       addDependency(staged.profileDir, '@acme/ext-plugin')
       const { ctx, manager, changes } = await bootProfile(staged)
 
@@ -806,7 +911,7 @@ describe('PluginManager', () => {
 
     it('detects a conflict through the layer file when the tree was not recomposed', async () => {
       const staged = await stageHome('startup')
-      stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
+      stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
       addDependency(staged.profileDir, 'ext-plugin')
       const { ctx, manager } = await bootProfile(staged)
 
@@ -833,13 +938,13 @@ describe('PluginManager', () => {
       expect(added.rowId).toBe('sql')
       expect(readFileSync(added.file, 'utf8')).toContain('- id: sql\n      name: ext-bundle/tools/sql.js\n      config:\n        dsn: sqlite://\n')
       await expect(manager.addRow('ext-bundle', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
-      await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).rejects.toMatchObject({ code: 'plugins/not-enableable' })
+      await expect(manager.addRow('ext-bundle', { kind: 'global' }, { module: './missing.js' })).resolves.toMatchObject({ rowId: 'ext-bundle/missing.js' })
       await expect(manager.addRow('absent', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/not-installed' })
     })
 
     it('writes a preset\'s layer through the roster, and refuses a preset target without one', async () => {
       const staged = await stageHome()
-      stagePackage(staged.profileDir, 'ext-plugin', { main: 'export const name = "p"\nexport function apply() {}\n' })
+      stagePackage(staged.profileDir, 'ext-plugin', { plugins: [{ name: '.' }], main: 'export const name = "p"\nexport function apply() {}\n' })
       addDependency(staged.profileDir, 'ext-plugin')
       const { ctx, manager } = await bootProfile(staged)
       await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable' })
@@ -927,7 +1032,7 @@ describe('PluginManager', () => {
       await manager.enable('ext-bundle')
       await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
       expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:hello', 'include:ext-bundle/extra.js']))
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
+      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
 
       await manager.uninstall('ext-bundle')
 

+ 1 - 0
packages/bundle/web-app/tests/web-app.spec.ts

@@ -323,6 +323,7 @@ describe('web-app runtime glue', () => {
   ])('announces readiness=$announces after the $id sibling fails', async ({ id, announces }) => {
     stageDist()
     const ctx = new Context()
+    ctx.provide('profileRuntime', { originOfEntry: () => announces ? { trust: 'external', stage: 'runtime' } : undefined } as unknown as AppBoot.ProfileRuntime)
     onTestFinished(() => ctx.fiber.dispose())
     ctx.baseUrl = 'file:///'
     ctx.provide('webServer', fakeHttpServer().server)

+ 2 - 2
packages/client/ui-plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
-README.md: 72b9bf02de66d0c6c8ae86e05e9f3f19d9981863
-README.zh.md: 527b8a3f6c61fcf57b99e8589e947ead552102db
+README.md: c34f69ba170a147f6313c21354c9e5667ac6d709
+README.zh.md: 387ca469f17f6978340fc8d424d943b5bd6757a2

+ 4 - 4
packages/client/ui-plugin-manager/README.md

@@ -29,11 +29,11 @@ Select **Plugins** in the sidebar. The page reads no Remote during plugin activa
 
 ### Installing a package
 
-**Add plugin** opens the install dialog. Enter what pnpm accepts — `dsh-better-sidebar@latest`, `/path/to/plugin`, a Git URL — and choose whether a newly installed pack is enabled right away. The dialog shows the run's progress as one terminal per pnpm command — the command line, pnpm's output streaming in with its colours, the exit code when it is not zero — and cannot be closed while the run is in flight. A line above the terminals names the profile directory pnpm installs into, and a finished run leaves **Done** as the only action until the spec changes, which starts the dialog over. Once pnpm exits, one sentence per package says what happened: a pack installed and enabled, a pack installed and waiting for its switch, a plugin installed and ready to join a preset, a package that is not a DSH plugin removed again, or a pack removed again because its row id conflicts with an installed one — the last with the Host's reason. A run or any other action the Host refuses because another change is still running, or because a session is running, shows that refusal in the Host's words.
+**Add plugin** accepts a pnpm package spec, local path or Git URL. The dialog shows the command, streamed output, and completion result, and stays open during installation. New bundles can be enabled immediately. Undeclared packages remain installed; invalid or conflicting bundle declarations may be rejected with the Host’s reason. A successful installation does not certify that a module can activate.
 
 ### Switching a plugin pack
 
-A pack's switch calls `plugins.enable` or `plugins.disable`. On a live-reload profile the tree recomposes before the switch settles; a profile that applies layer changes at its next start reports the change as *restart needed*, and the page names every such package in a banner until the restart. A pack the probe refused reads as a *problem* with the probe's reason in its expanded card and cannot be switched on; a pack whose components failed offers **Retry** in its expanded card, beside **Uninstall**. A built-in pack carries a locked switch. A disable first asks the Host what it would strand, with the switch inert meanwhile, and opens the confirmation only when something does.
+A pack’s switch changes its enabled layer selection. Live profiles recompose before the operation completes; startup-only profiles show a restart notice. Runtime failures leave successful components active and keep the pack enabled, with **Retry** and **Uninstall** on its page. Unreadable bundle declarations prevent enabling. Built-in packs have locked switches. Disabling first checks dependents and asks for confirmation when needed.
 
 ### Switching one row of a pack
 
@@ -41,11 +41,11 @@ A row's switch on the pack's page calls `plugins.setRowDisabled` against the pro
 
 ### Adding a plugin to a preset
 
-A plugin's card carries **Add to…**, listing every session and each preset; a target the plugin already joined is marked and disabled, and a package that declares several importable modules nests the targets under each module. The choice writes a row naming the module into the global user patch file or one preset's, through `plugins.addRow`. A preset's composition is managed on the preset's own detail page in the Agent presets section, where this package contributes the **Capabilities** section: one card per row with its switch, **Delete** on a row the person added, and an **Add** menu over the installed modules the preset does not carry yet. A switch writes `disabled: true` into that preset's user patch layer or removes the key again — a row the preset itself switched off stays locked, because the layer can only deny. Running and off are what the switch shows; no card tags them. Rows that declare no id cannot be switched.
+A package with explicit plugin declarations offers **Add to…** for every declared entry, including a declared main export. The menu marks targets already joined. Addition writes a global or preset user patch; it performs no import precheck. A package with no recognized declaration has no Add action and remains uninstallable. Preset rows are managed in the preset detail page’s **Capabilities** section, where switches write or remove the user’s `disabled: true` override and **Delete** removes a user insert.
 
 ### Reading a failure
 
-What the last action left to say sits above the groups: a restart notice, or the failure with the Host's own reason — a probe refusal, a rejected recomposition, a row id already taken. Dismiss it or let the next action replace it. A row waiting for a service another row provides — one the person switched off, say — reads as *waiting for a dependency* on the row and on its pack's tag, whether a live recomposition left its fiber pending or a fresh composition recorded the wait as a failure; the red *problem* is kept for a real failure.
+The page separates enablement, running phase and current issues. A missing service reads as *waiting for a dependency*. A row whose old instance remains active after invalid configuration reads *Running; latest update failed* with the actual error. The pack detail also identifies issues in other components its patch affects, preserving their original ownership. Runtime changes refresh the page after Loader and profile recomposition settle. Action refusals retain the Host’s reason.
 
 -----
 

+ 4 - 4
packages/client/ui-plugin-manager/README.zh.md

@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### 安装一个包
 
-**添加插件**打开安装对话框。输入 pnpm 接受的写法——`dsh-better-sidebar@latest`、`/path/to/plugin`、Git 地址——并选择新装的插件包是否立即启用。对话框把每条 pnpm 命令显示成一块终端——命令行、带颜色边跑边刷的输出、非零时的退出码——运行进行中对话框不能关闭。终端上方一行写明 pnpm 安装到的 profile 目录;运行完成后只剩**完成**一个动作,改动包名后对话框从头开始。pnpm 退出后,每个包一句话说明结果:插件包已安装并启用、插件包已安装等待开关、插件已安装可加入预设、不是 DSH 插件的包已被移除、行 id 与已安装插件包冲突的包已被移除——最后一种附宿主给出的原因。宿主因为另一个改动还在进行、或有会话正在运行而拒绝的安装或其他操作,会原样显示那条拒绝理由。
+**添加插件**接受 pnpm 包描述、本地路径或 Git URL。对话框显示命令、实时输出和完成结果,在安装期间保持打开。新组合包可以立即启用。未声明入口的包保持已安装;无效或冲突的组合包声明可能被拒绝,并显示宿主原因。安装成功不代表模块一定能够激活。
 
 ### 切换一个插件包
 
-插件包的开关调用 `plugins.enable` 或 `plugins.disable`。在实时重载的 profile 上,树会在开关落定前重新组合;在下次启动才应用层变更的 profile 上,改动被报告为*需重启*,页面会在横幅里点名每个这样的包直到重启。探针拒绝的插件包显示为*异常*,展开后可见探针的原因,且无法打开;组件启动失败的插件包在展开区提供**重试**,与**卸载**并排。内置插件包带锁定的开关。停用会先询问宿主它会搁置什么,询问期间开关置灰,只有存在依赖方时才弹出确认框。
+组合包开关改变其启用层选择。实时 profile 在操作完成前重组;仅启动时生效的 profile 显示重启提示。运行失败会保留成功组件和组合包的启用状态,并在详情页提供**重试**与**卸载**。组合包声明不可读时无法启用。内置组合包的开关被锁定。停用会先检查依赖方,必要时要求确认。
 
 ### 切换插件包里的一行
 
@@ -41,11 +41,11 @@ kind: "package-reference"
 
 ### 把插件加入预设
 
-插件卡片带**加入到…**,列出所有会话与每个预设;已加入的目标被标注并置灰,声明了多个可导入模块的包把目标嵌在各模块之下。选择后通过 `plugins.addRow` 把一行写进全局用户补丁文件或某个预设的补丁文件。预设的组合在 Agent 预设分区里该预设自己的详情页上管理,本包向那里贡献**能力**段:每行一张带开关的卡片,用户添加的行带**删除**,段头的**添加**菜单列出预设还没有的已安装模块。开关往该预设的用户补丁层写入 `disabled: true` 或再次删掉这个键——预设自己关掉的行保持锁定,因为该层只能拒绝。运行中与已停用由开关表达,卡片不为此打标签。没有声明 id 的行不能切换。
+有显式插件声明的包为每个声明入口提供**加入到…**,包括已声明的主入口。菜单标记已加入的目标。添加会写入全局或预设用户 patch,不进行导入预检。未识别到声明的包没有添加操作,但仍可卸载。预设行在预设详情页的**能力**区域管理:开关写入或删除用户的 `disabled: true` 覆盖,**删除**移除用户插入。
 
 ### 阅读失败
 
-上一次操作留下的话停在两组之上:重启提示,或带宿主原因的失败——探针拒绝、被拒的重新组合、已被占用的行 id。可以关掉它,也可以让下一次操作替换它。等待另一行提供的服务的行——比如那一行被人关掉了——在行上和插件包标签上都读作*等待依赖*,不论是就地重组把 fiber 留在挂起状态,还是全新组合把这次等待记成了失败;红色的*异常*只留给真正的失败。
+页面分别展示启用选择、运行阶段和当前问题。缺少服务显示为*等待依赖*。无效配置后旧实例仍在运行的行显示*运行中,但最近更新失败*及实际错误。组合包详情还标识其 patch 影响的其他组件的问题,保留原归属。运行变化在 Loader 和 profile 重组完成后刷新页面。操作被拒绝时保留宿主给出的原因。
 
 -----
 

+ 54 - 40
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -6,7 +6,7 @@
  * a restart is pending or something is wrong; the install dialog streaming
  * pnpm's output behind a fold; and the confirmation a destructive action
  * waits on, naming what still uses the package. Entry ids, module names,
- * kinds, and probe facts stay off the page. A card opens the package's own
+ * and module names stay in the detail view. A card opens the package's own
  * page: its version and source, its rows — each with its own switch on an
  * external pack composed on a live-reload profile — the built-in rows it
  * changes, the modules it declares addable, and its uninstall. A preset's
@@ -94,20 +94,23 @@ function isWaitingRow(row: RowView): boolean {
 
 /** A row with a recorded startup failure or a failed fiber; a wait for a service is not a failure. */
 function isFailedRow(row: RowView): boolean {
-  if (row.phase === 'active') return false
+  if (row.phase === 'active' && row.failure?.stage === 'inject-pending') return false
   return !isWaitingRow(row) && (row.failure !== undefined || row.phase === 'failed')
 }
 
 /** The tag one package carries: a pending restart, rows waiting for a service, a problem, or none. */
 function cardStatus(pkg: PluginPackageView): CardStatus | null {
   const base = pkg.kind === 'bundle' ? STATUS_OF[pkg.status] : pkg.reason === undefined ? null : 'problem'
-  if (base === 'problem' && pkg.kind === 'bundle' && pkg.enabled && pkg.rows.some(isWaitingRow) && !pkg.rows.some(isFailedRow)) return 'waiting'
+  if (base === 'problem' && pkg.kind === 'bundle' && pkg.enabled
+    && pkg.rows.some(isWaitingRow) && !pkg.rows.some(isFailedRow)
+    && !pkg.issues?.some(issue => issue.stage !== 'inject-pending')) return 'waiting'
   return base
 }
 
 /** The count line over a pack's components: the total, then only the states that occur. */
 function partsSummary(rows: readonly RowView[], t: Translate): string {
-  const failed = rows.filter(isFailedRow).length
+  const failed = rows.filter(row => row.phase !== 'active' && isFailedRow(row)).length
+  const updateFailed = rows.filter(row => row.phase === 'active' && isFailedRow(row)).length
   const waiting = rows.filter(isWaitingRow).length
   const off = rows.filter(row => !row.enabled && !isFailedRow(row) && !isWaitingRow(row)).length
   const running = rows.filter(row => row.enabled && row.phase === 'active').length
@@ -117,6 +120,7 @@ function partsSummary(rows: readonly RowView[], t: Translate): string {
     ...waiting > 0 ? [t('partsCountWaiting', { count: String(waiting) })] : [],
     ...off > 0 ? [t('partsCountOff', { count: String(off) })] : [],
     ...failed > 0 ? [t('partsCountFailed', { count: String(failed) })] : [],
+    ...updateFailed > 0 ? [t('partsCountUpdateFailed', { count: String(updateFailed) })] : [],
   ].join(' · ')
 }
 
@@ -152,7 +156,7 @@ function RowSwitch({ row, t, toggle }: { readonly row: RowView; readonly t: Tran
 /** What a row's state line says: the failure, why it is off, or the phase its fiber is in. */
 function rowStateText(row: RowView, t: Translate): string {
   if (isWaitingRow(row)) return t('rowPhasePending')
-  if (isFailedRow(row)) return t('rowStateFailed')
+  if (isFailedRow(row)) return t(row.phase === 'active' ? 'rowUpdateFailed' : 'rowStateFailed')
   if (!row.enabled) return t(row.disabledBy === 'user' ? 'partDisabledByUser' : 'partDisabledByComposition')
   return row.phase === null ? t('rowStateIdle') : t(PHASE_KEYS[row.phase])
 }
@@ -227,7 +231,7 @@ function RowsSection({ rows, t, toggle }: {
                     </span>
                     {rowToggle === undefined ? null : <RowSwitch row={row} t={t} toggle={rowToggle} />}
                   </div>
-                  {row.failure === undefined || row.phase === 'active' ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
+                  {row.failure === undefined || (row.phase === 'active' && !isFailedRow(row)) ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
                 </li>
               )
             })}
@@ -284,46 +288,40 @@ function ModulesSection({ pkg, t, busy, presets, globalModules, presetName, onAd
           const joined = joinedTargets(entry, presets, globalModules, t, presetName)
           const open = openMenu === entry.moduleName
           return (
-            <li key={entry.moduleName} className={css.row} data-plugin-module={entry.moduleName} {...entry.ok ? {} : { 'data-state': 'failed' }}>
+            <li key={entry.moduleName} className={css.row} data-plugin-module={entry.moduleName}>
               <div className={css.rowLine}>
                 <span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutline14 /></span>
                 <div className={css.rowMain}>
                   <span className={css.rowId}>{entry.title ?? (entry.declaredName === '.' ? title : entry.declaredName)}</span>
                   <span className={css.rowModule}>{entry.moduleName}</span>
                   <span className={css.rowNote}>
-                    {entry.ok
-                      ? joined.length > 0 ? t('moduleJoined', { targets: joined.join(t('joinedSeparator')) }) : t('moduleNotJoined')
-                      : t('moduleBroken', { error: entry.error ?? '' })}
+                    {joined.length > 0 ? t('moduleJoined', { targets: joined.join(t('joinedSeparator')) }) : t('moduleNotJoined')}
                   </span>
                 </div>
-                {entry.ok
-                  ? (
-                    <Menu
-                      open={open}
-                      onClose={() => { setOpenMenu(null) }}
-                      items={addTargets(entry, presets, globalModules, t, presetName)}
-                      onSelect={(id) => {
-                        setOpenMenu(null)
-                        const choice = JSON.parse(id) as AddChoice
-                        onAddRow(choice.declaredName, choice.target)
-                      }}
-                      align="end"
-                      portal
-                      anchor={(
-                        <Button
-                          variant="outline"
-                          size="sm"
-                          aria-haspopup="menu"
-                          aria-expanded={open}
-                          disabled={busy}
-                          onClick={() => { setOpenMenu(open ? null : entry.moduleName) }}
-                        >
-                          {t('addTo')}
-                        </Button>
-                      )}
-                    />
-                  )
-                  : null}
+                <Menu
+                  open={open}
+                  onClose={() => { setOpenMenu(null) }}
+                  items={addTargets(entry, presets, globalModules, t, presetName)}
+                  onSelect={(id) => {
+                    setOpenMenu(null)
+                    const choice = JSON.parse(id) as AddChoice
+                    onAddRow(choice.declaredName, choice.target)
+                  }}
+                  align="end"
+                  portal
+                  anchor={(
+                    <Button
+                      variant="outline"
+                      size="sm"
+                      aria-haspopup="menu"
+                      aria-expanded={open}
+                      disabled={busy}
+                      onClick={() => { setOpenMenu(open ? null : entry.moduleName) }}
+                    >
+                      {t('addTo')}
+                    </Button>
+                  )}
+                />
               </div>
             </li>
           )
@@ -396,7 +394,7 @@ function PackageCard({ pkg, t, busy, presets, globalModules, presetName, onOpen,
   const title = pkg.title ?? shortName(pkg.name)
   const builtin = pkg.trust === 'builtin'
   const status = cardStatus(pkg)
-  const addable = pkg.addable.filter(entry => entry.ok)
+  const addable = pkg.addable
   const [single] = addable
   const menuItems: MenuItem[] = single !== undefined && addable.length === 1
     ? addTargets(single, presets, globalModules, t, presetName)
@@ -484,6 +482,7 @@ function PackageDetail({
   const status = cardStatus(pkg)
   const retryable = bundle && pkg.enabled && (pkg.status === 'failed' || pkg.status === 'partial')
   const removable = pkg.installed && !builtin
+  const affectedIssues = (pkg.issues ?? []).filter(issue => !pkg.rows.some(row => row.entryId === issue.entryId))
   // A row's switch acts at once only on an external pack composed on a
   // profile that applies patches while it runs; elsewhere the rows stay read-only.
   const switchable = bundle && !builtin && pkg.enabled && pkg.liveReload
@@ -526,12 +525,27 @@ function PackageDetail({
         </div>
       </div>
       {pkg.reason === undefined || status === 'waiting' ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {pkg.reason}</p>}
+      {pkg.kind === 'unknown' ? <p className={css.detailDesc}>{t('unknownPackage')}</p> : null}
       <dl className={css.facts}>
         {pkg.version === undefined ? null : <><dt>{t('versionLabel')}</dt><dd>{pkg.version}</dd></>}
         <dt>{t('sourceLabel')}</dt>
         <dd>{t(builtin ? 'sourceBuiltin' : 'sourceExternal')}</dd>
       </dl>
       <div className={css.detailSections}>
+        {affectedIssues.length === 0 ? null : (
+          <section className={css.detailSection} data-plugin-affected-issues>
+            <h4 className={css.sectionTitle}>{t('affectedIssuesLabel')}</h4>
+            <ul className={css.rows}>
+              {affectedIssues.map(issue => (
+                <li key={issue.entryId} className={css.row}>
+                  <span className={css.rowId}>{issue.entryId}</span>
+                  <span className={css.rowModule}>{issue.moduleName}</span>
+                  <p className={css.rowFailure}>{issue.message}</p>
+                </li>
+              ))}
+            </ul>
+          </section>
+        )}
         {bundle
           ? (
             <RowsSection
@@ -564,7 +578,7 @@ function PackageDetail({
 function removedText(entry: PluginInstallRejection, t: Translate): string {
   return entry.reason.startsWith('row ')
     ? t('installRemovedConflict', { name: entry.name, reason: entry.reason })
-    : t('installRemovedLibrary', { name: entry.name })
+    : t('installRemovedInvalid', { name: entry.name, reason: entry.reason })
 }
 
 /** Output lines an install run's terminal shows before its middle folds: the first and last six of a long pnpm log. */

+ 1 - 1
packages/client/ui-plugin-manager/src/client/PresetPluginsSection.tsx

@@ -38,7 +38,7 @@ function addChoices(packages: readonly PluginPackageView[], preset: PresetGroup,
   for (const pkg of packages) {
     const title = pkg.title ?? shortName(pkg.name)
     for (const entry of pkg.addable) {
-      if (!entry.ok || carried.has(entry.moduleName)) continue
+      if (carried.has(entry.moduleName)) continue
       items.push({
         id: JSON.stringify({ packageName: pkg.name, declaredName: entry.declaredName } satisfies AddChoice),
         label: entry.title ?? (entry.declaredName === '.' ? title : `${title} · ${entry.declaredName}`),

+ 12 - 8
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -36,6 +36,7 @@ export const zh = {
   statusWaiting: '等待依赖',
   statusProblem: '异常',
   reasonLabel: '原因',
+  affectedIssuesLabel: '受影响组件的问题',
   versionLabel: '版本',
   sourceLabel: '来源',
   sourceBuiltin: '内置',
@@ -48,6 +49,9 @@ export const zh = {
   partsCountWaiting: '{count} 等待依赖',
   partsCountOff: '{count} 已停用',
   partsCountFailed: '{count} 异常',
+  partsCountUpdateFailed: '{count} 更新失败',
+  rowUpdateFailed: '运行中,但最近更新失败',
+  unknownPackage: '未识别到可添加的插件入口。',
   partsFilter: '筛选组件',
   partsFilterEmpty: '没有匹配的组件。',
   partDisabledByUser: '已停用',
@@ -70,14 +74,12 @@ export const zh = {
   modulesLabel: '可加入的模块',
   moduleJoined: '已加入:{targets}',
   moduleNotJoined: '尚未加入',
-  moduleBroken: '无法导入:{error}',
   joinedGlobal: '所有会话',
   joinedSeparator: '、',
   addToGlobal: '所有会话',
   addToGlobalAdded: '所有会话(已加入)',
   addToPreset: '预设:{name}',
   addToPresetAdded: '预设:{name}(已加入)',
-  addableNotOk: '无法导入',
   retryPackage: '重试',
   uninstall: '卸载',
   uninstallLabel: '卸载 {name}',
@@ -116,7 +118,7 @@ export const zh = {
   installDonePlugin: '已安装 {name},可以加入到某个预设。',
   installDoneOther: '已安装 {name}。',
   installDoneNothing: '安装完成,没有新增依赖。',
-  installRemovedLibrary: '{name} 不是 DSH 插件,已自动移除。',
+  installRemovedInvalid: '{name} 的插件包声明无效,已自动移除:{reason}',
   installRemovedConflict: '{name} 与已安装的插件包冲突,已自动移除:{reason}',
   installFailed: '安装失败。',
   busy: '另一个改动还在进行,等它结束再试:{reason}',
@@ -140,7 +142,7 @@ export const zh = {
   confirmDisableRow: '关闭',
   actionFailed: '操作失败:{reason}',
   notEnableable: '无法启用:{reason}',
-  enableFailed: '启用失败,已恢复停用:{reason}',
+  enableFailed: '无法应用插件包:{reason}',
   rowConflict: '已经有同名的插件行:{row}。',
   notInstalled: '{name} 没有安装在当前 profile。',
   'name.persona': '人设',
@@ -250,6 +252,7 @@ export const en = {
   statusWaiting: 'Waiting for a dependency',
   statusProblem: 'Problem',
   reasonLabel: 'Reason',
+  affectedIssuesLabel: 'Issues in affected components',
   versionLabel: 'Version',
   sourceLabel: 'Source',
   sourceBuiltin: 'Built-in',
@@ -262,6 +265,9 @@ export const en = {
   partsCountWaiting: '{count} waiting',
   partsCountOff: '{count} off',
   partsCountFailed: '{count} failed',
+  partsCountUpdateFailed: '{count} update failed',
+  rowUpdateFailed: 'Running; latest update failed',
+  unknownPackage: 'No addable plugin entry was declared.',
   partsFilter: 'Filter components',
   partsFilterEmpty: 'No component matches.',
   partDisabledByUser: 'Off',
@@ -284,14 +290,12 @@ export const en = {
   modulesLabel: 'Modules to add',
   moduleJoined: 'Added to: {targets}',
   moduleNotJoined: 'Not added yet',
-  moduleBroken: 'Cannot be imported: {error}',
   joinedGlobal: 'Every session',
   joinedSeparator: ', ',
   addToGlobal: 'All sessions',
   addToGlobalAdded: 'All sessions (added)',
   addToPreset: 'Preset: {name}',
   addToPresetAdded: 'Preset: {name} (added)',
-  addableNotOk: 'Cannot be imported',
   retryPackage: 'Retry',
   uninstall: 'Uninstall',
   uninstallLabel: 'Uninstall {name}',
@@ -330,7 +334,7 @@ export const en = {
   installDonePlugin: 'Installed {name}; it can join a preset.',
   installDoneOther: 'Installed {name}.',
   installDoneNothing: 'Install finished with no new dependency.',
-  installRemovedLibrary: '{name} is not a DSH plugin and was removed again.',
+  installRemovedInvalid: '{name} has invalid plugin pack declarations and was removed: {reason}',
   installRemovedConflict: '{name} conflicts with an installed plugin pack and was removed again: {reason}',
   installFailed: 'The install failed.',
   busy: 'Another change is still running; try again when it finishes: {reason}',
@@ -354,7 +358,7 @@ export const en = {
   confirmDisableRow: 'Switch off',
   actionFailed: 'The action failed: {reason}',
   notEnableable: 'Cannot be enabled: {reason}',
-  enableFailed: 'Enabling failed and the plugin pack stays off: {reason}',
+  enableFailed: 'Could not apply this plugin pack: {reason}',
   rowConflict: 'A plugin row with this name already exists: {row}.',
   notInstalled: '{name} is not installed in the current profile.',
   'name.persona': 'Persona',

+ 5 - 1
packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 import { Context, Service } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup } from '@testing-library/react'
+import { cleanup, render } from '@testing-library/react'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -69,6 +69,10 @@ describe('ui-plugin-manager browser plugin', () => {
     // The sidebar entry addresses the page by the same id and speaks the dictionary.
     const icon = b.slots.entries('sidebar.panellist')[0]!
     expect(icon.component).toBe(PluginsPanelIcon)
+    const unread = () => { throw new Error('The sidebar icon must not read application state') }
+    const glyph = render(<PluginsPanelIcon size={18} active={false}
+      usePanelInfo={unread} useSessions={unread} useSessionPendingInteraction={unread} useWorkspaces={unread} useResource={unread} />)
+    expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18')
     expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 })
     expect(icon.locale).toBe(NS)
     expect(resolveSlotLabel(icon.options.label)).toBe('插件')

+ 50 - 19
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -99,6 +99,37 @@ describe('locale dictionaries', () => {
 })
 
 describe('PluginManagerPage', () => {
+  it('keeps a non-dependency override failure visible even when owned rows are waiting', () => {
+    renderTab({ packages: [pkg({
+      name: 'overrides', title: 'Overrides', enabled: true, status: 'partial',
+      rows: [{ entryId: 'wait', rowId: 'wait', moduleName: 'overrides', enabled: true, phase: 'pending' }],
+      issues: [{ entryId: 'include:core', moduleName: 'core', stage: 'update', message: 'Rejected config' }],
+    })] })
+    expect(screen.getByText(en.statusProblem)).toBeTruthy()
+    expect(screen.queryByText(en.statusWaiting)).toBeNull()
+  })
+
+  it('shows an active old instance with its failed update and identifies affected external rows', () => {
+    const { set } = renderTab({ packages: [pkg({
+      name: 'updates', title: 'Updates', enabled: true, status: 'partial',
+      rows: [{ entryId: 'include:own', rowId: 'own', moduleName: 'updates', enabled: true, phase: 'active', failure: { stage: 'update', message: 'Invalid updated config' } }],
+      issues: [
+        { entryId: 'include:own', moduleName: 'updates', stage: 'update', message: 'Invalid updated config' },
+        { entryId: 'include:webserver', moduleName: 'webserver', stage: 'update', message: 'Invalid port' },
+      ],
+    })] })
+    fireEvent.click(screen.getByRole('button', { name: 'View Updates' }))
+    expect(screen.getByText(en.rowUpdateFailed)).toBeTruthy()
+    expect(screen.getByText('Invalid updated config')).toBeTruthy()
+    expect(screen.getByText('include:webserver')).toBeTruthy()
+    expect(screen.getByText('Invalid port')).toBeTruthy()
+    expect(screen.getByText(en.partsCountRunning.replace('{count}', '1'), { exact: false })).toBeTruthy()
+    expect(screen.getByText(en.partsCountUpdateFailed.replace('{count}', '1'), { exact: false })).toBeTruthy()
+    set({ packages: [pkg({ name: 'updates', title: 'Updates', kind: 'unknown', status: 'plain', rows: [] })] })
+    expect(screen.getByText(en.unknownPackage)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.uninstallLabel.replace('{name}', 'Updates') })).toBeTruthy()
+  })
+
   it('asks the store once mounted and renders the loading, unavailable, error, and empty states', () => {
     const { actions, set } = renderTab({ status: 'loading' })
     expect(actions.ensure).toHaveBeenCalledTimes(1)
@@ -131,10 +162,10 @@ describe('PluginManagerPage', () => {
         pkg({ description: 'A sidebar.' }),
         firstPartyPackage,
         pkg({ name: 'unknown', trust: 'builtin', kind: 'plugin', status: 'plain' }),
-        pkg({ name: 'builtin-lib', trust: 'builtin', kind: 'library', status: 'plain' }),
+        pkg({ name: 'builtin-lib', trust: 'builtin', kind: 'unknown', status: 'plain' }),
         pkg({ name: 'broken-bundle', enabled: false, status: 'not-enableable', reason: 'foreign cordis' }),
         pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain' }),
-        pkg({ name: 'some-lib', kind: 'library', status: 'plain' }),
+        pkg({ name: 'some-lib', kind: 'unknown', status: 'plain' }),
         pkg({ name: 'pending-bundle', title: 'Pending', enabled: true, status: 'restart-required' }),
         pkg({ name: 'dsh-untitled', enabled: false, status: 'restart-required', installed: false }),
         pkg({ name: 'off-bundle', enabled: false, status: 'disabled' }),
@@ -358,13 +389,13 @@ describe('PluginManagerPage', () => {
         pkg({
           name: 'dsh-tool-foo', kind: 'plugin', status: 'plain', title: 'Foo tools', reason: 'one module refused',
           addable: [
-            { moduleName: 'dsh-tool-foo', declaredName: '.', ok: true },
-            { moduleName: 'dsh-tool-foo/bar', declaredName: 'bar', title: 'Bar', ok: true },
-            { moduleName: 'dsh-tool-foo/broken', declaredName: 'broken', ok: false, error: 'no default export' },
-            { moduleName: 'dsh-tool-foo/silent', declaredName: 'silent', ok: false },
+            { moduleName: 'dsh-tool-foo', declaredName: '.' },
+            { moduleName: 'dsh-tool-foo/bar', declaredName: 'bar', title: 'Bar' },
+            { moduleName: 'dsh-tool-foo/broken', declaredName: 'broken' },
+            { moduleName: 'dsh-tool-foo/silent', declaredName: 'silent' },
           ],
         }),
-        pkg({ name: 'dsh-tool-bare', kind: 'plugin', status: 'plain', addable: [{ moduleName: 'dsh-tool-bare', declaredName: '.', ok: true }] }),
+        pkg({ name: 'dsh-tool-bare', kind: 'plugin', status: 'plain', addable: [{ moduleName: 'dsh-tool-bare', declaredName: '.' }] }),
       ],
       presets: [
         preset({ rows: [{ entryId: 'preset:foo', moduleName: 'dsh-tool-foo', source: 'user', enabled: true, fiberPhase: null }] }),
@@ -380,12 +411,12 @@ describe('PluginManagerPage', () => {
     const moduleText = (name: string): string | undefined => document.querySelector(`[data-plugin-module="${name}"]`)?.textContent
     expect(moduleText('dsh-tool-foo')).toBe(`Foo toolsdsh-tool-foo${en.moduleJoined.replace('{targets}', `${en.joinedGlobal}, 标准`)}${en.addTo}`)
     expect(moduleText('dsh-tool-foo/bar')).toBe(`Bardsh-tool-foo/bar${en.moduleNotJoined}${en.addTo}`)
-    expect(document.querySelector('[data-plugin-module="dsh-tool-foo/broken"]')?.getAttribute('data-state')).toBe('failed')
-    expect(moduleText('dsh-tool-foo/broken')).toContain(en.moduleBroken.replace('{error}', 'no default export'))
-    expect(moduleText('dsh-tool-foo/silent')).toContain(en.moduleBroken.replace('{error}', ''))
-    // Only an importable module offers Add to…; the menu marks the targets it joined.
+    expect(document.querySelector('[data-plugin-module="dsh-tool-foo/broken"]')?.getAttribute('data-state')).toBeNull()
+    expect(moduleText('dsh-tool-foo/broken')).toContain(en.moduleNotJoined)
+    expect(moduleText('dsh-tool-foo/silent')).toContain(en.moduleNotJoined)
+    // Every declared module offers Add to…; targets already joined stay marked.
     const menus = screen.getAllByRole('button', { name: en.addTo })
-    expect(menus).toHaveLength(2)
+    expect(menus).toHaveLength(4)
     fireEvent.click(menus[0] as HTMLElement)
     expect(screen.getByRole('menuitem', { name: en.addToGlobalAdded })).toBeTruthy()
     expect(screen.getByRole('menuitem', { name: en.addToPresetAdded.replace('{name}', '标准') })).toBeTruthy()
@@ -410,15 +441,15 @@ describe('PluginManagerPage', () => {
   it('offers Add to… for a plugin with importable modules, marking the targets it already joined', () => {
     const { actions } = renderTab({
       packages: [
-        pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain', addable: [{ moduleName: 'dsh-tool-foo', declaredName: '.', ok: true }] }),
+        pkg({ name: 'dsh-tool-foo', kind: 'plugin', status: 'plain', addable: [{ moduleName: 'dsh-tool-foo', declaredName: '.' }] }),
         pkg({
           name: 'multi',
           kind: 'plugin',
           status: 'plain',
           addable: [
-            { moduleName: 'multi/a', declaredName: './a', title: 'A tool', ok: true },
-            { moduleName: 'multi/b', declaredName: './b', ok: true },
-            { moduleName: 'multi/c', declaredName: './c', ok: false, error: 'cannot import' },
+            { moduleName: 'multi/a', declaredName: './a', title: 'A tool' },
+            { moduleName: 'multi/b', declaredName: './b' },
+            { moduleName: 'multi/c', declaredName: './c' },
           ],
         }),
         pkg({ name: 'unloadable', kind: 'plugin', status: 'plain', reason: 'the package failed to import: x' }),
@@ -452,7 +483,7 @@ describe('PluginManagerPage', () => {
     fireEvent.click(multi!)
     expect(screen.getByText('A tool')).toBeTruthy()
     expect(screen.getByText('./b')).toBeTruthy()
-    expect(screen.queryByText('./c')).toBeNull()
+    expect(screen.getByText('./c')).toBeTruthy()
   })
 
   it('drives the install dialog through its phases and words each outcome', () => {
@@ -509,7 +540,7 @@ describe('PluginManagerPage', () => {
         installedOnly: ['b'],
         plain: ['c'],
         removed: [
-          { name: 'lodash', reason: 'declares neither a dsh bundle nor a plugin module' },
+          { name: 'invalid-bundle', reason: 'invalid stage' },
           { name: 'clash', reason: 'row "x" is already declared by y' },
         ],
       },
@@ -518,7 +549,7 @@ describe('PluginManagerPage', () => {
     expect(screen.getByText(en.installDoneBundle.replace('{name}', 'b'))).toBeTruthy()
     expect(screen.getByText(en.installDonePlugin.replace('{name}', 'c'))).toBeTruthy()
     expect(screen.getByText(en.installDoneOther.replace('{name}', 'd'))).toBeTruthy()
-    expect(screen.getByText(en.installRemovedLibrary.replace('{name}', 'lodash'))).toBeTruthy()
+    expect(screen.getByText(en.installRemovedInvalid.replace('{name}', 'invalid-bundle').replace('{reason}', 'invalid stage'))).toBeTruthy()
     expect(screen.getByText(en.installRemovedConflict.replace('{name}', 'clash').replace('{reason}', 'row "x" is already declared by y'))).toBeTruthy()
     // A finished run leaves Done as the only action.
     expect(screen.getByRole('button', { name: en.installClose })).toBeTruthy()

+ 8 - 7
packages/client/ui-plugin-manager/tests/preset-section.client.spec.tsx

@@ -37,7 +37,7 @@ function pkg(overrides: Partial<PluginPackageView> = {}): PluginPackageView {
     cordisSameCopy: true,
     rows: [],
     overrides: [],
-    addable: [{ moduleName: 'dsh-tool-foo', declaredName: '.', ok: true }],
+    addable: [{ moduleName: 'dsh-tool-foo', declaredName: '.' }],
     liveReload: true,
     ...overrides,
   }
@@ -120,7 +120,7 @@ describe('PresetPluginsSection', () => {
       packages: [
         pkg({ title: 'Foo tool', description: 'A foo.' }),
         pkg({ name: '@fixture/extra', addable: [] }),
-        pkg({ name: 'multi', addable: [{ moduleName: 'multi/a', declaredName: './a', title: 'A tool', ok: true }] }),
+        pkg({ name: 'multi', addable: [{ moduleName: 'multi/a', declaredName: './a', title: 'A tool' }] }),
       ],
       presets: [preset({ rows })],
     })
@@ -167,9 +167,9 @@ describe('PresetPluginsSection', () => {
           name: 'multi',
           title: 'Multi',
           addable: [
-            { moduleName: 'multi/a', declaredName: './a', title: 'A tool', ok: true },
-            { moduleName: 'multi/b', declaredName: './b', ok: true },
-            { moduleName: 'multi/c', declaredName: './c', ok: false, error: 'cannot import' },
+            { moduleName: 'multi/a', declaredName: './a', title: 'A tool' },
+            { moduleName: 'multi/b', declaredName: './b' },
+            { moduleName: 'multi/c', declaredName: './c' },
           ],
         }),
       ],
@@ -177,11 +177,11 @@ describe('PresetPluginsSection', () => {
     })
     const add = screen.getByRole('button', { name: 'Add a capability to 标准' })
     fireEvent.click(add)
-    // The module the preset carries and the one that cannot import are left out.
+    // Declared modules remain addable until this preset already carries them.
     expect(screen.getByRole('menuitem', { name: 'Foo tool' })).toBeTruthy()
     expect(screen.getByRole('menuitem', { name: 'Multi · ./b' })).toBeTruthy()
     expect(screen.queryByRole('menuitem', { name: 'A tool' })).toBeNull()
-    expect(screen.getAllByRole('menuitem')).toHaveLength(2)
+    expect(screen.getAllByRole('menuitem')).toHaveLength(3)
     fireEvent.click(screen.getByRole('menuitem', { name: 'Multi · ./b' }))
     expect(actions.addRow).toHaveBeenCalledWith('multi', './b', TARGET)
     expect(screen.queryByRole('menuitem')).toBeNull()
@@ -190,6 +190,7 @@ describe('PresetPluginsSection', () => {
     set({ presets: [preset({ rows: [
       { entryId: 'sub', moduleName: 'multi/a', enabled: true, fiberPhase: null, source: 'user' },
       { entryId: 'b', moduleName: 'multi/b', enabled: true, fiberPhase: null, source: 'user' },
+      { entryId: 'c', moduleName: 'multi/c', enabled: true, fiberPhase: null, source: 'user' },
       { entryId: 'foo', moduleName: 'dsh-tool-foo', enabled: true, fiberPhase: null, source: 'user' },
     ] })] })
     fireEvent.click(add)

+ 2 - 1
packages/client/ui-settings-general/tests/apply.client.spec.ts

@@ -39,6 +39,7 @@ const SEATS = [
 function localeView(preference: string, revision = 0): SettingsNamespaceView {
   return {
     ns: LOCALE_SETTINGS_NAMESPACE,
+    registered: true,
     // The Remote wire serializes nested Schema values before the client rehydrates them.
     schema: JSON.parse(JSON.stringify(LocaleSettingsSchema.toJSON())) as SettingsNamespaceView['schema'],
     value: { preference },
@@ -50,7 +51,7 @@ function localeView(preference: string, revision = 0): SettingsNamespaceView {
 
 async function client(mock: RemoteMock, start: () => Promise<TestClient>, hasDocument = false) {
   const settings = mock.remote.settings
-  settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')] }))
+  settings.describe.mockResolvedValue(ok({ writable: true, hasDocument, namespaces: [localeView('zh')], scopes: [] }))
   const c = await start()
   // The locale adopts the Host preference once the describe mirror holds the document.
   await c.ctx.settingsScope.describe().ensure()

+ 4 - 3
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -262,13 +262,14 @@ describe('pushed invalidations', () => {
     const mock = RemoteMock.create().load(remoteDefaultResponses)
     const namespace = {
       ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+      registered: true,
       schema: {},
       value: {},
       applies: 'live' as const,
       secrets: [],
       revision: 0,
     }
-    const document = { writable: true, hasDocument: false, namespaces: [namespace] }
+    const document = { writable: true, hasDocument: false, namespaces: [namespace], scopes: [] }
     mock.remote.settings.describe.mockResolvedValue(ok(document))
     const b = await bench(true, mock)
     declare(b.slots)
@@ -295,8 +296,8 @@ describe('pushed invalidations', () => {
 
   it('joins the refreshed mirror view on a settings invalidation', async () => {
     const mock = RemoteMock.create().load(remoteDefaultResponses)
-    const namespace = { ns: 'llm-test', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 }
-    const document = { writable: true, hasDocument: false, namespaces: [namespace] }
+    const namespace = { ns: 'llm-test', registered: true, schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 1 }
+    const document = { writable: true, hasDocument: false, namespaces: [namespace], scopes: [] }
     const describe = mock.remote.settings.describe
     describe.mockResolvedValue(ok(document))
     const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] }))

+ 37 - 12
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1425,13 +1425,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: '@Remote(\'add\') async add(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>',
-        description: 'Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.',
+        description: 'Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.',
         parameters: [{ name: 'spec', description: 'what to install, in pnpm\'s own vocabulary.' }, { name: 'options', description: '`enable` puts every newly installed bundle into the layer list at once.' }],
         returns: 'what the run installed and enabled.',
       },
       {
         signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
-        description: 'Remove a package from the profile with its user-layer rows and probe record.',
+        description: 'Remove a package from the profile with its user-layer rows and any obsolete discovery cache.',
         parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
       },
       {
@@ -1487,6 +1487,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'rowId', description: 'the row\'s id as the composition declares it.' }],
         returns: 'the origin, or undefined for a row no bundle layer owns (a user or overlay row, or a bundle left out by a conflict).',
       },
+      {
+        signature: 'originOfEntry(entry: Entry): RowOrigin | undefined',
+        description: 'Resolve provenance within its Loader tree; nested includes inherit their owning entry.',
+        parameters: [{ name: 'entry', description: 'the live entry, including an entry inside another Include.' }],
+        returns: 'its supplying bundle, or undefined for a user-owned entry.',
+      },
       {
         signature: 'userDisabledRowIds(): ReadonlySet<string>',
         description: 'Row ids the user patch layers disable with a literal `disabled: true`, as the committed composition read them. The set describes the running tree: a user file the include rejected, or one that cannot be parsed, changes nothing here until a composition with it is accepted.',
@@ -1500,10 +1506,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'true when the user\'s patches disable the entry or one of the groups holding it.',
       },
       {
-        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. The candidate profile, its ownership, and its conflicts become the committed composition only once the update holds; until then, and after a rejection, `current`, `layers`, `originOf`, and `conflicts` keep describing the running tree. Calls queue: one that arrives while another is in flight starts after it settled and reads what it committed. A rejection is that call\'s outcome alone and does not stop the ones behind it.',
-        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.'],
+        signature: 'async recompose(options: { reloadBundles?: boolean } = {}): Promise<readonly EntryIssue[]>',
+        description: 'Apply a fresh profile stack and wait for live entries and removed fibers to settle. Parse/composition failures leave the applied stack unchanged. Accepted options can coexist with failed entries or fibers running their previous valid config. Calls serialize; a failed call does not block later changes.',
+        parameters: [{ name: 'options', description: 'whether to reread installed bundle layers from disk.' }],
+        returns: 'current entry issues after application, without rolling back successful siblings.',
+        throws: ['when preparation fails or the root Include cannot accept the update.'],
+      },
+      {
+        signature: 'whenIdle(): Promise<void>',
+        description: 'Wait for recompositions already queued when called, including removed-fiber cleanup. Observers may read accepted composition facts afterwards; a failed operation still reports its error to its caller and does not reject this observation.',
+        parameters: [],
+        returns: 'after the current recomposition queue settles.',
       },
     ],
   },
@@ -3942,6 +3955,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'BrandedNumber',
     declaration: 'export type BrandedNumber<B extends string> = number & {\n    readonly [BRAND]: B;\n};',
   },
+  {
+    name: 'BundleStage',
+    declaration: 'export type BundleStage = \'boot\' | \'runtime\';',
+  },
   {
     name: 'BundleTrust',
     declaration: 'export type BundleTrust = \'builtin\' | \'external\';',
@@ -4342,6 +4359,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'EncodedImageAttachment',
     declaration: 'export interface EncodedImageAttachment {\n    mediaType: ImageMediaType;\n    data: string;\n    name?: string;\n}',
   },
+  {
+    name: 'EntryIssue',
+    declaration: 'export interface EntryIssue {\n    readonly entry: Entry;\n    readonly stage: \'import\' | \'activation\' | \'update\' | \'disabled-expression\' | \'inject-pending\';\n    readonly message: string;\n    readonly error?: unknown;\n}',
+  },
   {
     name: 'EpochHeader',
     declaration: 'export interface EpochHeader {\n    config: LlmCallConfig;\n    adapterDefaults?: LlmCallConfigAdapterDefaults;\n    tools?: ToolSchema[];\n}',
@@ -4900,7 +4921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PluginChangeReason',
-    declaration: 'export type PluginChangeReason = \'install\' | \'uninstall\' | \'enable\' | \'disable\' | \'retry\' | \'row\';',
+    declaration: 'export type PluginChangeReason = \'install\' | \'uninstall\' | \'enable\' | \'disable\' | \'retry\' | \'row\' | \'runtime\';',
   },
   {
     name: 'PluginDependents',
@@ -4908,7 +4929,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PluginEnableResult',
-    declaration: 'export interface PluginEnableResult {\n    readonly changed: boolean;\n    readonly effect: \'live\' | \'restart\';\n}',
+    declaration: 'export interface PluginEnableResult {\n    readonly changed: boolean;\n    readonly effect: \'live\' | \'restart\';\n    readonly issues?: readonly PluginRowIssue[];\n}',
   },
   {
     name: 'PluginInstallLogChunk',
@@ -4924,11 +4945,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PluginPackageAddableView',
-    declaration: 'export interface PluginPackageAddableView {\n    readonly moduleName: string;\n    readonly declaredName: string;\n    readonly title?: string;\n    readonly config?: JsonValue;\n    readonly ok: boolean;\n    readonly error?: string;\n    readonly configSchema?: JsonValue;\n}',
+    declaration: 'export interface PluginPackageAddableView {\n    readonly moduleName: string;\n    readonly declaredName: string;\n    readonly title?: string;\n    readonly config?: JsonValue;\n}',
   },
   {
     name: 'PluginPackageKind',
-    declaration: 'export type PluginPackageKind = \'bundle\' | \'plugin\' | \'library\';',
+    declaration: 'export type PluginPackageKind = \'bundle\' | \'plugin\' | \'unknown\';',
   },
   {
     name: 'PluginPackageRowView',
@@ -4948,12 +4969,16 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'PluginPackageView',
-    declaration: 'export interface PluginPackageView {\n    readonly name: string;\n    readonly version?: string;\n    readonly title?: string;\n    readonly description?: string;\n    readonly kind: PluginPackageKind;\n    readonly trust: PluginPackageTrust;\n    readonly stage: PluginPackageStage;\n    readonly installed: boolean;\n    readonly enabled: boolean;\n    readonly status: PluginPackageStatus;\n    readonly reason?: string;\n    readonly enginesDsh?: string;\n    readonly cordisSameCopy: boolean | null;\n    readonly rows: readonly PluginPackageRowView[];\n    readonly overrides: readonly string[];\n    readonly addable: readonly PluginPackageAddableView[];\n    readonly probedAt?: string;\n    readonly liveReload: boolean;\n}',
+    declaration: 'export interface PluginPackageView {\n    readonly name: string;\n    readonly version?: string;\n    readonly title?: string;\n    readonly description?: string;\n    readonly kind: PluginPackageKind;\n    readonly trust: PluginPackageTrust;\n    readonly stage: PluginPackageStage;\n    readonly installed: boolean;\n    readonly enabled: boolean;\n    readonly status: PluginPackageStatus;\n    readonly reason?: string;\n    readonly enginesDsh?: string;\n    readonly cordisSameCopy: boolean | null;\n    readonly rows: readonly PluginPackageRowView[];\n    readonly issues?: readonly PluginRowIssue[];\n    readonly overrides: readonly string[];\n    readonly addable: readonly PluginPackageAddableView[];\n    readonly liveReload: boolean;\n}',
   },
   {
     name: 'PluginRowAddition',
     declaration: 'export interface PluginRowAddition {\n    readonly target: PluginRowTarget;\n    readonly rowId: string;\n    readonly file: string;\n}',
   },
+  {
+    name: 'PluginRowIssue',
+    declaration: 'export interface PluginRowIssue {\n    readonly entryId: string;\n    readonly moduleName: string;\n    readonly stage: string;\n    readonly message: string;\n}',
+  },
   {
     name: 'PluginRowPhase',
     declaration: 'export type PluginRowPhase = \'pending\' | \'loading\' | \'active\' | \'failed\' | \'unloading\' | null;',
@@ -5172,7 +5197,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'RowOrigin',
-    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly packageName: string;\n    readonly version?: string;\n}',
+    declaration: 'export interface RowOrigin {\n    readonly trust: BundleTrust;\n    readonly stage: BundleStage;\n    readonly packageName: string;\n    readonly version?: string;\n}',
   },
   {
     name: 'RunnerFailureRule',

+ 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: 83bd0ff70842f47a8ca03c1e77b16285955417bb
-README.zh.md: 87ce6857f4c4b34bedef8b74ecdd4fee9a1c0ba7
+README.md: dd3d6d76abeddad88ca097f8988ecaa560e7e212
+README.zh.md: 34068a91c110f65331c6104e06e678c7f966c63d

+ 4 - 4
packages/host/plugin-inventory/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Clients can call `pluginInventory/list` to display the host’s current plugins in load order, including each entry’s identifier, module specifier, effective enablement, and live phase. Deployments with an agent-preset roster also report each preset’s metadata, health, and flattened plugin composition; without a roster, preset data is absent. Each response is a point-in-time, read-only snapshot for display and diagnostics: it cannot mutate plugins and provides no history, provenance, or change subscription.
+Clients call `pluginInventory/list` for a read-only snapshot of non-group Loader entries, their provenance, effective enablement, running phase, and current failures. Deployments with an agent-preset roster also report each preset’s metadata and composition. The snapshot carries no durable history or change subscription.
 
 ## Table of Contents
 
@@ -31,7 +31,7 @@ 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, `external` for a row of a bundle the user installed, and `user` for a row the user's own patch file inserted that the composition left out, `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, or a group holding 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.
+`trust` identifies the supplying bundle, `package` names it with its version, and `disabledBy` distinguishes a user override from a composition condition. Explicit row ids remain unchanged. Failed entries remain in the Loader tree; `failure` reports import, activation, update, disabled-expression, or missing-service issues. An `active` fiber may still have an update failure and run its previous valid config. Conflicting rows omitted before loading are projected from `ProfileRuntime.conflicts`. Without profile provenance, rows carry no package.
 
 ### Per-preset compositions
 
@@ -55,7 +55,7 @@ The gateway is a direct projection with no second lifecycle truth: every `list()
 
 ### The phase mapping
 
-Fiber states map onto the public phase vocabulary, with `disposed` folding into `null` — an entry whose fiber is gone has no live root to report. The phase therefore never distinguishes why no live root exists: the entry may never have started, or its fiber may already have been disposed.
+Fiber states map onto the public phase vocabulary, with `disposed` folding into `null`. An enabled entry with a recorded failure and no fiber reports `failed`; a normally disabled entry without a live fiber reports `null`.
 
 ### Source map
 
@@ -97,7 +97,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.
+- **Point-in-time state only** — the result contains no durable failure history or subscription; removing an entry removes its diagnostics.
 - **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.
 

+ 4 - 4
packages/host/plugin-inventory/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-客户端可以调用 `pluginInventory/list`,按加载顺序展示宿主的当前插件,包括每个条目的标识符、模块标识、有效启用状态与存活阶段。部署组合了 agent preset roster 时,还会报告各预设的元数据、健康状态与压平后的插件组合;没有 roster 时,预设数据缺席。每次响应都是供展示和诊断使用的只读即时快照:它不能修改插件,也不提供历史、来源信息或变更订阅。
+客户端调用 `pluginInventory/list`,读取非 group Loader 条目的只读快照,包括来源、有效启用状态、运行阶段和当前失败。包含 agent-preset 目录的部署还报告各预设的元信息与组合。快照不提供持久历史或变化订阅。
 
 ## 目录
 
@@ -31,7 +31,7 @@ kind: "package-reference"
 
 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。
 
-当树由 profile launcher 组合时,每一行还会说明是谁提供的:`trust` 对安装自带组合包的行是 `builtin`,对用户安装的组合包的行是 `external`,对用户自己的补丁文件插入却被组合排除的行是 `user`;`package` 给出该组合包的名称与版本,对外部行还给出组合包自己的 patch 在 launcher 加前缀之前声明的 id;停用的行带 `disabledBy`——用户 patch 文件用字面量 `disabled: true` 停用它、或停用了持有它的组的是 `user`,组合包自己的门或墓碑是 `composition`。被隔离的外部组合包启动失败的行已经不在树里;它仍从 launcher 的失败注册表列出,`fiberPhase` 为 `'failed'`,并带一个说明阶段与消息的 `failure`。没有 launcher 时每一行都读作 `builtin`,也没有 package 或 failure。
+`trust` 标识提供该行的组合包,`package` 给出包名与版本,`disabledBy` 区分用户覆盖与组合条件。显式行 id 保持不变。失败条目留在 Loader 树中;`failure` 报告导入、激活、更新、disabled 表达式或缺失服务问题。`active` fiber 也可能更新失败,并继续运行先前的有效配置。加载前因冲突被排除的行从 `ProfileRuntime.conflicts` 投影。没有 profile 来源时,行不携带 package。
 
 ### 每个预设的组合
 
@@ -55,7 +55,7 @@ kind: "package-reference"
 
 ### 阶段映射
 
-Fiber 状态映射到公共阶段词汇,其中 `disposed` 折叠为 `null`——fiber 已消失的条目没有可报告的存活根。因此阶段从不区分为什么没有存活根:条目可能从未启动,也可能其 fiber 已被释放。
+Fiber 状态映射为公开阶段,其中 `disposed` 映射为 `null`。启用但没有 fiber 且存在失败记录的条目报告 `failed`;正常禁用且没有活跃 fiber 的条目报告 `null`。
 
 ### 源码地图
 
@@ -97,7 +97,7 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产
 
 这些限制说明即时清单无法向客户端提供哪些信息。它们是当前包约束,不是任务积压。
 
-- **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。
+- **仅提供当前状态**——结果不包含持久失败历史或订阅;移除条目也会移除其诊断。
 - **无修改能力**——服务不能在任一平面启用、停用、添加或移除插件;来源只追溯到组合包层,用户 patch 或 `--patch` overlay 插入的行不带 package。
 - **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。
 

+ 7 - 25
packages/host/plugin-inventory/src/index.ts

@@ -4,9 +4,8 @@ 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'
+// Profile provenance and live entry diagnostics share the boot implementation.
+import { inspectEntryIssues } 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'
@@ -78,42 +77,25 @@ export class PluginInventoryGateway extends TypertRemoteService {
   async list(): Promise<PluginInventorySnapshot> {
     const entries: PluginInventoryEntry[] = []
     const runtime = this.ctx.get('profileRuntime')
-    const failures = this.ctx.get('pluginFailures')
-    const listed = new Set<string>()
+    const failures = new Map((await inspectEntryIssues(this.ctx)).map(issue => [issue.entry, issue]))
     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)
+      const origin = runtime?.originOfEntry(entry)
+      const failure = failures.get(entry)
+      const enabled = failure?.stage === 'disabled-expression' || !entry.disabled
       entries.push({
         entryId: pluginEntryId(entry.id),
         moduleName: entry.options.name,
         enabled,
-        fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
+        fiberPhase: entry.fiber === undefined ? (failure === undefined ? null : 'failed') : FIBER_PHASE[entry.fiber.state],
         trust: origin?.trust ?? 'builtin',
         ...origin === undefined ? {} : { package: packageRef(origin) },
         ...enabled ? {} : { disabledBy: runtime?.userDisables(entry) === true ? '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 },
-      })
-    }
     // A row the composition left out never reached the tree; the conflict
     // names the layer that lost, so no lookup of the id's owner is needed.
     if (runtime !== undefined) {

+ 3 - 3
packages/host/plugin-inventory/src/types.ts

@@ -29,10 +29,10 @@ export interface PluginPackageRef {
   readonly version?: string
 }
 
-/** A recorded failure of a row inside an isolated external bundle, or a row the composition left out. */
+/** A current entry failure or unresolved dependency, or a row the composition left out. */
 export interface PluginFailure {
   /** The lifecycle step that failed; `conflict` is a row another layer already declares. */
-  readonly stage: 'import' | 'apply' | 'inject-pending' | 'conflict' | 'unknown'
+  readonly stage: 'import' | 'activation' | 'update' | 'disabled-expression' | 'inject-pending' | 'conflict'
   /** The failure text. */
   readonly message: string
 }
@@ -51,7 +51,7 @@ export interface PluginInventoryEntry {
   readonly package?: PluginPackageRef
   /** Present exactly when `enabled` is false. */
   readonly disabledBy?: PluginDisabledBy
-  /** Present for a row an isolated bundle failed to start, or a row the composition left out. */
+  /** Present for a failed attempt or unresolved dependency, including updates that leave an old fiber active. */
   readonly failure?: PluginFailure
 }
 

+ 15 - 15
packages/host/plugin-inventory/tests/inventory.spec.ts

@@ -3,7 +3,7 @@ import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
 import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
 import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
-import { ensurePluginFailures, ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
+import { ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
 import PluginInventoryGateway from '../src/index.ts'
 
 const contexts: Context[] = []
@@ -72,6 +72,7 @@ describe('PluginInventoryGateway', () => {
         enabled: true,
         fiberPhase: 'pending',
         trust: 'builtin',
+        failure: { stage: 'inject-pending', message: 'pending (waiting for service: neverReady)' },
       },
       {
         entryId: disabledId,
@@ -104,13 +105,13 @@ describe('PluginInventoryGateway', () => {
     const bare = await ctx.loader.create({ name: 'cordis:active' })
     const off = await ctx.loader.create({ name: 'cordis:active', disabled: true })
     const origins = new Map<string, RowOrigin>([
-      [versioned, { trust: 'external', packageName: 'ext', version: '1.2.3' }],
-      [bare, { trust: 'external', packageName: 'ext' }],
-      [off, { trust: 'external', packageName: 'ext' }],
-      ['gone', { trust: 'external', packageName: 'ext' }],
+      [versioned, { trust: 'external', stage: 'runtime', packageName: 'ext', version: '1.2.3' }],
+      [bare, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
+      [off, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
+      ['gone', { trust: 'external', stage: 'runtime', packageName: 'ext' }],
     ])
     ctx.provide('profileRuntime', {
-      originOf: (rowId: string) => origins.get(rowId),
+      originOfEntry: (entry: { id: string }) => origins.get(entry.id),
       userDisables: (entry: { id: string }) => entry.id === off,
       layers: [{ packageName: 'late', version: '9.9.9' }],
       conflicts: [
@@ -119,12 +120,11 @@ describe('PluginInventoryGateway', () => {
         { rowId: 'x', moduleName: 'gone/x', layer: 'gone', packageName: 'gone', declaredBy: 'gone', message: 'row "x" is declared twice by gone' },
       ],
     } as unknown as ProfileRuntime)
-    const registry = ensurePluginFailures(ctx)
-    registry.record({ entryId: 'gone', rowId: 'gone', moduleName: 'cordis:throws', groupId: 'bundle/ext', stage: 'apply', message: 'boom' })
-    // A record of a row that later mounted rides on the live entry and is not listed twice.
-    registry.record({ entryId: bare, rowId: bare, moduleName: 'cordis:active', groupId: 'bundle/ext', stage: 'import', message: 'stale' })
-    // A failure the runtime cannot attribute belongs to no package.
-    registry.record({ entryId: 'orphan', rowId: 'orphan', moduleName: 'cordis:throws', groupId: 'bundle/gone', stage: 'apply', message: 'lost' })
+    await ctx.loader.create({ id: 'gone', name: './dsh-missing-fixture.mjs' } as Parameters<typeof ctx.loader.create>[0])
+    await ctx.loader.create({ id: 'orphan', name: './dsh-missing-fixture.mjs' } as Parameters<typeof ctx.loader.create>[0])
+    ctx.loader.resolve('gone').lastFailure = { stage: 'import', error: 'boom' }
+    ctx.loader.resolve(bare).lastFailure = { stage: 'update', error: 'invalid config' }
+    ctx.loader.resolve('orphan').lastFailure = { stage: 'import', error: 'lost' }
 
     const { entries } = await inventory.list()
     // Compared in id order: the Loader assigns ids, and one that comes out all
@@ -132,11 +132,11 @@ describe('PluginInventoryGateway', () => {
     const byId = (left: { entryId: string }, right: { entryId: string }): number => left.entryId.localeCompare(right.entryId)
     expect([...entries].sort(byId)).toEqual([
       { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext', version: '1.2.3' } },
-      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'stale' } },
+      { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'update', message: 'invalid config' } },
       { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' },
       // A failed row the tree no longer holds is attributed through the runtime.
-      { entryId: 'gone', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'apply', message: 'boom' } },
-      { entryId: 'orphan', moduleName: 'cordis:throws', enabled: true, fiberPhase: 'failed', trust: 'external', failure: { stage: 'apply', message: 'lost' } },
+      { entryId: 'gone', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'failed to import: boom' } },
+      { entryId: 'orphan', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'builtin', failure: { stage: 'import', message: 'failed to import: lost' } },
       // A row the composition left out is listed from the runtime's conflicts, under the layer that lost.
       {
         entryId: 'conflict:late:tool', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external',

+ 2 - 2
packages/host/plugin-manager/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/plugin-manager/README.md
-README.md: da884551d16ea64c1ac16e54a5400f757f28b3da
-README.zh.md: 45d33ae40f318df3ceb908841011766d25893828
+README.md: 94f4debb4c8ae061d417a9a9da0affa22e07621c
+README.zh.md: 5d4e7c997110ac588aa91821de47732f167f8b67

+ 1 - 2
packages/host/plugin-manager/README.md

@@ -41,7 +41,6 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
 |---|---|---|
 | `pnpmCommand` | `pnpm` | The executable, resolved through `PATH` like the `dsh plugin` command. |
 | `installTimeoutMs` | `600000` | Bound on one install or remove run. |
-| `probeTimeoutMs` | `20000` | Bound on one package probe. |
 | `installLogTailBytes` | `16384` | How much trailing output an install failure reports. |
 
 -----
@@ -54,7 +53,7 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
 
 ### A relay, not a second manager
 
-The constructor builds one `PluginManager` with readers into the context — `ctx.get('profileRuntime')`, `ctx.get('agentPresets')`, and the running-agent count from `ctx.get('agents')` — so what is composed is read when a call arrives, not when the row mounts. Every Remote method is `relay(() => this.manager.x(...))`: a `PluginOperationError` becomes the Remote error of its code through one exhaustive switch, and anything else is rethrown. Tests hand a manager in through `PluginManagerInternals.manager`; the spawn and probe seams pass through to the manager.
+The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It observes native entry/status events and publishes a coalesced `plugins/changed` notification after Loader and queued profile recomposition settle. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
 
 ### Source map
 

+ 1 - 2
packages/host/plugin-manager/README.zh.md

@@ -41,7 +41,6 @@ kind: "package-reference"
 |---|---|---|
 | `pnpmCommand` | `pnpm` | 可执行文件,与 `dsh plugin` 命令一样经 `PATH` 解析。 |
 | `installTimeoutMs` | `600000` | 单次安装或移除运行的上限。 |
-| `probeTimeoutMs` | `20000` | 单次包探测的上限。 |
 | `installLogTailBytes` | `16384` | 安装失败时报告多少尾部输出。 |
 
 -----
@@ -54,7 +53,7 @@ kind: "package-reference"
 
 ### 转接,不是第二个管理器
 
-构造器构造一个 `PluginManager`,交给它读向上下文的读取器——`ctx.get('profileRuntime')`、`ctx.get('agentPresets')`,以及从 `ctx.get('agents')` 数出的运行中 agent 数——因此组合里有什么在调用到达时读取,而不是在行挂载时。每个 Remote 方法都是 `relay(() => this.manager.x(...))`:`PluginOperationError` 经一个穷尽的 switch 变成其码对应的 Remote 错误,其他东西原样重抛。测试经 `PluginManagerInternals.manager` 交入管理器;spawn 与探针的测试缝透传给管理器。
+适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它观测原生条目与状态事件,在 Loader 和已排队的 profile 重组完成后合并发布 `plugins/changed` 通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
 
 ### 源码地图
 

+ 1 - 1
packages/host/plugin-manager/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-host-plugin-manager",
   "description": "The plugins Remote of the Web host: pluginManager as a Typert service that relays each call to the shared plugin manager and maps its failures onto Remote error codes",
-  "version": "0.1.5-rc.1",
+  "version": "0.1.5-rc.2",
   "publishConfig": {
     "access": "public"
   },

+ 23 - 9
packages/host/plugin-manager/src/index.ts

@@ -10,7 +10,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import type { probePackage } from '@deepseek-ai/dsh-app-boot'
+import type { readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import {
@@ -44,16 +44,14 @@ export interface Config {
   pnpmCommand: string
   /** Bound on one install or remove run, in milliseconds. */
   installTimeoutMs: number
-  /** Bound on one package probe, in milliseconds. */
-  probeTimeoutMs: number
   /** How many trailing bytes of an install run's output an install failure reports. */
   installLogTailBytes: number
 }
 
-/** Test seams: the child spawner, the package probe, or a manager standing in for the shared one. */
+/** Test seams: the child spawner, the static metadata reader, or a manager standing in for the shared one. */
 export interface PluginManagerInternals {
   spawn?: SpawnLike
-  probe?: typeof probePackage
+  metadata?: typeof readPackageMetadata
   /** The manager every call relays to; defaults to one over this context's profile runtime. */
   manager?: PluginManager
 }
@@ -73,7 +71,6 @@ export class PluginManagerRemote extends TypertRemoteService {
   static Config: z<Config> = z.object({
     pnpmCommand: z.string().default('pnpm'),
     installTimeoutMs: z.number().min(1_000).default(600_000),
-    probeTimeoutMs: z.number().min(1_000).default(20_000),
     installLogTailBytes: z.number().min(256).default(16_384),
   })
 
@@ -87,8 +84,25 @@ export class PluginManagerRemote extends TypertRemoteService {
       presets: () => ctx.get('agentPresets'),
       runningAgents: () => (ctx.get('agents')?.list() ?? []).filter(agent => agent.status === 'running').length,
       ...internals.spawn === undefined ? {} : { spawn: internals.spawn },
-      ...internals.probe === undefined ? {} : { probe: internals.probe },
+      ...internals.metadata === undefined ? {} : { metadata: internals.metadata },
     })
+    const loader = ctx.loader
+    let generation = 0
+    let disposed = false
+    ctx.effect(() => () => { disposed = true })
+    const refresh = (): void => {
+      const requested = ++generation
+      // Loader events precede asynchronous import/update completion; publish only after settlement.
+      void Promise.resolve().then(async () => {
+        await loader.await()
+        await ctx.get('profileRuntime')?.whenIdle()
+        if (!disposed && requested === generation) ctx.emit('plugins/changed', { reason: 'runtime' })
+      }).catch((error: unknown) => { ctx.logger.warn('plugin inventory refresh failed', error) })
+    }
+    ctx.on('internal/status', (fiber) => { if (fiber.entry !== undefined) refresh() }, { global: true })
+    ctx.on('loader/entry-init', refresh, { global: true })
+    ctx.on('loader/partial-dispose', refresh, { global: true })
+
   }
 
   /**
@@ -101,7 +115,7 @@ export class PluginManagerRemote extends TypertRemoteService {
   }
 
   /**
-   * Install a package with pnpm, probe it, and leave it disabled unless asked otherwise.
+   * Install a package with pnpm, read its declarations, and leave it disabled unless asked otherwise.
    * @param spec - what to install, in pnpm's own vocabulary.
    * @param options - `enable` puts every newly installed bundle into the layer list at once.
    * @returns what the run installed and enabled.
@@ -112,7 +126,7 @@ export class PluginManagerRemote extends TypertRemoteService {
   }
 
   /**
-   * Remove a package from the profile with its user-layer rows and probe record.
+   * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
    * @param packageName - the installed dependency to remove.
    */
   @Remote('uninstall')

+ 2 - 2
packages/host/plugin-manager/src/types.ts

@@ -16,9 +16,9 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
     'plugins/unavailable': PluginOperationDetailsMap['plugins/unavailable']
     /** The package is not a profile dependency. */
     'plugins/not-installed': PluginOperationDetailsMap['plugins/not-installed']
-    /** The package cannot be enabled or added, with the probe's reason. */
+    /** The package cannot be enabled or added, with the declaration or request error. */
     'plugins/not-enableable': PluginOperationDetailsMap['plugins/not-enableable']
-    /** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
+    /** Preparation or the root Include rejected enablement; the layer selection was reverted. */
     'plugins/enable-failed': PluginOperationDetailsMap['plugins/enable-failed']
     /** pnpm exited non-zero, could not be spawned, or timed out. */
     'plugins/install-failed': PluginOperationDetailsMap['plugins/install-failed']

+ 87 - 7
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -9,10 +9,10 @@
 import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import type { probePackage } from '@deepseek-ai/dsh-app-boot'
+import type { readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
 import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import {
   PluginOperationError, type PluginManager, type PluginOperationFailure, type SpawnLike,
@@ -21,7 +21,7 @@ import PluginManagerRemote, { remoteErrorOf, type Config } from '@deepseek-ai/ds
 import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
 
 /** A complete config: the schema fills defaults at load, the type does not. */
-const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
+const CONFIG: Config = { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, installLogTailBytes: 16_384 }
 
 const contexts: Context[] = []
 afterEach(async () => {
@@ -45,6 +45,86 @@ async function mount(manager?: PluginManager): Promise<PluginManagerRemote> {
 }
 
 describe('PluginManagerRemote', () => {
+  it('waits for queued profile recomposition before publishing refreshed ownership', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.good = () => {}
+    const id = await ctx.loader.create({ name: 'cordis:good' })
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const whenIdle = vi.fn(() => gate)
+    ctx.provide('profileRuntime', { whenIdle } as never)
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    ctx.emit('loader/entry-init', ctx.loader.resolve(id))
+    try {
+      await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
+      expect(changes).toEqual([])
+    } finally { release() }
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
+  })
+
+  it('reports a failed refresh and cancels publication when disposed during settlement', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.good = () => {}
+    const id = await ctx.loader.create({ name: 'cordis:good' })
+    const entry = ctx.loader.resolve(id)
+    const remote = ctx.plugin(PluginManagerRemote, CONFIG)
+    await remote
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    const settle = vi.spyOn(ctx.loader, 'await').mockRejectedValueOnce(new Error('settlement unavailable'))
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await vi.waitFor(() => { expect(warn).toHaveBeenCalled() })
+      let enter!: () => void
+      const entered = new Promise<void>((resolve) => { enter = resolve })
+      let release!: () => void
+      const gate = new Promise<void>((resolve) => { release = resolve })
+      settle.mockImplementationOnce(async () => { enter(); await gate })
+      ctx.emit('loader/entry-init', entry)
+      await entered
+      try {
+        await remote.dispose()
+      } finally { release() }
+      await Promise.resolve()
+      expect(changes).toEqual([])
+    } finally {
+      settle.mockRestore()
+      warn.mockRestore()
+    }
+  })
+
+  it('announces Loader dependency recovery and stops publishing after the Remote is disposed', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    const remote = ctx.plugin(PluginManagerRemote, CONFIG)
+    await remote
+    ctx.loader.builtins.waiting = { inject: ['lateService'], apply() {} }
+    const id = await ctx.loader.create({ name: 'cordis:waiting' })
+    await vi.waitFor(() => { expect(changes).toContain('runtime') })
+    expect(ctx.loader.resolve(id).fiber?.state).toBe(0)
+    changes.length = 0
+    ctx.provide('lateService', {})
+    await vi.waitFor(() => {
+      expect(ctx.loader.resolve(id).fiber?.state).toBe(2)
+      expect(changes).toContain('runtime')
+    })
+    await remote.dispose()
+    const count = changes.length
+    await ctx.loader.resolve(id).update({ disabled: true })
+    await ctx.loader.await()
+    expect(changes).toHaveLength(count)
+  })
+
   it('publishes the plugins namespace with one direct method per operation', async () => {
     const remote = await mount()
     expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
@@ -104,13 +184,13 @@ describe('PluginManagerRemote', () => {
     ctx.provide('profileRuntime', {
       dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
     } as never)
-    const probe: typeof probePackage = options => Promise.resolve({
-      packageName: options.packageName, kind: 'plugin', ok: true, cordisSameCopy: null, rows: [], overrides: [], addable: [], checkedAt: '',
+    const metadata: typeof readPackageMetadata = options => ({
+      packageName: options.packageName, kind: 'plugin', cordisSameCopy: null, rows: [], overrides: [], addable: [{ name: '.' }],
     })
     const spawn: SpawnLike = () => { throw new Error('this test spawns nothing') }
     class SeamedRemote extends PluginManagerRemote {
       constructor(context: Context, config: Config) {
-        super(context, config, { spawn, probe })
+        super(context, config, { spawn, metadata })
       }
     }
     await ctx.plugin(SeamedRemote, CONFIG)
@@ -122,7 +202,7 @@ describe('PluginManagerRemote', () => {
       // The agent-count reader: a running session refuses an install before pnpm runs.
       ctx.provide('agents', { list: () => [{ status: 'running' }, { status: 'idle' }] } as never)
       await expect(remote.add('anything')).rejects.toMatchObject({ code: 'plugins/agents-running', details: { operation: 'add', running: 1 } })
-      // The probe seam answers for the package, then the roster reader finds no roster for a preset target.
+      // The static reader answers for the package, then the roster reader finds no roster for a preset target.
       await expect(remote.addRow('pkg', { kind: 'preset', preset: 'standard' })).rejects.toMatchObject({ code: 'plugins/unavailable', details: { reason: 'no roster' } })
     } finally {
       rmSync(profileDir, { recursive: true, force: true })

+ 5 - 5
packages/test-support/remote-mock/tests/proxy.client.spec.ts

@@ -2,7 +2,7 @@
 import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import { RemoteMock, frames, ok, openStream, type StreamHandle } from '../src/index.ts'
 
-const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [] })
+const describeValue = (hasDocument: boolean) => ok({ writable: true, hasDocument, namespaces: [], scopes: [] })
 const baseline = { type: 'baseline' as const, value: { queues: {}, jobs: {}, projections: {} } }
 
 async function drain(source: AsyncIterable<unknown>): Promise<unknown[]> {
@@ -36,7 +36,7 @@ describe('RemoteMock.remote unary proxies', () => {
   it('consumes native one-shot answers in FIFO order across direct and carrier calls, then uses the default handler', async () => {
     const fallback = describeValue(false)
     const local = describeValue(true)
-    const dispatched = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } }
+    const dispatched = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [], scopes: [] } }
     const carrier = describeValue(true)
     const handler = vi.fn(() => fallback)
     const mock = RemoteMock.create().unary('settings/describe', handler)
@@ -58,7 +58,7 @@ describe('RemoteMock.remote unary proxies', () => {
   it('records every business argument while the carrier keeps its signal out of positional args', async () => {
     const mock = RemoteMock.create()
     const mutate = mock.remote.settings.mutate
-    const result = ok({ ns: 'locale', schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 })
+    const result = ok({ ns: 'locale', registered: true, schema: {}, value: { preference: 'en' }, applies: 'live' as const, secrets: [], revision: 8 })
     const ops = [{ op: 'set' as const, path: ['preference'], value: 'en' }]
     mutate.mockResolvedValue(result)
 
@@ -76,7 +76,7 @@ describe('RemoteMock.remote unary proxies', () => {
     const a = RemoteMock.create().load(table)
     const b = RemoteMock.create().load(table)
     const first = describeValue(true)
-    const second = { ...describeValue(true), value: { writable: false, hasDocument: true, namespaces: [] } }
+    const second = { ...describeValue(true), value: { writable: false, hasDocument: true, namespaces: [], scopes: [] } }
     a.remote.settings.describe.mockResolvedValueOnce(first)
     b.remote.settings.describe.mockResolvedValueOnce(second)
 
@@ -90,7 +90,7 @@ describe('RemoteMock.remote unary proxies', () => {
   it('keeps queued overrides on mockClear and restores the live default rule on mockReset', async () => {
     const original = describeValue(false)
     const updated = describeValue(true)
-    const override = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [] } }
+    const override = { ...describeValue(false), value: { writable: false, hasDocument: false, namespaces: [], scopes: [] } }
     const mock = RemoteMock.create().unary('settings/describe', original)
     const describe = mock.remote.settings.describe
     await describe()

+ 7 - 7
packages/util/package-manifest/src/types.ts

@@ -34,9 +34,9 @@ export interface DshManifest {
   profile?: DshProfileManifest
   /** Client module loading and build metadata. */
   client?: DshClientManifest
-  /** Display title of the package, read by the package probe. */
+  /** Display title of the package, read without executing package code. */
   title?: string
-  /** Agent-plane modules the package declares addable to a composition, read by the package probe. */
+  /** Agent-plane modules the package declares addable to a composition, read without executing package code. */
   plugins?: DshPluginDeclaration[]
 }
 
@@ -53,16 +53,16 @@ export interface DshEnginesManifest {
 }
 
 /**
- * How an external bundle joins the tree. `runtime` (the default) mounts its
- * rows in a contained group: a failing row is isolated and recorded, and a
- * row id another layer owns leaves the bundle out. `boot` mounts them like
- * built-in rows: they claim ids first, and a failure stops the process.
+ * Startup policy for an external bundle. `runtime` (the default) permits
+ * failed rows while retaining successful siblings; conflicting row ids omit
+ * the whole bundle. `boot` rows are required and claim ids with built-in layers.
+ * This field does not delay execution until after startup.
  */
 export type BundleStage = 'boot' | 'runtime'
 
 /** One agent-plane module a package declares addable to a composition. */
 export interface DshPluginDeclaration {
-  /** The module's subpath or bare specifier, importable from the package. */
+  /** The package's export subpath, with `.` naming its main entry and `./tools` a subpath. */
   name: string
   /** Display title. */
   title?: string

+ 2 - 2
scripts/check-workspace-constraints.ts

@@ -185,8 +185,8 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
   // through a hashed chunk. The committed bin.js is the link target pnpm can
   // resolve at install time, before the build produces lib/bin.js.
   '@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
-  // The package probe's child process entry, path-loaded beside the lib.
-  '@deepseek-ai/dsh-app-boot': ['lib/probe-child.js', 'lib/patch-file.js'],
+  // The public patch-file parser and writer.
+  '@deepseek-ai/dsh-app-boot': ['lib/patch-file.js'],
 }
 
 function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean {

+ 1 - 1
scripts/gen-cordis-catalog.ts

@@ -155,7 +155,6 @@ 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',
   fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API',
@@ -703,6 +702,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',
+  EntryIssue: 'live entry diagnostics are owned by packages/boot/app-boot/README.md',
   RowOrigin: 'row provenance record of the profile runtime is owned by packages/boot/app-boot/README.md',
   PluginChangeReason: 'plugin manager change reasons are owned by packages/host/plugin-manager/README.md',
   PluginInstallLogChunk: 'plugin manager install log chunks are owned by packages/host/plugin-manager/README.md',

+ 2 - 2
vendor/README.md

@@ -49,8 +49,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).
 18. **Entry `disabled` interpolation in `loader/src/config/entry.ts`**: a `disabled: !!js` expression evaluates against the loader context at every mount decision; the raw node stays in the options, so write-back keeps the `!!js` form. `disabled` is the only interpolated metadata field. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `apps/cli/tests/windows-shell.spec.ts`.
 19. **`loader/src/internal.ts` runtime shape detection**: `ModuleLoader.fromInternal()` classifies the internal loader by which module-job API it owns — `getOrCreateModuleJob` for v2, `getModuleJobForImport` for v1 — instead of by Node major version. Upstream tags every major `>= 24` as v2, but the v2 interface arrived in Node 24.12.0, so 24.0–24.11.1 report major 24 while still carrying the v1 loader; consumers then called `resolveSync` with reversed parameters and every call threw. `dsh web` served an empty client graph (`__DSH_BOOT__.entries: []`) and HMR partial reload resolved no entry URL, both behind swallowed or warn-level errors. Arity cannot discriminate the two shapes, because each reports `resolveSync.length === 2`. A loader owning neither API is left unclassified rather than guessed, so consumers take their documented no-internals path. Covered on the `node-compat` Node version matrix, which pins 24.9 for the mistagged range.
-20. **`loader/src/config/entry.ts` typed update errors**: the per-row `failed to <stage> loader entry` wrapper is an exported `EntryUpdateError` carrying its `stage`, so a consumer that classifies a row failure reads the field instead of parsing the message. The message text is unchanged. Covered by `packages/boot/app-boot/tests/contained-group.spec.ts`.
-21. **`include/src/index.ts` detached inserts and overrides**: `applyEntryPatches` clones inserted rows and object-valued override values before they join the list, so a later patch that inserts into or overrides a row an earlier patch introduced mutates the copy and applying one patch list twice yields the same tree — the Loader re-applies the previous include config when it rolls a rejected update back, and an aliased insert row accumulated its children on every application while an aliased override value carried them back into the patch itself. Covered by `packages/boot/app-boot/tests/external-bundles.spec.ts`.
+20. **`loader/src/config/entry.ts` passive diagnostics**: `Entry.lastFailure` retains the original error and import, activation, or update stage without changing native per-row continuation or exception propagation. It clears on disable, a fresh import, or a configuration application. A rejected config update may coexist with an active fiber running its prior config; consumers must inspect both. Covered by `packages/boot/app-boot/tests/entry-issues.spec.ts`.
+21. **`include/src/index.ts` patch copies**: inserted rows and object-valued overrides are cloned before joining the output, so later patches cannot mutate reusable input patches. Repeated Include application preserves the same children and can remove user overrides. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts`; app-boot additionally supplies fresh execution copies during profile recomposition.
 
 ## Sync procedure
 

Vissa filer visades inte eftersom för många filer har ändrats