Bläddra i källkod

feat(plugins): plugin manager, preset user layers, and the patch-file writer

Add `dsh-host-plugin-manager`: the `pluginManager` service and the
`plugins` Remote that install, uninstall, enable, disable, and retry
bundles over the booted profile, add and remove rows in the profile's
or an agent preset's user layer, report what a package's services keep
alive, and fold the manifest, the probe record, and the live tree into
one view per package. pnpm runs the way the CLI runs it, streaming its
output as `plugins/install-log`; every change emits `plugins/changed`,
and api-remotes forwards both. Enabling is the Loader's own
transaction: a rejected recomposition restores the layer list, and a
runtime recomposition records contained rows through
`recordContainedStates` because the boot audit does not run again;
`ContainedGroup.create` now records a row that resolved pending rather
than clearing it.

Give every agent preset a user patch layer: `cordis.patch.yml` beside
a locally authored composition, or alone in `.agent-presets/<id>/`
for a shipped one. Discovery attaches and judges it, the mount applies
it as the include's runtime patches, standing generations follow the
layer's content and return to a retired generation when it comes back,
the composition inventory reports each row's `source` and
`disabledBy`, a copy carries the layer, and `removeOverlay` deletes it.
`dsh-global-tool-mask` is `tools.restrict()` as a scope-only row for
such a layer.

Add `dsh-patch-file` as the one home of the patch-list format: the
parser moved out of app-boot, plus a comment-preserving key-level
writer with lock, atomic replace, and readback. app-boot exports
`layerTrust`, `bundleLayerPatches`, and `recordContainedStates`, and
`ProfileRuntime` carries the install anchor.
Yichen Jiang 2 veckor sedan
förälder
incheckning
dc969e2ee8
93 ändrade filer med 5521 tillägg och 226 borttagningar
  1. 6 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.i18n.yaml
  2. 37 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
  3. 37 0
      .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.i18n.yaml
  5. 35 0
      .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md
  6. 35 0
      .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md
  7. 1 0
      apps/cli/package.json
  8. 2 14
      apps/cli/src/profile-boot.ts
  9. 2 2
      docs/architecture.i18n.yaml
  10. 1 1
      docs/architecture.md
  11. 1 1
      docs/architecture.zh.md
  12. 2 2
      docs/capability-seams.i18n.yaml
  13. 6 1
      docs/capability-seams.md
  14. 6 1
      docs/capability-seams.zh.md
  15. 2 2
      docs/config-catalog.i18n.yaml
  16. 42 1
      docs/config-catalog.md
  17. 42 1
      docs/config-catalog.zh.md
  18. 2 2
      docs/event-producer-consumer.i18n.yaml
  19. 2 0
      docs/event-producer-consumer.md
  20. 2 0
      docs/event-producer-consumer.zh.md
  21. 2 2
      docs/module-graph.i18n.yaml
  22. 14 1
      docs/module-graph.md
  23. 14 1
      docs/module-graph.zh.md
  24. 2 2
      docs/subsystems/core.i18n.yaml
  25. 171 0
      docs/subsystems/core.md
  26. 171 0
      docs/subsystems/core.zh.md
  27. 1 0
      packages/api/remotes/package.json
  28. 4 1
      packages/api/remotes/src/client/index.ts
  29. 3 0
      packages/api/remotes/src/remote-events.ts
  30. 3 0
      packages/api/remotes/tsconfig.client.json
  31. 3 0
      packages/api/remotes/tsconfig.host.json
  32. 2 2
      packages/boot/app-boot/README.i18n.yaml
  33. 4 4
      packages/boot/app-boot/README.md
  34. 4 4
      packages/boot/app-boot/README.zh.md
  35. 1 0
      packages/boot/app-boot/package.json
  36. 34 2
      packages/boot/app-boot/src/contained-group.ts
  37. 12 0
      packages/boot/app-boot/src/external-bundles.ts
  38. 53 71
      packages/boot/app-boot/src/index.ts
  39. 7 0
      packages/boot/app-boot/src/profile-runtime.ts
  40. 16 6
      packages/boot/app-boot/src/profile.ts
  41. 24 0
      packages/boot/app-boot/tests/contained-group.spec.ts
  42. 2 0
      packages/boot/app-boot/tests/profile-runtime.spec.ts
  43. 3 0
      packages/boot/app-boot/tsconfig.json
  44. 7 0
      packages/bundle/web-app/cordis.patch.yml
  45. 1 0
      packages/bundle/web-app/package.json
  46. 1 1
      packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx
  47. 179 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  48. 2 2
      packages/host/plugin-inventory/README.i18n.yaml
  49. 1 1
      packages/host/plugin-inventory/README.md
  50. 1 1
      packages/host/plugin-inventory/README.zh.md
  51. 4 0
      packages/host/plugin-inventory/src/types.ts
  52. 6 0
      packages/host/plugin-manager/README.i18n.yaml
  53. 132 0
      packages/host/plugin-manager/README.md
  54. 132 0
      packages/host/plugin-manager/README.zh.md
  55. 75 0
      packages/host/plugin-manager/package.json
  56. 858 0
      packages/host/plugin-manager/src/index.ts
  57. 222 0
      packages/host/plugin-manager/src/types.ts
  58. 754 0
      packages/host/plugin-manager/tests/plugin-manager.spec.ts
  59. 39 0
      packages/host/plugin-manager/tsconfig.json
  60. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  61. 22 6
      packages/preset/agent-presets/README.md
  62. 22 6
      packages/preset/agent-presets/README.zh.md
  63. 2 0
      packages/preset/agent-presets/package.json
  64. 35 0
      packages/preset/agent-presets/src/authoring.ts
  65. 83 11
      packages/preset/agent-presets/src/composition-inventory.ts
  66. 127 13
      packages/preset/agent-presets/src/discovery.ts
  67. 138 32
      packages/preset/agent-presets/src/index.ts
  68. 17 3
      packages/preset/agent-presets/src/mount.ts
  69. 8 0
      packages/preset/agent-presets/src/preset.ts
  70. 23 19
      packages/preset/agent-presets/tests/composition-inventory.spec.ts
  71. 2 2
      packages/preset/agent-presets/tests/mount.spec.ts
  72. 342 0
      packages/preset/agent-presets/tests/overlay.spec.ts
  73. 3 0
      packages/preset/agent-presets/tsconfig.json
  74. 6 0
      packages/preset/global-tool-mask/README.i18n.yaml
  75. 98 0
      packages/preset/global-tool-mask/README.md
  76. 98 0
      packages/preset/global-tool-mask/README.zh.md
  77. 42 0
      packages/preset/global-tool-mask/package.json
  78. 54 0
      packages/preset/global-tool-mask/src/index.ts
  79. 71 0
      packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts
  80. 22 0
      packages/preset/global-tool-mask/tsconfig.json
  81. 6 0
      packages/util/patch-file/README.i18n.yaml
  82. 112 0
      packages/util/patch-file/README.md
  83. 112 0
      packages/util/patch-file/README.zh.md
  84. 45 0
      packages/util/patch-file/package.json
  85. 397 0
      packages/util/patch-file/src/index.ts
  86. 251 0
      packages/util/patch-file/tests/patch-file.spec.ts
  87. 24 0
      packages/util/patch-file/tsconfig.json
  88. 96 0
      pnpm-lock.yaml
  89. 10 0
      scripts/gen-cordis-catalog.ts
  90. 8 1
      scripts/gen-doc-graphs.ts
  91. 3 0
      scripts/verify-package-readme-model-experience.ts
  92. 4 0
      tsconfig.base.json
  93. 3 0
      tsconfig.host.json

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-plugin-manager-over-the-profile-runtime.md
+2026-09-04-plugin-manager-over-the-profile-runtime.md: 8b12c1dcbee5e5f463fcba181441717de5b20c39
+2026-09-04-plugin-manager-over-the-profile-runtime.zh.md: 2f1fa6682124677c0614f24ef005f3efc212bd57

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

@@ -0,0 +1,37 @@
+# Agent Note: The plugin manager drives the profile runtime
+
+Status: implemented
+
+English | [中文](2026-09-04-plugin-manager-over-the-profile-runtime.zh.md)
+
+## 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.
+
+## Decision
+
+**One service, one manifest.** `dsh-host-plugin-manager` provides `pluginManager` and the `plugins` Remote: `list`, `install`, `uninstall`, `enable`, `disable`, `retry`, `addRow`, `removeRow`, `setRowDisabled`, `dependents`. 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`.
+
+**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.
+
+**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; 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`.
+
+**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.
+
+**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.
+
+## 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.
+
+**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.
+
+**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.
+
+## 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.
+
+## Testing
+
+`packages/host/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), 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/boot/app-boot/tests/contained-group.spec.ts` pins the waiting-row record across a reload.

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

@@ -0,0 +1,37 @@
+# Agent Note:插件管理器驱动 profile runtime
+
+Status: implemented
+
+[English](2026-09-04-plugin-manager-over-the-profile-runtime.md) | 中文
+
+## 问题
+
+安装插件曾是只有终端能做的事:`dsh plugin --profile web add <spec>` 运行 pnpm,把找到的每个组合包追加进 `dsh.profile.bundles`,下次启动再组合。运行中的任何东西都无法得知哪些包装了但没启用,无法不手改 `package.json` 就关掉一个组合包,无法把某个包的模块加进 profile 的用户层或某个 agent preset,也说不出一个包的服务撑着哪些行。Web 界面能经 `pluginInventory/list` 列出行,仅此而已;而 launcher 的 `profileRuntime`(前一篇笔记)已经能在用户 patch 重载时重新组合树,`reconcileInstalledBundles` 也已经把安装与启用分开。缺的是执行这些操作并把每个包报告成一个整体的宿主服务。
+
+## 决定
+
+**一个服务,一份 manifest。** `dsh-host-plugin-manager` 提供 `pluginManager` 与 `plugins` Remote:`list`、`install`、`uninstall`、`enable`、`disable`、`retry`、`addRow`、`removeRow`、`setRowDisabled`、`dependents`。每个操作都重新读取 profile manifest,并通过 CLI 所用的同一组 app-boot 助手——`reconcileInstalledBundles`、`enableBundle`、`disableBundle`——写回,因此 CLI 与管理器不可能对这个文件有分歧:`dependencies` 说装了什么,`dsh.profile.bundles` 说启用了什么。profile runtime 按调用解析而非注入,于是 web 组合包的这一行在不经 profile launcher 启动的组合里也能启动,并回答 `plugins/unavailable`。
+
+**启用就是 Loader 的事务。** `enable` 把组合包放进层列表,在 `healProfilesModuleFallback` 链接好该组合包携带的包之后调用 `profileRuntime.recompose({ reloadBundles: true })`。被拒绝的重新组合——`boot` 阶段而行抛错的组合包——就是 Loader 回滚到原本运行的树;管理器恢复层列表并报告 `plugins/enable-failed`。`runtime` 阶段而行失败的组合包由受控组隔离并逐行报告。由于启动审计不会再跑一次,管理器在在线重新组合之后调用 `recordContainedStates`,而 `ContainedGroup.create` 现在把以 pending 状态完成创建的行记录下来而不是清除——重载会重新创建组里的每一行,等待中的行必须带着记录穿过这一过程。`retry` 是先停用再启用:Loader 的更新不碰未改变的行,只有离开再回来才能重启一条失败的隔离行。
+
+**pnpm 按 CLI 的方式运行。** 经 `node:child_process`、带父进程环境、Windows 上开 `shell`,而不经 subprocess seam:seam 会清洗 pnpm 访问 registry 与代理所需的形似密钥的变量,也没有解析 `.cmd` shim 的 shell 模式。输出以某个 job id 下的 `plugins/install-log` 分块流式发出;非零退出、spawn 错误或超时即带日志尾部的 `plugins/install-failed`。新包被探测并保持停用,除非调用方要求 `enable`。
+
+**行经补丁文件写入器落地。** `addRow` 把 `{ id, name, config }` 插入 profile 的 `cordis.patch.yml` 或某个 agent preset 的用户层(经 roster 的 `overlayPathFor`),id 由包名与子路径派生;`setRowDisabled` 只写拒绝,写入或移除 `disabled: true`,因此组合包的 `!!js` 门被恢复而不是被覆盖。全局层当场重新组合;preset 的层在其下一个常驻代际生效。
+
+**每个包一份视图。** `list` 把 manifest、探针记录(缓存在 `.dsh-plugins/` 下,版本变化即刷新)与在线树折叠成一个 `status`:按活跃行数是 `running`、`partial` 或 `failed`;`disabled`;带探针原因的 `not-enableable`;`startup` 重载的 profile 上 manifest 与树不一致时是 `restart-required`;库或插件模块是 `plain`。行在已组合时来自树,否则来自探针,并已带上 launcher 将使用的前缀 id;树外组合包的 trust 来自 `layerTrust`,这也是 `loadProfile` 所用的同一条规则。
+
+## 考虑过的替代方案
+
+**经 `ctx.subprocess` 运行 pnpm。** 本次否决:seam 没有 shell 模式且会清洗环境;为一个调用方给 seam 加上两者,比管理器本身改动更大,而 CLI 的 spawn 已被验证。
+
+**`retry` 时原地重启失败的行。** 否决:行的 options 未变,Loader 的事务性更新不会碰它;手工重建一行会绕过组自己的创建路径及其失败记录。
+
+**不探测就启用组合包。** 否决:正是探针把无法 import、或解析到自己那份 cordis 副本的包,在树被要求挂载之前变成带原因的 `not-enableable` 视图。
+
+## 后果
+
+运行中的 Web 宿主可以在 live profile 上不重启地安装、启用、停用、重试与移除三方组合包,并把它们的模块加进全局层或某个 preset。更新已加载的包仍需重启(Node 的模块缓存);`dependents` 止于注入边;`engines.dsh` 只报告不强制;客户端 UI 在后续 PR 到来。
+
+## 测试
+
+`packages/host/plugin-manager/tests/plugin-manager.spec.ts` 经 `boot()` 启动一个临时 profile,带上 launcher 提供的 profile runtime 与一个按真实 pnpm 的方式编辑 manifest 的假 pnpm:视图折叠(已安装、已启用、已探测、等待中、用户停用、一方包、手写 manifest),带与不带启用的安装及其失败(退出码、spawn 错误、超时、日志尾部),live 与 `startup` profile 上的启用与停用,boot 阶段的回滚,不稳定隔离行的重试,全局层与 preset 层里的行及其冲突,按提供服务与按用户层引用的依赖检测,以及卸载。`packages/boot/app-boot/tests/contained-group.spec.ts` 钉住等待中的行记录跨重载保留。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md
+2026-09-04-preset-user-patch-layer-and-patch-file-writer.md: 6610e28584b57a91443b1bde2e7d81b345ef853d
+2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md: 62a92eb366263407b8122bda29a2caaad65fd706

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.md

@@ -0,0 +1,35 @@
+# Agent Note: A preset takes a user patch layer, and patch files get one home
+
+Status: implemented
+
+English | [中文](2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md)
+
+## Problem
+
+A shipped agent preset was read-only in every sense: to switch one of its rows off or add a tool to it, a person copied the whole preset and edited the copy, which then drifted from the shipped one on every upgrade. The profile already had the shape that solves this — a base composition plus a `cordis.patch.yml` user layer applied over it — and the preset had nothing of the kind. Separately, that file format had one parser in `dsh-app-boot` and no writer: a program that wanted to switch a row off had to rewrite the whole file, losing the author's comments and any `!!js` gate it did not understand.
+
+## Decision
+
+**A preset's user layer is `cordis.patch.yml` in its slot.** Beside the composition for a locally authored preset; alone in the user root's directory of the same id (`$DSH_HOME/.agent-presets/<id>/cordis.patch.yml`) for a shipped one, whose install stays untouched. Discovery attaches the layer to the preset that wins the id, judges it with the composition (an unparsable layer, a malformed insert, or an inserted row naming a module that cannot resolve makes the preset broken with that reason), and reports a layer whose id no root supplies as a broken slot rather than hiding it. The mount hands the parsed layer to the include as its runtime patches, so it applies with the Loader's own patch semantics. The composition inventory applies it before flattening and marks each row `source: 'preset' | 'user'` and, when off, `disabledBy: 'composition' | 'user'`. A copy carries the layer in beside the new composition; `removeOverlay` deletes it and an emptied slot.
+
+**Generations follow the layer's content.** The standing mount's stamp now covers the composition file's stat and a digest of the layer's text. A changed layer starts the next generation for sessions created afterwards, as an edited composition always did; a layer edited back to a content an earlier generation composed returns to that generation, so switching a row off and on again does not stack a third live subtree.
+
+**`dsh-patch-file` owns the format.** `parsePatchList` moved out of `dsh-app-boot` and is the one parser (js-yaml with the include's `!!js` dialect, relative names anchored to the file); `PatchDocument` edits a file at the key level through the `yaml` package's comment-preserving document, which keeps an unresolved `!!js` tag on its scalar and prints it back verbatim; `mutatePatchFile` takes the `dsh-atomic-write` lock, reads, edits, replaces atomically when the document is dirty, and parses the written text back. A removed row's comment block moves to its neighbour or the document's trailing comment instead of vanishing.
+
+**`dsh-global-tool-mask` is `tools.restrict()` as a row.** A preset's layer can add rows but could not subtract a host tool; this scope-only row masks named global tools for the sessions of one preset and rejects an unscoped mount, an empty mask, and a name the host does not register.
+
+## Alternatives considered
+
+**A settings namespace per preset for enablement.** Rejected earlier in the design: enablement is composition, not preference, and the profile's own user layer is already a patch file; a preset's should be the same file in the same format.
+
+**Editing the shipped composition in place.** Rejected: an upgrade overwrites it, and a session already running would be the one reading a half-written file.
+
+**A second parser in `dsh-agent-presets` to avoid a dependency on `dsh-app-boot`.** Rejected: two parsers of one format drift; the format got its own package instead, which both depend on.
+
+## Consequences
+
+A person hides a tool from `standard` with three lines under `.agent-presets/standard/` and keeps the shipped composition; the plugin manager writes the same file for them. A layer applies to the sessions created after it changes, never to running ones. The layer addresses rows by the composition's own ids, so an id the composition leaves anonymous cannot be targeted.
+
+## Testing
+
+`packages/preset/agent-presets/tests/overlay.spec.ts` pins discovery (attach, own layer, orphan slot, unparsable, malformed, unresolvable insert, composition verdict first), the mount applying the layer, generations across edits with the retired generation reused, the inventory's `source` and `disabledBy` from the file and from a mount, the layer path for shipped and authored presets, copy carrying the layer, and removal. `packages/util/patch-file/tests/patch-file.spec.ts` pins the parser, the document edits with comments and `!!js` intact, and the locked atomic mutation with readback. `packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts` pins the row's scoping and refusals.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-04-preset-user-patch-layer-and-patch-file-writer.zh.md

@@ -0,0 +1,35 @@
+# Agent Note:preset 获得用户补丁层,补丁文件有了唯一的家
+
+Status: implemented
+
+[English](2026-09-04-preset-user-patch-layer-and-patch-file-writer.md) | 中文
+
+## 问题
+
+随附的 agent preset 在任何意义上都是只读的:想关掉它的一行或给它加一个工具,只能整个复制 preset 再改副本,而副本从此在每次升级时与随附版本渐行渐远。profile 早已具备解决这件事的形状——一份基底组合加一层施加其上的 `cordis.patch.yml` 用户层——而 preset 没有任何对应物。另一方面,这种文件格式在 `dsh-app-boot` 里只有一个解析器而没有写入器:想关掉一行的程序只能整个改写文件,丢掉作者的注释和它看不懂的 `!!js` 门。
+
+## 决定
+
+**preset 的用户层就是其槽位里的 `cordis.patch.yml`。** 本地创作的 preset 放在组合旁边;随附 preset 则单独放在用户根目录里同 id 的目录(`$DSH_HOME/.agent-presets/<id>/cordis.patch.yml`),其安装目录不动。discovery 把这一层挂到赢得该 id 的 preset 上,与组合一起判定健康(不可解析的层、畸形的 insert、或插入了无法解析模块的行,都以该原因让 preset 变为 broken),并把没有任何根提供其 id 的层报告为损坏的槽位而不是藏起来。mount 把解析后的层作为 include 的运行时 patches 交上去,于是它以 Loader 自己的补丁语义施加。组合清单在展平前先施加它,并给每一行标上 `source: 'preset' | 'user'`,关闭时再标上 `disabledBy: 'composition' | 'user'`。复制会把该层带到新组合旁边;`removeOverlay` 删除它以及被清空的槽位。
+
+**代际跟随层的内容。** 常驻挂载的印记现在覆盖组合文件的 stat 与层文本的摘要。层变化即为之后创建的会话开启下一代,一如组合被编辑时的既有行为;层被改回某个早先代际组合过的内容时,回到那一代,因此一行关掉再打开不会堆出第三棵在线子树。
+
+**`dsh-patch-file` 拥有这种格式。** `parsePatchList` 从 `dsh-app-boot` 搬出,成为唯一的解析器(js-yaml 配 include 的 `!!js` 方言,相对名字锚定到文件);`PatchDocument` 通过 `yaml` 包保留注释的 document 在键级编辑文件,它把未解析的 `!!js` 标签留在标量上并原样打印;`mutatePatchFile` 拿 `dsh-atomic-write` 的锁,读取、编辑、在 document 变脏时原子替换,并把写出的文本回读解析。被移除行上方的注释块移到邻居或文档尾注释,而不是消失。
+
+**`dsh-global-tool-mask` 是写成行的 `tools.restrict()`。** preset 的层能加行却减不掉宿主工具;这一仅限作用域的行对某个 preset 的会话遮蔽点名的全局工具,并拒绝无作用域挂载、空掩码与宿主未注册的名字。
+
+## 考虑过的替代方案
+
+**为每个 preset 用一个 settings 命名空间做启停。** 在设计阶段已否决:启停是组合而非偏好,profile 自己的用户层本就是补丁文件;preset 的应当是同样格式的同一种文件。
+
+**原地编辑随附组合。** 否决:升级会覆盖它,而正在运行的会话会读到写了一半的文件。
+
+**在 `dsh-agent-presets` 里再写一个解析器以避免依赖 `dsh-app-boot`。** 否决:同一格式的两个解析器会漂移;改为让格式拥有自己的包,两者都依赖它。
+
+## 后果
+
+一个人用 `.agent-presets/standard/` 下的三行就能对 `standard` 隐藏一个工具并保留随附组合;插件管理器替他们写同一个文件。层作用于其变化之后创建的会话,从不作用于运行中的会话。层按组合自己的 id 寻址行,因此组合留作匿名的行无法被定位。
+
+## 测试
+
+`packages/preset/agent-presets/tests/overlay.spec.ts` 钉住 discovery(附着、自有层、孤儿槽位、不可解析、畸形、不可解析的插入、组合判定优先)、施加层的挂载、编辑之间的代际及退役代际的复用、清单从文件与从挂载得到的 `source` 与 `disabledBy`、随附与自作 preset 的层路径、复制携带层,以及移除。`packages/util/patch-file/tests/patch-file.spec.ts` 钉住解析器、保留注释与 `!!js` 的文档编辑,以及带回读的加锁原子变更。`packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts` 钉住该行的作用域与拒绝。

+ 1 - 0
apps/cli/package.json

@@ -57,6 +57,7 @@
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-mcp-client": "workspace:^",
     "@deepseek-ai/dsh-persona": "workspace:^",
+    "@deepseek-ai/dsh-global-tool-mask": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
     "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",

+ 2 - 14
apps/cli/src/profile-boot.ts

@@ -19,8 +19,8 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
+  bundleLayerPatches,
   composeEntries,
-  composeExternalLayer,
   healProfilesModuleFallback,
   installFailLoud,
   installRuntimeGuards,
@@ -33,7 +33,6 @@ import {
   warnNestedFiberFailures,
   watchUserPatches,
   type Profile,
-  type ProfileLayer,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
@@ -149,18 +148,6 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
   ]
 }
 
-/**
- * The patches one bundle layer contributes. A built-in layer, or an external
- * layer the profile stages at boot, mounts its patches as written; every other
- * external layer mounts as one contained, id-prefixed group.
- * @param layer - the resolved layer.
- * @returns the layer's patches in application order.
- */
-export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] {
-  if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches
-  return layer.patches
-}
-
 /**
  * Load `name` and compose its effective patch stack: bundle layers in
  * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
@@ -306,6 +293,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     warnNestedFiberFailures(ctx, NAME, (line) => { process.stderr.write(`${line}\n`) })
     await ctx.plugin(ProfileRuntime, {
       profile: composed.profile,
+      installAnchor: INSTALL_ANCHOR,
       loadProfile: () => prepareProfile(options.profile),
       compose: composeFor,
       rootEntry: () => rootIncludeEntry(ctx),

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: d054e99bd988f1555985f80ddc6ee3f3011251d6
-architecture.zh.md: 246546518fea0fbde58f95e09802b814daab4b30
+architecture.md: fb71638bb8583978c4bca5c9751f8435d04f9e7a
+architecture.zh.md: 7a0c902e90771e8d399868ceda9798d92651f92b

+ 1 - 1
docs/architecture.md

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

+ 1 - 1
docs/architecture.zh.md

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

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 409315b7f15a124511db1baf9e28ed440006ad72
-capability-seams.zh.md: 33338ea631df091a16c7a5fea79a6cb12d5af092
+capability-seams.md: f6ba5a55f0c6dc9dd1571f23af51f6bee4af6d8a
+capability-seams.zh.md: ac43955e9fcff59438d4b602c2e45750dba8ad98

+ 6 - 1
docs/capability-seams.md

@@ -106,6 +106,8 @@ flowchart LR
   svc_planMode["ctx.planMode<br/>Plan collaboration state"]
   pkg_agent_presets["agent-presets"]
   svc_agentPresets["ctx.agentPresets<br/>Per-session agent composition"]
+  pkg_host_plugin_manager["host-plugin-manager"]
+  svc_pluginManager["ctx.pluginManager<br/>Plugin management over the booted profile"]
   pkg_app_boot["app-boot"]
   svc_profileRuntime["ctx.profileRuntime<br/>Booted profile facts and recomposition"]
   pkg_host_plugin_inventory["host-plugin-inventory"]
@@ -264,6 +266,7 @@ flowchart LR
   pkg_host_directory_picker --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
+  pkg_host_plugin_manager --> svc_pluginManager
   pkg_host_webserver --> svc_webServer
   pkg_inspector --> svc_inspector
   pkg_invariants --> svc_invariants
@@ -381,6 +384,7 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_profileRuntime --> pkg_host_plugin_inventory
+  svc_profileRuntime --> pkg_host_plugin_manager
   svc_sandbox --> pkg_bash_sandbox
   svc_sandbox --> pkg_terminal_bash
   svc_sandboxPolicy --> pkg_bash_sandbox
@@ -505,7 +509,8 @@ flowchart LR
 | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. |
-| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. |
+| `ctx.pluginManager` | `core` | [`host-plugin-manager`](../packages/host/plugin-manager) | - | - | - | Installs, enables, disables, retries, and removes bundles through pnpm and the profile runtime, edits rows in the profile's or a preset's user layer, and folds manifest, probe, and tree facts into one view per package for the plugins Remote. |
+| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row's inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share. |
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. |

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

@@ -108,6 +108,8 @@ flowchart LR
   svc_planMode["ctx.planMode<br/>Plan collaboration state"]
   pkg_agent_presets["agent-presets"]
   svc_agentPresets["ctx.agentPresets<br/>Per-session agent composition"]
+  pkg_host_plugin_manager["host-plugin-manager"]
+  svc_pluginManager["ctx.pluginManager<br/>Plugin management over the booted profile"]
   pkg_app_boot["app-boot"]
   svc_profileRuntime["ctx.profileRuntime<br/>Booted profile facts and recomposition"]
   pkg_host_plugin_inventory["host-plugin-inventory"]
@@ -266,6 +268,7 @@ flowchart LR
   pkg_host_directory_picker --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
+  pkg_host_plugin_manager --> svc_pluginManager
   pkg_host_webserver --> svc_webServer
   pkg_inspector --> svc_inspector
   pkg_invariants --> svc_invariants
@@ -383,6 +386,7 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_profileRuntime --> pkg_host_plugin_inventory
+  svc_profileRuntime --> pkg_host_plugin_manager
   svc_sandbox --> pkg_bash_sandbox
   svc_sandbox --> pkg_terminal_bash
   svc_sandboxPolicy --> pkg_bash_sandbox
@@ -507,7 +511,8 @@ flowchart LR
 | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 |
 | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 |
 | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 |
-| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 |
+| `ctx.pluginManager` | `core` | [`host-plugin-manager`](../packages/host/plugin-manager) | - | - | - | 经 pnpm 与 profile runtime 安装、启用、停用、重试与移除组合包,编辑 profile 或某个 preset 用户层里的行,并把 manifest、探针与树的事实折叠成每个包一份视图供 plugins Remote 使用。 |
+| `ctx.profileRuntime` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`host-plugin-inventory`](../packages/host/plugin-inventory), [`host-plugin-manager`](../packages/host/plugin-manager) | - | 由 profile launcher 在启动后提供:已组合的组合包层及其 trust 与 stage、每一行的插入层、用户 patch 文件停用的行,以及用户 patch 重载与运行时组合包变更共用的那一条重新组合路径。 |
 | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 |
 | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 |
 | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 |

+ 2 - 2
docs/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: cd84522a925025686607b3b8f252d0474e357fa7
-config-catalog.zh.md: d23b8471e9085bc1128f1dc32937a9070802506f
+config-catalog.md: 17a9aafed2def073be11f738c1b02e9cde6aaf32
+config-catalog.zh.md: cb531c531d6fb80227af497a0168ca638c17faa8

+ 42 - 1
docs/config-catalog.md

@@ -157,7 +157,7 @@ export interface PresetRoot {
 export type PresetTrust = 'system' | 'user'
 ```
 
-Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts)
+Source: [`packages/preset/agent-presets/src/preset.ts:60`](../packages/preset/agent-presets/src/preset.ts)
 
 <a id="deepseek-aidsh-agent-tool-presentation"></a>
 
@@ -747,6 +747,24 @@ Depends on: [`LocalConfig`](#deepseek-aidsh-fs-local)
 
 Source: [`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts)
 
+<a id="deepseek-aidsh-global-tool-mask"></a>
+
+## `@deepseek-ai/dsh-global-tool-mask`
+
+Requires: `tools`
+
+```ts config-catalog
+/** Plugin config: the global-tool mask, in `tools.restrict()`'s own vocabulary. */
+export interface Config {
+  /** Global tool names that stay visible; everything else is hidden. Empty means no allow list. */
+  allow: string[]
+  /** Global tool names hidden from this scope. Empty means no deny list. */
+  deny: string[]
+}
+```
+
+Source: [`packages/preset/global-tool-mask/src/index.ts:28`](../packages/preset/global-tool-mask/src/index.ts)
+
 <a id="deepseek-aidsh-goal"></a>
 
 ## `@deepseek-ai/dsh-goal`
@@ -874,6 +892,28 @@ export interface Config {
 
 Source: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
 
+<a id="deepseek-aidsh-host-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-host-plugin-manager`
+
+Requires: `loader`
+
+```ts config-catalog
+/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */
+export interface Config {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  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
+}
+```
+
+Source: [`packages/host/plugin-manager/src/index.ts:96`](../packages/host/plugin-manager/src/index.ts)
+
 <a id="deepseek-aidsh-host-webserver"></a>
 
 ## `@deepseek-ai/dsh-host-webserver`
@@ -3448,6 +3488,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-output-retention` ([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
+- `@deepseek-ai/dsh-patch-file` ([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/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))

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

@@ -159,7 +159,7 @@ export interface PresetRoot {
 export type PresetTrust = 'system' | 'user'
 ```
 
-来源:[`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/agent-presets/src/preset.ts)
+来源:[`packages/preset/agent-presets/src/preset.ts:60`](../packages/preset/agent-presets/src/preset.ts)
 
 <a id="deepseek-aidsh-agent-tool-presentation"></a>
 
@@ -749,6 +749,24 @@ export type Config = LocalConfig
 
 来源:[`packages/fs/fs-sandbox/src/index.ts:45`](../packages/fs/fs-sandbox/src/index.ts)
 
+<a id="deepseek-aidsh-global-tool-mask"></a>
+
+## `@deepseek-ai/dsh-global-tool-mask`
+
+需要:`tools`
+
+```ts config-catalog
+/** Plugin config: the global-tool mask, in `tools.restrict()`'s own vocabulary. */
+export interface Config {
+  /** Global tool names that stay visible; everything else is hidden. Empty means no allow list. */
+  allow: string[]
+  /** Global tool names hidden from this scope. Empty means no deny list. */
+  deny: string[]
+}
+```
+
+来源:[`packages/preset/global-tool-mask/src/index.ts:28`](../packages/preset/global-tool-mask/src/index.ts)
+
 <a id="deepseek-aidsh-goal"></a>
 
 ## `@deepseek-ai/dsh-goal`
@@ -876,6 +894,28 @@ export interface Config {
 
 来源:[`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
 
+<a id="deepseek-aidsh-host-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-host-plugin-manager`
+
+需要:`loader`
+
+```ts config-catalog
+/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */
+export interface Config {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  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
+}
+```
+
+来源:[`packages/host/plugin-manager/src/index.ts:96`](../packages/host/plugin-manager/src/index.ts)
+
 <a id="deepseek-aidsh-host-webserver"></a>
 
 ## `@deepseek-ai/dsh-host-webserver`
@@ -3449,6 +3489,7 @@ export interface Config {
 - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts))
 - `@deepseek-ai/dsh-native-command`([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts))
 - `@deepseek-ai/dsh-output-retention`([`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts))
+- `@deepseek-ai/dsh-patch-file`([`packages/util/patch-file/src/index.ts`](../packages/util/patch-file/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: 62eba828604ba1e90d9d7ef22743cf10daf84f70
-event-producer-consumer.zh.md: f82c379516d9aec00dade2acdf3f8291a340da44
+event-producer-consumer.md: 47aa3f6588c61b5843f1fd8c871192adc09efe28
+event-producer-consumer.zh.md: f83a5ea9f68276c3fa515e1bcb16287f7cddca91

+ 2 - 0
docs/event-producer-consumer.md

@@ -45,6 +45,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:68`](../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/host/plugin-manager/src/types.ts:212`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/host/plugin-manager/src/types.ts:218`](../packages/host/plugin-manager/src/types.ts) | `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:51`](../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:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `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) |

+ 2 - 0
docs/event-producer-consumer.zh.md

@@ -47,6 +47,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:68`](../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/host/plugin-manager/src/types.ts:212`](../packages/host/plugin-manager/src/types.ts) | `plugin-manager` (`emit`) | `remotes` |
+| `plugins/install-log` | `emit` | [`packages/host/plugin-manager/src/types.ts:218`](../packages/host/plugin-manager/src/types.ts) | `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:51`](../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:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `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) |

+ 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: 7dfe9a3b312c141e4df4c74f96a78deeade9b718
-module-graph.zh.md: 4e70a6a9baeda7d379672d4aa102806f9e816d3e
+module-graph.md: 18a54edf93d052e87a7e0d5f7430cebc902f5c3a
+module-graph.zh.md: ff41b5e11862b75eb075d3752b59c7eef6909e07

+ 14 - 1
docs/module-graph.md

@@ -16,6 +16,7 @@ flowchart TD
     pkg_launch_environment["launch-environment"]
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
+    pkg_patch_file["patch-file"]
     pkg_timeout["timeout"]
     pkg_util_crypto["util-crypto"]
     pkg_util_time["util-time"]
@@ -236,6 +237,7 @@ flowchart TD
     pkg_host_directory_picker_native["host-directory-picker-native"]
     pkg_host_frontend_static["host-frontend-static"]
     pkg_host_plugin_inventory["host-plugin-inventory"]
+    pkg_host_plugin_manager["host-plugin-manager"]
     pkg_host_webserver["host-webserver"]
   end
   subgraph group_identity["packages/identity"]
@@ -263,6 +265,7 @@ flowchart TD
   end
   subgraph group_preset["packages/preset"]
     pkg_agent_presets["agent-presets"]
+    pkg_global_tool_mask["global-tool-mask"]
     pkg_persona["persona"]
   end
   subgraph group_runtime_diagnostics["packages/runtime-diagnostics"]
@@ -813,6 +816,7 @@ flowchart TD
   pkg_agent_presets --> pkg_atomic_write
   pkg_agent_presets --> pkg_home_paths
   pkg_agent_presets --> pkg_invariants
+  pkg_agent_presets --> pkg_patch_file
   pkg_agent_presets --> pkg_scope
   pkg_agent_presets --> pkg_session
   pkg_agent_presets --> pkg_session_projection
@@ -820,6 +824,7 @@ flowchart TD
   pkg_agent_presets --> pkg_system_prompt
   pkg_agent_presets --> pkg_tools
   pkg_agent_presets --> pkg_typert_protocol
+  pkg_global_tool_mask --> pkg_tools
   pkg_schedule --> pkg_agent
   pkg_schedule --> pkg_brand
   pkg_schedule --> pkg_invariants
@@ -923,6 +928,11 @@ flowchart TD
   pkg_host_plugin_inventory --> pkg_app_boot
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_typert_protocol
+  pkg_host_plugin_manager --> pkg_agent_presets
+  pkg_host_plugin_manager --> pkg_app_boot
+  pkg_host_plugin_manager --> pkg_patch_file
+  pkg_host_plugin_manager --> pkg_typert_protocol
+  pkg_host_plugin_manager --> pkg_util_values
   pkg_tool_bash --> pkg_agent
   pkg_tool_bash --> pkg_jobs
   pkg_tool_bash --> pkg_llm
@@ -1159,6 +1169,7 @@ flowchart TD
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
+| [`patch-file`](../packages/util/patch-file) | `util` | — |
 | [`timeout`](../packages/util/timeout) | `util` | — |
 | [`util-crypto`](../packages/util/crypto) | `util` | — |
 | [`util-time`](../packages/util/time) | `util` | — |
@@ -1355,7 +1366,8 @@ flowchart TD
 | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`patch-file`](../packages/util/patch-file), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`global-tool-mask`](../packages/preset/global-tool-mask) | `preset` | [`tools`](../packages/core/tools) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1376,6 +1388,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |

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

@@ -18,6 +18,7 @@ flowchart TD
     pkg_launch_environment["launch-environment"]
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
+    pkg_patch_file["patch-file"]
     pkg_timeout["timeout"]
     pkg_util_crypto["util-crypto"]
     pkg_util_time["util-time"]
@@ -238,6 +239,7 @@ flowchart TD
     pkg_host_directory_picker_native["host-directory-picker-native"]
     pkg_host_frontend_static["host-frontend-static"]
     pkg_host_plugin_inventory["host-plugin-inventory"]
