Pārlūkot izejas kodu

refactor(hmr): own profile reload lifecycle through YAML

Turtle 1 mēnesi atpakaļ
vecāks
revīzija
abd765a600
72 mainītis faili ar 595 papildinājumiem un 571 dzēšanām
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  4. 2 2
      apps/cli/README.i18n.yaml
  5. 1 1
      apps/cli/README.md
  6. 1 1
      apps/cli/README.zh.md
  7. 2 2
      apps/cli/reference/README.i18n.yaml
  8. 4 4
      apps/cli/reference/README.md
  9. 4 4
      apps/cli/reference/README.zh.md
  10. 5 82
      apps/cli/src/profile-boot.ts
  11. 20 18
      apps/cli/tests/built-bin.e2e.ts
  12. 16 17
      apps/cli/tests/profile-hmr.spec.ts
  13. 3 4
      apps/cli/tests/profile-initialization.spec.ts
  14. 1 2
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  15. 3 14
      apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
  16. 0 1
      apps/cli/tests/web-agent-presets.e2e.ts
  17. 3 0
      apps/desktop-host/config/desktop.cordis.patch.yml
  18. 0 1
      apps/web/tests/scaffold.ts
  19. 2 2
      docs/architecture.i18n.yaml
  20. 1 1
      docs/architecture.md
  21. 1 1
      docs/architecture.zh.md
  22. 1 1
      docs/config-catalog.i18n.yaml
  23. 1 1
      docs/config-catalog.md
  24. 2 2
      docs/event-producer-consumer.i18n.yaml
  25. 3 3
      docs/event-producer-consumer.md
  26. 3 3
      docs/event-producer-consumer.zh.md
  27. 2 2
      docs/module-graph.i18n.yaml
  28. 4 3
      docs/module-graph.md
  29. 4 3
      docs/module-graph.zh.md
  30. 2 2
      packages/boot/app-boot/README.i18n.yaml
  31. 3 3
      packages/boot/app-boot/README.md
  32. 2 2
      packages/boot/app-boot/README.zh.md
  33. 0 8
      packages/boot/app-boot/package.json
  34. 8 52
      packages/boot/app-boot/src/index.ts
  35. 0 1
      packages/boot/app-boot/src/profile-context.ts
  36. 6 32
      packages/boot/app-boot/src/profile.ts
  37. 0 1
      packages/boot/app-boot/tests/profile-resolution.spec.ts
  38. 2 51
      packages/boot/app-boot/tests/profile.spec.ts
  39. 22 170
      packages/boot/app-boot/tests/user-patches.spec.ts
  40. 0 3
      packages/boot/app-boot/tsconfig.json
  41. 2 2
      packages/boot/hmr/README.i18n.yaml
  42. 3 3
      packages/boot/hmr/README.md
  43. 3 3
      packages/boot/hmr/README.zh.md
  44. 8 3
      packages/boot/hmr/package.json
  45. 37 3
      packages/boot/hmr/src/index.ts
  46. 1 2
      packages/boot/hmr/tests/modules.spec.ts
  47. 152 0
      packages/boot/hmr/tests/profile.spec.ts
  48. 161 0
      packages/boot/hmr/tests/reconciliation.spec.ts
  49. 9 0
      packages/boot/hmr/tsconfig.json
  50. 2 2
      packages/boot/plugin-manager/README.i18n.yaml
  51. 1 1
      packages/boot/plugin-manager/README.md
  52. 1 1
      packages/boot/plugin-manager/README.zh.md
  53. 14 9
      packages/boot/plugin-manager/src/index.ts
  54. 1 1
      packages/boot/plugin-manager/src/operations.ts
  55. 28 7
      packages/boot/plugin-manager/tests/manager.spec.ts
  56. 2 2
      packages/bundle/acp-app/README.i18n.yaml
  57. 1 1
      packages/bundle/acp-app/README.md
  58. 1 1
      packages/bundle/acp-app/README.zh.md
  59. 3 0
      packages/bundle/acp-app/cordis.patch.yml
  60. 1 1
      packages/bundle/acp-app/tests/acp-app.spec.ts
  61. 3 4
      packages/bundle/base/cordis.patch.yml
  62. 1 2
      packages/bundle/base/tests/base.spec.ts
  63. 3 0
      packages/bundle/headless/cordis.patch.yml
  64. 2 2
      packages/bundle/sdk-app/README.i18n.yaml
  65. 1 1
      packages/bundle/sdk-app/README.md
  66. 1 1
      packages/bundle/sdk-app/README.zh.md
  67. 3 0
      packages/bundle/sdk-app/cordis.patch.yml
  68. 1 1
      packages/bundle/sdk-app/tests/sdk-app.spec.ts
  69. 0 1
      packages/util/package-manifest/src/index.ts
  70. 0 5
      packages/util/package-manifest/src/types.ts
  71. 9 6
      pnpm-lock.yaml
  72. 1 1
      scripts/verify-default-product-isolation.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
-2026-09-14-current-profile-plugin-management.md: 1234007022bb0ac5e9247a0387d03ed9a4e94cdc
-2026-09-14-current-profile-plugin-management.zh.md: 0d6059089c51379bad3b99bbcbe533afa8ac4c26
+2026-09-14-current-profile-plugin-management.md: 194e3a36bfd3c1fd09b9a3d091e3cd39f47d8814
+2026-09-14-current-profile-plugin-management.zh.md: 207f0d90ba64363dbfab45978b9390d36db9ef75

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md

@@ -10,11 +10,11 @@ Web and agent controls need to change a running profile without creating an inde
 
 ## Decision
 
