Browse Source

Merge branch 'worktree/pr3594-manifest-mergeup' into feat/plugin-mgmt-3-settings

Yichen Jiang 1 month ago
parent
commit
89d1bc27c1
38 changed files with 215 additions and 306 deletions
  1. 0 6
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.i18n.yaml
  2. 0 38
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
  3. 0 38
      .agents/notes/archived/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md
  4. 0 6
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml
  5. 0 42
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.md
  6. 0 42
      .agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.zh.md
  7. 0 6
      .agents/notes/archived/manifest.json
  8. 2 2
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml
  9. 13 15
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
  10. 19 21
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md
  11. 2 2
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.i18n.yaml
  12. 1 1
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md
  13. 1 1
      .agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md
  14. 2 2
      docs/module-graph.i18n.yaml
  15. 1 2
      docs/module-graph.md
  16. 1 2
      docs/module-graph.zh.md
  17. 2 2
      docs/subsystems/core.i18n.yaml
  18. 1 1
      docs/subsystems/core.md
  19. 1 1
      docs/subsystems/core.zh.md
  20. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  21. 1 1
      packages/boot/plugin-manager/README.md
  22. 1 1
      packages/boot/plugin-manager/README.zh.md
  23. 2 3
      packages/boot/plugin-manager/src/installer.ts
  24. 1 1
      packages/boot/plugin-manager/src/manager.ts
  25. 0 5
      packages/boot/plugin-manager/tests/plugin-manager.spec.ts
  26. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  27. 2 2
      packages/host/plugin-manager/README.i18n.yaml
  28. 1 1
      packages/host/plugin-manager/README.md
  29. 1 1
      packages/host/plugin-manager/README.zh.md
  30. 29 9
      packages/host/plugin-manager/src/index.ts
  31. 123 2
      packages/host/plugin-manager/tests/plugin-manager.spec.ts
  32. 1 2
      packages/subprocess/subprocess/package.json
  33. 2 4
      packages/subprocess/subprocess/src/index.ts
  34. 2 2
      packages/util/launch-environment/README.i18n.yaml
  35. 0 4
      packages/util/launch-environment/README.md
  36. 0 4
      packages/util/launch-environment/README.zh.md
  37. 0 21
      packages/util/launch-environment/src/index.ts
  38. 0 10
      packages/util/launch-environment/tests/launch-environment.spec.ts

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

@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-boot-scoped-fail-loud-and-package-probe.md
-2026-09-04-boot-scoped-fail-loud-and-package-probe.md: c56067dfa3209cae83d40aaa07f46b13d1de94ff
-2026-09-04-boot-scoped-fail-loud-and-package-probe.zh.md: b49f667e2d915367938cec2a1a0e8386f737e4d2

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

@@ -1,38 +0,0 @@
-# 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)
-
-## Problem
-
-`installFailLoud` registered a process-wide `unhandledRejection` handler that wrote `fatal load failure` and exited, and the launcher discarded the uninstaller it returned, so the handler lived for the whole process. That is right during startup, where an unhandled rejection is a load failure nobody else will report. After boot it meant that any plugin's stray continuation — one rejected promise a community bundle forgot to await — took every session down with it, and no `uncaughtException` handler existed at all, so a synchronous throw in a timer callback crashed the process with Node's default trace and no origin. Mounting third-party code at runtime, which the plugin manager exists to do, was a bet on the process with these two defaults in place.
-
-Separately, nothing could say what an installed package was without importing it into the host: whether it declared a bundle layer or exported a plugin, which rows its patch would insert, whether it resolved `@deepseek-ai/cordis` to the harness's copy (judged by package directory, through links and a packaged executable's proxy) or to one of its own — the actual shape of a "dependency conflict" under the profile's hoisted linker with `autoInstallPeers: false` — and what `Config` schema its main export carried.
-
-## Decision
-
-**Fail-loud ends when the tree is up.** The launcher keeps `installFailLoud`'s uninstaller and calls it once `boot()` returns, then installs `installRuntimeGuards`: an unhandled rejection after boot is reported to stderr and the process keeps running — the guard neither stops the task that produced it nor attributes it to a plugin, built-in or external, so this is a process-level policy chosen over exiting, not isolation; an uncaught exception is reported with its origin and the process exits, as Node would, because its state is unknown. Both handlers are removed on shutdown.
-
-**Nested failures are reported, not yet fatal.** `warnNestedFiberFailures` walks every runtime's fibers and reports a `FAILED` fiber that belongs to a built-in entry but is not that entry's root fiber — a `ctx.inject()` continuation that threw, which the Loader stamps with the entry but the activation audit never sees. It runs after boot as advisory lines; it becomes part of the fatal audit once shipped compositions are known clean.
-
-**The probe runs the package where it cannot hurt.** `probePackage` reads the installed package's manifest in the host — kind from `dsh.bundle`, the rows and overrides of its patch, `dsh.plugins` declarations, `engines.dsh`, title and description — and spawns one Node child — `probe-child.ts`, its own module beside the probe, run through tsx under a source launch and as `lib/probe-child.js` when built — that resolves `@deepseek-ai/cordis` from the package, imports the main export and every declared addable module, and sends one report over an IPC channel. stdout and stderr stay the imported modules' own, so a package that prints at import still reports, and the child is killed once the report arrived, so a package that keeps a timer alive costs nothing more. The report and the cached record are validated field by field as the process and file boundaries they cross: a message that does not echo the run's token is the package's own, not the report, and an unrecognized record is probed again. The child gets the parent environment minus credential-shaped names, removes the token and `process.send` before importing, keeps at most 16 KiB of stderr for the failure text, and is awaited to `close` after the kill so nothing of it outlives the call. A child that throws, exits, or hangs yields `ok: false` with the reason or a rejection naming the timeout; `ok` states only that the main export imported and cordis is not a second copy, and `kind` with `addable[].ok` decide what can be enabled or added. Records are cached under the profile's `.dsh-plugins/` and invalidated by version.
-
-**A disabled bundle is a manifest fact.** `disableBundle` moves the package from `dsh.profile.bundles` to `dsh.profile.disabledBundles`, and `reconcileInstalledBundles` enables only a bundle named in neither list — one the run installed, or a dependency whose update declared `dsh.bundle` — so an unrelated pnpm run never re-enables what the user turned off, and the record goes when its dependency does. Comparing the manifest before and after the run was rejected: the installed state alone cannot tell a bundle the user disabled from one that became a bundle after it was installed.
-
-## Alternatives considered
-
-**Attribute a runtime rejection to the plugin that produced it and mark that plugin failed.** The right end state, but a promise carries no fiber, and cordis's effect wrappers cover only what plugins register through them. Deferred: the guard reports and lets the process continue; attribution needs an async-context seam.
-
-**Import the package in the host to learn its shape.** Rejected: import runs code with the host's privileges before the user enabled anything, and a hang or a `process.exit` in a package's module scope would be the host's.
-
-**Keep `installFailLoud` process-wide and ask plugin authors to be careful.** Rejected: the harness cannot review the code it lets users install, and the cost of one missed `await` was every session.
-
-## Consequences
-
-A stray rejection from any plugin after boot is a stderr line, not an exit; a misbehaving package is diagnosed by its probe record before it is enabled. The probe adds one child process per install and per version change, with a 20-second default bound. Runtime attribution of rejections to plugins remains open, so the plugin list cannot yet mark the plugin a reported rejection came from.
-
-## Testing
-
-`packages/boot/app-boot/tests/contained-group.spec.ts` pins `installRuntimeGuards` on a fake process (report without exit for a rejection, exit for an exception, uninstall) and against the real process, and `warnNestedFiberFailures` on a booted tree with a throwing `ctx.inject()` continuation under a built-in entry and under a contained one. `tests/probe.spec.ts` stages packages under a temporary profile: a bundle with gated, anonymous, and nested rows and an override, a plain plugin, a library, a package that throws at import, one that carries its own cordis copy, one whose addable module is missing, a hanging child, an exiting child, an unresolvable package, and a bad Node executable; and the cache round-trip with version mismatch and a corrupt record.

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

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