+    pkg_host_plugin_manager["host-plugin-manager"]
     pkg_host_webserver["host-webserver"]
   end
   subgraph group_identity["packages/identity"]
@@ -265,6 +267,7 @@ flowchart TD
   end
   subgraph group_preset["packages/preset"]
     pkg_agent_presets["agent-presets"]
+    pkg_global_tool_mask["global-tool-mask"]
     pkg_persona["persona"]
   end
   subgraph group_runtime_diagnostics["packages/runtime-diagnostics"]
@@ -815,6 +818,7 @@ flowchart TD
   pkg_agent_presets --> pkg_atomic_write
   pkg_agent_presets --> pkg_home_paths
   pkg_agent_presets --> pkg_invariants
+  pkg_agent_presets --> pkg_patch_file
   pkg_agent_presets --> pkg_scope
   pkg_agent_presets --> pkg_session
   pkg_agent_presets --> pkg_session_projection
@@ -822,6 +826,7 @@ flowchart TD
   pkg_agent_presets --> pkg_system_prompt
   pkg_agent_presets --> pkg_tools
   pkg_agent_presets --> pkg_typert_protocol
+  pkg_global_tool_mask --> pkg_tools
   pkg_schedule --> pkg_agent
   pkg_schedule --> pkg_brand
   pkg_schedule --> pkg_invariants
@@ -925,6 +930,11 @@ flowchart TD
   pkg_host_plugin_inventory --> pkg_app_boot
   pkg_host_plugin_inventory --> pkg_brand
   pkg_host_plugin_inventory --> pkg_typert_protocol
+  pkg_host_plugin_manager --> pkg_agent_presets
+  pkg_host_plugin_manager --> pkg_app_boot
+  pkg_host_plugin_manager --> pkg_patch_file
+  pkg_host_plugin_manager --> pkg_typert_protocol
+  pkg_host_plugin_manager --> pkg_util_values
   pkg_tool_bash --> pkg_agent
   pkg_tool_bash --> pkg_jobs
   pkg_tool_bash --> pkg_llm
@@ -1161,6 +1171,7 @@ flowchart TD
 | [`launch-environment`](../packages/util/launch-environment) | `util` | — |
 | [`native-command`](../packages/util/native-command) | `util` | — |
 | [`output-retention`](../packages/util/output-retention) | `util` | — |
+| [`patch-file`](../packages/util/patch-file) | `util` | — |
 | [`timeout`](../packages/util/timeout) | `util` | — |
 | [`util-crypto`](../packages/util/crypto) | `util` | — |
 | [`util-time`](../packages/util/time) | `util` | — |
@@ -1357,7 +1368,8 @@ flowchart TD
 | [`tool-jobs`](../packages/jobs/tool-jobs) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`patch-file`](../packages/util/patch-file), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
+| [`global-tool-mask`](../packages/preset/global-tool-mask) | `preset` | [`tools`](../packages/core/tools) |
 | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
 | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1378,6 +1390,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+| [`host-plugin-manager`](../packages/host/plugin-manager) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`app-boot`](../packages/boot/app-boot), [`patch-file`](../packages/util/patch-file), [`typert-protocol`](../packages/typert/protocol), [`util-values`](../packages/util/values) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: 70d12007ba1675cece6f0ed9c86ac89fcdfebf4e
-core.zh.md: 2bc19d2d1dbea704567de86494da3696d02705bd
+core.md: df34d06554cf11fabd107e5b288329298653e710
+core.zh.md: 85234c6fca3a3584055fa36cfa17fe7aa30c680d

+ 171 - 0
docs/subsystems/core.md

@@ -542,6 +542,28 @@ composedPreset(agentCtx: Context): string | undefined
  */
 async read(id: string): Promise<string>
 
+/**
+ * Where one preset's user patch layer is, or would be written: the layer
+ * discovery attached, else the writable root's slot of the same id — beside
+ * the composition for a locally authored preset, alone in the slot for a
+ * shipped one. The file need not exist yet.
+ * @param id - the preset id.
+ * @returns the absolute path of the layer file.
+ * @throws when the preset is unknown, or it has no layer and the
+ * deployment configures no writable root.
+ */
+async overlayPathFor(id: string): Promise<string>
+
+/**
+ * Delete one preset's user patch layer, so the next generation composes the
+ * preset exactly as its root supplies it. Sessions already joined keep the
+ * generation they run on.
+ * @param id - the preset id.
+ * @returns true when a layer was removed; false when the preset had none.
+ * @throws when the preset is unknown or its layer lies outside the writable root.
+ */
+async removeOverlay(id: string): Promise<boolean>
+
 /**
  * One preset's composition text with the roster row it belongs to.
  * @param agentPreset - the preset id.
@@ -838,6 +860,117 @@ roots(): Agent[]
 
 Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
 
+<a id="ctxpluginmanager--pluginmanager"></a>
+
+### `ctx.pluginManager` — `PluginManager`
+
+The `pluginManager` service and the `plugins` Remote.
+
+Every method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.
+
+```ts cordis-catalog
+/**
+ * Every package the profile knows: its template and installed bundles,
+ * and every other installed dependency.
+ * @returns one view per package, bundles first in layer order.
+ */
+@Remote('list') async list(): Promise<PluginPackageView[]>
+
+/**
+ * Install a package into the profile with pnpm, probe it, and leave it
+ * disabled unless asked otherwise. The run's output streams as
+ * `plugins/install-log` chunks carrying the returned `jobId`.
+ * @param spec - what to install, in pnpm's own vocabulary: a registry
+ * name, a `github:` or git URL, a tarball, or an absolute path.
+ * @param options - `enable` puts every newly installed bundle into the layer list at once.
+ * @returns what the run installed and enabled.
+ * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero
+ * or the run times out, `plugins/enable-failed` when enabling was asked
+ * for and the tree rejected the bundle.
+ */
+@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
+
+/**
+ * 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.
+ * @param packageName - the installed dependency to remove.
+ * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.
+ */
+@Remote('uninstall') async uninstall(packageName: string): Promise<void>
+
+/**
+ * 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.
+ * @param packageName - the installed bundle.
+ * @returns whether the list changed and whether the change is live.
+ * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
+ * for a package that declares no bundle or whose probe refused it, or
+ * `plugins/enable-failed`.
+ */
+@Remote('enable') async enable(packageName: string): Promise<PluginEnableResult>
+
+/**
+ * Take a bundle out of the layer list and, on a live profile, recompose
+ * the tree without it.
+ * @param packageName - the enabled bundle.
+ * @returns whether the list changed and whether the change is live.
+ * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.
+ */
+@Remote('disable') async disable(packageName: string): Promise<PluginEnableResult>
+
+/**
+ * Compose an enabled bundle again from scratch: its group leaves the tree
+ * and returns, so rows that failed at boot get another start.
+ * @param packageName - the enabled bundle.
+ * @returns the enable outcome of the second step.
+ * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.
+ */
+@Remote('retry') async retry(packageName: string): Promise<PluginEnableResult>
+
+/**
+ * Add a row naming one of the package's modules to a user layer: the
+ * profile's global `cordis.patch.yml`, or an agent preset's.
+ * @param packageName - the installed package.
+ * @param target - which layer.
+ * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
+ * `id` overrides the derived row id, `config` overrides the declared default.
+ * @returns where the row landed.
+ * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
+ * when the module is not one the probe found addable, `plugins/row-conflict`,
+ * or `plugins/unavailable` for a preset target without a roster.
+ */
+@Remote('addRow') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise<PluginRowAddition>
+
+/**
+ * Remove a row a user layer inserted.
+ * @param target - which layer.
+ * @param rowId - the inserted row's id.
+ * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row.
+ */
+@Remote('removeRow') async removeRow(target: PluginRowTarget, rowId: string): Promise<void>
+
+/**
+ * Switch one row off or on in a user layer. Deny-only: `true` writes
+ * `disabled: true` for the row, `false` removes that key, so a bundle's
+ * own `!!js` gate is restored rather than overridden.
+ * @param target - which layer.
+ * @param rowId - the row's id as the composition declares it.
+ * @param disabled - whether the layer should switch the row off.
+ */
+@Remote('setRowDisabled') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void>
+
+/**
+ * What disabling or removing a package would strand: services its rows
+ * provide that rows outside it inject, and user-layer rows naming its modules.
+ * @param packageName - the package.
+ * @returns the dependents.
+ */
+@Remote('dependents') async dependents(packageName: string): Promise<PluginDependents>
+```
+
+Source: [`packages/host/plugin-manager/src/index.ts`](../../packages/host/plugin-manager/src/index.ts)
+
 <a id="ctxprofileruntime--profileruntime"></a>
 
 ### `ctx.profileRuntime` — `ProfileRuntime`
@@ -1243,4 +1376,42 @@ One session committed a different agent preset to its durable log. Consumers inv
 ```
 
 Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts)
+
+<a id="plugins-events"></a>
+
+### `plugins/*` events
+
+<a id="pluginschanged--emit"></a>
+
+#### `plugins/changed` — emit
+
+The manager changed what is installed, enabled, or composed.
+
+```ts cordis-catalog
+/**
+ * The manager changed what is installed, enabled, or composed.
+ * @mode emit
+ * @param change - why, and which package when one is concerned.
+ */
+'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
+```
+
+Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
+
+<a id="pluginsinstall-log--emit"></a>
+
+#### `plugins/install-log` — emit
+
+One chunk of an install run's output, in order; the last chunk carries the exit code.
+
+```ts cordis-catalog
+/**
+ * One chunk of an install run's output, in order; the last chunk carries the exit code.
+ * @mode emit
+ * @param chunk - the chunk.
+ */
+'plugins/install-log'(chunk: PluginInstallLogChunk): void
+```
+
+Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
 <!-- END GENERATED cordis-surface -->

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

@@ -552,6 +552,28 @@ composedPreset(agentCtx: Context): string | undefined
  */
 async read(id: string): Promise<string>
 
+/**
+ * Where one preset's user patch layer is, or would be written: the layer
+ * discovery attached, else the writable root's slot of the same id — beside
+ * the composition for a locally authored preset, alone in the slot for a
+ * shipped one. The file need not exist yet.
+ * @param id - the preset id.
+ * @returns the absolute path of the layer file.
+ * @throws when the preset is unknown, or it has no layer and the
+ * deployment configures no writable root.
+ */
+async overlayPathFor(id: string): Promise<string>
+
+/**
+ * Delete one preset's user patch layer, so the next generation composes the
+ * preset exactly as its root supplies it. Sessions already joined keep the
+ * generation they run on.
+ * @param id - the preset id.
+ * @returns true when a layer was removed; false when the preset had none.
+ * @throws when the preset is unknown or its layer lies outside the writable root.
+ */
+async removeOverlay(id: string): Promise<boolean>
+
 /**
  * One preset's composition text with the roster row it belongs to.
  * @param agentPreset - the preset id.
@@ -848,6 +870,117 @@ roots(): Agent[]
 
 Source: [`packages/core/agent/src/index.ts`](../../packages/core/agent/src/index.ts)
 
+<a id="ctxpluginmanager--pluginmanager"></a>
+
+### `ctx.pluginManager` — `PluginManager`
+
+The `pluginManager` service and the `plugins` Remote.
+
+Every method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.
+
+```ts cordis-catalog
+/**
+ * Every package the profile knows: its template and installed bundles,
+ * and every other installed dependency.
+ * @returns one view per package, bundles first in layer order.
+ */
+@Remote('list') async list(): Promise<PluginPackageView[]>
+
+/**
+ * Install a package into the profile with pnpm, probe it, and leave it
+ * disabled unless asked otherwise. The run's output streams as
+ * `plugins/install-log` chunks carrying the returned `jobId`.
+ * @param spec - what to install, in pnpm's own vocabulary: a registry
+ * name, a `github:` or git URL, a tarball, or an absolute path.
+ * @param options - `enable` puts every newly installed bundle into the layer list at once.
+ * @returns what the run installed and enabled.
+ * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero
+ * or the run times out, `plugins/enable-failed` when enabling was asked
+ * for and the tree rejected the bundle.
+ */
+@Remote('install') async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>
+
+/**
+ * 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.
+ * @param packageName - the installed dependency to remove.
+ * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.
+ */
+@Remote('uninstall') async uninstall(packageName: string): Promise<void>
+
+/**
+ * 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.
+ * @param packageName - the installed bundle.
+ * @returns whether the list changed and whether the change is live.
+ * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
+ * for a package that declares no bundle or whose probe refused it, or
+ * `plugins/enable-failed`.
+ */
+@Remote('enable') async enable(packageName: string): Promise<PluginEnableResult>
+
+/**
+ * Take a bundle out of the layer list and, on a live profile, recompose
+ * the tree without it.
+ * @param packageName - the enabled bundle.
+ * @returns whether the list changed and whether the change is live.
+ * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.
+ */
+@Remote('disable') async disable(packageName: string): Promise<PluginEnableResult>
+
+/**
+ * Compose an enabled bundle again from scratch: its group leaves the tree
+ * and returns, so rows that failed at boot get another start.
+ * @param packageName - the enabled bundle.
+ * @returns the enable outcome of the second step.
+ * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.
+ */
+@Remote('retry') async retry(packageName: string): Promise<PluginEnableResult>
+
+/**
+ * Add a row naming one of the package's modules to a user layer: the
+ * profile's global `cordis.patch.yml`, or an agent preset's.
+ * @param packageName - the installed package.
+ * @param target - which layer.
+ * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
+ * `id` overrides the derived row id, `config` overrides the declared default.
+ * @returns where the row landed.
+ * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
+ * when the module is not one the probe found addable, `plugins/row-conflict`,
+ * or `plugins/unavailable` for a preset target without a roster.
+ */
+@Remote('addRow') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise<PluginRowAddition>
+
+/**
+ * Remove a row a user layer inserted.
+ * @param target - which layer.
+ * @param rowId - the inserted row's id.
+ * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row.
+ */
+@Remote('removeRow') async removeRow(target: PluginRowTarget, rowId: string): Promise<void>
+
+/**
+ * Switch one row off or on in a user layer. Deny-only: `true` writes
+ * `disabled: true` for the row, `false` removes that key, so a bundle's
+ * own `!!js` gate is restored rather than overridden.
+ * @param target - which layer.
+ * @param rowId - the row's id as the composition declares it.
+ * @param disabled - whether the layer should switch the row off.
+ */
+@Remote('setRowDisabled') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void>
+
+/**
+ * What disabling or removing a package would strand: services its rows
+ * provide that rows outside it inject, and user-layer rows naming its modules.
+ * @param packageName - the package.
+ * @returns the dependents.
+ */
+@Remote('dependents') async dependents(packageName: string): Promise<PluginDependents>
+```
+
+Source: [`packages/host/plugin-manager/src/index.ts`](../../packages/host/plugin-manager/src/index.ts)
+
 <a id="ctxprofileruntime--profileruntime"></a>
 
 ### `ctx.profileRuntime` — `ProfileRuntime`
@@ -1253,4 +1386,42 @@ One session committed a different agent preset to its durable log. Consumers inv
 ```
 
 Source: [`packages/preset/agent-presets/src/types.ts`](../../packages/preset/agent-presets/src/types.ts)
+
+<a id="plugins-events"></a>
+
+### `plugins/*` events
+
+<a id="pluginschanged--emit"></a>
+
+#### `plugins/changed` — emit
+
+The manager changed what is installed, enabled, or composed.
+
+```ts cordis-catalog
+/**
+ * The manager changed what is installed, enabled, or composed.
+ * @mode emit
+ * @param change - why, and which package when one is concerned.
+ */
+'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
+```
+
+Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
+
+<a id="pluginsinstall-log--emit"></a>
+
+#### `plugins/install-log` — emit
+
+One chunk of an install run's output, in order; the last chunk carries the exit code.
+
+```ts cordis-catalog
+/**
+ * One chunk of an install run's output, in order; the last chunk carries the exit code.
+ * @mode emit
+ * @param chunk - the chunk.
+ */
+'plugins/install-log'(chunk: PluginInstallLogChunk): void
+```
+
+Source: [`packages/host/plugin-manager/src/types.ts`](../../packages/host/plugin-manager/src/types.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 0
packages/api/remotes/package.json

@@ -70,6 +70,7 @@
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
+    "@deepseek-ai/dsh-host-plugin-manager": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",

+ 4 - 1
packages/api/remotes/src/client/index.ts

@@ -8,6 +8,7 @@ import goalsRemote from '@deepseek-ai/dsh-goal/remote'
 import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
+import pluginManagerRemote from '@deepseek-ai/dsh-host-plugin-manager/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
 import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
 import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
@@ -23,6 +24,8 @@ export type {} from '@deepseek-ai/dsh-api-settings-controller/remote'
 export type {} from '@deepseek-ai/dsh-goal/remote'
 export type {} from '@deepseek-ai/dsh-llm/remote'
 export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
+export type {} from '@deepseek-ai/dsh-host-plugin-manager/remote'
+export type * from '@deepseek-ai/dsh-host-plugin-manager/types'
 export type {} from '@deepseek-ai/dsh-message-feedback/remote'
 export type {} from '@deepseek-ai/dsh-session-reference/remote'
 export type {} from '@deepseek-ai/dsh-subagent/remote'
@@ -145,7 +148,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
   try {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
-      pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
+      pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionReferencesRemote,
       subagentsRemote, sessionRemote, workspaceRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))

+ 3 - 0
packages/api/remotes/src/remote-events.ts

@@ -7,6 +7,7 @@
  */
 
 import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events'
+import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
 import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol'
 
 /**
@@ -30,6 +31,8 @@ export const API_REMOTE_FORWARDED_EVENTS = [
   { event: 'cordis/inspect-query', mode: 'emit' },
   { event: 'cordis/inspect-query-resolved', mode: 'emit' },
   { event: 'llm/adapters-updated', mode: 'emit' },
+  { event: 'plugins/changed', mode: 'emit' },
+  { event: 'plugins/install-log', mode: 'emit' },
   { event: 'settings/document-updated', mode: 'emit' },
   { event: 'user-questions/request', mode: 'waterfall' },
 ] as const satisfies readonly TypertForwardableEventEntry[]

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -42,6 +42,9 @@
     {
       "path": "../../host/plugin-inventory"
     },
+    {
+      "path": "../../host/plugin-manager"
+    },
     {
       "path": "../../interaction/commands"
     },

+ 3 - 0
packages/api/remotes/tsconfig.host.json

@@ -32,6 +32,9 @@
     {
       "path": "../../preset/agent-presets"
     },
+    {
+      "path": "../../host/plugin-manager"
+    },
     {
       "path": "../../extensions/cordis-host-runner"
     },

+ 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: 0190a9e1d4c4b6d775aa84ee608c763555acbcce
-README.zh.md: 6b3da08c8cbf5dcc68bf3d4991dda06c170c72af
+README.md: 8ab2f482263252e793478d4e45afed3e4ee3666d
+README.zh.md: 51687c5af81715354b33427d824d62083e83b160

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

@@ -56,7 +56,7 @@ Profiles with `patchReload: live` watch both user patch files: a valid edit reco
 
 A bundle you installed with `dsh plugin` is an **external** bundle: its rows mount under one contained group named `bundle/<package>`, every row id is prefixed `<package>/<id>`, and a row that fails to start is isolated and recorded instead of stopping the process — the group and its other rows stay up, and the plugin list shows the failure. Template bundles are built in and keep failing loud. A bundle that provides a service built-in rows inject must mount like a built-in one: its author declares `dsh.bundle.stage: boot` in `package.json`, or you set `dsh.profile.stages` in the profile manifest, which wins. Even without that, an isolated failure that leaves a built-in row waiting for a service still stops the boot and names the isolated bundle. Two more profile-manifest fields shape this: `dsh.profile.firstParty` lists installed packages treated as built in (a first-party package linked in during development), and `dependencies` versus `dsh.profile.bundles` distinguishes a package that is merely installed from one whose layer is enabled.
 
-After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one a runtime bundle enable or install uses. Startup's fail-loud rejection guard is uninstalled once the tree is up: an unhandled rejection after boot is reported and contained, an uncaught exception is reported and exits.
+After the tree is up the launcher provides `ctx.profileRuntime`, which holds the booted profile's facts and the installation anchor, attributes each row to the layer that inserted it, reads which rows the user patch files disable, and recomposes the tree — the same path the patch watchers take, and the one the [plugin manager](../../host/plugin-manager/README.md) uses to enable or retry a bundle; `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 contained, an uncaught exception is reported and exits.
 
 ### Previewing the effective configuration
 
@@ -101,9 +101,9 @@ The exports each own one stage of the boot: config resolution and snapshot repla
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard and runtime guards, activation audit, patch parsing, config dump, harness-source section |
-| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution with trust and stage, module fallback |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | External layer composition (contained group, id prefixing, patch rewriting) and the manifest operations behind install, enable, and disable |
+| [`src/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 `dsh-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, id prefixing, patch rewriting), `bundleLayerPatches`, and the manifest operations behind install, enable, and disable |
 | [`src/contained-group.ts`](src/contained-group.ts) | The `cordis:contained-group` builtin and the `pluginFailures` registry |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | The `profileRuntime` service: profile facts, row provenance, user-disabled rows, recomposition |
 | [`src/probe.ts`](src/probe.ts) | The child-process package probe and its per-profile cache |

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

@@ -56,7 +56,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 用 `dsh plugin` 安装的组合包是**外部**组合包:它的行挂在一个名为 `bundle/<package>` 的受控组下,每个行 id 都加上 `<package>/<id>` 前缀,启动失败的行被隔离并记录而不是让进程停下——组和它的其他行继续运行,插件列表显示失败。模板组合包是内置的,仍然明确失败。若某个组合包提供内置行注入的服务,它必须像内置行一样挂载:作者在 `package.json` 里声明 `dsh.bundle.stage: boot`,或者你在 profile manifest 里设置 `dsh.profile.stages`,后者优先。即使没有这些声明,隔离的失败若让某个内置行停在等待服务的状态,启动仍会失败并点名那个被隔离的组合包。profile manifest 还有两个相关字段:`dsh.profile.firstParty` 列出按内置处理的已安装包(开发期 link 进来的一方包),`dependencies` 与 `dsh.profile.bundles` 的区别则把"只是装了"的包和"层已启用"的包分开。
 
-树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,运行时启用或安装组合包也走它。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
+树起来之后 launcher 提供 `ctx.profileRuntime`:它持有已启动 profile 的事实与安装锚点,把每一行归属到插入它的层,读取用户 patch 文件停用了哪些行,并重新组合整棵树——patch 监视器走的正是这条路,[插件管理器](../../host/plugin-manager/README.zh.md) 启用或重试组合包也走它;这样的调用方之后会运行 `recordContainedStates`,因为启动审计不会再跑一次。启动期的 fail-loud rejection 守卫在树起来后卸载:启动后未处理的 rejection 会被报告并兜住,未捕获的异常会被报告并退出。
 
 ### 预览生效配置
 
@@ -101,9 +101,9 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计、patch 解析、配置 dump、harness 源码段落 |
-| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 trust 与 stage 的组合包解析、模块后备机制 |
-| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)与安装、启用、停用背后的 manifest 操作 |
+| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、fail-loud 守卫与运行时守卫、激活审计与 `recordContainedStates`、经 `dsh-patch-file` 加载 patch 文件、配置 dump、harness 源码段落 |
+| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、带 `layerTrust` 与 stage 的组合包解析、模块后备机制 |
+| [`src/external-bundles.ts`](src/external-bundles.ts) | 外部层组合(受控组、id 前缀、patch 改写)、`bundleLayerPatches`,与安装、启用、停用背后的 manifest 操作 |
 | [`src/contained-group.ts`](src/contained-group.ts) | `cordis:contained-group` builtin 与 `pluginFailures` 注册表 |
 | [`src/profile-runtime.ts`](src/profile-runtime.ts) | `profileRuntime` 服务:profile 事实、行来源、用户停用的行、重新组合 |
 | [`src/probe.ts`](src/probe.ts) | 子进程包探针及其按 profile 的缓存 |

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

@@ -28,6 +28,7 @@
   "license": "MIT",
   "dependencies": {
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
+    "@deepseek-ai/dsh-patch-file": "workspace:^",
     "js-yaml": "^4.2.0",
     "resolve.exports": "^2.0.3"
   },

+ 34 - 2
packages/boot/app-boot/src/contained-group.ts

@@ -8,9 +8,24 @@
  * @module @deepseek-ai/dsh-app-boot/contained-group
  */
 
