Kaynağa Gözat

Adapt current consumers to nontransactional Cordis Loader

Move exact user-patch watching into app boot and audit activation explicitly.
Wait for CLI fallback HMR and preset subtrees. Join the first fiber-disposal
result in the directory chooser and browser package runner. Keep async
update errors observable without restoring generic Loader rollback.

Restore the current workspace-link and Schemastery manifests and their gate.
Preserve unconditional patch-input cloning, awaited Include initialization,
and durable teardown writes. Update diagnostics, snapshots, bilingual docs,
and the exhaustive vendor divergence ledger.

Validation: focused Loader, watcher, preset, inventory, and cleanup tests;
100% watcher coverage; 32 Web preset cases; real CLI webhook model session;
SDK smokes; 13 headless expected cases; 3 recorded-session replays; build,
typecheck, lint contracts, and all 34 doc-sync gates.

A full Node 24/Python 3.12 Vitest run had 21806 passes and two failures:
a baseline-reproduced experimental CSS import failure and a Python probe
timeout that passed alone. The default Node 26/Python 3.9 run additionally
exposed unsupported Python and FileHandle-GC host failures.
turtle1999 2 hafta önce
ebeveyn
işleme
2abb542a22
53 değiştirilmiş dosya ile 787 ekleme ve 187 silme
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml
  2. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
  3. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md
  4. 6 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.i18n.yaml
  5. 33 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md
  6. 33 0
      .agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md
  7. 2 2
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml
  8. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
  9. 7 7
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md
  10. 1 0
      apps/cli/src/profile-boot.ts
  11. 0 4
      apps/cli/tests/built-bin.e2e.ts
  12. 2 2
      apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt
  13. 1 1
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  14. 1 0
      package.json
  15. 2 2
      packages/boot/app-boot/README.i18n.yaml
  16. 2 2
      packages/boot/app-boot/README.md
  17. 2 2
      packages/boot/app-boot/README.zh.md
  18. 2 2
      packages/boot/app-boot/package.json
  19. 45 23
      packages/boot/app-boot/src/index.ts
  20. 89 0
      packages/boot/app-boot/src/watch-config.ts
  21. 6 6
      packages/boot/app-boot/tests/app-boot.spec.ts
  22. 25 13
      packages/boot/app-boot/tests/config-reload.spec.ts
  23. 30 29
      packages/boot/app-boot/tests/user-patches.spec.ts
  24. 286 0
      packages/boot/app-boot/tests/watch-config.spec.ts
  25. 2 2
      packages/extensions/cordis-client-runner/README.i18n.yaml
  26. 1 1
      packages/extensions/cordis-client-runner/README.md
  27. 1 1
      packages/extensions/cordis-client-runner/README.zh.md
  28. 3 1
      packages/extensions/cordis-client-runner/src/client/runtime.ts
  29. 25 5
      packages/extensions/cordis-client-runner/tests/runner.client.spec.ts
  30. 7 3
      packages/host/directory-picker-auto/src/index.ts
  31. 19 3
      packages/host/directory-picker-auto/tests/loader-composition.spec.ts
  32. 1 1
      packages/host/plugin-inventory/tests/inventory.spec.ts
  33. 1 13
      packages/host/webserver/tests/webserver.spec.ts
  34. 1 0
      packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts
  35. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  36. 1 1
      packages/preset/agent-presets/README.md
  37. 1 1
      packages/preset/agent-presets/README.zh.md
  38. 12 8
      packages/preset/agent-presets/src/mount.ts
  39. 1 8
      packages/preset/agent-presets/tests/mount.spec.ts
  40. 1 0
      packages/todo/tool-todo/tests/loader-composition.spec.ts
  41. 3 3
      pnpm-lock.yaml
  42. 4 0
      pnpm-workspace.yaml
  43. 72 0
      scripts/verify-vendored-links.ts
  44. 9 9
      vendor/README.md
  45. 1 1
      vendor/cordis/src/events.ts
  46. 3 3
      vendor/cordis/src/fiber.ts
  47. 0 1
      vendor/hmr/src/index.ts
  48. 11 6
      vendor/include/src/index.ts
  49. 6 5
      vendor/loader/src/config/entry.ts
  50. 2 1
      vendor/loader/src/config/isolate.ts
  51. 1 1
      vendor/loader/src/config/utils.ts
  52. 1 1
      vendor/loader/src/index.ts
  53. 9 0
      vendor/schemastery/package.json

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md
-2026-07-31-fail-loud-releases-the-terminal.md: e5196121a850997b5eff045a26dd7c638776196c
-2026-07-31-fail-loud-releases-the-terminal.zh.md: 5cec9fe7d4ac75df1b5c88f2ea4cb0a665730719
+2026-07-31-fail-loud-releases-the-terminal.md: a6b8ccc5a0ad885a349b32707face7bfce9d7bed
+2026-07-31-fail-loud-releases-the-terminal.zh.md: 728fca424d2622af6222d51a3240eafd0e117986

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.md