-[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles, reload policy and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. The launcher registers the `hmr/before-reload` file-lock wrapper before plugin startup. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
+[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. HMR registers the profile watches and `hmr/before-reload` file-lock wrapper during its own initialization, then waits for application readiness before processing edits. The final YAML composition controls whether HMR runs; the launcher installs no fallback. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
 
 Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
 
-This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
+This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Profiles without HMR keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md

@@ -10,7 +10,7 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 ## 决策
 
-[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包、重载策略和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。launcher 在插件启动前注册 `hmr/before-reload` 文件锁包装。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
+[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。HMR 在自身初始化时注册 profile 监听与 `hmr/before-reload` 文件锁包装,等待应用就绪后再处理编辑。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
 
 profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
 

+ 2 - 2
apps/cli/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 apps/cli/README.md
-README.md: 0f52e6afbe32a6be44f66afa11e5da1f4795f24c
-README.zh.md: 7fa8a27ed546adda1e54eab24dc9ac17a322b0cb
+README.md: e07a6109d819852a238043aa60eb1d101caf39a7
+README.zh.md: 232a96238f1bdb6af0949ae02776daf313d725f8

+ 1 - 1
apps/cli/README.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profiles
 
-A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload; `startup` applies them once. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections.
+A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections.
 
 The tree composes over an empty root:
 - each bundle's patch in `dsh.profile.bundles` order

+ 1 - 1
apps/cli/README.zh.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profile
 
-profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层;`startup` 则只应用一次。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。
+profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。
 
 配置树以空根为起点,依次叠加以下配置层:
 - `dsh.profile.bundles` 中各组合包的 patch

+ 2 - 2
apps/cli/reference/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 apps/cli/reference/README.md
-README.md: b2931e05c1c10c6de13427b2cdaf38a0e78db904
-README.zh.md: 2a9014a5d338c3d81d9976d8cb47474a95c45cb5
+README.md: dcf9f6e3ad2304bf13a5497b9252638fdef2638f
+README.zh.md: c1138bbed8a884757d97c445061bb1686f47b44c

+ 4 - 4
apps/cli/reference/README.md

@@ -6,13 +6,13 @@ This reference defines the profile, web-alias, plugin-management, and config-dum
 
 ## Profile boot
 
-`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. `dsh.profile.patchReload` selects `live` patch-file watching or `startup` one-time loading; omission defaults a custom profile to `live`. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
+`dsh --profile <name>` boots the profile at `$DSH_HOME/profiles/<name>`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
 
 Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode.
 
 The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile <name> add <package>`.
 
-`dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list and `patchReload` value into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
+`dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
 
 An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
 
@@ -25,7 +25,7 @@ dsh --profile rescue
 
 The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
 
-A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. In a `patchReload: live` profile, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
+A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. With HMR enabled, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
 
 Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments.
 
@@ -90,7 +90,7 @@ The production Web runner needs built package and frontend artifacts (`pnpm run
 
 Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
 
-The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. A `patchReload: live` profile watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a `startup` profile applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
+The base-backed modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. The standalone `sdk-minimal` profile uses the invoking directory as its sandbox-policy root but intentionally omits filesystem tools, instruction discovery, and SQLite. An enabled HMR plugin watches both `cordis.patch.yml` layers (profile and home) and reapplies valid edits transactionally; without HMR, the process applies them once. A one-shot surface exits through its bounded shutdown, which disposes any live watchers.
 
 New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service.
 

+ 4 - 4
apps/cli/reference/README.zh.md

@@ -8,13 +8,13 @@
 
 ## Profile 启动
 
-`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
+`dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
 
 组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。
 
 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
 
-`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的组合包列表和 `patchReload` 值复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
+`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的组合包列表复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
 
 profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
 
@@ -27,7 +27,7 @@ dsh --profile rescue
 
 启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
 
-每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在 `patchReload: live` profile 中,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
+每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。启用 HMR 时,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
 
 启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。
 
@@ -92,7 +92,7 @@ dsh web --help
 
 进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
 
-基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。`patchReload: live` profile 会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;`startup` profile 则只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。
+基于 base 的模式都将运行命令时所在的目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。独立的 `sdk-minimal` profile 把运行命令时所在的目录作为沙箱策略根目录,但刻意省略文件系统工具、指令发现与 SQLite。启用的 HMR 插件会监视 profile 与 home 两个 `cordis.patch.yml` 配置层的有效变更,并以事务方式重新应用;未启用 HMR 时只应用一次。一次性运行模式通过有界关闭流程退出,该流程会 dispose 所有实时监视器。
 
 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。
 

+ 5 - 82
apps/cli/src/profile-boot.ts

@@ -2,8 +2,7 @@
  * Shared profile boot for every `dsh` surface: resolve the profile, stack its
  * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
  * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
- * tree over the profile's empty root config, apply its selected patch-reload
- * lifecycle, and wire fail-loud plus bounded shutdown.
+ * tree over the profile's empty root config, and wire fail-loud plus bounded shutdown.
  *
  * App flags are not the launcher's business: the invocation's inner arguments
  * are provided to the tree through `ctx.cmdlineArgs`, where any injected app
@@ -11,11 +10,9 @@
  * @module @deepseek-ai/dsh/profile-boot
  */
 
-import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
 import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import type {} from '@deepseek-ai/dsh-hmr'
-import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import { FiberState, type Context } from '@deepseek-ai/cordis'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import {
@@ -31,7 +28,6 @@ import {
   PROFILE_PATCH_FILENAME,
   PROFILE_TEMPLATES,
   resolveProfileDir,
-  reconcileProfilePatches,
   type ProfileContext,
   type Profile,
   type ProfileResolutionGeneration,
@@ -94,7 +90,7 @@ export const PROFILE_ROOT_FILENAME = 'cordis.yml'
 
 /**
  * Initialize a missing profile from one shipped template. This copies only
- * the template's bundle list and patch-reload policy; local state from the
+ * the template's bundle list; local state from the
  * same-named shipped profile is not read, and no inheritance metadata is
  * persisted. Shipped profile names are reserved, and the target directory is
  * claimed exclusively so existing or concurrent state is never reused.
@@ -141,7 +137,7 @@ export function initializeProfileFromDefault(
     )
   }
   try {
-    initProfile(dir, template.bundles, template.patchReload)
+    initProfile(dir, template.bundles)
   } catch (error) {
     try {
       rmSync(dir, { recursive: true, force: true })
@@ -227,22 +223,6 @@ export interface RunProfileOptions {
   resolutionMode?: ProfileResolutionMode
 }
 
-/**
- * Re-throw a watcher-setup failure unless a shutdown already owns the tree:
- * a signal aborted this invocation, or an app requested exit (`ctx.appExit`
- * from a fast one-shot) and the root's disposal rejected the in-flight setup
- * await. Either way the failure describes a tree that is exiting as asked,
- * not a broken watch.
- * @param ctx - the booted root context.
- * @param signal - this invocation's signal-shutdown fact.
- * @param error - the setup failure.
- */
-function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
-  if (signal.aborted) return
-  if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
-  throw error
-}
-
 /**
  * Boot one profile invocation end to end and leave process lifetime to the
  * mounted plugins (or to a one-shot runner the composition mounts).
@@ -286,28 +266,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   })
 
   const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
-  const manifestPath = join(composed.profile.dir, 'package.json')
-  const watchedFiles = [composed.profile.patchPath, homePatchPath(), manifestPath]
-  const readInputs = (): string => JSON.stringify(watchedFiles.map((filename) => {
-    try {
-      return readFileSync(filename, 'utf8')
-    } catch (error) {
-      if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
-      throw error
-    }
-  }))
-  let lastInputs = readInputs()
   const profileContext: ProfileContext = {
     name: options.profile,
     dir: composed.profile.dir, patchPath: composed.profile.patchPath, installAnchor: INSTALL_ANCHOR,
     startedBundles: composed.profile.layers.map(layer => layer.packageName),
-    cwd: process.cwd(), home: resolveDshHome(), patchReload: composed.profile.patchReload,
+    cwd: process.cwd(), home: resolveDshHome(),
     overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
   }
   const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext), async (hostCtx) => {
     app.current = hostCtx
     hostCtx.provide('profileContext', profileContext)
-    hostCtx.on('hmr/before-reload', next => withFileLock(manifestPath, next))
     // Before any config-tree entry mounts, so plugins resolve all launch-time
     // environment values from the same immutable launch snapshot.
     hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
@@ -324,51 +292,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     })
   })
   app.current = ctx
-  // A live-reload profile can dispose the whole tree while post-boot watcher
-  // setup is in flight — a signal or appExit. Loader presence and fiber state
-  // own liveness; the initial check skips a tree that already exited, and the
-  // catch below re-checks for an exit that landed mid-setup. Startup-frozen
-  // profiles apply every user layer above but install no HMR fallback or watcher.
-  if (composed.profile.patchReload === 'live'
-    && !signalShutdown.signal.aborted
-    && ctx.fiber.state === FiberState.ACTIVE
-    && ctx.get('loader') !== undefined) {
-    try {
-      // Config-only HMR for the live profile patch layer: dsh-base disables
-      // module reload by default, so when no profile explicitly enabled that
-      // service, mount a watch-only instance with no module roots —
-      // cordis.patch.yml edits stay live without replacing source modules. A
-      // silent skip would break the documented reload contract. HMR injects
-      // the timer service, which a bare custom profile may not mount either.
-      if (ctx.get('hmr') === undefined) {
-        if (ctx.get('timer') === undefined) {
-          await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
-        }
-        await ctx.loader.create({ name: '@deepseek-ai/dsh-hmr', config: { root: [] } })
-        await ctx.loader.await()
-      }
-      const refresh = async (): Promise<void> => {
-        if (readInputs() === lastInputs) return
-        lastInputs = readInputs()
-        await reconcileProfilePatches(ctx, readProfilePatches(NAME, profileContext), NAME)
-      }
-      const watching = ctx.inject(['hmr'], async (owner) => {
-        for (const filename of watchedFiles) {
-          await owner.effect(() => owner.hmr.watchConfig(filename, refresh))
-        }
-      })
-      await watching.await()
-      // Include writes that finished while the file watchers were registering.
-      try {
-        await ctx.hmr.runExclusive(() => withFileLock(manifestPath, refresh))
-      } catch (error) {
-        ctx.logger.warn('profile reload failed')
-        ctx.logger.warn(error)
-      }
-    } catch (error) {
-      suppressShutdownError(ctx, signalShutdown.signal, error)
-    }
-  }
   if (!signalShutdown.signal.aborted
     && ctx.fiber.state === FiberState.ACTIVE
     && ctx.get('loader') !== undefined) {

+ 20 - 18
apps/cli/tests/built-bin.e2e.ts

@@ -115,6 +115,12 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture {
   ].join('\n'))
   writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
     '- insert:',
+    '    - id: hmr-timer',
+    "      name: '@deepseek-ai/cordis-plugin-timer'",
+    '    - id: hmr',
+    "      name: '@deepseek-ai/dsh-hmr'",
+    '      config:',
+    '        root: []',
     '    - id: profile-lifecycle-fixture',
     `      name: ${pathToFileURL(join(bundleDir, 'plugin.mjs')).href}`,
     '',
@@ -279,6 +285,12 @@ function createStartupFixture(): StartupFixture {
   ].join('\n'))
   writeFileSync(join(bundleDir, 'cordis.patch.yml'), [
     '- insert:',
+    '    - id: hmr-timer',
+    "      name: '@deepseek-ai/cordis-plugin-timer'",
+    '    - id: hmr',
+    "      name: '@deepseek-ai/dsh-hmr'",
+    '      config:',
+    '        root: []',
     '    - id: startup-fixture',
     `      name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`,
     '      inject: [fixtureStartup]',
@@ -666,12 +678,11 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       const dir = join(home, 'profiles', 'rescue')
       const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
         dependencies: Record<string, string>
-        dsh: { profile: { bundles: string[]; patchReload: string } }
+        dsh: { profile: { bundles: string[] } }
       }
       expect(manifest.dependencies).toEqual({})
       expect(manifest.dsh.profile).toEqual({
         bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
-        patchReload: 'live',
       })
       expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('[]')
       expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
@@ -802,12 +813,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     const configFile = join(fixture.home, 'config-echo')
     try {
       await waitForFile(fixture.settled)
-      // The live profile layer: even without an hmr row in the composition,
-      // the launcher mounts a config-only watcher, so an edited
-      // cordis.patch.yml lands in the running tree (the reload disposes the
-      // patched row's old fiber — observable as the disposed marker — and
-      // mounts the new config, which echoes its generation and re-writes the
-      // ready marker).
+      // The YAML HMR entry applies the patch and awaits the replaced plugin.
       rmSync(fixture.ready)
       writeFileSync(profilePatch, [
         '- id: profile-lifecycle-fixture',
@@ -909,16 +915,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       + "\nexport const inject = ['hmr', 'appReady']\n"
     writeFileSync(source, original)
     const hmrPatch = [
-      '- insert:',
-      '    - id: hmr-timer',
-      "      name: '@deepseek-ai/cordis-plugin-timer'",
-      '    - id: hmr',
-      "      name: '@deepseek-ai/dsh-hmr'",
-      '      config:',
-      `        root: [${JSON.stringify(join(fixture.home, 'lifecycle-bundle'))}]`,
-      '        ignored: []',
-      '        usePolling: true',
-      '        debounce: 0',
+      '- id: hmr',
+      '  config:',
+      `    root: [${JSON.stringify(join(fixture.home, 'lifecycle-bundle'))}]`,
+      '    ignored: []',
+      '    usePolling: true',
+      '    debounce: 0',
       '',
     ].join('\n')
     const patch = join(dir, 'cordis.patch.yml')

+ 16 - 17
apps/cli/tests/profile-hmr.spec.ts

@@ -20,27 +20,26 @@ function hmr(layers: PatchOptions[][]) {
   return row
 }
 
-describe('profile module-HMR policy', () => {
-  it.each(['web-app', 'headless', 'sdk-app', 'acp-app'] as const)(
-    '%s inherits the disabled base row without a mode override',
-    (mode) => {
-      const modePatches = bundle(mode)
-      expect(modePatches.some(patch => patch.id === 'hmr')).toBe(false)
-      expect(hmr([bundle('base'), modePatches])).toMatchObject({
-        disabled: true,
-        config: { root: ['.'] },
-      })
-    },
-  )
-
-  it('requires an explicit later layer to enable source-module reload', () => {
-    expect(hmr([bundle('base'), [{ id: 'hmr', disabled: false }]])).toMatchObject({
-      disabled: false,
+describe('YAML-owned profile HMR', () => {
+  it('enables configuration watching in the base and web compositions', () => {
+    expect(hmr([bundle('base'), bundle('web-app')])).toMatchObject({ config: { root: [] } })
+    expect(hmr([bundle('base')]).disabled).not.toBe(true)
+  })
+
+  it.each(['headless', 'sdk-app', 'acp-app'] as const)('%s disables HMR with a bundle override', (mode) => {
+    expect(hmr([bundle('base'), bundle(mode)])).toMatchObject({ disabled: true })
+    expect(hmr([bundle('base'), bundle(mode), [{ id: 'hmr', disabled: false }]])).toMatchObject({
+      disabled: false, config: { root: [] },
+    })
+  })
+
+  it('selects module roots through a later YAML layer', () => {
+    expect(hmr([bundle('base'), [{ id: 'hmr', config: { root: ['.'] } }]])).toMatchObject({
       config: { root: ['.'] },
     })
   })
 
-  it('keeps the standalone sdk-minimal tree free of module HMR', () => {
+  it('keeps the standalone sdk-minimal tree free of HMR', () => {
     expect(composeEntries([bundle('sdk-minimal')]).find(entry => entry.id === 'hmr')).toBeUndefined()
   })
 })

+ 3 - 4
apps/cli/tests/profile-initialization.spec.ts

@@ -51,7 +51,7 @@ describe('initializeProfileFromDefault', () => {
           name: 'dsh-profile-custom',
           private: true,
           dependencies: {},
-          dsh: { profile: { bundles: [...template.bundles], patchReload: template.patchReload } },
+          dsh: { profile: { bundles: [...template.bundles] } },
         })
         expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
         expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
@@ -62,7 +62,7 @@ describe('initializeProfileFromDefault', () => {
   it('does not copy the local source profile dependencies or user patch', () => {
     withHome((home) => {
       const sourceDir = resolveProfileDir('web', home)
-      initProfile(sourceDir, ['local-bundle'], 'startup')
+      initProfile(sourceDir, ['local-bundle'])
       const sourceManifest = readProfileManifest('test', sourceDir)
       sourceManifest.dependencies = { 'local-bundle': '1.0.0' }
       writeProfileManifest(sourceDir, sourceManifest)
@@ -75,7 +75,6 @@ describe('initializeProfileFromDefault', () => {
       expect(target.dependencies).toEqual({})
       expect(target.dsh?.profile).toEqual({
         bundles: [...PROFILE_TEMPLATES.web!.bundles],
-        patchReload: PROFILE_TEMPLATES.web!.patchReload,
       })
       expect(readFileSync(join(targetDir, PROFILE_PATCH_FILENAME), 'utf8')).not.toContain('local-only')
     })
@@ -84,7 +83,7 @@ describe('initializeProfileFromDefault', () => {
   it('rejects an existing target without changing its files', () => {
     withHome((home) => {
       const dir = resolveProfileDir('rescue', home)
-      initProfile(dir, ['existing-bundle'], 'startup')
+      initProfile(dir, ['existing-bundle'])
       writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: existing\n  disabled: true\n')
       const paths = ['package.json', PROFILE_PATCH_FILENAME, 'pnpm-workspace.yaml'].map(file => join(dir, file))
       const before = paths.map(path => readFileSync(path))

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

@@ -291,10 +291,9 @@ describe('Python SDK dsh profile keyless smoke', () => {
 
       const profile = JSON.parse(
         await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'),
-      ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } }
+      ) as { dsh?: { profile?: { bundles?: string[] } } }
       expect(profile.dsh?.profile).toEqual({
         bundles: ['@deepseek-ai/dsh-sdk-minimal'],
-        patchReload: 'startup',
       })
       expect(modelRequests[0]?.tools).toEqual(expect.any(Array))
       const tools = modelRequests[0]?.tools as { name?: string }[]

+ 3 - 14
apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts

@@ -339,27 +339,16 @@ describe.skipIf(!built)('Web process failure matrix', () => {
   it('reports a patch failure during watcher registration and accepts a correction', async () => {
     const f = fixture()
     const observerPath = fileURLToPath(f.observer.name)
-    // Write after the last native watch is ready, before the launcher's catch-up
-    // reconciliation. No filesystem callback can run inside this synchronous write.
+    // This sibling mounts after HMR's watchers, before application readiness.
     writeFileSync(observerPath, readFileSync(observerPath, 'utf8').replace(
       'export function apply(ctx, config) {',
       `export const inject = ['hmr']
 export function apply(ctx, config) {
-  const hmr = ctx.hmr
-  const watch = hmr.watchConfig
-  hmr.watchConfig = async function (filename, refresh) {
-    const dispose = await watch.call(this, filename, refresh)
-    if (filename.endsWith('package.json')) {
-      await this.runExclusive(async () => {})
-      writeFileSync(${JSON.stringify(f.patch)}, ${JSON.stringify(f.render('matrix-optional', 'import'))})
-    }
-    return dispose
-  }
-  ctx.effect(() => () => { hmr.watchConfig = watch })`,
+  writeFileSync(${JSON.stringify(f.patch)}, ${JSON.stringify(f.render('matrix-optional', 'import'))})`,
     ))
     const app = start(f)
     try {
-      await app.wait(() => app.logs().includes('profile reload failed') && app.logs().includes('missing.mjs'))
+      await app.wait(() => app.logs().includes('config reload at') && app.logs().includes('missing.mjs'))
       await app.serves()
       writeFileSync(f.patch, f.render('matrix-optional', undefined, 2))
       await app.wait(() => app.events().includes('target apply 2\n'))

+ 0 - 1
apps/cli/tests/web-agent-presets.e2e.ts

@@ -138,7 +138,6 @@ async function bootWeb(
     layers: [],
     patchPath: join(profileDir, 'cordis.patch.yml'),
     patches: [],
-    patchReload: 'startup',
   }
   let bundlePatches: PatchOptions[] = [
     ...loadOverlayPatches('dsh-test', BASE_PATCH),

+ 3 - 0
apps/desktop-host/config/desktop.cordis.patch.yml

@@ -9,6 +9,9 @@
 - id: web-runtime
   disabled: true
 
+- id: hmr
+  disabled: true
+
 - id: client-hmr
   disabled: true
 

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

@@ -695,7 +695,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       layers: extraLayers,
       patchPath: join(profileDir, 'cordis.patch.yml'),
       patches: [],
-      patchReload: 'startup',
     }
     const profileResolutionMode = options.profileResolutionMode ?? 'runtime'
     const resolutionOptions = { installAnchor: INSTALL_ANCHOR, home: harnessHome, profile }

+ 2 - 2
docs/architecture.i18n.yaml

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

+ 1 - 1
docs/architecture.md

@@ -26,7 +26,7 @@ Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile
 
 Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.
 
-Custom profiles default to live patch reload. The shipped `web` profile is live; `headless`, `sdk`, `sdk-minimal`, and `acp` apply all layers once at startup because replacing a one-shot or stdio application's dependencies after it owns work would invalidate that lifecycle.
+YAML controls HMR: base enables config-only `dsh-hmr`; headless, SDK and ACP disable it; `sdk-minimal` omits it. Profile patches can override these defaults. HMR owns watching and reload coordination; the launcher provides profile data and readiness.
 
 The base bundle includes [Plugin Manager](../packages/boot/plugin-manager/README.md) for Web settings and agents.
 

+ 1 - 1
docs/architecture.zh.md

@@ -26,7 +26,7 @@
 
 各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。
 
-自定义 profile 默认实时重载 patch。随附的 `web` profile 使用实时重载;`headless`、`sdk`、`sdk-minimal` 和 `acp` 则只在启动时应用一次所有配置层,因为一次性应用或 stdio 应用拥有工作之后,替换其依赖会破坏该生命周期。
+YAML 控制 HMR:base 启用仅监视配置的 `dsh-hmr`;Headless、SDK 和 ACP 禁用它;`sdk-minimal` 不包含它。Profile patch 可以覆盖这些默认值。HMR 负责监听和重载协调;启动器提供 profile 数据和就绪信号。
 
 base 组合包提供用于 Web 设置和 Agent 的[插件管理器](../packages/boot/plugin-manager/README.zh.md)。
 

+ 1 - 1
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: cfd41d7396c14d1662fe51bc74b295ccf551a895
+config-catalog.md: aa5681f7b3607b8e5625fbed13370acc97a4ccaf
 config-catalog.zh.md: eff5f686fef8f7add5ea527d18e997d695541ae5

+ 1 - 1
docs/config-catalog.md

@@ -929,7 +929,7 @@ export interface HmrConfig extends ChokidarOptions {
 
 Depends on: `ChokidarOptions` (`chokidar`)
 
-Source: [`packages/boot/hmr/src/index.ts:54`](../packages/boot/hmr/src/index.ts)
+Source: [`packages/boot/hmr/src/index.ts:57`](../packages/boot/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 12abe60e6a5e7905c449bd5cc54cd0b163ea2891
-event-producer-consumer.zh.md: 47fff7f14a3b61d1df65079b0c46ce814e4f496e
+event-producer-consumer.md: 045ac6ac05fecbdc9bb8a1381fe15d965d8f9a8e
+event-producer-consumer.zh.md: 71cd6f8ea442b58c420ceebfbf7b770de9c2f80f

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

@@ -45,9 +45,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `hmr/before-reload` | `waterfall` | [`packages/boot/hmr/src/index.ts:28`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`waterfall`) | - |
-| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:33`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
-| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:38`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/before-reload` | `waterfall` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`waterfall`) | [`hmr`](../packages/boot/hmr) |
+| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:41`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |

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

@@ -47,9 +47,9 @@
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
-| `hmr/before-reload` | `waterfall` | [`packages/boot/hmr/src/index.ts:28`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`waterfall`) | - |
-| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:33`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
-| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:38`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/before-reload` | `waterfall` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`waterfall`) | [`hmr`](../packages/boot/hmr) |
+| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:41`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:44`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |

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

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

+ 4 - 3
docs/module-graph.md

@@ -457,7 +457,6 @@ flowchart TD
   pkg_spill --> pkg_brand
   pkg_spill --> pkg_llm
   pkg_spill --> pkg_session
-  pkg_app_boot --> pkg_hmr
   pkg_app_boot --> pkg_home_paths
   pkg_app_boot --> pkg_launch_environment
   pkg_app_boot --> pkg_system_prompt
@@ -492,6 +491,8 @@ flowchart TD
   pkg_spill_local --> pkg_spill
   pkg_session_log_export --> pkg_session
   pkg_session_log_export --> pkg_session_persistence
+  pkg_hmr --> pkg_app_boot
+  pkg_hmr --> pkg_cmdline
   pkg_ptc_runtime --> pkg_sandbox
   pkg_sandbox_local --> pkg_llm
   pkg_sandbox_local --> pkg_sandbox
@@ -1303,7 +1304,6 @@ flowchart TD
 | [`api-gateway`](../packages/api/gateway) | `api` | — |
 | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
 | [`cmdline`](../packages/boot/cmdline) | `boot` | — |
-| [`hmr`](../packages/boot/hmr) | `boot` | — |
 | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
 | [`base`](../packages/bundle/base) | `bundle` | — |
 | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — |
@@ -1416,7 +1416,7 @@ flowchart TD
 | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
 | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`app-boot`](../packages/boot/app-boot) | `boot` | [`hmr`](../packages/boot/hmr), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
+| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
 | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
 | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) |
@@ -1429,6 +1429,7 @@ flowchart TD
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
+| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
 | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |

+ 4 - 3
docs/module-graph.zh.md

@@ -459,7 +459,6 @@ flowchart TD
   pkg_spill --> pkg_brand
   pkg_spill --> pkg_llm
   pkg_spill --> pkg_session
-  pkg_app_boot --> pkg_hmr
   pkg_app_boot --> pkg_home_paths
   pkg_app_boot --> pkg_launch_environment
   pkg_app_boot --> pkg_system_prompt
@@ -494,6 +493,8 @@ flowchart TD
   pkg_spill_local --> pkg_spill
   pkg_session_log_export --> pkg_session
   pkg_session_log_export --> pkg_session_persistence
+  pkg_hmr --> pkg_app_boot
+  pkg_hmr --> pkg_cmdline
   pkg_ptc_runtime --> pkg_sandbox
   pkg_sandbox_local --> pkg_llm
   pkg_sandbox_local --> pkg_sandbox
@@ -1305,7 +1306,6 @@ flowchart TD
 | [`api-gateway`](../packages/api/gateway) | `api` | — |
 | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
 | [`cmdline`](../packages/boot/cmdline) | `boot` | — |
-| [`hmr`](../packages/boot/hmr) | `boot` | — |
 | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
 | [`base`](../packages/bundle/base) | `bundle` | — |
 | [`sdk-app`](../packages/bundle/sdk-app) | `bundle` | — |
@@ -1418,7 +1418,7 @@ flowchart TD
 | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) |
 | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
-| [`app-boot`](../packages/boot/app-boot) | `boot` | [`hmr`](../packages/boot/hmr), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
+| [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) |
 | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
 | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) |
@@ -1431,6 +1431,7 @@ flowchart TD
 | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
 | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
+| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
 | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |

+ 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: 295c4c64002c1f7ddabe4b12eb405355ccb3c91b
-README.zh.md: 8cf81694f62292f6ff941f14d5cb15433f67ddcb
+README.md: 6b05c128598f4316dfd146401333798e43c7ead8
+README.zh.md: 06d289ab04e83c1afc7fd2d46fc2c0cb303d5212

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-app-boot` is the shared Loader boot library behind `dsh` profiles, including the CLI packaged by the Python runtime wheel. It loads environment layers, composes profile bundles and patches, boots every plugin, and returns the running app or identifies the failed plugin and cause. Product applications use the `dsh` launcher instead of publishing separate bins; direct-config helpers remain only for lower-level embedders and tests. You can preview the effective configuration before booting, select live or startup-only patch application per profile, and let a terminal-owning app restore its terminal before a fatal exit.
+`dsh-app-boot` is the shared Loader boot library behind `dsh` profiles, including the CLI packaged by the Python runtime wheel. It loads environment layers, composes profile bundles and patches, boots every plugin, and returns the running app or identifies the failed plugin and cause. Product applications use the `dsh` launcher instead of publishing separate bins; direct-config helpers remain only for lower-level embedders and tests. You can preview the effective configuration before booting, configure HMR through profile YAML, and let a terminal-owning app restore its terminal before a fatal exit.
 
 ## Table of Contents
 
@@ -47,14 +47,14 @@ With that entry point, startup keeps every plugin that can activate. An enabled
 
 Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
 
-A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
+A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles with its own `cordis.patch.yml`. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
 
 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 the profile manifest and both user patch files, re-read the ordered bundle layers, and apply the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) mutations and waits for the shared profile package lock before reading changes. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback.
+The enabled `dsh-hmr` plugin watches the profile manifest and both user patch files, re-reads the ordered bundle layers, and applies the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) mutations and waits for the shared profile package lock before reading changes. The launcher does not install HMR or watchers; disabled or absent HMR means changes require restart.
 
 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.
 

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

@@ -47,14 +47,14 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
 
 Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
 
-profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。缺失组合包或未声明 patch 的组合包会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
+profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装组合包和自身 `cordis.patch.yml` 组成。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。缺失组合包或未声明 patch 的组合包会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
 
 你的机器本地偏好同样位于 harness home 中:
 
 - **`.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 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。
 
-带 `patchReload: live` 的 profile 会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的修改串行化,读取变化前等待共享的 profile 包操作锁。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR(热模块替换)回退。
+启用的 `dsh-hmr` 插件会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的修改串行化,读取变化前等待共享的 profile 包操作锁。启动器不安装 HMR 或监视器;HMR 被禁用或不存在时,更改需要重启。
 
 插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
 

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

@@ -34,14 +34,12 @@
   "dependencies": {
     "@deepseek-ai/dsh-atomic-write": "workspace:^",
     "@deepseek-ai/dsh-package-manifest": "workspace:^",
-    "chokidar": "4.0.3",
     "js-yaml": "^4.2.0",
     "node-addon-require-builtin": "^0.1.4",
     "resolve.exports": "^2.0.3"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis-plugin-group": "workspace:^",
-    "@deepseek-ai/dsh-hmr": "workspace:^",
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
@@ -49,14 +47,8 @@
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
-  "peerDependenciesMeta": {
-    "@deepseek-ai/dsh-hmr": {
-      "optional": true
-    }
-  },
   "devDependencies": {
     "@deepseek-ai/cordis-plugin-group": "workspace:^",
-    "@deepseek-ai/dsh-hmr": "workspace:^",
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",

+ 8 - 52
packages/boot/app-boot/src/index.ts

@@ -17,7 +17,6 @@ import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '
 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/dsh-hmr'
 export { readProfilePatches, resolveTelemetryPatch, type ProfileContext } from './profile-context.ts'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 
@@ -32,7 +31,6 @@ export {
   composeEntries,
   createProfileResolutionGeneration,
   DEFAULT_PROFILE_BUNDLES,
-  DEFAULT_PROFILE_PATCH_RELOAD,
   healProfilesModuleFallback,
   initProfile,
   loadProfile,
@@ -234,53 +232,6 @@ const bootstrapIncludes = new WeakMap<Context, Entry>()
 // reference `process.env`.
 const userPatchesSchema = entryListSchema
 
-/** Options for live user patch-layer reconciliation. */
-export interface UserPatchWatchOptions {
-  /** Diagnostic prefix used by {@link loadOptionalPatches}. */
-  binName: string
-  /** Absolute path of the watched patch file (a profile's `cordis.patch.yml`). */
-  filename: string
-  /**
-   * Compose the full patch list for a fresh user-layer generation —
-   * the same composition the app booted with, so a reload can interleave the
-   * new user patches between app-owned layers (bundle layers below,
-   * overlays above). Identity when omitted: the user layer
-   * is the whole patch list.
-   */
-  compose?: (userPatches: PatchOptions[]) => PatchOptions[]
-}
-
-/**
- * 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.
- * @throws when HMR or the root Include is absent, watcher setup fails, or initial path resolution fails.
- */
-export async function watchUserPatches(
-  ctx: Context,
-  options: UserPatchWatchOptions,
-): Promise<() => Promise<void>> {
-  const { binName, filename, compose = (patches: PatchOptions[]) => patches } = options
-  const hmr = ctx.get('hmr')
-  if (hmr === undefined) throw new Error(`${binName}: user patch-layer watching requires the 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.watchConfig(filename, async () => {
-    await reconcileProfilePatches(ctx, compose(loadOptionalPatches(binName, filename) ?? []), binName)
-  })
-  try {
-    return await register
-  } catch (error) {
-    // A surface can dispose the whole tree while the watcher is still opening;
-    // the HMR effect registration then fails with INACTIVE_EFFECT. That is the
-    // app exiting exactly as asked, not a watch failure, so return a no-op
-    // disposer instead of crashing.
-    if ((error as { code?: string } | null)?.code === 'INACTIVE_EFFECT') return async () => {}
-    throw error
-  }
-}
-
 /** Apply one complete patch generation and wait for Loader activation diagnostics.
  * @param ctx Booted root context.
  * @param patches Complete ordered patch list.
@@ -290,14 +241,18 @@ export async function reconcileProfilePatches(ctx: Context, patches: PatchOption
   const entry = bootstrapIncludes.get(ctx)
   if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
   // Removed entries leave the Loader store before their async disposers finish.
-  const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [row.fiber])
+  const previousFibers = [...ctx.loader.entries()].flatMap(row => row.fiber === undefined ? [] : [{
+    fiber: row.fiber, failed: row.fiber.state === FIBER_FAILED || row.fiber.state === FIBER_DISPOSED,
+  }])
   const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
   await entry.update({ config: { ...includeConfig, patches } })
-  const results = await Promise.allSettled(previousFibers.map(fiber => fiber.await()))
+  const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()))
   await ctx.loader.await()
   const failures = await inactiveEntries(ctx)
   if (failures.length > 0) throw new Error(activationDiagnostic(binName, 'warning', failures).trimEnd())
-  for (const result of results) if (result.status === 'rejected') throw result.reason
+  for (const [index, result] of results.entries()) {
+    if (result.status === 'rejected' && !previousFibers[index]?.failed) throw result.reason
+  }
 }
 
 /**
@@ -705,6 +660,7 @@ export function installFailLoud(
 const FIBER_PENDING = 0 as FiberState.PENDING
 const FIBER_ACTIVE = 2 as FiberState.ACTIVE
 const FIBER_FAILED = 3 as FiberState.FAILED
+const FIBER_DISPOSED = 4 as FiberState.DISPOSED
 
 /**
  * Entry ids whose presence defines a usable DSH application.

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

@@ -14,7 +14,6 @@ export interface ProfileContext {
   readonly home: string
   /** Bundle packages used to start this process, before any persisted edits. */
   readonly startedBundles: readonly string[]
-  readonly patchReload: 'live' | 'startup'
   /** Parsed command-line overlays, applied above profile and home patches. */
   readonly overlays: readonly PatchOptions[]
   /** Launch-time DSH_TELEMETRY_DISABLED value; any non-empty value opts out. */

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

@@ -34,7 +34,7 @@ import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-import type { DshPackageManifest, ProfilePatchReload } from '@deepseek-ai/dsh-package-manifest'
+import type { DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
 import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports'
 import { loadOverlayPatches } from './index.ts'
 import {
@@ -56,8 +56,6 @@ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml'
 export interface ProfileTemplate {
   /** Ordered bundle layer list. */
   bundles: readonly string[]
-  /** User patch-file lifecycle for the generated profile. */
-  patchReload: ProfilePatchReload
 }
 
 /** Package metadata accepted by the profile reader; local profiles need no published identity. */
@@ -87,8 +85,6 @@ export interface Profile {
   patchPath: string
   /** The profile's own patches; empty when the file is absent. */
   patches: PatchOptions[]
-  /** Whether the launcher watches user patch files after boot. */
-  patchReload: ProfilePatchReload
 }
 
 /** One package selected by the profile module-fallback rules. */
@@ -139,23 +135,18 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome())
 export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
   acp: {
     bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
-    patchReload: 'startup',
   },
   web: {
     bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
-    patchReload: 'live',
   },
   headless: {
     bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
-    patchReload: 'startup',
   },
   sdk: {
     bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
-    patchReload: 'startup',
   },
   'sdk-minimal': {
     bundles: ['@deepseek-ai/dsh-sdk-minimal'],
-    patchReload: 'startup',
   },
 }
 
@@ -167,9 +158,6 @@ const INSTALLATION_OWNED_PROFILE_TUPLES: Record<string, readonly string[]> = {
 /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */
 export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base']
 
-/** Custom profiles retain the historical live patch-file behavior. */
-export const DEFAULT_PROFILE_PATCH_RELOAD: ProfilePatchReload = 'live'
-
 const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
 # a top-level YAML array of loader patch entries (id-targeted config
 # overrides, disables, and insert lists; \`!!js\` expressions allowed).
@@ -194,12 +182,10 @@ autoInstallPeers: false
  * so re-running is a no-op on an initialized profile.
  * @param dir - the profile directory from {@link resolveProfileDir}.
  * @param bundles - the initial `dsh.profile.bundles` layer list.
- * @param patchReload - user patch-file lifecycle; custom profiles default to live reload.
  */
 export function initProfile(
   dir: string,
   bundles: readonly string[],
-  patchReload: ProfilePatchReload = DEFAULT_PROFILE_PATCH_RELOAD,
 ): void {
   mkdirSync(dir, { recursive: true })
   const manifestPath = join(dir, 'package.json')
@@ -208,7 +194,7 @@ export function initProfile(
       name: `dsh-profile-${basename(dir)}`,
       private: true,
       dependencies: {},
-      dsh: { profile: { bundles: [...bundles], patchReload } },
+      dsh: { profile: { bundles: [...bundles] } },
     }
     writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n')
   }
@@ -774,9 +760,7 @@ function sameBundles(left: readonly string[], right: readonly string[]): boolean
 
 /**
  * Normalize an exact installation-owned bundle tuple to its shipped template,
- * or add the shipped reload default to an exact current tuple. A changed value
- * is written back during profile loading while every other manifest field is
- * preserved; any other bundle list is user-owned and remains untouched.
+ * preserving all other manifest fields. Other bundle lists remain untouched.
  */
 function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest {
   const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name]
@@ -784,9 +768,7 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan
   const bundles = manifest.dsh?.profile?.bundles
   if (template === undefined || bundles === undefined) return manifest
   const isRetiredTuple = installationOwned !== undefined && sameBundles(bundles, installationOwned)
-  const isCurrentTuple = sameBundles(bundles, template.bundles)
-  const needsReloadDefault = manifest.dsh?.profile?.patchReload === undefined && isCurrentTuple
-  if (!isRetiredTuple && !needsReloadDefault) return manifest
+  if (!isRetiredTuple) return manifest
   const normalized: ProfileManifest = {
     ...manifest,
     dsh: {
@@ -794,7 +776,6 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan
       profile: {
         ...manifest.dsh?.profile,
         bundles: [...template.bundles],
-        patchReload: manifest.dsh?.profile?.patchReload ?? template.patchReload,
       },
     },
   }
@@ -866,13 +847,6 @@ export function loadProfileDirectory(
 ): Profile {
   const manifest = readProfileManifest(binName, dir)
   const bundles = manifest.dsh?.profile?.bundles ?? []
-  const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload
-  if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') {
-    throw new Error(
-      `${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`,
-    )
-  }
-  const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD
   const layers = bundles.map((packageName): ProfileLayer => {
     const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
     const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
@@ -887,7 +861,7 @@ export function loadProfileDirectory(
   const patches = options.userLayer !== false && existsSync(patchPath)
     ? loadOverlayPatches(binName, patchPath)
     : []
-  return { name: basename(dir), dir, layers, patchPath, patches, patchReload }
+  return { name: basename(dir), dir, layers, patchPath, patches }
 }
 
 /**
@@ -916,7 +890,7 @@ export function loadProfile(
         `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
       )
     }
-    initProfile(dir, template.bundles, template.patchReload)
+    initProfile(dir, template.bundles)
   }
   normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
   return loadProfileDirectory(binName, dir, installAnchor, options)

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

@@ -160,7 +160,6 @@ function fixture(name = 'resolution-lib'): {
       layers: [],
       patchPath: join(profileDir, 'cordis.patch.yml'),
       patches: [],
-      patchReload: 'startup',
     },
   }
 }

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

@@ -86,7 +86,6 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile
     }],
     patchPath: join(dir, PROFILE_PATCH_FILENAME),
     patches: [],
-    patchReload: 'live',
   }
 }
 
@@ -110,7 +109,7 @@ it('composes current files from profile data and retains launch overlay and tele
   writeFileSync(join(home, PROFILE_PATCH_FILENAME), '- id: session-telemetry-otel\n  disabled: false\n')
   const context = {
     name: 'test', dir, patchPath, installAnchor, home, cwd: home,
-    startedBundles: ['base'], patchReload: 'live' as const,
+    startedBundles: ['base'],
     overlays: [{ id: 'session-telemetry-otel', disabled: false }], telemetryDisabledEnv: 'false',
   }
   expect(composeEntries([readProfilePatches('test', context)])[0]?.disabled).toBe(true)
@@ -130,14 +129,12 @@ describe('initProfile', () => {
     initProfile(dir, ['@deepseek-ai/dsh-base'])
     const manifest = readProfileManifest('t', dir)
     expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
-    expect(manifest.dsh?.profile?.patchReload).toBe('live')
     expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
     expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
     // Re-init keeps user edits.
     writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n  config: {}\n')
-    initProfile(dir, ['other'], 'startup')
+    initProfile(dir, ['other'])
     expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base'])
-    expect(readProfileManifest('t', dir).dsh?.profile?.patchReload).toBe('live')
     expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x')
   })
 })
@@ -210,7 +207,6 @@ describe('loadProfile', () => {
     const profile = loadProfile('t', 'demo', anchor, home)
     expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b'])
     expect(profile.patches).toHaveLength(1)
-    expect(profile.patchReload).toBe('live')
     const entries = composeEntries([
       ...profile.layers.map(layer => layer.patches),
       profile.patches,
@@ -222,7 +218,6 @@ describe('loadProfile', () => {
     writeProfileManifest(dir, { name: 'bare' })
     const bare = loadProfile('t', 'demo', anchor, home)
     expect(bare.layers).toEqual([])
-    expect(bare.patchReload).toBe('live')
   })
 
   it('auto-initializes only shipped templates and fails loud otherwise', () => {
@@ -234,19 +229,14 @@ describe('loadProfile', () => {
     // cannot be asserted to fail here: the source-plane test runner resolves
     // @deepseek-ai/* through tsconfig paths regardless of the staged anchor.
     expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base')
-    expect(PROFILE_TEMPLATES.web?.patchReload).toBe('live')
-    expect(PROFILE_TEMPLATES.headless?.patchReload).toBe('startup')
     expect(PROFILE_TEMPLATES.acp).toEqual({
       bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
-      patchReload: 'startup',
     })
     expect(PROFILE_TEMPLATES.sdk).toEqual({
       bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
-      patchReload: 'startup',
     })
     expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({
       bundles: ['@deepseek-ai/dsh-sdk-minimal'],
-      patchReload: 'startup',
     })
     try {
       loadProfile('t', 'web', anchor, home)
@@ -255,8 +245,6 @@ describe('loadProfile', () => {
     }
     expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles)
       .toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []])
-    expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.patchReload)
-      .toBe('live')
   })
 
   it('normalizes only the exact installation-owned headless bundle tuple', () => {
@@ -272,12 +260,10 @@ describe('loadProfile', () => {
       '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless',
     ])
     const retiredManifest = readProfileManifest('t', stock)
-    delete retiredManifest.dsh!.profile!.patchReload
     writeProfileManifest(stock, retiredManifest)
     loadProfile('t', 'headless', anchor, home)
     expect(readProfileManifest('t', stock).dsh?.profile).toEqual({
       bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
-      patchReload: 'startup',
     })
 
     const customHome = tmp()
@@ -291,38 +277,6 @@ describe('loadProfile', () => {
     ])
   })
 
-  it('adds a shipped reload default only to an exact stock tuple and preserves explicit choices', () => {
-    const anchor = stageInstallation({
-      '@deepseek-ai/dsh-base': { patch: '[]\n' },
-      '@deepseek-ai/dsh-web-app': { patch: '[]\n' },
-    })
-    const stockHome = tmp()
-    const stock = resolveProfileDir('web', stockHome)
-    initProfile(stock, PROFILE_TEMPLATES.web?.bundles ?? [])
-    const stockManifest = readProfileManifest('t', stock)
-    delete stockManifest.dsh!.profile!.patchReload
-    writeProfileManifest(stock, stockManifest)
-    expect(loadProfile('t', 'web', anchor, stockHome).patchReload).toBe('live')
-    expect(readProfileManifest('t', stock).dsh?.profile?.patchReload).toBe('live')
-
-    const explicitHome = tmp()
-    const explicit = resolveProfileDir('web', explicitHome)
-    initProfile(explicit, PROFILE_TEMPLATES.web?.bundles ?? [], 'startup')
-    expect(loadProfile('t', 'web', anchor, explicitHome).patchReload).toBe('startup')
-  })
-
-  it('fails loud on an unknown patch reload value from disk', () => {
-    const anchor = stageInstallation({})
-    const home = tmp()
-    const dir = resolveProfileDir('demo', home)
-    initProfile(dir, [])
-    const manifest = readProfileManifest('t', dir)
-    const rawProfile = manifest.dsh!.profile as { patchReload?: string }
-    rawProfile.patchReload = 'sometimes'
-    writeProfileManifest(dir, manifest)
-    expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('patchReload must be "live" or "startup"')
-  })
-
   it('fails loud when a listed bundle declares no dsh.bundle', () => {
     const anchor = stageInstallation({ 'not-a-bundle': {} })
     const home = tmp()
@@ -466,7 +420,6 @@ describe('healProfilesModuleFallback', () => {
       }],
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
-      patchReload: 'live',
     }
 
     await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
@@ -511,7 +464,6 @@ describe('healProfilesModuleFallback', () => {
       })),
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
-      patchReload: 'live',
     }
 
     await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })
@@ -551,7 +503,6 @@ describe('healProfilesModuleFallback', () => {
       })),
       patchPath: join(dir, PROFILE_PATCH_FILENAME),
       patches: [],
-      patchReload: 'live',
     }
 
     await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home })

+ 22 - 170
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -4,42 +4,24 @@
  * a real Loader tree with live file watching.
  */
 
-import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { afterAll, afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
-import { FSWatcher, type ChokidarOptions } from 'chokidar'
+import { afterAll, afterEach, describe, expect, it, onTestFinished } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import Hmr from '@deepseek-ai/dsh-hmr'
 import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import Timer from '@deepseek-ai/cordis-plugin-timer'
 import {
   boot,
   loadOptionalPatches,
   loadOverlayPatches,
   PROFILE_PATCH_FILENAME,
   reconcileProfilePatches,
-  watchUserPatches,
 } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
 
-const configWatch = vi.hoisted(() => ({
-  create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined,
-}))
-
-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),
-  }
-})
-
 const tempRoots: string[] = []
 afterAll(() => {
   for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
@@ -51,14 +33,6 @@ const tmp = (): string => {
   return dir
 }
 
-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('loadOptionalPatches', () => {
   afterEach(() => {
     delete process.env.DSH_HOME
@@ -363,7 +337,7 @@ describe('profile reconciliation settlement', () => {
     await expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('profile reload requires the root Include entry')
   })
 
-  it('reports a retained activation failure even when the failed entry is removed', async () => {
+  it('removes a previously failed entry without reporting its old activation error', async () => {
     const dir = tmp()
     writeFileSync(join(dir, 'cordis.yml'), '[]\n')
     writeFileSync(join(dir, 'candidate.mjs'), 'export function apply(_ctx, config) { if (config.fail) throw new Error("candidate activation failed") }\n')
@@ -373,11 +347,29 @@ describe('profile reconciliation settlement', () => {
     if (entry === undefined) throw new Error('candidate entry missing')
     await entry.update({ config: { fail: true } })
     await ctx.loader.await()
-    await expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('candidate activation failed')
+    await reconcileProfilePatches(ctx, [], NAME)
     expect([...ctx.loader.entries()].some(row => row.options.id === 'candidate')).toBe(false)
     await reconcileProfilePatches(ctx, [], NAME)
   })
 
+  it('reports an activation failure that settles while its entry is being removed', async () => {
+    const dir = tmp()
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+    writeFileSync(join(dir, 'candidate.mjs'), 'export async function apply(ctx, config) { if (config.fail) { ctx.get("pendingFailure").entered(); await ctx.get("pendingFailure").release; throw new Error("in-flight failure") } }\n')
+    const ctx = await boot(NAME, join(dir, 'cordis.yml'), [{ insert: [{ id: 'candidate', name: './candidate.mjs', config: { fail: false } }] }], (host) => {
+      host.provide('pendingFailure', { entered: () => { entered.resolve(undefined) }, release: release.promise })
+    })
+    onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() })
+    const entry = [...ctx.loader.entries()].find(row => row.options.id === 'candidate')!
+    await entry.update({ config: { fail: true } })
+    await entered.promise
+    const result = expect(reconcileProfilePatches(ctx, [], NAME)).rejects.toThrow('in-flight failure')
+    release.resolve(undefined)
+    await result
+  })
+
   it('waits for a removed plugin to release its resources', async () => {
     const dir = tmp()
     const started = Promise.withResolvers<undefined>()
@@ -458,144 +450,4 @@ describe('boot with user patches', () => {
     }
   })
 
-  it.each([
-    { id: 'noop', asyncApply: false },
-    { id: 'webserver', asyncApply: false },
-    { id: 'webserver', asyncApply: true },
-  ])('keeps HMR best effort and recovers ($id, async apply: $asyncApply)', { timeout: 20_000 }, async ({ id, asyncApply }) => {
-    const dir = tmp()
-    const userDir = tmp()
-    const filename = join(userDir, PROFILE_PATCH_FILENAME)
-    const basePatches = [{ id, config: { value: 'generated' } }]
-    const ctx = await boot(NAME, writeTree(dir, id, asyncApply), basePatches)
-    onTestFinished(() => ctx.fiber.dispose())
-    await ctx.plugin(Timer)
-    await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
-    // 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 })
-    configWatch.create = (options) => {
-      const watcher = new FSWatcher(options)
-      watchers.push(watcher)
-      queueMicrotask(() => { watcher.emit('ready') })
-      return watcher
-    }
-    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,
-      compose: userPatches => [...basePatches, ...userPatches],
-    })
-    expect(watchers).toHaveLength(1)
-    const watcher = watchers[0]!
-    try {
-      writeFileSync(filename, `- id: ${id}\n  config:\n    value: live\n`)
-      watcher.emit('add', filename)
-      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'live', 'user patch addition was not applied')
-
-      writeFileSync(filename, `- id: ${id}\n  config:\n    fail: true\n`)
-      watcher.emit('change', filename)
-      await eventually(() => failures.length === 1, 'failed candidate was not reported')
-      expect(failures[0]).toBeInstanceOf(Error)
-      expect(entryConfig(ctx, id)).toMatchObject({ fail: true })
-
-      writeFileSync(filename, `- id: ${id}\n  disabled: !!js "JSON.parse('invalid')"\n`)
-      watcher.emit('change', filename)
-      await eventually(() => failures.length === 2, 'disabled expression failure was not reported')
-      expect(failures[1]?.message).toContain(`${id} (./noop.mjs): disabled expression failed: SyntaxError`)
-      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
-        .toEqual({ __jsExpr: "JSON.parse('invalid')" })
-
-      writeFileSync(filename, 'invalid: [unclosed\n')
-      watcher.emit('change', filename)
-      await eventually(() => failures.length === 3, 'parse failure was not reported')
-      expect(failures[2]).toBeInstanceOf(Error)
-      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
-        .toEqual({ __jsExpr: "JSON.parse('invalid')" })
-
-      writeFileSync(filename, `- id: ${id}\n  config:\n    value: recovered\n`)
-      watcher.emit('change', filename)
-      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'recovered', 'valid recovery was not applied')
-      await ctx.loader.await()
-      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.fiber?.state).toBe(2)
-
-      unlinkSync(filename)
-      watcher.emit('unlink', filename)
-      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
-      expect(failures).toHaveLength(3)
-
-      // Default compose: the user layer IS the whole patch list, so a
-      // fresh generation replaces the app-owned layer instead of stacking on it.
-      await dispose()
-      const disposeDefault = await watchUserPatches(ctx, { binName: NAME, filename })
-      expect(watchers).toHaveLength(2)
-      try {
-        writeFileSync(filename, `- id: ${id}\n  config:\n    value: identity\n`)
-        watchers[1]!.emit('add', filename)
-        await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
-      } finally {
-        await disposeDefault()
-      }
-    } finally {
-      await dispose()
-    }
-  })
-
-  it('fails loud when the exact watcher lacks HMR or a root Include', async () => {
-    const dir = tmp()
-    const withoutHmr = await boot(NAME, writeTree(dir))
-    await expect(watchUserPatches(withoutHmr, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the HMR service')
-    await withoutHmr.fiber.dispose()
-
-    const withoutInclude = new Context()
-    withoutInclude.baseUrl = pathToFileURL(`${tmp()}/`).href
-    await withoutInclude.plugin(Loader)
-    await withoutInclude.plugin(Timer)
-    await withoutInclude.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
-    await expect(watchUserPatches(withoutInclude, { binName: NAME, filename: join(tmp(), PROFILE_PATCH_FILENAME) })).rejects.toThrow('requires the root Include entry')
-    await withoutInclude.fiber.dispose()
-  })
-
-  it('returns a no-op disposer when the tree is disposed while the watcher opens', async () => {
-    const dir = tmp()
-    const ctx = await boot(NAME, writeTree(dir))
-    await ctx.plugin(Timer)
-    await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
-    const previousFactory = configWatch.create
-    onTestFinished(() => { configWatch.create = previousFactory })
-    const release = Promise.withResolvers<undefined>()
-    ctx.effect(() => () => release.promise)
-    let disposal: Promise<void> | undefined
-    onTestFinished(async () => { release.resolve(undefined); await disposal })
-    configWatch.create = (options) => {
-      const watcher = new FSWatcher(options)
-      disposal = ctx.fiber.dispose()
-      queueMicrotask(() => { 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 () => {
-    const dir = tmp()
-    const filename = join(tmp(), PROFILE_PATCH_FILENAME)
-    const ctx = await boot(NAME, writeTree(dir))
-    try {
-      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: the watcher refuses; not a teardown race.
-      await expect(watchUserPatches(ctx, { binName: NAME, filename })).rejects.toThrow('already registered')
-      await dispose()
-    } finally {
-      await ctx.fiber.dispose()
-    }
-  })
 })

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

@@ -20,9 +20,6 @@
     {
       "path": "../../../vendor/group"
     },
-    {
-      "path": "../hmr"
-    },
     {
       "path": "../../core/system-prompt"
     },

+ 2 - 2
packages/boot/hmr/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/hmr/README.md
-README.md: a7e5d6272830b969b6990f841693854d5f7e1868
-README.zh.md: 87e37487af5f6c15af7ba91361fc76d88f1f3f81
+README.md: 01ab7a8e91af163970f607904cf478a95815c412
+README.zh.md: 23ff8242ac3078f6827a72bafbc652e0a7a99a71

+ 3 - 3
packages/boot/hmr/README.md

@@ -24,7 +24,7 @@ Reload plugin source and configuration while an application is running. Module r
 <a id="use-this-package"></a>
 ## Use this package
 
-Live profiles install configuration watching automatically. To enable source-module watching, configure the `hmr` entry supplied by the base bundle in the profile patch before launching:
+The base bundle enables HMR with `root: []` when the launcher supplies `profileContext`; hosts without that profile context leave this entry disabled. Headless, SDK and ACP bundles disable that entry in YAML; a later profile patch can enable it. Disabling or omitting HMR applies changes on restart. To enable source-module watching, configure the `hmr` entry supplied by the base bundle in the profile patch before launching:
 
 ```yaml
 - id: hmr
@@ -54,9 +54,9 @@ Chokidar options, including polling, retain their existing meaning. Exact config
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes caller-owned mutations with automatic reloads and rejects nested transactions. The `hmr/before-reload` waterfall lets the launcher hold the profile file lock around every automatic reload; callers performing mutations acquire their file lock inside `runExclusive()` in the same order. File events received during a transaction are processed afterward.
+`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes caller-owned mutations with automatic reloads and rejects nested transactions. HMR registers a `hmr/before-reload` listener that holds the profile file lock around every automatic reload; callers performing mutations acquire their file lock inside `runExclusive()` in the same order. File events received during a transaction are processed afterward.
 
-The launcher retains profile parsing and patch precedence. HMR owns watchers, module-cache replacement and reload scheduling. Unknown-file notifications do not acquire reload locks, so lock-file events cannot trigger another lock acquisition. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
+App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Unknown-file notifications do not acquire reload locks, so lock-file events cannot trigger another lock acquisition. No invariant companion is published because the queue and watcher registrations have no independent persisted projection.
 
 Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
 

+ 3 - 3
packages/boot/hmr/README.zh.md

@@ -24,7 +24,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-live profile 自动启用配置监听。如需监听源码模块,在启动前通过 profile patch 配置 base 组合包提供的 `hmr` 条目:
+启动器提供 `profileContext` 时,base 组合包以 `root: []` 启用 HMR;没有该 profile 上下文的宿主保留此条目为禁用状态。Headless、SDK 和 ACP 组合包在 YAML 中禁用此条目,后续 profile patch 可以重新启用。禁用或省略 HMR 时,更改在重启后生效。如需监听源码模块,在启动前通过 profile patch 配置 base 组合包提供的 `hmr` 条目:
 
 ```yaml
 - id: hmr
@@ -54,9 +54,9 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
 <details>
 <summary>实现细节——点击展开</summary>
 
-`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将调用方的修改与自动重载串行化,并拒绝嵌套事务。`hmr/before-reload` 瀑布事件让启动器在每次自动重载期间持有 profile 文件锁;主动修改的调用方在 `runExclusive()` 内以相同顺序取得文件锁。事务期间收到的文件事件在事务结束后处理。
+`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将调用方的修改与自动重载串行化,并拒绝嵌套事务。HMR 注册 `hmr/before-reload` 监听器,在每次自动重载期间持有 profile 文件锁;主动修改的调用方在 `runExclusive()` 内以相同顺序取得文件锁。事务期间收到的文件事件在事务结束后处理。
 
-启动器保留 profile 解析和 patch 优先级规则。HMR 负责监听器、模块缓存替换和重载调度。未知文件通知不获取重载锁,因此锁文件事件不会触发下一次取锁。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
+App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。未知文件通知不获取重载锁,因此锁文件事件不会触发下一次取锁。不发布 invariant 伴生入口,因为队列和监听注册没有独立的持久投影。
 
 被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
 

+ 8 - 3
packages/boot/hmr/package.json

@@ -31,13 +31,16 @@
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
-    "@deepseek-ai/cordis-plugin-include": "workspace:^"
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-cmdline": "workspace:^"
   },
   "dependencies": {
     "@babel/code-frame": "^7.29.0",
     "chokidar": "^4.0.3",
     "picomatch": "^4.0.3",
-    "@deepseek-ai/schemastery": "workspace:^"
+    "@deepseek-ai/schemastery": "workspace:^",
+    "@deepseek-ai/dsh-atomic-write": "workspace:^"
   },
   "devDependencies": {
     "@types/babel__code-frame": "^7.27.0",
@@ -46,6 +49,8 @@
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
-    "@deepseek-ai/cordis-plugin-include": "workspace:^"
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/dsh-app-boot": "workspace:^",
+    "@deepseek-ai/dsh-cmdline": "workspace:^"
   }
 }

+ 37 - 3
packages/boot/hmr/src/index.ts

@@ -5,8 +5,11 @@ import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
 import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
 import type { Include } from '@deepseek-ai/cordis-plugin-include'
 import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
-import { basename, dirname, relative, resolve } from 'node:path'
-import { realpathSync } from 'node:fs'
+import { basename, dirname, join, relative, resolve } from 'node:path'
+import { readFileSync, realpathSync } from 'node:fs'
+import { readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot'
+import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import type {} from '@deepseek-ai/dsh-cmdline'
 import { handleError } from './error.ts'
 import type {} from '@deepseek-ai/cordis-plugin-timer'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -131,6 +134,7 @@ class Hmr extends Service {
   private stashed = new Set<string>()
   private operations: Promise<unknown> = Promise.resolve()
   private readonly executing = new AsyncLocalStorage<boolean>()
+  private applicationReady: Promise<boolean> = Promise.resolve(true)
   private closing = false
   private readonly configPaths = new Set<string>()
 
@@ -149,7 +153,9 @@ class Hmr extends Service {
   }
 
   private runReload(operation: () => Promise<void>): Promise<void> {
-    return this.runExclusive(() => this.ctx.waterfall('hmr/before-reload', operation))
+    return this.runExclusive(async () => {
+      if (await this.applicationReady) await this.ctx.waterfall('hmr/before-reload', operation)
+    })
   }
 
   /** Watch a configuration path through the same queue as module replacement.
@@ -203,6 +209,33 @@ class Hmr extends Service {
       if (!this.executing.getStore()) await this.operations
     }
 
+    const profile = this.ownerContext.get('profileContext')
+    if (profile !== undefined) {
+      const ready = this.ownerContext.get('appReady')
+      if (ready === undefined) throw new Error('Profile HMR requires application readiness')
+      const started = Promise.withResolvers<boolean>()
+      this.applicationReady = started.promise
+      const unsubscribe = ready.onReady(() => { started.resolve(true) })
+      yield () => { unsubscribe(); started.resolve(false); return Promise.resolve() }
+      const manifestPath = join(profile.dir, 'package.json')
+      this.ownerContext.on('hmr/before-reload', next => withFileLock(manifestPath, next))
+      const files = [profile.patchPath, join(profile.home, PROFILE_PATCH_FILENAME), manifestPath]
+      let lastInputs: string | undefined
+      const refresh = async (): Promise<void> => {
+        const inputs = JSON.stringify(files.map((filename) => {
+          try { return readFileSync(filename, 'utf8') }
+          catch (error) {
+            if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
+            throw error
+          }
+        }))
+        if (inputs === lastInputs) return
+        lastInputs = inputs
+        await reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', profile), 'dsh')
+      }
+      for (const filename of files) await this.watchConfig(filename, refresh)
+    }
+
     const { loader } = this.ctx
     const { root, ignored } = this.config
     if (!this.config.base) {
@@ -234,6 +267,7 @@ class Hmr extends Service {
     const changed = new Set<string>()
     const dispatch = this.ctx.debounce(() => {
       void this.runExclusive(async () => {
+        if (!await this.applicationReady) return
         const batch = [...changed]
         changed.clear()
         const includes = new Set<Include>()

+ 1 - 2
packages/boot/hmr/tests/modules.spec.ts

@@ -228,8 +228,7 @@ it('does not disturb an unchanged runtime after an earlier replacement fails', a
   const first = module('first.mjs', { apply() {} })
   const untouched = vi.fn()
   const second = module('second.mjs', { apply: untouched })
-  await ctx.loader.create({ name: first.url })
-  await ctx.loader.create({ name: second.url })
+  await ctx.loader.root.update([{ id: 'first', name: first.url }, { id: 'second', name: second.url }])
   await ctx.loader.await()
   imports.set(first.url, { apply() { throw new Error('failed before second') } })
   reload.stashed.add(first.url)

+ 152 - 0
packages/boot/hmr/tests/profile.spec.ts

@@ -0,0 +1,152 @@
+/** Profile watches share HMR's queue, readiness barrier and cross-process file lock. */
+import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Timer from '@deepseek-ai/cordis-plugin-timer'
+import { boot, initProfile, readProfilePatches, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
+import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import { FSWatcher } from 'chokidar'
+import { expect, it, onTestFinished, vi } from 'vitest'
+import Hmr from '../src/index.ts'
+
+const watchers = vi.hoisted(() => [] as FSWatcher[])
+vi.mock('chokidar', async (original) => {
+  const native = await original<typeof import('chokidar')>()
+  return { ...native, watch: () => {
+    const watcher = new native.FSWatcher()
+    watchers.push(watcher)
+    queueMicrotask(() => watcher.emit('ready'))
+    return watcher
+  } }
+})
+
+async function fixture() {
+  const home = realpathSync(mkdtempSync(join(tmpdir(), 'hmr-profile-')))
+  const dir = join(home, 'profiles', 'test')
+  initProfile(dir, [])
+  writeFileSync(join(dir, 'cordis.yml'), '[]\n')
+  const profile: ProfileContext = {
+    name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), home, cwd: home,
+    installAnchor: join(home, 'package.json'), startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
+  }
+  const rows = [{ insert: [
+    { id: 'timer', name: 'cordis:timer' },
+    { id: 'hmr', name: 'cordis:hmr', config: { root: [] } },
+    { id: 'probe', name: 'cordis:probe', config: { value: 'initial' } },
+  ] }]
+  writeFileSync(profile.patchPath, JSON.stringify(rows))
+  let commit: (() => void) | undefined
+  const start = watchers.length
+  const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (host) => {
+    host.provide('profileContext', profile)
+    host.provide('appReady', { onReady(listener) { commit = listener; return () => { commit = undefined } } })
+    host.loader.builtins.timer = Timer
+    host.loader.builtins.hmr = Hmr
+    host.loader.builtins.probe = { apply(ctx: Context, config: { value: string }) { ctx.provide('profileProbe', config.value) } }
+  })
+  onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
+  const configWatches = watchers.slice(start, start + 3)
+  const emit = (index: number, filename: string) => { configWatches[index]!.emit('change', filename) }
+  const drain = () => ctx.hmr.runExclusive(async () => {})
+  return { ctx, profile, dir, home, emit, drain, commit: () => commit?.() }
+}
+
+it('waits for application readiness and applies profile, home and manifest changes', async () => {
+  const f = await fixture()
+  const patches = [{ insert: [
+    { id: 'timer', name: 'cordis:timer' }, { id: 'hmr', name: 'cordis:hmr', config: { root: [] } },
+    { id: 'probe', name: 'cordis:probe', config: { value: 'edited' } },
+  ] }]
+  writeFileSync(f.profile.patchPath, JSON.stringify(patches))
+  f.emit(0, f.profile.patchPath)
+  let completed = false
+  const drained = f.drain().then(() => { completed = true })
+  await Promise.resolve(undefined)
+  expect(completed).toBe(false)
+  expect(f.ctx.get('profileProbe')).toBe('initial')
+  f.commit()
+  await drained
+  expect(f.ctx.get('profileProbe')).toBe('edited')
+  const homePatch = join(f.home, 'cordis.patch.yml')
+  writeFileSync(homePatch, '- id: probe\n  config: { value: home }\n')
+  f.emit(1, homePatch)
+  await f.drain()
+  expect(f.ctx.get('profileProbe')).toBe('home')
+  rmSync(homePatch)
+  const manifest = join(f.dir, 'package.json')
+  f.emit(2, manifest)
+  await f.drain()
+  expect(f.ctx.get('profileProbe')).toBe('edited')
+  f.emit(2, manifest)
+  await f.drain()
+  expect(f.ctx.get('profileProbe')).toBe('edited')
+})
+
+it('does not reload while a CLI package operation owns the profile lock', async () => {
+  const f = await fixture()
+  f.commit()
+  const entered = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<undefined>()
+  onTestFinished(() => { release.resolve(undefined) })
+  const locked = withFileLock(join(f.dir, 'package.json'), async () => { entered.resolve(undefined); await release.promise })
+  await entered.promise
+  f.emit(0, f.profile.patchPath)
+  let settled = false
+  const drained = f.drain().then(() => { settled = true })
+  await Promise.resolve(undefined)
+  expect(settled).toBe(false)
+  release.resolve(undefined)
+  await locked
+  await drained
+})
+
+it('cancels queued reloads when the application exits before readiness', async () => {
+  const f = await fixture()
+  f.emit(0, f.profile.patchPath)
+  await f.ctx.fiber.dispose()
+  f.commit()
+  expect(f.ctx.get('hmr')).toBeUndefined()
+})
+
+it('reports unreadable inputs and recovers after the file is corrected', async () => {
+  const f = await fixture()
+  f.commit()
+  const warn = vi.spyOn(f.ctx.logger, 'warn').mockImplementation(() => {})
+  onTestFinished(() => { warn.mockRestore() })
+  const original = readFileSync(f.profile.patchPath, 'utf8')
+  rmSync(f.profile.patchPath)
+  mkdirSync(f.profile.patchPath)
+  f.emit(0, f.profile.patchPath)
+  await f.drain()
+  expect(warn).toHaveBeenCalled()
+  rmSync(f.profile.patchPath, { recursive: true })
+  writeFileSync(f.profile.patchPath, original)
+  f.emit(0, f.profile.patchPath)
+  await f.drain()
+  expect(readFileSync(f.profile.patchPath, 'utf8')).toBe(original)
+})
+
+it('rejects a profile launcher that omits application readiness', async () => {
+  const ctx = new Context()
+  ctx.baseUrl = pathToFileURL(process.cwd() + '/').href
+  onTestFinished(() => ctx.fiber.dispose())
+  await ctx.plugin(Loader)
+  await ctx.plugin(Timer)
+  ctx.provide('profileContext', {} as ProfileContext)
+  await expect(ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })).rejects.toThrow('Profile HMR requires application readiness')
+})
+
+it('cancels a queued module notification when startup is interrupted', async () => {
+  const f = await fixture()
+  const queued = Promise.withResolvers<undefined>()
+  const hmr = f.ctx.hmr
+  const run = hmr.runExclusive.bind(hmr)
+  vi.spyOn(hmr, 'runExclusive').mockImplementation((operation) => { queued.resolve(undefined); return run(operation) })
+  watchers.at(-1)!.emit('change', 'pending.mjs')
+  await queued.promise
+  await f.ctx.fiber.dispose()
+  expect(f.ctx.get('hmr')).toBeUndefined()
+})

+ 161 - 0
packages/boot/hmr/tests/reconciliation.spec.ts

@@ -0,0 +1,161 @@
+/**
+ * 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 with live file watching.
+ */
+
+import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, describe, expect, it, onTestFinished, vi } from 'vitest'
+import { FSWatcher, type ChokidarOptions } from 'chokidar'
+import { Context } from '@deepseek-ai/cordis'
+import Hmr from '../src/index.ts'
+import Timer from '@deepseek-ai/cordis-plugin-timer'
+import {
+  boot,
+  loadOptionalPatches,
+  PROFILE_PATCH_FILENAME,
+  reconcileProfilePatches,
+} from '@deepseek-ai/dsh-app-boot'
+
+const NAME = 'dsh-test-bin'
+
+const configWatch = vi.hoisted(() => ({
+  create: undefined as ((options?: ChokidarOptions) => FSWatcher) | undefined,
+}))
+
+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),
+  }
+})
+
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
+  tempRoots.push(dir)
+  return dir
+}
+
+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))
+  }
+}
+
+function writeTree(dir: string, id = 'noop', asyncApply = false): string {
+  writeFileSync(join(dir, 'noop.mjs'), [
+    'export const name = "noop"',
+    `export ${asyncApply ? 'async ' : ''}function apply(_ctx, config = {}) {`,
+    '  if (config.fail) throw new Error("candidate config failed")',
+    '}',
+    '',
+  ].join('\n'))
+  writeFileSync(join(dir, 'cordis.yml'), `- id: ${id}\n  name: ./noop.mjs\n  config:\n    value: base\n`)
+  return join(dir, 'cordis.yml')
+}
+
+function entryConfig(ctx: Context, id: string): unknown {
+  return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
+}
+
+describe('profile reconciliation', () => {
+  it.each([
+    { id: 'noop', asyncApply: false },
+    { id: 'webserver', asyncApply: false },
+    { id: 'webserver', asyncApply: true },
+  ])('keeps HMR best effort and recovers ($id, async apply: $asyncApply)', { timeout: 20_000 }, async ({ id, asyncApply }) => {
+    const dir = tmp()
+    const userDir = tmp()
+    const filename = join(userDir, PROFILE_PATCH_FILENAME)
+    const basePatches = [{ id, config: { value: 'generated' } }]
+    const ctx = await boot(NAME, writeTree(dir, id, asyncApply), basePatches)
+    onTestFinished(() => ctx.fiber.dispose())
+    await ctx.plugin(Timer)
+    await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+    // 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 })
+    configWatch.create = (options) => {
+      const watcher = new FSWatcher(options)
+      watchers.push(watcher)
+      queueMicrotask(() => { watcher.emit('ready') })
+      return watcher
+    }
+    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 ctx.hmr.watchConfig(filename, () =>
+      reconcileProfilePatches(ctx, [...basePatches, ...loadOptionalPatches(NAME, filename) ?? []], NAME))
+    expect(watchers).toHaveLength(1)
+    const watcher = watchers[0]!
+    try {
+      writeFileSync(filename, `- id: ${id}\n  config:\n    value: live\n`)
+      watcher.emit('add', filename)
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'live', 'user patch addition was not applied')
+
+      writeFileSync(filename, `- id: ${id}\n  config:\n    fail: true\n`)
+      watcher.emit('change', filename)
+      await eventually(() => failures.length === 1, 'failed candidate was not reported')
+      expect(failures[0]).toBeInstanceOf(Error)
+      expect(entryConfig(ctx, id)).toMatchObject({ fail: true })
+
+      writeFileSync(filename, `- id: ${id}\n  disabled: !!js "JSON.parse('invalid')"\n`)
+      watcher.emit('change', filename)
+      await eventually(() => failures.length === 2, 'disabled expression failure was not reported')
+      expect(failures[1]?.message).toContain(`${id} (./noop.mjs): disabled expression failed: SyntaxError`)
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
+        .toEqual({ __jsExpr: "JSON.parse('invalid')" })
+
+      writeFileSync(filename, 'invalid: [unclosed\n')
+      watcher.emit('change', filename)
+      await eventually(() => failures.length === 3, 'parse failure was not reported')
+      expect(failures[2]).toBeInstanceOf(Error)
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.disabled)
+        .toEqual({ __jsExpr: "JSON.parse('invalid')" })
+
+      writeFileSync(filename, `- id: ${id}\n  config:\n    value: recovered\n`)
+      watcher.emit('change', filename)
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'recovered', 'valid recovery was not applied')
+      await ctx.loader.await()
+      expect([...ctx.loader.entries()].find(entry => entry.options.id === id)?.fiber?.state).toBe(2)
+
+      unlinkSync(filename)
+      watcher.emit('unlink', filename)
+      await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'generated', 'user patch removal did not restore the app-owned patch')
+      expect(failures).toHaveLength(3)
+
+      // Default compose: the user layer IS the whole patch list, so a
+      // fresh generation replaces the app-owned layer instead of stacking on it.
+      await dispose()
+      const disposeDefault = await ctx.hmr.watchConfig(filename, () =>
+        reconcileProfilePatches(ctx, loadOptionalPatches(NAME, filename) ?? [], NAME))
+      expect(watchers).toHaveLength(2)
+      try {
+        writeFileSync(filename, `- id: ${id}\n  config:\n    value: identity\n`)
+        watchers[1]!.emit('add', filename)
+        await eventually(() => (entryConfig(ctx, id) as { value?: string }).value === 'identity', 'default-compose user patch was not applied')
+      } finally {
+        await disposeDefault()
+      }
+    } finally {
+      await dispose()
+    }
+  })
+
+})

+ 9 - 0
packages/boot/hmr/tsconfig.json

@@ -22,6 +22,15 @@
     },
     {
       "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../app-boot"
+    },
+    {
+      "path": "../cmdline"
+    },
+    {
+      "path": "../../util/atomic-write"
     }
   ]
 }

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/plugin-manager/README.md
-README.md: 57fd9988e6866be26bc2e06dae39359ec2b86632
-README.zh.md: 89afffe76854e4c0bbfbc95cedf785e033b33efd
+README.md: 48a3399fb333121ad6738d60e4f0a3e3f442714e
+README.zh.md: 8d81dd49ab71bfef036d0e8535dfa208f55b423e

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Manage the current profile's plugins without editing configuration by hand. Enable or disable individual plugin entries, select installed bundles, and install or remove external bundles. Live profiles apply configuration changes immediately; startup-only profiles retain their running composition until restart. Changes affect every session using the profile.
+Manage the current profile's plugins without editing configuration by hand. Enable or disable individual plugin entries, select installed bundles, and install or remove external bundles. With HMR enabled in YAML, configuration changes apply immediately; without HMR, the running composition remains until restart. Changes affect every session using the profile.
 
 ## Table of Contents
 

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

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-管理当前 profile 的插件,无需手动编辑配置。启停单个插件条目、选择已安装的组合包,以及安装或删除外部组合包。live profile 立即应用配置变化;仅启动时加载的 profile 在重启前保留运行中的组合。改动影响使用该 profile 的全部会话。
+管理当前 profile 的插件,无需手动编辑配置。启停单个插件条目、选择已安装的组合包,以及安装或删除外部组合包。在 YAML 中启用 HMR 时,配置变化立即生效;未启用 HMR 时,运行中的组合保留到重启。改动影响使用该 profile 的全部会话。
 
 ## 目录
 

+ 14 - 9
packages/boot/plugin-manager/src/index.ts

@@ -159,7 +159,7 @@ export class PluginManager extends TypertRemoteService {
       await writePluginEnabled(this.profile.patchPath, row.patchId, enabled)
       await this.reload()
       const current = (await this.listPlugins()).find(item => item.entryId === id)
-      return current?.enabled !== enabled && this.profile.patchReload === 'live' ? 'overridden' : undefined
+      return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined
     }, `Plugin ${id}: ${enabled ? 'enabled' : 'disabled'}.`)
   }
 
@@ -215,8 +215,10 @@ export class PluginManager extends TypertRemoteService {
     return this.change(async () => {
       const bundle = (await this.listBundles()).find(item => item.name === name)
       if (bundle === undefined || !bundle.removable) throw new Error(`Bundle is not a profile-owned dependency: ${name}`)
-      if (this.profile.patchReload === 'startup' && this.profile.startedBundles.includes(name)) {
-        throw new Error('Stop this startup-only profile and remove the bundle with dsh plugin')
+      if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
+        || this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
+          .some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
+        throw new Error('Stop this profile before removing a loaded bundle with dsh plugin')
       }
       if (bundle.enabled) {
         await this.selectBundle(name, false)
@@ -252,16 +254,19 @@ export class PluginManager extends TypertRemoteService {
     await saveManifest(this.profile.dir, manifest)
   }
 
-  private protectsManager(name: string): boolean {
+  private bundleRows(name: string): EntryOptions[] {
     const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
-    if (info?.dsh?.bundle === undefined) return false
+    if (info?.dsh?.bundle === undefined) return []
     const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
-    const rows = flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]))
-    return rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
+    return flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]))
+  }
+
+  private protectsManager(name: string): boolean {
+    return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
   }
 
   private async reload(): Promise<void> {
-    if (this.profile.patchReload === 'startup') return
+    if (this.ownerContext.get('hmr') === undefined) return
     await reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh')
   }
 
@@ -272,7 +277,7 @@ export class PluginManager extends TypertRemoteService {
     const locked = () => withFileLock(join(this.profile.dir, 'package.json'), async () => {
       this.abort.signal.throwIfAborted()
       const before = this.diskState()
-      let application: ChangeResult['application'] = this.profile.patchReload === 'live' ? 'applied' : 'restart-required'
+      let application: ChangeResult['application'] = this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required'
       try {
         application = await operation() ?? application
       } catch (error) {

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

@@ -163,7 +163,7 @@ export async function runPluginCommand(
   return withFileLock(join(dir, 'package.json'), async () => {
     if (!existsSync(join(dir, 'package.json'))) {
       const template = PROFILE_TEMPLATES[context.profile]
-      initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES, template?.patchReload)
+      initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES)
       options.onOutput?.(`dsh: initialized profile ${context.profile} at ${dir}\n`, 'stderr')
     }
     return runProfilePnpm(context, args, options)

+ 28 - 7
packages/boot/plugin-manager/tests/manager.spec.ts

@@ -22,7 +22,7 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
   const dir = join(home, 'profiles', 'test')
   const anchor = join(home, 'package.json')
   writeFileSync(anchor, '{"name":"installation","dependencies":{}}\n')
-  initProfile(dir, ['core', 'extra'], reload)
+  initProfile(dir, ['core', 'extra'])
   const bundle = (name: string, rows: unknown[]) => {
     const path = join(dir, 'node_modules', name)
     mkdirSync(path, { recursive: true })
@@ -39,16 +39,24 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
   const profile: ProfileContext = {
     name: 'test',
     startedBundles: ['core', 'extra'],
-    dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home, patchReload: reload,
+    dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor, cwd: home, home,
     overlays: overlay ? [{ id: 'managed', disabled: true }] : [], telemetryDisabledEnv: undefined,
   }
   const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), (ctx) => {
+    ctx.provide('appReady', { onReady: (listener: () => void) => { listener(); return () => {} } })
     prepare?.(ctx)
     ctx.provide('profileContext', profile)
     ctx.loader.builtins.manager = PluginManager
   })
   onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
-  return { ctx, dir, manager: ctx.pluginManager, bundle, profile }
+  let stopHmr = async () => {}
+  if (reload === 'live') {
+    await ctx.plugin(Timer)
+    const owner = await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+    stopHmr = () => owner.dispose()
+    await ctx.hmr.runExclusive(async () => {})
+  }
+  return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr }
 }
 
 it('lists bundle versions and current-profile plugin targets', async () => {
@@ -171,7 +179,8 @@ it('keeps saved changes after activation failure and allows a corrected configur
   expect(await manager.setPluginEnabled(id, true)).toMatchObject({ changed: true, application: 'failed' })
   expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('disabled: false')
   writeFileSync(join(dir, 'cordis.patch.yml'), '- id: managed\n  disabled: true\n  config: { fail: false }\n')
-  expect(await manager.setPluginEnabled(id, true)).toMatchObject({ application: 'applied' })
+  const result = await manager.setPluginEnabled(id, true)
+  expect(result, JSON.stringify(result)).toMatchObject({ application: 'applied' })
 })
 
 
@@ -289,10 +298,22 @@ it('bounds batched notices and discloses omitted operation results', async () =>
 })
 
 it('applies a manager change through the active HMR service', async () => {
-  const { ctx, manager } = await fixture()
-  await ctx.plugin(Timer)
-  await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
+  const { manager } = await fixture()
   const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
   expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: true, application: 'applied' })
   expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(false)
 })
+
+it('refuses removal of a hot-installed bundle after HMR is disabled', async () => {
+  const { ctx, manager, dir, bundle, stopHmr } = await fixture()
+  bundle('later', [{ id: 'later', name: './plugin.mjs', config: { service: 'laterProbe' } }])
+  const manifest = readProfileManifest('test', dir)
+  manifest.dependencies = { ...manifest.dependencies, later: '1' }
+  writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
+  expect(await manager.setBundleEnabled('later', true)).toMatchObject({ application: 'applied' })
+  await stopHmr()
+  expect(ctx.get('hmr')).toBeUndefined()
+  expect(await manager.setBundleEnabled('later', false)).toMatchObject({ application: 'restart-required' })
+  expect(ctx.get('laterProbe')).toBe(true)
+  expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' })
+})

+ 2 - 2
packages/bundle/acp-app/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/bundle/acp-app/README.md
-README.md: 25e51402af8f522a65ea0e9f0a402f29e6d73bc2
-README.zh.md: 55fab63d45b1e938586a4c075b8567eb5332aa67
+README.md: c770e25bbcf19cd22b7bc3850eb38923f00b86e1
+README.zh.md: 3f87f92b9333f0f48d8d7171ce084d95f6bf9a56

+ 1 - 1
packages/bundle/acp-app/README.md

@@ -60,7 +60,7 @@ Stable for a fixed profile, provider, model, and tool roster. Profile changes ta
 
 - **A profile can omit the ACP bridge** — a custom ACP launch profile must retain this bundle or another `dsh-acp` row; otherwise no peer answers the client.
 - **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin.
-- **Configuration changes require restart** — the shipped `acp` profile uses `patchReload: startup` so one stdio connection never observes a replacement bridge or Agent dependency.
+- **Configuration changes require restart** — the `acp-app` bundle disables HMR in YAML so one stdio connection never observes a replacement bridge or Agent dependency.
 
 
 <a id="dev-note"></a>

+ 1 - 1
packages/bundle/acp-app/README.zh.md

@@ -60,7 +60,7 @@ profile 在第一方指导之前提供 `You are a coding agent powered by the {{
 
 - **profile 可以省略 ACP bridge**:自定义 ACP 启动 profile 必须保留本组合包或另一个 `dsh-acp` 配置项;否则没有 peer 响应 client。
 - **用户插件可能破坏 stdout 纯净性**:profile 与单次启动 patch 属于受信任的应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入的插件。
-- **配置更改需要重启**:随附 `acp` profile 使用 `patchReload: startup`,确保一条 stdio 连接不会观察到 bridge 或 Agent 依赖被替换。
+- **配置更改需要重启**:`acp-app` 组合包在 YAML 中禁用 HMR,确保一条 stdio 连接不会观察到 bridge 或 Agent 依赖被替换。
 
 
 <a id="dev-note"></a>

+ 3 - 0
packages/bundle/acp-app/cordis.patch.yml

@@ -19,3 +19,6 @@
       config:
         provider: deepseek-official
         model: deepseek-v4-flash
+
+- id: hmr
+  disabled: true

+ 1 - 1
packages/bundle/acp-app/tests/acp-app.spec.ts

@@ -24,7 +24,7 @@ describe('dsh-acp-app bundle', () => {
       disabled?: boolean
       insert?: Array<{ config?: { model?: string; provider?: string }; id?: string; inject?: string[]; name?: string }>
     }>
-    expect(patches.find(patch => patch.id === 'hmr')).toBeUndefined()
+    expect(patches.find(patch => patch.id === 'hmr')).toMatchObject({ disabled: true })
     expect(patches.find(patch => patch.id === 'session-title-llm')).toMatchObject({ disabled: true })
     const rows = patches.flatMap(patch => patch.insert ?? [])
     expect(rows.find(row => row.id === 'acp-app-startup')?.name).toBe('@deepseek-ai/dsh-acp-app')

+ 3 - 4
packages/bundle/base/cordis.patch.yml

@@ -24,13 +24,12 @@
     - id: timer
       name: '@deepseek-ai/cordis-plugin-timer'
 
-    # Module reload is opt-in per profile. `patchReload: live` config watching
-    # uses the launcher's watch-only fallback and does not require this row.
+    # Profile configuration reloads by default; module roots are opt-in.
     - id: hmr
       name: '@deepseek-ai/dsh-hmr'
-      disabled: true
+      disabled: !!js "!ctx.get('profileContext')"
       config:
-        root: ['.']
+        root: []
 
     - id: llm
       name: '@deepseek-ai/dsh-llm'

+ 1 - 2
packages/bundle/base/tests/base.spec.ts

@@ -37,8 +37,7 @@ describe('dsh-base bundle', () => {
       __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'",
     })
     expect(rows.find(row => row.id === 'hmr')).toMatchObject({
-      disabled: true,
-      config: { root: ['.'] },
+      config: { root: [] },
     })
     expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
     expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)

+ 3 - 0
packages/bundle/headless/cordis.patch.yml

@@ -29,3 +29,6 @@
         task: !!js ctx.headlessStartup.task
         sessionId: !!js ctx.headlessStartup.sessionId
         json: !!js ctx.headlessStartup.json
+
+- id: hmr
+  disabled: true

+ 2 - 2
packages/bundle/sdk-app/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/bundle/sdk-app/README.md
-README.md: 538e695e017b823b93ca8696f9e3202f33274ce4
-README.zh.md: 132388c8c1f4e01c2688f65c4d7d9bf10af4abf0
+README.md: 0bac09b5a7e355434e35b04f4e9abd3e816505af
+README.zh.md: e0e0d7e1e147c0a2dfaed7014ea0b3a0a9028f2b

+ 1 - 1
packages/bundle/sdk-app/README.md

@@ -58,7 +58,7 @@ Stable for a fixed profile, provider, model, and tool roster. Profile changes ta
 
 - **A profile can omit the SDK server** — a custom profile selected by the TypeScript client must retain this bundle or another `dsh-sdk-jsonrpc-server` row; client initialization fails when no peer answers.
 - **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin.
-- **Configuration changes require restart** — the shipped `sdk` profile uses `patchReload: startup` so one stdio connection never observes a replacement server or Agent dependency.
+- **Configuration changes require restart** — the `sdk-app` bundle disables HMR in YAML so one stdio connection never observes a replacement server or Agent dependency.
 
 
 <a id="dev-note"></a>

+ 1 - 1
packages/bundle/sdk-app/README.zh.md

@@ -58,7 +58,7 @@ profile 在第一方指导之前提供 `You are a coding agent powered by the {{
 
 - **profile 可能省略 SDK server**:TypeScript client 选择的自定义 profile 必须保留本组合包或另一个 `dsh-sdk-jsonrpc-server` 配置项;没有 peer 响应时,client 初始化会失败。
 - **用户插件可以破坏 stdout 纯净性**:profile 与逐次启动 patch 属于受信任应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入插件。
-- **配置变化需要重启**:随附 `sdk` profile 使用 `patchReload: startup`,因此一个 stdio 连接不会观察到 server 或 agent 依赖被替换。
+- **配置变化需要重启**:`sdk-app` 组合包在 YAML 中禁用 HMR,因此一个 stdio 连接不会观察到 server 或 agent 依赖被替换。
 
 
 <a id="dev-note"></a>

+ 3 - 0
packages/bundle/sdk-app/cordis.patch.yml

@@ -20,3 +20,6 @@
       inject: [sdkAppStartup, loader]
       config:
         maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)"
+
+- id: hmr
+  disabled: true

+ 1 - 1
packages/bundle/sdk-app/tests/sdk-app.spec.ts

@@ -20,7 +20,7 @@ describe('dsh-sdk-app bundle', () => {
       readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'),
       { schema: entryListSchema },
     ) as Array<{ id?: string; disabled?: boolean; insert?: Array<{ id?: string; inject?: string[]; name?: string }> }>
-    expect(patches.find(patch => patch.id === 'hmr')).toBeUndefined()
+    expect(patches.find(patch => patch.id === 'hmr')).toMatchObject({ disabled: true })
     expect(patches.find(patch => patch.id === 'session-title-llm')).toMatchObject({ disabled: true })
     const rows = patches.flatMap(patch => patch.insert ?? [])
     expect(rows.find(row => row.id === 'sdk-app-startup')?.name).toBe('@deepseek-ai/dsh-sdk-app')

+ 0 - 1
packages/util/package-manifest/src/index.ts

@@ -10,5 +10,4 @@ export type {
   DshManifest,
   DshPackageManifest,
   DshProfileManifest,
-  ProfilePatchReload,
 } from './types.ts'

+ 0 - 5
packages/util/package-manifest/src/types.ts

@@ -58,13 +58,8 @@ export interface DshBundleManifest {
 export interface DshProfileManifest {
   /** Ordered bundle layer list, using installed package names. */
   bundles?: string[]
-  /** User patch lifecycle; omitted means `live` for custom profiles. */
-  patchReload?: ProfilePatchReload
 }
 
-/** Whether user patch files reload while a profile remains active or apply only at startup. */
-export type ProfilePatchReload = 'live' | 'startup'
-
 /** Client module declaration read by client-modules and the client build. */
 export interface DshClientManifest {
   /** Client platform identifier; the Web consumer selects `web`. */

+ 9 - 6
pnpm-lock.yaml

@@ -1392,9 +1392,6 @@ 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
@@ -1420,9 +1417,6 @@ importers:
       '@deepseek-ai/cordis-plugin-timer':
         specifier: workspace:^
         version: link:../../../vendor/timer
-      '@deepseek-ai/dsh-hmr':
-        specifier: workspace:^
-        version: link:../hmr
       '@deepseek-ai/dsh-home-paths':
         specifier: workspace:^
         version: link:../../util/home-paths
@@ -1456,6 +1450,9 @@ importers:
       '@babel/code-frame':
         specifier: ^7.29.0
         version: 7.29.7
+      '@deepseek-ai/dsh-atomic-write':
+        specifier: workspace:^
+        version: link:../../util/atomic-write
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -1478,6 +1475,12 @@ importers:
       '@deepseek-ai/cordis-plugin-timer':
         specifier: workspace:^
         version: link:../../../vendor/timer
+      '@deepseek-ai/dsh-app-boot':
+        specifier: workspace:^
+        version: link:../app-boot
+      '@deepseek-ai/dsh-cmdline':
+        specifier: workspace:^
+        version: link:../cmdline
       '@types/babel__code-frame':
         specifier: ^7.27.0
         version: 7.27.0

+ 1 - 1
scripts/verify-default-product-isolation.spec.ts

@@ -40,7 +40,7 @@ function fixture(): string {
   write(root, 'packages/bundle/base/package.json', { name: base, dsh: { bundle: { patch: './cordis.patch.yml' } } })
   write(root, patch, [{ insert: [{ name: core }] }])
   write(root, preset, [{ name: core }])
-  write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'], patchReload: 'live' } }\n`
+  write(root, profile, `export const PROFILE_TEMPLATES = { web: { bundles: ['${base}'] } }\n`
     + `export const DEFAULT_PROFILE_BUNDLES = ['${base}']\n`)
   write(root, 'packages/experimental/prototype/package.json', { name: experimental })
   return root