-import type { Context } from '@deepseek-ai/cordis'
+import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis'
 import { Group, type Entry, type EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 
+/** Runtime mirror: FiberState is a cross-package const enum. */
+const FIBER_PENDING = 0 as FiberState.PENDING
+
+/**
+ * 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'
 
@@ -101,7 +116,24 @@ export class ContainedGroup extends Group {
   override async create(options: Omit<EntryOptions, 'id'>): Promise<string> {
     try {
       const id = await super.create(options)
-      this.registry()?.clear(id)
+      // 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.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()

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

@@ -142,6 +142,18 @@ export function composeExternalLayer(layer: ProfileLayer): ComposedExternalLayer
   return { patches: [{ insert: [group] }, ...trailing], rows, overrides }
 }
 
+/**
+ * The patches one bundle layer contributes. A built-in layer, or an external
+ * layer the profile stages at boot, mounts its patches as written; every other
+ * external layer mounts as one contained, id-prefixed group.
+ * @param layer - the resolved layer.
+ * @returns the layer's patches in application order.
+ */
+export function bundleLayerPatches(layer: ProfileLayer): PatchOptions[] {
+  if (layer.trust === 'external' && layer.stage === 'runtime') return composeExternalLayer(layer).patches
+  return layer.patches
+}
+
 /**
  * Whether an installed dependency exports a profile patch, i.e. is a bundle.
  * @param binName - the diagnostic prefix used by manifest reads.

+ 53 - 71
packages/boot/app-boot/src/index.ts

@@ -14,12 +14,14 @@ import * as yaml from 'js-yaml'
 import { Context, type FiberState } from '@deepseek-ai/cordis'
 import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import { parsePatchList } from '@deepseek-ai/dsh-patch-file'
 import Group from '@deepseek-ai/cordis-plugin-group'
 import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import type {} from '@deepseek-ai/cordis-plugin-hmr'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
+import {
+  pendingMessage, ContainedGroup, ensurePluginFailures, isContainedEntry } from './contained-group.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -29,6 +31,7 @@ declare module '@deepseek-ai/cordis' {
 }
 
 export {
+  layerTrust,
   composeEntries,
   DEFAULT_PROFILE_BUNDLES,
   DEFAULT_PROFILE_PATCH_RELOAD,
@@ -59,6 +62,7 @@ export {
   type ContainedFailure, type ContainedFailureStage,
 } from './contained-group.ts'
 export {
+  bundleLayerPatches,
   BUNDLE_GROUP_PREFIX, bundleGroupId, composeExternalLayer, CONTAINED_GROUP_MODULE, disableBundle, enableBundle,
   exportsBundlePatch, externalRowId, isJsDisabled, reconcileInstalledBundles,
   type BundleReconciliation, type ComposedExternalLayer, type ExternalRowOrigin,
@@ -239,13 +243,6 @@ export function loadLayeredEnv(
 
 const bootstrapIncludes = new WeakMap<Context, Entry>()
 
-// The include's YAML dialect (`!!js` scalars become expression nodes the
-// Loader interpolates against each entry's injection-ready context), imported
-// from the include itself so patch parsing and config dumping can never drift
-// from what the include mounts. User patch layers share it so they may
-// reference `process.env`.
-const userPatchesSchema = entryListSchema
-
 /** Options for live user patch-layer reconciliation. */
 export interface UserPatchWatchOptions {
   /** Diagnostic prefix used by {@link loadOptionalPatches}. */
@@ -344,51 +341,6 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[
   return parsePatchList(binName, file, content, 'overlay')
 }
 
-/** Resolve relative plugin paths in one patch file's `insert` rows without changing assertion names. */
-function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
-  const base = dirname(resolve(file))
-  const visit = (entry: EntryOptions): void => {
-    if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) {
-      entry.name = pathToFileURL(resolve(base, entry.name)).href
-    }
-    if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)
-  }
-  for (const patch of patches) patch.insert?.forEach(visit)
-  return patches
-}
-/**
- * Parse one loader patch list: a top-level YAML array of
- * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and
- * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
- * because a patch file that cannot be applied at all is a misconfiguration; a
- * single patch whose target row is absent stays a per-entry Loader warning, so
- * one overlay shared across surfaces does not have to match every tree.
- * @param binName - the diagnostic prefix on the thrown error.
- * @param file - the source path, quoted in errors.
- * @param content - the file's text.
- * @param label - what to call this list in errors (`patches`, `overlay`).
- * @returns the parsed patch list.
- */
-function parsePatchList(
-  binName: string, file: string, content: string, label: string,
-): PatchOptions[] {
-  let parsed: unknown
-  try {
-    parsed = yaml.load(content, { schema: userPatchesSchema })
-  } catch (error) {
-    throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
-  }
-  if (!Array.isArray(parsed)) {
-    throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
-  }
-  parsed.forEach((entry, index) => {
-    if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
-      throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
-    }
-  })
-  return anchorInsertedPluginNames(parsed as PatchOptions[], file)
-}
-
 /** One overlay patch list with the source label printed in dump comments. */
 export interface ConfigDumpLayer {
   /** Source name shown in dump comments (a file basename or path). */
@@ -802,6 +754,50 @@ function owningGroupId(entry: Entry): string {
  */
 export async function assertEntriesActivated(ctx: Context, binName: string): Promise<void> {
   assertEntriesLoaded(ctx, binName)
+  const { failures, builtinPending } = await auditEntries(ctx)
+  const registry = ctx.get('pluginFailures')
+  if (failures.length > 0) {
+    const noun = failures.length === 1 ? 'entry' : 'entries'
+    // A contained failure is only harmless while nothing built-in depends on
+    // it: a built-in row left waiting for a service names the isolated
+    // bundles, because one of them is the likely missing provider.
+    const isolated = builtinPending ? registry?.list() ?? [] : []
+    const hint = isolated.length === 0
+      ? ''
+      : `\n${binName}: isolated bundle failure(s) may be the missing provider — `
+        + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} `
+        + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud'
+    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`)
+  }
+}
+
+/**
+ * Record the state of every contained row after a runtime recomposition —
+ * a bundle enabled or retried while the tree runs — the way the boot audit
+ * records it: an active row clears its record, a failed or waiting row is
+ * recorded. Built-in rows are left to the caller's own policy; nothing here
+ * throws.
+ * @param ctx - the context whose Loader entries to walk.
+ * @returns the diagnostics the boot audit would have raised for built-in rows.
+ */
+export async function recordContainedStates(ctx: Context): Promise<string[]> {
+  return (await auditEntries(ctx)).failures
+}
+
+/** What one walk of the Loader entries found beyond the contained records it wrote. */
+interface EntryAudit {
+  /** One line per built-in entry that failed or is not active. */
+  failures: string[]
+  /** Whether a built-in entry is waiting for a service. */
+  builtinPending: boolean
+}
+
+/**
+ * Walk every enabled entry: clear or record contained rows, collect built-in
+ * diagnostics, and pass built-in rejections through the process checkpoint
+ * so the fail-loud handler does not exit over a rejection the audit reports.
+ */
+async function auditEntries(ctx: Context): Promise<EntryAudit> {
   const failures: string[] = []
   const rejectionReasons: unknown[] = []
   const registry = ctx.get('pluginFailures')
@@ -832,9 +828,7 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
       continue
     }
     if (state === FIBER_PENDING) {
-      const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)
-      const subject = missing.length === 1 ? 'service' : 'services'
-      const line = `pending (waiting for ${subject}: ${missing.join(', ') || 'unknown'})`
+      const line = pendingMessage(fiber)
       if (contained) {
         registry?.record({
           entryId: entry.id, rowId: entry.options.id, moduleName: entry.options.name,
@@ -848,22 +842,10 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
       failures.push(`${entry.options.name}: fiber state ${String(state)}`)
     }
   }
-  if (failures.length > 0) {
-    if (rejectionReasons.length > 0) {
-      await observeLoaderRejectionCheckpoint(rejectionReasons)
-    }
-    const noun = failures.length === 1 ? 'entry' : 'entries'
-    // A contained failure is only harmless while nothing built-in depends on
-    // it: a built-in row left waiting for a service names the isolated
-    // bundles, because one of them is the likely missing provider.
-    const isolated = builtinPending ? registry?.list() ?? [] : []
-    const hint = isolated.length === 0
-      ? ''
-      : `\n${binName}: isolated bundle failure(s) may be the missing provider — `
-        + `check ${[...new Set(isolated.map(failure => failure.groupId))].join(', ')} `
-        + 'or mark that bundle `dsh.bundle.stage: boot` so it fails loud'
-    throw new Error(`${binName}: ${String(failures.length)} ${noun} did not activate\n${failures.join('\n')}${hint}`)
+  if (rejectionReasons.length > 0) {
+    await observeLoaderRejectionCheckpoint(rejectionReasons)
   }
+  return { failures, builtinPending }
 }
 
 /**

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

@@ -38,6 +38,8 @@ export interface RowOrigin {
 export interface ProfileRuntimeOptions {
   /** The profile as booted. */
   profile: Profile
+  /** Absolute path of the dsh app's package.json: the first resolution anchor for profile packages. */
+  installAnchor: string
   /** Re-read the profile from disk, re-resolving its bundle layers. */
   loadProfile: () => Profile
   /** The complete patch stack for a profile: bundle layers, user layers, overlays. */
@@ -77,6 +79,11 @@ export class ProfileRuntime extends Service {
     return this.profile.name
   }
 
+  /** Absolute path of the dsh app's package.json, the anchor profile packages resolve from. */
+  get installAnchor(): string {
+    return this.options.installAnchor
+  }
+
   /** Absolute profile directory. */
   get dir(): string {
     return this.profile.dir

+ 16 - 6
packages/boot/app-boot/src/profile.ts

@@ -741,6 +741,21 @@ export function writeProfileManifest(dir: string, manifest: ProfileManifest): vo
   writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n')
 }
 
+/**
+ * Who supplied one bundle of a profile. Provenance, not the package name,
+ * decides: a template bundle is never a dependency, and everything pnpm
+ * added is out-of-tree — a fork that kept a first-party name still lands in
+ * `dependencies` — unless the profile lists it under `dsh.profile.firstParty`.
+ * @param manifest - the profile manifest.
+ * @param packageName - the bundle's package name.
+ * @returns `external` for an installed third-party bundle, else `builtin`.
+ */
+export function layerTrust(manifest: ProfileManifest, packageName: string): BundleTrust {
+  const installed = packageName in (manifest.dependencies ?? {})
+  const firstParty = manifest.dsh?.profile?.firstParty ?? []
+  return installed && !firstParty.includes(packageName) ? 'external' : 'builtin'
+}
+
 /**
  * Validate one bundle stage value read from a manifest; an unknown value is a
  * misconfiguration and fails at load.
@@ -872,8 +887,6 @@ export function loadProfile(
     )
   }
   const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD
-  const dependencies = new Set(Object.keys(manifest.dependencies ?? {}))
-  const firstParty = new Set(manifest.dsh?.profile?.firstParty ?? [])
   const stages = manifest.dsh?.profile?.stages ?? {}
   const layers = bundles.map((packageName): ProfileLayer => {
     const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
@@ -883,10 +896,7 @@ export function loadProfile(
       throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
     }
     const patchPath = join(packageDir, declared)
-    // Provenance, not the package name, decides trust: a template bundle is
-    // never a dependency, and everything pnpm added is out-of-tree — a fork
-    // that kept a first-party name still lands in `dependencies`.
-    const trust: BundleTrust = dependencies.has(packageName) && !firstParty.has(packageName) ? 'external' : 'builtin'
+    const trust = layerTrust(manifest, packageName)
     const stage = readBundleStage(binName, packageName, stages[packageName] ?? bundleManifest.dsh?.bundle?.stage)
     return {
       packageName,

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

@@ -97,6 +97,30 @@ describe('cordis:contained-group', () => {
     expect(registry.get('include:ext/waiting')?.message).toContain('neverReady')
   })
 
+  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

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

@@ -31,6 +31,7 @@ async function harness(
   const compose = vi.fn((current: Profile) => [{ id: `composed-for-${current.layers.length}` }] as PatchOptions[])
   await ctx.plugin(ProfileRuntime, {
     profile: profile(layers),
+    installAnchor: '/install/package.json',
     loadProfile: () => options.reloaded ?? profile(layers),
     compose,
     rootEntry: options.rootEntry ?? (() => undefined),
@@ -43,6 +44,7 @@ describe('ProfileRuntime', () => {
   it('exposes the booted profile\'s facts', async () => {
     const { runtime } = await harness([layer('@deepseek-ai/dsh-base', 'builtin', [])])
     expect(runtime.profileName).toBe('web')
+    expect(runtime.installAnchor).toBe('/install/package.json')
     expect(runtime.dir).toBe('/profiles/web')
     expect(runtime.patchPath).toBe('/profiles/web/cordis.patch.yml')
     expect(runtime.patchReload).toBe('live')

+ 3 - 0
packages/boot/app-boot/tsconfig.json

@@ -29,6 +29,9 @@
     {
       "path": "../../util/atomic-write"
     },
+    {
+      "path": "../../util/patch-file"
+    },
     {
       "path": "../../util/launch-environment"
     },

+ 7 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -87,6 +87,13 @@
     - id: plugin-inventory
       name: '@deepseek-ai/dsh-host-plugin-inventory'
 
+    # Plugin management over the booted profile: install, enable, disable,
+    # retry, and user-layer rows through the `plugins` Remote. Resolves the
+    # profile runtime per call, so a composition booted without the profile
+    # launcher still starts and answers `plugins/unavailable`.
+    - id: plugin-manager
+      name: '@deepseek-ai/dsh-host-plugin-manager'
+
     # Session commands, cold reads, and live control over Typert Remote.
     - id: session-controller
       name: '@deepseek-ai/dsh-api-session-controller'

+ 1 - 0
packages/bundle/web-app/package.json

@@ -94,6 +94,7 @@
     "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
     "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
+    "@deepseek-ai/dsh-host-plugin-manager": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-file-reference-local": "workspace:^",

+ 1 - 1
packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx

@@ -305,7 +305,7 @@ describe('PluginInventorySettingsTab', () => {
         id: 'solo',
         trust: 'user',
         isDefault: false,
-        rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }],
+        rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null, source: 'preset' }],
       }],
     })
 

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

@@ -187,6 +187,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'the composition exactly as stored.',
         throws: ['when no configured root supplies that id.'],
       },
+      {
+        signature: 'async overlayPathFor(id: string): Promise<string>',
+        description: 'Where one preset\'s user patch layer is, or would be written: the layer discovery attached, else the writable root\'s slot of the same id — beside the composition for a locally authored preset, alone in the slot for a shipped one. The file need not exist yet.',
+        parameters: [{ name: 'id', description: 'the preset id.' }],
+        returns: 'the absolute path of the layer file.',
+        throws: ['when the preset is unknown, or it has no layer and the deployment configures no writable root.'],
+      },
+      {
+        signature: 'async removeOverlay(id: string): Promise<boolean>',
+        description: 'Delete one preset\'s user patch layer, so the next generation composes the preset exactly as its root supplies it. Sessions already joined keep the generation they run on.',
+        parameters: [{ name: 'id', description: 'the preset id.' }],
+        returns: 'true when a layer was removed; false when the preset had none.',
+        throws: ['when the preset is unknown or its layer lies outside the writable root.'],
+      },
       {
         signature: '@Remote(\'read\') async readDocument(agentPreset: string): Promise<AgentPresetDocument>',
         description: 'One preset\'s composition text with the roster row it belongs to.',
@@ -1287,6 +1301,77 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'pluginManager',
+    summary: 'The `pluginManager` service and the `plugins` Remote.',
+    description: 'The `pluginManager` service and the `plugins` Remote.\n\nEvery method that changes the profile reads the manifest afresh and writes it through the app-boot helpers the `dsh plugin` command uses, so the CLI and the manager never disagree on the file. The profile runtime is resolved per call: a composition without it (a test, a launcher other than the profile launcher) still mounts this service, and every call then reports `plugins/unavailable` rather than the service failing to start.',
+    methods: [
+      {
+        signature: '@Remote(\'list\') async list(): Promise<PluginPackageView[]>',
+        description: 'Every package the profile knows: its template and installed bundles, and every other installed dependency.',
+        parameters: [],
+        returns: 'one view per package, bundles first in layer order.',
+      },
+      {
+        signature: '@Remote(\'install\') async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult>',
+        description: 'Install a package into the profile with pnpm, probe it, and leave it disabled unless asked otherwise. The run\'s output streams as `plugins/install-log` chunks carrying the returned `jobId`.',
+        parameters: [{ name: 'spec', description: 'what to install, in pnpm\'s own vocabulary: a registry name, a `github:` or git URL, a tarball, or an absolute path.' }, { name: 'options', description: '`enable` puts every newly installed bundle into the layer list at once.' }],
+        returns: 'what the run installed and enabled.',
+        throws: ['{RemoteError} `plugins/install-failed` when pnpm exits non-zero or the run times out, `plugins/enable-failed` when enabling was asked for and the tree rejected the bundle.'],
+      },
+      {
+        signature: '@Remote(\'uninstall\') async uninstall(packageName: string): Promise<void>',
+        description: '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.',
+        parameters: [{ name: 'packageName', description: 'the installed dependency to remove.' }],
+        throws: ['{RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.'],
+      },
+      {
+        signature: '@Remote(\'enable\') async enable(packageName: string): Promise<PluginEnableResult>',
+        description: '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.',
+        parameters: [{ name: 'packageName', description: 'the installed bundle.' }],
+        returns: 'whether the list changed and whether the change is live.',
+        throws: ['{RemoteError} `plugins/not-installed`, `plugins/not-enableable` for a package that declares no bundle or whose probe refused it, or `plugins/enable-failed`.'],
+      },
+      {
+        signature: '@Remote(\'disable\') async disable(packageName: string): Promise<PluginEnableResult>',
+        description: 'Take a bundle out of the layer list and, on a live profile, recompose the tree without it.',
+        parameters: [{ name: 'packageName', description: 'the enabled bundle.' }],
+        returns: 'whether the list changed and whether the change is live.',
+        throws: ['{RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.'],
+      },
+      {
+        signature: '@Remote(\'retry\') async retry(packageName: string): Promise<PluginEnableResult>',
+        description: 'Compose an enabled bundle again from scratch: its group leaves the tree and returns, so rows that failed at boot get another start.',
+        parameters: [{ name: 'packageName', description: 'the enabled bundle.' }],
+        returns: 'the enable outcome of the second step.',
+        throws: ['{RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.'],
+      },
+      {
+        signature: '@Remote(\'addRow\') async addRow( packageName: string, target: PluginRowTarget, options?: { module?: string; id?: string; config?: JsonValue }, ): Promise<PluginRowAddition>',
+        description: 'Add a row naming one of the package\'s modules to a user layer: the profile\'s global `cordis.patch.yml`, or an agent preset\'s.',
+        parameters: [{ name: 'packageName', description: 'the installed package.' }, { name: 'target', description: 'which layer.' }, { name: 'options', description: '`module` selects a declared `dsh.plugins[]` name (default `.`), `id` overrides the derived row id, `config` overrides the declared default.' }],
+        returns: 'where the row landed.',
+        throws: ['{RemoteError} `plugins/not-installed`, `plugins/not-enableable` when the module is not one the probe found addable, `plugins/row-conflict`, or `plugins/unavailable` for a preset target without a roster.'],
+      },
+      {
+        signature: '@Remote(\'removeRow\') async removeRow(target: PluginRowTarget, rowId: string): Promise<void>',
+        description: 'Remove a row a user layer inserted.',
+        parameters: [{ name: 'target', description: 'which layer.' }, { name: 'rowId', description: 'the inserted row\'s id.' }],
+        throws: ['{RemoteError} `gateway/bad-request` when the layer inserts no such row.'],
+      },
+      {
+        signature: '@Remote(\'setRowDisabled\') async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void>',
+        description: 'Switch one row off or on in a user layer. Deny-only: `true` writes `disabled: true` for the row, `false` removes that key, so a bundle\'s own `!!js` gate is restored rather than overridden.',
+        parameters: [{ name: 'target', description: 'which layer.' }, { name: 'rowId', description: 'the row\'s id as the composition declares it.' }, { name: 'disabled', description: 'whether the layer should switch the row off.' }],
+      },
+      {
+        signature: '@Remote(\'dependents\') async dependents(packageName: string): Promise<PluginDependents>',
+        description: 'What disabling or removing a package would strand: services its rows provide that rows outside it inject, and user-layer rows naming its modules.',
+        parameters: [{ name: 'packageName', description: 'the package.' }],
+        returns: 'the dependents.',
+      },
+    ],
+  },
   {
     key: 'profileRuntime',
     summary: 'Facts and recomposition of the booted profile.',
@@ -3167,6 +3252,22 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmRuntime; call `next()` to reach the resolved adapter\'s stream, or yield your own chunks to short-circuit.',
     parameters: [{ name: 'options', description: 'the full request. A LOOP-built request carries the process-local {@link markAgentLoopRequest} identity and arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability Agent Note), so listeners read it, never rewrite it. Hand-built calls do not carry that marker; their messages already obey the immutable creation contract.' }],
   },
+  {
+    name: 'plugins/changed',
+    mode: 'emit',
+    signature: '\'plugins/changed\'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void',
+    summary: 'The manager changed what is installed, enabled, or composed.',
+    description: 'The manager changed what is installed, enabled, or composed.',
+    parameters: [{ name: 'change', description: 'why, and which package when one is concerned.' }],
+  },
+  {
+    name: 'plugins/install-log',
+    mode: 'emit',
+    signature: '\'plugins/install-log\'(chunk: PluginInstallLogChunk): void',
+    summary: 'One chunk of an install run\'s output, in order; the last chunk carries the exit code.',
+    description: 'One chunk of an install run\'s output, in order; the last chunk carries the exit code.',
+    parameters: [{ name: 'chunk', description: 'the chunk.' }],
+  },
   {
     name: 'session-telemetry/record',
     mode: 'waterfall',
@@ -3421,7 +3522,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'AgentPreset',
-    declaration: 'export interface AgentPreset {\n    readonly id: string;\n    readonly trust: PresetTrust;\n    readonly path: string;\n    readonly name?: string;\n    readonly description?: string;\n    readonly order?: number;\n    readonly broken?: string;\n}',
+    declaration: 'export interface AgentPreset {\n    readonly id: string;\n    readonly trust: PresetTrust;\n    readonly path: string;\n    readonly overlayPath?: string;\n    readonly name?: string;\n    readonly description?: string;\n    readonly order?: number;\n    readonly broken?: string;\n}',
   },
   {
     name: 'AgentPresetComposition',
@@ -3429,7 +3530,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'AgentPresetCompositionRow',
-    declaration: 'export interface AgentPresetCompositionRow {\n    readonly entryId: string | null;\n    readonly moduleName: string;\n    readonly enabled: CompositionRowEnablement;\n    readonly condition?: string;\n    readonly fiberState?: FiberState;\n}',
+    declaration: 'export interface AgentPresetCompositionRow {\n    readonly entryId: string | null;\n    readonly moduleName: string;\n    readonly enabled: CompositionRowEnablement;\n    readonly condition?: string;\n    readonly fiberState?: FiberState;\n    readonly source: CompositionRowSource;\n    readonly disabledBy?: CompositionRowDisabledBy;\n}',
   },
   {
     name: 'AgentPresetDirectoryOpenValue',
@@ -3703,10 +3804,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'CompactionTrigger',
     declaration: 'export type CompactionTrigger = \'pressure\' | \'context-overflow\';',
   },
+  {
+    name: 'CompositionRowDisabledBy',
+    declaration: 'export type CompositionRowDisabledBy = \'composition\' | \'user\';',
+  },
   {
     name: 'CompositionRowEnablement',
     declaration: 'export type CompositionRowEnablement = boolean | \'conditional\';',
   },
+  {
+    name: 'CompositionRowSource',
+    declaration: 'export type CompositionRowSource = \'preset\' | \'user\';',
+  },
   {
     name: 'ConfinedArgv',
     declaration: 'export interface ConfinedArgv {\n    argv: string[];\n    enforcement: SandboxEnforcement;\n    denialSignatures: readonly string[];\n    runnerFailureRules: readonly RunnerFailureRule[];\n}',
@@ -4523,6 +4632,74 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PermissionSelect',
     declaration: 'export interface PermissionSelect {\n    options: PresetOption[];\n    currentValue: string;\n}',
   },
+  {
+    name: 'PluginChangeReason',
+    declaration: 'export type PluginChangeReason = \'install\' | \'uninstall\' | \'enable\' | \'disable\' | \'retry\' | \'row\';',
+  },
+  {
+    name: 'PluginDependents',
+    declaration: 'export interface PluginDependents {\n    readonly services: readonly PluginServiceDependent[];\n    readonly references: readonly PluginRowReference[];\n}',
+  },
+  {
+    name: 'PluginEnableResult',
+    declaration: 'export interface PluginEnableResult {\n    readonly changed: boolean;\n    readonly effect: \'live\' | \'restart\';\n}',
+  },
+  {
+    name: 'PluginInstallLogChunk',
+    declaration: 'export interface PluginInstallLogChunk {\n    readonly jobId: string;\n    readonly spec: string;\n    readonly stream: \'stdout\' | \'stderr\';\n    readonly text: string;\n    readonly exitCode?: number | null;\n}',
+  },
+  {
+    name: 'PluginInstallResult',
+    declaration: 'export interface PluginInstallResult {\n    readonly installed: readonly string[];\n    readonly enabled: readonly string[];\n    readonly installedOnly: readonly string[];\n    readonly plain: readonly string[];\n    readonly jobId: string;\n}',
+  },
+  {
+    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}',
+  },
+  {
+    name: 'PluginPackageKind',
+    declaration: 'export type PluginPackageKind = \'bundle\' | \'plugin\' | \'library\';',
+  },
+  {
+    name: 'PluginPackageRowView',
+    declaration: 'export interface PluginPackageRowView {\n    readonly entryId: string;\n    readonly originalId?: string;\n    readonly moduleName: string;\n    readonly enabled: boolean;\n    readonly disabledBy?: \'user\' | \'composition\';\n    readonly phase: PluginRowPhase;\n    readonly failure?: {\n        readonly stage: string;\n        readonly message: string;\n    };\n}',
+  },
+  {
+    name: 'PluginPackageStage',
+    declaration: 'export type PluginPackageStage = \'boot\' | \'runtime\';',
+  },
+  {
+    name: 'PluginPackageStatus',
+    declaration: 'export type PluginPackageStatus = \'running\' | \'partial\' | \'failed\' | \'disabled\' | \'not-enableable\' | \'restart-required\' | \'plain\';',
+  },
+  {
+    name: 'PluginPackageTrust',
+    declaration: 'export type PluginPackageTrust = \'builtin\' | \'external\';',
+  },
+  {
+    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}',
+  },
+  {
+    name: 'PluginRowAddition',
+    declaration: 'export interface PluginRowAddition {\n    readonly target: PluginRowTarget;\n    readonly rowId: string;\n    readonly file: string;\n}',
+  },
+  {
+    name: 'PluginRowPhase',
+    declaration: 'export type PluginRowPhase = \'pending\' | \'loading\' | \'active\' | \'failed\' | \'unloading\' | null;',
+  },
+  {
+    name: 'PluginRowReference',
+    declaration: 'export interface PluginRowReference {\n    readonly target: PluginRowTarget;\n    readonly rowId: string;\n    readonly moduleName: string;\n}',
+  },
+  {
+    name: 'PluginRowTarget',
+    declaration: 'export type PluginRowTarget = {\n    readonly kind: \'global\';\n} | {\n    readonly kind: \'preset\';\n    readonly preset: string;\n};',
+  },
+  {
+    name: 'PluginServiceDependent',
+    declaration: 'export interface PluginServiceDependent {\n    readonly service: string;\n    readonly providedBy: string;\n    readonly injectedBy: readonly string[];\n}',
+  },
   {
     name: 'PostToolDecision',
     declaration: 'export type PostToolDecision = {\n    kind: \'accept\';\n    content?: ContentBlock[];\n    value?: never;\n    additionalContexts?: UserMessage[];\n} | {\n    kind: \'accept\';\n    value: JsonValue;\n    content?: never;\n    additionalContexts?: UserMessage[];\n} | {\n    kind: \'block\';\n    feedback: ContentBlock[];\n    additionalContexts?: UserMessage[];\n};',

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md
-README.md: 0eaf395ec9ecbdaf56c26c9adc8498b0e6ebaaec
-README.zh.md: 60140b175c7f4f7b28cd35e710b92af4c2deaa5d
+README.md: d352f4749406b3402718cbfb01e0ca588e813dd0
+README.zh.md: 9cf530aec7824e5e3d7e9d62e4ca6b7e83808378

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

@@ -35,7 +35,7 @@ When the profile launcher composed the tree, each row also says who supplied it:
 
 ### Per-preset compositions
 
-With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent.
+With a roster composed, `agentPresets` carries one group per preset in roster order: its id, whether the deployment ships it or the user owns it (`trust`, which clients use to localize shipped names), published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, a root-fiber phase when the composition is live, whether the preset's composition file or its user patch layer supplied the row (`source`), and for a row that is off whether the composition or the layer switched it off (`disabledBy`). A preset some session already composed answers from its newest standing generation — even when its file has since broken, because the mount is what those sessions run; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset nothing composed stays listed with its reason and no rows. Without a roster the field is absent.
 
 ### What you can and cannot do with it
 

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

@@ -35,7 +35,7 @@ kind: "package-reference"
 
 ### 每个预设的组合
 
-组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新 standing 世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。
+组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、随部署内置还是用户自建(`trust`,客户端据此本地化内置预设名)、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有)、组合存活时的根 Fiber 阶段、该行来自预设的组合文件还是其用户补丁层(`source`),以及被关掉的行是组合还是补丁层关掉的(`disabledBy`)。已有会话组合过的预设由其最新 standing 世代作答——即使其文件事后损坏也是如此,因为挂载才是这些会话实际运行的组合;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;无人组合的坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。
 
 ### 你能用它做什么、不能做什么
 

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

@@ -73,6 +73,10 @@ export interface AgentPresetPluginRow {
   readonly condition?: string
   /** Root-fiber phase when the composition is live; null otherwise. */
   readonly fiberPhase: PluginFiberPhase
+  /** Whether the preset's composition file or its user patch layer supplied the row. */
+  readonly source: 'preset' | 'user'
+  /** Present exactly when `enabled` is false: the composition's own gate, or the user patch layer. */
+  readonly disabledBy?: PluginDisabledBy
 }
 
 /** One agent preset's identity and flattened composition in the inventory. */

+ 6 - 0
packages/host/plugin-manager/README.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 packages/host/plugin-manager/README.md
+README.md: 4e6b9fd69ad1aaa9e760c8ad1db4378ef0f0334f
+README.zh.md: 6f1d5796f7b8008ffa5a30336da166d6f8bf852e

+ 132 - 0
packages/host/plugin-manager/README.md

@@ -0,0 +1,132 @@
+---
+description: "Plugin management over the booted profile: the pluginManager service and the plugins Remote that install, enable, disable, and retry bundles, edit user-layer rows, and report every package's state."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-host-plugin-manager
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-host-plugin-manager` is the one place that changes what a running profile is composed of. The profile launcher composes the host tree from bundle layers and user patch files and, through `profileRuntime`, can recompose it while it runs; this service drives that: it runs pnpm in the profile directory to install or remove a package, moves a bundle in and out of the profile's layer list and recomposes the tree with the outcome, composes a bundle again when its rows failed at boot, adds and removes rows in the profile's global user layer or one agent preset's, and folds the manifest, the probe record, and the live tree into one view per package. The `plugins` Remote exposes each operation; every change is followed by a `plugins/changed` event and an install run streams pnpm's output as `plugins/install-log`. Client packages consume the Remote through the [`api-remotes`](../../api/remotes/README.md) assembly.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Mount the row in a host composition beside the plugin inventory; the web bundle does. The row injects only the Loader and resolves the profile runtime per call, so a composition booted without the profile launcher still starts and answers every call with `plugins/unavailable`.
+
+### What a package view says
+
+`plugins/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, with the ids the launcher will prefix them with. `addable` lists the modules the package declares in `dsh.plugins`, each with its default config and the probe's verdict.
+
+### Installing and enabling
+
+`plugins/install` takes a pnpm spec — a registry name, a `github:` or git URL, a tarball, an absolute path — runs `pnpm add` in the profile directory, records what pnpm wrote to `dependencies`, probes every new package in a child process, and leaves new bundles disabled unless `enable` was asked for. pnpm's output arrives as `plugins/install-log` chunks carrying the run's `jobId`; the last chunk carries the exit code. A non-zero exit, a spawn failure, or the timeout fails the call with `plugins/install-failed` and the tail of the log.
+
+`plugins/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 `plugins/retry` composes the bundle again from scratch. `plugins/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'`.
+
+`plugins/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.
+
+### Rows in user layers
+
+`plugins/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`. `plugins/removeRow` removes an inserted row and `plugins/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.
+
+`plugins/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.
+
+### Configuration
+
+| Field | Default | Meaning |
+|---|---|---|
+| `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. |
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+### One manifest, two writers
+
+Every change reads the profile manifest afresh and writes it through the same app-boot helpers the `dsh plugin` command uses (`reconcileInstalledBundles`, `enableBundle`, `disableBundle`), so the CLI and the manager never disagree on the file. `dependencies` records what is installed; `dsh.profile.bundles` records what is enabled.
+
+### pnpm runs through `node:child_process`
+
+The subprocess seam scrubs secret-shaped variables and has no shell mode, and pnpm needs both the user's registry, proxy, and auth settings and, on Windows, the shell that resolves its `.cmd` shim. The manager therefore spawns pnpm the way the CLI does, with the parent environment and `shell` on Windows, and streams the child's output itself.
+
+### 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.
+
+### Source map
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | `PluginManager`: the `pluginManager` service, the `plugins` Remote methods, the pnpm runner, and the view fold |
+| [`src/types.ts`](src/types.ts) | Wire payloads, the `plugins/changed` and `plugins/install-log` events, and the `plugins/*` failure codes |
+| — | No runtime invariant companion is published; every view is folded from the manifest, the probe cache, and Loader-owned state on each call. |
+
+Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+Read these when the manager's contract is not enough: the runtime it drives, the files it edits, and the surface that renders it.
+
+- [App boot](../../boot/app-boot/README.md) — the profile runtime, external bundle isolation, and the package probe.
+- [Patch files](../../util/patch-file/README.md) — how user-layer rows are written.
+- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer a preset target writes.
+- [Plugin inventory](../plugin-inventory/README.md) — the row-level read-only projection beside this service.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the host-side plugin manager registers nothing model-facing; the rows it composes own every registration they make.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define what the manager will not do for a client. They are current package constraints, not a task backlog.
+
+- **Updating a loaded package needs a restart** — Node caches ESM modules by URL and a hoisted install keeps the path; `pnpm update` through `install` rewrites the files but the running tree keeps the old modules until the process restarts.
+- **Dependents stop at injection** — a registry-type dependency (a tool, an LLM adapter) has no `inject` edge, so `dependents` cannot name a row that only reads what the package registered.
+- **A preset row is not composed live** — the manager writes the preset's layer; sessions created afterwards compose it, sessions already running keep their generation.
+- **No `engines.dsh` check yet** — the range is reported, not enforced against the running harness version.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 132 - 0
packages/host/plugin-manager/README.zh.md