@@ -15,7 +15,7 @@ $ 1;2;4cecho hello
 zsh: command not found: 4cecho
 ```
 
-The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The transactional Loader now settles config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
+The Loader mounts entries concurrently, so entry failure order is not startup order. `ui-tui` activates and calls pi-tui's `ProcessTerminal.start()`, which puts stdin in raw mode, enables bracketed paste, and writes the Kitty keyboard-protocol probe — a sequence ending in a Device Attributes query (`ESC [ c`). A sibling entry (here `llm-pi-ai`) then rejects on its own config. At the time, that rejection surfaced as an unhandled rejection, and `installFailLoud` wrote one stderr line and called `process.exit(1)` immediately. (The [Loader activation audit](../simplification/2026-09-09-nontransactional-loader.md) reports config-tree failures through `boot()`, which disposes the partial context itself; the release hook remains the guard for rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting.)
 
 Nothing disposed the tree, so `ProcessTerminal.stop()` never ran: raw mode, bracketed paste, and the keyboard protocol stayed set on the shell that outlived the process. The terminal's answer to the Device Attributes query (`1;2;4c`) arrived after exit and was read by the shell as typed input — the literal text above.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md

@@ -17,7 +17,7 @@ zsh: command not found: 4cecho
 
 Loader 并发挂载各个条目,因此条目失败的顺序并不等于启动顺序。`ui-tui` 会先激活并调用 pi-tui 的 `ProcessTerminal.start()`,它把 stdin 置为 raw 模式、启用 bracketed paste,并写出 Kitty 键盘协议探测序列——该序列以一个 Device Attributes 查询(`ESC [ c`)结尾。随后某个同级条目(这里是 `llm-pi-ai`)因自身配置而 rejection。
 
-在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。(事务化 Loader 现在让配置树失败经 `boot()` 结算,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
+在当时,该 rejection 以未处理 rejection 的形式浮现,而 `installFailLoud` 只写一行 stderr 就立即调用 `process.exit(1)`。([Loader 激活检查](../simplification/2026-09-09-nontransactional-loader.zh.md) 让配置树失败经 `boot()` 报告,由它自行 dispose(资源释放)部分构建的上下文;release 钩子仍然守护 `boot()` 看不到的 rejection——插件游离的异步工作在挂载期间或挂载之后失败。)没有任何环节 dispose 这棵树,因此 `ProcessTerminal.stop()` 从未执行:raw 模式、bracketed paste 和键盘协议都残留在比进程活得更久的 shell 上。终端对 Device Attributes 查询的回应(`1;2;4c`)在进程退出之后才到达,被 shell 当作用户输入读入——也就是上面那段字面文本。
 
 `/exit` 路径从不受影响,因为它会 dispose 整棵树,从而进入 TUI 自身的 `shutdown()`:先 `drainInput()`(吸收尚未返回的响应),再 `ui.stop()`。缺陷在于**启动失败**没有通往这同一套拆卸流程的路径。
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.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/simplification/2026-09-09-nontransactional-loader.md
+2026-09-09-nontransactional-loader.md: e3d43e6d56235e35d1a273900a935a7df19318be
+2026-09-09-nontransactional-loader.zh.md: 55abb03a1f72c9e88304033d76d3db80bb28f916

+ 33 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.md

@@ -0,0 +1,33 @@
+# Agent Note: Keep Loader mutations non-transactional
+
+Status: implemented
+
+English | [中文](2026-09-09-nontransactional-loader.zh.md)
+
+## Problem
+
+Transactional config reload preserves an old plugin generation after a failed edit, but requires Loader to own candidate imports, lifecycle settlement, rollback, option identity, and Include serialization. These changes make the vendored implementation substantially different from its pinned sources. Application startup and profile patch watching also depend on that settlement implicitly.
+
+## Decision
+
+Revert the five commits in [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932), resolving package moves and retaining independent later behavior. The reported merge commit belongs to the larger #936 dependency chain; reverting its first-parent diff would remove unrelated repository-plugin support. The [vendor ledger](../../../../vendor/README.md#local-modifications) records every retained source change against the unchanged pins.
+
+Loader changes entry options eagerly. EntryGroup starts siblings concurrently and logs application failures; EntryTree waits for outstanding work without rejecting failed fibers. Neither restores a previous plugin or configuration. Include retains parse validation and patch reapplication, but plugin failures can leave a partially applied tree.
+
+Application consumers own their completion checks. The CLI waits for its fallback HMR service before installing live patch watchers. The directory chooser checks the entries it mounts. The chooser and browser package runner capture the first fiber-disposal result before removing the entry, then await it before reporting teardown complete. Preset mounting waits for its subtree and reports import, activation, and missing-service failures. [App boot](../../../../packages/boot/app-boot/README.md) owns exact patch-file watching, activation audits, and partial-context cleanup. These adaptations are separate from the reverse patch.
+
+The small update-result extension forwards asynchronous restart failures through Fiber and Entry updates. The detached import-completion observer handles both fiber outcomes; the fiber still retains its failure for an explicit audit. Durable Include writes drain before and after child removal so a later teardown write cannot erase an earlier terminal write failure. Missing-file initialization waits for its write and rereads the file before mounting initial entries.
+
+## Alternatives considered
+
+**Keep transactional Loader updates.** They provide automatic recovery from a rejected plugin candidate, but retain the vendored lifecycle machinery being removed. Parse failures can be contained without plugin rollback.
+
+**Restore every vendored file verbatim.** This would also remove lazy injected config evaluation, conditional disabled entries, lifecycle disposal fixes, durable writes, and module-loader compatibility. Those changes have independent consumers and remain recorded in the vendor ledger.
+
+**Move generic rollback into app boot.** This would retain the same candidate-generation and restoration obligations under another owner. Applications instead report failures and allow a later valid edit to recover.
+
+## Consequences
+
+A plugin activation failure can leave the new options and a failed fiber in place. Callers that require active plugins must audit after settlement; awaiting `Loader.create()` alone does not establish activation. Automatic plugin rollback requires a separate future decision with evidence that its recovery benefit warrants the additional lifecycle implementation.
+
+[Live-patch tests](../testing/2026-09-09-user-patch-hmr-test-delivery.md) retain controlled event delivery and native watcher coverage, while asserting failure reporting without rollback. The [terminal-release policy](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.md) remains applicable to fatal errors and partial boot teardown. Web preset composition and a real CLI webhook-created model Session provide the application-level verification beyond hand-mounted plugins.

+ 33 - 0
.agents/notes/implemented/simplification/2026-09-09-nontransactional-loader.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 保持 Loader 更改非事务化
+
+Status: implemented
+
+[English](2026-09-09-nontransactional-loader.md) | 中文
+
+## 问题
+
+事务化配置重载会在编辑失败后保留旧插件代次,但要求 Loader 负责候选导入、生命周期结算、回滚、选项对象身份和 Include 串行化。这些更改使 vendor 实现与固定来源产生显著差异。应用启动和 profile patch 监视也隐式依赖该结算行为。
+
+## 决策
+
+撤销 [#932](https://github.com/deepseek-harness/deepseek-harness/pull/932) 中的五个提交,解决包移动冲突并保留后续独立行为。记录的合并提交属于更大的 #936 依赖链;撤销其第一父提交差异还会删除无关的仓库插件支持。[Vendor 修改记录](../../../../vendor/README.md#local-modifications) 按不变的固定来源记录每项保留的源码更改。
+
+Loader 立即更改条目选项。EntryGroup 并发启动同级条目并记录应用失败;EntryTree 等待未完成的工作,但不因失败的 fiber 而拒绝。两者均不恢复旧插件或配置。Include 保留解析校验和 patch 重应用,但插件失败可能留下部分应用的配置树。
+
+应用消费者负责完成检查。CLI 在安装实时 patch 监视器前等待其回退 HMR 服务。目录选择器检查其挂载的条目。选择器和浏览器包运行器在移除条目前取得首次 fiber 释放的结果,并等待该结果后才报告拆卸完成。预设挂载等待其子树,并报告导入、激活和缺少服务的失败。[应用启动](../../../../packages/boot/app-boot/README.zh.md) 负责精确 patch 文件监视、激活检查和部分上下文清理。这些适配与反向补丁分开。
+
+小范围的更新结果扩展通过 Fiber 和 Entry 更新传递异步重启失败。游离的导入完成观察器处理 fiber 的两种结果;fiber 仍保留失败信息供显式检查。Include 的持久写入在删除子条目前后均排空,防止后续拆卸写入掩盖更早的终止性写入失败。缺失文件的初始化等待写入完成,并重新读取文件后才挂载初始条目。
+
+## 考虑过的替代方案
+
+**保留事务化 Loader 更新。** 它们能从被拒绝的插件候选自动恢复,但会保留本次删除的 vendor 生命周期机制。解析失败可以独立于插件回滚进行处理。
+
+**逐字恢复所有 vendor 文件。** 这还会删除延迟注入配置求值、条件禁用条目、生命周期释放修复、持久写入和模块加载器兼容性。这些更改具有独立消费者,并继续记录在 vendor 修改记录中。
+
+**将通用回滚移入应用启动。** 这会在另一归属下保留相同的候选代次与恢复义务。应用改为报告失败,并允许后续有效编辑恢复。
+
+## 后果
+
+插件激活失败可能保留新选项和失败的 fiber。要求插件处于激活状态的调用者必须在结算后检查;仅等待 `Loader.create()` 不能证明激活。自动插件回滚需要后续独立决策,并证明其恢复收益值得额外的生命周期实现。
+
+[实时 patch 测试](../testing/2026-09-09-user-patch-hmr-test-delivery.zh.md) 保留受控事件投递和原生监视覆盖,同时断言不回滚时的失败报告。[终端释放策略](../bug-fix/2026-07-31-fail-loud-releases-the-terminal.zh.md) 仍适用于致命错误和部分启动拆卸。Web preset 组合与真实 CLI webhook 创建的模型 Session 提供手动挂载插件之外的应用级验证。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
-2026-09-09-user-patch-hmr-test-delivery.md: 427cf938d38eaf5c351fc0334bfd7df766d5663c
-2026-09-09-user-patch-hmr-test-delivery.zh.md: c2a3a14c9f322e748dbfd131a98138b13c5d47a6
+2026-09-09-user-patch-hmr-test-delivery.md: fadd191650411f07c3b8b1f35c94e73869fbc8e2
+2026-09-09-user-patch-hmr-test-delivery.zh.md: b108cf25766ff108d87c429e9f208c2d4cf7afd2

+ 7 - 7
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md

@@ -1,4 +1,4 @@
-# Agent Note: User-patch transactions control filesystem event delivery
+# Agent Note: User-patch tests control filesystem event delivery
 
 Status: implemented
 
@@ -6,22 +6,22 @@ English | [中文](2026-09-09-user-patch-hmr-test-delivery.zh.md)
 
 ## Problem
 
-The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the transaction assertions from exercising the parser, activation, and rollback behavior they own.
+The [macOS Sandbox run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) times out while waiting for the first user-patch addition. Concurrent local reproductions show no filesystem notification reaching HMR. A polling variant also misses a subsequent edit while HMR has no pending refresh. These failures prevent the refresh assertions from exercising the parser, activation, and recovery behavior they own.
 
 ## Decision
 
-The [user-patch transaction test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. HMR registration, refresh serialization, Include recomposition, plugin activation, failure broadcasting, rollback, and recovery remain real. The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
+The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
 
-The separate [HMR config tests](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The transaction test does not establish operating-system delivery guarantees.
+The separate [watcher tests](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
 
 ## Alternatives considered
 
-**Native notifications for every transaction assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
+**Native notifications for every refresh assertion.** Rejected because it repeats the native delivery dependency across each parser and activation state transition. A missing event obscures which downstream behavior is broken.
 
 **Polling and fixed settling delays.** Rejected because neither acknowledges delivery of the next edit. Chokidar readiness does not expose completion of Node's asynchronous initial polling baseline; a local polling reproduction still misses changes. Increasing the test deadline cannot recover an event that was never emitted.
 
-**Mock HMR registration or Include.** Rejected because the test must retain transactional recomposition and last-good-state assertions after activation and parse failures.
+**Mock HMR registration or Include.** Rejected because the test must retain real recomposition, report activation failures, and preserve the running configuration after parse failures.
 
 ## Consequences
 
-The transaction sequence retains every semantic assertion and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.
+The refresh sequence retains its semantic assertions and removes fixed change-throttle sleeps. Independent concurrent processes exercise isolation, and a forced setup failure verifies watcher closure and factory restoration before the next case. Native watcher failures remain visible in their owning tests and require their own diagnosis.

+ 7 - 7
.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 用户 patch 事务控制文件系统事件投递
+# Agent Note: 用户 patch 测试控制文件系统事件投递
 
 Status: implemented
 
@@ -6,22 +6,22 @@ Status: implemented
 
 ## 问题
 
-[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止事务断言执行其负责验证的解析、激活与回滚行为。
+[macOS Sandbox 运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34238200206/job/102101292119) 在等待首次用户 patch 新增时超时。本地并发复现表明,没有文件系统通知到达 HMR。轮询变体也会遗漏后续修改,此时 HMR 没有待执行的刷新。这些失败阻止刷新断言执行其负责验证的解析、激活与回滚行为。
 
 ## 决策
 
-[用户 patch 事务测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。HMR 注册、刷新串行化、Include 重组、插件激活、失败广播、回滚与恢复仍使用真实实现。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
+[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
 
-独立的 [HMR 配置测试](../../../../packages/boot/app-boot/tests/hmr-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。事务测试不验证操作系统的投递保证。
+独立的 [监视器测试](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
 
 ## 考虑过的替代方案
 
-**每个事务断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
+**每个刷新断言都使用原生通知。** 不采用,因为这会让每次解析器与激活状态转换都重复依赖原生投递。事件缺失会掩盖下游究竟哪个行为出现问题。
 
 **轮询与固定等待。** 不采用,因为两者都不能确认下一次修改已经投递。Chokidar 就绪状态不暴露 Node 异步初始轮询基线的完成时刻;本地轮询复现仍会遗漏修改。延长测试期限无法恢复从未发出的事件。
 
-**Mock HMR 注册或 Include。** 不采用,因为测试必须保留事务重组,以及激活和解析失败后的最后有效状态断言
+**Mock HMR 注册或 Include。** 不采用,因为测试必须保留真实重组、报告激活失败,并在解析失败后保留运行中的配置
 
 ## 影响
 
-事务序列保留所有语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。
+刷新序列保留其语义断言,并移除固定的 change 节流等待。独立并发进程验证隔离性,强制初始化失败则验证 watcher 在下一用例前关闭、工厂在下一用例前恢复。原生 watcher 失败仍在其所属测试中可见,需要单独诊断。

+ 1 - 0
apps/cli/src/profile-boot.ts

@@ -368,6 +368,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
           await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
         }
         await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
+        await ctx.loader.await()
       }
       await watchUserPatches(ctx, {
         binName: NAME,

+ 0 - 4
apps/cli/tests/built-bin.e2e.ts

@@ -757,10 +757,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
   }, SPAWN_TIMEOUT_MS + 30_000)
 
   it('reports a patch-overlay boot failure without hanging', async () => {
-    // The HMR main watcher's initial scan once refreshed the include
-    // mid-initial-apply, deadlocking the failing apply's rollback against the
-    // refresh drain: dsh exited 13 with no diagnostic instead of settling
-    // ([vendor/README.md](../../../vendor/README.md)).
     const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-'))
     try {
       const result = await runBuiltBin(['--profile', 'web', '--patch', invalidProvider], {

+ 2 - 2
apps/cli/tests/profiles/headless/tests/expected/startup-activation-error/stderr.expected.txt

@@ -1,3 +1,3 @@
-headless-test-driver: plugin tree failed to load: failed to apply loader entry include (cordis:include): failed to apply loader entry activation-error (./activation-error.mjs): startup activation snapshot failure
-Error: startup activation snapshot failure
+headless-test-driver: plugin tree failed to load: headless-test-driver: 1 entry did not activate
+./activation-error.mjs: Error: startup activation snapshot failure
     at activation-error-fixture

+ 1 - 1
apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts

@@ -355,7 +355,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
       expect(exitCode, stderr).toBe(1)
       expect(stdout).toBe('')
       expect(stderr).toContain('plugin tree failed to load')
-      expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)')
+      expect(stderr).toContain('@deepseek-ai/dsh-sdk-jsonrpc-server: SyntaxError:')
       expect(stderr).toContain('sometimes')
     } finally {
       await rm(root, { recursive: true, force: true })

+ 1 - 0
package.json

@@ -133,6 +133,7 @@
     "verify-client-packages": "tsx scripts/verify-client-packages.ts",
     "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts",
     "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts",
+    "verify-vendored-links": "tsx scripts/verify-vendored-links.ts",
     "verify-cordis-config": "tsx scripts/verify-cordis-config.ts",
     "rescope-vendor": "tsx scripts/rescope-vendor.ts",
     "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check",

+ 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: df386b64089f962960b09538381e9d4b4905feb0
-README.zh.md: 5a7246c52ea5bcc8d5322d9fac4a87ec2e2ef4cc
+README.md: bbc3e3147a455149d3e263f0bf4f1091d93f46cc
+README.zh.md: 53d8b58df6da74581539585bce81d885f7708cf8

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

@@ -54,7 +54,7 @@ Your machine-local preferences also live in the Harness home:
 - **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line.
 - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead.
 
-Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
+Profiles with `patchReload: live` watch both user patch files. Parse failures preserve the running configuration; plugin activation failures are reported and can leave a partially applied tree. A later valid edit can recover it. Loader changes are not rolled back. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
 
 Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
 
@@ -118,7 +118,7 @@ Read these pages when the package-level contract is not enough. They move from t
 - [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`).
 - [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior.
 - [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design.
-- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of transaction behavior and native filesystem delivery.
+- [User-patch HMR tests](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md) — ownership of live-patch behavior and native filesystem delivery.
 
 -----
 

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

@@ -54,7 +54,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。
 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个 config(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
-带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
+带 `patchReload: live` 的 profile 会监视两份用户 patch 文件。解析失败会保留运行中的配置;插件激活失败会被报告,并可能留下部分应用的配置树。后续有效编辑可以恢复它。Loader 更改不会回滚。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 
@@ -118,7 +118,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head
 - [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。
 - [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。
 - [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。
-- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——事务行为与原生文件系统投递的验证归属。
+- [用户 patch HMR 测试](../../../.agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md)——实时 patch 行为与原生文件系统投递的验证归属。
 
 -----
 

+ 2 - 2
packages/boot/app-boot/package.json

@@ -29,6 +29,7 @@
   "dependencies": {
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
     "@deepseek-ai/dsh-package-manifest": "workspace:^",
+    "chokidar": "4.0.3",
     "js-yaml": "^4.2.0",
     "resolve.exports": "^2.0.3"
   },
@@ -57,7 +58,6 @@
     "@deepseek-ai/dsh-home-paths": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@types/js-yaml": "^4.0.9",
-    "@deepseek-ai/cordis": "workspace:^",
-    "chokidar": "4.0.3"
+    "@deepseek-ai/cordis": "workspace:^"
   }
 }

+ 45 - 23
packages/boot/app-boot/src/index.ts

@@ -18,6 +18,7 @@ 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 { watchConfig } from './watch-config.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
 declare module '@deepseek-ai/cordis' {
@@ -241,7 +242,7 @@ export interface UserPatchWatchOptions {
 }
 
 /**
- * Watch the user patch layer through Cordis HMR and transactionally reapply it to the boot include.
+ * Watch the user patch layer and reapply it to the boot Include without rollback.
  * @param ctx - settled app context containing the root Include and an active HMR service.
  * @param options - diagnostic, file, and patch-composition inputs.
  * @returns an asynchronous disposer after the exact-path watcher is ready.
@@ -256,7 +257,7 @@ export async function watchUserPatches(
   if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the Cordis HMR service`)
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`)
-  const register = hmr.registerConfig(filename, async () => {
+  const register = watchConfig(ctx, filename, hmr.config, async () => {
     // Re-read the include's non-patch options per refresh so a writer that
     // updates another option between refreshes is not silently reverted.
     const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config
@@ -268,6 +269,8 @@ export async function watchUserPatches(
         patches,
       },
     })
+    await ctx.loader.await()
+    await assertEntriesActivated(ctx, binName)
   })
   try {
     return await register
@@ -511,7 +514,7 @@ function groupedDump(
  * names; relative names continue to resolve beside the configuration file.
  * @returns the created root Include entry, or `undefined` when a surface
  * disposed the whole tree (taking the Loader service with it) while the
- * transactional create was still settling entry lifecycle.
+ * entry creation was in flight.
  */
 export async function mountRootInclude(
   ctx: Context,
@@ -762,11 +765,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * is statically imported and mounted as the `cordis:include` builtin, loading
  * through the ambient module pipeline (vite/tsx/plain ESM). The package build
  * embeds Include while leaving Loader external, so the built include tree and
- * host share one Loader peer. Loader
- * settlement rejects startup failures, which `boot` wraps after disposing the
- * partial context; a missing fiber or never-activating entry is rejected by
- * the final audit, {@link assertEntriesActivated}, which rethrows a plugin's
- * init rejection with its original stack; later unhandled rejections remain
+ * host share one Loader peer. The final audit, {@link assertEntriesActivated},
+ * rejects missing, failed, or inactive entries. `boot` disposes its partial
+ * context on failure and preserves the original activation stack; later unhandled rejections remain
  * covered by {@link installFailLoud}. Built bins need the Loader's native
  * helper for bare plugin specifiers; relative specifiers do not.
  * @param binName - the diagnostic prefix for load-failure errors.
@@ -780,6 +781,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro
  * complete plugin set.
  * @returns the root context once every entry has started, or as soon as a
  * surface disposed the tree while startup was still in flight.
+ * @throws a labelled error after disposing the partial context — `host
+ * preparation failed` when `prepare` threw before any config-tree entry
+ * mounted, `plugin tree failed to load` afterwards.
  */
 export async function boot(
   binName: string,
@@ -789,21 +793,39 @@ export async function boot(
   bareModuleBaseUrl?: string,
 ): Promise<Context> {
   const ctx = new Context()
-  ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
-  ctx.provide('dshHomePath', dshHomePath)
-  await ctx.plugin(Loader)
-  await prepare?.(ctx)
-  await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
-  await ctx.loader.await()
-  // A surface can finish and dispose the whole tree while that await is still
-  // pending: the TUI renders as soon as its own fiber starts, so an `/exit`
-  // typed before the last entry settles tears the context down under us. The
-  // Loader service goes with it, and the activation audit describes a live
-  // tree — reading `ctx.loader` here would throw a TypeError over an app that
-  // exited exactly as asked.
-  if (ctx.get('loader') === undefined) return ctx
-  await assertEntriesActivated(ctx, binName)
-  return ctx
+  // Two failure labels: `prepare` runs before any config-tree entry mounts,
+  // so its failure is host setup, not the plugin tree.
+  let stage = 'host preparation failed'
+  try {
+    ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
+    ctx.provide('dshHomePath', dshHomePath)
+    await ctx.plugin(Loader)
+    await prepare?.(ctx)
+    stage = 'plugin tree failed to load'
+    await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl)
+    // A surface can finish and dispose the whole tree while startup is still
+    // in flight, before the last entry settles. The Loader service goes with
+    // it, and the activation audit describes a live tree — reading `ctx.loader`
+    // past this point would throw a TypeError over an app that exited exactly
+    // as asked. Re-check after settlement before auditing the tree.
+    await ctx.get('loader')?.await()
+    if (ctx.get('loader') === undefined) return ctx
+    await assertEntriesActivated(ctx, binName)
+    return ctx
+  } catch (cause) {
+    // Root-fiber disposal contains cleanup failures per observer (Cordis
+    // fiber.ts hardening) and a repeated call returns the settled single-shot
+    // result, so this await cannot reject and replace `cause`.
+    await ctx.fiber.dispose()
+    const detail = cause instanceof Error ? cause.message : String(cause)
+    // A wrapper can carry an activation error whose original stack names the failed plugin.
+    let deepest: unknown = cause
+    while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause
+    const stack = deepest instanceof AggregateError
+      ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}`
+      : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : ''
+    throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
+  }
 }
 
 /** Prompt-section name for the harness-source location line an app bin adds after boot. */

+ 89 - 0
packages/boot/app-boot/src/watch-config.ts

@@ -0,0 +1,89 @@
+/** Exact-path watching for live profile patch files outside Cordis module roots. */
+import { dirname, relative, resolve } from 'node:path'
+import { realpath, stat } from 'node:fs/promises'
+import { watch, type ChokidarOptions } from 'chokidar'
+import type { Context } from '@deepseek-ai/cordis'
+
+const registrations = new WeakMap<Context, Set<string>>()
+
+async function findWatchRoot(filename: string): Promise<{ filename: string; root: string; depth: number }> {
+  let root = dirname(filename)
+  let depth = 0
+  while (true) {
+    try {
+      if (!(await stat(root)).isDirectory()) throw new Error(`config watch parent is not a directory: ${root}`)
+      const canonicalRoot = await realpath(root)
+      return { filename: resolve(canonicalRoot, relative(root, filename)), root: canonicalRoot, depth }
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+      const parent = dirname(root)
+      if (parent === root) throw error
+      root = parent
+      depth += 1
+    }
+  }
+}
+
+/**
+ * Watch one patch path, including missing parents, and serialize refresh callbacks.
+ * @param ctx Context that owns watcher disposal and receives refresh failures.
+ * @param filename Absolute patch-file path.
+ * @param options Deployment watcher options inherited from the HMR configuration.
+ * @param refresh Callback for additions, changes, and removals.
+ * @returns A disposer that closes the watcher and drains its current refresh.
+ * @throws When path resolution, watcher startup, or effect registration fails.
+ */
+export async function watchConfig(
+  ctx: Context, filename: string, options: ChokidarOptions, refresh: () => Promise<void> | void,
+): Promise<() => Promise<void>> {
+  const target = await findWatchRoot(filename)
+  const paths = registrations.get(ctx) ?? new Set<string>()
+  registrations.set(ctx, paths)
+  if (paths.has(target.filename)) throw new Error(`config path already registered: ${filename}`)
+  const { cwd: _cwd, ignored: _ignored, ...watchOptions } = options
+  const watcher = watch(target.root, {
+    ...watchOptions, depth: target.depth, ignoreInitial: false,
+  })
+  paths.add(target.filename)
+  const state = { dirty: false }
+  let running: Promise<void> | undefined
+  const onChange = (path: string) => {
+    const observed = resolve(path)
+    if (observed !== filename && observed !== target.filename) return
+    state.dirty = true
+    if (running) return
+    running = (async () => {
+      while (state.dirty) {
+        state.dirty = false
+        try {
+          await refresh()
+        } catch (reason) {
+          const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason })
+          ctx.logger.warn('config reload at %C failed', filename)
+          ctx.logger.warn(error)
+        }
+      }
+    })().finally(() => { running = undefined })
+  }
+  watcher.on('add', onChange)
+  watcher.on('change', onChange)
+  watcher.on('unlink', onChange)
+  const ready = Promise.withResolvers<void>()
+  let pending = true
+  watcher.once('ready', () => { pending = false; ready.resolve() })
+  watcher.on('error', (error) => {
+    if (pending) { pending = false; ready.reject(error) } else { ctx.logger.warn(error) }
+  })
+  const dispose = async () => {
+    await watcher.close()
+    paths.delete(target.filename)
+    await running
+  }
+  try {
+    await ready.promise
+    return ctx.effect(() => dispose, 'app-boot.watchConfig()')
+  } catch (error) {
+    await dispose()
+    throw error
+  }
+}

+ 6 - 6
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -790,7 +790,7 @@ describe('boot', () => {
     const dir = tmp()
     writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n  name: ./missing.mjs\n')
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(
-      `${NAME}: plugin tree failed to load: failed to apply loader entry`,
+      `${NAME}: plugin tree failed to load: ${NAME}: plugin(s) failed to load: ./missing.mjs`,
     )
   })
 
@@ -808,7 +808,7 @@ describe('boot', () => {
     writeFileSync(configPath, config)
 
     await expect(boot(NAME, configPath)).rejects.toThrow(
-      'failed to apply loader entry invalid-config (./noop.mjs)',
+      './noop.mjs: SyntaxError:',
     )
     expect(readFileSync(configPath, 'utf8')).toBe(config)
   })
@@ -825,8 +825,8 @@ describe('boot', () => {
     ].join('\n'))
     writeFileSync(join(dir, 'cordis.yml'), '- id: failing\n  name: ./failing.mjs\n')
     await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(new RegExp([
-      String.raw`failed to apply loader entry failing \(\./failing\.mjs\): pinned activation failure\n`,
-      String.raw`Error: pinned activation failure\n {4}at failing-fixture$`,
+      String.raw`\./failing\.mjs: Error: pinned activation failure\n`,
+      String.raw` {4}at failing-fixture$`,
     ].join('')))
   })
 
@@ -835,9 +835,9 @@ describe('boot', () => {
     const deepest = new Error('stackless deep failure')
     delete (deepest as { stack?: string }).stack
     await expect(boot(NAME, join(dir, 'cordis.yml'), undefined, () => {
-      throw new Error('host preparation failed', { cause: deepest })
+      throw new Error('wrapped setup failure', { cause: deepest })
     })).rejects.toThrow(
-      `${NAME}: plugin tree failed to load: host preparation failed\nstackless deep failure`,
+      `${NAME}: host preparation failed: wrapped setup failure\nstackless deep failure`,
     )
   })
 

+ 25 - 13
packages/boot/app-boot/tests/config-reload.spec.ts

@@ -1,15 +1,6 @@
-/**
- * Config hot-reload resilience of the booted include tree. `dsh-app-boot`
- * installs a fail-loud unhandled-rejection handler, so a `refresh()` that
- * rethrows a config-file parse error would kill a live app on one bad
- * `cordis.yml` edit (the HMR watcher awaits `refresh()` in an async event
- * callback nobody else catches). These tests pin the vendored
- * `@cordisjs/plugin-include` contract that boot relies on: an invalid file
- * keeps the last good tree, and a valid re-read re-applies overlay patches
- * exactly like the initial load.
- */
-
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+/** File reload and overlay behavior through the booted Include tree. */
+
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, describe, expect, it } from 'vitest'
@@ -32,10 +23,11 @@ interface TreeFixture {
   include: Include
 }
 
-async function bootTree(configBody: string): Promise<TreeFixture> {
+async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
   tempRoots.push(dir)
   writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
+  for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
   writeFileSync(join(dir, 'cordis.yml'), configBody)
   const ctx = await boot(NAME, join(dir, 'cordis.yml'))
   const entry = [...ctx.loader.entries()].find(candidate => candidate.subtree !== undefined)
@@ -48,6 +40,26 @@ function entryConfig(ctx: Context, id: string): unknown {
 }
 
 describe('include refresh with an invalid file', () => {
+  it('writes and activates initial entries when an included file is missing', async () => {
+    const { ctx, dir } = await bootTree([
+      '- id: initialized',
+      "  name: 'cordis:include'",
+      '  config:',
+      '    path: ./created.yml',
+      '    initial:',
+      '      - id: noop',
+      '        name: ./noop.mjs',
+      '        config: { value: initial }',
+      '',
+    ].join('\n'))
+    try {
+      expect(entryConfig(ctx, 'noop')).toEqual({ value: 'initial' })
+      expect(readFileSync(join(dir, 'created.yml'), 'utf8')).toContain('id: noop')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('keeps the last good tree instead of throwing, then applies the next valid edit', async () => {
     const { ctx, dir, include } = await bootTree('- id: noop\n  name: ./noop.mjs\n  config:\n    value: 1\n')
     try {

+ 30 - 29
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -1,7 +1,7 @@
 /**
  * User patch-layer behavior of `dsh-app-boot`: the optional patch-list loader
  * (a profile's `cordis.patch.yml`) and `boot()` applying the user layer over
- * a real Loader tree, kept live through transactional HMR.
+ * a real Loader tree with live file watching.
  */
 
 import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -270,7 +270,9 @@ describe('Loader config interpolation', () => {
       await provider?.update({ disabled: true })
       await provider?.update({ config: { fail: true } })
       await provider?.update({ disabled: false })
-      await expect(ctx.loader.await()).rejects.toThrow('rejected provider')
+      await ctx.loader.await()
+      const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
+      await expect(reader?.fiber?.await()).rejects.toThrow('rejected provider')
       expect(ctx.get('readerResult')).toBeUndefined()
 
       await provider?.update({ disabled: true })
@@ -342,7 +344,8 @@ describe('Loader entry disabled interpolation', () => {
       const disabledFalse = { __jsExpr: 'process.version.length === 0' } as unknown as boolean
       await entry?.update({ disabled: disabledTrue })
       expect(entry?.disabled).toBe(true)
-      expect(entry?.fiber).toBeUndefined()
+      await ctx.loader.await()
+      expect(entry?.fiber?.uid).toBeNull()
       await entry?.update({ disabled: disabledFalse })
       expect(entry?.disabled).toBe(false)
       expect(entry?.fiber).toBeDefined()
@@ -403,7 +406,7 @@ describe('boot with user patches', () => {
     }
   })
 
-  it('watches add, failure, recovery, and removal through transactional HMR', { timeout: 20_000 }, async () => {
+  it('applies live patches, reports failures without rollback, and recovers after a later edit', { timeout: 20_000 }, async () => {
     const dir = tmp()
     const userDir = tmp()
     const filename = join(userDir, PROFILE_PATCH_FILENAME)
@@ -412,8 +415,8 @@ describe('boot with user patches', () => {
     onTestFinished(() => ctx.fiber.dispose())
     await ctx.plugin(Timer)
     await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
-    // Native notifications belong to hmr-config.spec.ts; this case owns the
-    // real HMR/Include transaction after each delivered filesystem event.
+    // Native notifications belong to watch-config.spec.ts; this case owns
+    // Include recomposition after each explicitly delivered filesystem event.
     const watchers: FSWatcher[] = []
     const previousFactory = configWatch.create
     onTestFinished(() => { configWatch.create = previousFactory })
@@ -423,10 +426,11 @@ describe('boot with user patches', () => {
       queueMicrotask(() => { watcher.emit('ready') })
       return watcher
     }
-    const failures: Array<{ filename: string; error: Error }> = []
-    ctx.on('hmr/config-update-failed', (failedFilename, error) => {
-      failures.push({ filename: failedFilename, error })
+    const failures: Error[] = []
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation((value: unknown) => {
+      if (value instanceof Error) failures.push(value)
     })
+    onTestFinished(() => { warn.mockRestore() })
     const dispose = await watchUserPatches(ctx, {
       binName: NAME,
       filename,
@@ -441,16 +445,15 @@ describe('boot with user patches', () => {
 
       writeFileSync(filename, '- id: noop\n  config:\n    fail: true\n')
       watcher.emit('change', filename)
-      await eventually(() => failures.length === 1, 'failed candidate was not broadcast')
-      expect(failures[0]).toMatchObject({ filename })
-      expect(failures[0]?.error).toBeInstanceOf(Error)
-      expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
+      await eventually(() => failures.length === 1, 'failed candidate was not reported')
+      expect(failures[0]).toBeInstanceOf(Error)
+      expect(entryConfig(ctx, 'noop')).toMatchObject({ fail: true })
 
       writeFileSync(filename, 'invalid: [unclosed\n')
       watcher.emit('change', filename)
-      await eventually(() => failures.length === 2, 'parse failure was not broadcast')
-      expect(failures[1]?.error).toBeInstanceOf(Error)
-      expect((entryConfig(ctx, 'noop') as { value?: string }).value).toBe('live')
+      await eventually(() => failures.length === 2, 'parse failure was not reported')
+      expect(failures[1]).toBeInstanceOf(Error)
+      expect(entryConfig(ctx, 'noop')).toMatchObject({ fail: true })
 
       writeFileSync(filename, '- id: noop\n  config:\n    value: recovered\n')
       watcher.emit('change', filename)
@@ -494,21 +497,19 @@ describe('boot with user patches', () => {
   })
 
   it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
-    // A surface can dispose the whole tree while registerConfig's effect
-    // registration is still in flight (the HMR effect then fails with
-    // INACTIVE_EFFECT); the app is exiting exactly as asked, so the watcher
-    // must not crash the process. The stub makes the race deterministic — the
-    // live-teardown ordering itself is not stageable.
     const dir = tmp()
     const ctx = await boot(NAME, writeTree(dir))
-    try {
-      const teardown = Object.assign(new Error('cannot create effect on inactive context'), { code: 'INACTIVE_EFFECT' })
-      ctx.provide('hmr', { registerConfig: () => Promise.reject(teardown) })
-      const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
-      await expect(dispose()).resolves.toBeUndefined()
-    } finally {
-      await ctx.fiber.dispose()
+    await ctx.plugin(Timer)
+    await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = (options) => {
+      const watcher = new FSWatcher(options)
+      void ctx.fiber.dispose().then(() => { watcher.emit('ready') })
+      return watcher
     }
+    const dispose = await watchUserPatches(ctx, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })
+    await expect(dispose()).resolves.toBeUndefined()
   })
 
   it('propagates registration failures other than mid-teardown', async () => {
@@ -519,7 +520,7 @@ describe('boot with user patches', () => {
       await ctx.plugin(Timer)
       await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
       const dispose = await watchUserPatches(ctx, { binName: NAME, filename })
-      // Same user-layer path registered twice: HMR refuses; not a teardown race.
+      // Same user-layer path registered twice: the watcher refuses; not a teardown race.
       await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
       await dispose()
     } finally {

+ 286 - 0
packages/boot/app-boot/tests/watch-config.spec.ts

@@ -0,0 +1,286 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
+import { realpath } from 'node:fs/promises'
+import * as fsPromises from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, parse } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import { watchConfig } from '../src/watch-config.ts'
+import Hmr from '@deepseek-ai/cordis-plugin-hmr'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Timer from '@deepseek-ai/cordis-plugin-timer'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { FSWatcher, type ChokidarOptions } from 'chokidar'
+
+const configWatch = vi.hoisted(() => ({ create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined }))
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const native = await importOriginal<typeof import('node:fs/promises')>()
+  return { ...native, stat: vi.fn(native.stat) }
+})
+vi.mock('chokidar', async (importOriginal) => {
+  const native = await importOriginal<typeof import('chokidar')>()
+  return { ...native, watch: (paths: string | string[], options?: ChokidarOptions) =>
+    configWatch.create === undefined ? native.watch(paths, options) : configWatch.create(options) }
+})
+
+/** Every per-test tree root, removed once the booted watcher has been disposed. */
+const hmrRoots: string[] = []
+
+async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(dir).href + '/'
+  await ctx.plugin(Loader)
+  await ctx.plugin(Timer)
+  await ctx.plugin(Hmr, {
+    root,
+    ignored: [],
+    debounce: 0,
+    ...usePolling === undefined ? {} : { usePolling },
+  })
+  return ctx
+}
+
+async function eventually(test: () => boolean, message: string): Promise<void> {
+  const deadline = Date.now() + 10_000
+  while (!test()) {
+    if (Date.now() >= deadline) throw new Error(message)
+    await new Promise(resolve => setTimeout(resolve, 10))
+  }
+}
+
+describe('HMR exact config paths', () => {
+  afterEach(() => {
+    for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+  })
+
+  it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
+    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
+    const alias = `${target}-alias`
+    const aliasFilename = join(alias, 'module.ts')
+    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
+    writeFileSync(aliasFilename, 'export const generation = 0\n')
+    // This acceptance owns alias-to-cache identity. Other cases below exercise
+    // native events; polling keeps Windows fs.watch queue pressure out of it.
+    const ctx = await bootHmr(alias, ['.'], true)
+    const filename = join(await realpath(target), 'module.ts')
+    const expected = pathToFileURL(filename).href
+    const cacheHas = vi.spyOn(ctx.loader.internal!.loadCache, 'has').mockReturnValue(false)
+    const observed: string[] = []
+    ctx.on('hmr/change', (url) => { observed.push(url) })
+    try {
+      const deadline = Date.now() + 20_000
+      for (let generation = 1; !observed.includes(expected); generation += 1) {
+        if (Date.now() >= deadline) {
+          throw new Error(`HMR did not observe ${expected} through the alias; observed ${JSON.stringify(observed)}`)
+        }
+        // The watch base, not the writer spelling, is the alias under test.
+        // Grow the file on every write: polling must not depend on timestamp
+        // precision when several generations land inside one filesystem tick.
+        writeFileSync(filename, `export const generation = ${generation}\n${' '.repeat(generation)}\n`)
+        // Leave Chokidar's atomic-write window idle so one coalesced change can publish.
+        await new Promise(resolve => setTimeout(resolve, 250))
+      }
+      expect(cacheHas).toHaveBeenCalledWith(expected)
+    } finally {
+      await ctx.fiber.dispose()
+      unlinkSync(alias)
+      rmSync(target, { recursive: true, force: true })
+    }
+  })
+
+  it('collapses filesystem aliases before registering an exact watch', async () => {
+    const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-canonical-'))
+    const alias = `${target}-alias`
+    symlinkSync(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
+    const ctx = await bootHmr(alias)
+    try {
+      await watchConfig(ctx, join(alias, 'plugins.yml'), {}, () => {})
+      await expect(watchConfig(ctx, join(await realpath(target), 'plugins.yml'), {}, () => {}))
+        .rejects.toThrow('config path already registered')
+    } finally {
+      await ctx.fiber.dispose()
+      unlinkSync(alias)
+      rmSync(target, { recursive: true, force: true })
+    }
+  })
+
+  it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(dir)
+    const observed: string[] = []
+    try {
+      await watchConfig(ctx, filename, {}, () => {
+        try {
+          observed.push(readFileSync(filename, 'utf8'))
+        } catch (error) {
+          if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+          observed.push('missing')
+        }
+      })
+
+      writeFileSync(filename, 'one', { flag: 'wx' })
+      await eventually(() => observed.includes('one'), 'HMR did not observe config creation')
+      writeFileSync(filename, 'two')
+      await eventually(() => observed.includes('two'), 'HMR did not observe config change')
+      unlinkSync(filename)
+      await eventually(() => observed.includes('missing'), 'HMR did not observe config removal')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(root)
+    const dir = join(root, 'later')
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(root)
+    const observed: string[] = []
+    try {
+      await watchConfig(ctx, filename, {}, () => {
+        observed.push(readFileSync(filename, 'utf8'))
+      })
+      mkdirSync(dir)
+      writeFileSync(filename, 'created')
+      await eventually(() => observed.includes('created'), 'HMR did not observe config creation under a new parent')
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('serializes refreshes and waits for them during disposal', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(dir)
+    onTestFinished(() => ctx.fiber.dispose())
+    const watcher = new FSWatcher()
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    onTestFinished(() => { release.resolve(undefined) })
+    let calls = 0
+    let active = 0
+    let maxActive = 0
+    const dispose = await watchConfig(ctx, filename, {}, async () => {
+      active += 1
+      maxActive = Math.max(maxActive, active)
+      if (++calls === 1) {
+        started.resolve(undefined)
+        await release.promise
+      }
+      active -= 1
+    })
+    watcher.emit('change', join(dir, 'unrelated.yml'))
+    expect(calls).toBe(0)
+    watcher.emit('add', filename)
+    await started.promise
+    watcher.emit('change', filename)
+    watcher.emit('unlink', filename)
+    let disposed = false
+    const disposal = dispose().then(() => { disposed = true })
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+    release.resolve(undefined)
+    await disposal
+    expect(maxActive).toBe(1)
+    expect(calls).toBe(2)
+  })
+
+  it('rejects a patch path whose parent is a regular file', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-parent-'))
+    hmrRoots.push(dir)
+    const parent = join(dir, 'file')
+    writeFileSync(parent, '')
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await expect(watchConfig(ctx, join(parent, 'plugins.yml'), {}, () => {}))
+      .rejects.toThrow('config watch parent is not a directory')
+  })
+
+  it('stops searching when the filesystem root cannot be read', async () => {
+    const failure = Object.assign(new Error('filesystem root unavailable'), { code: 'ENOENT' })
+    const read = vi.mocked(fsPromises.stat).mockClear().mockRejectedValueOnce(failure)
+    onTestFinished(() => { read.mockRestore() })
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    await expect(watchConfig(ctx, join(parse(tmpdir()).root, 'plugins.yml'), {}, () => {})).rejects.toBe(failure)
+    expect(read).toHaveBeenCalledOnce()
+  })
+
+  it.each(['creation', 'ready'] as const)('releases registration after watcher %s fails', async (phase) => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-watch-failure-'))
+    hmrRoots.push(dir)
+    const ctx = new Context()
+    onTestFinished(() => ctx.fiber.dispose())
+    const filename = join(dir, 'plugins.yml')
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    const failure = new Error('watcher unavailable')
+    const failed = new FSWatcher()
+    const closed = vi.spyOn(failed, 'close')
+    configWatch.create = () => {
+      if (phase === 'creation') throw failure
+      queueMicrotask(() => { failed.emit('error', failure) })
+      return failed
+    }
+    await expect(watchConfig(ctx, filename, {}, () => {})).rejects.toBe(failure)
+    if (phase === 'ready') expect(closed).toHaveBeenCalledOnce()
+    const watcher = new FSWatcher()
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    await watchConfig(ctx, filename, {}, () => {})
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    onTestFinished(() => { warn.mockRestore() })
+    watcher.emit('error', failure)
+    expect(warn).toHaveBeenCalledWith(failure)
+  })
+
+  it('closes a ready watcher when its context has already been disposed', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-disposed-'))
+    hmrRoots.push(dir)
+    const root = new Context()
+    const fiber = root.plugin(() => {})
+    await fiber
+    const ctx = fiber.ctx
+    await fiber.dispose()
+    const watcher = new FSWatcher()
+    const close = vi.spyOn(watcher, 'close')
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    await expect(watchConfig(ctx, join(dir, 'plugins.yml'), {}, () => {}))
+      .rejects.toThrow('cannot create effect on inactive context')
+    expect(close).toHaveBeenCalledOnce()
+  })
+
+  it('logs a normalized refresh failure and continues processing later events', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'dsh-patch-failure-'))
+    hmrRoots.push(dir)
+    const filename = join(dir, 'plugins.yml')
+    const ctx = await bootHmr(dir)
+    onTestFinished(() => ctx.fiber.dispose())
+    const watcher = new FSWatcher()
+    const previousFactory = configWatch.create
+    onTestFinished(() => { configWatch.create = previousFactory })
+    configWatch.create = () => { queueMicrotask(() => { watcher.emit('ready') }); return watcher }
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    onTestFinished(() => { warn.mockRestore() })
+    let calls = 0
+    const recovered = Promise.withResolvers<undefined>()
+    await watchConfig(ctx, filename, {}, () => {
+      if (++calls === 1) throw 42
+      recovered.resolve(undefined)
+    })
+    watcher.emit('change', filename)
+    await expect.poll(() => warn.mock.calls.length).toBe(2)
+    expect(warn.mock.calls[0]).toEqual(['config reload at %C failed', filename])
+    expect(warn.mock.calls[1]?.[0]).toMatchObject({ message: '42' })
+    watcher.emit('change', filename)
+    await recovered.promise
+    expect(calls).toBe(2)
+  })
+})

+ 2 - 2
packages/extensions/cordis-client-runner/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/extensions/cordis-client-runner/README.md
-README.md: 260d4703bda2560a4254962d7b868221d0328ec8
-README.zh.md: a74128508c5b6a81d0600452b6ec5301bf7c8dcb
+README.md: 153a8bf3b71d2c4c8edf3423dd0b7004083d1ecc
+README.zh.md: 3f36f180043ce9074161889d3f1b0a7ab9b781ba

+ 1 - 1
packages/extensions/cordis-client-runner/README.md

@@ -51,7 +51,7 @@ This section explains the design behind the browser half; the observable behavio
 
 ### Design philosophy
 
-The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload is entry removal plus factory invalidation plus style removal. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel.
+The browser half is built on one principle: a dynamic package must ride the same activation gating, fiber-effect cleanup, and status projection as a static one. The evaluated plugin is seated in the module table and mounted through `loader.create`; unload removes the entry, waits for its fiber's cleanup, then invalidates its factory and removes its styles. The guard is a whitelist — lifecycle verbs plus declared services — that mirrors the host-side sandbox facade, so a package author meets one contract on both halves. One observer feeds two outlets: the slot registry's entry-error seam is watched only here, and a crash belonging to a package this runner seated goes upstream to the host for the model and onto this package's own `renderFailures` for the panel.
 
 ### Source map
 

+ 1 - 1
packages/extensions/cordis-client-runner/README.zh.md

@@ -51,7 +51,7 @@ kind: "package-reference"
 
 ### 设计理念
 
-浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载 = 移除 entry + 失效 factory + 撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry 错误接缝,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。
+浏览器半建立在一个原则之上:动态包必须与静态包共享同一套激活门控、fiber effect 清理与状态投影。求值后的插件被塞进模块表,并经 `loader.create` 挂载;卸载先移除 entry,等待其 fiber 清理完成,再使 factory 失效并撤下样式。guard 是一份白名单——生命周期动词加已声明服务——与 host 侧沙箱门面对称,因此包作者在两侧面对同一个约定。一个观察者供两个出口:只有这里监视槽位注册表的 entry 错误接缝,凡属于本 runner 落座过的包的崩溃,一路上行给 host(给模型),一路发布到本包自己的 `renderFailures`(给面板)。
 
 ### 源码地图
 

+ 3 - 1
packages/extensions/cordis-client-runner/src/client/runtime.ts

@@ -451,7 +451,9 @@ export class DynamicCordisPackageRunner {
     this.failures.delete(id)
     // Entry removal disposes the fiber (slot entries and facade effects
     // cascade); the factory invalidation makes a later re-load legal.
-    await this.env.loader.remove(entryId)
+    const disposal = this.env.loader.resolve(entryId).fiber?.dispose()
+    this.env.loader.remove(entryId)
+    await disposal
     this.env.modules.invalidate(moduleIdOf(id))
     styles.dispose()
   }

+ 25 - 5
packages/extensions/cordis-client-runner/tests/runner.client.spec.ts

@@ -13,7 +13,7 @@
 
 import { Context } from '@deepseek-ai/cordis'
 import type { Loader } from '@deepseek-ai/cordis-plugin-loader'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type {
   CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId,
 } from '@deepseek-ai/dsh-api-remotes/client'
@@ -52,7 +52,7 @@ interface Bench {
   invalidated: string[]
   removed: string[]
   created: string[]
-  invoke: ReturnType<typeof vi.fn>
+  invoke: ReturnType<typeof vi.fn<() => Promise<unknown>>>
   /** Render failures the runner sent upstream, in order. */
   reported: {
     agentId: SessionId
@@ -103,15 +103,15 @@ async function boot(): Promise<Bench> {
       return Promise.resolve(entryId)
     },
     resolve: (entryId: string) => fibers.get(entryId) ?? { fiber: undefined },
-    remove: async (entryId: string) => {
+    remove: (entryId: string) => {
       removed.push(entryId)
       const entry = fibers.get(entryId)
       fibers.delete(entryId)
-      await (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
+      void (entry?.fiber as { dispose(): Promise<void> } | undefined)?.dispose()
     },
   } as unknown as Loader
 
-  const invoke = vi.fn(() => Promise.resolve(null))
+  const invoke = vi.fn<() => Promise<unknown>>(() => Promise.resolve(null))
   const reported: Bench['reported'] = []
   // The crash seam is stood in so a test can report an entry failure without a
   // React render, exactly as the renderer's boundary would; registrations still
@@ -298,6 +298,26 @@ describe('failure stages', () => {
 })
 
 describe('retract', () => {
+  it('waits for plugin cleanup before invalidating its module factory', async () => {
+    const bench = await boot()
+    const started = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    onTestFinished(() => { release.resolve(undefined) })
+    bench.invoke.mockImplementation(() => {
+      started.resolve(undefined)
+      return release.promise
+    })
+    await bench.runner.load(half({ code: 'return { apply: (ctx) => ctx.effect(() => () => host.call("cleanup", null)) }' }))
+    bench.runner.retract(PLUGIN, RUN)
+    await started.promise
+    await bench.settle()
+    expect(bench.removed).toEqual(['entry-1'])
+    expect(bench.invalidated).toEqual(['dyn/dyn-1'])
+    release.resolve(undefined)
+    await bench.settle()
+    expect(bench.invalidated).toEqual(['dyn/dyn-1', 'dyn/dyn-1'])
+  })
+
   it('unloads at the named revision', async () => {
     const bench = await boot()
     await bench.runner.load(half())

+ 7 - 3
packages/host/directory-picker-auto/src/index.ts

@@ -79,14 +79,18 @@ export async function apply(ctx: Context): Promise<void> {
         // nothing is left to unmount or await then.
         const entry = ctx.loader.store[id]
         if (entry === undefined) continue
-        const fiber = entry.fiber
+        const disposal = entry.fiber?.dispose()
         ctx.loader.remove(id)
-        await fiber?.dispose()
+        await disposal
       }
     }
     try {
       for (const name of [BACKEND_PACKAGES[backend], SURFACE_PACKAGES[backend]]) {
-        ids.push(await ctx.loader.create({ name }))
+        const id = await ctx.loader.create({ name })
+        ids.push(id)
+        const entry = ctx.loader.resolve(id)
+        if (entry.fiber === undefined) throw new Error(`directory-picker-auto: failed to load ${name}`)
+        await entry.fiber.await()
       }
     } catch (cause) {
       // Setup owns the entries it created until it returns the disposer: leaving

+ 19 - 3
packages/host/directory-picker-auto/tests/loader-composition.spec.ts

@@ -12,7 +12,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
@@ -205,7 +205,21 @@ describe('real Loader composition', () => {
     // and the disposer joins the backend's teardown — the service is gone the
     // moment dispose() settles, with no further loader await.
     const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
-    await autoEntry.fiber!.dispose()
+    const backendEntry = [...ctx.loader.entries()].find(entry => entry.options.name === NATIVE)!
+    const cleanupStarted = Promise.withResolvers<undefined>()
+    const releaseCleanup = Promise.withResolvers<undefined>()
+    onTestFinished(() => { releaseCleanup.resolve(undefined) })
+    backendEntry.fiber!.ctx.effect(() => async () => {
+      cleanupStarted.resolve(undefined)
+      await releaseCleanup.promise
+    })
+    let disposed = false
+    const disposal = autoEntry.fiber!.dispose().then(() => { disposed = true })
+    await cleanupStarted.promise
+    await Promise.resolve()
+    expect(disposed).toBe(false)
+    releaseCleanup.resolve(undefined)
+    await disposal
     expect(entryNames(ctx)).not.toContain(NATIVE)
     expect(entryNames(ctx)).not.toContain(NATIVE_SURFACE)
     expect(ctx.get('directoryPicker')).toBeUndefined()
@@ -245,7 +259,9 @@ describe('real Loader composition', () => {
 
   it('unmounts the backend when the surface entry fails to load', { timeout: 60_000 }, async () => {
     stubAttendedHost()
-    await expect(loadComposition('127.0.0.1', { failSurface: true })).rejects.toThrow(/surface import failed/)
+    const { ctx } = await loadComposition('127.0.0.1', { failSurface: true })
+    const chooser = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)!
+    await expect(chooser.fiber!.await()).rejects.toThrow(/directory-picker-auto: failed to load/)
 
     // Setup owns both entries until it returns its disposer, so a failed surface
     // must take the mounted backend with it: otherwise a retry collides with the

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

@@ -86,7 +86,7 @@ describe('PluginInventoryGateway', () => {
       fiberPhase: null,
     })
 
-    await ctx.loader.remove(pendingId)
+    ctx.loader.remove(pendingId)
     expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false)
   })
 

+ 1 - 13
packages/host/webserver/tests/webserver.spec.ts

@@ -361,25 +361,13 @@ describe('real Loader composition', () => {
     const firstRoot = root
     root = undefined // keep the first composition's files until the end
 
-    // loader.await() never rejects (allSettled); the bind failure surfaces as
-    // a FAILED fiber whose error escapes as a late rejection — the shape the
-    // boot's installFailLoud is contracted to catch. Capture it here the same
-    // way, and assert it really is the bind error.
-    const rejections: unknown[] = []
-    const onUnhandled = (err: unknown): void => { rejections.push(err) }
-    process.on('unhandledRejection', onUnhandled)
     let second: Context | undefined
     try {
       second = await loadComposition(takenPort)
       const entry = [...second.loader.entries()].find(e => e.options.name === '@deepseek-ai/dsh-host-webserver')
       expect(entry?.fiber?.state).toBe(FiberState.FAILED)
-      // The rejection escapes a tick after loader.await() settles; bounded poll.
-      for (let i = 0; i < 100 && rejections.length === 0; i++) {
-        await new Promise(resolve => setTimeout(resolve, 10))
-      }
-      expect(rejections.map(String).join('\n')).toContain('EADDRINUSE')
+      await expect(entry?.fiber?.await()).rejects.toThrow('EADDRINUSE')
     } finally {
-      process.off('unhandledRejection', onUnhandled)
       await second?.fiber.dispose()
       context = first
       if (root !== undefined) await rm(root, { recursive: true, force: true })

+ 1 - 0
packages/llm/plugin-package-inventory-deepseek/tests/inventory.spec.ts

@@ -194,6 +194,7 @@ describe('DeepSeek plugin package inventory', () => {
 
     await ctx.loader.create({ name: 'versioned-plugin/plugin.mjs' })
     await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(composition).href } })
+    await ctx.loader.await()
 
     const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: { messages: [] }, signal: SIGNAL })
     expect(prepared.fields.dsh_plugin_packages?.packages).toEqual([

+ 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: 6ef3374a5f1ae6b83005073f80d63d156ee92f9c
-README.zh.md: a1be6d634e592126711befad4ffcb06ca0bdc26c
+README.md: ff03d71ddd9360846597101026e5e8662fc2a370
+README.zh.md: 2d96b0948628f6a7c034b48d683fcfe737ae7d78

+ 1 - 1
packages/preset/agent-presets/README.md

@@ -126,7 +126,7 @@ This section explains the design behind the roster and the standing mount; obser
 
 ### The mount audit
 
-A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit.
+A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects an import or activation failure, an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit.
 
 ### Authoring mechanics
 

+ 1 - 1
packages/preset/agent-presets/README.zh.md

@@ -126,7 +126,7 @@ agent-presets:
 
 ### 挂载审计
 
-直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。
+直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝导入或激活失败、无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。
 
 ### 创作机制
 

+ 12 - 8
packages/preset/agent-presets/src/mount.ts

@@ -295,24 +295,28 @@ export function serviceForAgent<K extends string & keyof Context>(
 /**
  * Rows that did not reach a usable state, each rendered as one diagnostic line.
  *
- * A row whose module failed to import or whose plugin threw already rejects the
- * mount through the loader; what remains observable here is a row still waiting
- * for a service the composition never supplies.
+ * Wait for the subtree, then report import failures, activation failures, and
+ * rows waiting for services the composition does not supply.
  * @param tree - the mounted subtree.
  * @returns one line per unusable row, empty when every enabled row is usable.
  */
-export function inactiveRows(tree: EntryTree): string[] {
+export async function inactiveRows(tree: EntryTree): Promise<string[]> {
+  await tree.await()
   const lines: string[] = []
   for (const entry of tree.entries()) {
     if (entry.disabled) continue
     const fiber = entry.fiber
-    /* v8 ignore next 4 -- the loader rejects an entry whose module or plugin failed,
-       so a settled tree never holds an enabled fiber-less entry; the branch exists
-       only because `Entry.fiber` is declared optional. */
     if (fiber === undefined) {
       lines.push(`${entry.options.id} (${entry.options.name}): never started`)
       continue
     }
+    try {
+      await fiber.await()
+    } catch (error) {
+      const detail = error instanceof Error ? error.message : String(error)
+      lines.push(`${entry.options.id} (${entry.options.name}): ${detail}`)
+      continue
+    }
     const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined)
     if (missing.length > 0) {
       lines.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`)
@@ -400,7 +404,7 @@ export async function mountPreset(agentCtx: Context, preset: AgentPreset): Promi
     /* v8 ignore next -- the subclass constructor runs before `await()` settles for every mounted tree */
     if (subtree === undefined) throw new Error('mounted subtree did not publish its entry tree')
     const { tree, fiber } = subtree
-    const unusable = inactiveRows(tree)
+    const unusable = await inactiveRows(tree)
     if (unusable.length > 0) {
       throw new Error(`${String(unusable.length)} row(s) did not activate:\n${unusable.join('\n')}`)
     }

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

@@ -255,20 +255,13 @@ describe('rejecting a composition that cannot be used', () => {
   })
 
   it('names every failed row, not just the count', async () => {
-    // The Loader folds several failed rows into one AggregateError whose own
-    // message names none of them; unflattened, the operator is told only that
-    // "loader entries failed to apply" and has nothing to act on.
     await expect(agentOn(ctx, 'sess-two-broken', 'two-broken'))
       .rejects.toThrow(/first-refuses[\s\S]*second-refuses/)
   })
 
   it('names the rows inside a failed group, not the group alone', async () => {
-    // The Loader's per-row wrapper keeps only `cause.message`, so a group's
-    // own AggregateError arrives with its `errors` reachable through `cause`
-    // alone. Reading the message stops at "loader entries failed to apply"
-    // and names neither row that actually refused.
     await expect(agentOn(ctx, 'sess-nested-broken', 'nested-broken'))
-      .rejects.toThrow(/outer[\s\S]*inner-first[\s\S]*inner-second/)
+      .rejects.toThrow(/inner-first[\s\S]*inner-second/)
   })
 
   it('names the unresolved service when a row never activates', async () => {

+ 1 - 0
packages/todo/tool-todo/tests/loader-composition.spec.ts

@@ -87,6 +87,7 @@ async function boot(configLines: readonly string[]): Promise<Context> {
   } as unknown as NonNullable<typeof ctx.loader.internal>
   await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
   await ctx.loader.await()
+  for (const entry of ctx.loader.entries()) await entry.fiber?.await()
   return ctx
 }
 

+ 3 - 3
pnpm-lock.yaml

@@ -1255,6 +1255,9 @@ importers:
       '@deepseek-ai/dsh-package-manifest':
         specifier: workspace:^
         version: link:../../util/package-manifest
+      chokidar:
+        specifier: 4.0.3
+        version: 4.0.3
       js-yaml:
         specifier: ^4.2.0
         version: 4.2.0
@@ -1292,9 +1295,6 @@ importers:
       '@types/js-yaml':
         specifier: ^4.0.9
         version: 4.0.9
-      chokidar:
-        specifier: 4.0.3
-        version: 4.0.3
 
   packages/boot/cmdline:
     devDependencies:

+ 4 - 0
pnpm-workspace.yaml

@@ -14,6 +14,10 @@ packages:
   # closure is what the exe bundles and what the Python runtime distributes.
   - python/sdk-runtime
 
+# Vendored framework packages keep their upstream semver ranges, while local
+# builds must resolve those matching names to this workspace's pinned sources.
+linkWorkspacePackages: true
+
 overrides:
   '@deepseek-ai/cosmokit': 'link:vendor/cosmokit'
   '@deepseek-ai/schemastery': 'link:vendor/schemastery'

+ 72 - 0
scripts/verify-vendored-links.ts

@@ -0,0 +1,72 @@
+/**
+ * Verify that pnpm-lock.yaml resolves every vendored package name to its
+ * workspace `link:` — never a registry copy. `linkWorkspacePackages: true`
+ * (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the
+ * pinned vendored sources; a registry copy of the same name coexisting with
+ * the vendored one silently forks the framework layer (vendor/README.md).
+ */
+import { readdir, readFile } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import * as yaml from 'js-yaml'
+
+const root = resolve(import.meta.dirname, '..')
+
+async function vendoredNames(): Promise<Set<string>> {
+  const names = new Set<string>()
+  for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
+    if (!entry.isDirectory()) continue
+    let manifest: { name?: string }
+    try {
+      manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string }
+    } catch {
+      continue // not a package directory (e.g. vendor/README.md siblings)
+    }
+    if (manifest.name !== undefined) names.add(manifest.name)
+  }
+  return names
+}
+
+interface Lockfile {
+  importers?: Record<string, Record<string, unknown>>
+  packages?: Record<string, unknown>
+  snapshots?: Record<string, unknown>
+}
+
+const names = await vendoredNames()
+if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/')
+const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile
+
+const violations: string[] = []
+
+// Importer resolutions: every dependency entry naming a vendored package must
+// resolve to a link:, or the build silently uses a registry copy.
+for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) {
+  for (const [section, dependencies] of Object.entries(sections)) {
+    if (typeof dependencies !== 'object' || dependencies === null) continue
+    for (const [dependency, entry] of Object.entries(dependencies as Record<string, { version?: string }>)) {
+      if (!names.has(dependency)) continue
+      const version = entry.version ?? ''
+      if (!version.startsWith('link:')) {
+        violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`)
+      }
+    }
+  }
+}
+
+// Package/snapshot keys: a registry copy materializes as a `<name>@<version>`
+// key; vendored names must never appear there at all.
+for (const section of ['packages', 'snapshots'] as const) {
+  for (const key of Object.keys(lockfile[section] ?? {})) {
+    const atIndex = key.lastIndexOf('@')
+    if (atIndex <= 0) continue
+    const packageName = key.slice(0, atIndex)
+    if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`)
+  }
+}
+
+if (violations.length > 0) {
+  console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`)
+  for (const violation of violations) console.error(`  - ${violation}`)
+  process.exit(1)
+}
+console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`)

+ 9 - 9
vendor/README.md

@@ -2,7 +2,7 @@
 
 This directory contains source-vendored copies of the Cordis framework and its foundation libraries. They are copied into this monorepo instead of being depended on via npm, so that the harness fully owns its framework layer (auditable, patchable, pinned).
 
-All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory.
+All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. The `hygiene` gate `verify-vendored-links` asserts every vendored name resolves to a workspace `link:` in `pnpm-lock.yaml` with no registry copy alongside. Schemastery's manifest additionally declares a conditional `exports` map (import → `.mjs`, require → `.cjs`): pnpm links the directory itself, so without `exports` Node's ESM resolver would fall back to `main` and load the CJS entry whose lazy `require('@deepseek-ai/cosmokit')` can race ESM loading of the same linked module under module-hook hosts (vitest). Upstream MIT `LICENSE` files are preserved in each package directory.
 
 This file covers the manifest, the local-modification log, and the procedure for **updating** an existing vendored package. To **add a new** one, see the cookbook guide: [docs/cookbook/adding-a-vendored-package.md](../docs/cookbook/adding-a-vendored-package.md).
 
@@ -33,17 +33,17 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions.
 2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges are preserved except that `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency, and `loader` requires `node-addon-require-builtin@^0.1.4` to match the runtime used by published app packages.
 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
-4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
+4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
-7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
-8. **Transactional Loader/Include config reconciliation**: Loader imports a changed entry name before disposal, awaits lifecycle settlement, and restores the previous plugin or config when candidate application fails. Loader settlement rechecks service-gated fibers after current tasks drain, rejects failures, and leaves fibers with absent dependencies pending. Group updates run sequentially and undo changes and additions on live-update failure, await removal, preserve programmatic option identity, and persist direct or tree-level mutations only after success. Include reads and validates detached candidate content, applies patches to a clone, reconciles the tree, and only then commits its cached content/data; direct refresh failures propagate for the caller to contain. A non-array parse is invalid, patches re-apply on every file or Include-config update, and initial content falls back to `initial` only on `ENOENT`. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
-9. **`hmr/src/index.ts` exact config watching**: `registerConfig()` watches one absolute config path outside module roots, including a path under missing parents, serializes and coalesces refreshes, and returns an async disposer that closes the watcher and drains active work. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. Refresh failures are normalized to `Error`, logged, and broadcast through the parallel `hmr/config-update-failed` event; observer failures are contained. Config-file changes discovered by the ordinary HMR watcher use the same serialized path. Covered by `packages/boot/app-boot/tests/hmr-config.spec.ts`.
-10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
-11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
-12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`.
+7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
+8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
+9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/app-boot/src/watch-config.ts`. Covered by `packages/boot/app-boot/tests/watch-config.spec.ts`.
+10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. HMR declares the same Loader and Timer injections with `static inject` instead of decorators. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions.
+11. **`include/src/index.ts` patch-semantics export**: extracted the private `applyPatches` body into the exported pure function `applyEntryPatches(data, patches, warn)` (the method delegates to it) and exported the `!!js` YAML dialect as `entryListSchema`, so `dsh --dump-config` composes and prints exactly what the include would mount without booting a tree. The entry list is deep-cloned even without patches so Loader mutations cannot alter the cached parse. Public patch and Include fields carry JSDoc for config tooling. Behavior-preserving for mounting; the extraction exists because config tooling must never reimplement (and drift from) the patch algorithm. `applyEntryPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/boot/app-boot/tests/config-reload.spec.ts`.
+12. **`cordis/src/{events,fiber}.ts` and `loader/src/{index,config/entry,config/isolate}.ts` update completion**: `Fiber.update()` returns its update-waterfall result. Entry config updates forward and await that result, while the isolate listener forwards it after its synchronous context changes. This prevents asynchronous restart failures from escaping their callers without adding rollback. The detached `Entry.init()` completion observer handles both outcomes; the fiber retains its activation error for explicit audits. Covered by `packages/boot/app-boot/tests/user-patches.spec.ts` and `packages/host/webserver/tests/webserver.spec.ts`.
 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change.
-14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
+14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained writes before and after child removal during Include teardown. The first drain preserves an existing terminal write failure even if child removal schedules a later write. Missing-file initialization awaits the write and forces a fresh parse before mounting the initial entries. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures.
 15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`.
 16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.
 17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).

+ 1 - 1
vendor/cordis/src/events.ts

@@ -340,7 +340,7 @@ export interface Events {
   /** Interception hook for a service binding (no core producer). */
   'internal/service'(this: Context, name: string, value: any): void
   /** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
-  'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
+  'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void | Promise<void>): void | Promise<void>
   /** Waterfall: a service is being read through the context proxy. */
   'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
   /** Waterfall: a service is being written through the context proxy. */

+ 3 - 3
vendor/cordis/src/fiber.ts

@@ -730,8 +730,8 @@ export class Fiber {
    *
    * @param config — the new raw config; validated before anything restarts.
    * @param noSave — hint for persistence hooks not to write the change back.
-   * @returns nothing; the restart runs behind the `internal/update` waterfall.
-   * @throws {ValidationError} when the new config fails validation.
+   * @returns the update waterfall result; the default restart returns a promise.
+   * @throws when validation, an update listener, or the restarted plugin fails.
    */
   update(config: any, noSave = false) {
     this.assertActive()
@@ -745,7 +745,7 @@ export class Fiber {
       return
     }
     config = this._resolveConfig(config)
-    this.context.waterfall(this, 'internal/update', config, noSave, () => {
+    return this.context.waterfall(this, 'internal/update', config, noSave, () => {
       this.config = config
       this._error = undefined
       return this.restart()

+ 0 - 1
vendor/hmr/src/index.ts

@@ -20,7 +20,6 @@ declare module '@deepseek-ai/cordis' {
   interface Events {
     'hmr/change'(url: string): void
     'hmr/reload'(reloads: Map<Plugin, Reload>): void
-
   }
 }
 

+ 11 - 6
vendor/include/src/index.ts

@@ -44,7 +44,8 @@ function retryableWriteError(error: unknown): boolean {
  * Apply patch lists to an entry list — THE patch semantics of this include,
  * shared by mounting (`applyPatches`) and offline config tooling
  * (`dsh --dump-config`) so a dump can never drift from what boots. The input
- * is never mutated: patching shared entry objects would bake earlier patch
+ * is never mutated and the result is always detached from it (even with no
+ * patches): patching or mounting shared entry objects would bake earlier
  * values into the cached parse, so repeated application (config hot-reloads)
  * could never revert a removed or changed patch. Inserted entries are indexed
  * as they are added, so a later patch in the same list can target a row an
@@ -59,8 +60,8 @@ export function applyEntryPatches(
   patches: PatchOptions[] | undefined,
   warn: (message: string, ...args: any[]) => void,
 ): EntryOptions[] {
-  if (!patches?.length) return [...data]
   data = structuredClone(data)
+  if (!patches?.length) return data
 
   const entryMap = new Map<string, EntryOptions>()
   const buildMap = (entries: EntryOptions[]) => {
@@ -251,8 +252,8 @@ export class Include extends EntryTree {
       // never be mislabelled as absent or silently overwritten.
       if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') throw error
       if (this.config.initial) {
-        this.writeFile(this.config.initial as any)
-        await this.read()
+        await this._writeFile(this.config.initial as any)
+        await this.read(true)
       } else {
         throw new Error(`config file not found: ${this.filename}`)
       }
@@ -263,8 +264,12 @@ export class Include extends EntryTree {
   }
 
   async stop() {
-    await this.root.stop()
-    await this.flushWrite()
+    try {
+      await this.flushWrite()
+    } finally {
+      this.root.stop()
+      await this.flushWrite()
+    }
   }
 
   /**

+ 6 - 5
vendor/loader/src/config/entry.ts

@@ -96,11 +96,11 @@ export class Entry {
   }
 
   private _patchContext(diff: string[]) {
-    this.context.waterfall('loader/patch-context', this, () => {
+    return this.context.waterfall('loader/patch-context', this, () => {
       Object.setPrototypeOf(this.ctx, this.parent.ctx)
 
       if (this.fiber?.uid && (diff.includes('config') || this.options.group)) {
-        this.fiber.update(this.options.config, true)
+        return this.fiber.update(this.options.config, true)
       }
     })
   }
@@ -142,7 +142,7 @@ export class Entry {
         .filter(key => !deepEqual(this.options[key], legacy[key]))
       if (!diff.length && !force) return
       this.context.emit('loader/partial-dispose', this, legacy, true)
-      this._patchContext(diff)
+      await this._patchContext(diff)
     } else {
       await this.init()
     }
@@ -165,10 +165,11 @@ export class Entry {
     } finally {
       this._initTask = undefined
     }
-    this.fiber?.await().finally(() => {
+    const notify = () => {
       if (this.loader.getTasks().length) return
       this.ctx.reflect.notify(['loader'])
-    })
+    }
+    this.fiber?.await().then(notify, notify)
   }
 
   private async _init() {

+ 2 - 1
vendor/loader/src/config/isolate.ts

@@ -126,7 +126,7 @@ export default function isolate(ctx: Context) {
     swap(entry.ctx[Context.intercept], entry.options.intercept)
 
     // step 4: reload fiber
-    next()
+    const result = next()
 
     // step 5: replace service impl
     for (const [symbol1, symbol2, flag1, flag2] of Object.values(diff)) {
@@ -150,6 +150,7 @@ export default function isolate(ctx: Context) {
         delete entry.ctx[delims[name]]
       }
     }
+    return result
   })
 
   ctx.on('loader/partial-dispose', (entry, legacy, active) => {

+ 1 - 1
vendor/loader/src/config/utils.ts

@@ -8,7 +8,7 @@ export const evaluate = new Function('ctx', 'expr', `
   }
 `) as ((ctx: object, expr: string) => any)
 
-/** Recursively replace YAML `!js` expression nodes with evaluated values. */
+/** Recursively replace YAML `!!js` expression nodes with evaluated values. */
 export function interpolate(ctx: object, value: any) {
   if (isJsExpr(value)) {
     return evaluate(ctx, value.__jsExpr)

+ 1 - 1
vendor/loader/src/index.ts

@@ -26,7 +26,7 @@ declare module '@deepseek-ai/cordis' {
     'loader/config-update'(): void
     'loader/entry-init'(entry: Entry): void
     'loader/partial-dispose'(entry: Entry, legacy: Partial<EntryOptions>, active: boolean): void
-    'loader/patch-context'(entry: Entry, next: () => void): void
+    'loader/patch-context'(entry: Entry, next: () => void | Promise<void>): void | Promise<void>
   }
 
   interface Context {

+ 9 - 0
vendor/schemastery/package.json

@@ -14,6 +14,15 @@
   "main": "lib/index.cjs",
   "module": "lib/index.mjs",
   "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "import": "./lib/index.mjs",
+      "require": "./lib/index.cjs"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
   "files": [
     "lib/index.mjs",
     "lib/index.cjs",