+ 0 - 6
.agents/notes/archived/architecture/2026-09-04-external-bundles-as-contained-groups.i18n.yaml

@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-external-bundles-as-contained-groups.md
-2026-09-04-external-bundles-as-contained-groups.md: 28e6f6b2bcf07455fdc8f297b6820569ff856ee4
-2026-09-04-external-bundles-as-contained-groups.zh.md: daa00851d264a90b8b8fd6719b797842bdc35074

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

@@ -1,42 +0,0 @@
-# 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)
-
-## Problem
-
-A bundle installed with `dsh plugin add` mounted its rows exactly like the installation's own: appended to the root entry list, under the ids its patch declared, inside the one Loader transaction that builds the tree. Three consequences followed from the vendored Loader's shape. `EntryGroup.update` is all-or-nothing — one rejected row rolls the group back and rethrows — so a community plugin that no longer compiled against the current harness stopped every `dsh` surface from starting, and the diagnostic named the row, not the bundle. Entry ids are unique per tree (`tree.store`), and a `create()` that finds an existing id moves that entry under the new group and replaces its options, so two bundles that both inserted `id: hello` made the second silently take over the first. Nothing recorded which layer had inserted a row, so the plugin list could not tell a built-in row from an installed one, nor a row the user disabled from one the composition gated.
-
-## Decision
-
-**Every external bundle is one group.** The profile launcher classifies each layer by provenance: a bundle that is a pnpm dependency of the profile is `external`, a template bundle or one the profile lists under `dsh.profile.firstParty` is `builtin`. `composeExternalLayer` renders a `runtime`-stage external layer as one `cordis:contained-group` entry, `bundle/<package>`, inserted empty, followed by the bundle's patches in the order written: each root insert is re-targeted into that group, every insert into one built-in group lands in one contained wrapper group nested inside the target (a patch that replaces the target's config removes that wrapper, so the next insert creates a new one under the same id), and id-targeted patches pass through, reported as overrides when they address rows the bundle did not introduce. The rows an id-targeted patch sets as a group's config count as the bundle's own: they mount as children exactly like inserted ones. Every id the layer introduces, declared or generated, goes through one registration, so a row that spells the group's or a wrapper's id is a repeat. Keeping the written order is what lets a patch that replaces a group's config and then appends to it mean the same thing under both stages. `/` rather than `:` in the group id because `:` is the Loader's nested-id separator.
-
-**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. 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.
-
-**Installed and enabled are two facts.** `reconcileInstalledBundles` no longer appends every bundle-declaring dependency to `dsh.profile.bundles` unconditionally; `autoEnable` keeps the CLI's install-and-enable semantics, and `enableBundle`/`disableBundle` are the manifest operations a plugin manager calls. `dependencies` records the install, `bundles` the enabled layers.
-
-**Provenance and recomposition are one launcher service.** `ProfileRuntime` (`ctx.profileRuntime`) holds the committed composition — the profile, the owner of every row id (`originOf`), and the conflicts — reads which rows the user patch files disable with a literal `disabled: true`, and is the one entry point that recomposes the tree: it composes a candidate, applies it through the root include, and publishes the candidate only once the include accepted it, so a rejected update leaves the facts describing the tree still running; recompositions run one at a time, each reading what the previous one committed, so a watcher firing while a bundle is being enabled recomposes the enabled tree instead of the one before it. The patch watchers call its `recompose` instead of composing themselves. The plugin inventory reads it and the failure registry to serve `trust`, `package`, `disabledBy`, and `failure` per row, listing the conflicts and the rows the registry alone knows.
-
-## Alternatives considered
-
-**Add an `optional` field to `EntryOptions` and teach the vendored `EntryGroup.update` to skip it.** Precise, but a vendored divergence to log and re-apply on every sync, a new metadata field for `verify-cordis-config` to admit, and it would still not give a bundle an identity in the tree. Rejected: the Loader's own `builtins` seat already lets the launcher substitute the group class, and one group per bundle is the identity every later feature — a package-level switch, per-package failure reporting — needs.
-
-**A second boot phase for external bundles, mounted by a runtime plugin after the built-in tree is up.** Cleanest isolation and no composition rewriting, but every bundle that provides a built-in seam service would need an explicit escape anyway, sessions could start before the second phase landed, and the browser roster would need recomputation. Rejected as the larger change for the same outcome; the contained group keeps one boot transaction.
-
-**Prefix every external row id with its package name.** Implemented first, then withdrawn: it made collisions impossible, but a user patch, a third-party manager writing `disabled` rows, and a bundle's own `!!js` expression comparing `e.options.id` all addressed the declared id and silently missed — the deep-whale skin manager's mutual-exclusion rows and the pi-ai OAuth bundle's self-excluding gate (which recursed into a stack overflow once its own id no longer matched) both broke this way — and `--dump-config` composed the unprefixed layers, so the dump disagreed with the boot. Ownership with a loud conflict keeps the declared id everywhere and turns a collision into a visible record.
-
-**Keep ids unprefixed and rely on authors choosing unique ids.** Rejected because the failure mode is silent takeover, not an error; the ownership check is what makes unprefixed ids safe.
-
-## Consequences
-
-A community bundle that breaks on a harness upgrade no longer stops `dsh`; the plugin list shows the failed row with its stage and message, and the process keeps serving. Two bundles that declare the same row id cannot both mount: the earlier one in `dsh.profile.bundles` keeps it and the later one is left out with a conflict record, so uninstalling the earlier bundle lets the later one mount on the next boot. A user patch targets an external bundle's row by the id the bundle declares, `--dump-config` shows the contained group and reports the same conflicts, and a bundle's `!!js` expression sees its own declared id. A bundle's override of a built-in row stays outside isolation, because it edits that row in place. The nested-fiber audit that would catch a failed `ctx.inject()` continuation under a built-in entry ships as advisory lines; making it fatal waits on a pass over shipped compositions.
-
-## Testing
-
-`packages/boot/app-boot/tests/external-bundles.spec.ts` pins the composition (grouping, declared ids, written order, overrides, one wrapper per built-in target, repeated ids, no mutation of the layer's patches) and the manifest operations; `tests/compose-stack.spec.ts` pins ownership: built-in duplicates throw, an external bundle that collides or repeats an id is left out whole, a user insert of a taken id is dropped, conflict records replace the previous composition's. `tests/contained-group.spec.ts` boots real trees: a failing contained row is recorded while its siblings and the built-in rows run, a pending contained row is recorded, a built-in failure still rejects, a built-in row left pending beside an isolated bundle rejects and names it, and a contained row that fails on a later reload is recorded by the audit. `tests/profile.spec.ts` pins trust and stage resolution including the deployer override and the unknown-stage rejection; `tests/profile-runtime.spec.ts` pins provenance and recomposition; `packages/host/plugin-inventory/tests/inventory.spec.ts` pins the new row fields.

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

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

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

@@ -361,12 +361,6 @@
     "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-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: 0d101b4c134bdf779d640275d5dea267f920a96b
-2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 582aca7d0b7395860d7dcd1277ad97b48d9b08db
+2026-09-04-plugin-manager-over-the-profile-runtime.md: e5b3e697e56ffe008556bb112565cd0f83941ee7
+2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 4661241c23210b09e483c558348054fa81e84677

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

@@ -4,40 +4,38 @@ 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.
+The CLI and Web need the same installation checks, while only a running application can apply bundle changes or inspect active rows. Keeping both responsibilities in the Web host would make the CLI depend on that host or duplicate its installer. Profile manifests, user patches and pnpm operations also need coordinated mutation so concurrent requests cannot overwrite each other's intent.
 
 ## Decision
 
-**One manager, one Remote.** `dsh-plugin-manager`, in the boot group beside `app-boot`, owns what plugin management does: `PluginInstaller` needs only the profile on disk (pnpm runs, the probe, the post-install checks) and `PluginManager` adds every operation over the booted tree, each refusal a `PluginOperationError` with a `plugins/*` code. `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote — `list`, `add`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents` — as a relay: one Remote method per manager method, and one exhaustive switch turning a failure into the Remote error of the same code (`plugins/bad-request` into the Gateway's `gateway/bad-request`). The manager takes the profile runtime, the preset roster, and the running-agent count as readers called per call, and the roster only as `PresetLayers` (layer path, preset list, composition rows), so it depends on neither the roster nor the agent registry; `dsh plugin add` and `remove` run the same installer, so the CLI and the Web host share one admission rule without booting a profile in the terminal. Every operation reads the profile manifest afresh and writes it through the same app-boot helpers the CLI uses — `reconcileInstalledBundles`, `enableBundle`, `disableBundle` — so the CLI and the manager cannot disagree on the file: `dependencies` says what is installed, `dsh.profile.bundles` says what is enabled. The profile runtime is resolved per call rather than injected, so the web bundle's row starts in a composition booted without the profile launcher and answers `plugins/unavailable`.
+**One business manager, one Remote adapter.** `dsh-plugin-manager` belongs beside app-boot. `PluginInstaller` operates on profile files and pnpm without a running Loader; `PluginManager` adds bundle and row operations over the live profile. The CLI shares the installer. `dsh-host-plugin-manager` supplies per-call readers for the profile runtime, preset layers and running-agent count, relays methods, and maps `PluginOperationError` codes to Remote errors. Missing profile runtime reports `plugins/unavailable` when called. The manager depends on the preset-layer interface, not the preset or agent implementations.
 
-**Enablement is the Loader's transaction.** `enable` puts the bundle in the layer list and calls `profileRuntime.recompose({ reloadBundles: true })`, after `healProfilesModuleFallback` has linked the packages the bundle carries. A rejected recomposition — a `boot`-stage bundle whose row throws — is the Loader rolling back to the tree that was running; the manager restores the list and reports `plugins/enable-failed`. A `runtime`-stage bundle whose row fails is isolated by the contained group and reported per row. Because the boot audit does not run again, the manager calls `recordContainedStates` after a live recomposition, and `ContainedGroup.create` now records a row that resolved in the pending state instead of clearing it — a reload re-creates every row of a group, and a waiting row must keep its record through that. `retry` is disable then enable: the Loader's update leaves an unchanged row alone, so only leaving and returning restarts a failed isolated row.
+**One mutation at a time.** An overlapping mutation receives `plugins/busy` rather than entering a queue with stale assumptions. Installation and removal reject with `plugins/agents-running` while an agent is running because pnpm rewrites modules those agents import. Bundle and row edits leave `node_modules` alone and do not use that guard. Every operation reads the profile manifest afresh: dependencies record installation, and `dsh.profile.bundles` records enablement.
 
-**pnpm runs the way the CLI runs it.** Through `node:child_process` with the parent environment and `shell` on Windows, not through the subprocess seam: the seam scrubs secret-shaped variables pnpm needs for registries and proxies and has no shell mode for the `.cmd` shim. Output streams as `plugins/install-log` chunks under a job id, each naming the command line and the profile directory, with pnpm's colours kept for the Web dialog's terminal and stripped for a CLI whose stdout is not a terminal; a non-zero exit, a spawn error, or the timeout is `plugins/install-failed` with the log tail. New packages are probed and left disabled unless the caller asked for `enable`.
+**pnpm retains its normal launch environment.** The installer uses `node:child_process` and Windows shell handling for the `.cmd` shim. Registry credentials and proxy configuration remain available. Output streams as `plugins/install-log` under a job id, with terminal colours when requested; spawn failures, timeouts and nonzero exits report `plugins/install-failed` with a bounded log tail. A failed add restores the saved manifest. Static declaration and bundle-conflict checks follow installation; unknown packages remain installed, and new bundles remain disabled unless the caller requests enablement. No discovery-cache directory is owned.
 
-**A successful `pnpm add` is not an installed plugin.** The manifest is snapshotted before the run and restored when pnpm fails, so a failed add leaves no dependency behind. Each package pnpm added is then judged: one that declares neither a bundle nor a plugin module, or a bundle whose row id a composed layer already owns (`claimLayerIds` over the current layers plus the candidate), is removed again with `pnpm remove` and reported under `removed` with its reason; a package the probe refused stays, because the view can explain it and `retry` can try again. The manager runs one mutation at a time and refuses a second with `plugins/busy` rather than queueing it — every write races on the manifest, the user layers, or `node_modules` otherwise — and `install` and `uninstall` refuse with `plugins/agents-running` while any agent is running, because pnpm rewrites the directory those sessions import from. The three guards follow the community `dshmarket` manager, which learned each of them from a bug.
+**Enablement selects a layer; diagnostics describe actual rows.** Bundle enablement, nontransactional recomposition and explicit `dsh.plugins` discovery follow [native entry diagnostics and static declarations](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md). The manager keeps per-row issues distinct from the enabled choice. `retry` removes the complete layer, awaits cleanup, and adds it again; unchanged row options alone do not restart a failed plugin. `list` derives each package view from the manifest, static declarations and current Loader state.
 
-**Rows go through the patch-file writer.** `addRow` inserts `{ id, name, config }` into the profile's `cordis.patch.yml` or an agent preset's user layer (through the roster's `overlayPathFor`), with the id derived from the package name and subpath; `setRowDisabled` is deny-only, writing or removing `disabled: true` so a bundle's `!!js` gate is restored rather than overridden. The global layer is recomposed on the spot; a preset's layer reaches its next standing generation.
+**Rows go through the patch-file writer.** `addRow` inserts the declared module and config into the profile user patch or the preset overlay returned by its roster. `setRowDisabled` writes or removes `disabled: true`, restoring the author's original gate when the user withdraws a denial. Global changes recompose the live tree; preset changes affect subsequent standing generations.
 
-**One view per package.** `list` folds the manifest, the probe record (cached under `.dsh-plugins/`, refreshed on a version change), and the live tree into a `status`: `running`, `partial`, or `failed` by active rows; `disabled`; `not-enableable` with the probe's reason; `restart-required` when a `startup`-reload profile's manifest and tree disagree; `plain` for a library or plugin module. Rows come from the tree while composed and from the probe otherwise, already carrying the prefixed ids the launcher will use; trust for a bundle outside the tree comes from `layerTrust`, the one rule `loadProfile` also applies.
+**Runtime notifications follow diagnostics.** Native entry and fiber events share one pending reader. It waits for Loader and profile recomposition, then compares row identity, module, fiber phase, failure stage and message. Only a changed diagnostic set emits `plugins/changed` with reason `runtime`; healthy status churn and repeated identical failures stay silent. Management operations retain their own completion notifications. Events arriving during a read request another pass, and disposing the adapter cancels publication.
 
 ## Alternatives considered
 
-**Running pnpm through `ctx.subprocess`.** Rejected for this release: the seam has no shell mode and scrubs the environment; adding both to the seam for one caller is a larger change than the manager, and the CLI's spawn is proven.
+**Run pnpm through `ctx.subprocess`.** Its implicit environment scrub removes registry credentials, and it lacks the Windows shell mode this caller needs. Extending the tool subprocess service for package installation would enlarge an unrelated interface.
 
-**Restarting a failed row in place on `retry`.** Rejected: a row's options are unchanged, so the Loader's transactional update would not touch it; re-creating one row by hand would bypass the group's own create path and its failure record.
+**Restart one failed row directly.** This bypasses whole-layer composition and does not account for the bundle's groups and overrides. Removing and adding the layer gives its Loader entries a complete lifecycle.
 
-**Enabling a bundle without probing it.** Rejected: the probe is what turns a package that cannot import, or that resolves its own cordis copy, into a `not-enableable` view with a reason before the tree is asked to mount it.
+**Keep business operations in the Web host or app-boot entry file.** The host would become a CLI dependency; putting installation, streaming and dependency inspection in the boot entry would couple every launcher to management implementation. A separate boot-group package serves both consumers.
 
-**Keeping the operations inside the host package.** Rejected after review: `host/` is the web GUI's half, so the `dsh plugin` command could reuse the install path only by depending on a Web host package, and it kept its own pnpm forwarder without the post-install checks instead. The one-file-in-`app-boot` variant was rejected too: every `dsh` surface loads `app-boot` at start, and a thousand lines of pnpm streaming, probing, and dependents belong beside it, not inside it.
+**Broadcast after every fiber transition.** Most transitions do not change a plugin diagnostic. Unconditional notifications make clients reread package files and rerender during unrelated work. Diagnostic comparison preserves failure discovery and recovery notifications; explicit management operations already report their own changes.
 
 ## Consequences
 
-A running Web host can install, enable, disable, retry, and remove third-party bundles and add their modules to the global layer or a preset without a restart on a live profile. Updating a loaded package still needs a restart (Node's module cache); `dependents` stops at injection edges; `engines.dsh` is reported, not enforced; the client UI arrives in a later PR. An enable, disable, or row edit is not guarded by running sessions: it recomposes the tree, which is the Loader's transaction, and leaves `node_modules` alone.
+Live profiles support bundle and row changes without restarting. Updated module code still requires a restart because Node caches ESM modules. `dependents` reports injection relationships, not arbitrary application dependencies, and `engines.dsh` is advisory. Runtime notifications do not promise to describe every healthy configuration-file change; they report diagnostic changes, while operation notifications report management mutations.
 
 ## Testing
 
-`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.
+Manager integration tests use real profile files and a native Loader tree; CLI tests exercise the shared installer. Adapter tests cover Remote forwarding, failure changes and recovery, unchanged-diagnostic suppression, one pending read during event bursts, profile-recomposition barriers, and disposal during settlement.

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

@@ -1,43 +1,41 @@
-# Agent Note:插件管理器驱动 profile runtime
+# Agent Note: 插件管理器驱动 profile runtime
 
 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 文件归属仍然适用。
+## Problem
 
-## 问题
+CLI 与 Web 需要相同的安装检查,而只有运行中的应用才能应用组合包变更或检查活跃行。把两类职责都放在 Web 宿主会迫使 CLI 依赖该宿主或重复实现安装器。Profile 清单、用户 patch 和 pnpm 操作也需要协调修改,避免并发请求覆盖彼此的意图。
 
-安装插件曾是只有终端能做的事:`dsh plugin --profile web add <spec>` 运行 pnpm,把找到的每个组合包追加进 `dsh.profile.bundles`,下次启动再组合。运行中的任何东西都无法得知哪些包装了但没启用,无法不手改 `package.json` 就关掉一个组合包,无法把某个包的模块加进 profile 的用户层或某个 agent preset,也说不出一个包的服务撑着哪些行。Web 界面能经 `pluginInventory/list` 列出行,仅此而已;而 launcher 的 `profileRuntime`(前一篇笔记)已经能在用户 patch 重载时重新组合树,`reconcileInstalledBundles` 也已经把安装与启用分开。缺的是执行这些操作并把每个包报告成一个整体的宿主服务。
+## Decision
 
-## 决定
+**一个业务管理器,一个 Remote 适配器。** `dsh-plugin-manager` 位于 app-boot 旁。`PluginInstaller` 操作 profile 文件和 pnpm,不需要运行中的 Loader;`PluginManager` 在其上增加面向运行中 profile 的组合包与行操作。CLI 共用安装器。`dsh-host-plugin-manager` 提供逐次调用的 profile runtime、预设层与运行中 agent 数读取器,转接方法并将 `PluginOperationError` 错误码映射为 Remote 错误。缺少 profile runtime 时在调用处报告 `plugins/unavailable`。管理器依赖预设层接口,不依赖预设或 agent 的实现。
 
-**一个管理器,一个 Remote。** boot 组里与 `app-boot` 并列的 `dsh-plugin-manager` 拥有插件管理做什么:`PluginInstaller` 只需要磁盘上的 profile(pnpm 运行、探针、装后检查),`PluginManager` 在其上加上已启动树的每项操作,每次拒绝都是带 `plugins/*` 码的 `PluginOperationError`。`dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote——`list`、`add`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`——作为转接层:一个 Remote 方法对应一个管理器方法,一个穷尽的 switch 把失败转成同码的 Remote 错误(`plugins/bad-request` 转成 Gateway 的 `gateway/bad-request`)。管理器把 profile runtime、preset roster 与运行中 agent 数当作按调用读取的读取器接入,roster 只以 `PresetLayers`(层路径、preset 列表、组合行)的形态接入,因此既不依赖 roster 也不依赖 agent 注册表;`dsh plugin add` 与 `remove` 跑同一个安装器,CLI 与 Web 宿主共用一套准入规则,终端里不必启动 profile。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
+**同时只允许一个变更。** 重叠变更收到 `plugins/busy`,不进入可能携带过期假设的队列。有 agent 运行时,安装与移除以 `plugins/agents-running` 拒绝,因为 pnpm 会重写这些 agent 导入的模块。组合包与行编辑不改 `node_modules`,不使用这一限制。每次操作重新读取 profile 清单:依赖记录安装,`dsh.profile.bundles` 记录启用。
 
-**启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
+**pnpm 保留正常的启动环境。** 安装器使用 `node:child_process`,并在 Windows 上通过 shell 处理 `.cmd` 垫片。Registry 凭据与代理配置保持可用。输出以 job id 下的 `plugins/install-log` 流式发送,按请求保留终端颜色;启动失败、超时与非零退出以 `plugins/install-failed` 和有界日志尾部报告。add 失败时恢复保存的清单。安装后进行静态声明和组合包冲突检查;未知包保留安装,新组合包保持禁用,除非调用方请求启用。没有需要维护的发现缓存目录。
 
-**pnpm 按 CLI 的方式运行。** 经 `node:child_process`、带父进程环境、Windows 上开 `shell`,而不经 subprocess seam:seam 会清洗 pnpm 访问 registry 与代理所需的形似密钥的变量,也没有解析 `.cmd` shim 的 shell 模式。输出以某个 job id 下的 `plugins/install-log` 分块流式发出,每块写明命令行与 profile 目录,pnpm 的颜色为 Web 对话框的终端保留、对 stdout 不是终端的 CLI 去掉;非零退出、spawn 错误或超时即带日志尾部的 `plugins/install-failed`。新包被探测并保持停用,除非调用方要求 `enable`。
+**启用选择层,诊断描述实际行。** 组合包启用、非事务重组和显式 `dsh.plugins` 发现遵循[原生条目诊断与静态声明](2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md)。管理器区分逐行问题与启用选择。`retry` 移除完整层,等待清理,再将其加入;仅保留不变的行选项不会重启失败插件。`list` 从清单、静态声明和当前 Loader 状态派生每个包的视图。
 
-**`pnpm add` 成功不等于装好了插件。** 运行前先给 manifest 拍快照,pnpm 失败时恢复,失败的 add 不会留下依赖。之后逐个裁决 pnpm 加进来的包:既不声明组合包也不声明插件模块的,或者行 id 已被已组合层占有的组合包(对当前各层加候选层跑 `claimLayerIds`),再以 `pnpm remove` 移除并连同原因报在 `removed` 里;探针拒绝的包保留,因为视图能解释它、`retry` 还能再试。管理器一次只跑一个变更,第二个以 `plugins/busy` 拒绝而不是排队——否则每次写入都会在 manifest、用户层或 `node_modules` 上竞争——`install` 与 `uninstall` 在任一 agent 运行时以 `plugins/agents-running` 拒绝,因为 pnpm 会重写那些会话正在 import 的目录。这三道守卫来自社区的 `dshmarket` 管理器,它每一条都是从一个 bug 学来的。
+**行通过 patch-file 写入器修改。** `addRow` 将声明的模块与配置插入 profile 用户 patch 或 roster 返回的预设覆盖层。`setRowDisabled` 写入或移除 `disabled: true`,用户撤销禁用时恢复作者原来的条件。全局修改重组运行中的树;预设修改影响后续常驻代际。
 
-**行经补丁文件写入器落地。** `addRow` 把 `{ id, name, config }` 插入 profile 的 `cordis.patch.yml` 或某个 agent preset 的用户层(经 roster 的 `overlayPathFor`),id 由包名与子路径派生;`setRowDisabled` 只写拒绝,写入或移除 `disabled: true`,因此组合包的 `!!js` 门被恢复而不是被覆盖。全局层当场重新组合;preset 的层在其下一个常驻代际生效。
+**运行时通知跟随诊断。** 原生条目与 fiber 事件共用一次待完成的读取。读取等待 Loader 和 profile 重组完成,再比较行身份、模块、fiber 阶段、失败阶段与消息。只有诊断集合变化才发送原因是 `runtime` 的 `plugins/changed`;健康状态波动和重复的相同失败保持安静。管理操作仍保留自己的完成通知。读取期间到来的事件请求再次读取,适配器销毁时取消发布。
 
-**每个包一份视图。** `list` 把 manifest、探针记录(缓存在 `.dsh-plugins/` 下,版本变化即刷新)与在线树折叠成一个 `status`:按活跃行数是 `running`、`partial` 或 `failed`;`disabled`;带探针原因的 `not-enableable`;`startup` 重载的 profile 上 manifest 与树不一致时是 `restart-required`;库或插件模块是 `plain`。行在已组合时来自树,否则来自探针,并已带上 launcher 将使用的前缀 id;树外组合包的 trust 来自 `layerTrust`,这也是 `loadProfile` 所用的同一条规则。
+## Alternatives considered
 
-## 考虑过的替代方案
+**通过 `ctx.subprocess` 运行 pnpm。** 它的隐式环境清洗会移除 registry 凭据,也缺少本调用方需要的 Windows shell 模式。为安装包而扩展工具子进程服务会扩大无关接口。
 
-**经 `ctx.subprocess` 运行 pnpm。** 本次否决:seam 没有 shell 模式且会清洗环境;为一个调用方给 seam 加上两者,比管理器本身改动更大,而 CLI 的 spawn 已被验证。
+**直接重启某个失败行。** 这会绕开整层组合,也无法顾及组合包中的 group 和覆盖。移除并重新加入层为其 Loader 条目提供完整生命周期。
 
-**`retry` 时原地重启失败的行。** 否决:行的 options 未变,Loader 的事务性更新不会碰它;手工重建一行会绕过组自己的创建路径及其失败记录。
+**把业务操作保留在 Web 宿主或 app-boot 入口文件。** 宿主会成为 CLI 依赖;把安装、流式输出与依赖查询放进 boot 入口则会让每个启动器耦合管理实现。独立的 boot 组包同时服务两类消费方。
 
-**不探测就启用组合包。** 否决:正是探针把无法 import、或解析到自己那份 cordis 副本的包,在树被要求挂载之前变成带原因的 `not-enableable` 视图。
+**每次 fiber 状态变化都广播。** 大部分变化不改变插件诊断。无条件通知会让客户端在无关工作期间重新读取包文件并渲染。比较诊断仍能报告失败发现与恢复;显式管理操作已经会报告自己的变更。
 
-**把操作留在宿主包里。** 评审后否决:`host/` 是 Web GUI 的那一半,`dsh plugin` 命令要复用安装路径就只能依赖一个 Web 宿主包,于是它一直保留着自己的、没有装后检查的 pnpm 转发器。放进 `app-boot` 一个文件的变体也被否决:每个 `dsh` 表面启动时都加载 `app-boot`,上千行的 pnpm 流式输出、探测与依赖检测应该与它并列,而不是塞进它。
+## Consequences
 
-## 后果
+实时 profile 支持不重启地修改组合包与行。模块代码更新仍需重启,因为 Node 会缓存 ESM 模块。`dependents` 报告注入关系,不报告任意应用依赖,`engines.dsh` 仅供参考。运行时通知不保证描述每一次健康的配置文件修改;它报告诊断变化,操作通知则报告管理变更。
 
-运行中的 Web 宿主可以在 live profile 上不重启地安装、启用、停用、重试与移除三方组合包,并把它们的模块加进全局层或某个 preset。更新已加载的包仍需重启(Node 的模块缓存);`dependents` 止于注入边;`engines.dsh` 只报告不强制;客户端 UI 在后续 PR 到来。启用、停用与行编辑不受运行中会话限制:它们重组的是树,那是 Loader 的事务,不碰 `node_modules`。
+## Testing
 
-## 测试
-
-`packages/boot/plugin-manager/tests/plugin-manager.spec.ts` 覆盖实际 profile 文件与原生 Loader 树上的管理操作。Host 适配器测试覆盖直接转接、错误码与依赖恢复通知;`apps/cli/tests/plugin.spec.ts` 覆盖共用安装器。生命周期与静态发现的验证遵循取代这些机制的新笔记。
+管理器集成测试使用真实 profile 文件和原生 Loader 树;CLI 测试覆盖共用安装器。适配器测试覆盖 Remote 转接、失败变化与恢复、不变诊断不发通知、事件突发期间共用一次读取、profile 重组屏障以及等待期间的销毁。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.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-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
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.md: ff2024ce0ad82a43692a0a1f77d906789320ea13
+2026-09-11-native-entry-diagnostics-and-static-plugin-declarations.zh.md: 7fc80b864e9d92e72756e7dc7470debc9d41b793

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

@@ -24,7 +24,7 @@ Third-party plugin failures must leave the application’s management endpoints
 
 ## 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.
+**Keep contained groups and child probes.** Contained groups 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. Child probes 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.
 

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

@@ -24,7 +24,7 @@ Status: implemented
 
 ## 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 视为运行隔离。
+**保留 contained group 和子进程 probe。** contained group 防止事务式 Loader 回滚,并为已移除行提供失败记录。原生条目会同时保留成功行与失败行,包装只增加身份,并未提供整层组合所缺少的整包启停能力。子进程 probe 为发现阶段的执行设定边界,并发现未声明导出与 schema。静态声明避免这次执行,同时放弃自动主入口识别和挂载前 schema。未来的执行沙箱需要独立设计进程归属与清理,不能把发现 probe 视为运行隔离。
 
 **在 Host 内 import 并 try/catch。** 拒绝,因为发现阶段可能产生不可逆副作用、退出 Host 或阻塞事件循环。捕获异常无法恢复隔离。通过解析源码推断插件导出会增加一个结果不完整的 JavaScript 解释器。
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: edf56d23ed8cd99ae41fb977070f6e86e84a3b29
-module-graph.zh.md: 369e9b793ec3261f72014109991449261eafa993
+module-graph.md: cf81ecf016bfb905ac0c98a230c193f020b26a0f
+module-graph.zh.md: d0d3664699dd87c48836eaea0c6c85861534da04

+ 1 - 2
docs/module-graph.md

@@ -406,7 +406,6 @@ flowchart TD
   pkg_storage_json --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_subprocess --> pkg_http_proxy
-  pkg_subprocess --> pkg_launch_environment
   pkg_typert_loader --> pkg_typert_registry
   pkg_session --> pkg_scope
   pkg_system_prompt --> pkg_invariants
@@ -1316,7 +1315,7 @@ flowchart TD
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
-| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
+| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

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

@@ -408,7 +408,6 @@ flowchart TD
   pkg_storage_json --> pkg_storage
   pkg_storage_sqlite --> pkg_storage
   pkg_subprocess --> pkg_http_proxy
-  pkg_subprocess --> pkg_launch_environment
   pkg_typert_loader --> pkg_typert_registry
   pkg_session --> pkg_scope
   pkg_system_prompt --> pkg_invariants
@@ -1318,7 +1317,7 @@ flowchart TD
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) |
 | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) |
 | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) |
-| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`launch-environment`](../packages/util/launch-environment) |
+| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
 | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
 | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |

+ 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: 0e96a41ec96b246fd104a9625783211753eac429
-core.zh.md: 1b02373d427e297763114ff9c34af2f957d84ab7
+core.md: 6f79778545f284068142a9d96fe0e118466db153
+core.zh.md: d81002498ac9db8d3f93f8a5bad784fb48accdc5

+ 1 - 1
docs/subsystems/core.md

@@ -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 any obsolete discovery cache.
+ * Remove a package from the profile with its user-layer rows.
  * @param packageName - the installed dependency to remove.
  */
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>

+ 1 - 1
docs/subsystems/core.zh.md

@@ -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 any obsolete discovery cache.
+ * Remove a package from the profile with its user-layer rows.
  * @param packageName - the installed dependency to remove.
  */
 @Remote('uninstall') async uninstall(packageName: string): Promise<void>

+ 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: 490c0522eaf27136a5650cfdbe4fe672820948da
-README.zh.md: b31f461c8cdd83428f4e1107ad636d3658cee4d2
+README.md: 54f3eabaca809cd40c3b26810c3c21bcf835be09
+README.zh.md: 6a3ef96352586c5c015e99908179e94dd4d97779

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

@@ -74,7 +74,7 @@ console.log(await manager.list())
 
 `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.
 
-`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.
+`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, and runs pnpm remove.
 
 `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.
 

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

@@ -74,7 +74,7 @@ console.log(await manager.list())
 
 `list` 报告包身份、`bundle` / `plugin` / `unknown` 分类、trust、stage、安装与启用状态。运行行携带实际阶段与失败;禁用组合包显示静态 patch 声明。`addable` 只来自 `dsh.plugins`,包括表示主入口的 `.` 和声明的默认配置。行更新失败后,活跃实例可能保留先前配置。包的 `issues` 还报告其 patch 覆盖的失败行,但不转移这些行的归属。
 
-`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove 并清理过期发现记录。
+`enable` 选择整份组合包层,并在实时 profile 中重组。逐行失败保留启用选择与成功的其他行;结果报告 `issues`,列表可显示 `partial` 或 `failed`。准备失败会撤销启用选择,并抛出 `plugins/enable-failed`。`disable` 移除整层,包括覆盖。`retry` 先禁用并等待清理,再启用。仅启动时生效的 profile 报告 `effect: restart`。`uninstall` 禁用组合包、删除用户插入的引用、执行 pnpm remove。
 
 `addRow` 将显式声明的 `dsh.plugins` 模块写入 profile 的全局 `cordis.patch.yml` 或预设用户层。它保留声明的默认配置,检查目标行 id,不在挂载前 import。`removeRow` 删除用户插入。`setRowDisabled` 写入或删除 `disabled: true`,保留组合包自己的条件。全局编辑在实时 profile 中立即重组;预设编辑应用于后续代际。`dependents` 报告注入依赖方与用户层模块引用。
 

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

@@ -7,7 +7,7 @@
 
 import { spawn as spawnChild } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
-import { readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import {
   awaitChildClose,
@@ -189,7 +189,7 @@ export class PluginInstaller {
   }
 
   /**
-   * Run `pnpm remove`, reconcile the layer list, and remove any obsolete discovery record.
+   * Run `pnpm remove` and reconcile the layer list.
    * @param packageName - the dependency to remove.
    * @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
    */
@@ -198,7 +198,6 @@ export class PluginInstaller {
     const before = readProfileManifest(NAME, profileDir)
     await this.runPnpm(['remove', packageName], packageName)
     reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
-    rmSync(join(profileDir, '.dsh-plugins', `${packageName.replaceAll('/', '__')}.json`), { force: true })
   }
 
   /**

+ 1 - 1
packages/boot/plugin-manager/src/manager.ts

@@ -219,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 clean obsolete discovery metadata.
+   * user-layer row that names it, and run `pnpm remove`.
    * @param packageName - the installed dependency to remove.
    * @throws {PluginOperationError} `plugins/not-installed`, `plugins/agents-running`,
    * or `plugins/install-failed` when pnpm exits non-zero.

+ 0 - 5
packages/boot/plugin-manager/tests/plugin-manager.spec.ts

@@ -382,7 +382,6 @@ describe('PluginManager', () => {
       // 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' }])
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins'))).toBe(false)
     })
 
     it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
@@ -558,7 +557,6 @@ describe('PluginManager', () => {
       })
       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(true)
     })
 
@@ -718,7 +716,6 @@ 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: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')
     })
@@ -1011,7 +1008,6 @@ 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(false)
 
       await manager.uninstall('ext-bundle')
 
@@ -1020,7 +1016,6 @@ describe('PluginManager', () => {
       expect(entryIds(ctx)).not.toContain('include:hello')
       expect(entryIds(ctx)).not.toContain('include:ext-bundle/extra.js')
       expect(readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
-      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(false)
       expect(changes.map(change => change.reason)).toEqual(['enable', 'row', 'disable', 'uninstall'])
       await expect(manager.uninstall('ext-bundle')).rejects.toMatchObject({ code: 'plugins/not-installed' })
     })

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

@@ -1431,7 +1431,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
-        description: 'Remove a package from the profile with its user-layer rows and any obsolete discovery cache.',
+        description: 'Remove a package from the profile with its user-layer rows.',
         parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
       },
       {

+ 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: 94f4debb4c8ae061d417a9a9da0affa22e07621c
-README.zh.md: 5d4e7c997110ac588aa91821de47732f167f8b67
+README.md: c991e1675effe9ca0633b3637f839ee8835a5aa7
+README.zh.md: e2be2a6cd613a09ea4260a40d5f0765eb6a8520b

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

@@ -53,7 +53,7 @@ A manager failure reaches the client as a `RemoteError` with the same `code` and
 
 ### A relay, not a second 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.
+The adapter supplies per-call profile, preset and agent readers to one `PluginManager`, relays each Remote method, and maps domain errors. It coalesces native entry/status events into one reader, waits for Loader and queued profile recomposition to settle, and publishes `plugins/changed` only when the row diagnostics change. Management operations retain their own change notifications. Disposal cancels pending notifications and removes the listeners. Tests may replace the manager, pnpm spawner or static metadata reader.
 
 ### Source map
 

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

@@ -53,7 +53,7 @@ kind: "package-reference"
 
 ### 转接,不是第二个管理器
 
-适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它观测原生条目与状态事件,在 Loader 和已排队的 profile 重组完成后合并发布 `plugins/changed` 通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
+适配器为一个 `PluginManager` 提供逐次调用的 profile、预设与 agent 读取器,转接各 Remote 方法并映射领域错误。它将原生条目与状态事件合并到一次读取中,等待 Loader 和已排队的 profile 重组完成后,仅在行诊断变化时发布 `plugins/changed` 通知。管理操作仍保留各自的变更通知。销毁会取消待发通知并移除监听器。测试可替换管理器、pnpm 启动器或静态元信息读取器。
 
 ### 源码地图
 

+ 29 - 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 { readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
+import { inspectEntryIssues, type readPackageMetadata } from '@deepseek-ai/dsh-app-boot'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-presets'
 import {
@@ -87,21 +87,41 @@ export class PluginManagerRemote extends TypertRemoteService {
       ...internals.metadata === undefined ? {} : { metadata: internals.metadata },
     })
     const loader = ctx.loader
-    let generation = 0
     let disposed = false
+    let refreshing = false
+    let dirty = false
+    let previousIssues = '[]'
+    const invalidated = (): boolean => disposed || dirty
     ctx.effect(() => () => { disposed = true })
     const refresh = (): void => {
-      const requested = ++generation
-      // Loader events precede asynchronous import/update completion; publish only after settlement.
+      dirty = true
+      if (refreshing) return
+      refreshing = true
+      // One reader settles the latest tree; events during its read request another pass.
       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) })
+        while (dirty && !disposed) {
+          dirty = false
+          await loader.await()
+          await ctx.get('profileRuntime')?.whenIdle()
+          if (invalidated()) continue
+          const issues = await inspectEntryIssues(ctx)
+          if (invalidated()) continue
+          const next = JSON.stringify(issues.map(({ entry, stage, message }) =>
+            JSON.stringify([entry.id, entry.options.name, entry.fiber?.state, stage, message]),
+          ).sort())
+          if (next === previousIssues) continue
+          previousIssues = next
+          ctx.emit('plugins/changed', { reason: 'runtime' })
+        }
+      }).catch((error: unknown) => { ctx.logger.warn('plugin inventory refresh failed', error) }).finally(() => {
+        refreshing = false
+        if (dirty && !disposed) refresh()
+      })
     }
     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 })
+    refresh()
 
   }
 
@@ -126,7 +146,7 @@ export class PluginManagerRemote extends TypertRemoteService {
   }
 
   /**
-   * Remove a package from the profile with its user-layer rows and any obsolete discovery cache.
+   * Remove a package from the profile with its user-layer rows.
    * @param packageName - the installed dependency to remove.
    */
   @Remote('uninstall')

+ 123 - 2
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -45,19 +45,21 @@ async function mount(manager?: PluginManager): Promise<PluginManagerRemote> {
 }
 
 describe('PluginManagerRemote', () => {
-  it('waits for queued profile recomposition before publishing refreshed ownership', async () => {
+  it('waits for queued profile recomposition before publishing changed issues', async () => {
     const ctx = new Context()
     contexts.push(ctx)
     await ctx.plugin(Loader)
-    ctx.loader.builtins.good = () => {}
+    ctx.loader.builtins.good = { inject: ['missingService'], apply() {} }
     const id = await ctx.loader.create({ name: 'cordis:good' })
     await ctx.plugin(PluginManagerRemote, CONFIG)
+    await new Promise<void>(resolve => setImmediate(resolve))
     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.provide('missingService', {})
     ctx.emit('loader/entry-init', ctx.loader.resolve(id))
     try {
       await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
@@ -125,6 +127,124 @@ describe('PluginManagerRemote', () => {
     expect(changes).toHaveLength(count)
   })
 
+  it('keeps unrelated healthy changes and unchanged errors silent, but reports error changes and recovery', 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 changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    await new Promise<void>(resolve => setImmediate(resolve))
+    await entry.update({ config: { healthy: true } })
+    await ctx.loader.await()
+    await new Promise<void>(resolve => setImmediate(resolve))
+    expect(changes).toEqual([])
+
+    entry.lastFailure = { stage: 'update', error: 'first update failed' }
+    ctx.emit('loader/entry-init', entry)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
+    ctx.emit('loader/entry-init', entry)
+    await new Promise<void>(resolve => setImmediate(resolve))
+    expect(changes).toEqual(['runtime'])
+    entry.lastFailure = { stage: 'update', error: 'different update failed' }
+    ctx.emit('loader/entry-init', entry)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime']) })
+    delete entry.lastFailure
+    ctx.emit('loader/entry-init', entry)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime', 'runtime']) })
+  })
+
+  it('shares one pending refresh across a burst of Loader events', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    ctx.loader.builtins.waiting = { inject: ['lateService'], apply() {} }
+    const id = await ctx.loader.create({ name: 'cordis:waiting' })
+    const entry = ctx.loader.resolve(id)
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    await vi.waitFor(() => { expect(changes).toEqual(['runtime']) })
+    const gate = Promise.withResolvers<undefined>()
+    const whenIdle = vi.fn(() => gate.promise)
+    ctx.provide('profileRuntime', { whenIdle } as never)
+    const settle = vi.spyOn(ctx.loader, 'await')
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await vi.waitFor(() => { expect(whenIdle).toHaveBeenCalledOnce() })
+      for (let i = 0; i < 100; i++) ctx.emit('loader/entry-init', entry)
+      expect(settle).toHaveBeenCalledOnce()
+      expect(whenIdle).toHaveBeenCalledOnce()
+      ctx.provide('lateService', {})
+      gate.resolve(undefined)
+      await vi.waitFor(() => { expect(changes).toEqual(['runtime', 'runtime']) })
+      expect(entry.fiber?.state).toBe(2)
+    } finally {
+      gate.resolve(undefined)
+      settle.mockRestore()
+    }
+  })
+
+  it('discards a diagnostic snapshot changed while it was being read', 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)
+    await ctx.plugin(PluginManagerRemote, CONFIG)
+    await new Promise<void>(resolve => setImmediate(resolve))
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    entry.lastFailure = { stage: 'update', error: new Error('transient failure') }
+    const read = vi.spyOn(entry, 'disabled', 'get').mockImplementationOnce(() => {
+      queueMicrotask(() => {
+        delete entry.lastFailure
+        ctx.emit('loader/entry-init', entry)
+      })
+      return false
+    })
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await vi.waitFor(() => { expect(read).toHaveBeenCalledTimes(2) })
+      await new Promise<void>(resolve => setImmediate(resolve))
+      expect(changes).toEqual([])
+    } finally {
+      read.mockRestore()
+    }
+  })
+
+  it('cancels publication when disposed during the diagnostic read', 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
+    await new Promise<void>(resolve => setImmediate(resolve))
+    const changes: string[] = []
+    ctx.on('plugins/changed', ({ reason }) => { changes.push(reason) })
+    entry.lastFailure = { stage: 'update', error: new Error('update failed') }
+    const disposed = Promise.withResolvers<undefined>()
+    const read = vi.spyOn(entry, 'disabled', 'get').mockImplementationOnce(() => {
+      queueMicrotask(() => { void remote.dispose().then(() => { disposed.resolve(undefined) }, disposed.reject) })
+      return false
+    })
+    try {
+      ctx.emit('loader/entry-init', entry)
+      await disposed.promise
+      await new Promise<void>(resolve => setImmediate(resolve))
+      expect(changes).toEqual([])
+    } finally {
+      read.mockRestore()
+    }
+  })
+
   it('publishes the plugins namespace with one direct method per operation', async () => {
     const remote = await mount()
     expect(remote.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
@@ -183,6 +303,7 @@ describe('PluginManagerRemote', () => {
     await ctx.plugin(Loader)
     ctx.provide('profileRuntime', {
       dir: profileDir, profileName: 'web', installAnchor: join(profileDir, 'package.json'), patchReload: 'startup', current: { layers: [] },
+      whenIdle: async () => {},
     } as never)
     const metadata: typeof readPackageMetadata = options => ({
       packageName: options.packageName, kind: 'plugin', cordisSameCopy: null, rows: [], overrides: [], addable: [{ name: '.' }],

+ 1 - 2
packages/subprocess/subprocess/package.json

@@ -28,8 +28,7 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-http-proxy": "workspace:^",
-    "@deepseek-ai/dsh-launch-environment": "workspace:^"
+    "@deepseek-ai/dsh-http-proxy": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",

+ 2 - 4
packages/subprocess/subprocess/src/index.ts

@@ -10,7 +10,6 @@
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import { proxyEnvironmentForChild } from '@deepseek-ai/dsh-http-proxy'
-import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-launch-environment'
 import { DSH_ENV_PREFIX } from './types.ts'
 import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts'
 import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts'
@@ -39,12 +38,11 @@ export type {
 /**
  * Credential-shaped environment names are NOT forwarded to children (the
  * harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a spawned
- * process implicitly). One heuristic for every in-repo spawner, defined in
- * `dsh-launch-environment` so the package probe scrubs by the same rule; a
+ * process implicitly). One heuristic for every in-repo spawner; a
  * deliberately supplied entry survives because explicit env layers merge
  * after the scrub.
  */
-export { SENSITIVE_ENV_PATTERN }
+export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
 
 /**
  * The ambient parent environment minus credential-shaped names and minus all

+ 2 - 2
packages/util/launch-environment/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/util/launch-environment/README.md
-README.md: fc84be1607df15163d579aebb8dde4b450faa4f5
-README.zh.md: be276d35e25c07ca1ae372154fec7573bbbaa8cf
+README.md: 43d3e27b1901ac7fea655a08ae183084bbbab1ff
+README.zh.md: 0e161e46219d4175950e3b1fe70950bdd7c1b732

+ 0 - 4
packages/util/launch-environment/README.md

@@ -53,10 +53,6 @@ Names match the way the platform matches them: exactly on POSIX, case-insensitiv
 
 `launchEnvironmentOf(ctx)` returns the launcher's snapshot when the product CLI booted the tree, and otherwise the inherited environment as the only layer. The fallback does not weaken the rules: an SDK host or a bare `cordis.yml` discovered no files, so everything it has is the environment it was launched with.
 
-### Scrubbing credentials for a child
-
-`withoutSensitiveEnv(process.env)` returns the environment without its credential-shaped entries — names matching `SENSITIVE_ENV_PATTERN` (`KEY`, `PASSWORD`, `SECRET`, `TOKEN`, case-insensitively) — and without unset values, for a child that must not see the harness's credentials: the package probe in `dsh-app-boot` spawns with it, and `dsh-subprocess` scrubs every spawned command by the same pattern.
-
 -----
 
 <a id="understand-the-implementation"></a>

+ 0 - 4
packages/util/launch-environment/README.zh.md

@@ -53,10 +53,6 @@ const endpoint = launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value
 
 当产品 CLI(命令行界面)引导了这棵树时,`launchEnvironmentOf(ctx)` 返回启动器的快照;否则返回只含继承环境的那一层。该回退并不削弱规则:SDK 宿主或裸 `cordis.yml` 从未发现过任何文件,因此它拥有的一切就是它被启动时的环境。
 
-### 为子进程剔除凭据
-
-`withoutSensitiveEnv(process.env)` 返回去掉了密钥形态条目——名字匹配 `SENSITIVE_ENV_PATTERN`(`KEY`、`PASSWORD`、`SECRET`、`TOKEN`,不分大小写)——且去掉了未设置值的环境,给不该看到 harness 凭据的子进程用:`dsh-app-boot` 的包探针用它生成子进程,`dsh-subprocess` 按同一模式清洗每条被生成的命令。
-
 -----
 
 <a id="understand-the-implementation"></a>

+ 0 - 21
packages/util/launch-environment/src/index.ts

@@ -135,24 +135,3 @@ declare module '@deepseek-ai/cordis' {
     launchEnvironment?: LaunchEnvironmentSnapshot
   }
 }
-
-/**
- * Environment names that carry credentials — API keys, passwords, secrets,
- * tokens — matched case-insensitively. The one rule every harness child spawn
- * scrubs by, so the harness's own credentials never reach a child implicitly.
- */
-export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i
-
-/**
- * An environment without its credential-shaped entries, for a child that must
- * not see the harness's credentials: a package probe, a spawned tool.
- * @param env - the environment to scrub, typically `process.env`.
- * @returns a fresh object holding every entry whose name is not credential-shaped and whose value is set.
- */
-export function withoutSensitiveEnv(env: NodeJS.ProcessEnv): Record<string, string> {
-  const scrubbed: Record<string, string> = {}
-  for (const [name, value] of Object.entries(env)) {
-    if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(name)) scrubbed[name] = value
-  }
-  return scrubbed
-}

+ 0 - 10
packages/util/launch-environment/tests/launch-environment.spec.ts

@@ -2,18 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import {
   createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY, launchedThroughSsh, launchEnvironmentOf,
-  SENSITIVE_ENV_PATTERN, withoutSensitiveEnv,
 } from '../src/index.ts'
 
-describe('withoutSensitiveEnv', () => {
-  it('drops credential-shaped names, case-insensitively, and unset values', () => {
-    expect(withoutSensitiveEnv({
-      PATH: '/bin', DEEPSEEK_API_KEY: 'k', npm_config_token: 't', Secret_Thing: 's', DB_PASSWORD: 'p', UNSET: undefined,
-    })).toEqual({ PATH: '/bin' })
-    expect(SENSITIVE_ENV_PATTERN.test('HOME')).toBe(false)
-  })
-})
-
 const layered = createLaunchEnvironmentSnapshot([
   { source: 'process', values: { SHARED: 'from-process', ONLY_PROCESS: 'p' } },
   { source: 'project-env', path: '/work/.env', values: { SHARED: 'from-project', ONLY_PROJECT: 'j' } },