@@ -0,0 +1,132 @@
+---
+description: "面向已启动 profile 的插件管理:pluginManager 服务与 plugins Remote,负责安装、启用、停用与重试组合包、编辑用户层的行,并报告每个包的状态。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-host-plugin-manager
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-host-plugin-manager` 是改变一个运行中 profile 由什么组成的唯一地方。profile launcher 从组合包层与用户补丁文件组合出宿主树,并经 `profileRuntime` 在运行中重新组合;本服务驱动这一切:在 profile 目录运行 pnpm 安装或移除包,把组合包移入或移出 profile 的层列表并以结果重新组合树,在组合包的行启动失败时重新组合它,向 profile 的全局用户层或某个 agent preset 的用户层添加与移除行,并把 manifest、探针记录与在线树折叠成每个包一份视图。`plugins` Remote 暴露每一项操作;每次变更之后发出 `plugins/changed` 事件,安装运行把 pnpm 的输出以 `plugins/install-log` 流式发出。客户端包通过 [`api-remotes`](../../api/remotes/README.zh.md) 装配消费该 Remote。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+把这一行挂在宿主组合里插件清单旁边;web 组合包已经这么做了。该行只注入 Loader,并在每次调用时解析 profile runtime,因此不经 profile launcher 启动的组合仍能启动,只是每次调用都回答 `plugins/unavailable`。
+
+### 一份包视图说了什么
+
+`plugins/list` 为 profile 知道的每个包返回一份视图:模板组合包与已安装的组合包,以及其他每个已安装的依赖。视图携带 manifest 事实(名字、版本、标题、描述、`engines.dsh`),这个包是什么(`bundle`、`plugin` 或 `library`),谁提供它(`builtin` 或 `external`),它的行何时挂载(`boot` 或 `runtime`),是否已安装与已启用,以及折叠出的 `status`:已启用的组合包按活跃行的多少是 `running`、`partial` 或 `failed`;已安装但不在层列表中的组合包是 `disabled`;探针拒绝时是 `not-enableable` 并附原因;在启动时才应用变更的 profile 上 manifest 与在线树不一致时是 `restart-required`;库或插件模块是 `plain`,它们被添加进组合而不是被启用。组合包已组合时行来自在线树——阶段、被谁停用,以及隔离行记录的失败——否则来自探针记录,并带上 launcher 将加的前缀 id。`addable` 列出包在 `dsh.plugins` 里声明的模块,各自带默认配置与探针的判定。
+
+### 安装与启用
+
+`plugins/install` 接受一个 pnpm spec——registry 名字、`github:` 或 git URL、tarball、绝对路径——在 profile 目录运行 `pnpm add`,记录 pnpm 写进 `dependencies` 的内容,在子进程里探测每个新包,并让新组合包保持停用,除非调用方要求 `enable`。pnpm 的输出以带本次 `jobId` 的 `plugins/install-log` 分块到达;最后一块携带退出码。非零退出、spawn 失败或超时都以 `plugins/install-failed` 与日志尾部让调用失败。
+
+`plugins/enable` 把已安装的组合包放进层列表,并在 live profile 上经 profile runtime 带着它重新组合树。这次重新组合就是 Loader 自己的事务:树拒绝的组合包——`boot` 阶段而行抛错的组合包——回滚,层列表恢复,调用以点名原因的 `plugins/enable-failed` 失败,而原本运行的树继续运行。`runtime` 阶段而行失败的组合包则被隔离:调用成功,视图报告该行的失败,`plugins/retry` 从头重新组合它。`plugins/disable` 是反向操作;模板组合包不是依赖,无法停用。在 `patchReload` 为 `startup` 的 profile 上,两者只写 manifest 并报告 `effect: 'restart'`。
+
+`plugins/uninstall` 在组合包已启用时先停用它,删除每一条点名该包模块的用户层行,运行 `pnpm remove`,并忘掉探针记录。
+
+### 用户层里的行
+
+`plugins/addRow` 把一条点名该包某个模块的行——`plugin` 包的主导出,或某个 `dsh.plugins` 条目——插入 profile 的全局 `cordis.patch.yml`(`target: { kind: 'global' }`)或某个 agent preset 的用户层(`{ kind: 'preset', preset }`,经 roster 的 `overlayPathFor`)。行 id 未给出时由包名与子路径派生;已被占用的 id 以 `plugins/row-conflict` 失败。`plugins/removeRow` 移除一条插入的行,`plugins/setRowDisabled` 为任意行写入或移除 `disabled: true`——只写拒绝,因此组合包自己的 `!!js` 门被恢复而不是被覆盖。全局层当场在线重新组合;preset 的层在其下一个常驻代际生效。
+
+`plugins/dependents` 说明停用或移除一个包会搁浅什么:其行提供而包外的行注入的服务,以及点名其模块的用户层行。
+
+### 配置
+
+| 字段 | 默认值 | 含义 |
+|---|---|---|
+| `pnpmCommand` | `pnpm` | 可执行文件,与 `dsh plugin` 命令一样经 `PATH` 解析。 |
+| `installTimeoutMs` | `600000` | 单次安装或移除运行的上限。 |
+| `probeTimeoutMs` | `20000` | 单次包探测的上限。 |
+| `installLogTailBytes` | `16384` | 安装失败时报告多少尾部输出。 |
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内幕——点击展开</summary>
+
+### 一份 manifest,两个写入者
+
+每次变更都重新读取 profile manifest,并通过 `dsh plugin` 命令所用的同一组 app-boot 助手(`reconcileInstalledBundles`、`enableBundle`、`disableBundle`)写回,因此 CLI 与管理器对这个文件永远不会有分歧。`dependencies` 记录装了什么;`dsh.profile.bundles` 记录启用了什么。
+
+### pnpm 经 `node:child_process` 运行
+
+subprocess seam 会清洗形似密钥的变量且没有 shell 模式,而 pnpm 既需要用户的 registry、代理与鉴权设置,在 Windows 上又需要解析其 `.cmd` shim 的 shell。于是管理器按 CLI 的方式生成 pnpm:带父进程环境、Windows 上开 `shell`,并自己流式读取子进程的输出。
+
+### 重试即先停用再启用
+
+Loader 的事务性更新不会碰未改变的行,因此一条失败的隔离行在普通的重新组合中不会重新启动。重试把组合包移出层列表再放回去:两次重新组合,manifest 首尾如一。
+
+### 源码地图
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | `PluginManager`:`pluginManager` 服务、`plugins` Remote 方法、pnpm 运行器与视图折叠 |
+| [`src/types.ts`](src/types.ts) | wire 载荷、`plugins/changed` 与 `plugins/install-log` 事件,以及 `plugins/*` 失败码 |
+| — | 不发布运行时不变量伴随件;每份视图都在每次调用时从 manifest、探针缓存与 Loader 持有的状态折叠而来。 |
+
+Typert 生成 `./typert` 与 `./remote` 暴露的宿主与客户端 Remote 工件。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+当管理器的契约还不够时读这些:它驱动的运行时、它编辑的文件,以及渲染它的界面。
+
+- [App boot](../../boot/app-boot/README.zh.md)——profile runtime、外部组合包隔离与包探针。
+- [补丁文件](../../util/patch-file/README.zh.md)——用户层的行如何写入。
+- [Agent presets](../../preset/agent-presets/README.zh.md)——preset 目标所写的每预设用户层。
+- [插件清单](../plugin-inventory/README.zh.md)——本服务旁边的行级只读投影。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,宿主侧的插件管理器不注册任何面向模型的东西;它组合出的行各自拥有自己做出的注册。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送 provider 请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制界定管理器不会为客户端做什么。它们是当前包的约束,不是任务清单。
+
+- **更新已加载的包需要重启**——Node 按 URL 缓存 ESM 模块,hoisted 安装下路径不变;经 `install` 做的 `pnpm update` 改写了文件,但运行中的树在进程重启前一直用旧模块。
+- **依赖检测止于注入**——注册型依赖(工具、LLM 适配器)没有 `inject` 边,因此 `dependents` 无法点名只读取该包所注册内容的行。
+- **preset 的行不在线组合**——管理器写入 preset 的层;之后创建的会话组合它,已在运行的会话保持其代际。
+- **尚无 `engines.dsh` 检查**——该范围只被报告,不对运行中的 harness 版本强制执行。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 75 - 0
packages/host/plugin-manager/package.json

@@ -0,0 +1,75 @@
+{
+  "name": "@deepseek-ai/dsh-host-plugin-manager",
+  "description": "Plugin management over the booted profile: install, enable, disable, and retry bundles, add rows to the global or a preset's user layer, and report each package's state through the plugins Remote",
+  "version": "0.1.2-rc.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/host/plugin-manager"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^",
+    "zod": "^4.4.3"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-patch-file": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^"
+  },
+  "peerDependenciesMeta": {
+    "@deepseek-ai/dsh-agent-presets": {
+      "optional": true
+    }
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-group": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-agent-presets": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-patch-file": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^"
+  }
+}

+ 858 - 0
packages/host/plugin-manager/src/index.ts

@@ -0,0 +1,858 @@
+/**
+ * Plugin management over the booted profile.
+ *
+ * The profile launcher composes the host tree from bundle layers and user
+ * patch files and, since `profileRuntime`, can recompose it while running.
+ * This service is the one place that changes what those layers are: it runs
+ * pnpm in the profile directory to install or remove a package, moves a
+ * bundle in and out of the profile's layer list, recomposes the tree through
+ * the runtime and reports the outcome, retries a bundle whose rows failed,
+ * adds and removes rows in the profile's global user layer or one agent
+ * preset's, and folds the manifest, the probe record, and the live tree into
+ * one view per package for the `plugins` Remote. Every change is followed by
+ * `plugins/changed`; an install run streams pnpm's output as
+ * `plugins/install-log`.
+ * @module @deepseek-ai/dsh-host-plugin-manager
+ */
+
+import { spawn as spawnChild, type ChildProcess, type SpawnOptions } from 'node:child_process'
+import { randomUUID } from 'node:crypto'
+import { readFileSync, rmSync } from 'node:fs'
+import { join } from 'node:path'
+import type { Context, Fiber, FiberState } from '@deepseek-ai/cordis'
+import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
+import z from '@deepseek-ai/schemastery'
+import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
+import {
+  bundleGroupId,
+  disableBundle,
+  enableBundle,
+  externalRowId,
+  healProfilesModuleFallback,
+  layerTrust,
+  loadProfile,
+  PLUGIN_PROBE_DIR,
+  probePackage,
+  readProbeCache,
+  readProfileManifest,
+  reconcileInstalledBundles,
+  recordContainedStates,
+  resolveBundleDir,
+  writeProbeCache,
+  type BundleStage,
+  type PluginProbe,
+  type ProfileManifest,
+  type ProfileRuntime,
+} from '@deepseek-ai/dsh-app-boot'
+import { mutatePatchFile, readPatchListFile, type PatchRow } from '@deepseek-ai/dsh-patch-file'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+// Typert-generated ./typert and ./remote artifacts import Zod at runtime.
+import type {} from 'zod'
+import type {
+  PluginChangeReason,
+  PluginDependents,
+  PluginEnableResult,
+  PluginInstallResult,
+  PluginPackageAddableView,
+  PluginPackageRowView,
+  PluginPackageStatus,
+  PluginPackageView,
+  PluginRowAddition,
+  PluginRowPhase,
+  PluginRowReference,
+  PluginRowTarget,
+  PluginServiceDependent,
+} from './types.ts'
+
+export type * from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Plugin management over the booted profile; mounted by the web bundle. */
+    pluginManager: PluginManager
+  }
+}
+
+/** Diagnostic prefix on errors this service raises through app-boot helpers. */
+const NAME = 'plugin-manager'
+
+/** The message of a thrown value; a non-Error keeps its text. */
+function messageOf(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
+
+/** The profile's installed dependencies by name; a hand-written manifest may omit the field. */
+function dependenciesOf(manifest: ProfileManifest): Record<string, string> {
+  return manifest.dependencies ?? {}
+}
+
+/** The profile's enabled layer list; a hand-written manifest may omit the section. */
+function bundlesOf(manifest: ProfileManifest): readonly string[] {
+  return manifest.dsh?.profile?.bundles ?? []
+}
+
+/** Plugin config: the pnpm command and the bounds on the child processes this service runs. */
+export interface Config {
+  /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
+  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
+}
+
+/** Runtime mirror: FiberState is a cross-package const enum. */
+const FIBER_STATE = {
+  PENDING: 0 as FiberState.PENDING,
+  LOADING: 1 as FiberState.LOADING,
+  ACTIVE: 2 as FiberState.ACTIVE,
+  FAILED: 3 as FiberState.FAILED,
+  DISPOSED: 4 as FiberState.DISPOSED,
+  UNLOADING: 5 as FiberState.UNLOADING,
+} as const
+
+/** Complete projection of Cordis Fiber states onto the row phase vocabulary. */
+const ROW_PHASE = {
+  [FIBER_STATE.PENDING]: 'pending',
+  [FIBER_STATE.LOADING]: 'loading',
+  [FIBER_STATE.ACTIVE]: 'active',
+  [FIBER_STATE.FAILED]: 'failed',
+  [FIBER_STATE.DISPOSED]: null,
+  [FIBER_STATE.UNLOADING]: 'unloading',
+} as const satisfies Record<FiberState, PluginRowPhase>
+
+/** The spawn function, replaceable in tests so no pnpm runs. */
+export type SpawnLike = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess
+
+/** Test seams: the child spawner and the package probe. */
+export interface PluginManagerInternals {
+  spawn?: SpawnLike
+  probe?: typeof probePackage
+}
+
+/** The installed package's manifest slice the view reads. */
+type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
+
+/** Fields of one row's provenance a package view needs. */
+interface RowFacts {
+  readonly entry: Entry
+  readonly rowId: string
+}
+
+/**
+ * The `pluginManager` service and the `plugins` Remote.
+ *
+ * Every method that changes the profile reads the manifest afresh and writes
+ * it through the app-boot helpers the `dsh plugin` command uses, so the CLI
+ * and the manager never disagree on the file. The profile runtime is
+ * resolved per call: a composition without it (a test, a launcher other than
+ * the profile launcher) still mounts this service, and every call then
+ * reports `plugins/unavailable` rather than the service failing to start.
+ */
+export class PluginManager extends TypertRemoteService {
+  static inject = ['loader']
+
+  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),
+  })
+
+  private readonly spawn: SpawnLike
+  private readonly probeRunner: typeof probePackage
+
+  constructor(ctx: Context, public config: Config, internals: PluginManagerInternals = {}) {
+    super(ctx, 'pluginManager', { namespace: 'plugins' })
+    this.spawn = internals.spawn ?? spawnChild
+    this.probeRunner = internals.probe ?? probePackage
+  }
+
+  /** The profile runtime, or the failure a caller without one receives. */
+  private runtime(): ProfileRuntime {
+    const runtime = this.ctx.get('profileRuntime')
+    if (runtime === undefined) {
+      throw new RemoteError(
+        'plugins/unavailable',
+        'plugin-manager: no profile is composed in this process, so there are no plugins to manage',
+        { reason: 'no profile runtime' },
+      )
+    }
+    return runtime
+  }
+
+  /**
+   * Every package the profile knows: its template and installed bundles,
+   * and every other installed dependency.
+   * @returns one view per package, bundles first in layer order.
+   */
+  @Remote('list')
+  async list(): Promise<PluginPackageView[]> {
+    const runtime = this.runtime()
+    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 this.view(runtime, manifest, name))
+    return views
+  }
+
+  /** Fold one package's manifest, probe, and tree facts into its view. */
+  private async view(runtime: ProfileRuntime, manifest: ProfileManifest, name: string): Promise<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
+    if (installed) {
+      try {
+        probe = await this.probe(runtime, name)
+      } catch (error) {
+        probeFailure = messageOf(error)
+      }
+    }
+    const packageManifest = readInstalledManifest(runtime, name)
+    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 composed = layer !== undefined
+    const rows = composed ? this.composedRows(runtime, name) : this.probedRows(name, trust, probe)
+    const status = this.status({ kind, installed, enabled, composed, liveReload, probe, probeFailure, rows })
+    const reason = probeFailure ?? probe?.reason ?? (status === 'restart-required'
+      ? 'the profile applies layer changes at its next start'
+      : status === 'failed' || status === 'partial'
+        ? rows.find(row => row.failure !== undefined)?.failure?.message
+        : undefined)
+    return {
+      name,
+      ...optional('version', packageManifest?.version),
+      ...optional('title', packageManifest?.dsh?.title),
+      ...optional('description', packageManifest?.description),
+      kind,
+      trust,
+      stage,
+      installed,
+      enabled,
+      status,
+      ...optional('reason', reason),
+      ...optional('enginesDsh', probe?.enginesDsh),
+      cordisSameCopy: probe?.cordisSameCopy ?? null,
+      rows,
+      overrides: probe?.overrides ?? [],
+      addable: (probe?.addable ?? []).map(entry => addableView(name, entry)),
+      ...optional('probedAt', probe?.checkedAt),
+      liveReload,
+    }
+  }
+
+  /** The package's status, folded from what the view already knows. */
+  private status(facts: {
+    kind: PluginPackageView['kind']
+    installed: boolean
+    enabled: boolean
+    composed: boolean
+    liveReload: boolean
+    probe: PluginProbe | undefined
+    probeFailure: string | undefined
+    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.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'
+    const active = live.filter(row => row.phase === 'active').length
+    if (active === live.length) return 'running'
+    return active === 0 ? 'failed' : 'partial'
+  }
+
+  /** The rows a composed bundle owns in the live tree, plus rows only the failure registry knows. */
+  private composedRows(runtime: ProfileRuntime, name: string): PluginPackageRowView[] {
+    const failures = this.ctx.get('pluginFailures')
+    const userDisabled = runtime.userDisabledRowIds()
+    const rows: PluginPackageRowView[] = []
+    const listed = new Set<string>()
+    for (const { entry, rowId } of this.ownedEntries(runtime, name)) {
+      listed.add(entry.id)
+      const origin = runtime.originOf(rowId)
+      const failure = failures?.get(entry.id)
+      rows.push({
+        entryId: entry.id,
+        ...optional('originalId', origin?.originalId),
+        moduleName: entry.options.name,
+        enabled: !entry.disabled,
+        ...entry.disabled ? { disabledBy: userDisabled.has(rowId) ? 'user' as const : 'composition' as const } : {},
+        phase: entry.fiber === undefined ? null : 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
+      const origin = runtime.originOf(failure.rowId)
+      if (origin?.packageName !== name) continue
+      rows.push({
+        entryId: failure.entryId,
+        ...optional('originalId', origin.originalId),
+        moduleName: failure.moduleName,
+        enabled: true,
+        phase: 'failed',
+        failure: { stage: failure.stage, message: failure.message },
+      })
+    }
+    return rows
+  }
+
+  /** The non-group tree entries a package's layer inserted. */
+  private ownedEntries(runtime: ProfileRuntime, name: string): RowFacts[] {
+    const found: RowFacts[] = []
+    for (const entry of this.ctx.loader.entries()) {
+      if (entry.options.group) continue
+      const rowId = entry.options.id
+      if (runtime.originOf(rowId)?.packageName !== name) continue
+      found.push({ entry, rowId })
+    }
+    return found
+  }
+
+  /** The rows a bundle would contribute, from its probe record, when it is not composed. */
+  private probedRows(name: string, trust: PluginPackageView['trust'], probe: PluginProbe | undefined): PluginPackageRowView[] {
+    return (probe?.rows ?? []).map(row => ({
+      entryId: row.id === undefined ? row.name : trust === 'external' ? externalRowId(name, row.id) : row.id,
+      ...optional('originalId', row.id),
+      moduleName: row.name,
+      enabled: !row.gated,
+      ...row.gated ? { disabledBy: 'composition' as const } : {},
+      phase: null,
+    }))
+  }
+
+  /** The package's probe record, from the profile cache or a fresh probe written to it. */
+  private async probe(runtime: ProfileRuntime, name: string): Promise<PluginProbe> {
+    const version = readInstalledManifest(runtime, name)?.version
+    const cached = readProbeCache(runtime.dir, name, version)
+    if (cached !== undefined) return cached
+    const probe = await this.probeRunner({
+      binName: NAME,
+      profileDir: runtime.dir,
+      installAnchor: runtime.installAnchor,
+      packageName: name,
+      timeoutMs: this.config.probeTimeoutMs,
+    })
+    writeProbeCache(runtime.dir, probe)
+    return probe
+  }
+
+  /**
+   * Install a package into the profile with pnpm, probe it, and leave it
+   * disabled unless asked otherwise. The run's output streams as
+   * `plugins/install-log` chunks carrying the returned `jobId`.
+   * @param spec - what to install, in pnpm's own vocabulary: a registry
+   * name, a `github:` or git URL, a tarball, or an absolute path.
+   * @param options - `enable` puts every newly installed bundle into the layer list at once.
+   * @returns what the run installed and enabled.
+   * @throws {RemoteError} `plugins/install-failed` when pnpm exits non-zero
+   * or the run times out, `plugins/enable-failed` when enabling was asked
+   * for and the tree rejected the bundle.
+   */
+  @Remote('install')
+  async install(spec: string, options?: { enable?: boolean }): Promise<PluginInstallResult> {
+    const runtime = this.runtime()
+    if (spec.trim().length === 0) {
+      throw new RemoteError('gateway/bad-request', 'plugin-manager: the package spec must not be empty', {})
+    }
+    const before = readProfileManifest(NAME, runtime.dir)
+    const jobId = await this.runPnpm(runtime, ['add', spec], spec)
+    const outcome = reconcileInstalledBundles(NAME, runtime.dir, runtime.installAnchor, before, { autoEnable: false })
+    const after = readProfileManifest(NAME, runtime.dir)
+    const installed = Object.keys(dependenciesOf(after)).filter(name => !(name in dependenciesOf(before)))
+    await healProfilesModuleFallback({ installAnchor: runtime.installAnchor, profile: runtime.current })
+    for (const name of installed) {
+      // A probe that cannot run leaves no record; the view reports the
+      // package as not enableable with the probe's own reason.
+      try {
+        await this.probe(runtime, name)
+      } catch {
+        // The failure is re-derived on every list and shown there.
+      }
+    }
+    const enabled: string[] = []
+    if (options?.enable === true) {
+      for (const name of outcome.installedOnly) {
+        await this.enable(name)
+        enabled.push(name)
+      }
+    }
+    this.changed('install')
+    return {
+      installed,
+      enabled,
+      installedOnly: outcome.installedOnly.filter(name => !enabled.includes(name)),
+      plain: outcome.plain,
+      jobId,
+    }
+  }
+
+  /**
+   * 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.
+   * @param packageName - the installed dependency to remove.
+   * @throws {RemoteError} `plugins/not-installed`, or `plugins/install-failed` when pnpm exits non-zero.
+   */
+  @Remote('uninstall')
+  async uninstall(packageName: string): Promise<void> {
+    const runtime = this.runtime()
+    this.assertInstalled(runtime, packageName)
+    const references = await this.rowReferences(runtime, packageName)
+    if (bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
+      await this.disable(packageName)
+    }
+    for (const reference of references) {
+      await this.editLayer(runtime, reference.target, (document) => { document.removeInsert(reference.rowId) })
+    }
+    const before = readProfileManifest(NAME, runtime.dir)
+    await this.runPnpm(runtime, ['remove', packageName], packageName)
+    reconcileInstalledBundles(NAME, runtime.dir, runtime.installAnchor, before, { autoEnable: false })
+    rmSync(join(runtime.dir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true })
+    if (references.some(reference => reference.target.kind === 'global') && runtime.patchReload === 'live') {
+      await runtime.recompose()
+    }
+    this.changed('uninstall', packageName)
+  }
+
+  /**
+   * 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.
+   * @param packageName - the installed bundle.
+   * @returns whether the list changed and whether the change is live.
+   * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
+   * for a package that declares no bundle or whose probe refused it, or
+   * `plugins/enable-failed`.
+   */
+  @Remote('enable')
+  async enable(packageName: string): Promise<PluginEnableResult> {
+    const runtime = this.runtime()
+    this.assertInstalled(runtime, packageName)
+    const probe = await this.probe(runtime, packageName).catch((error: unknown) => {
+      const reason = `cannot be probed: ${messageOf(error)}`
+      throw new RemoteError('plugins/not-enableable', `plugin-manager: ${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 RemoteError('plugins/not-enableable', `plugin-manager: ${packageName} cannot be enabled: ${reason}`, { packageName, reason })
+    }
+    let changed: boolean
+    try {
+      changed = enableBundle(NAME, runtime.dir, runtime.installAnchor, packageName)
+    } catch (error) {
+      const reason = messageOf(error)
+      throw new RemoteError('plugins/not-enableable', reason, { packageName, reason })
+    }
+    if (runtime.patchReload !== 'live') {
+      this.changed('enable', packageName)
+      return { changed, effect: 'restart' }
+    }
+    if (changed) {
+      try {
+        // Links for the packages the bundle carries must exist before the
+        // recomposition imports its rows.
+        await healProfilesModuleFallback({
+          installAnchor: runtime.installAnchor,
+          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 RemoteError(
+          'plugins/enable-failed',
+          `plugin-manager: enabling ${packageName} failed and the layer list was restored: ${reason}`,
+          { packageName, reason },
+          { cause: error },
+        )
+      }
+    }
+    this.changed('enable', packageName)
+    return { changed, effect: 'live' }
+  }
+
+  /**
+   * Take a bundle out of the layer list and, on a live profile, recompose
+   * the tree without it.
+   * @param packageName - the enabled bundle.
+   * @returns whether the list changed and whether the change is live.
+   * @throws {RemoteError} `gateway/bad-request` for a template bundle, which is not a dependency.
+   */
+  @Remote('disable')
+  async disable(packageName: string): Promise<PluginEnableResult> {
+    const runtime = this.runtime()
+    let changed: boolean
+    try {
+      changed = disableBundle(NAME, runtime.dir, packageName)
+    } catch (error) {
+      throw new RemoteError('gateway/bad-request', messageOf(error), {})
+    }
+    if (runtime.patchReload !== 'live') {
+      this.changed('disable', packageName)
+      return { changed, effect: 'restart' }
+    }
+    if (changed) await runtime.recompose({ reloadBundles: true })
+    this.changed('disable', packageName)
+    return { changed, effect: 'live' }
+  }
+
+  /**
+   * Compose an enabled bundle again from scratch: its group leaves the tree
+   * and returns, so rows that failed at boot get another start.
+   * @param packageName - the enabled bundle.
+   * @returns the enable outcome of the second step.
+   * @throws {RemoteError} `gateway/bad-request` when the bundle is not enabled, or the enable failures.
+   */
+  @Remote('retry')
+  async retry(packageName: string): Promise<PluginEnableResult> {
+    const runtime = this.runtime()
+    if (!bundlesOf(readProfileManifest(NAME, runtime.dir)).includes(packageName)) {
+      throw new RemoteError('gateway/bad-request', `plugin-manager: ${packageName} is not enabled`, {})
+    }
+    await this.disable(packageName)
+    const result = await this.enable(packageName)
+    this.changed('retry', packageName)
+    return result
+  }
+
+  /**
+   * Add a row naming one of the package's modules to a user layer: the
+   * profile's global `cordis.patch.yml`, or an agent preset's.
+   * @param packageName - the installed package.
+   * @param target - which layer.
+   * @param options - `module` selects a declared `dsh.plugins[]` name (default `.`),
+   * `id` overrides the derived row id, `config` overrides the declared default.
+   * @returns where the row landed.
+   * @throws {RemoteError} `plugins/not-installed`, `plugins/not-enableable`
+   * when the module is not one the probe found addable, `plugins/row-conflict`,
+   * or `plugins/unavailable` for a preset target without a roster.
+   */
+  @Remote('addRow')
+  async addRow(
+    packageName: string,
+    target: PluginRowTarget,
+    options?: { module?: string; id?: string; config?: JsonValue },
+  ): Promise<PluginRowAddition> {
+    const runtime = this.runtime()
+    this.assertInstalled(runtime, packageName)
+    const probe = await this.probe(runtime, 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`
+      throw new RemoteError('plugins/not-enableable', `plugin-manager: ${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 (await this.rowExists(runtime, target, rowId)) {
+      throw new RemoteError('plugins/row-conflict', `plugin-manager: a row ${JSON.stringify(rowId)} already exists`, { rowId, target })
+    }
+    const file = await this.editLayer(runtime, target, (document) => { document.appendInsert(row) })
+    this.changed('row', packageName)
+    return { target, rowId, file }
+  }
+
+  /**
+   * Remove a row a user layer inserted.
+   * @param target - which layer.
+   * @param rowId - the inserted row's id.
+   * @throws {RemoteError} `gateway/bad-request` when the layer inserts no such row.
+   */
+  @Remote('removeRow')
+  async removeRow(target: PluginRowTarget, rowId: string): Promise<void> {
+    const runtime = this.runtime()
+    // A holder rather than a `let`: the assignment happens inside the edit
+    // callback, which control-flow narrowing does not see.
+    const outcome = { removed: false }
+    await this.editLayer(runtime, target, (document) => { outcome.removed = document.removeInsert(rowId) })
+    if (!outcome.removed) {
+      throw new RemoteError('gateway/bad-request', `plugin-manager: the layer inserts no row ${JSON.stringify(rowId)}`, {})
+    }
+    this.changed('row')
+  }
+
+  /**
+   * Switch one row off or on in a user layer. Deny-only: `true` writes
+   * `disabled: true` for the row, `false` removes that key, so a bundle's
+   * own `!!js` gate is restored rather than overridden.
+   * @param target - which layer.
+   * @param rowId - the row's id as the composition declares it.
+   * @param disabled - whether the layer should switch the row off.
+   */
+  @Remote('setRowDisabled')
+  async setRowDisabled(target: PluginRowTarget, rowId: string, disabled: boolean): Promise<void> {
+    const runtime = this.runtime()
+    await this.editLayer(runtime, target, (document) => {
+      if (disabled) document.setRowField(rowId, 'disabled', true)
+      else document.deleteRowField(rowId, 'disabled')
+    })
+    this.changed('row')
+  }
+
+  /**
+   * What disabling or removing a package would strand: services its rows
+   * provide that rows outside it inject, and user-layer rows naming its modules.
+   * @param packageName - the package.
+   * @returns the dependents.
+   */
+  @Remote('dependents')
+  async dependents(packageName: string): Promise<PluginDependents> {
+    const runtime = this.runtime()
+    const owned = this.ownedEntries(runtime, packageName)
+    const ownedIds = new Set(owned.map(({ entry }) => entry.id))
+    const store = this.ctx.reflect.store
+    const services: PluginServiceDependent[] = []
+    for (const { entry } of owned) {
+      const fiber = entry.fiber
+      if (fiber === undefined) continue
+      for (const key of Object.getOwnPropertySymbols(store)) {
+        const impl = store[key]
+        /* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */
+        if (impl === undefined || !withinFiber(impl.fiber, fiber)) continue
+        const injectedBy: string[] = []
+        for (const other of this.ctx.loader.entries()) {
+          if (ownedIds.has(other.id) || other.fiber === undefined) continue
+          if (impl.name in other.fiber.inject) injectedBy.push(other.id)
+        }
+        if (injectedBy.length > 0) services.push({ service: impl.name, providedBy: entry.id, injectedBy })
+      }
+    }
+    return { services, references: await this.rowReferences(runtime, packageName) }
+  }
+
+  /** Every user-layer row naming the package or one of its subpaths. */
+  private async rowReferences(runtime: ProfileRuntime, packageName: string): Promise<PluginRowReference[]> {
+    const found: PluginRowReference[] = []
+    const names = (target: PluginRowTarget, rows: readonly PatchRow[]): void => {
+      for (const row of rows) {
+        // A group's config is a row list once the patch parser accepted the file.
+        /* v8 ignore next */
+        if (row.group === true) names(target, (row.config ?? []) as PatchRow[])
+        if (typeof row.id !== 'string') continue
+        if (row.name === packageName || row.name.startsWith(`${packageName}/`)) {
+          found.push({ target, rowId: row.id, moduleName: row.name })
+        }
+      }
+    }
+    const scan = async (target: PluginRowTarget, file: string): Promise<void> => {
+      let patches
+      try {
+        patches = await readPatchListFile(NAME, file, 'patches')
+      } catch {
+        // An unreadable layer names nothing this call can act on; the
+        // launcher and the roster report it as their own failure.
+        return
+      }
+      for (const patch of patches ?? []) if (patch.insert !== undefined) names(target, patch.insert)
+    }
+    await scan({ kind: 'global' }, runtime.patchPath)
+    const presets = this.ctx.get('agentPresets')
+    if (presets !== undefined) {
+      for (const preset of await presets.list()) {
+        if (preset.overlayPath !== undefined) await scan({ kind: 'preset', preset: preset.id }, preset.overlayPath)
+      }
+    }
+    return found
+  }
+
+  /** 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)
+        || (await readPatchListFile(NAME, runtime.patchPath, 'patches') ?? []).some(patch => patch.insert?.some(row => row.id === rowId))
+    }
+    const presets = this.presets()
+    const composition = (await presets.compositionInventory()).find(candidate => candidate.id === target.preset)
+    return composition?.rows.some(row => row.entryId === rowId) ?? false
+  }
+
+  /** The roster, or the failure a preset target without one receives. */
+  private presets(): AgentPresets {
+    const presets = this.ctx.get('agentPresets')
+    if (presets === undefined) {
+      throw new RemoteError('plugins/unavailable', 'plugin-manager: no agent-preset roster is composed', { reason: 'no roster' })
+    }
+    return presets
+  }
+
+  /** Edit one user layer file and, for the live global layer, recompose. */
+  private async editLayer(
+    runtime: ProfileRuntime,
+    target: PluginRowTarget,
+    mutate: Parameters<typeof mutatePatchFile>[1],
+  ): Promise<string> {
+    const file = target.kind === 'global' ? runtime.patchPath : await this.presets().overlayPathFor(target.preset)
+    await mutatePatchFile(file, mutate, { binName: NAME, mode: 0o600, dirMode: 0o700 })
+    // The profile launcher's watcher reapplies the global file on its own;
+    // recomposing here makes the change visible to this call's caller before
+    // it returns, and the watcher's later pass composes the same text.
+    if (target.kind === 'global' && runtime.patchReload === 'live') await runtime.recompose()
+    return file
+  }
+
+  private assertInstalled(runtime: ProfileRuntime, packageName: string): void {
+    if (packageName in dependenciesOf(readProfileManifest(NAME, runtime.dir))) return
+    throw new RemoteError(
+      'plugins/not-installed',
+      `plugin-manager: ${packageName} is not installed in profile ${runtime.profileName}`,
+      { packageName },
+    )
+  }
+
+  /**
+   * Run one pnpm command in the profile directory, streaming its output as
+   * `plugins/install-log` chunks.
+   * @returns the run's job id.
+   * @throws {RemoteError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
+   */
+  private async runPnpm(runtime: ProfileRuntime, args: readonly string[], spec: string): Promise<string> {
+    const jobId = randomUUID()
+    const tail: string[] = []
+    let tailBytes = 0
+    const record = (stream: 'stdout' | 'stderr', text: string): void => {
+      tail.push(text)
+      tailBytes += Buffer.byteLength(text)
+      while (tailBytes > this.config.installLogTailBytes && tail.length > 1) {
+        tailBytes -= Buffer.byteLength(tail.shift() as string)
+      }
+      this.ctx.emit('plugins/install-log', { jobId, spec, stream, text })
+    }
+    // Windows resolves pnpm through its .cmd shim, which spawn() refuses
+    // without a shell since the CVE-2024-27980 hardening. The parent
+    // environment is passed whole, as the `dsh plugin` command does: pnpm
+    // needs the user's registry, proxy, and auth settings.
+    const child = this.spawn(this.config.pnpmCommand, args, {
+      cwd: runtime.dir,
+      stdio: ['ignore', 'pipe', 'pipe'],
+      shell: process.platform === 'win32',
+      env: process.env,
+    })
+    child.stdout?.setEncoding('utf8')
+    child.stderr?.setEncoding('utf8')
+    child.stdout?.on('data', (text: string) => { record('stdout', text) })
+    child.stderr?.on('data', (text: string) => { record('stderr', text) })
+    const exitCode = await new Promise<number | null>((resolve, reject) => {
+      let settled = false
+      const settle = (outcome: () => void): void => {
+        if (settled) return
+        settled = true
+        clearTimeout(timer)
+        outcome()
+      }
+      const timer = setTimeout(() => {
+        child.kill('SIGKILL')
+        settle(() => { reject(new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`)) })
+      }, this.config.installTimeoutMs)
+      child.on('error', (error) => { settle(() => { reject(error) }) })
+      child.on('close', (code) => { settle(() => { resolve(code) }) })
+    }).catch((error: unknown) => {
+      const message = messageOf(error)
+      record('stderr', `${message}\n`)
+      this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stderr', text: '', exitCode: null })
+      throw new RemoteError('plugins/install-failed', `plugin-manager: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
+    })
+    this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stdout', text: '', exitCode })
+    if (exitCode !== 0) {
+      throw new RemoteError(
+        'plugins/install-failed',
+        `plugin-manager: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${runtime.dir}`,
+        { spec, exitCode, log: tail.join('') },
+      )
+    }
+    return jobId
+  }
+
+  private changed(reason: PluginChangeReason, packageName?: string): void {
+    this.ctx.emit('plugins/changed', { reason, ...optional('packageName', packageName) })
+  }
+}
+
+/**
+ * Whether `fiber` is `root` itself or sits anywhere inside its subtree: a
+ * service is provided by a fiber under the row's root fiber (a `ctx.inject`
+ * continuation, an `isolate` realm). An entry holds the thenable wrapper
+ * `ctx.plugin()` returns, which is never identical to the fiber in a parent
+ * chain, so fibers of one runtime compare by `uid`.
+ */
+function withinFiber(fiber: Fiber, root: Fiber): boolean {
+  let current = fiber
+  for (;;) {
+    if (current === root || (current.uid !== null && current.uid === root.uid)) return true
+    const parent = current.parent.fiber
+    if (parent === current) return false
+    current = parent
+  }
+}
+
+/** One optional field, present only when its value is. */
+function optional<K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } {
+  return value === undefined ? {} : { [key]: value } as { [P in K]?: V }
+}
+
+/** The installed package's manifest, or undefined when it cannot be resolved from the profile. */
+function readInstalledManifest(runtime: ProfileRuntime, name: string): InstalledManifest | undefined {
+  let dir: string
+  try {
+    dir = resolveBundleDir(NAME, name, runtime.installAnchor, runtime.dir)
+  } catch {
+    // A dependency pnpm removed underneath the manifest, or one that never
+    // materialized; the view reports what the manifest still says.
+    return undefined
+  }
+  // resolveBundleDir answers only a directory holding a package.json.
+  return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as InstalledManifest
+}
+
+/** The row `name` for one declared addable module. */
+function moduleSpecifier(packageName: string, declared: string): string {
+  if (declared === '.') return packageName
+  return `${packageName}/${declared.replace(/^\.\//, '')}`
+}
+
+/** The row id derived from a package name and module: the unscoped name, then the subpath. */
+function derivedRowId(packageName: string, declared: string): string {
+  const base = packageName.replace(/^@/, '')
+  return declared === '.' ? base : `${base}/${declared.replace(/^\.\//, '')}`
+}
+
+/** The wire view of one probed addable module. */
+function addableView(packageName: string, entry: PluginProbe['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.
+    ...optional('config', entry.config as JsonValue | undefined),
+    ok: entry.ok,
+    ...optional('error', entry.error),
+    ...optional('configSchema', entry.configSchema as JsonValue | undefined),
+  }
+}
+
+/** Group id of an isolated bundle, for callers that address its rows in the tree. */
+export { bundleGroupId }
+
+export default PluginManager

+ 222 - 0
packages/host/plugin-manager/src/types.ts

@@ -0,0 +1,222 @@
+/**
+ * Client-safe payloads, events, and failure codes of the plugin manager.
+ * Every value here crosses the Typert wire as lossless JSON.
+ * @module @deepseek-ai/dsh-host-plugin-manager/types
+ */
+
+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'
+
+/** 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`. */
+export type PluginPackageStage = 'boot' | 'runtime'
+
+/**
+ * The state one package is in, folded from the profile manifest, the probe
+ * record, and the live tree:
+ *
+ * - `running`: enabled and every row active;
+ * - `partial`: enabled with at least one row failed or waiting;
+ * - `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);
+ * - `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.
+ */
+export type PluginPackageStatus =
+  | 'running'
+  | 'partial'
+  | 'failed'
+  | 'disabled'
+  | 'not-enableable'
+  | 'restart-required'
+  | 'plain'
+
+/** Lifecycle phase of a row's root fiber; null when the row has no live fiber. */
+export type PluginRowPhase = 'pending' | 'loading' | 'active' | 'failed' | 'unloading' | null
+
+/** One row a bundle contributes to the host tree, as the tree runs it. */
+export interface PluginPackageRowView {
+  /** The row's tree-wide id, prefixed for an isolated bundle. */
+  readonly entryId: string
+  /** The id the bundle's own patch declared, when the launcher prefixed it. */
+  readonly originalId?: string
+  /** Module specifier the row names. */
+  readonly moduleName: string
+  /** Effective enablement, including a disabled owning group. */
+  readonly enabled: boolean
+  /** Present exactly when `enabled` is false. */
+  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. */
+  readonly failure?: { readonly stage: string; readonly message: string }
+}
+
+/** One agent-plane module a package declares addable to a composition. */
+export interface PluginPackageAddableView {
+  /** The module as it is named in a row: the bare package for `.`, else `<package>/<subpath>`. */
+  readonly moduleName: string
+  /** The `dsh.plugins[].name` the package declared. */
+  readonly declaredName: string
+  /** Display title, when declared. */
+  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. */
+  readonly name: string
+  /** The installed version, when the manifest declares one. */
+  readonly version?: string
+  /** Display title from `dsh.title`, when declared. */
+  readonly title?: string
+  /** Free text from the manifest. */
+  readonly description?: string
+  readonly kind: PluginPackageKind
+  readonly trust: PluginPackageTrust
+  readonly stage: PluginPackageStage
+  /** Whether the package is a profile dependency (installed by `dsh plugin add` or the manager). */
+  readonly installed: boolean
+  /** Whether the bundle is in the profile's layer list. */
+  readonly enabled: boolean
+  readonly status: PluginPackageStatus
+  /** Why the package cannot be enabled, or why it is not running, when the status calls for one. */
+  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. */
+  readonly cordisSameCopy: boolean | null
+  /** The rows the bundle contributes, from the live tree when enabled, else from the probe. */
+  readonly rows: readonly PluginPackageRowView[]
+  /** 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
+}
+
+/** Where a composition row is added or edited: the profile's global user layer, or one preset's. */
+export type PluginRowTarget =
+  | { readonly kind: 'global' }
+  | { readonly kind: 'preset'; readonly preset: string }
+
+/** What one install run changed. */
+export interface PluginInstallResult {
+  /** Dependencies present after the run and absent before it, by name. */
+  readonly installed: readonly string[]
+  /** Bundles newly enabled, when the caller asked for it. */
+  readonly enabled: readonly string[]
+  /** Newly installed bundles left disabled. */
+  readonly installedOnly: readonly string[]
+  /** Newly installed dependencies that declare no bundle. */
+  readonly plain: readonly string[]
+  /** The identifier the run's `plugins/install-log` chunks carried. */
+  readonly jobId: string
+}
+
+/** What enabling a package did. */
+export interface PluginEnableResult {
+  /** Whether the layer list changed. */
+  readonly changed: boolean
+  /** `live` when the tree was recomposed, `restart` when the profile applies changes at its next start. */
+  readonly effect: 'live' | 'restart'
+}
+
+/** One row outside a package that injects a service one of its rows provides. */
+export interface PluginServiceDependent {
+  /** The service name. */
+  readonly service: string
+  /** The package row providing it. */
+  readonly providedBy: string
+  /** Rows outside the package whose fiber injects it. */
+  readonly injectedBy: readonly string[]
+}
+
+/** One user-layer row that names a module of the package. */
+export interface PluginRowReference {
+  readonly target: PluginRowTarget
+  readonly rowId: string
+  readonly moduleName: string
+}
+
+/** What disabling or uninstalling a package would strand. */
+export interface PluginDependents {
+  readonly services: readonly PluginServiceDependent[]
+  readonly references: readonly PluginRowReference[]
+}
+
+/** Where a row was added. */
+export interface PluginRowAddition {
+  readonly target: PluginRowTarget
+  /** The id the row was given. */
+  readonly rowId: string
+  /** The user layer file the row was written to. */
+  readonly file: string
+}
+
+/** Why the manager changed something, for a listener deciding what to refresh. */
+export type PluginChangeReason = 'install' | 'uninstall' | 'enable' | 'disable' | 'retry' | 'row'
+
+/** One chunk of an install run's output. */
+export interface PluginInstallLogChunk {
+  /** The run the chunk belongs to. */
+  readonly jobId: string
+  /** The package spec the run installs or removes. */
+  readonly spec: string
+  readonly stream: 'stdout' | 'stderr'
+  readonly text: string
+  /** Present on the run's last chunk, with pnpm's exit code (null for a signal). */
+  readonly exitCode?: number | null
+}
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** No profile runtime is composed, so there is no profile to manage. */
+    '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. */
+    'plugins/not-enableable': { readonly packageName: string; readonly reason: string }
+    /** Enabling composed the bundle and the tree rejected it; the layer list was restored. */
+    'plugins/enable-failed': { readonly packageName: string; readonly reason: string }
+    /** pnpm exited non-zero. */
+    'plugins/install-failed': { readonly spec: string; readonly exitCode: number | null; readonly log: string }
+    /** The row id is already taken in the target user layer. */
+    'plugins/row-conflict': { readonly rowId: string; readonly target: PluginRowTarget }
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * The manager changed what is installed, enabled, or composed.
+     * @mode emit
+     * @param change - why, and which package when one is concerned.
+     */
+    'plugins/changed'(change: { readonly reason: PluginChangeReason; readonly packageName?: string }): void
+    /**
+     * One chunk of an install run's output, in order; the last chunk carries the exit code.
+     * @mode emit
+     * @param chunk - the chunk.
+     */
+    'plugins/install-log'(chunk: PluginInstallLogChunk): void
+  }
+}
+
+export {}

+ 754 - 0
packages/host/plugin-manager/tests/plugin-manager.spec.ts

@@ -0,0 +1,754 @@
+/**
+ * The plugin manager over a real profile: a temporary harness home with one
+ * profile, packages staged the way pnpm leaves them, the host tree booted
+ * through `boot()` with the profile runtime the launcher provides, and a
+ * fake pnpm that edits the profile the way the real one does.
+ */
+
+import { EventEmitter } from 'node:events'
+import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdtemp } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { PassThrough } from 'node:stream'
+import type { ChildProcess } from 'node:child_process'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context, type Plugin } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import {
+  boot, bundleLayerPatches, loadOptionalPatches, loadProfile, ProfileRuntime, readProbeCache, rootIncludeEntry,
+  type Profile,
+} from '@deepseek-ai/dsh-app-boot'
+import { remoteMethods, RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import PluginManager, {
+  type PluginInstallLogChunk, type PluginManagerInternals, type SpawnLike,
+} from '@deepseek-ai/dsh-host-plugin-manager'
+import type {} from '@deepseek-ai/dsh-host-plugin-manager/types'
+
+const NAME = 'dsh-test'
+
+/** A complete manager config: the schema fills defaults at load, the type does not. */
+function managerConfig(overrides: Partial<PluginManager['config']> = {}): PluginManager['config'] {
+  return { pnpmCommand: 'pnpm', installTimeoutMs: 1_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384, ...overrides }
+}
+
+const contexts: Context[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+/** Builtins the staged bundles name; registered on every boot. */
+const good: Plugin.Function = () => {}
+const throws: Plugin.Function = () => { throw new Error('boom at apply') }
+let flakyCalls = 0
+const flaky: Plugin.Function = () => {
+  flakyCalls += 1
+  if (flakyCalls === 1) throw new Error('flaky first start')
+}
+const provider: Plugin.Function = (ctx) => { ctx.effect(() => ctx.reflect.provide('fixtureSvc', { ready: true })) }
+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.good = good
+  ctx.loader.builtins.throws = throws
+  ctx.loader.builtins.flaky = flaky
+  ctx.loader.builtins.provider = provider
+  ctx.loader.builtins['lonely-provider'] = lonelyProvider
+  ctx.loader.builtins.consumer = consumer
+}
+
+interface StagedHome {
+  home: string
+  profileDir: string
+  anchor: string
+}
+
+/** A harness home with one empty live profile and an install anchor that carries nothing. */
+async function stageHome(patchReload: 'live' | 'startup' = 'live'): Promise<StagedHome> {
+  const home = await mkdtemp(join(tmpdir(), 'dsh-plugin-manager-'))
+  const profileDir = join(home, 'profiles', 'web')
+  mkdirSync(profileDir, { recursive: true })
+  writeFileSync(join(profileDir, 'package.json'), JSON.stringify({
+    name: 'dsh-profile-web', private: true, dependencies: {}, dsh: { profile: { bundles: [], patchReload } },
+  }, null, 2))
+  writeFileSync(join(profileDir, 'cordis.yml'), '[]\n')
+  const anchorDir = join(home, 'anchor')
+  mkdirSync(anchorDir, { recursive: true })
+  const anchor = join(anchorDir, 'package.json')
+  writeFileSync(anchor, JSON.stringify({ name: 'dsh-anchor', version: '0.0.0', dependencies: {} }))
+  return { home, profileDir, anchor }
+}
+
+interface StagedPackage {
+  /** The bundle patch text; omitted stages a bundle-less package. */
+  patch?: string
+  /** `index.js` text, exported as the package main. */
+  main?: string
+  version?: string
+  stage?: 'boot' | 'runtime'
+  plugins?: { name: string; title?: string; config?: unknown }[]
+  files?: Record<string, string>
+}
+
+/** Stage one package under the profile's node_modules, the way pnpm leaves it. */
+function stagePackage(profileDir: string, name: string, staged: StagedPackage): void {
+  const dir = join(profileDir, 'node_modules', name)
+  mkdirSync(dir, { recursive: true })
+  writeFileSync(join(dir, 'package.json'), JSON.stringify({
+    name,
+    version: staged.version ?? '1.0.0',
+    description: `staged ${name}`,
+    type: 'module',
+    ...staged.main === undefined ? {} : { main: 'index.js' },
+    dsh: {
+      title: `Title of ${name}`,
+      ...staged.patch === undefined ? {} : { bundle: { patch: './cordis.patch.yml', ...staged.stage === undefined ? {} : { stage: staged.stage } } },
+      ...staged.plugins === undefined ? {} : { plugins: staged.plugins },
+    },
+  }, null, 2))
+  if (staged.patch !== undefined) writeFileSync(join(dir, 'cordis.patch.yml'), staged.patch)
+  if (staged.main !== undefined) writeFileSync(join(dir, 'index.js'), staged.main)
+  for (const [file, text] of Object.entries(staged.files ?? {})) {
+    mkdirSync(join(dir, file, '..'), { recursive: true })
+    writeFileSync(join(dir, file), text)
+  }
+}
+
+/** Add a dependency to the profile manifest, as `pnpm add` does. */
+function addDependency(profileDir: string, name: string, spec = '1.0.0'): void {
+  const path = join(profileDir, 'package.json')
+  const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
+  manifest.dependencies[name] = spec
+  writeFileSync(path, JSON.stringify(manifest, null, 2))
+}
+
+function manifestOf(profileDir: string): { dependencies: Record<string, string>; dsh: { profile: { bundles: string[] } } } {
+  return JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as ReturnType<typeof manifestOf>
+}
+
+/** What the fake pnpm does for one invocation. */
+type PnpmBehavior = (args: readonly string[]) => { code: number | null; stdout?: string; stderr?: string; hang?: boolean; error?: unknown }
+
+/** A fake `spawn` that runs `behavior` on the next tick and reports through a child-like emitter. */
+function fakePnpm(profileDir: string, behavior: PnpmBehavior, calls: string[][] = []): SpawnLike {
+  return (command, args, options) => {
+    calls.push([command, ...args])
+    expect(options.cwd).toBe(profileDir)
+    const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; kill: (signal?: string) => boolean }
+    child.stdout = new PassThrough()
+    child.stderr = new PassThrough()
+    let killed = false
+    child.kill = () => { killed = true; return true }
+    setTimeout(() => {
+      const outcome = behavior(args)
+      if (outcome.error !== undefined) {
+        child.emit('error', outcome.error)
+        return
+      }
+      if (outcome.stdout !== undefined) child.stdout.write(outcome.stdout)
+      if (outcome.stderr !== undefined) child.stderr.write(outcome.stderr)
+      if (outcome.hang === true) {
+        // Report the kill the timeout sends, as a real child would.
+        const poll = setInterval(() => {
+          if (!killed) return
+          clearInterval(poll)
+          child.emit('close', null)
+        }, 10)
+        return
+      }
+      setTimeout(() => { child.emit('close', outcome.code) }, 5)
+    }, 5)
+    return child as unknown as ChildProcess
+  }
+}
+
+/** The pnpm every install test shares: `add <name>` stages nothing (the test did) and records the dependency. */
+function recordingPnpm(profileDir: string, calls: string[][] = []): SpawnLike {
+  return fakePnpm(profileDir, (args) => {
+    const [verb, target] = args
+    if (verb === 'add' && target !== undefined) {
+      addDependency(profileDir, target)
+      return { code: 0, stdout: `+ ${target} 1.0.0\n` }
+    }
+    if (verb === 'remove' && target !== undefined) {
+      const path = join(profileDir, 'package.json')
+      const manifest = JSON.parse(readFileSync(path, 'utf8')) as { dependencies: Record<string, string> }
+      const { [target]: _removed, ...remaining } = manifest.dependencies
+      writeFileSync(path, JSON.stringify({ ...manifest, dependencies: remaining }, null, 2))
+      rmSync(join(profileDir, 'node_modules', target), { recursive: true, force: true })
+      return { code: 0, stdout: `- ${target}\n` }
+    }
+    return { code: 1, stderr: 'unexpected pnpm invocation\n' }
+  }, calls)
+}
+
+interface Booted {
+  ctx: Context
+  manager: PluginManager
+  runtime: ProfileRuntime
+  changes: { reason: string; packageName?: string }[]
+  log: PluginInstallLogChunk[]
+}
+
+/** Boot the profile the way the launcher does, then mount the manager over it. */
+async function bootProfile(staged: StagedHome, internals: PluginManagerInternals = {}, config: Partial<PluginManager['config']> = {}): Promise<Booted> {
+  const load = (): Profile => loadProfile(NAME, 'web', staged.anchor, staged.home)
+  const composeFor = (profile: Profile): PatchOptions[] => structuredClone([
+    ...profile.layers.flatMap(bundleLayerPatches),
+    ...loadOptionalPatches(NAME, profile.patchPath) ?? [],
+  ])
+  const profile = load()
+  const ctx = await boot(NAME, join(staged.profileDir, 'cordis.yml'), composeFor(profile), prepare)
+  contexts.push(ctx)
+  await ctx.plugin(ProfileRuntime, {
+    profile,
+    installAnchor: staged.anchor,
+    loadProfile: load,
+    compose: composeFor,
+    rootEntry: () => rootIncludeEntry(ctx),
+    readUserPatches: () => loadOptionalPatches(NAME, profile.patchPath) ?? [],
+  })
+  class TestManager extends PluginManager {
+    constructor(context: Context, managerConfig: PluginManager['config']) {
+      super(context, managerConfig, internals)
+    }
+  }
+  const changes: Booted['changes'] = []
+  const log: PluginInstallLogChunk[] = []
+  ctx.on('plugins/changed', (change) => { changes.push(change) })
+  ctx.on('plugins/install-log', (chunk) => { log.push(chunk) })
+  await ctx.plugin(TestManager, managerConfig(config))
+  const manager = ctx.get('pluginManager')
+  if (manager === undefined) throw new Error('manager did not mount')
+  return { ctx, manager, runtime: ctx.profileRuntime, changes, log }
+}
+
+const entryIds = (ctx: Context): string[] => [...ctx.loader.entries()].map(entry => entry.id)
+
+const BUNDLE_ONE_ROW = '- insert:\n    - id: hello\n      name: cordis:good\n'
+
+describe('PluginManager', () => {
+  it('publishes the plugins namespace with one direct method per operation', async () => {
+    const staged = await stageHome()
+    const { manager } = await bootProfile(staged)
+    expect(manager.typertRemote).toMatchObject({ serviceKey: 'pluginManager', namespace: 'plugins' })
+    expect(remoteMethods(manager).map(marker => marker.method)).toEqual([
+      'list', 'install', 'uninstall', 'enable', 'disable', 'retry', 'addRow', 'removeRow', 'setRowDisabled', 'dependents',
+    ])
+  })
+
+  it('reports plugins/unavailable without a profile runtime', async () => {
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Loader)
+    await ctx.plugin(PluginManager, managerConfig())
+    await expect(ctx.get('pluginManager')?.list()).rejects.toMatchObject({ code: 'plugins/unavailable' })
+  })
+
+  describe('list', () => {
+    it('folds installed, enabled, and probed 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' })
+      addDependency(staged.profileDir, 'ext-bundle')
+      addDependency(staged.profileDir, 'ext-lib')
+      addDependency(staged.profileDir, 'ext-plugin')
+      const { manager } = await bootProfile(staged)
+
+      const views = await manager.list()
+
+      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-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, prefixed the way the launcher will prefix them.
+      expect(bundle?.rows).toEqual([{ entryId: 'ext-bundle/hello', originalId: 'hello', moduleName: 'cordis:good', enabled: true, phase: null }])
+      expect(bundle?.addable).toEqual([{ moduleName: 'ext-bundle/extra.js', declaredName: './extra.js', title: 'Extra', ok: true }])
+      expect(bundle?.probedAt).toEqual(expect.any(String) as string)
+      expect(readProbeCache(staged.profileDir, 'ext-bundle', '1.0.0')?.kind).toBe('bundle')
+    })
+
+    it('reads a composed bundle\'s rows from the live tree with their failures', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-mixed', { patch: '- insert:\n    - id: ok\n      name: cordis:good\n    - id: bad\n      name: cordis:throws\n' })
+      addDependency(staged.profileDir, 'ext-mixed')
+      const manifest = manifestOf(staged.profileDir)
+      manifest.dsh.profile.bundles.push('ext-mixed')
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
+      const { manager } = await bootProfile(staged)
+
+      const [view] = await manager.list()
+
+      expect(view).toMatchObject({ name: 'ext-mixed', status: 'partial', enabled: true })
+      expect(view?.reason).toContain('boom at apply')
+      expect(view?.rows.map(row => [row.entryId, row.phase, row.failure?.stage])).toEqual([
+        ['include:ext-mixed/ok', 'active', undefined],
+        ['include:ext-mixed/bad', 'failed', 'apply'],
+      ])
+    })
+
+    it('reports a package the probe refuses as not enableable, and 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')
+      stagePackage(staged.profileDir, 'ext-later', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-later')
+      const { manager } = await bootProfile(staged)
+      // Enabled after boot on a startup-reload profile: the manifest says yes, the tree says no.
+      const manifest = manifestOf(staged.profileDir)
+      manifest.dsh.profile.bundles.push('ext-later')
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
+
+      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-later')).toMatchObject({ status: 'restart-required', liveReload: false })
+    })
+
+    it('reports a package whose probe cannot run with the probe\'s failure', 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' }),
+      })
+
+      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' } })
+    })
+
+    it('reads a hand-written manifest: no dependencies, a template bundle, a ghost, and a builtin layer added after boot', async () => {
+      const staged = await stageHome('startup')
+      stagePackage(staged.profileDir, 'tpl', { patch: BUNDLE_ONE_ROW })
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl'], patchReload: 'startup' } } }))
+      const { manager } = await bootProfile(staged)
+      expect((await manager.list()).map(view => [view.name, view.trust, view.status, view.installed, view.rows.length])).toEqual([['tpl', 'builtin', 'running', false, 1]])
+      // A manifest with no dsh section at all knows no bundles.
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' }))
+      expect(await manager.list()).toEqual([])
+
+      // Bundles the manifest names after boot: one staged, one that resolves to nothing.
+      stagePackage(staged.profileDir, 'tpl-later', { patch: BUNDLE_ONE_ROW })
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: ['tpl', 'tpl-later', 'ghost'], patchReload: 'startup' } } }))
+      const views = await manager.list()
+      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'],
+      ])
+      await expect(manager.uninstall('ghost')).rejects.toMatchObject({ code: 'plugins/not-installed' })
+      // Retrying takes the bundle out first, which a template bundle refuses.
+      await expect(manager.retry('ghost')).rejects.toMatchObject({ code: 'gateway/bad-request' })
+    })
+
+    it('folds disabled, user-disabled, and waiting rows', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-off', { patch: '- insert:\n    - id: a\n      name: cordis:good\n      disabled: true\n    - id: b\n      name: cordis:good\n' })
+      addDependency(staged.profileDir, 'ext-off')
+      stagePackage(staged.profileDir, 'ext-waiting', { patch: '- insert:\n    - id: w\n      name: cordis:consumer\n' })
+      addDependency(staged.profileDir, 'ext-waiting')
+      const { manager } = await bootProfile(staged)
+      await manager.enable('ext-off')
+      await manager.enable('ext-waiting')
+      await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true)
+
+      const views = await manager.list()
+
+      const off = views.find(view => view.name === 'ext-off')
+      expect(off).toMatchObject({ status: 'running' })
+      expect(off?.rows.map(row => [row.originalId, row.enabled, row.disabledBy, row.phase])).toEqual([
+        ['a', false, 'composition', null],
+        ['b', false, 'user', null],
+      ])
+      await manager.setRowDisabled({ kind: 'global' }, 'ext-off/b', true)
+      const waiting = views.find(view => view.name === 'ext-waiting')
+      expect(waiting).toMatchObject({ status: 'failed', reason: expect.stringContaining('fixtureSvc') as string })
+      expect(waiting?.rows).toEqual([expect.objectContaining({ entryId: 'include:ext-waiting/w', phase: 'pending', failure: expect.objectContaining({ stage: 'inject-pending' }) as object })])
+      // Another bundle's recorded failure is not this one's row.
+      expect(off?.rows.some(row => row.failure !== undefined)).toBe(false)
+    })
+
+    it('reads anonymous and gated probe rows, unprefixed for a first-party package', 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')
+      stagePackage(staged.profileDir, 'fp-bundle', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'fp-bundle')
+      const manifest = JSON.parse(readFileSync(join(staged.profileDir, 'package.json'), 'utf8')) as { dsh: { profile: Record<string, unknown> } }
+      manifest.dsh.profile.firstParty = ['fp-bundle']
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest))
+      const { manager } = await bootProfile(staged)
+
+      const views = await manager.list()
+
+      expect(views.find(view => view.name === 'ext-anon')?.rows).toEqual([
+        { entryId: 'cordis:good', moduleName: 'cordis:good', enabled: true, phase: null },
+        { entryId: 'ext-anon/gated', originalId: 'gated', moduleName: 'cordis:good', enabled: false, disabledBy: 'composition', phase: null },
+      ])
+      await manager.enable('fp-bundle')
+      const firstParty = (await manager.list()).find(view => view.name === 'fp-bundle')
+      expect(firstParty).toMatchObject({ trust: 'builtin', status: 'running' })
+      expect(firstParty?.rows.map(row => row.entryId)).toEqual(['include:hello'])
+      await manager.disable('fp-bundle')
+      expect((await manager.list()).find(view => view.name === 'fp-bundle')?.rows.map(row => row.entryId)).toEqual(['hello'])
+    })
+  })
+
+  describe('install', () => {
+    it('runs pnpm add, records the dependency, probes the package, and leaves it disabled', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
+      const calls: string[][] = []
+      const { manager, changes, log } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
+
+      const result = await manager.install('github:acme/ext-new')
+
+      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.
+      expect(result).toEqual({ installed: ['github:acme/ext-new'], enabled: [], installedOnly: [], plain: ['github:acme/ext-new'], jobId: expect.any(String) as string })
+      expect(log.map(chunk => [chunk.stream, chunk.text, chunk.exitCode])).toEqual([
+        ['stdout', '+ github:acme/ext-new 1.0.0\n', undefined],
+        ['stdout', '', 0],
+      ])
+      expect(changes).toEqual([{ reason: 'install' }])
+    })
+
+    it('reconciles by the installed name, and enables the new bundle when asked', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
+      const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
+
+      const result = await manager.install('ext-new', { enable: true })
+
+      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:ext-new/hello']))
+      expect(readProbeCache(staged.profileDir, 'ext-new')).toBeDefined()
+      expect(changes.map(change => change.reason)).toEqual(['enable', 'install'])
+      expect((await manager.list()).find(view => view.name === 'ext-new')?.status).toBe('running')
+    })
+
+    it('reports a plain dependency and a non-zero exit with the log tail', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
+      const { manager } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir) })
+
+      expect(await manager.install('ext-lib')).toMatchObject({ installed: ['ext-lib'], plain: ['ext-lib'], installedOnly: [] })
+      expect((await manager.list()).find(view => view.name === 'ext-lib')?.status).toBe('plain')
+      await manager.uninstall('ext-lib')
+      expect((await manager.list()).some(view => view.name === 'ext-lib')).toBe(false)
+    })
+
+    it('installs into a manifest that declares no dependencies yet', async () => {
+      const staged = await stageHome()
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify({ name: 'dsh-profile-web', dsh: { profile: { bundles: [], patchReload: 'live' } } }))
+      stagePackage(staged.profileDir, 'ext-new', { patch: BUNDLE_ONE_ROW })
+      const { manager } = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => {
+        const path = join(staged.profileDir, 'package.json')
+        const manifest = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
+        writeFileSync(path, JSON.stringify({ ...manifest, dependencies: { 'ext-new': '1.0.0' } }))
+        return { code: 0 }
+      }) })
+
+      expect(await manager.install('ext-new')).toMatchObject({ installed: ['ext-new'], installedOnly: ['ext-new'] })
+    })
+
+    it('fails loud on a non-zero exit, a spawn error, a timeout, and an empty spec', async () => {
+      const staged = await stageHome()
+      const exits = await bootProfile(staged, { spawn: fakePnpm(staged.profileDir, () => ({ code: 1, stderr: 'ERR_PNPM_NO_MATCHING_VERSION\n' })) })
+      await expect(exits.manager.install('nope')).rejects.toMatchObject({
+        code: 'plugins/install-failed', details: { spec: 'nope', exitCode: 1, log: 'ERR_PNPM_NO_MATCHING_VERSION\n' },
+      })
+      expect(exits.log.at(-1)).toMatchObject({ exitCode: 1 })
+      await expect(exits.manager.install('  ')).rejects.toMatchObject({ code: 'gateway/bad-request' })
+
+      const erroringHome = await stageHome()
+      const erroring = await bootProfile(erroringHome, { spawn: fakePnpm(erroringHome.profileDir, () => ({ code: null, error: 'spawn pnpm ENOENT' })) })
+      await expect(erroring.manager.install('x')).rejects.toMatchObject({ code: 'plugins/install-failed', details: { exitCode: null } })
+      expect(erroring.log.some(chunk => chunk.text.includes('ENOENT'))).toBe(true)
+
+      const hangingHome = await stageHome()
+      const hanging = await bootProfile(hangingHome, { spawn: fakePnpm(hangingHome.profileDir, () => ({ code: null, hang: true })) })
+      await expect(hanging.manager.install('x')).rejects.toMatchObject({ code: 'plugins/install-failed' })
+      expect(hanging.log.some(chunk => chunk.text.includes('timed out'))).toBe(true)
+    })
+
+    it('keeps only the tail of a long log in the failure', async () => {
+      const staged = await stageHome()
+      const { manager } = await bootProfile(staged, {
+        spawn: fakePnpm(staged.profileDir, () => ({ code: 2, stdout: 'a'.repeat(300), stderr: 'b'.repeat(300) })),
+      }, { installLogTailBytes: 256 })
+      await expect(manager.install('x')).rejects.toMatchObject({ details: { log: 'b'.repeat(300) } })
+    })
+  })
+
+  describe('enable, disable, and retry', () => {
+    it('composes an installed bundle live, and takes it out again', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-bundle')
+      const { ctx, manager, changes } = await bootProfile(staged)
+
+      expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
+      expect(entryIds(ctx)).toContain('include:ext-bundle/hello')
+      expect(await manager.enable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
+      expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'live' })
+      expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
+      expect(await manager.disable('ext-bundle')).toEqual({ changed: false, effect: 'live' })
+      expect(changes.map(change => [change.reason, change.packageName])).toEqual([
+        ['enable', 'ext-bundle'], ['enable', 'ext-bundle'], ['disable', 'ext-bundle'], ['disable', 'ext-bundle'],
+      ])
+    })
+
+    it('only writes the manifest on a startup-reload profile', async () => {
+      const staged = await stageHome('startup')
+      stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-bundle')
+      const { ctx, manager } = await bootProfile(staged)
+
+      expect(await manager.enable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
+      expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-bundle'])
+      expect(entryIds(ctx)).not.toContain('include:ext-bundle/hello')
+      expect(await manager.disable('ext-bundle')).toEqual({ changed: true, effect: 'restart' })
+    })
+
+    it('refuses what cannot be enabled or disabled', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
+      addDependency(staged.profileDir, 'ext-lib')
+      stagePackage(staged.profileDir, 'ext-broken', { patch: BUNDLE_ONE_ROW, main: 'throw new Error("no import for you")\n' })
+      addDependency(staged.profileDir, 'ext-broken')
+      const { manager } = await bootProfile(staged)
+
+      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 } })
+      // A template bundle is not a dependency and cannot be disabled.
+      const manifest = manifestOf(staged.profileDir)
+      manifest.dsh.profile.bundles.push('template')
+      writeFileSync(join(staged.profileDir, 'package.json'), JSON.stringify(manifest, null, 2))
+      await expect(manager.disable('template')).rejects.toMatchObject({ code: 'gateway/bad-request' })
+      await expect(manager.retry('ext-lib')).rejects.toMatchObject({ code: 'gateway/bad-request' })
+    })
+
+    it('restores the layer list when the tree rejects a boot-stage bundle', 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')
+      stagePackage(staged.profileDir, 'ext-fine', { patch: BUNDLE_ONE_ROW })
+      addDependency(staged.profileDir, 'ext-fine')
+      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 },
+      })
+
+      expect(manifestOf(staged.profileDir).dsh.profile.bundles).toEqual(['ext-fine'])
+      expect(entryIds(ctx)).toContain('include:ext-fine/hello')
+      expect(entryIds(ctx)).not.toContain('include:bad')
+    })
+
+    it('retries an isolated bundle by composing it again', 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')
+      flakyCalls = 0
+      const { manager, changes } = await bootProfile(staged)
+      await manager.enable('ext-flaky')
+      expect((await manager.list())[0]).toMatchObject({ status: 'failed', reason: expect.stringContaining('flaky first start') as string })
+
+      expect(await manager.retry('ext-flaky')).toEqual({ changed: true, effect: 'live' })
+
+      expect((await manager.list())[0]).toMatchObject({ status: 'running' })
+      expect(changes.map(change => change.reason)).toEqual(['enable', 'disable', 'enable', 'retry'])
+    })
+  })
+
+  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' })
+      addDependency(staged.profileDir, '@acme/ext-plugin')
+      const { ctx, manager, changes } = await bootProfile(staged)
+
+      const added = await manager.addRow('@acme/ext-plugin', { kind: 'global' })
+
+      expect(added).toEqual({ target: { kind: 'global' }, rowId: 'acme/ext-plugin', file: join(staged.profileDir, 'cordis.patch.yml') })
+      expect(readFileSync(added.file, 'utf8')).toBe('- insert:\n    - id: acme/ext-plugin\n      name: "@acme/ext-plugin"\n      config: {}\n')
+      // Composed live: the row is in the tree, though its module cannot import from the temp home.
+      expect(entryIds(ctx)).toContain('include:acme/ext-plugin')
+      await expect(manager.addRow('@acme/ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
+
+      await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', true)
+      expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(true)
+      expect(readFileSync(added.file, 'utf8')).toContain('- id: acme/ext-plugin\n  disabled: true\n')
+      await manager.setRowDisabled({ kind: 'global' }, 'acme/ext-plugin', false)
+      expect(ctx.loader.resolve('include:acme/ext-plugin')?.disabled).toBe(false)
+      expect(readFileSync(added.file, 'utf8')).not.toContain('disabled')
+
+      await manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')
+      expect(entryIds(ctx)).not.toContain('include:acme/ext-plugin')
+      await expect(manager.removeRow({ kind: 'global' }, 'acme/ext-plugin')).rejects.toMatchObject({ code: 'gateway/bad-request' })
+      expect(changes.map(change => change.reason)).toEqual(['row', 'row', 'row', 'row'])
+    })
+
+    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' })
+      addDependency(staged.profileDir, 'ext-plugin')
+      const { ctx, manager } = await bootProfile(staged)
+
+      await manager.addRow('ext-plugin', { kind: 'global' })
+
+      expect(entryIds(ctx)).not.toContain('include:ext-plugin')
+      await expect(manager.addRow('ext-plugin', { kind: 'global' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
+    })
+
+    it('adds a declared addable module with its default config and an explicit id, and refuses the rest', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-bundle', {
+        patch: BUNDLE_ONE_ROW,
+        plugins: [
+          { name: './tools/sql.js', title: 'SQL', config: { dsn: 'sqlite://' } },
+          { name: './missing.js' },
+        ],
+        files: { 'tools/sql.js': 'export const name = "sql"\nexport function apply() {}\n' },
+      })
+      addDependency(staged.profileDir, 'ext-bundle')
+      const { manager } = await bootProfile(staged)
+
+      const added = await manager.addRow('ext-bundle', { kind: 'global' }, { module: './tools/sql.js', id: 'sql' })
+      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('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' })
+      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' })
+
+      const overlay = join(staged.home, '.agent-presets', 'standard', 'cordis.patch.yml')
+      const roster = {
+        overlayPathFor: (id: string) => Promise.resolve(join(staged.home, '.agent-presets', id, 'cordis.patch.yml')),
+        compositionInventory: () => Promise.resolve([{ id: 'standard', rows: [{ entryId: 'tool-web' }] }]),
+        list: () => Promise.resolve([{ id: 'standard', overlayPath: existsSync(overlay) ? overlay : undefined }]),
+      }
+      ctx.provide('agentPresets', roster)
+      // No layer exists yet: nothing references the package.
+      expect((await manager.dependents('ext-plugin')).references).toEqual([])
+      // A preset the inventory does not know has no rows to conflict with.
+      const elsewhere = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'other' })
+      expect(elsewhere.file).toBe(join(staged.home, '.agent-presets', 'other', 'cordis.patch.yml'))
+
+      const added = await manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' })
+      expect(added).toEqual({ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', file: overlay })
+      await expect(manager.addRow('ext-plugin', { kind: 'preset', preset: 'standard' }, { id: 'tool-web' })).rejects.toMatchObject({ code: 'plugins/row-conflict' })
+      await manager.setRowDisabled({ kind: 'preset', preset: 'standard' }, 'tool-web', true)
+      expect(readFileSync(overlay, 'utf8')).toBe('- insert:\n    - id: ext-plugin\n      name: ext-plugin\n      config: {}\n- id: tool-web\n  disabled: true\n')
+      // The preset's layer is not part of the host tree.
+      expect(entryIds(ctx)).not.toContain('include:ext-plugin')
+
+      const dependents = await manager.dependents('ext-plugin')
+      expect(dependents.references).toEqual([{ target: { kind: 'preset', preset: 'standard' }, rowId: 'ext-plugin', moduleName: 'ext-plugin' }])
+      await manager.removeRow({ kind: 'preset', preset: 'standard' }, 'ext-plugin')
+      expect(readFileSync(overlay, 'utf8')).toBe('- id: tool-web\n  disabled: true\n')
+    })
+  })
+
+  describe('dependents and uninstall', () => {
+    it('names the services other rows inject and the user-layer rows that reference the package', async () => {
+      const staged = await stageHome()
+      // Beside the provider: a row nobody injects, and a row switched off (no fiber to read).
+      stagePackage(staged.profileDir, 'ext-provider', { patch: '- insert:\n    - id: svc\n      name: cordis:provider\n    - id: lonely\n      name: cordis:lonely-provider\n    - id: off\n      name: cordis:good\n      disabled: true\n' })
+      addDependency(staged.profileDir, 'ext-provider')
+      writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), [
+        '- insert:',
+        '    - name: ext-provider/anonymous.js',
+        '      disabled: true',
+        '    - id: ref',
+        '      name: ext-provider/tools/x.js',
+        '      disabled: true',
+        '    - id: grp',
+        '      name: cordis:group',
+        '      group: true',
+        '      config:',
+        '        - id: nested-ref',
+        '          name: ext-provider',
+        '          disabled: true',
+        '',
+      ].join('\n'))
+      const { manager, runtime } = await bootProfile(staged)
+      await manager.enable('ext-provider')
+      // A built-in row injecting the bundle's service, composed once the
+      // provider is up (a boot would refuse a row left waiting).
+      writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), `${readFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'utf8')}- insert:\n    - id: needs-svc\n      name: cordis:consumer\n`)
+      await runtime.recompose()
+
+      const dependents = await manager.dependents('ext-provider')
+
+      expect(dependents.services).toEqual([{ service: 'fixtureSvc', providedBy: 'include:ext-provider/svc', injectedBy: ['include:needs-svc'] }])
+      expect(dependents.references.map(reference => reference.rowId)).toEqual(['ref', 'nested-ref'])
+      expect(dependents.references[0]).toEqual({ target: { kind: 'global' }, rowId: 'ref', moduleName: 'ext-provider/tools/x.js' })
+    })
+
+    it('ignores an unreadable user layer while collecting references', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-lib', { main: 'export const x = 1\n' })
+      addDependency(staged.profileDir, 'ext-lib')
+      const { manager } = await bootProfile(staged)
+      writeFileSync(join(staged.profileDir, 'cordis.patch.yml'), 'a: [\n')
+
+      expect((await manager.dependents('ext-lib')).references).toEqual([])
+    })
+
+    it('disables, drops references, removes the package, and forgets its probe', async () => {
+      const staged = await stageHome()
+      stagePackage(staged.profileDir, 'ext-bundle', { patch: BUNDLE_ONE_ROW, plugins: [{ name: './extra.js' }], files: { 'extra.js': 'export function apply() {}\n' } })
+      addDependency(staged.profileDir, 'ext-bundle')
+      const calls: string[][] = []
+      const { ctx, manager, changes } = await bootProfile(staged, { spawn: recordingPnpm(staged.profileDir, calls) })
+      await manager.enable('ext-bundle')
+      await manager.addRow('ext-bundle', { kind: 'global' }, { module: './extra.js' })
+      expect(entryIds(ctx)).toEqual(expect.arrayContaining(['include:ext-bundle/hello', 'include:ext-bundle/extra.js']))
+      expect(existsSync(join(staged.profileDir, '.dsh-plugins', 'ext-bundle.json'))).toBe(true)
+
+      await manager.uninstall('ext-bundle')
+
+      expect(calls).toEqual([['pnpm', 'remove', 'ext-bundle']])
+      expect(manifestOf(staged.profileDir)).toMatchObject({ dependencies: {}, dsh: { profile: { bundles: [] } } })
+      expect(entryIds(ctx)).not.toContain('include:ext-bundle/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' })
+    })
+  })
+})
+
+describe('RemoteError codes', () => {
+  it('declare their details', () => {
+    const error = new RemoteError('plugins/row-conflict', 'taken', { rowId: 'x', target: { kind: 'global' } })
+    expect(error.details).toEqual({ rowId: 'x', target: { kind: 'global' } })
+  })
+})

+ 39 - 0
packages/host/plugin-manager/tsconfig.json

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../../../vendor/include"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../boot/app-boot"
+    },
+    {
+      "path": "../../preset/agent-presets"
+    },
+    {
+      "path": "../../util/patch-file"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../util/values"
+    }
+  ]
+}

+ 2 - 2
packages/preset/agent-presets/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/preset/agent-presets/README.md
-README.md: 5ddc40cc28a28429522ed324e114134cb59210ff
-README.zh.md: bdb187e7f7aeb9c529bc073de85ec1bb2d3b463a
+README.md: 3c14e366cbecf30128305d501b7534f15a92e96a
+README.zh.md: 2fb755cf291a0b236caff9ed548bd778d7d52bb8

+ 22 - 6
packages/preset/agent-presets/README.md

@@ -74,6 +74,22 @@ Authoring is copy-only: creating a preset copies an existing preset's whole dire
 
 A copy is refused when the id is not `[a-z0-9][a-z0-9-]*` (the id becomes a directory name), when the id is already taken (a copy never overwrites), or when the source is unknown. Deleting removes only locally authored presets; presets that ship with the deployment are not removable. A session already running on a deleted preset keeps running on it.
 
+### Adjusting a preset without editing it
+
+Every preset accepts a user patch layer: a `cordis.patch.yml` in the Loader's patch-list format — the same file a profile's user layer is — that switches rows off, replaces a row's config, or inserts rows, applied over the composition at every mount. For a locally authored preset the file sits beside `agent.cordis.yml`; for a shipped preset it sits alone in `<dshHome>/.agent-presets/<id>/cordis.patch.yml`, so the shipped install stays as it came:
+
+```yaml
+- id: tool-web
+  disabled: true
+- insert:
+    - id: hide-fetch
+      name: '@deepseek-ai/dsh-global-tool-mask'
+      config:
+        deny: [web_fetch]
+```
+
+The layer is judged with the composition: a layer that does not parse, inserts a malformed row, or inserts a row naming a module that cannot resolve makes the preset broken with that reason, and a layer whose id no root supplies is listed as a broken slot. Sessions created after the layer changes compose the new content; running sessions keep theirs. The composition inventory reports each row's `source` (`preset` or `user`) and, for a row that is off, whether the composition or the layer switched it off. A copy carries the layer in beside the new composition, and `removeOverlay` deletes it. The [plugin manager](../../host/plugin-manager/README.md) writes this file through `overlayPathFor`.
+
 ### Switching a session's preset
 
 A session can switch to a different preset only while it has produced nothing — no messages or tool calls. After that, the composition is fixed for the session's life, because swapping tools mid-conversation would leave logged tool calls the new composition cannot make. A committed switch emits `tools/change` because the resolved tool set changed without a registry edit. The switch is also recorded in the session log, so a resumed or forked session rebuilds under the composition it ran.
@@ -95,7 +111,7 @@ This section explains the design behind the roster and the standing mount; obser
 ### Design philosophy
 
 - **One standing composition per preset.** A preset is mounted once per process under a standing scope; agents join by parenting their scope key to the mount, so the mount's registrations and listeners cover every joined agent and no sibling preset's.
-- **Generations keyed on the composition file.** The mount records the composition file's stamp (mtime and size); a session that finds the stamp stale starts the next generation, while sessions already joined keep the generation they run on — a running session outlives its file changing or disappearing.
+- **Generations keyed on the composition file and the user patch layer.** The mount records the composition file's stamp (mtime and size) and a digest of the layer's text; a session that finds the stamp stale starts the next generation, while sessions already joined keep the generation they run on — a running session outlives its file changing or disappearing. A layer edited back to a content an earlier generation composed returns to that generation instead of composing a third.
 - **The preset file is an input, never a persistence target.** The mounted subtree overrides `write()` as a no-op, so a loader-initiated write-back never rewrites a shared preset file.
 - **Discovery owns health.** A directory whose composition is missing or unloadable is a broken roster row with a reason, not a skip — a skipped directory would still occupy its id while no surface shows anything to delete.
 
@@ -104,11 +120,11 @@ This section explains the design behind the roster and the standing mount; obser
 | File | Role |
 |---|---|
 | [`src/index.ts`](src/index.ts) | Service entry: `Config` schema, settings namespace, roster API, standing-mount coordination |
-| [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, health checks, id validation, ordering |
-| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates, mount reads with fiber states |
+| [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, user patch layer attachment, health checks, id validation, ordering |
+| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates and the user patch layer applied, mount reads with fiber states, per-row `source` and `disabledBy` |
 | [`src/preset.ts`](src/preset.ts) | Vocabulary: preset id rule, `AgentPreset` and `PresetRoot`, error types |
-| [`src/mount.ts`](src/mount.ts) | Subtree mounting, host base-URL handling, mount audit, `write()` suppression |
-| [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, permission tightening |
+| [`src/mount.ts`](src/mount.ts) | Subtree mounting with the user patch layer as runtime patches, host base-URL handling, mount audit, `write()` suppression |
+| [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, user patch layer copy and removal, permission tightening |
 | [`src/metadata.ts`](src/metadata.ts) | `preset.yml` display metadata |
 | [`src/session.ts`](src/session.ts) | `agent-preset/selected` event and the `agentPreset` Session projection |
 | [`src/types.ts`](src/types.ts) | Client-safe wire payloads and cordis event declaration |
@@ -171,7 +187,7 @@ These limits define when the roster is a poor fit or needs special operational c
 
 - **A preset outside the writable root is discoverable but not deletable** — `remove()` refuses anything that does not live under the first `user` root, so a deployment that configures its own writable root while leaving `includeUserRoot` on lists the harness-home presets, mounts them, and answers "it does not live under the writable preset root" for every delete. A deployment that wants only its own presets sets `includeUserRoot: false`.
 - **A session cannot change preset once it has produced anything** — switching re-links a blank session's parent scope to another standing mount, and only a blank one: swapping tools mid-conversation would strand tools the model has called.
-- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts.
+- **A generation is keyed on the composition file and the user patch layer alone** — the stamp check notices `agent.cordis.yml` or `cordis.patch.yml` changing, not an edit to a skill file or asset beside them; those reach new sessions only once one of the two files itself moves or the process restarts.
 - **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-filesystem` watches its roots by default, so each edit-then-create cycle adds a live watcher set.
 - **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start.
 - **Health asks what is installed, not what would import** — discovery proves the composition parses in the loader dialect, holds named rows, and that each row it can prove will start names a package present above the harness base or a file that exists; it never imports one, so a package whose own entry file is missing, a plugin that throws on apply, and one waiting forever for a service all still fail at the first session. `disabled` is the one entry field the Loader interpolates, so a row carrying an expression there is left unchecked rather than judged from the file.

+ 22 - 6
packages/preset/agent-presets/README.zh.md

@@ -74,6 +74,22 @@ agent-presets:
 
 以下情况会拒绝复制:id 不符合 `[a-z0-9][a-z0-9-]*`(id 会成为目录名)、id 已被占用(复制从不覆写)、或来源未知。删除只移除本地创作的 preset;随部署提供的 preset 不可删除。已在被删除 preset 上运行的会话会继续运行。
 
+### 不编辑 preset 也能调整它
+
+每个 preset 都接受一层用户补丁:一个采用 Loader 补丁列表格式的 `cordis.patch.yml`——与 profile 的用户层是同一种文件——用来关掉行、替换某行的 config 或插入行,在每次挂载时施加在组装之上。本地创作的 preset 把它放在 `agent.cordis.yml` 旁边;随附 preset 则单独放在 `<dshHome>/.agent-presets/<id>/cordis.patch.yml`,随附安装保持原样:
+
+```yaml
+- id: tool-web
+  disabled: true
+- insert:
+    - id: hide-fetch
+      name: '@deepseek-ai/dsh-global-tool-mask'
+      config:
+        deny: [web_fetch]
+```
+
+这一层与组装一起判定:无法解析的层、插入了畸形行、或插入了引用无法解析模块的行,都会让 preset 带着该原因变为 broken;没有任何根目录提供其 id 的层会被列为损坏的槽位。在层变化之后创建的会话组合新内容;运行中的会话保持各自的。组合清单报告每一行的 `source`(`preset` 或 `user`),以及被关掉的行是组装还是这一层关掉的。复制会把这一层带到新组装旁边,`removeOverlay` 删除它。[插件管理器](../../host/plugin-manager/README.zh.md) 经 `overlayPathFor` 写入这个文件。
+
 ### 切换会话的 preset
 
 会话只有在尚未产出任何内容——没有消息或工具调用——时才能切换到不同的 preset。此后组装在会话的生命周期内固定,因为在对话中途调换工具会留下新组装无法执行的已记录工具调用。已提交的切换会发出 `tools/change`,因为解析后的工具集在没有注册表编辑的情况下发生了变化。切换也会记入会话日志,因此恢复或 fork 的会话会按它运行的组装重建。
@@ -95,7 +111,7 @@ agent-presets:
 ### 设计理念
 
 - **每个 preset 一份常驻组装。** preset 在进程内只挂载一次,挂到常驻 scope 之下;agent 通过把自己的 scope key 认父到该挂载来加入,因此挂载的注册与监听器覆盖每个已加入的 agent,而不覆盖兄弟 preset 的。
-- **代际以组装文件为键。** 挂载记录组装文件的 stamp(mtime 与大小);发现 stamp 过期的会话会开启下一个代际,而已加入的会话保持各自运行的那个代际——运行中的会话在文件被修改或删除后继续存活。
+- **代际以组装文件与用户补丁层为键。** 挂载记录组装文件的 stamp(mtime 与大小)以及补丁层文本的摘要;发现 stamp 过期的会话会开启下一个代际,而已加入的会话保持各自运行的那个代际——运行中的会话在文件被修改或删除后继续存活。补丁层被改回某个早先代际组装过的内容时,回到那一代而不是再组装第三个。
 - **preset 文件是输入,绝不是持久化目标。** 被挂载的子树把 `write()` 覆写为空操作,因此 loader 发起的写回绝不会重写共享的 preset 文件。
 - **发现过程拥有健康。** 组装缺失或不可加载的目录是携带原因的 broken 名单行,而不是被跳过——被跳过的目录仍占着它的 id,而任何界面都没有可删的东西。
 
@@ -104,11 +120,11 @@ agent-presets:
 | 文件 | 职责 |
 |---|---|
 | [`src/index.ts`](src/index.ts) | 服务入口:`Config` schema、settings 命名空间、名单 API、常驻挂载协调 |
-| [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、健康检查、id 校验、排序 |
-| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门)与挂载读取(携带 fiber 状态) |
+| [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、用户补丁层附着、健康检查、id 校验、排序 |
+| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门并施加用户补丁层)与挂载读取(携带 fiber 状态),每行的 `source` 与 `disabledBy` |
 | [`src/preset.ts`](src/preset.ts) | 词汇体系:preset id 规则、`AgentPreset` 与 `PresetRoot`、错误类型 |
-| [`src/mount.ts`](src/mount.ts) | 子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 |
-| [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 |
+| [`src/mount.ts`](src/mount.ts) | 以用户补丁层为运行时 patches 的子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 |
+| [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、用户补丁层的复制与移除、权限收紧 |
 | [`src/metadata.ts`](src/metadata.ts) | `preset.yml` 展示元数据 |
 | [`src/session.ts`](src/session.ts) | `agent-preset/selected` 事件与 `agentPreset` Session 投影 |
 | [`src/types.ts`](src/types.ts) | client-safe 的线上载荷与 cordis 事件声明 |
@@ -171,7 +187,7 @@ agent-presets:
 
 - **位于可写根目录之外的 preset 可被发现却无法删除**——`remove()` 拒绝任何不在第一个 `user` 根目录下的 preset,因此一个既配置了自有可写根、又保留 `includeUserRoot` 的部署,会列出并挂载 harness home 下的 preset,却对每次删除回答「它不在可写 preset 根目录之下」。只想要自有 preset 的部署应设置 `includeUserRoot: false`。
 - **会话一旦产出任何内容便无法更换 preset**——切换会把空白会话的父作用域重链到另一个常驻挂载,且仅限空白会话:在对话中途调换工具会抽走模型已调用的工具。
-- **代际只以组装文件为键**——stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。
+- **代际只以组装文件与用户补丁层为键**——stamp 检查只察觉 `agent.cordis.yml` 或 `cordis.patch.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等这两个文件之一本身变动或进程重启才达到新会话。
 - **被替代的代际永不回收**——已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-filesystem` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。
 - **副本从不被实际挂载以校验**——它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。
 - **健康问的是「装没装」,不是「能不能 import」**——发现过程证明组装能以加载器方言解析、由具名行组成,且每一行它能证明会启动的行所引用的包装在 harness 基准之上、或所引用的文件确实存在;它从不 import 任何一个,因此入口文件缺失的包、在 apply 时抛错的插件、以及永远等待某个服务的插件,都仍在第一个会话处失败。`disabled` 是加载器唯一会插值的条目字段,因此在该字段写了表达式的行会被跳过,而不是仅凭文件下判断。

+ 2 - 0
packages/preset/agent-presets/package.json

@@ -60,6 +60,7 @@
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
@@ -84,6 +85,7 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "@deepseek-ai/dsh-patch-file": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",

+ 35 - 0
packages/preset/agent-presets/src/authoring.ts

@@ -17,6 +17,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
 import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { expandHomePath } from '@deepseek-ai/dsh-home-paths'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { OVERLAY_FILE } from './discovery.ts'
 import { METADATA_FILE, renderPresetMetadata } from './metadata.ts'
 import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
 
@@ -116,6 +117,11 @@ async function tightenModes(dir: string): Promise<void> {
  * sorted into the shipped set's declared order, would make the roster stop
  * distinguishing them. With no name given and no description to keep, the
  * file is removed so the copy publishes nothing rather than a blank.
+ *
+ * The source's user patch layer travels with the copy: a layer beside the
+ * composition is copied with the directory, and a shipped preset's layer —
+ * kept in the user root's slot of the same id — is copied in beside the new
+ * composition, so the copy composes exactly what its source composed.
  * @param roots - the configured roots; the first `user` one receives the copy.
  * @param source - the resolved preset the copy starts from.
  * @param id - the new preset's id, which becomes its directory name.
@@ -144,6 +150,9 @@ export async function copyComposition(
     await cp(dirname(source.path), dir, {
       recursive: true, dereference: true, force: false, errorOnExist: true,
     })
+    if (source.overlayPath !== undefined && dirname(source.overlayPath) !== dirname(source.path)) {
+      await cp(source.overlayPath, join(dir, OVERLAY_FILE), { dereference: true, force: false, errorOnExist: true })
+    }
     await tightenModes(dir)
     const rendered = renderPresetMetadata({
       ...name === undefined ? {} : { name },
@@ -189,3 +198,29 @@ export async function deleteComposition(
   }
   await rm(dir, { recursive: true, force: true })
 }
+
+/**
+ * Delete a preset's user patch layer, restoring the composition as its root
+ * supplies it. Only a layer under the writable root is deletable: a layer
+ * beside a shipped composition would be part of the deployment.
+ * @param roots - the configured roots.
+ * @param preset - the resolved preset whose layer to remove.
+ * @returns true when a layer was removed; false when the preset had none.
+ * @throws when the layer lies outside the writable root.
+ */
+export async function deleteOverlay(
+  roots: readonly PresetRoot[],
+  preset: AgentPreset,
+): Promise<boolean> {
+  if (preset.overlayPath === undefined) return false
+  const dir = join(writableRoot(roots, preset.id), preset.id)
+  if (!isAbsolute(preset.overlayPath) || !preset.overlayPath.startsWith(dir)) {
+    throw notWritable(preset.id, 'its user patch layer does not live under the writable preset root')
+  }
+  await rm(preset.overlayPath, { force: true })
+  // A shipped preset's slot exists only to hold the layer; an emptied slot
+  // would otherwise read as a directory missing its composition.
+  const remaining = await readdir(dir)
+  if (remaining.length === 0) await rm(dir, { recursive: true, force: true })
+  return true
+}

+ 83 - 11
packages/preset/agent-presets/src/composition-inventory.ts

@@ -6,15 +6,18 @@
  * boot answers from its composition file, with `!!js` disabled expressions
  * evaluated through the caller-supplied Loader evaluator so the file answer
  * matches the decision a mount on this host would make. A row whose
- * expression the evaluator refuses stays `'conditional'`.
+ * expression the evaluator refuses stays `'conditional'`. Either way the
+ * preset's user patch layer is accounted for: a row the layer inserted reads
+ * `source: 'user'`, and a row it switched off with a literal `disabled: true`
+ * reads `disabledBy: 'user'`.
  * @module @deepseek-ai/dsh-agent-presets/composition-inventory
  */
 
 import { readFile } from 'node:fs/promises'
 import { load } from 'js-yaml'
 import type { FiberState } from '@deepseek-ai/cordis'
-import { isJsExpr, type EntryTree } from '@deepseek-ai/cordis-plugin-loader'
-import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import { isJsExpr, type EntryOptions, type EntryTree } from '@deepseek-ai/cordis-plugin-loader'
+import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { entryListProblem } from './discovery.ts'
 import type { PresetTrust } from './preset.ts'
 
@@ -32,6 +35,12 @@ export type CompositionRowEnablement = boolean | 'conditional'
  */
 export type DisabledExpressionEvaluator = (expression: string) => unknown
 
+/** Who put a row into a preset's composition: its file, or the user patch layer over it. */
+export type CompositionRowSource = 'preset' | 'user'
+
+/** Why a row is off: the composition's own gate, or the user patch layer's literal `disabled: true`. */
+export type CompositionRowDisabledBy = 'composition' | 'user'
+
 /** One plugin row a preset composition names. */
 export interface AgentPresetCompositionRow {
   /**
@@ -47,8 +56,46 @@ export interface AgentPresetCompositionRow {
   readonly condition?: string
   /** Root-fiber state, present only when read from a live mount. */
   readonly fiberState?: FiberState
+  /** Whether the composition file or the user patch layer supplied the row. */
+  readonly source: CompositionRowSource
+  /** Present exactly when `enabled` is false. */
+  readonly disabledBy?: CompositionRowDisabledBy
+}
+
+/** What a user patch layer decided about a composition's rows, by row id. */
+export interface OverlayFacts {
+  /** Ids of rows the layer inserted, including rows inside inserted groups. */
+  readonly inserted: ReadonlySet<string>
+  /** Ids of rows the layer switched off with a literal `disabled: true`. */
+  readonly disabled: ReadonlySet<string>
+}
+
+/**
+ * Read one user patch layer's decisions by row id. A `!!js` gate in the layer
+ * is a condition, not a user decision, and is left to the composition.
+ * @param patches - the layer's patch list, parsed; empty when the preset has none.
+ * @returns the inserted and user-disabled row ids.
+ */
+export function overlayFacts(patches: readonly PatchOptions[]): OverlayFacts {
+  const inserted = new Set<string>()
+  const disabled = new Set<string>()
+  const visit = (row: EntryOptions): void => {
+    if (typeof row.id === 'string') inserted.add(row.id)
+    if (row.group && Array.isArray(row.config)) (row.config as EntryOptions[]).forEach(visit)
+  }
+  for (const patch of patches) {
+    if (patch.insert !== undefined) {
+      patch.insert.forEach(visit)
+      continue
+    }
+    if (typeof patch.id === 'string' && patch.disabled === true) disabled.add(patch.id)
+  }
+  return { inserted, disabled }
 }
 
+/** The empty layer: nothing inserted, nothing switched off. */
+const NO_OVERLAY: OverlayFacts = { inserted: new Set(), disabled: new Set() }
+
 /** One preset's roster identity beside its composition rows. */
 export interface AgentPresetComposition {
   /** Stable preset id. */
@@ -129,39 +176,56 @@ function flattenRows(
   rows: readonly unknown[],
   outerDisabled: boolean | 'conditional',
   evaluateExpression: DisabledExpressionEvaluator,
+  overlay: OverlayFacts,
   found: AgentPresetCompositionRow[],
 ): void {
   for (const value of rows) {
     const row = value as RawRow
     const disabled = combineDisabled(outerDisabled, disabledContribution(row.disabled, evaluateExpression))
     if (row.group === true) {
-      flattenRows(row.config as readonly unknown[], disabled, evaluateExpression, found)
+      flattenRows(row.config as readonly unknown[], disabled, evaluateExpression, overlay, found)
       continue
     }
+    const id = typeof row.id === 'string' && row.id !== '' ? row.id : null
     found.push({
-      entryId: typeof row.id === 'string' && row.id !== '' ? row.id : null,
+      entryId: id,
       moduleName: row.name,
       enabled: disabled === true ? false : disabled === 'conditional' ? 'conditional' : true,
       ...isJsExpr(row.disabled) ? { condition: row.disabled.__jsExpr } : {},
+      ...rowProvenance(id, disabled === true, overlay),
     })
   }
 }
 
+/** The `source` and `disabledBy` fields of one row, from the layer's decisions. */
+function rowProvenance(
+  id: string | null, disabled: boolean, overlay: OverlayFacts,
+): Pick<AgentPresetCompositionRow, 'source' | 'disabledBy'> {
+  const userRow = id !== null && overlay.inserted.has(id)
+  return {
+    source: userRow ? 'user' : 'preset',
+    ...disabled ? { disabledBy: id !== null && overlay.disabled.has(id) ? 'user' as const : 'composition' as const } : {},
+  }
+}
+
 /**
  * Plugin rows of one composition file, for a preset with no live mount.
  *
  * Parsed with the Loader's own dialect ({@link entryListSchema}), so the rows
- * reported are the rows a mount would start from. A file that stopped reading
- * as a composition — discovery judged the preset healthy moments earlier, so
- * only an edit racing this read gets here — answers as broken with the raced
- * reason rather than dropping the rows silently.
+ * reported are the rows a mount would start from, with the user patch layer
+ * applied through the include's own patch algorithm. A file that stopped
+ * reading as a composition — discovery judged the preset healthy moments
+ * earlier, so only an edit racing this read gets here — answers as broken
+ * with the raced reason rather than dropping the rows silently.
  * @param path - absolute path of the composition file.
  * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes.
+ * @param overlay - the preset's user patch layer, parsed; empty when it has none.
  * @returns flattened rows in composition order, or why they cannot be read.
  */
 export async function fileComposition(
   path: string,
   evaluateExpression: DisabledExpressionEvaluator,
+  overlay: readonly PatchOptions[] = [],
 ): Promise<{ rows: AgentPresetCompositionRow[] } | { broken: string }> {
   let rows: unknown
   try {
@@ -172,17 +236,22 @@ export async function fileComposition(
   }
   const problem = entryListProblem(rows)
   if (problem !== undefined) return { broken: problem }
+  // Silent: a patch that matches no row is the Loader's warning at mount
+  // time, not an inventory failure.
+  const composed = applyEntryPatches(rows as EntryOptions[], [...overlay], () => {})
   const found: AgentPresetCompositionRow[] = []
-  flattenRows(rows as readonly unknown[], false, evaluateExpression, found)
+  flattenRows(composed, false, evaluateExpression, overlay.length === 0 ? NO_OVERLAY : overlayFacts(overlay), found)
   return { rows: found }
 }
 
 /**
  * Plugin rows of one live standing composition, in Loader-entry order.
  * @param tree - the standing mount's entry tree.
+ * @param overlay - the preset's user patch layer, parsed; empty when it has none.
  * @returns rows with the Loader's evaluated enablement and root-fiber states.
  */
-export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionRow[] {
+export function mountedCompositionRows(tree: EntryTree, overlay: readonly PatchOptions[] = []): AgentPresetCompositionRow[] {
+  const facts = overlay.length === 0 ? NO_OVERLAY : overlayFacts(overlay)
   const found: AgentPresetCompositionRow[] = []
   for (const entry of tree.entries()) {
     if (entry.options.group) continue
@@ -192,6 +261,9 @@ export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionR
       enabled: !entry.disabled,
       ...isJsExpr(entry.options.disabled) ? { condition: entry.options.disabled.__jsExpr } : {},
       ...entry.fiber === undefined ? {} : { fiberState: entry.fiber.state },
+      // Provenance and user patches address rows by the id the composition
+      // declares; the tree-wide `entry.id` carries the include's prefix.
+      ...rowProvenance(entry.options.id, entry.disabled, facts),
     })
   }
   return found

+ 127 - 13
packages/preset/agent-presets/src/discovery.ts

@@ -5,6 +5,12 @@
  * re-reads the roots on every call so a preset authored while the process is
  * running is visible without a restart.
  *
+ * A preset may carry a user patch layer, {@link OVERLAY_FILE}: beside its
+ * composition, or — for a shipped preset — alone in the user root's directory
+ * of the same id. Discovery attaches the layer to the preset that wins the
+ * id, so the mount applies it and the inventory reports it, and judges its
+ * health with the composition's.
+ *
  * Discovery also owns preset HEALTH: a directory whose composition is
  * missing or unloadable is reported as a broken roster row rather than
  * skipped. A skipped directory would still occupy its id on disk — the copy
@@ -27,15 +33,25 @@ import { isBuiltin } from 'node:module'
 import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
 import { load } from 'js-yaml'
-import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { expandHomePath } from '@deepseek-ai/dsh-home-paths'
+import { readPatchListFile } from '@deepseek-ai/dsh-patch-file'
 import { readPresetMetadata } from './metadata.ts'
-import { PRESET_ID, type AgentPreset, type PresetRoot } from './preset.ts'
+import { PRESET_ID, type AgentPreset, type PresetRoot, type PresetTrust } from './preset.ts'
 import { classifyRowSpecifier, type RowSpecifier } from './specifier.ts'
 
 /** The composition file that makes a directory a preset. */
 export const COMPOSITION_FILE = 'agent.cordis.yml'
 
+/**
+ * The user patch layer over a preset's composition: a Loader patch list in
+ * the same format as a profile's `cordis.patch.yml`, applied at every mount.
+ */
+export const OVERLAY_FILE = 'cordis.patch.yml'
+
+/** Diagnostic prefix on overlay parse errors. */
+const BIN_NAME = 'agent-presets'
+
 /**
  * Harness-home directory holding locally authored presets.
  *
@@ -256,6 +272,50 @@ async function compositionProblem(path: string, harnessBase: string): Promise<st
     + unresolvable.map(row => `- ${row.label}: ${row.name}`).join('\n')
 }
 
+/**
+ * Why the user patch layer at `path` cannot apply, or undefined when it can.
+ * Parsed with the shared patch-list parser, so a layer the profile launcher
+ * would accept is never called broken here; every inserted row is then
+ * shape-checked and resolved the way composition rows are.
+ * @param path - absolute path of the overlay file.
+ * @param harnessBase - base URL a row's package name resolves against.
+ * @returns one human-readable reason, or undefined when the layer is loadable.
+ */
+async function overlayProblem(path: string, harnessBase: string): Promise<string | undefined> {
+  let patches: PatchOptions[] | undefined
+  try {
+    patches = await readPatchListFile(BIN_NAME, path, 'user patch layer')
+  } catch (error) {
+    /* v8 ignore next -- the parser throws Errors for every failure; the fallback keeps a hostile value readable */
+    const full = error instanceof Error ? error.message : String(error)
+    return `the user patch layer ${OVERLAY_FILE} cannot be applied: ${full.replace(/\n[\s\S]*$/, '')}`
+  }
+  // The caller statted this file moments ago; gone in between reads as nothing to apply.
+  if (patches === undefined) return undefined
+  const presetBase = new URL('.', pathToFileURL(path)).href
+  for (const [index, patch] of patches.entries()) {
+    if (patch.insert === undefined) continue
+    const shape = entryListProblem(patch.insert, `user patch layer entry ${String(index + 1)}`)
+    if (shape !== undefined) return shape
+    const unresolvable = await unresolvableRows(patch.insert, presetBase, harnessBase, `user patch layer entry ${String(index + 1)}`)
+    const [first] = unresolvable
+    if (first !== undefined) {
+      return `the user patch layer ${OVERLAY_FILE} inserts ${first.label}, which names a plugin that cannot be resolved: ${first.name}`
+    }
+  }
+  return undefined
+}
+
+/** One user patch layer found alone in a preset slot: a directory with the layer and no composition. */
+interface OverlayRecord {
+  /** The preset id the layer applies to; the directory's name. */
+  readonly id: string
+  /** Absolute path of the layer file. */
+  readonly path: string
+  /** Trust of the root the layer was found under. */
+  readonly trust: PresetTrust
+}
+
 /**
  * Whether `path` names an existing regular file.
  * @param path - absolute path to test.
@@ -290,40 +350,75 @@ async function isFile(path: string): Promise<boolean> {
  * @returns the root's presets ordered by id.
  */
 export async function scanRoot(root: PresetRoot, harnessBase: string): Promise<AgentPreset[]> {
+  return (await scanRootSlots(root, harnessBase)).presets
+}
+
+/** Sort presets by declared order, then id. */
+function sortPresets(found: AgentPreset[]): AgentPreset[] {
+  // Declared order first so the shipped set reads by capability; everything
+  // else falls back to the id, which keeps authored presets stable.
+  return found.sort((left, right) => {
+    const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY)
+    return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder
+  })
+}
+
+/**
+ * Scan one root for every preset slot: the presets it holds, and the user
+ * patch layers found alone in a directory that holds no composition.
+ * @param root - the directory and the trust its presets inherit.
+ * @param harnessBase - base URL a row's package name resolves against.
+ * @returns the root's presets ordered by id, and its lone overlays.
+ */
+async function scanRootSlots(
+  root: PresetRoot, harnessBase: string,
+): Promise<{ presets: AgentPreset[]; overlays: OverlayRecord[] }> {
   const dir = resolve(expandHomePath(root.path))
   let children
   try {
     children = await readdir(dir, { withFileTypes: true })
   } catch (error) {
-    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
+    if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { presets: [], overlays: [] }
     throw new Error(`agent-presets: cannot read preset root ${dir}: ${String(error)}`, { cause: error })
   }
-  const found: AgentPreset[] = []
+  const presets: AgentPreset[] = []
+  const overlays: OverlayRecord[] = []
   for (const child of children) {
     if (!child.isDirectory() || !PRESET_ID.test(child.name)) continue
     const directory = join(dir, child.name)
     const path = join(directory, COMPOSITION_FILE)
+    const overlayPath = join(directory, OVERLAY_FILE)
+    const hasOverlay = await isFile(overlayPath)
+    if (!await isFile(path)) {
+      // A layer alone in the slot is not a preset: it belongs to the preset
+      // of the same id another root supplies, and only fails when none does.
+      if (hasOverlay) {
+        overlays.push({ id: child.name, path: overlayPath, trust: root.trust })
+        continue
+      }
+    }
     const broken = await isFile(path)
-      ? await compositionProblem(path, harnessBase)
+      ? await compositionProblem(path, harnessBase) ?? (hasOverlay ? await overlayProblem(overlayPath, harnessBase) : undefined)
       : `the composition file ${COMPOSITION_FILE} is missing — the directory still occupies the id; delete it or restore the file`
     // Display text only, and never fatal: a preset with unreadable metadata
     // still mounts, it just shows its id.
     const metadata = await readPresetMetadata(directory)
-    found.push({
+    presets.push({
       id: child.name, trust: root.trust, path, ...metadata,
+      ...hasOverlay ? { overlayPath } : {},
       ...broken === undefined ? {} : { broken },
     })
   }
-  // Declared order first so the shipped set reads by capability; everything
-  // else falls back to the id, which keeps authored presets stable.
-  return found.sort((left, right) => {
-    const byOrder = (left.order ?? Number.POSITIVE_INFINITY) - (right.order ?? Number.POSITIVE_INFINITY)
-    return byOrder === 0 ? left.id.localeCompare(right.id) : byOrder
-  })
+  return { presets: sortPresets(presets), overlays }
 }
 
 /**
  * Scan every root in precedence order.
+ *
+ * A user patch layer found alone in a later root's slot attaches to the
+ * preset that won the id, unless that preset carries a layer of its own; one
+ * whose id no root supplies becomes a broken row, because the directory still
+ * occupies the id and a roster that hid it would leave nothing to delete.
  * @param roots - roots in precedence order; an earlier root wins a duplicate id.
  * @param harnessBase - base URL a row's package name resolves against.
  * @returns every discovered preset, first-root-wins per id.
@@ -333,11 +428,30 @@ export async function discoverPresets(
   harnessBase: string,
 ): Promise<AgentPreset[]> {
   const byId = new Map<string, AgentPreset>()
+  const loneOverlays: OverlayRecord[] = []
   for (const root of roots) {
-    for (const preset of await scanRoot(root, harnessBase)) {
+    const { presets, overlays } = await scanRootSlots(root, harnessBase)
+    for (const preset of presets) {
       if (byId.has(preset.id)) continue
       byId.set(preset.id, preset)
     }
+    loneOverlays.push(...overlays)
+  }
+  for (const overlay of loneOverlays) {
+    const preset = byId.get(overlay.id)
+    if (preset === undefined) {
+      byId.set(overlay.id, {
+        id: overlay.id,
+        trust: overlay.trust,
+        path: join(dirname(overlay.path), COMPOSITION_FILE),
+        overlayPath: overlay.path,
+        broken: `the directory holds a user patch layer ${OVERLAY_FILE} but no root supplies a preset "${overlay.id}" for it to apply to — delete the directory or restore the preset`,
+      })
+      continue
+    }
+    if (preset.overlayPath !== undefined) continue
+    const problem = preset.broken === undefined ? await overlayProblem(overlay.path, harnessBase) : undefined
+    byId.set(preset.id, { ...preset, overlayPath: overlay.path, ...problem === undefined ? {} : { broken: problem } })
   }
   return [...byId.values()]
 }

+ 138 - 32
packages/preset/agent-presets/src/index.ts

@@ -21,8 +21,11 @@
  * @module @deepseek-ai/dsh-agent-presets
  */
 
-import { stat } from 'node:fs/promises'
+import { createHash } from 'node:crypto'
+import { readFile, stat } from 'node:fs/promises'
+import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
 import z from '@deepseek-ai/schemastery'
 import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
@@ -37,8 +40,9 @@ import type {} from '@deepseek-ai/dsh-tools'
 import type SettingsService from '@deepseek-ai/dsh-settings'
 import type { SettingsScope } from '@deepseek-ai/dsh-settings'
 import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
-import { discoverPresets, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts'
-import { copyComposition, deleteComposition, presetExists, readComposition } from './authoring.ts'
+import { readPatchListFile } from '@deepseek-ai/dsh-patch-file'
+import { discoverPresets, OVERLAY_FILE, SHIPPED_PRESET_ROOT, USER_PRESET_DIR } from './discovery.ts'
+import { copyComposition, deleteComposition, deleteOverlay, presetExists, readComposition, writableRoot } from './authoring.ts'
 import { livePresetMounts, mountPreset, serviceForAgent, standingMountFor } from './mount.ts'
 import {
   fileComposition, mountedCompositionRows,
@@ -48,8 +52,10 @@ import type { AgentPreset, Config, PresetRoot } from './preset.ts'
 import { agentPresetProjectionDefinition } from './session.ts'
 export type * from './types.ts'
 export type {
-  AgentPresetComposition, AgentPresetCompositionRow, CompositionRowEnablement,
+  AgentPresetComposition, AgentPresetCompositionRow, CompositionRowDisabledBy, CompositionRowEnablement,
+  CompositionRowSource, OverlayFacts,
 } from './composition-inventory.ts'
+export { overlayFacts } from './composition-inventory.ts'
 
 /** Settings namespace carrying the user's chosen default preset. */
 export const SETTINGS_NAMESPACE = 'agent-presets'
@@ -72,7 +78,7 @@ export const AgentPresetSettingsSchema: z<AgentPresetSettings> = z.object({
   default: z.string(),
 })
 
-export { COMPOSITION_FILE, discoverPresets, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts'
+export { COMPOSITION_FILE, discoverPresets, OVERLAY_FILE, scanRoot, SHIPPED_PRESET_ROOT } from './discovery.ts'
 export {
   METADATA_FILE, readPresetMetadata, renderPresetMetadata, type PresetMetadata,
 } from './metadata.ts'
@@ -80,7 +86,7 @@ export {
   inactiveRows, leakedServices, livePresetMounts, mountPreset, serviceForAgent, standingMountFor,
   type JoinedPresetMount, type PresetMount,
 } from './mount.ts'
-export { copyComposition, deleteComposition, readComposition, writableRoot } from './authoring.ts'
+export { copyComposition, deleteComposition, deleteOverlay, readComposition, writableRoot } from './authoring.ts'
 export { agentPresetProjectionDefinition } from './session.ts'
 export type { AgentPreset, Config, PresetRoot, PresetTrust } from './preset.ts'
 
@@ -315,14 +321,14 @@ export class AgentPresets extends TypertRemoteService {
       // superseded generation's record precedes its replacement's.
       const mount = livePresetMounts(rootFiber).findLast(candidate => candidate.presetId === preset.id)
       if (mount !== undefined) {
-        found.push({ ...identity, rows: mountedCompositionRows(mount.tree) })
+        found.push({ ...identity, rows: mountedCompositionRows(mount.tree, await readOverlay(preset)) })
         continue
       }
       if (preset.broken !== undefined) {
         found.push({ ...identity, broken: preset.broken, rows: [] })
         continue
       }
-      const read = await fileComposition(preset.path, evaluateExpression)
+      const read = await fileComposition(preset.path, evaluateExpression, await readOverlay(preset))
       found.push('broken' in read
         ? { ...identity, broken: read.broken, rows: [] }
         : { ...identity, rows: read.rows })
@@ -381,15 +387,24 @@ export class AgentPresets extends TypertRemoteService {
    * Standing mounts by preset id, single-flight so two agents racing the
    * first use of one preset share one composition. A settled failure is
    * removed so a later session retries a preset whose file has been fixed; a
-   * settled success serves until the composition FILE visibly changes — each
-   * generation records its file stamp, and a stale stamp starts the next
-   * generation for sessions created afterwards. Sessions already joined keep
-   * the generation they run on; a superseded one is never disposed while the
-   * process lives (reclaimed only by whole-tree teardown), so editing files
-   * is bounded by how often compositions change, not by session count.
+   * settled success serves until the composition FILE or the user patch
+   * layer visibly changes — each generation records its stamp, and a stale
+   * stamp starts the next generation for sessions created afterwards.
+   * Sessions already joined keep the generation they run on; a superseded
+   * one is never disposed while the process lives (reclaimed only by
+   * whole-tree teardown), so editing files is bounded by how often
+   * compositions change, not by session count.
    */
   private readonly standing = new Map<string, Promise<StandingMount>>()
 
+  /**
+   * Superseded generations by preset id and stamp key. A layer edited back
+   * to an earlier content — a row switched off and on again — returns to the
+   * generation that content already composed instead of composing a third,
+   * which keeps a toggle from stacking live subtrees.
+   */
+  private readonly retired = new Map<string, Map<string, StandingMount>>()
+
   /**
    * Parent bindings of the agents this roster composed, keyed by the agent's
    * scope key. The binding is dsh-scope's only re-link capability; holding it
@@ -502,6 +517,33 @@ export class AgentPresets extends TypertRemoteService {
     return await readComposition(await this.resolve(id))
   }
 
+  /**
+   * Where one preset's user patch layer is, or would be written: the layer
+   * discovery attached, else the writable root's slot of the same id — beside
+   * the composition for a locally authored preset, alone in the slot for a
+   * shipped one. The file need not exist yet.
+   * @param id - the preset id.
+   * @returns the absolute path of the layer file.
+   * @throws when the preset is unknown, or it has no layer and the
+   * deployment configures no writable root.
+   */
+  async overlayPathFor(id: string): Promise<string> {
+    const preset = await this.resolve(id)
+    return preset.overlayPath ?? join(writableRoot(this.resolvedRoots, id), id, OVERLAY_FILE)
+  }
+
+  /**
+   * Delete one preset's user patch layer, so the next generation composes the
+   * preset exactly as its root supplies it. Sessions already joined keep the
+   * generation they run on.
+   * @param id - the preset id.
+   * @returns true when a layer was removed; false when the preset had none.
+   * @throws when the preset is unknown or its layer lies outside the writable root.
+   */
+  async removeOverlay(id: string): Promise<boolean> {
+    return await deleteOverlay(this.resolvedRoots, await this.resolve(id))
+  }
+
   /**
    * One preset's composition text with the roster row it belongs to.
    * @param agentPreset - the preset id.
@@ -748,12 +790,13 @@ export class AgentPresets extends TypertRemoteService {
     const pending = this.standing.get(preset.id)
     if (pending !== undefined) {
       const mounted = await pending
-      // Files are the only composition editor (authoring is copy/delete), so
-      // the stamp is what notices an edit: a changed file starts the next
-      // generation here, for this and later sessions. An unreadable stamp
-      // serves the current generation — a mount must survive its file
-      // disappearing, and failing the session over a stat would not.
-      const current = await compositionStamp(preset.path)
+      // Files are the only composition editor (authoring is copy/delete, the
+      // user patch layer is a file), so the stamp is what notices an edit: a
+      // changed file starts the next generation here, for this and later
+      // sessions. An unreadable stamp serves the current generation — a
+      // mount must survive its file disappearing, and failing the session
+      // over a stat would not.
+      const current = await compositionStamp(preset)
       if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
       // TODO: reclaim the superseded generation once the last agent joined to
       // it is gone. The subtree is not inert — `dsh-skill-filesystem` watches its
@@ -763,17 +806,25 @@ export class AgentPresets extends TypertRemoteService {
       // decremented when the agent's scope key dies.
       // Guarded delete: a caller that raced this one may have already started
       // the next generation, and dropping THAT pointer would fork a third.
-      if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)
+      if (this.standing.get(preset.id) === pending) {
+        this.standing.delete(preset.id)
+        this.retire(preset.id, mounted)
+      }
       return this.ensureStanding(preset)
     }
     const created = (async (): Promise<StandingMount> => {
+      // Stamped before the file is read: an edit racing the mount makes the
+      // stamp stale rather than silently current, so the next session
+      // refreshes instead of trusting a composition older than its stamp.
+      const stamp = await compositionStamp(preset)
+      const reused = stamp === undefined ? undefined : this.retired.get(preset.id)?.get(stampKey(stamp))
+      if (reused !== undefined) {
+        this.retired.get(preset.id)?.delete(stampKey(reused.stamp))
+        return reused
+      }
       const key: ScopeKey = { agentPreset: preset.id }
       const scope = createScope(this.selfCtx, key)
       try {
-        // Stamped before the file is read: an edit racing the mount makes the
-        // stamp stale rather than silently current, so the next session
-        // refreshes instead of trusting a composition older than its stamp.
-        const stamp = await compositionStamp(preset.path)
         if (stamp === undefined) {
           const reason = `composition file is unreadable: ${preset.path}`
           throw new RemoteError(
@@ -793,21 +844,42 @@ export class AgentPresets extends TypertRemoteService {
     this.standing.set(preset.id, created)
     return created
   }
+
+  /** File a superseded generation under its stamp, for a later edit that restores its content. */
+  private retire(presetId: string, mount: StandingMount): void {
+    let generations = this.retired.get(presetId)
+    if (generations === undefined) {
+      generations = new Map()
+      this.retired.set(presetId, generations)
+    }
+    generations.set(stampKey(mount.stamp), mount)
+  }
 }
 
-/** The composition file identity one standing generation was mounted from. */
+/**
+ * The composition identity one standing generation was mounted from: the
+ * composition file's stat, and the user patch layer's content — content
+ * rather than stat, so a layer edited back to what an earlier generation
+ * composed identifies that generation.
+ */
 interface CompositionStamp {
   /** Modification time in milliseconds, as `stat` reports it. */
   readonly mtimeMs: number
   /** File size in bytes, the tiebreak for edits within one mtime tick. */
   readonly size: number
+  /** Digest of the user patch layer's text; empty when the preset has none. */
+  readonly overlay: string
 }
 
-/** Read one composition file's stamp, or undefined when it cannot be statted. */
-async function compositionStamp(path: string): Promise<CompositionStamp | undefined> {
+/**
+ * Read one preset's stamp, or undefined when its composition cannot be statted.
+ * A layer that cannot be read stamps as absent: the mount then composes the
+ * bare preset, which is also what an unreadable layer applies.
+ */
+async function compositionStamp(preset: AgentPreset): Promise<CompositionStamp | undefined> {
   try {
-    const { mtimeMs, size } = await stat(path)
-    return { mtimeMs, size }
+    const { mtimeMs, size } = await stat(preset.path)
+    return { mtimeMs, size, overlay: await overlayDigest(preset.overlayPath) }
   } catch {
     // Deleted, replaced by an unreadable entry, or otherwise unstattable all
     // mean the same to the caller: the file offers no identity to compare.
@@ -815,9 +887,43 @@ async function compositionStamp(path: string): Promise<CompositionStamp | undefi
   }
 }
 
-/** Whether two stamps name the same file state. */
+/** Digest of the layer file's text, empty when the preset has none or it cannot be read. */
+async function overlayDigest(overlayPath: string | undefined): Promise<string> {
+  if (overlayPath === undefined) return ''
+  let text: string
+  try {
+    text = await readFile(overlayPath, 'utf8')
+  } catch {
+    // A layer deleted since discovery is a layer that applies nothing.
+    return ''
+  }
+  return createHash('sha1').update(text).digest('hex')
+}
+
+/** Whether two stamps name the same composition state. */
 function sameStamp(a: CompositionStamp, b: CompositionStamp): boolean {
-  return a.mtimeMs === b.mtimeMs && a.size === b.size
+  return a.mtimeMs === b.mtimeMs && a.size === b.size && a.overlay === b.overlay
+}
+
+/** One stamp as a map key. */
+function stampKey(stamp: CompositionStamp): string {
+  return `${String(stamp.mtimeMs)}:${String(stamp.size)}:${stamp.overlay}`
+}
+
+/**
+ * The preset's user patch layer as parsed, or empty when it has none or the
+ * layer cannot be read — the inventory then reports the rows the bare
+ * composition holds, which is also what such a layer applies.
+ */
+async function readOverlay(preset: AgentPreset): Promise<PatchOptions[]> {
+  if (preset.overlayPath === undefined) return []
+  try {
+    return await readPatchListFile('agent-presets', preset.overlayPath, 'user patch layer') ?? []
+  } catch {
+    // Discovery already reported an unparsable layer as the preset's health;
+    // the inventory answers with the composition alone.
+    return []
+  }
 }
 
 /** One preset's standing composition. */

+ 17 - 3
packages/preset/agent-presets/src/mount.ts

@@ -20,6 +20,7 @@ import { Include } from '@deepseek-ai/cordis-plugin-include'
 import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader'
 import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { readPatchListFile } from '@deepseek-ai/dsh-patch-file'
 import type { AgentPreset } from './preset.ts'
 import { classifyRowSpecifier } from './specifier.ts'
 
@@ -368,12 +369,19 @@ function mountDetail(error: unknown): string {
 /**
  * Mount `preset` under `agentCtx` and return only once every row is usable.
  *
+ * The preset's user patch layer, when it has one, is read here and handed to
+ * the include as its runtime patches, so the layer is applied over the
+ * composition with the Loader's own patch semantics — a `disabled: true`
+ * switches a row off, a `config` replaces the row's, an `insert` adds rows —
+ * and re-read at every mount, which is what makes a generation follow the
+ * layer file.
+ *
  * The subtree is owned by `agentCtx`'s fiber, so it unwinds with the agent and
  * the caller receives no disposer. A rejection leaves nothing mounted.
  * @param agentCtx - the agent's scope context, from the agent factory's `setup`.
  * @param preset - the resolved preset to compose the agent from.
- * @throws when `agentCtx` carries no scope, a row is unusable, or a row
- * published a service into the root realm.
+ * @throws when `agentCtx` carries no scope, the user patch layer cannot be
+ * read, a row is unusable, or a row published a service into the root realm.
  */
 export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promise<void> {
   const scope = scopeOf(agentCtx)
@@ -383,7 +391,13 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
       + 'its registrations would apply to every agent in the process',
     )
   }
-  const config: Include.Config = { path: pathToFileURL(preset.path).href }
+  const patches = preset.overlayPath === undefined
+    ? undefined
+    : await readPatchListFile('agent-presets', preset.overlayPath, 'user patch layer')
+  const config: Include.Config = {
+    path: pathToFileURL(preset.path).href,
+    ...patches === undefined ? {} : { patches },
+  }
   // Captured before the subtree exists: the standing scope context still
   // carries the host composition's base, which is inside the installed
   // harness and is therefore where a row's package name has to resolve from.

+ 8 - 0
packages/preset/agent-presets/src/preset.ts

@@ -25,6 +25,14 @@ export interface AgentPreset {
   readonly trust: PresetTrust
   /** Absolute path of the preset's agent composition file. */
   readonly path: string
+  /**
+   * Absolute path of the preset's user patch layer (`cordis.patch.yml`), when
+   * one exists: beside the composition for a locally authored preset, or in
+   * the user root's directory of the same id for a shipped one. Applied over
+   * the composition at every mount, so a person can disable or add a row
+   * without editing the file the deployment ships.
+   */
+  readonly overlayPath?: string
   /** Display name from the preset's own metadata; absent falls back to {@link id}. */
   readonly name?: string
   /** One sentence on what this preset is for, when it published one. */

+ 23 - 19
packages/preset/agent-presets/tests/composition-inventory.spec.ts

@@ -104,20 +104,21 @@ describe('fileComposition', () => {
 
     expect(await fileComposition(path, refuseExpression)).toEqual({
       rows: [
-        { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true },
-        { entryId: null, moduleName: 'pkg-anonymous', enabled: true },
-        { entryId: 'off', moduleName: 'pkg-off', enabled: false },
+        { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, source: 'preset' },
+        { entryId: null, moduleName: 'pkg-anonymous', enabled: true, source: 'preset' },
+        { entryId: 'off', moduleName: 'pkg-off', enabled: false, source: 'preset', disabledBy: 'composition' },
         {
           entryId: 'cond',
           moduleName: 'pkg-cond',
           enabled: 'conditional',
+          source: 'preset',
           condition: 'process.platform === \'win32\'',
         },
-        { entryId: 'child', moduleName: 'pkg-child', enabled: true },
-        { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false },
-        { entryId: 'buried', moduleName: 'pkg-buried', enabled: false },
-        { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional' },
-        { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false },
+        { entryId: 'child', moduleName: 'pkg-child', enabled: true, source: 'preset' },
+        { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false, source: 'preset', disabledBy: 'composition' },
+        { entryId: 'buried', moduleName: 'pkg-buried', enabled: false, source: 'preset', disabledBy: 'composition' },
+        { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional', source: 'preset' },
+        { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false, source: 'preset', disabledBy: 'composition' },
       ],
     })
   })
@@ -136,8 +137,8 @@ describe('fileComposition', () => {
 
     expect(await fileComposition(path, evaluateExpression)).toEqual({
       rows: [
-        { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1' },
-        { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2' },
+        { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1', source: 'preset', disabledBy: 'composition' },
+        { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2', source: 'preset' },
       ],
     })
   })
@@ -185,13 +186,14 @@ describe('mountedCompositionRows', () => {
     const byId = new Map(rows.map(row => [row.entryId, row]))
     expect(rows).toHaveLength(3)
     expect(byId.get(activeId)).toEqual(
-      { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE })
+      { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE, source: 'preset' })
     expect(byId.get(disabledId)).toEqual(
-      { entryId: disabledId, moduleName: 'cordis:active', enabled: false })
+      { entryId: disabledId, moduleName: 'cordis:active', enabled: false, source: 'preset', disabledBy: 'composition' })
     expect(byId.get(evaluatedId)).toEqual({
       entryId: evaluatedId,
       moduleName: 'cordis:active',
       enabled: true,
+      source: 'preset',
       condition: 'false',
       fiberState: FiberState.ACTIVE,
     })
@@ -224,15 +226,15 @@ describe('AgentPresets.compositionInventory', () => {
         id: 'minimal',
         trust: 'system',
         isDefault: true,
-        rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true }],
+        rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true, source: 'preset' }],
       },
       {
         id: 'standard',
         trust: 'system',
         isDefault: false,
         rows: [
-          { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true },
-          { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false },
+          { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true, source: 'preset' },
+          { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
         ],
       },
       {
@@ -241,15 +243,16 @@ describe('AgentPresets.compositionInventory', () => {
         name: '我的模式',
         isDefault: false,
         rows: [
-          { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true },
+          { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true, source: 'preset' },
           // The platform-gate shape: the service evaluates it with the
           // Loader's own scope, so the file answer matches a mount's.
-          { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1' },
+          { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1', source: 'preset', disabledBy: 'composition' },
           // An expression the evaluator refuses stays a mount's decision.
           {
             entryId: 'undecidable',
             moduleName: '@deepseek-ai/dsh-system-prompt',
             enabled: 'conditional',
+            source: 'preset',
             condition: 'nothing.here',
           },
         ],
@@ -279,9 +282,10 @@ describe('AgentPresets.compositionInventory', () => {
         entryId: 'alpha',
         moduleName: '../../plugins/contribute.js',
         enabled: true,
+        source: 'preset',
         fiberState: FiberState.ACTIVE,
       },
-      { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false },
+      { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
     ])
   })
 
@@ -311,7 +315,7 @@ describe('AgentPresets.compositionInventory', () => {
     expect(volatile).toMatchObject({ id: 'volatile', trust: 'user', isDefault: true })
     expect(volatile?.broken).toBeUndefined()
     expect(volatile?.rows).toEqual([
-      { entryId: 'only', moduleName: plugin, enabled: true, fiberState: FiberState.ACTIVE },
+      { entryId: 'only', moduleName: plugin, enabled: true, fiberState: FiberState.ACTIVE, source: 'preset' },
     ])
   })
 

+ 2 - 2
packages/preset/agent-presets/tests/mount.spec.ts

@@ -726,7 +726,7 @@ describe('editing a composition file', () => {
       standing: Map<string, Promise<{
         key: unknown
         scope: unknown
-        stamp: { mtimeMs: number; size: number }
+        stamp: { mtimeMs: number; size: number; overlay: string }
       }>>
       ensureStanding(current: typeof preset): Promise<unknown>
     }
@@ -734,7 +734,7 @@ describe('editing a composition file', () => {
     const stale = await stalePromise
     await writeFile(path, rowFor('afterwards'))
     const { mtimeMs, size } = await stat(path)
-    const newer = { ...stale, stamp: { mtimeMs, size } }
+    const newer = { ...stale, stamp: { mtimeMs, size, overlay: '' } }
     const newerPromise = Promise.resolve(newer)
 
     // `await pending` yields before the guarded delete, letting the winning

+ 342 - 0
packages/preset/agent-presets/tests/overlay.spec.ts

@@ -0,0 +1,342 @@
+/**
+ * The per-preset user patch layer: a `cordis.patch.yml` beside a locally
+ * authored composition, or alone in the user root's slot of a shipped
+ * preset's id. Discovery attaches it and judges it, the mount applies it,
+ * generations follow its content, the inventory reports its decisions, a
+ * copy carries it, and authoring can remove it.
+ */
+
+import { existsSync } from 'node:fs'
+import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { Context, FiberState } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import Group from '@deepseek-ai/cordis-plugin-group'
+import LlmRuntime from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { afterEach, describe, expect, it } from 'vitest'
+import AgentPresets, {
+  COMPOSITION_FILE, discoverPresets, livePresetMounts, overlayFacts, OVERLAY_FILE, scanRoot,
+} from '@deepseek-ai/dsh-agent-presets'
+import type { Config } from '@deepseek-ai/dsh-agent-presets'
+
+const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
+const HARNESS = new URL('.', import.meta.url).href
+const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
+const CONTRIBUTE = join(FIXTURES, 'plugins', 'contribute.js')
+
+const contexts: Context[] = []
+
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
+/** A fresh user root, optionally holding one slot with the given files. */
+async function userRoot(slots: Record<string, Record<string, string>> = {}): Promise<string> {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-overlay-root-'))
+  for (const [id, files] of Object.entries(slots)) {
+    await mkdir(join(root, id), { recursive: true })
+    for (const [file, content] of Object.entries(files)) await writeFile(join(root, id, file), content)
+  }
+  return root
+}
+
+/** An overlay that switches `alpha` off and inserts one row registering `tool`. */
+function overlayInserting(tool: string): string {
+  return [
+    '- id: alpha',
+    '  disabled: true',
+    '- insert:',
+    `    - id: user-${tool}`,
+    `      name: ${CONTRIBUTE}`,
+    '      config:',
+    `        tool: ${tool}`,
+    '',
+  ].join('\n')
+}
+
+async function harness(roster: Config): Promise<Context> {
+  const ctx = new Context()
+  contexts.push(ctx)
+  ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
+  await ctx.plugin(Loader)
+  ctx.loader.builtins.include = Include
+  ctx.loader.builtins.group = Group
+  await ctx.plugin(LlmRuntime)
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SystemPrompt, { persona: '' })
+  await ctx.plugin(ToolRuntime)
+  await ctx.plugin(AgentRegistry)
+  await ctx.plugin(SessionProjectionRegistry)
+  await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(AgentPresets, roster)
+  return ctx
+}
+
+async function agentOn(ctx: Context, id: string, presetId: string): Promise<Agent> {
+  const handle = await ctx.agents.create({
+    sessionId: SessionId(id),
+    setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, presetId),
+  })
+  return handle.agent
+}
+
+const toolNames = (ctx: Context, agent: Agent): string[] =>
+  ctx.tools.schemas(agent).map(schema => schema.name).sort()
+
+const rosterOver = (root: string, extra: Partial<Config> = {}): Config => ({
+  default: 'standard',
+  roots: [SYSTEM, { path: root, trust: 'user' }],
+  includeShippedRoot: false,
+  includeUserRoot: false,
+  ...extra,
+})
+
+describe('discovery', () => {
+  it('attaches a lone layer in a later root to the preset that won the id', async () => {
+    const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+
+    const presets = await discoverPresets([SYSTEM, { path: root, trust: 'user' }], HARNESS)
+
+    const standard = presets.find(preset => preset.id === 'standard')
+    expect(standard).toMatchObject({ trust: 'system', overlayPath: join(root, 'standard', OVERLAY_FILE) })
+    expect(standard?.broken).toBeUndefined()
+    // The lone layer is not a preset of its own root.
+    expect(await scanRoot({ path: root, trust: 'user' }, HARNESS)).toEqual([])
+  })
+
+  it('reads a layer beside a locally authored composition as that preset\'s own', async () => {
+    const root = await userRoot({
+      mine: {
+        [COMPOSITION_FILE]: `- id: only\n  name: ${CONTRIBUTE}\n  config:\n    tool: only\n`,
+        [OVERLAY_FILE]: '- id: only\n  disabled: true\n',
+      },
+    })
+    const other = await userRoot({ mine: { [OVERLAY_FILE]: '- id: only\n  config: {}\n' } })
+
+    const presets = await discoverPresets([{ path: root, trust: 'user' }, { path: other, trust: 'user' }], HARNESS)
+
+    // The preset's own layer wins; a later root's lone layer for the same id is ignored.
+    expect(presets).toEqual([expect.objectContaining({ id: 'mine', overlayPath: join(root, 'mine', OVERLAY_FILE) })])
+  })
+
+  it('reports a lone layer whose id no root supplies as a broken slot', async () => {
+    const root = await userRoot({ ghost: { [OVERLAY_FILE]: '[]\n' } })
+
+    const presets = await discoverPresets([SYSTEM, { path: root, trust: 'user' }], HARNESS)
+
+    expect(presets.find(preset => preset.id === 'ghost')).toMatchObject({
+      trust: 'user',
+      path: join(root, 'ghost', COMPOSITION_FILE),
+      overlayPath: join(root, 'ghost', OVERLAY_FILE),
+      broken: expect.stringContaining('no root supplies a preset "ghost"') as string,
+    })
+    // A slot with neither file is still the missing-composition case.
+    const empty = await userRoot({ hollow: { 'notes.txt': '' } })
+    const hollow = (await discoverPresets([{ path: empty, trust: 'user' }], HARNESS)).find(preset => preset.id === 'hollow')
+    expect(hollow?.broken).toContain(`${COMPOSITION_FILE} is missing`)
+  })
+
+  it('judges the layer with the composition: unparsable, malformed, or unresolvable inserts break the preset', async () => {
+    const unparsable = await userRoot({ standard: { [OVERLAY_FILE]: 'a: [\n' } })
+    const malformed = await userRoot({ standard: { [OVERLAY_FILE]: '- insert:\n    - config: {}\n' } })
+    const unresolvable = await userRoot({ standard: { [OVERLAY_FILE]: '- insert:\n    - id: x\n      name: ./nowhere.js\n' } })
+    const own = await userRoot({
+      mine: { [COMPOSITION_FILE]: '[]\n', [OVERLAY_FILE]: 'a: 1\n' },
+    })
+
+    const [a, b, c, d] = await Promise.all([unparsable, malformed, unresolvable].map(
+      async root => (await discoverPresets([SYSTEM, { path: root, trust: 'user' }], HARNESS)).find(preset => preset.id === 'standard'),
+    ).concat([
+      (async () => (await discoverPresets([{ path: own, trust: 'user' }], HARNESS)).find(preset => preset.id === 'mine'))(),
+    ]))
+
+    expect(a?.broken).toContain(`the user patch layer ${OVERLAY_FILE} cannot be applied`)
+    expect(b?.broken).toContain('user patch layer entry 1 row 1 names no plugin')
+    expect(c?.broken).toContain('inserts row "x", which names a plugin that cannot be resolved')
+    expect(d?.broken).toContain('must be a top-level YAML array')
+  })
+
+  it('leaves a broken composition\'s verdict in place rather than judging its layer', async () => {
+    const shipped = await userRoot({ bad: { [COMPOSITION_FILE]: '- id: x\n  name: ./nowhere.js\n' } })
+    const root = await userRoot({ bad: { [OVERLAY_FILE]: 'a: [\n' } })
+
+    const presets = await discoverPresets([{ path: shipped, trust: 'system' }, { path: root, trust: 'user' }], HARNESS)
+
+    const broken = presets.find(preset => preset.id === 'bad')
+    expect(broken?.overlayPath).toBe(join(root, 'bad', OVERLAY_FILE))
+    expect(broken?.broken).toContain('cannot be resolved')
+    expect(broken?.broken).not.toContain('user patch layer')
+  })
+})
+
+describe('mounting', () => {
+  it('applies the layer over the composition: a row switched off, a row added', async () => {
+    const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+    const ctx = await harness(rosterOver(root))
+
+    const agent = await agentOn(ctx, 'sess-overlaid', 'standard')
+
+    expect(toolNames(ctx, agent)).toEqual(['gamma'])
+  })
+
+  it('starts a new generation when the layer changes and returns to one whose content comes back', async () => {
+    const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+    const ctx = await harness(rosterOver(root))
+    const first = await agentOn(ctx, 'sess-gen-1', 'standard')
+    const firstMount = livePresetMounts().findLast(mount => mount.presetId === 'standard')
+
+    await writeFile(join(root, 'standard', OVERLAY_FILE), overlayInserting('delta'))
+    const second = await agentOn(ctx, 'sess-gen-2', 'standard')
+    const secondMount = livePresetMounts().findLast(mount => mount.presetId === 'standard')
+
+    expect(toolNames(ctx, first)).toEqual(['gamma'])
+    expect(toolNames(ctx, second)).toEqual(['delta'])
+    expect(secondMount).not.toBe(firstMount)
+    expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(2)
+
+    // The same content again: the retired generation serves, no third mount.
+    await writeFile(join(root, 'standard', OVERLAY_FILE), overlayInserting('gamma'))
+    const third = await agentOn(ctx, 'sess-gen-3', 'standard')
+    expect(toolNames(ctx, third)).toEqual(['gamma'])
+    expect(livePresetMounts().filter(mount => mount.presetId === 'standard')).toHaveLength(2)
+    expect(await ctx.agentPresets.standingKeyFor('standard')).toBe(firstMount?.key)
+  })
+
+  it('composes the bare preset when the layer disappears between discovery and mount', async () => {
+    const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+    const ctx = await harness(rosterOver(root))
+    const preset = await ctx.agentPresets.resolve('standard')
+    await writeFile(join(root, 'standard', OVERLAY_FILE), '[]\n')
+
+    const racer = ctx.agentPresets as unknown as { ensureStanding(current: typeof preset): Promise<unknown> }
+    await racer.ensureStanding(preset)
+    const agent = await agentOn(ctx, 'sess-bare', 'standard')
+
+    expect(toolNames(ctx, agent)).toEqual(['alpha'])
+  })
+})
+
+describe('inventory', () => {
+  it('reports the layer\'s decisions from the file and from a standing mount', async () => {
+    const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+    const ctx = await harness(rosterOver(root))
+
+    const fromFile = (await ctx.agentPresets.compositionInventory()).find(composition => composition.id === 'standard')
+    expect(fromFile?.rows).toEqual([
+      { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' },
+      { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
+      { entryId: 'user-gamma', moduleName: CONTRIBUTE, enabled: true, source: 'user' },
+    ])
+
+    await agentOn(ctx, 'sess-inventory', 'standard')
+    const mounted = (await ctx.agentPresets.compositionInventory()).find(composition => composition.id === 'standard')
+    expect(mounted?.rows).toEqual([
+      { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'user' },
+      { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false, source: 'preset', disabledBy: 'composition' },
+      { entryId: 'user-gamma', moduleName: CONTRIBUTE, enabled: true, source: 'user', fiberState: FiberState.ACTIVE },
+    ])
+  })
+
+  it('answers from the composition alone when the layer stopped reading', async () => {
+    const root = await userRoot({ standard: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+    const ctx = await harness(rosterOver(root))
+    await agentOn(ctx, 'sess-inventory-broken-layer', 'standard')
+    await writeFile(join(root, 'standard', OVERLAY_FILE), 'a: [\n')
+
+    const mounted = (await ctx.agentPresets.compositionInventory()).find(composition => composition.id === 'standard')
+
+    expect(mounted?.rows.map(row => [row.entryId, row.source, row.disabledBy])).toEqual([
+      ['alpha', 'preset', 'composition'],
+      ['alpha-extra', 'preset', 'composition'],
+      ['user-gamma', 'preset', undefined],
+    ])
+  })
+
+  it('reads a layer\'s inserted and disabled ids, ignoring !!js gates', () => {
+    expect(overlayFacts([
+      { id: 'a', disabled: true },
+      { id: 'b', disabled: { __jsExpr: 'true' } as unknown as boolean },
+      { id: 'c', config: {} },
+      { insert: [{ id: 'd', name: 'x' }, { id: 'g', name: 'cordis:group', group: true, config: [{ id: 'e', name: 'y' }] }] },
+    ])).toEqual({ inserted: new Set(['d', 'g', 'e']), disabled: new Set(['a']) })
+  })
+})
+
+describe('authoring', () => {
+  it('names the layer path for shipped and locally authored presets', async () => {
+    const root = await userRoot({
+      mine: { [COMPOSITION_FILE]: `- id: only\n  name: ${CONTRIBUTE}\n  config:\n    tool: only\n` },
+    })
+    const ctx = await harness(rosterOver(root))
+
+    expect(await ctx.agentPresets.overlayPathFor('standard')).toBe(join(root, 'standard', OVERLAY_FILE))
+    expect(await ctx.agentPresets.overlayPathFor('mine')).toBe(join(root, 'mine', OVERLAY_FILE))
+    await writeFile(join(root, 'mine', OVERLAY_FILE), '[]\n')
+    expect(await ctx.agentPresets.overlayPathFor('mine')).toBe(join(root, 'mine', OVERLAY_FILE))
+  })
+
+  it('refuses a layer path when the deployment configures no writable root', async () => {
+    const ctx = await harness({ default: 'standard', roots: [SYSTEM], includeShippedRoot: false, includeUserRoot: false })
+
+    await expect(ctx.agentPresets.overlayPathFor('standard')).rejects.toMatchObject({ code: 'agent-preset/read-only' })
+  })
+
+  it('copies a shipped preset\'s layer in beside the new composition', async () => {
+    // A shipped preset whose rows resolve from anywhere, so its copy mounts.
+    const shipped = await userRoot({
+      base: { [COMPOSITION_FILE]: `- id: alpha\n  name: ${CONTRIBUTE}\n  config:\n    tool: alpha\n` },
+    })
+    const root = await userRoot({ base: { [OVERLAY_FILE]: overlayInserting('gamma') } })
+    const ctx = await harness({
+      default: 'base',
+      roots: [{ path: shipped, trust: 'system' }, { path: root, trust: 'user' }],
+      includeShippedRoot: false,
+      includeUserRoot: false,
+    })
+
+    await ctx.agentPresets.copy('base', 'research', 'Research')
+
+    expect(await readFile(join(root, 'research', OVERLAY_FILE), 'utf8')).toBe(overlayInserting('gamma'))
+    const research = await ctx.agentPresets.resolve('research')
+    expect(research.overlayPath).toBe(join(root, 'research', OVERLAY_FILE))
+    const agent = await agentOn(ctx, 'sess-copied', 'research')
+    expect(toolNames(ctx, agent)).toEqual(['gamma'])
+  })
+
+  it('removes a layer and the slot it alone occupied', async () => {
+    const root = await userRoot({
+      standard: { [OVERLAY_FILE]: overlayInserting('gamma') },
+      mine: { [COMPOSITION_FILE]: '[]\n', [OVERLAY_FILE]: '[]\n' },
+    })
+    const ctx = await harness(rosterOver(root))
+
+    expect(await ctx.agentPresets.removeOverlay('standard')).toBe(true)
+    expect(existsSync(join(root, 'standard'))).toBe(false)
+    expect(await ctx.agentPresets.removeOverlay('standard')).toBe(false)
+    expect(await ctx.agentPresets.removeOverlay('mine')).toBe(true)
+    expect(existsSync(join(root, 'mine', COMPOSITION_FILE))).toBe(true)
+    expect((await ctx.agentPresets.resolve('standard')).overlayPath).toBeUndefined()
+  })
+
+  it('refuses to remove a layer outside the writable root', async () => {
+    const shipped = await userRoot({ standard: { [OVERLAY_FILE]: '[]\n' } })
+    const writable = await userRoot()
+    const ctx = await harness({
+      default: 'standard',
+      roots: [SYSTEM, { path: shipped, trust: 'system' }, { path: writable, trust: 'user' }],
+      includeShippedRoot: false,
+      includeUserRoot: false,
+    })
+
+    await expect(ctx.agentPresets.removeOverlay('standard')).rejects.toMatchObject({ code: 'agent-preset/read-only' })
+  })
+})

+ 3 - 0
packages/preset/agent-presets/tsconfig.json

@@ -51,6 +51,9 @@
     {
       "path": "../../util/home-paths"
     },
+    {
+      "path": "../../util/patch-file"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 6 - 0
packages/preset/global-tool-mask/README.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 packages/preset/global-tool-mask/README.md
+README.md: 8c0b9dba5ac932f9d531323fcfde016b0029a7d2
+README.zh.md: 9e65f12f8f2636bc33b24ba4a2a751474019f3be

+ 98 - 0
packages/preset/global-tool-mask/README.md

@@ -0,0 +1,98 @@
+---
+description: "Composition-authored global-tool mask for one agent preset: a row that hides named host tools from the sessions the preset composes."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-global-tool-mask
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-global-tool-mask` is `tools.restrict()` as a composition row. The host's global tools — the ones a host row registers, such as `web_fetch` — reach every session through the global layer, and nothing in a preset file could hide one until now: a preset can add rows, not subtract tools other rows registered. Mounted inside an agent preset, this row masks the named global tools for the sessions that preset composes and nothing else; mounted globally it rejects, because a context-global restriction would mask every agent. Its intended home is a preset's user patch layer (`$DSH_HOME/.agent-presets/<id>/cordis.patch.yml`), where a person hides a host tool from one preset without editing the composition the deployment ships.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Insert one row into a preset's composition or its user patch layer. `deny` hides the named tools; `allow` keeps only the named tools; both together intersect:
+
+```yaml
+- insert:
+    - id: hide-web
+      name: '@deepseek-ai/dsh-global-tool-mask'
+      config:
+        deny: [web_fetch, web_search]
+```
+
+The mask follows the row's fiber: it applies when the preset's standing composition mounts and lifts when that composition is torn down. The names are checked at mount, so a mask that names no tool, or names one the host does not register, fails the preset loud with the registry's own message rather than silently masking nothing. Scoped registrations — tools the preset's own rows register — are never affected; the reserved PTC transport name cannot be masked either.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The row is one `ctx.effect()` around `ctx.tools.restrict(config)`. Every rule — scoped context required, non-empty filter, known names, the reserved transport name — belongs to the registry and is enforced there; the row adds a `Config` schema so a hand-written composition is validated at load.
+
+### Source map
+
+| File | Role |
+|---|---|
+| [`src/index.ts`](src/index.ts) | The `global-tool-mask` function plugin: `name`, `inject`, `Config`, `apply` |
+| — | No runtime invariant companion is published; the registry owns the restriction's lifetime and reports the mask through its own views. |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+Read these when the question is what the mask acts on or where the row lives.
+
+- [Tool registry](../../core/tools/README.md) — `tools.restrict()`, the layered views, and why a restriction requires a scope.
+- [Agent presets](../agent-presets/README.md) — the compositions and user patch layers this row is inserted into.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+Indirectly, through the tool registry: the row removes tool schemas from the request the registry assembles for the scoped agent and registers no prompt, schema, or result of its own.
+
+#### KV Cache effect
+
+None of its own; the registry assembles the tool list, and a mask changes which schemas that list carries for the sessions of one preset from their first request onward.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define what the row will not do. They are current package constraints, not a task backlog.
+
+- **Global tools only** — the mask cannot hide a tool a row of the same preset registers; disable that row instead.
+- **Mount-time names** — a tool registered after the preset mounted is not retroactively judged: a name the host adds later cannot have been in the mask, and a name the host stops registering leaves the mask as it was.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 98 - 0
packages/preset/global-tool-mask/README.zh.md

@@ -0,0 +1,98 @@
+---
+description: "面向单个 agent preset 的、由组合书写的全局工具掩码:一行把点名的宿主工具对该 preset 组合出的会话隐藏起来。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-global-tool-mask
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-global-tool-mask` 是写成组合行的 `tools.restrict()`。宿主的全局工具——由宿主行注册的那些,如 `web_fetch`——经全局层到达每一个会话,而在此之前 preset 文件里没有任何东西能藏起其中一个:preset 能加行,却减不掉别的行注册的工具。挂在 agent preset 内部时,本行只对该 preset 组合出的会话遮蔽点名的全局工具,别的一概不动;挂在全局则拒绝,因为一个上下文全局的限制会遮住每一个 agent。它预期的落点是 preset 的用户补丁层(`$DSH_HOME/.agent-presets/<id>/cordis.patch.yml`):一个人不必编辑部署随附的组合,就能把一个宿主工具对某个 preset 藏起来。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+往 preset 的组合或其用户补丁层插入一行。`deny` 隐藏点名的工具;`allow` 只保留点名的工具;两者同时给出时取交集:
+
+```yaml
+- insert:
+    - id: hide-web
+      name: '@deepseek-ai/dsh-global-tool-mask'
+      config:
+        deny: [web_fetch, web_search]
+```
+
+掩码跟随本行的 fiber:preset 的常驻组合挂载时生效,该组合被拆除时解除。名字在挂载时检查,因此一个没点名任何工具、或点名了宿主未注册工具的掩码会带着注册表自己的消息让 preset 大声失败,而不是悄悄什么都不遮。作用域内的注册——preset 自己的行注册的工具——永远不受影响;保留的 PTC 传输名也不能被遮蔽。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内幕——点击展开</summary>
+
+本行就是包着 `ctx.tools.restrict(config)` 的一个 `ctx.effect()`。每条规则——必须是作用域上下文、过滤器非空、名字已知、保留的传输名——都属于注册表并在那里执行;本行只增加一个 `Config` schema,让手写的组合在加载时得到校验。
+
+### 源码地图
+
+| 文件 | 职责 |
+|---|---|
+| [`src/index.ts`](src/index.ts) | `global-tool-mask` 函数插件:`name`、`inject`、`Config`、`apply` |
+| — | 不发布运行时不变量伴随件;注册表拥有限制的生命周期,并通过自己的视图报告掩码。 |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+当问题在于掩码作用于什么、或这一行放在哪里时,读这些。
+
+- [工具注册表](../../core/tools/README.zh.md)——`tools.restrict()`、分层视图,以及限制为何需要作用域。
+- [Agent presets](../agent-presets/README.zh.md)——本行被插入其中的组合与用户补丁层。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+间接地,经由工具注册表:本行从注册表为作用域内 agent 组装的请求中移除工具 schema,自身不注册任何提示词、schema 或结果。
+
+#### KV Cache 影响
+
+自身无;工具列表由注册表组装,掩码改变的是该列表为某个 preset 的会话从首次请求起携带哪些 schema。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制界定本行不会做什么。它们是当前包的约束,不是任务清单。
+
+- **仅限全局工具**——掩码藏不住同一 preset 中某行注册的工具;请改为停用那一行。
+- **名字在挂载时判定**——preset 挂载后才注册的工具不会被追溯判定:宿主之后新增的名字不可能出现在掩码里,宿主不再注册的名字也让掩码保持原样。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 42 - 0
packages/preset/global-tool-mask/package.json

@@ -0,0 +1,42 @@
+{
+  "name": "@deepseek-ai/dsh-global-tool-mask",
+  "description": "Composition-authored global-tool mask for one agent preset: a row that hides named host tools from the sessions the preset composes",
+  "version": "0.1.2-rc.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/preset/global-tool-mask"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-scope": "workspace:^",
+    "@deepseek-ai/dsh-system-prompt": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  }
+}

+ 54 - 0
packages/preset/global-tool-mask/src/index.ts

@@ -0,0 +1,54 @@
+/**
+ * A global-tool mask as a composable row.
+ *
+ * `dsh-tools` lets an agent scope restrict the host's global tools through
+ * `tools.restrict()`, and nothing in a composition file could call it: a
+ * preset can add rows, but the tools a host row registers reach every
+ * session through the global layer. This row is that call as a row —
+ * **scope-only**, like `dsh-persona`: mounted inside an agent preset it hides
+ * the named global tools from the sessions the preset composes; mounted
+ * globally it rejects, because a context-global restriction would mask every
+ * agent. A preset's user patch layer is where the row is meant to land, so a
+ * person can hide a host tool from one preset without editing the file the
+ * deployment ships.
+ * @module @deepseek-ai/dsh-global-tool-mask
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import type {} from '@deepseek-ai/dsh-tools'
+
+/** Cordis plugin name. */
+export const name = 'global-tool-mask'
+
+/** The registry this row masks. */
+export const inject = ['tools']
+
+/** Plugin config: the global-tool mask, in `tools.restrict()`'s own vocabulary. */
+export interface Config {
+  /** Global tool names that stay visible; everything else is hidden. Empty means no allow list. */
+  allow: string[]
+  /** Global tool names hidden from this scope. Empty means no deny list. */
+  deny: string[]
+}
+
+/** Runtime schema for the mask row. */
+export const Config: z<Config> = z.object({
+  allow: z.array(z.string()),
+  deny: z.array(z.string()),
+})
+
+/**
+ * Restrict the global tools visible to the mounting context's scope.
+ * @param ctx - an agent scope context; an unscoped context rejects, as does
+ * a mask that names no tool or names one the host does not register.
+ * @param config - the mask; `allow` and `deny` intersect when both are given.
+ * An empty list is the same as an absent one: the schema materializes a
+ * missing list as `[]`, and an empty `allow` would otherwise hide every tool.
+ */
+export function apply(ctx: Context, config: Config): void {
+  ctx.effect(() => ctx.tools.restrict({
+    ...config.allow.length === 0 ? {} : { allow: config.allow },
+    ...config.deny.length === 0 ? {} : { deny: config.deny },
+  }), 'global-tool-mask.restrict()')
+}

+ 71 - 0
packages/preset/global-tool-mask/tests/global-tool-mask.spec.ts

@@ -0,0 +1,71 @@
+import { Context } from '@deepseek-ai/cordis'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime from '@deepseek-ai/dsh-tools'
+import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope'
+import { describe, expect, it } from 'vitest'
+import * as ToolRestrict from '@deepseek-ai/dsh-global-tool-mask'
+
+async function harness(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(SystemPrompt, { persona: '' })
+  await ctx.plugin(ToolRuntime)
+  for (const name of ['alpha', 'beta', 'gamma']) {
+    ctx.tools.register({
+      name,
+      description: `fixture tool ${name}`,
+      parameters: { type: 'object', properties: {}, additionalProperties: false },
+      output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
+      execute: () => Promise.resolve(name),
+    })
+  }
+  return ctx
+}
+
+const visible = (ctx: Context, scope?: ScopeKey): string[] =>
+  ctx.tools.schemas(scope).map(schema => schema.name).sort()
+
+describe('the global-tool-mask row', () => {
+  it('rejects an unscoped mount, which would mask every agent', async () => {
+    const ctx = await harness()
+
+    await expect(ctx.plugin(ToolRestrict, { allow: [], deny: ['alpha'] })).rejects.toThrow(/requires a scoped context/)
+  })
+
+  it('hides denied global tools from one scope only', async () => {
+    const ctx = await harness()
+    const key: ScopeKey = { agent: 'a1' }
+
+    await createScope(ctx, key).ctx.plugin(ToolRestrict, { allow: [], deny: ['alpha'] })
+
+    expect(visible(ctx, key)).toEqual(['beta', 'gamma'])
+    expect(visible(ctx)).toEqual(['alpha', 'beta', 'gamma'])
+  })
+
+  it('keeps only allowed tools, intersected with a deny list', async () => {
+    const ctx = await harness()
+    const key: ScopeKey = { agent: 'a2' }
+
+    await createScope(ctx, key).ctx.plugin(ToolRestrict, { allow: ['alpha', 'beta'], deny: ['beta'] })
+
+    expect(visible(ctx, key)).toEqual(['alpha'])
+  })
+
+  it('fails loud on an empty mask and on a tool the host does not register', async () => {
+    const ctx = await harness()
+
+    await expect(createScope(ctx, { agent: 'a3' }).ctx.plugin(ToolRestrict, { allow: [], deny: [] })).rejects.toThrow(/is a no-op/)
+    await expect(createScope(ctx, { agent: 'a4' }).ctx.plugin(ToolRestrict, { allow: [], deny: ['nope'] }))
+      .rejects.toThrow(/unknown global tool "nope"/)
+  })
+
+  it('lifts the mask when its fiber unloads', async () => {
+    const ctx = await harness()
+    const key: ScopeKey = { agent: 'a5' }
+    const fiber = await createScope(ctx, key).ctx.plugin(ToolRestrict, { allow: [], deny: ['alpha'] })
+    expect(visible(ctx, key)).toEqual(['beta', 'gamma'])
+
+    await fiber.dispose()
+
+    expect(visible(ctx, key)).toEqual(['alpha', 'beta', 'gamma'])
+  })
+})

+ 22 - 0
packages/preset/global-tool-mask/tsconfig.json

@@ -0,0 +1,22 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": ["src"],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/tools"
+    }
+  ]
+}

+ 6 - 0
packages/util/patch-file/README.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 packages/util/patch-file/README.md
+README.md: b2e25491c6633e0e5f190112e7b13e2fa61040f7
+README.zh.md: 427bf9217828a8fc534b27cb620e3da7d7bbee58

+ 112 - 0
packages/util/patch-file/README.md

@@ -0,0 +1,112 @@
+---
+description: "Loader patch-list files: the shared parser for cordis.patch.yml layers and a comment-preserving key-level writer with lock, atomic replace, and readback."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-patch-file
+
+English | [中文](README.zh.md)
+
+## Summary
+
+`dsh-patch-file` owns the file format every composition layer of the harness is written in: a `cordis.patch.yml` is a top-level YAML sequence of Loader patch entries — id-targeted overrides such as `disabled: true` or a `config` replacement, and `insert` lists of new rows — in the Loader's own dialect, where a `!!js` scalar is an expression the row's fiber evaluates. `parsePatchList` is the one parser the profile launcher, bundle layers, `--patch` overlays, and agent-preset user layers read through, so a file one of them accepts is a file all of them accept. `PatchDocument` edits such a file at the key level while keeping its comments, blank lines, and `!!js` scalars as the author wrote them, and `mutatePatchFile` commits one edit under the cross-process writer lock, through an atomic replace, and reads the written text back through the parser before reporting what every reader will now load.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+Read a patch layer with `parsePatchList` (text in hand) or `readPatchListFile` (absent file reads as `undefined`). Both anchor a relative `insert` row name such as `./plugin.js` to the file's own directory and fail loud on anything that is not a sequence of mappings, because a patch file that cannot be applied at all is a misconfiguration; a patch whose target row is absent stays a per-entry Loader warning.
+
+Write through `mutatePatchFile`. The callback receives a `PatchDocument` and edits it by row id, the way the Loader addresses rows:
+
+```ts
+await mutatePatchFile(file, (document) => {
+  document.setRowField('tool-web', 'disabled', true)      // the id-targeted patch is created when absent
+  document.deleteRowField('tool-web', 'config')           // a patch reduced to its id is removed whole
+  document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' })          // into the root list
+  document.appendInsert({ id: 'sql', name: 'dsh-sql' }, 'agents')          // into the group with that id
+  document.removeInsert('tool-foo')                       // an emptied insert patch is removed whole
+}, { binName: 'dsh', mode: 0o600, dirMode: 0o700 })
+```
+
+`setRowField` never accepts `id` or `insert`; `rowField` reads one key back, with a `!!js` scalar returned as its source text. `appendInsert` refuses an id the file already inserts, and `insertedRow`/`removeInsert` find rows inside inserted groups too. Values written are plain data; a `!!js` scalar on another key is left untouched, which is what lets a user layer revert a `disabled: true` it wrote without disturbing a bundle's `!!js` gate on a different row.
+
+`mutatePatchFile` takes the `<file>.lock` sibling the way `dsh-atomic-write` does, reads the file (absent reads as empty), applies the edit, replaces the file atomically with the stated permission bits when the text changed, and returns the patch list as re-read from the written text. An edit that changes nothing writes nothing.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+### Two parsers, one dialect
+
+Reading 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) | `parsePatchList`, `readPatchListFile`, `anchorInsertedPluginNames`, `PatchDocument`, `mutatePatchFile` |
+| — | No runtime invariant companion is published; the package holds no runtime state, and its file contract is pinned by unit tests. |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+Read these when the file format's place in the composition is the question.
+
+- [App boot](../../boot/app-boot/README.md) — the profile launcher that composes bundle layers, user layers, and overlays from these files.
+- [Cordis include plugin](../../../vendor/include/README.md) — `applyEntryPatches`, the patch semantics every layer is applied with.
+- [Agent presets](../../preset/agent-presets/README.md) — the per-preset user layer written in this format.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the package only reads and writes composition patch files; the rows they name own every model-facing registration.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+These limits define what the writer will not do to a file. They are current package constraints, not a task backlog.
+
+- **Key-level, not row-level, merging** — `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.
+- **No expression authoring** — the writer 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 writer is never removed by a contender, which fails after the wait instead; `dsh-atomic-write` documents the same choice.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 112 - 0
packages/util/patch-file/README.zh.md

@@ -0,0 +1,112 @@
+---
+description: "Loader 补丁列表文件:cordis.patch.yml 各层共用的解析器,以及带锁、原子替换与回读的、保留注释的键级写入器。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-patch-file
+
+[English](README.md) | 中文
+
+## 概述
+
+`dsh-patch-file` 拥有 harness 每一个组合层所使用的文件格式:一个 `cordis.patch.yml` 是 Loader 补丁条目的顶层 YAML 序列——按 id 定位的覆盖(如 `disabled: true` 或替换 `config`)与插入新行的 `insert` 列表——采用 Loader 自己的方言,其中 `!!js` 标量是由该行 fiber 求值的表达式。`parsePatchList` 是 profile launcher、组合包层、`--patch` 覆盖层与 agent preset 用户层共用的唯一解析器,因此其中一方接受的文件所有人都接受。`PatchDocument` 在键级编辑这样的文件,同时保留作者写下的注释、空行与 `!!js` 标量;`mutatePatchFile` 在跨进程写锁下提交一次编辑,经原子替换落盘,并在报告结果前把写入的文本经解析器回读,告诉调用方每个读者此后会加载到什么。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+用 `parsePatchList`(已有文本)或 `readPatchListFile`(文件不存在时读到 `undefined`)读取一个补丁层。两者都把 `insert` 行中相对的名字(如 `./plugin.js`)锚定到文件自己的目录,并对任何不是"映射序列"的内容直接报错,因为一个完全无法施加的补丁文件就是配置错误;而目标行不存在的单条补丁仍然只是 Loader 的逐条警告。
+
+通过 `mutatePatchFile` 写入。回调拿到一个 `PatchDocument`,按 Loader 寻址行的方式以行 id 编辑:
+
+```ts
+await mutatePatchFile(file, (document) => {
+  document.setRowField('tool-web', 'disabled', true)      // the id-targeted patch is created when absent
+  document.deleteRowField('tool-web', 'config')           // a patch reduced to its id is removed whole
+  document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' })          // into the root list
+  document.appendInsert({ id: 'sql', name: 'dsh-sql' }, 'agents')          // into the group with that id
+  document.removeInsert('tool-foo')                       // an emptied insert patch is removed whole
+}, { binName: 'dsh', mode: 0o600, dirMode: 0o700 })
+```
+
+`setRowField` 永远不接受 `id` 与 `insert`;`rowField` 读回一个键,`!!js` 标量以其源文本返回。`appendInsert` 拒绝文件已插入的 id;`insertedRow`/`removeInsert` 也能找到插入组内部的行。写入的值都是普通数据;别的键上的 `!!js` 标量原样不动,这正是用户层能撤回自己写的 `disabled: true` 而不惊动组合包在另一行上的 `!!js` 门的原因。
+
+`mutatePatchFile` 像 `dsh-atomic-write` 一样占用 `<file>.lock` 兄弟文件,读取文件(不存在按空处理),施加编辑,在文本有变化时以声明的权限位原子替换文件,然后返回从写入文本重新读出的补丁列表。什么都没改的编辑什么都不写。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现内幕——点击展开</summary>
+
+### 两个解析器,一种方言
+
+读取用 `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) | `parsePatchList`、`readPatchListFile`、`anchorInsertedPluginNames`、`PatchDocument`、`mutatePatchFile` |
+| — | 不发布运行时不变量伴随件;本包不持有运行时状态,其文件契约由单元测试钉住。 |
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+当问题在于这种文件格式在组合中的位置时,读这些。
+
+- [App boot](../../boot/app-boot/README.zh.md)——从这些文件组合组合包层、用户层与覆盖层的 profile launcher。
+- [Cordis include 插件](../../../vendor/include/README.md)——`applyEntryPatches`,每一层施加时所用的补丁语义。
+- [Agent presets](../../preset/agent-presets/README.zh.md)——以此格式书写的每预设用户层。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无,本包只读写组合补丁文件;这些文件点名的行拥有全部面向模型的注册。
+
+#### KV Cache 影响
+
+无;本包既不组装也不发送 provider 请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+
+这些限制界定写入器不会对文件做什么。它们是当前包的约束,不是任务清单。
+
+- **键级而非行级合并**——`setRowField('x', 'config', value)` 替换该补丁的整个 `config` 映射;只想改一个嵌套字段的调用方先用 `rowField` 读出当前值,再把合并后的映射写回。
+- **不生成表达式**——写入器只输出普通数据;`!!js` 门是作者敲进文件的东西,从不是某个 API 调用的产物。
+- **锁孤儿由操作者处理**——写入器崩溃留下的锁文件不会被竞争者移除,竞争者等待超时后失败;`dsh-atomic-write` 记录了同样的选择。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 45 - 0
packages/util/patch-file/package.json

@@ -0,0 +1,45 @@
+{
+  "name": "@deepseek-ai/dsh-patch-file",
+  "description": "Loader patch-list files (cordis.patch.yml): the shared parser with the !!js dialect and relative-name anchoring, and a comment-preserving key-level writer with lock, atomic replace, and readback",
+  "version": "0.1.2-rc.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/util/patch-file"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/dsh-atomic-write": "workspace:^",
+    "js-yaml": "^4.2.0",
+    "yaml": "^2.9.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@types/js-yaml": "^4.0.9"
+  }
+}

+ 397 - 0
packages/util/patch-file/src/index.ts

@@ -0,0 +1,397 @@
+/**
+ * Loader patch-list files — the `cordis.patch.yml` layers a profile, a
+ * bundle, a `--patch` overlay, and an agent preset's user layer are written
+ * in: a top-level YAML sequence of `@deepseek-ai/cordis-plugin-include`
+ * `PatchOptions` (id-targeted overrides and `insert` lists) in the Loader's
+ * own dialect, where `!!js` marks an expression the row's fiber evaluates.
+ *
+ * One home for both directions. {@link parsePatchList} is the parser every
+ * reader of such a file shares, so a file the boot accepts is a file every
+ * other consumer accepts. {@link PatchDocument} edits one file at the key
+ * level while keeping its comments, blank lines, and `!!js` scalars as the
+ * author wrote them, and {@link mutatePatchFile} commits one edit under the
+ * cross-process writer lock, through an atomic replace, and reads the result
+ * back through the parser before reporting success.
+ * @module @deepseek-ai/dsh-patch-file
+ */
+
+import { mkdir, readFile } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import * as yaml from 'js-yaml'
+import { Document, isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from 'yaml'
+import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
+
+/**
+ * Resolve relative plugin paths in one patch list's `insert` rows against the
+ * file's own directory, without changing assertion names. A row naming
+ * `./plugin.js` means the file beside the patch file, wherever the Loader's
+ * root happens to be.
+ * @param patches - the parsed patch list, mutated in place.
+ * @param file - the patch file's path, whose directory anchors the names.
+ * @returns the same list.
+ */
+export function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
+  const base = dirname(resolve(file))
+  const visit = (entry: EntryOptions): void => {
+    if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) {
+      entry.name = pathToFileURL(resolve(base, entry.name)).href
+    }
+    if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)
+  }
+  for (const patch of patches) patch.insert?.forEach(visit)
+  return patches
+}
+
+/**
+ * Parse one loader patch list: a top-level YAML array of `PatchOptions`
+ * (id-targeted config overrides and `insert` lists, `!!js` expressions
+ * allowed). Every invalid field or value throws, because a patch file that
+ * cannot be applied at all is a misconfiguration; a single patch whose target
+ * row is absent stays a per-entry Loader warning, so one overlay shared
+ * across surfaces does not have to match every tree.
+ * @param binName - the diagnostic prefix on the thrown error.
+ * @param file - the source path, quoted in errors and anchoring relative names.
+ * @param content - the file's text.
+ * @param label - what to call this list in errors (`patches`, `overlay`).
+ * @returns the parsed patch list.
+ */
+export function parsePatchList(
+  binName: string, file: string, content: string, label: string,
+): PatchOptions[] {
+  let parsed: unknown
+  try {
+    parsed = yaml.load(content, { schema: entryListSchema })
+  } catch (error) {
+    throw new Error(`${binName}: failed to parse ${label} ${file}: ${String(error)}`)
+  }
+  // An empty file is an empty layer: js-yaml reads it as undefined.
+  if (parsed === undefined || parsed === null) return []
+  if (!Array.isArray(parsed)) {
+    throw new Error(`${binName}: ${label} ${file} must be a top-level YAML array of loader patch entries`)
+  }
+  parsed.forEach((entry, index) => {
+    if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
+      throw new Error(`${binName}: ${label} entry ${String(index + 1)} in ${file} must be a mapping (a loader patch entry)`)
+    }
+  })
+  return anchorInsertedPluginNames(parsed as PatchOptions[], file)
+}
+
+/**
+ * Read and parse one patch-list file.
+ * @param binName - the diagnostic prefix on the thrown error.
+ * @param file - absolute path of the file.
+ * @param label - what to call this list in errors (`patches`, `overlay`).
+ * @returns the parsed list, or undefined when the file does not exist.
+ * @throws when the file exists but cannot be read or parsed.
+ */
+export async function readPatchListFile(binName: string, file: string, label: string): Promise<PatchOptions[] | undefined> {
+  let content: string
+  try {
+    content = await readFile(file, 'utf8')
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
+    throw new Error(`${binName}: failed to read ${label} ${file}: ${String(error)}`)
+  }
+  return parsePatchList(binName, file, content, label)
+}
+
+/** One inserted row as the writer addresses it: the Loader's row options. */
+export type PatchRow = EntryOptions
+
+/**
+ * One patch-list file held as a comment-preserving document.
+ *
+ * Edits address rows by id the way the Loader does: an id-targeted patch is
+ * the top-level item whose `id` matches and that carries no `insert`; an
+ * inserted row is found inside any `insert` list, recursing into inserted
+ * groups. Values written are plain data; a `!!js` scalar the author wrote on
+ * another key is left exactly as it stands.
+ */
+export class PatchDocument {
+  private readonly document: Document
+  private readonly root: YAMLSeq
+  private edited = false
+
+  private constructor(document: Document, root: YAMLSeq) {
+    this.document = document
+    this.root = root
+  }
+
+  /** Whether any edit changed the document since it was parsed. */
+  get dirty(): boolean {
+    return this.edited
+  }
+
+  /**
+   * Remove one item from a sequence, keeping the comment block written above
+   * it: the comment moves to the following item, or becomes the document's
+   * trailing comment when the removed item was the last one. Without this a
+   * user's note above a row would vanish with the row it happened to precede.
+   */
+  private splice(seq: YAMLSeq, item: YAMLMap): void {
+    const index = seq.items.indexOf(item)
+    seq.items.splice(index, 1)
+    this.edited = true
+    const comment = item.commentBefore
+    if (comment === null || comment === undefined) return
+    const next = seq.items[index]
+    if (isMap(next)) {
+      next.commentBefore = next.commentBefore === null || next.commentBefore === undefined
+        ? comment
+        : `${comment}\n${next.commentBefore}`
+      return
+    }
+    // Prepended: items are removed from the end one at a time, so the file's
+    // own order survives in the trailing comment.
+    this.document.comment = this.document.comment === null
+      ? comment
+      : `${comment}\n${this.document.comment}`
+  }
+
+  /**
+   * Parse a patch-list file's text.
+   * @param text - the file's text; empty or comment-only text is an empty list.
+   * @param file - the path, quoted in errors.
+   * @returns the document.
+   * @throws when the text is not valid YAML or its root is not a sequence.
+   */
+  static parse(text: string, file: string): PatchDocument {
+    // Typed as the general document: a comment-only file parses with no
+    // contents, and the empty sequence assigned then is not a parsed node.
+    const document: Document = parseDocument(text, { prettyErrors: true })
+    // First line only: the parser appends a multi-line code frame.
+    const problems = document.errors.map(error => error.message.replace(/\n[\s\S]*$/, ''))
+    if (problems.length > 0) {
+      throw new Error(`patch-file: ${file} is not valid YAML: ${problems.join('; ')}`)
+    }
+    let root: unknown = document.contents
+    if (root === null) {
+      root = new YAMLSeq()
+      document.contents = root as YAMLSeq
+    }
+    if (!isSeq(root)) {
+      throw new Error(`patch-file: ${file} must be a top-level YAML array of loader patch entries`)
+    }
+    for (const [index, item] of root.items.entries()) {
+      if (!isMap(item)) {
+        throw new Error(`patch-file: entry ${String(index + 1)} in ${file} must be a mapping (a loader patch entry)`)
+      }
+    }
+    return new PatchDocument(document, root)
+  }
+
+  /** The top-level patch items, each a mapping. */
+  private get items(): YAMLMap[] {
+    return this.root.items as YAMLMap[]
+  }
+
+  /** The id-targeted patch for `id`: the item with that id and no `insert`. */
+  private targeted(id: string): YAMLMap | undefined {
+    return this.items.find(item => !item.has('insert') && item.get('id') === id)
+  }
+
+  /**
+   * Whether an id-targeted patch for `id` exists.
+   * @param id - the row id the patch targets.
+   * @returns true when the file carries one.
+   */
+  hasRow(id: string): boolean {
+    return this.targeted(id) !== undefined
+  }
+
+  /**
+   * One key of the id-targeted patch for `id`, as plain data.
+   * @param id - the row id the patch targets.
+   * @param key - the patch key.
+   * @returns the value, or undefined when the patch or the key is absent. A
+   * `!!js` scalar reads as its source text.
+   */
+  rowField(id: string, key: string): unknown {
+    const patch = this.targeted(id)
+    if (patch === undefined || !patch.has(key)) return undefined
+    return patch.get(key)
+  }
+
+  /**
+   * Set one key on the id-targeted patch for `id`, creating the patch when the
+   * file has none. Other keys of the patch and every comment stay.
+   * @param id - the row id the patch targets.
+   * @param key - the patch key (`disabled`, `config`, ...); never `id`.
+   * @param value - plain data to write.
+   */
+  setRowField(id: string, key: string, value: unknown): void {
+    if (key === 'id' || key === 'insert') {
+      throw new Error(`patch-file: ${key} is not a settable key of an id-targeted patch`)
+    }
+    let patch = this.targeted(id)
+    if (patch === undefined) {
+      patch = new YAMLMap()
+      patch.set('id', id)
+      this.root.add(patch)
+    }
+    patch.set(key, this.document.createNode(value))
+    this.edited = true
+  }
+
+  /**
+   * Delete one key from the id-targeted patch for `id`; a patch left with
+   * only its id is removed whole, so an override fully reverted leaves no
+   * trace.
+   * @param id - the row id the patch targets.
+   * @param key - the patch key to delete.
+   * @returns true when the key was present.
+   */
+  deleteRowField(id: string, key: string): boolean {
+    const patch = this.targeted(id)
+    if (patch === undefined || !patch.has(key)) return false
+    patch.delete(key)
+    this.edited = true
+    const remaining = patch.items.map(pair => String(pair.key))
+    if (remaining.length === 1 && remaining[0] === 'id') this.splice(this.root, patch)
+    return true
+  }
+
+  /** Every `insert` list in the file, with the group id each targets. */
+  private insertLists(): { patch: YAMLMap; into: string | undefined; rows: YAMLSeq }[] {
+    const found: { patch: YAMLMap; into: string | undefined; rows: YAMLSeq }[] = []
+    for (const patch of this.items) {
+      if (!patch.has('insert')) continue
+      const rows = patch.get('insert', true)
+      if (!isSeq(rows)) {
+        throw new Error('patch-file: an insert patch must hold a list of rows')
+      }
+      const into = patch.get('id')
+      found.push({ patch, into: typeof into === 'string' ? into : undefined, rows })
+    }
+    return found
+  }
+
+  /**
+   * Append one row to the `insert` list targeting `into` (the root list when
+   * omitted), creating the patch when the file has none for that target.
+   * @param row - the row options to insert; plain data.
+   * @param into - the id of the group the row goes into, or undefined for the root.
+   * @throws when a row with the same id is already inserted by this file.
+   */
+  appendInsert(row: PatchRow, into?: string): void {
+    if (typeof row.id === 'string' && this.insertedRow(row.id) !== undefined) {
+      throw new Error(`patch-file: row ${JSON.stringify(row.id)} is already inserted by this file`)
+    }
+    this.edited = true
+    const list = this.insertLists().find(candidate => candidate.into === into)
+    if (list !== undefined) {
+      list.rows.add(this.document.createNode(row))
+      return
+    }
+    this.root.add(this.document.createNode({
+      ...into === undefined ? {} : { id: into },
+      insert: [row],
+    }))
+  }
+
+  /** Walk one row list for the row with `id`, recursing into inserted groups. */
+  private static findInserted(rows: YAMLSeq, id: string): { parent: YAMLSeq; row: YAMLMap } | undefined {
+    for (const item of rows.items) {
+      if (!isMap(item)) continue
+      if (item.get('id') === id) return { parent: rows, row: item }
+      if (item.get('group') !== true) continue
+      const nested = item.get('config', true)
+      if (!isSeq(nested)) continue
+      const found = PatchDocument.findInserted(nested, id)
+      if (found !== undefined) return found
+    }
+    return undefined
+  }
+
+  /**
+   * One row this file inserts, as plain data.
+   * @param id - the inserted row's id.
+   * @returns the row options, or undefined when this file inserts no such row.
+   */
+  insertedRow(id: string): PatchRow | undefined {
+    for (const list of this.insertLists()) {
+      const found = PatchDocument.findInserted(list.rows, id)
+      if (found !== undefined) return found.row.toJSON() as PatchRow
+    }
+    return undefined
+  }
+
+  /**
+   * Remove one row this file inserts; an `insert` patch left empty is removed
+   * whole.
+   * @param id - the inserted row's id.
+   * @returns true when the row was present.
+   */
+  removeInsert(id: string): boolean {
+    for (const list of this.insertLists()) {
+      const found = PatchDocument.findInserted(list.rows, id)
+      if (found === undefined) continue
+      this.splice(found.parent, found.row)
+      if (list.rows.items.length === 0) this.splice(this.root, list.patch)
+      return true
+    }
+    return false
+  }
+
+  /**
+   * Render the document as text, comments and `!!js` scalars intact.
+   * @returns the YAML text, ending in one newline; an empty list renders as `[]`.
+   */
+  toString(): string {
+    return this.document.toString()
+  }
+}
+
+/** Filesystem options for {@link mutatePatchFile}. */
+export interface MutatePatchFileOptions {
+  /** The diagnostic prefix on thrown errors. */
+  binName: string
+  /** Permission bits for the replaced file; required so the decision stays at the call site. */
+  mode: number
+  /** Permission bits for parent directories this call creates. */
+  dirMode?: number
+  /** Maximum time to wait for the writer lock, in milliseconds. */
+  waitMs?: number
+}
+
+/**
+ * Apply one edit to a patch-list file: take the cross-process writer lock,
+ * read the file (absent reads as empty), let `mutate` edit the document,
+ * replace the file atomically, and parse the written text back through
+ * {@link parsePatchList} so the caller learns what every reader will now
+ * load. An edit that changes nothing writes nothing.
+ * @param file - absolute path of the patch-list file.
+ * @param mutate - the edit, applied to the parsed document.
+ * @param options - diagnostics, permissions, and lock wait.
+ * @returns the patch list as re-read from the file after the write.
+ * @throws when the file cannot be parsed, the lock cannot be taken, or the
+ * written file does not parse — the last is reported after the write landed.
+ */
+export async function mutatePatchFile(
+  file: string,
+  mutate: (document: PatchDocument) => void,
+  options: MutatePatchFileOptions,
+): Promise<PatchOptions[]> {
+  await mkdir(dirname(file), { recursive: true, ...options.dirMode === undefined ? {} : { mode: options.dirMode } })
+  return await withFileLock(file, async () => {
+    let before: string
+    try {
+      before = await readFile(file, 'utf8')
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
+        throw new Error(`${options.binName}: failed to read patches ${file}: ${String(error)}`)
+      }
+      before = ''
+    }
+    const document = PatchDocument.parse(before, file)
+    mutate(document)
+    if (!document.dirty) return parsePatchList(options.binName, file, before, 'patches')
+    const after = document.toString()
+    await writeFileAtomic(file, after, { mode: options.mode, ...options.dirMode === undefined ? {} : { dirMode: options.dirMode } })
+    return parsePatchList(options.binName, file, after, 'patches')
+  }, options.waitMs === undefined ? undefined : { waitMs: options.waitMs })
+}

+ 251 - 0
packages/util/patch-file/tests/patch-file.spec.ts

@@ -0,0 +1,251 @@
+import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
+import { existsSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { isJsExpr } from '@deepseek-ai/cordis-plugin-loader'
+import {
+  anchorInsertedPluginNames,
+  mutatePatchFile,
+  parsePatchList,
+  PatchDocument,
+  readPatchListFile,
+} from '../src/index.ts'
+
+const SAMPLE = `# top comment
+- id: message-feedback # trailing
+  disabled: true
+
+# insert block
+- insert:
+    - id: tool-foo
+      name: dsh-tool-foo
+      config: {}
+    - id: group-a
+      name: cordis:group
+      group: true
+      config:
+        - id: nested-row
+          name: dsh-nested
+- id: tool-bash
+  disabled: !!js process.platform === 'win32'
+  config:
+    a: 1 # keep me
+`
+
+async function tempDir(): Promise<string> {
+  return await mkdtemp(join(tmpdir(), 'dsh-patch-file-'))
+}
+
+describe('PatchDocument', () => {
+  it('reads an empty or comment-only file as an empty list', () => {
+    expect(PatchDocument.parse('', 'x.yml').toString()).toBe('[]\n')
+    expect(PatchDocument.parse('# only a comment\n', 'x.yml').toString()).toContain('[]')
+  })
+
+  it('refuses a root that is not a sequence, a non-map item, and invalid YAML', () => {
+    expect(() => PatchDocument.parse('a: 1\n', 'x.yml')).toThrow('must be a top-level YAML array')
+    expect(() => PatchDocument.parse('- just-a-string\n', 'x.yml')).toThrow('entry 1 in x.yml must be a mapping')
+    expect(() => PatchDocument.parse('- id: [\n', 'x.yml')).toThrow('x.yml is not valid YAML')
+  })
+
+  it('reads and sets id-targeted patch keys while keeping comments and !!js scalars', () => {
+    const document = PatchDocument.parse(SAMPLE, 'x.yml')
+    expect(document.hasRow('message-feedback')).toBe(true)
+    expect(document.hasRow('tool-foo')).toBe(false)
+    expect(document.rowField('message-feedback', 'disabled')).toBe(true)
+    expect(document.rowField('message-feedback', 'config')).toBeUndefined()
+    expect(document.rowField('absent', 'disabled')).toBeUndefined()
+    // A `!!js` scalar reads as its source text; the writer never evaluates it.
+    expect(document.rowField('tool-bash', 'disabled')).toBe("process.platform === 'win32'")
+
+    document.setRowField('tool-bash', 'config', { a: 1, b: 'x' })
+    document.setRowField('new-row', 'disabled', true)
+    const text = document.toString()
+    expect(text).toContain('# top comment')
+    expect(text).toContain('- id: message-feedback # trailing')
+    expect(text).toContain("disabled: !!js process.platform === 'win32'")
+    expect(text).toContain('- id: new-row\n  disabled: true')
+    expect(text).toMatch(/config:\n\s+a: 1\n\s+b: x/)
+    // The rewritten text is what the boot parser loads.
+    const rows = parsePatchList('spec', 'x.yml', text, 'patches')
+    expect(rows.find(patch => patch.id === 'tool-bash')?.config).toEqual({ a: 1, b: 'x' })
+    expect(isJsExpr(rows.find(patch => patch.id === 'tool-bash')?.disabled)).toBe(true)
+  })
+
+  it('refuses to set id or insert through the key setter', () => {
+    const document = PatchDocument.parse(SAMPLE, 'x.yml')
+    expect(() => { document.setRowField('tool-bash', 'id', 'other') }).toThrow('id is not a settable key')
+    expect(() => { document.setRowField('tool-bash', 'insert', []) }).toThrow('insert is not a settable key')
+  })
+
+  it('deletes keys and removes a patch reduced to its id', () => {
+    const document = PatchDocument.parse(SAMPLE, 'x.yml')
+    expect(document.deleteRowField('absent', 'disabled')).toBe(false)
+    expect(document.deleteRowField('tool-bash', 'absent')).toBe(false)
+    expect(document.deleteRowField('tool-bash', 'config')).toBe(true)
+    expect(document.hasRow('tool-bash')).toBe(true)
+    expect(document.deleteRowField('message-feedback', 'disabled')).toBe(true)
+    expect(document.hasRow('message-feedback')).toBe(false)
+    const text = document.toString()
+    expect(text).not.toContain('message-feedback')
+    expect(text).toContain('# top comment')
+    expect(text).not.toContain('keep me')
+  })
+
+  it('appends inserted rows to the root list, to a group, and creates the patch when absent', () => {
+    const document = PatchDocument.parse(SAMPLE, 'x.yml')
+    document.appendInsert({ id: 'tool-bar', name: 'dsh-tool-bar' })
+    document.appendInsert({ id: 'in-group', name: 'dsh-in-group', config: { k: 1 } }, 'agents')
+    document.appendInsert({ id: 'in-group-2', name: 'dsh-in-group-2' }, 'agents')
+    const rows = parsePatchList('spec', 'x.yml', document.toString(), 'patches')
+    const root = rows.find(patch => patch.insert !== undefined && patch.id === undefined)
+    expect(root?.insert?.map(row => row.id)).toEqual(['tool-foo', 'group-a', 'tool-bar'])
+    const grouped = rows.find(patch => patch.id === 'agents')
+    expect(grouped?.insert?.map(row => row.id)).toEqual(['in-group', 'in-group-2'])
+    expect(grouped?.insert?.[0]?.config).toEqual({ k: 1 })
+  })
+
+  it('refuses to insert a row id the file already inserts', () => {
+    const document = PatchDocument.parse(SAMPLE, 'x.yml')
+    expect(() => { document.appendInsert({ id: 'nested-row', name: 'x' }) }).toThrow('"nested-row" is already inserted')
+  })
+
+  it('finds and removes inserted rows, including inside inserted groups', () => {
+    const document = PatchDocument.parse(SAMPLE, 'x.yml')
+    expect(document.insertedRow('tool-foo')).toEqual({ id: 'tool-foo', name: 'dsh-tool-foo', config: {} })
+    expect(document.insertedRow('nested-row')).toEqual({ id: 'nested-row', name: 'dsh-nested' })
+    expect(document.insertedRow('absent')).toBeUndefined()
+    expect(document.removeInsert('absent')).toBe(false)
+    expect(document.removeInsert('nested-row')).toBe(true)
+    expect(document.insertedRow('nested-row')).toBeUndefined()
+    expect(document.removeInsert('tool-foo')).toBe(true)
+    expect(document.removeInsert('group-a')).toBe(true)
+    // The insert patch emptied by the removals is gone with its comment block.
+    expect(document.toString()).not.toContain('insert:')
+    expect(document.toString()).toContain('- id: tool-bash')
+  })
+
+  it('skips non-map items and non-group rows while walking insert lists', () => {
+    const document = PatchDocument.parse(
+      '- insert:\n    - plain\n    - id: leaf\n      name: x\n      group: true\n      config: notalist\n', 'x.yml',
+    )
+    expect(document.insertedRow('leaf')).toEqual({ id: 'leaf', name: 'x', group: true, config: 'notalist' })
+    expect(document.insertedRow('missing')).toBeUndefined()
+  })
+
+  it('refuses an insert patch whose list is not a sequence', () => {
+    const document = PatchDocument.parse('- insert: 1\n', 'x.yml')
+    expect(() => document.insertedRow('x')).toThrow('insert patch must hold a list of rows')
+  })
+})
+
+describe('parsePatchList', () => {
+  it('reads empty text as an empty layer and anchors relative names to the file', () => {
+    expect(parsePatchList('spec', '/tmp/x.yml', '', 'patches')).toEqual([])
+    const rows = parsePatchList('spec', '/tmp/dir/x.yml', [
+      '- insert:',
+      '    - id: rel',
+      '      name: ./plugin.js',
+      '    - id: bare',
+      '      name: dsh-bare',
+      '    - id: g',
+      '      name: cordis:group',
+      '      group: true',
+      '      config:',
+      '        - id: up',
+      '          name: ../up.js',
+      '- id: other',
+      '  disabled: true',
+    ].join('\n'), 'overlay')
+    const insert = rows[0]?.insert ?? []
+    expect(insert[0]?.name).toBe(pathToFileURL(resolve('/tmp/dir', './plugin.js')).href)
+    expect(insert[1]?.name).toBe('dsh-bare')
+    expect((insert[2]?.config as { name: string }[])[0]?.name).toBe(pathToFileURL(resolve('/tmp/dir', '../up.js')).href)
+    expect(rows[1]).toEqual({ id: 'other', disabled: true })
+  })
+
+  it('fails loud on invalid YAML, a non-array root, and a non-map entry', () => {
+    expect(() => parsePatchList('spec', 'x.yml', '- id: [\n', 'overlay')).toThrow('spec: failed to parse overlay x.yml')
+    expect(() => parsePatchList('spec', 'x.yml', 'a: 1\n', 'patches')).toThrow('patches x.yml must be a top-level YAML array')
+    expect(() => parsePatchList('spec', 'x.yml', '- 1\n', 'patches')).toThrow('patches entry 1 in x.yml must be a mapping')
+  })
+
+  it('anchors names in place and returns the same list', () => {
+    const patches = [{ insert: [{ id: 'a', name: './a.js' }] }]
+    expect(anchorInsertedPluginNames(patches, '/tmp/dir/x.yml')).toBe(patches)
+    expect(patches[0]?.insert[0]?.name).toBe(pathToFileURL('/tmp/dir/a.js').href)
+  })
+})
+
+describe('readPatchListFile', () => {
+  it('reads undefined for an absent file, the list for a present one, and throws for an unreadable one', async () => {
+    const dir = await tempDir()
+    expect(await readPatchListFile('spec', join(dir, 'absent.yml'), 'patches')).toBeUndefined()
+    await writeFile(join(dir, 'x.yml'), '- id: a\n  disabled: true\n')
+    expect(await readPatchListFile('spec', join(dir, 'x.yml'), 'patches')).toEqual([{ id: 'a', disabled: true }])
+    await expect(readPatchListFile('spec', dir, 'patches')).rejects.toThrow('spec: failed to read patches')
+  })
+})
+
+describe('mutatePatchFile', () => {
+  it('creates the file under the lock, replaces it atomically, and reads it back', async () => {
+    const dir = await tempDir()
+    const file = join(dir, 'nested', 'cordis.patch.yml')
+    const rows = await mutatePatchFile(file, (document) => {
+      document.setRowField('tool-web', 'disabled', true)
+      document.appendInsert({ id: 'tool-foo', name: 'dsh-tool-foo' })
+    }, { binName: 'spec', mode: 0o600, dirMode: 0o700 })
+    expect(rows).toEqual([{ id: 'tool-web', disabled: true }, { insert: [{ id: 'tool-foo', name: 'dsh-tool-foo' }] }])
+    expect(await readFile(file, 'utf8')).toBe('- id: tool-web\n  disabled: true\n- insert:\n    - id: tool-foo\n      name: dsh-tool-foo\n')
+    expect(existsSync(`${file}.lock`)).toBe(false)
+    if (process.platform !== 'win32') expect((await stat(file)).mode & 0o777).toBe(0o600)
+
+    // A second edit keeps the author's comment and reverts only its own key.
+    await writeFile(file, `# mine\n${await readFile(file, 'utf8')}`)
+    const reverted = await mutatePatchFile(file, (document) => {
+      document.deleteRowField('tool-web', 'disabled')
+    }, { binName: 'spec', mode: 0o600, waitMs: 500 })
+    expect(reverted).toEqual([{ insert: [{ id: 'tool-foo', name: 'dsh-tool-foo' }] }])
+    expect(await readFile(file, 'utf8')).toContain('# mine')
+  })
+
+  it('writes nothing when the edit changes nothing', async () => {
+    const dir = await tempDir()
+    const file = join(dir, 'cordis.patch.yml')
+    expect(await mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 })).toEqual([])
+    expect(existsSync(file)).toBe(false)
+  })
+
+  it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('reports a file that exists but cannot be read', async () => {
+    const dir = await tempDir()
+    const file = join(dir, 'cordis.patch.yml')
+    await writeFile(file, '[]\n', { mode: 0o000 })
+    await expect(mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 }))
+      .rejects.toThrow('spec: failed to read patches')
+    expect(existsSync(`${file}.lock`)).toBe(false)
+  })
+
+  it('keeps the comment above a removed row', async () => {
+    const dir = await tempDir()
+    const file = join(dir, 'cordis.patch.yml')
+    await writeFile(file, '# first\n- id: a\n  disabled: true\n# second\n- id: b\n  disabled: true\n# third\n- id: c\n  disabled: true\n')
+    await mutatePatchFile(file, (document) => { document.deleteRowField('a', 'disabled') }, { binName: 'spec', mode: 0o600 })
+    expect(await readFile(file, 'utf8')).toBe('# first\n# second\n- id: b\n  disabled: true\n# third\n- id: c\n  disabled: true\n')
+    // Removing from the end twice keeps the file's own comment order.
+    await mutatePatchFile(file, (document) => {
+      document.deleteRowField('c', 'disabled')
+      document.deleteRowField('b', 'disabled')
+    }, { binName: 'spec', mode: 0o600 })
+    expect(await readFile(file, 'utf8')).toBe('[]\n\n# first\n# second\n# third\n')
+  })
+
+  it('reports a file that does not parse', async () => {
+    const dir = await tempDir()
+    const file = join(dir, 'cordis.patch.yml')
+    await writeFile(file, 'a: 1\n')
+    await expect(mutatePatchFile(file, () => {}, { binName: 'spec', mode: 0o600 }))
+      .rejects.toThrow('must be a top-level YAML array')
+  })
+})

+ 24 - 0
packages/util/patch-file/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/include"
+    },
+    {
+      "path": "../../../vendor/loader"
+    },
+    {
+      "path": "../atomic-write"
+    }
+  ]
+}

+ 96 - 0
pnpm-lock.yaml

@@ -295,6 +295,9 @@ importers:
       '@deepseek-ai/dsh-tool-ralph':
         specifier: workspace:^
         version: link:../../packages/workflow/tool-ralph
+      '@deepseek-ai/dsh-global-tool-mask':
+        specifier: workspace:^
+        version: link:../../packages/preset/global-tool-mask
       '@deepseek-ai/dsh-tool-skill':
         specifier: workspace:^
         version: link:../../packages/skill/tool-skill
@@ -723,6 +726,9 @@ importers:
       '@deepseek-ai/dsh-host-plugin-inventory':
         specifier: workspace:^
         version: link:../../host/plugin-inventory
+      '@deepseek-ai/dsh-host-plugin-manager':
+        specifier: workspace:^
+        version: link:../../host/plugin-manager
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
@@ -961,6 +967,9 @@ importers:
       '@deepseek-ai/dsh-atomic-write':
         specifier: workspace:^
         version: link:../../util/atomic-write
+      '@deepseek-ai/dsh-patch-file':
+        specifier: workspace:^
+        version: link:../../util/patch-file
       js-yaml:
         specifier: ^4.2.0
         version: 4.2.0
@@ -1624,6 +1633,9 @@ importers:
       '@deepseek-ai/dsh-host-plugin-inventory':
         specifier: workspace:^
         version: link:../../host/plugin-inventory
+      '@deepseek-ai/dsh-host-plugin-manager':
+        specifier: workspace:^
+        version: link:../../host/plugin-manager
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../host/webserver
@@ -5802,6 +5814,43 @@ importers:
         specifier: workspace:^
         version: link:../../typert/protocol
 
+  packages/host/plugin-manager:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-group':
+        specifier: workspace:^
+        version: link:../../../vendor/group
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@deepseek-ai/dsh-agent-presets':
+        specifier: workspace:^
+        version: link:../../preset/agent-presets
+      '@deepseek-ai/dsh-app-boot':
+        specifier: workspace:^
+        version: link:../../boot/app-boot
+      '@deepseek-ai/dsh-patch-file':
+        specifier: workspace:^
+        version: link:../../util/patch-file
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
+
   packages/host/webserver:
     dependencies:
       '@deepseek-ai/schemastery':
@@ -6583,6 +6632,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-patch-file':
+        specifier: workspace:^
+        version: link:../../util/patch-file
       '@deepseek-ai/dsh-scope':
         specifier: workspace:^
         version: link:../../core/scope
@@ -6624,6 +6676,25 @@ importers:
         specifier: workspace:^
         version: link:../../core/system-prompt
 
+  packages/preset/global-tool-mask:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+
   packages/runtime-diagnostics/invariants:
     dependencies:
       '@deepseek-ai/schemastery':
@@ -9349,6 +9420,31 @@ importers:
         specifier: workspace:^
         version: link:../../../vendor/cordis
 
+  packages/util/patch-file:
+    dependencies:
+      '@deepseek-ai/dsh-atomic-write':
+        specifier: workspace:^
+        version: link:../atomic-write
+      js-yaml:
+        specifier: ^4.2.0
+        version: 4.3.1
+      yaml:
+        specifier: ^2.9.0
+        version: 2.9.0
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/cordis-plugin-include':
+        specifier: workspace:^
+        version: link:../../../vendor/include
+      '@deepseek-ai/cordis-plugin-loader':
+        specifier: workspace:^
+        version: link:../../../vendor/loader
+      '@types/js-yaml':
+        specifier: ^4.0.9
+        version: 4.0.9
+
   packages/util/time:
     devDependencies:
       '@deepseek-ai/cordis':

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

@@ -58,6 +58,7 @@ export const SERVICE_PAGE: Record<string, string> = {
   agentDefaultModel: 'core.md',
   agentPresets: 'core.md',
   profileRuntime: 'core.md',
+  pluginManager: 'core.md',
   agents: 'core.md',
   approval: 'approval.md',
   attachments: 'attachment.md',
@@ -188,6 +189,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
   'agent': 'core.md',
   'agent-loop': 'core.md',
   'agent-preset': 'core.md',
+  'plugins': 'core.md',
   'api-session': 'session.md',
   'approval': 'approval.md',
   'commands': 'commands.md',
@@ -668,6 +670,14 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   AgentHandle: 'agent ownership handle is owned by packages/core/agent/README.md',
   AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md',
   RowOrigin: 'row provenance record of the profile runtime is owned by packages/boot/app-boot/README.md',
+  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',
+  PluginPackageView: 'plugin manager package views are owned by packages/host/plugin-manager/README.md',
+  PluginInstallResult: 'plugin manager install results are owned by packages/host/plugin-manager/README.md',
+  PluginEnableResult: 'plugin manager enable results are owned by packages/host/plugin-manager/README.md',
+  PluginRowTarget: 'plugin manager row targets are owned by packages/host/plugin-manager/README.md',
+  PluginRowAddition: 'plugin manager row additions are owned by packages/host/plugin-manager/README.md',
+  PluginDependents: 'plugin manager dependents are owned by packages/host/plugin-manager/README.md',
   AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md',
   AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md',
   AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md',

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

@@ -377,12 +377,19 @@ const SERVICE_ROLES: ServiceRole[] = [
     mode: 'core',
     note: 'Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm.',
   },
+  {
+    key: 'pluginManager',
+    pkg: 'host-plugin-manager',
+    title: 'Plugin management over the booted profile',
+    mode: 'core',
+    note: 'Installs, enables, disables, retries, and removes bundles through pnpm and the profile runtime, edits rows in the profile\'s or a preset\'s user layer, and folds manifest, probe, and tree facts into one view per package for the plugins Remote.',
+  },
   {
     key: 'profileRuntime',
     pkg: 'app-boot',
     title: 'Booted profile facts and recomposition',
     mode: 'core',
-    consumers: ['host-plugin-inventory'],
+    consumers: ['host-plugin-inventory', 'host-plugin-manager'],
     note: 'Provided by the profile launcher after boot: the composed bundle layers with their trust and stage, each row\'s inserting layer, the rows user patch files disable, and the one recomposition path user patch reloads and runtime bundle changes share.',
   },
   {

+ 3 - 0
scripts/verify-package-readme-model-experience.ts

@@ -55,8 +55,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/code-runtime/code-runtime-worker-thread': { kind: 'indirect', reason: 'The worker backend delegates model rendering to PTC mode in dsh-tools.' },
   'packages/experimental/code-runtime-python': { kind: 'indirect', reason: 'Explicit source-checkout compositions delegate model rendering to PTC mode in dsh-tools.' },
   'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
+  'packages/preset/global-tool-mask': { kind: 'indirect', reason: 'The row only calls tools.restrict(); the registry owns the tool list the model sees.' },
   'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' },
   'packages/util/deque': { kind: 'none', reason: 'In-process collection primitive; registers nothing model-facing.' },
+  'packages/util/patch-file': { kind: 'none', reason: 'Composition patch-file parser and writer; the rows those files name own every model-facing registration.' },
   'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
   'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
   'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' },
@@ -126,6 +128,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/webhook/webhook-github': { kind: 'indirect', reason: 'The adapter delegates model-visible text to matching rules and dsh-webhook.' },
   'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers nothing model-facing.' },
   'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' },
+  'packages/host/plugin-manager': { kind: 'none', reason: 'Host-side plugin management over the profile; the rows it composes own every model-facing registration.' },
   'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' },
   'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
   'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },

+ 4 - 0
tsconfig.base.json

@@ -148,6 +148,8 @@
       "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"],
       "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],
       "@deepseek-ai/dsh-host-plugin-inventory/types": ["./packages/host/plugin-inventory/src/types.ts"],
+      "@deepseek-ai/dsh-host-plugin-manager": ["./packages/host/plugin-manager/src"],
+      "@deepseek-ai/dsh-host-plugin-manager/types": ["./packages/host/plugin-manager/src/types.ts"],
       "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"],
       "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"],
       "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"],
@@ -276,6 +278,7 @@
       "@deepseek-ai/dsh-fs-local": ["./packages/fs/fs-local/src"],
       "@deepseek-ai/dsh-fs-observation-policy": ["./packages/fs/fs-observation-policy/src"],
       "@deepseek-ai/dsh-fs-sandbox": ["./packages/fs/fs-sandbox/src"],
+      "@deepseek-ai/dsh-global-tool-mask": ["./packages/preset/global-tool-mask/src"],
       "@deepseek-ai/dsh-goal": ["./packages/goal/goal/src"],
       "@deepseek-ai/dsh-goal/invariant": ["./packages/goal/goal/src/invariant.ts"],
       "@deepseek-ai/dsh-goal-round-driver": ["./packages/goal/goal-round-driver/src"],
@@ -306,6 +309,7 @@
       "@deepseek-ai/dsh-message-feedback": ["./packages/feedback/message-feedback/src"],
       "@deepseek-ai/dsh-native-command": ["./packages/util/native-command/src"],
       "@deepseek-ai/dsh-output-retention": ["./packages/util/output-retention/src"],
+      "@deepseek-ai/dsh-patch-file": ["./packages/util/patch-file/src"],
       "@deepseek-ai/dsh-permission-presets": ["./packages/interaction/permission-presets/src"],
       "@deepseek-ai/dsh-permission-presets/invariant": ["./packages/interaction/permission-presets/src/invariant.ts"],
       "@deepseek-ai/dsh-persona": ["./packages/preset/persona/src"],

+ 3 - 0
tsconfig.host.json

@@ -141,6 +141,7 @@
     { "path": "./packages/util/workspace-path" },
     { "path": "./packages/util/output-retention" },
     { "path": "./packages/util/atomic-write" },
+    { "path": "./packages/util/patch-file" },
     { "path": "./packages/attachment/attachment" },
     { "path": "./packages/attachment/attachment-local" },
     { "path": "./packages/llm/llm" },
@@ -314,6 +315,7 @@
     { "path": "./packages/plan/plan-mode" },
     { "path": "./packages/preset/agent-presets" },
     { "path": "./packages/preset/persona" },
+    { "path": "./packages/preset/global-tool-mask" },
     { "path": "./packages/guard/repeat-tool-reminder" },
     { "path": "./packages/extensions/cordis-host-runner" },
     { "path": "./packages/extensions/tool-cordis" },
@@ -327,6 +329,7 @@
     { "path": "./packages/host/directory-picker-native" },
     { "path": "./packages/host/frontend-static" },
     { "path": "./packages/host/plugin-inventory" },
+    { "path": "./packages/host/plugin-manager" },
     { "path": "./packages/llm/plugin-package-inventory-deepseek" },
     { "path": "./packages/host/webserver" },
     { "path": "./packages/sdk/client" },