Bläddra i källkod

Merge remote-tracking branch 'origin/master' into desktop/native-fatal-recovery

07akioni 3 veckor sedan
förälder
incheckning
d50d3cb745
100 ändrade filer med 2108 tillägg och 395 borttagningar
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 8 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 14 8
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  5. 33 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  6. 33 0
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.i18n.yaml
  8. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md
  9. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.zh.md
  10. 6 0
      .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml
  11. 35 0
      .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md
  12. 35 0
      .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md
  13. 2 2
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.i18n.yaml
  14. 1 1
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.md
  15. 1 1
      .agents/notes/implemented/testing/2026-09-09-user-patch-hmr-test-delivery.zh.md
  16. 1 0
      .github/review-ownership/.gitignore
  17. 9 5
      .github/review-ownership/README.md
  18. 86 0
      .github/review-ownership/blame-ownership.mjs
  19. 52 0
      .github/review-ownership/blame-ownership.test.mjs
  20. 135 0
      .github/review-ownership/blame-production.py
  21. 41 10
      .github/review-ownership/check-approval.mjs
  22. 132 2
      .github/review-ownership/check-approval.test.mjs
  23. 1 0
      .github/review-ownership/requirements.txt
  24. 221 0
      .github/review-ownership/test_blame_production.py
  25. 7 0
      .github/workflows/ci.yml
  26. 22 2
      .github/workflows/weighted-approval.yml
  27. 1 1
      THIRD_PARTY_NOTICES.md
  28. 2 2
      apps/cli/README.i18n.yaml
  29. 1 1
      apps/cli/README.md
  30. 1 1
      apps/cli/README.zh.md
  31. 8 2
      apps/cli/composition.md
  32. 4 2
      apps/cli/package.json
  33. 2 2
      apps/cli/reference/README.i18n.yaml
  34. 4 4
      apps/cli/reference/README.md
  35. 4 4
      apps/cli/reference/README.zh.md
  36. 1 1
      apps/cli/src/bin.ts
  37. 19 99
      apps/cli/src/plugin.ts
  38. 26 135
      apps/cli/src/profile-boot.ts
  39. 142 17
      apps/cli/tests/built-bin.e2e.ts
  40. 16 17
      apps/cli/tests/profile-hmr.spec.ts
  41. 3 4
      apps/cli/tests/profile-initialization.spec.ts
  42. 1 2
      apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts
  43. 20 0
      apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
  44. 6 16
      apps/cli/tests/resolved-profile-boot.spec.ts
  45. 1 1
      apps/cli/tests/telemetry-switch.spec.ts
  46. 0 1
      apps/cli/tests/web-agent-presets.e2e.ts
  47. 6 0
      apps/cli/tsconfig.json
  48. 3 3
      apps/desktop/src/project-manager.ts
  49. 241 0
      apps/web/tests/client-plugin-live.e2e.ts
  50. 4 0
      apps/web/tests/expected/client-plugin-live/bootstrap-rebuild.expected.md
  51. 1 0
      apps/web/tests/expected/client-plugin-live/enabled.expected.md
  52. 1 0
      apps/web/tests/expected/client-plugin-live/recovered.expected.md
  53. 31 0
      apps/web/tests/fixtures/plugins/fixture-live-client/client.js
  54. 3 0
      apps/web/tests/fixtures/plugins/fixture-live-client/cordis.patch.yml
  55. 2 0
      apps/web/tests/fixtures/plugins/fixture-live-client/index.js
  56. 13 0
      apps/web/tests/fixtures/plugins/fixture-live-client/package.json
  57. 0 1
      apps/web/tests/scaffold.ts
  58. 1 0
      apps/web/tsconfig.json
  59. 2 2
      docs/architecture.i18n.yaml
  60. 3 1
      docs/architecture.md
  61. 3 1
      docs/architecture.zh.md
  62. 2 2
      docs/capability-seams.i18n.yaml
  63. 17 0
      docs/capability-seams.md
  64. 17 0
      docs/capability-seams.zh.md
  65. 2 2
      docs/config-catalog.i18n.yaml
  66. 44 2
      docs/config-catalog.md
  67. 44 2
      docs/config-catalog.zh.md
  68. 0 3
      docs/cordis-api/inherited.md
  69. 2 2
      docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml
  70. 2 2
      docs/cordis-tutorial/06-composition-and-hmr.md
  71. 2 2
      docs/cordis-tutorial/06-composition-and-hmr.zh.md
  72. 2 2
      docs/event-producer-consumer.i18n.yaml
  73. 2 0
      docs/event-producer-consumer.md
  74. 2 0
      docs/event-producer-consumer.zh.md
  75. 2 2
      docs/module-graph.i18n.yaml
  76. 13 0
      docs/module-graph.md
  77. 13 0
      docs/module-graph.zh.md
  78. 2 2
      docs/subsystems/README.i18n.yaml
  79. 1 0
      docs/subsystems/README.md
  80. 1 0
      docs/subsystems/README.zh.md
  81. 6 0
      docs/subsystems/boot.i18n.yaml
  82. 146 0
      docs/subsystems/boot.md
  83. 146 0
      docs/subsystems/boot.zh.md
  84. 2 2
      docs/subsystems/client-modules.i18n.yaml
  85. 1 1
      docs/subsystems/client-modules.md
  86. 1 1
      docs/subsystems/client-modules.zh.md
  87. 2 2
      docs/tool-catalog.i18n.yaml
  88. 50 0
      docs/tool-catalog.md
  89. 50 0
      docs/tool-catalog.zh.md
  90. 2 2
      docs/user/develop/framework/index.i18n.yaml
  91. 1 1
      docs/user/develop/framework/index.md
  92. 1 1
      docs/user/develop/framework/index.zh.md
  93. 1 1
      package.json
  94. 2 1
      packages/api/remotes/package.json
  95. 4 1
      packages/api/remotes/src/client/index.ts
  96. 3 0
      packages/api/remotes/tsconfig.client.json
  97. 2 2
      packages/api/session-controller/README.i18n.yaml
  98. 0 0
      packages/api/session-controller/README.md
  99. 0 0
      packages/api/session-controller/README.zh.md
  100. 3 2
      packages/api/session-controller/src/client/sessions/manager.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: d0f9b20f0adabda6cc7132e5411bcadd60c9e108
-2026-07-23-client-plugin-loading-model.zh.md: 27d5026ed03509d6408a16389246cb1321a37959
+2026-07-23-client-plugin-loading-model.md: e9dc734c05eb5a73a7c8ef531bd3042548e0f06e
+2026-07-23-client-plugin-loading-model.zh.md: f4df6c0a108bd2b99756764df089fd3c7f58839f

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 8 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md


+ 14 - 8
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -40,11 +40,11 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 ### Combo 外部脚本到达与源码映射
 
-Host 会快照每个已构建插件产物,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本都由其中的 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。`bootstrap` 与 `application` 是图中的调度阶段,不是 URL 组成部分:HTML 先预加载所有 application URL,再执行所有阻塞 parser 的 bootstrap URL。模块系统按 combo URL 复用进行中的传输,因此同组 row 的并发到达只执行一个脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
 共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形式 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。Combo 生成会移除每个局部调试指令、记录其生成行偏移、以原插件 map URL 解析每个自带 source,再产出 Indexed Source Map v3。插件有自带 map 时直接用于对应 section;没有时则生成 identity section,内嵌构建后 bundle,并在存在时把 packer 写入的 `sourceURL` 用作 source 名。绝对 map URL 会平行改写脚本资源列表中的每个 `client.js` 后缀,因此 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 指向 `/plugins/??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`。单资源也采用相同规则,仍产出只有一个 section 的 indexed map。Vite 壳同样产出 sourcemap,使壳代码与经 combo 加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加按内容寻址的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。启动 combo revision 覆盖合并脚本输入与 indexed map。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle,并发布所得 revision。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -68,25 +68,31 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 4. `settled` = 每个 entry 已创建 + `loader.await()` 完全停稳 + 一次全 ACTIVE 扫描。扫描列出每个 import 失败、FAILED 或 PENDING 的 fiber 及其缺失的服务。它存在的理由:cordis 的 inject 等待没有超时——这次扫描就是大声失败的兜底线。
 5. 不依赖框架的 loading 页经 `internal/status` 投影真实 fiber 状态。检查完成后,内核调用 `ctx.uiRenderer.mount(container)`,一次切换到真实 UI。
 
+### 动态图对账
+
+modules 控制器只持有从启动清单创建的 Loader 条目。Host 的完整快照更新模块描述并对账这些条目;其他 Loader 贡献方保留自身所有权。新增模块通过单资源 URL 到达,因为重放启动 batch 可能重复注册现有 factory。移除使用 Loader 删除语义,随后等待已捕获 fiber 清理完毕,再移除未使用的模块与样式。已声明及已观察到的传递依赖使共享模块保持存活。Factory revision 的跟踪独立于条目激活,因为下载或物化完成后仍可能没有创建条目。图更新会在任何消费者导入依赖前,使未归属受管条目的陈旧 factory 和失败的到达目标失效,并清除其样式;重建帧也会对账因导入失败而缺失的条目。
+
+Host SSE 适配器转发现有图变化通知,并在连接时发送当前完整图。图描述浏览器的目标条目,不代表 Host 清理完成:Host 生命周期顺序属于其 Loader,每个浏览器则等待自身被移除 fiber 的清理。图对账与代码重建共用一个页面队列。本地代际阻止过期下载挂载;不透明 revision 只比较相等。失败页面报告本地错误,并可重试同一张图而不改变 Host 启用状态。这保留了无关页面状态,也无需重启应用或引入第二套插件执行器。Electron 的独立安装流程不属于此机制。
+
 ### 热重载:一个驱动插件,自行监视的 bundle
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它会在新产物快照中一并读取当前 source map,而仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。Bundle 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;它只哈希可执行 bundle 字节,所以仅写入 map 不会重新挂载未变化的可执行代码。`rev` 真正变化时,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次 bundle stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
-浏览器侧,驱动插件每帧重载一个插件,串行执行:
+浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
 
 1. `invalidate`——丢弃陈旧的 factory 与记录,并把 rebuilt 帧的 revision 绑定到该 row 的单资源 combo URL。Factory 还活着会让下一步变成 no-op。
 2. `prefetch`——加载该单资源外部脚本并登记新 factory,旧 fiber 此刻仍在服役。初始多资源脚本不会再次执行。
 3. `registry.delete`——先于任何 fiber 操作。裸做 fiber dispose 会触发 vendored Loader 的自 dispose 分支,把 entry 永久停用。
 4. 排空旧 fiber 的各 disposer。
 5. 移除名下的 `<style data-plugin>` 标签。
-6. `entry.refresh()`——重新 import,物化新工厂。CSS 在这里重新注入,沿用同一批稳定标签 id。
+6. 通过模块系统物化新导出,再调用 `entry.refresh()` 由 Loader 挂载。CSS 在旧 disposer 清理完成后重新注入;显式物化使导入错误能够被捕获,而不只留下 Loader 的控制台日志。
 7. `fiber.await()`——让失败大声重抛。
 
-每个插件都共享同一套语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
+Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始导出,重新挂载旧代码会重置消费者,却无法应用请求的 revision。页面会报告需要刷新,并保留 bootstrap fiber。所有非 bootstrap 插件都共享同一套替换语义;`immediately` 行的重载与 lazy 行分毫不差。依赖级联不花一行 client 代码:fiber 的激活纪元串接着它各服务提供方的 uid,因此替换 connection 等基础 provider 的 fiber 时,每个依赖方都会经 cordis 本身重新装载——行为正确,但代价较高。
 
-支持边界,如实陈述。重载粒度刻意做粗:全新 fiber、全新组件、React 状态丢失、数据层不动——react-refresh 级的状态保留与「重执行 bundle 即重跑 factory」相冲突,属刻意不做。静态装配包与外壳内核不是 entry:改动它们意味着外壳重建加整页刷新。重载不做回滚:import 失败让 entry 失去 fiber,下一个 rebuilt 帧从头重试;apply 失败留下 FAILED fiber 交给状态投影;两者都大声记录。自我重载可行——在途的重载在旧 bundle 的闭包里跑完,新的 apply 再开一条新 SSE 通道——但空窗期到达的帧会丢失,下次重建会再次通知。一处已知的仅限 dev 竞态:rebuilt 帧与仍在途的 boot 到达重叠时共享那次到达的任务,可能物化重建前的字节;下一帧自愈。
+重载会创建新的 fiber 和组件状态,不保留被替换插件内部的 React 状态。静态组装库与应用壳需要重建后的页面。导入或激活失败仍可诊断和重试,不会回滚无关插件。自重载关闭旧 SSE 通道并打开新通道;其完整快照补齐遗漏的图变更。启动、图更新和重建帧共用同一队列,因此代码替换不会与初始模块到达重叠。
 
 ## 包归属
 
@@ -96,7 +102,7 @@ Host 会快照每个已构建插件产物,并把每个调度阶段的有序 ro
 
 Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
-接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。
+接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
 
 名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。
 

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

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

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

@@ -0,0 +1,33 @@
+# Agent Note: Current-profile plugin management shares CLI transactions
+
+Status: implemented
+
+English | [中文](2026-09-14-current-profile-plugin-management.zh.md)
+
+## Problem
+
+Web and agent controls need to change a running profile without creating an independent package installer or overwriting user-authored YAML. A file watcher can otherwise load the intermediate manifest written during package installation, or report success before removed plugins finish releasing resources.
+
+## 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 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 manager configuration changes through one queue. HMR registers the profile watches during its own initialization, then waits for application readiness before processing edits. Manifest notifications compare only the ordered bundle list; dependency-only changes do not trigger a configuration reload. The final YAML composition controls whether HMR runs; the launcher installs no fallback. Pnpm runs outside `hmr.runExclusive()`; only configuration changes and Loader updates enter that queue. HMR does not acquire the package writer lock, so installation cannot block unrelated file-driven configuration changes. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
+
+Configuration watches use Chokidar write stabilization by default. Its ordinary change handler discards a second event within 50 ms, so a write immediately after activation can leave the previous bundle running. Stabilized delivery observes the final file instead; file-driven updates pay the stability delay, while direct manager transactions do not. A regression feeds consecutive changes through Chokidar’s real normalization and verifies both applied states.
+
+Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, 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). 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.
+
+CLI calls inherit the terminal and authentication environment; service calls retain the subprocess credential scrub and bounded diagnostics. Management records carry error codes and parameters for locale-owned Web presentation. Reconciliation compares entry identity, fiber identity, configuration and diagnostics before and after updating: unchanged inactive entries remain warnings, while newly affected failures reject the operation. Explicit enablement targets must activate.
+
+## Alternatives considered
+
+**Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation.
+
+**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest. Failed installation permits one removal attempt for an unambiguously identified new dependency. Existing dependencies and successful installations whose activation fails remain in place. Invalid leftover packages stay visible and removable.
+
+**Source-module hot replacement for package updates.** Configuration changes can reuse the loaded module cache, whereas replacing installed JavaScript needs a new process generation. Replacing an existing dependency reports a required restart.
+
+## Consequences
+
+The same profile can be managed through CLI, Web and tools, with file-level coordination and preserved patch precedence. Operators must repair failed package operations using the reported files and diagnostics. A startup process must stop before its loaded packages can be removed through CLI. Management components remain protected against service-initiated removal.

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

@@ -0,0 +1,33 @@
+# Agent Note:当前 profile 插件管理共享 CLI 事务
+
+Status: implemented
+
+[English](2026-09-14-current-profile-plugin-management.md) | 中文
+
+## 问题
+
+Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安装器或覆盖用户编写的 YAML。文件监听器可能读到安装期间写入的中间 manifest,也可能在被删除插件尚未释放资源时报告成功。
+
+## 决策
+
+[插件管理器](../../../../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 重新组合与管理器配置变更。HMR 在自身初始化时注册 profile 监听,等待应用就绪后再处理编辑。manifest 通知只比较有序组合包列表;仅依赖字段变化不会触发配置重载。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。pnpm 在 `hmr.runExclusive()` 外执行;只有配置变更和 Loader 更新进入该队列。HMR 不获取包操作写锁,因此安装不会阻塞其他由文件变化触发的配置更新。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
+
+配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。
+
+profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
+
+这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service;service 合并持久通知,告知存活 Agent 而不唤醒它们。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
+
+CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
+
+## 考虑过的替代方案
+
+**由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。
+
+**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用。安装失败只允许对可唯一识别的新增依赖尝试一次删除。已有依赖及安装成功但激活失败的包保留原处。无效的残留依赖仍可见、可删除。
+
+**包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。
+
+## 影响
+
+同一 profile 可以通过 CLI、Web 和工具管理,操作通过文件锁协调并保留 patch 优先级。运维人员需要根据报告的文件和诊断修复失败的包操作。startup 进程必须先停止,才能通过 CLI 删除其加载的包。管理组件受保护,不能通过 service 删除。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md
+2026-09-15-linear-session-list-cache.md: fb09950f77c224d2b85bb76792bdf8c3c87e33f5
+2026-09-15-linear-session-list-cache.zh.md: b877f0659fff4e1b3d48aa4250e634120ddc365f

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md

@@ -0,0 +1,25 @@
+# Agent Note: Linear Session list cache reconciliation
+
+Status: implemented
+
+English | [中文](2026-09-15-linear-session-list-cache.zh.md)
+
+## Problem
+
+The Client Session list retains row objects for React reference stability. Scanning the entire new list for every cached ID makes snapshot rebuilds quadratic, including first hydration because new rows enter the cache before cleanup. Thousands of Sessions can occupy the browser thread during list updates.
+
+## Decision
+
+SessionManager builds one ID set from its reconciled rows and uses it for cache eviction and selected-row membership. Field comparisons, row identities, array reuse, lineage order, and retained subagent addresses keep their existing semantics.
+
+## Alternatives considered
+
+**Replace the row cache on every rebuild.** A new Map can also remove missing entries linearly, but requires changing the row-reuse path. A temporary membership set confines the change to membership checks.
+
+**Enforce unit-test wall-clock limits.** Shared CI load makes tight time budgets unreliable. An instance-local ID accessor counts membership reads during repeated refreshes; the original implementation exceeds the linear bound without depending on machine speed.
+
+## Consequences
+
+Reconciliation uses O(n + c) time and O(n) temporary membership storage for n current rows and c cached rows. Missing rows lose cached identity; unchanged rows and their array retain identity, and an off-list selection candidate can become current again when its row returns.
+
+Local macOS arm64 Node 26 measurements compile the production manager to JavaScript and time subscribed refreshes through the resulting list snapshot, with fresh synthetic Remote response objects. After three warmups, nine samples give median refresh times of 0.67, 1.40, 2.78, and 5.46 ms for 1,000, 2,600, 5,000, and 10,000 rows. The corresponding original medians are 4.86, 29.01, 30.62, and 459.89 ms. These measurements include list reconstruction and exclude server scanning, transport, DOM rendering, browser input, and memory measurement; they do not establish end-to-end reconnect latency. The focused manager tests cover eviction, empty lists, selection recovery, identity reuse, and the linear read bound.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Session 列表缓存的线性对账
+
+Status: implemented
+
+[English](2026-09-15-linear-session-list-cache.md) | 中文
+
+## 问题
+
+Client Session 列表保留行对象,以维持 React 引用稳定性。为每个缓存 ID 扫描整个新列表,会使快照重建成本按平方增长;首次加载也受影响,因为新行在清理之前就已进入缓存。数千个 Session 可能在列表更新时占用浏览器线程。
+
+## 决策
+
+SessionManager 从对账后的行构建一个 ID 集合,用于缓存淘汰和选中行的成员检查。字段比较、行对象标识、数组复用、派生顺序和保留的子智能体地址维持现有语义。
+
+## 考虑过的替代方案
+
+**每次重建都替换行缓存。** 新 Map 同样可以在线性时间内移除缺失条目,但需要修改行复用路径。临时成员集合把改动限定在成员检查。
+
+**在单元测试中限制实际耗时。** 共享 CI 负载会使严格时间预算不可靠。实例局部的 ID 访问器统计重复刷新时的成员读取次数;原实现超过线性上限,无需依赖机器速度。
+
+## 影响
+
+对于 n 个当前行和 c 个缓存行,对账使用 O(n + c) 时间和 O(n) 临时成员存储。缺失行失去缓存标识;未变化的行及其数组保留标识,离开列表的候选选中项在行返回后可以再次成为当前项。
+
+本地 macOS arm64 Node 26 测量将生产 manager 编译为 JavaScript,并在存在订阅时,从刷新开始计时到结果列表快照可读,Remote 响应使用新建的合成对象。预热三次后采集九个样本,1,000、2,600、5,000 和 10,000 行的刷新耗时中位数分别为 0.67、1.40、2.78 和 5.46 ms。原实现对应中位数为 4.86、29.01、30.62 和 459.89 ms。测量包含列表重建,排除服务端扫描、传输、DOM 渲染、浏览器输入和内存测量,因此不能证明端到端重连延迟。聚焦 manager 的测试覆盖淘汰、空列表、选择恢复、标识复用和线性读取上限。

+ 6 - 0
.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md
+2026-09-11-production-blame-approval-weight.md: d9a223c2f332cc110ddd8f6a3f592febe0072987
+2026-09-11-production-blame-approval-weight.zh.md: f4dfc9d4fc5cefcccadb2785f58893ec92043ba7

+ 35 - 0
.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md

@@ -0,0 +1,35 @@
+# Agent Note: Weight approvals by changed production-line ownership
+
+Status: implemented
+
+English | [中文](2026-09-11-production-blame-approval-weight.zh.md)
+
+## Problem
+
+A fixed one-point reviewer weight does not reflect authorship of the code a pull request changes. Directory-level ownership can reward unrelated code in the same folder and distort the contribution relevant to the review.
+
+## Decision
+
+The [approval policy](../../../../.github/review-ownership/README.md) scales a one-point approval by `min(2, 1 + 4 × ownedLines / totalLines)` over changed old production lines. Ownership of 0% gives one point, 12.5% gives 1.5 points, and 25% or more gives two points. Scores are not rounded before comparison with the two-point success threshold. The merge base supplies both classification and blame, while GitHub associates blame commits with reviewer accounts. Unlinked authors remain in the denominator. Additions have no prior owner and contribute no lines; an empty denominator produces no boost.
+
+The publisher marks the head pending before dependency setup and evaluation, so an interrupted history fetch cannot preserve an earlier success. It uses the live base branch and exact reviewed head, reads complete Git history without checking out PR code, and skips attribution when base points or blockers already decide the result. A maintained lexer separates comments from code across the repository’s source languages. Author lookups batch commits and all reviewers share one measurement. Existing [pending-status semantics](2026-09-09-blocked-weighted-approvals-remain-pending.md) and [review-event validation](2026-09-10-approval-review-workflow-identity.md) remain independent requirements.
+
+## Alternatives considered
+
+**A hard cutoff.** Linear weighting distinguishes partial ownership below 25%. The 25% cap lets a reviewer responsible for one quarter of the changed old code satisfy the two-point requirement; additional ownership does not increase their vote beyond that requirement.
+
+**Directory-weighted ownership.** Code elsewhere in a changed directory does not establish ownership of the lines under review.
+
+**Include newly added lines in the denominator.** Those lines have no merge-base owner and would dilute the authorship signal for existing code.
+
+**Match author names or email strings to reviewer logins.** Display names are not account identifiers, and one account can own commits under multiple emails. GitHub’s commit-author account association supplies that mapping.
+
+## Consequences
+
+History fetching dominates cold execution. Complete history is required before selecting the merge base: a shallow apparent ancestor can exclude changes from the denominator, so shallow classification cannot safely decide that blame is unnecessary. On 2026-09-11, local measurements of PRs #3969 and #3977 count 73 and 535 old production lines across 4 and 14 files. Three local runs take 0.31–0.32 seconds and 0.87–0.93 seconds; one batched author query adds approximately one second per PR. A fresh single-branch bare clone of master takes 47 seconds on the same host. These are host observations, not CI time guarantees.
+
+Blame measures last-touch authorship, not review quality or semantic expertise. The production classifier excludes unsupported source locations and file extensions; adding shipped source outside its inventory requires extending that classifier. Lexer classification is lexical rather than a semantic test of executable behavior. Evaluation errors retain the error status instead of silently awarding or withholding the boost.
+
+## Verification
+
+[Policy tests](../../../../.github/review-ownership/check-approval.test.mjs) cover the threshold, zero denominators, blockers, shared measurements, and failed attribution. [Author tests](../../../../.github/review-ownership/blame-ownership.test.mjs) cover batching, account aggregation, and incomplete responses. [Git integration tests](../../../../.github/review-ownership/test_blame_production.py) exercise merge bases, renames, deletions, mixed comment lines, exclusions, shallow history, and fetching without checking out PR code.

+ 35 - 0
.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 按变更生产代码行的归属调整审批权重
+
+Status: implemented
+
+[English](2026-09-11-production-blame-approval-weight.md) | 中文
+
+## Problem
+
+固定的一分评审权重无法反映评审者对拉取请求所修改代码的作者贡献。目录级归属可能奖励同一文件夹中的无关代码,扭曲与本次评审有关的贡献。
+
+## Decision
+
+[审批策略](../../../../.github/review-ownership/README.md) 按变更旧生产代码行的归属比例,以 `min(2, 1 + 4 × ownedLines / totalLines)` 调整一分批准的权重。归属比例为 0% 时计一分,12.5% 时计 1.5 分,25% 及以上时计两分。分数在与两分通过线比较前不做舍入。合并基点同时提供代码分类和 blame 依据,GitHub 将归属提交关联到评审者账号。无法关联账号的作者仍计入分母。新增行没有原作者,不计入行数;分母为空时不提升权重。
+
+发布器在依赖安装和评估前将头提交标记为待定,避免历史拉取中断后保留此前的成功状态。它使用实时 base 分支和被评审的精确 head,读取完整 Git 历史,不检出 PR 代码;基础分数或阻塞评审已决定结果时跳过归属计算。维护中的词法分析器区分仓库各源码语言中的注释与代码。作者查询按提交批量执行,所有评审者共用一次统计。现有的[待定状态语义](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)和[评审事件验证](2026-09-10-approval-review-workflow-identity.zh.md)仍是独立要求。
+
+## Alternatives considered
+
+**硬阈值。** 线性权重区分 25% 以下的部分归属。25% 封顶让负责四分之一变更旧代码的评审者满足两分要求;更多归属不会使其票数超过该要求。
+
+**目录加权归属。** 变更目录中其他代码的归属不能证明待评审代码行的归属。
+
+**将新增行计入分母。** 这些行没有合并基点上的作者,会稀释既有代码的作者贡献信号。
+
+**将作者姓名或邮箱字符串与评审者登录名匹配。** 显示名称不是账号标识,一个账号也可能使用多个邮箱提交代码。GitHub 的提交作者账号关联提供该映射。
+
+## Consequences
+
+冷启动的主要开销是拉取历史。选择合并基点前必须具备完整历史:浅历史中看似共同祖先的提交可能使部分变更被排除出分母,因此不能据此安全判定无需 blame。2026-09-11,在本机测量 PR #3969 和 #3977,得到 4 和 14 个文件中的 73 和 535 行旧生产代码。三次本地运行分别耗时 0.31–0.32 秒和 0.87–0.93 秒;每个 PR 的一次批量作者查询额外耗时约一秒。同一主机上,全新单分支裸克隆 master 耗时 47 秒。这些是本机观测值,不是 CI 耗时保证。
+
+Blame 衡量最后修改者归属,不代表评审质量或语义上的专业能力。生产代码分类器排除未支持的源码位置和扩展名;在其清单之外添加交付源码时,必须扩展分类器。词法分类不判断代码是否在语义上可执行。评估错误保留错误状态,不静默授予或取消加权。
+
+## Verification
+
+[策略测试](../../../../.github/review-ownership/check-approval.test.mjs)覆盖阈值、零分母、阻塞评审、共享统计和归属计算失败。[作者测试](../../../../.github/review-ownership/blame-ownership.test.mjs)覆盖批量查询、账号聚合和不完整响应。[Git 集成测试](../../../../.github/review-ownership/test_blame_production.py)验证合并基点、重命名、删除、混合注释行、排除规则、浅历史,以及不检出 PR 代码的拉取过程。

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

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

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

@@ -12,7 +12,7 @@ The macOS Sandbox run (run 34238200206, job 102101292119) times out while waitin
 
 The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
 
-The separate [watcher tests](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
+The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
 
 ## Alternatives considered
 

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

@@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p
 
 [用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
 
-独立的 [监视器测试](../../../../packages/boot/app-boot/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
+独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
 
 ## 考虑过的替代方案
 

+ 1 - 0
.github/review-ownership/.gitignore

@@ -0,0 +1 @@
+__pycache__/

+ 9 - 5
.github/review-ownership/README.md

@@ -15,19 +15,23 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
 
 ## Approval scoring
 
-The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
+The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
 
 Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count.
 
+A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when base approval points already meet the threshold or a blocking review exists. Displayed scores use at most two decimal places; the decision uses the unrounded score. The curve endpoints come from the policy’s default and required points.
+
+Production source means supported code files under `src/` in `packages/`, `apps/`, `python/`, and `native/`, plus the Desktop renderer, Python interpreter scripts, and committed runtime/packer launchers. The [classifier](blame-production.py) excludes documentation, tests, fixtures, snapshots, test support (including `src/testing/` and `src/testing.ts`), examples, generated source, dependencies, vendored code, declarations, comments, and blank lines. Pygments lexers distinguish comments from strings; mixed code/comment lines count, as do C preprocessor directives. Classification uses the old path and content, so changes to the PR’s file locations or generated headers cannot remove old lines from the denominator. Pure renames have no changed lines; renames with edits use the old path for blame.
+
 Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
 
-The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
+The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Pull-request and review events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
 
 <a id="security"></a>
 
 ## Security
 
-The status-writing job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
+All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, base points are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
 
 Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 
@@ -35,10 +39,10 @@ Approval policy changes take effect only after they merge into the default branc
 
 ## Verification
 
-Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI.
+Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
 
 <a id="dev-note"></a>
 
 ## Dev Note
 
-None.
+[Production blame weighting](../../.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md) records the scoring rationale and measured costs.

+ 86 - 0
.github/review-ownership/blame-ownership.mjs

@@ -0,0 +1,86 @@
+/** Merge-base production ownership for approval scoring; PR blobs are data, never programs. */
+import { execFile } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+
+const exec = promisify(execFile)
+/** GitHub account syntax shared by policy and commit-author validation. */
+export const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
+const SHA = /^[0-9a-f]{40}$/u
+
+/**
+ * Resolve GitHub author accounts for counted commits, retaining unknown authors in the denominator.
+ * @param {{totalLines: number, commitLines: Record<string, number>}} measurement Local blame counts.
+ * @param {string} repository Owner/name.
+ * @param {(path: string, options: object) => Promise<unknown>} api GitHub API caller.
+ * @returns {Promise<{totalLines: number, reviewerLines: Record<string, number>}>} Case-folded account counts.
+ */
+export async function resolveBlameAuthors(measurement, repository, api) {
+  const entries = Object.entries(measurement.commitLines)
+  if (!Number.isSafeInteger(measurement.totalLines) || measurement.totalLines < 0
+    || entries.some(([sha, count]) => !SHA.test(sha) || !Number.isSafeInteger(count) || count <= 0)
+    || entries.reduce((sum, [, count]) => sum + count, 0) !== measurement.totalLines) {
+    throw new Error('invalid production blame counts')
+  }
+  const [owner, name] = repository.split('/')
+  const reviewerLines = Object.create(null)
+  for (let offset = 0; offset < entries.length; offset += 50) {
+    const batch = entries.slice(offset, offset + 50)
+    const fields = batch.map(([sha], index) => `c${index}: object(oid: "${sha}") { ... on Commit { author { user { login } } } }`)
+    const response = await api('/graphql', {
+      method: 'POST',
+      body: {
+        query: `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${fields.join('\n')} } }`,
+        variables: { owner, name },
+      },
+    })
+    if (response.errors?.length || !response.data?.repository) throw new Error('GitHub blame author lookup failed')
+    for (const [index, [, count]] of batch.entries()) {
+      const author = response.data.repository[`c${index}`]?.author
+      if (!author || !Object.hasOwn(author, 'user')) throw new Error('GitHub returned no blame commit author')
+      if (author.user === null) continue
+      const login = author.user.login
+      if (typeof login !== 'string' || !LOGIN.test(login)) {
+        throw new Error('GitHub returned an invalid blame author login')
+      }
+      const key = login.toLowerCase()
+      reviewerLines[key] = (reviewerLines[key] ?? 0) + count
+    }
+  }
+  return { totalLines: measurement.totalLines, reviewerLines }
+}
+
+/**
+ * Fetch complete history without checking out the PR and measure its old production lines once.
+ * @param {{repository: string, number: number, headSha: string}} pull Reviewed pull request.
+ * @param {(path: string, options?: object) => Promise<unknown>} api GitHub API caller.
+ * @returns {Promise<{totalLines: number, reviewerLines: Record<string, number>}>} Production ownership.
+ */
+export async function productionOwnership(pull, api) {
+  const current = await api(`/repos/${pull.repository}/pulls/${pull.number}`)
+  if (current.head?.sha !== pull.headSha || !SHA.test(pull.headSha) || typeof current.base?.ref !== 'string') {
+    throw new Error('pull request changed or has no valid base branch')
+  }
+  const baseRef = `refs/heads/${current.base.ref}`
+  await exec('git', ['check-ref-format', baseRef])
+  const token = process.env.GITHUB_TOKEN
+  if (!token) throw new Error('GITHUB_TOKEN is not set')
+  const server = process.env.GITHUB_SERVER_URL ?? 'https://github.com'
+  const environment = {
+    ...process.env,
+    GIT_TERMINAL_PROMPT: '0',
+    GIT_CONFIG_COUNT: '1',
+    GIT_CONFIG_KEY_0: `http.${server}/.extraheader`,
+    GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
+  }
+  const { stdout: shallow } = await exec('git', ['rev-parse', '--is-shallow-repository'])
+  await exec('git', [
+    'fetch', '--no-tags', ...(shallow.trim() === 'true' ? ['--unshallow'] : []),
+    `${server}/${pull.repository}.git`, baseRef, pull.headSha,
+  ], { env: environment, maxBuffer: 16 * 1024 * 1024 })
+  const { stdout: baseSha } = await exec('git', ['rev-parse', 'FETCH_HEAD'])
+  const { stdout } = await exec('python3', [
+    fileURLToPath(new URL('blame-production.py', import.meta.url)), baseSha.trim(), pull.headSha,
+  ], { maxBuffer: 16 * 1024 * 1024 })
+  return resolveBlameAuthors(JSON.parse(stdout), pull.repository, api)
+}

+ 52 - 0
.github/review-ownership/blame-ownership.test.mjs

@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { resolveBlameAuthors } from './blame-ownership.mjs'
+
+const sha = number => number.toString(16).padStart(40, '0')
+
+test('combines commit author accounts and retains unlinked authors in the denominator', async () => {
+  const result = await resolveBlameAuthors({
+    totalLines: 10, commitLines: { [sha(1)]: 2, [sha(2)]: 3, [sha(3)]: 5 },
+  }, 'owner/repo', async (path, { body }) => {
+    assert.equal(path, '/graphql')
+    assert.deepEqual(body.variables, { owner: 'owner', name: 'repo' })
+    assert.equal(body.query, `query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { ${[1, 2, 3].map((number, index) => `c${index}: object(oid: "${sha(number)}") { ... on Commit { author { user { login } } } }`).join('\n')} } }`)
+    return { data: { repository: {
+      c0: { author: { user: { login: 'Writer' } } },
+      c1: { author: { user: { login: 'writer' } } },
+      c2: { author: { user: null } },
+    } } }
+  })
+  assert.equal(result.totalLines, 10)
+  assert.deepEqual({ ...result.reviewerLines }, { writer: 5 })
+})
+
+test('batches author lookup and skips zero-line changes', async () => {
+  let calls = 0
+  await resolveBlameAuthors({
+    totalLines: 51, commitLines: Object.fromEntries(Array.from({ length: 51 }, (_, index) => [sha(index), 1])),
+  }, 'owner/repo', async (path, { body }) => {
+    const offset = calls * 50
+    const length = calls++ === 0 ? 50 : 1
+    for (let index = 0; index < length; index++) {
+      assert.ok(body.query.includes(`c${index}: object(oid: "${sha(offset + index)}") { ... on Commit { author { user { login } } } }`))
+    }
+    assert.equal((body.query.match(/object\(oid:/gu) ?? []).length, length)
+    return { data: { repository: Object.fromEntries(Array.from({ length }, (_, index) =>
+      [`c${index}`, { author: { user: null } }])) } }
+  })
+  assert.equal(calls, 2)
+  await resolveBlameAuthors({ totalLines: 0, commitLines: {} }, 'owner/repo', async () => {
+    throw new Error('empty changes must not query authors')
+  })
+})
+
+test('rejects incomplete or failed author lookups rather than lowering the denominator', async () => {
+  for (const response of [{ errors: [{ message: 'rate limited' }] }, { data: { repository: { c0: null } } }]) {
+    await assert.rejects(resolveBlameAuthors({ totalLines: 1, commitLines: { [sha(1)]: 1 } },
+      'owner/repo', async () => response), /author/u)
+  }
+  await assert.rejects(resolveBlameAuthors({ totalLines: 2, commitLines: { [sha(1)]: 1 } },
+    'owner/repo', async () => { throw new Error('invalid counts must not reach GitHub') }), /invalid production/u)
+})

+ 135 - 0
.github/review-ownership/blame-production.py

@@ -0,0 +1,135 @@
+"""Count changed old production lines by merge-base blame commit; never execute PR files."""
+
+import argparse
+from collections import Counter
+import json
+import os
+from pathlib import PurePosixPath
+import re
+import subprocess
+
+from pygments import lex
+from pygments.lexers import get_lexer_for_filename
+from pygments.token import Comment, Literal
+
+
+EXCLUDED = frozenset((
+    'vendor', 'node_modules', 'dist', 'lib', 'build', 'coverage', 'target',
+    'test', 'tests', '__tests__', 'fixture', 'fixtures', 'snapshot', 'snapshots',
+    'testing', 'test-support', 'support', 'examples', 'docs', 'gen', 'generated', 'generated-effect',
+))
+EXTENSIONS = frozenset(('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.py', '.c', '.h', '.cpp', '.hpp', '.rs', '.html'))
+SHIPPED_ROOTS = ('apps/desktop/renderer/', 'packages/experimental/code-runtime-python/py/')
+SHIPPED_FILES = frozenset(('python/sdk-runtime/runtime-bootstrap.mjs', 'packages/experimental/webworker-packer/bin.js'))
+GENERATED = re.compile(r'auto-generated|automatically generated|generated by|do not edit', re.I)
+SHA = re.compile(r'[0-9a-f]{40}')
+
+
+def git(repo, *args):
+    """Run a bounded read-only Git command without external diff drivers or replacements."""
+    return subprocess.run(
+        ['git', '-C', repo, '--no-replace-objects', *args], check=True,
+        stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120,
+        env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'},
+    ).stdout
+
+
+def production_path(path):
+    """Recognize shipped source roots, excluding tests, generated files and tooling."""
+    name = PurePosixPath(path)
+    shipped = (name.parts[0] in ('packages', 'apps', 'python', 'native') and 'src' in name.parts) \
+        or path.startswith(SHIPPED_ROOTS) or path in SHIPPED_FILES
+    return (
+        shipped
+        and not EXCLUDED.intersection(name.parts)
+        and not re.search(r'\.(?:test|spec|e2e|gen|generated|d)\.', name.name)
+        and name.stem != 'testing'
+        and name.suffix in EXTENSIONS
+    )
+
+
+def code_lines(path, source):
+    """Return physical lines containing non-comment, nonblank tokens, including mixed lines."""
+    lexer = get_lexer_for_filename(path, stripnl=False, ensurenl=False)
+    lines = set()
+    line = 1
+    leading = True
+    for kind, value in lex(source, lexer):
+        if leading and (kind in Comment or kind in Literal.String.Doc):
+            if GENERATED.search(value):
+                return set()
+        elif value.strip():
+            leading = False
+        for index, fragment in enumerate(value.split('\n')):
+            if index:
+                line += 1
+            if (kind not in Comment or kind in Comment.Preproc or kind in Comment.PreprocFile) \
+                    and kind not in Literal.String.Doc and fragment.strip():
+                lines.add(line)
+    return lines
+
+
+def changed_files(repo, base, head):
+    """Yield old paths and blob IDs, preserving rename detection and arbitrary filenames."""
+    fields = git(repo, 'diff', '--raw', '-z', '--no-abbrev', '--find-renames',
+                 '--no-ext-diff', '--no-textconv', base, head, '--').split(b'\0')
+    index = 0
+    while index < len(fields) - 1:
+        metadata = fields[index].decode('ascii').split()
+        path = os.fsdecode(fields[index + 1])
+        index += 2
+        if metadata[4].startswith(('R', 'C')):
+            index += 1
+        if metadata[0] not in (':100644', ':100755') or not production_path(path):
+            continue
+        if metadata[2] != metadata[3]:
+            yield path, metadata[2], metadata[3]
+
+
+def measure(repo, base, head):
+    """Compute the merge base and attribute changed old code lines to their last commits."""
+    for revision in (base, head):
+        if not SHA.fullmatch(revision):
+            raise ValueError('base and head must be full commit SHAs')
+    if git(repo, 'rev-parse', '--is-shallow-repository').strip() != b'false':
+        raise ValueError('blame requires complete history')
+    merge_base = git(repo, 'merge-base', base, head).decode().strip()
+    counts = Counter()
+    files = 0
+    for path, old_blob, new_blob in changed_files(repo, merge_base, head):
+        source = git(repo, 'cat-file', 'blob', old_blob).decode('utf-8', errors='replace')
+        eligible = code_lines(path, source)
+        if not eligible:
+            continue
+        if new_blob == '0' * 40:
+            changed = eligible
+        else:
+            patch = git(repo, 'diff', '--no-ext-diff', '--no-textconv', '--text', '--unified=0',
+                        old_blob, new_blob, '--').decode('utf-8', errors='replace')
+            changed = set()
+            for start, length in re.findall(r'^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@', patch, re.M):
+                start = int(start)
+                changed.update(eligible.intersection(range(start, start + int(length or '1'))))
+        if not changed:
+            continue
+        files += 1
+        # One blame per file covers all changed ranges; unchanged intervening lines never count.
+        blame = git(repo, 'blame', '--no-textconv', '--line-porcelain', '-L', f'{min(changed)},{max(changed)}',
+                    merge_base, '--', path).decode('utf-8', errors='replace')
+        attributed = 0
+        for commit, line in re.findall(r'^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$', blame, re.M):
+            if int(line) in changed:
+                counts[commit] += 1
+                attributed += 1
+        if attributed != len(changed):
+            raise ValueError(f'incomplete blame for {path!r}')
+    return {'mergeBase': merge_base, 'files': files, 'totalLines': sum(counts.values()), 'commitLines': dict(counts)}
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('base')
+    parser.add_argument('head')
+    parser.add_argument('--repo', default='.')
+    args = parser.parse_args()
+    print(json.dumps(measure(args.repo, args.base, args.head)))

+ 41 - 10
.github/review-ownership/check-approval.mjs

@@ -4,13 +4,14 @@ import { readFileSync } from 'node:fs'
 import process from 'node:process'
 import { pathToFileURL } from 'node:url'
 
+import { productionOwnership, LOGIN } from './blame-ownership.mjs'
+
 const API_VERSION = '2026-03-10'
 const MAX_PULL_REQUEST_REVIEWS = 3_000
 const PAGE_SIZE = 100
 const STATUS_CONTEXT = 'weighted approval'
 const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
 const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
-const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
 
 class GitHubApiError extends Error {
   constructor(message, status) {
@@ -128,10 +129,10 @@ export async function listPullRequestReviews(api, repository, pullNumber) {
 
 /**
  * Evaluate approval points from current reviews and repository permissions.
- * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Runtime inputs.
- * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership}} options Runtime inputs.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
  */
-export async function evaluateApproval({ event, policySource, api }) {
+export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership }) {
   const pull = pullRequestFromEvent(event)
   const policy = parseApprovalPolicy(policySource)
   if (pull.draft) {
@@ -160,12 +161,24 @@ export async function evaluateApproval({ event, policySource, api }) {
       })
     }
   }
+  const unboostedPoints = approvals.reduce((sum, approval) => sum + approval.points, 0)
+  if (blockers.length === 0 && unboostedPoints < policy.requiredPoints
+    && approvals.some(approval => approval.points === policy.defaultPoints)) {
+    const ownership = await getOwnership(pull, api)
+    for (const approval of approvals) {
+      if (approval.points !== policy.defaultPoints) continue
+      const ownedLines = ownership.reviewerLines[approval.login.toLowerCase()] ?? 0
+      approval.ownership = { ownedLines, totalLines: ownership.totalLines }
+      if (ownership.totalLines > 0) approval.points = Math.min(policy.requiredPoints, policy.defaultPoints
+        + (policy.requiredPoints - policy.defaultPoints) * 4 * ownedLines / ownership.totalLines)
+    }
+  }
   approvals.sort((left, right) => left.login.localeCompare(right.login, 'en'))
   blockers.sort((left, right) => left.localeCompare(right, 'en'))
   ignoredReviewers.sort((left, right) => left.localeCompare(right, 'en'))
   const points = approvals.reduce((total, approval) => {
     const next = total + approval.points
-    if (!Number.isSafeInteger(next)) throw new Error('approval points exceed the safe integer range')
+    if (!Number.isFinite(next) || next > Number.MAX_SAFE_INTEGER) throw new Error('approval points must be finite and at most Number.MAX_SAFE_INTEGER')
     return next
   }, 0)
   if (blockers.length > 0) {
@@ -180,26 +193,28 @@ export async function evaluateApproval({ event, policySource, api }) {
     blockers,
     ignoredReviewers,
     state,
-    `${points}/${policy.requiredPoints} approval points`,
+    `${Number(points.toFixed(2))}/${policy.requiredPoints} approval points`,
   )
 }
 
 /**
  * Evaluate and publish the required commit status, publishing an error status when evaluation fails.
- * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void}} options Runtime inputs.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership}} options Runtime inputs.
  * @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
  */
-export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) {
+export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, write = line => process.stdout.write(`${line}\n`) }) {
   const pull = pullRequestFromEvent(event)
+  await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
   let result
   try {
-    result = await evaluateApproval({ event, policySource, api })
+    result = await evaluateApproval({ event, policySource, api, getOwnership })
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
   }
   write(`Approval score: ${result.points}/${result.requiredPoints}.`)
-  writeList(write, 'Counted approvals', result.approvals.map(({ login, points }) => `@${login}: ${points}`))
+  writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership }) =>
+    `@${login}: ${points}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
   writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
   writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
   await publishStatus(api, pull, result.state, result.description, runUrl)
@@ -207,6 +222,17 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, write
   return result
 }
 
+/**
+ * Revoke a previous success before dependency installation, or report its failure.
+ * @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string}} options Publication inputs.
+ * @returns {Promise<void>} Completion of the status write.
+ */
+export async function publishApprovalPhase({ event, api, runUrl, phase }) {
+  if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase')
+  await publishStatus(api, pullRequestFromEvent(event), phase,
+    phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl)
+}
+
 /**
  * Resolve the reviewed pull request from a completed run of the review-event workflow file.
  * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Trusted workflow inputs.
@@ -360,6 +386,11 @@ async function main() {
     }
     event = resolved
   }
+  const phase = process.argv[2]
+  if (phase) {
+    await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
+    return
+  }
   await runApprovalCheck({
     event,
     policySource,

+ 132 - 2
.github/review-ownership/check-approval.test.mjs

@@ -10,6 +10,7 @@ import {
   listPullRequestReviews,
   parseApprovalPolicy,
   runApprovalCheck,
+  publishApprovalPhase,
 } from './check-approval.mjs'
 
 const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
@@ -149,6 +150,7 @@ test('accepts one two-point approval from a write-capable reviewer', async () =>
   const result = await evaluateApproval({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async (path) => {
       calls.push(path)
       if (path.includes('/reviews?')) return [review('07akioni', 'APPROVED')]
@@ -166,6 +168,7 @@ test('accepts two one-point approvals and ignores reviews without write access',
   const result = await evaluateApproval({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async (path) => {
       if (path.includes('/reviews?')) {
         return [
@@ -193,6 +196,7 @@ test('keeps one one-point approval pending without failing the status', async ()
   const result = await evaluateApproval({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async (path) => {
       if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
       if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
@@ -227,6 +231,7 @@ test('keeps the status pending on a write-capable change request while ignoring
   const result = await runApprovalCheck({
     event: pullRequestEvent({ author: 'author' }),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     runUrl: 'https://github.example/actions/runs/1',
     api: async (path, options = {}) => {
       if (path.includes('/reviews?')) {
@@ -254,6 +259,11 @@ test('keeps the status pending on a write-capable change request while ignoring
   assert.deepEqual(result.blockers, ['blocker'])
   assert.deepEqual(result.ignoredReviewers, ['reader'])
   assert.deepEqual(statuses, [{
+    state: 'pending',
+    context: 'weighted approval',
+    description: 'Evaluating approval points.',
+    target_url: 'https://github.example/actions/runs/1',
+  }, {
     state: 'pending',
     context: 'weighted approval',
     description: '1 blocking change request.',
@@ -265,6 +275,7 @@ test('keeps drafts pending without reading reviews', async () => {
   const result = await evaluateApproval({
     event: pullRequestEvent({ draft: true }),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     api: async () => { throw new Error('draft evaluation must not call GitHub') },
   })
   assert.equal(result.state, 'pending')
@@ -278,6 +289,7 @@ test('publishes the required status and replaces stale success with error on eva
   const result = await runApprovalCheck({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     runUrl: 'https://github.example/actions/runs/1',
     api: async (path, options = {}) => {
       calls.push({ path, options })
@@ -307,6 +319,7 @@ test('publishes the required status and replaces stale success with error on eva
   await assert.rejects(runApprovalCheck({
     event: pullRequestEvent(),
     policySource,
+    getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
     runUrl: 'https://github.example/actions/runs/2',
     api: async (path, options = {}) => {
       if (path.includes('/reviews?')) throw new Error('reviews unavailable')
@@ -318,8 +331,9 @@ test('publishes the required status and replaces stale success with error on eva
     },
     write: () => {},
   }), /reviews unavailable/u)
-  assert.equal(failures[0].options.body.state, 'error')
-  assert.equal(failures[0].options.body.description, 'Approval evaluation failed.')
+  assert.equal(failures[0].options.body.state, 'pending')
+  assert.equal(failures[1].options.body.state, 'error')
+  assert.equal(failures[1].options.body.description, 'Approval evaluation failed.')
 })
 
 test('sends authenticated JSON and escapes an API error body', async () => {
@@ -346,3 +360,119 @@ test('sends authenticated JSON and escapes an API error body', async () => {
   })
   await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
 })
+
+for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999999], [0, 100, 1], [1, 8, 1.5], [24, 100, 1.96], [1, 4, 2], [25, 100, 2], [26, 100, 2], [100, 100, 2], [0, 0, 1]]) {
+  test(`scores ${ownedLines}/${totalLines} old production lines as ${expectedPoints} points`, async () => {
+    let measurements = 0
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource,
+      getOwnership: async () => {
+        measurements++
+        return { totalLines, reviewerLines: { writer: ownedLines } }
+      },
+      api: async path => path.includes('/reviews?')
+        ? [review('Writer', 'APPROVED')]
+        : { permission: 'write' },
+    })
+    assert.equal(result.points, expectedPoints)
+    assert.equal(result.description, `${Number(expectedPoints.toFixed(2))}/2 approval points.`)
+    assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
+    assert.equal(measurements, 1)
+    assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
+  })
+}
+
+test('does not fetch ownership when a change request blocks approval', async () => {
+  let measurements = 0
+  const result = await evaluateApproval({
+    event: pullRequestEvent(), policySource,
+    getOwnership: async () => {
+      measurements++
+      return { totalLines: 2, reviewerLines: { first: 1, second: 1 } }
+    },
+    api: async path => path.includes('/reviews?')
+      ? [review('first', 'APPROVED'), review('second', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED')]
+      : { permission: 'write' },
+  })
+  assert.equal(measurements, 0)
+  assert.equal(result.points, 2)
+  assert.equal(result.state, 'pending')
+})
+
+test('does not fetch history when approvals already have two-point weights', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(), policySource,
+    getOwnership: async () => { throw new Error('unexpected history fetch') },
+    api: async path => path.includes('/reviews?') ? [review('turtle1999', 'APPROVED')] : { permission: 'write' },
+  })
+  assert.equal(result.points, 2)
+})
+
+test('publishes error when production attribution fails', async () => {
+  const states = []
+  await assert.rejects(runApprovalCheck({
+    event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
+    getOwnership: async () => { throw new Error('incomplete history') },
+    api: async (path, options) => {
+      if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+      if (path.includes('/permission')) return { permission: 'write' }
+      states.push(options.body.state)
+      return {}
+    },
+  }), /incomplete history/u)
+  assert.deepEqual(states, ['pending', 'error'])
+})
+
+
+test('revokes a previous success before starting expensive attribution', async () => {
+  const states = []
+  await runApprovalCheck({
+    event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
+    getOwnership: async () => {
+      assert.deepEqual(states, ['pending'])
+      return { totalLines: 100, reviewerLines: { writer: 25 } }
+    },
+    api: async (path, options) => {
+      if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+      if (path.includes('/permission')) return { permission: 'write' }
+      states.push(options.body.state)
+      return {}
+    },
+  })
+  assert.deepEqual(states, ['pending', 'success'])
+})
+
+for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) {
+  test(`does not fetch ownership for sufficient approvals: ${reviewers}`, async () => {
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource,
+      getOwnership: async () => { throw new Error('unnecessary lookup') },
+      api: async path => path.includes('/reviews?')
+        ? reviewers.map(login => review(login, 'APPROVED')) : { permission: 'write' },
+    })
+    assert.equal(result.state, 'success')
+  })
+}
+
+test('uses policy endpoints and formats only the displayed score', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource: JSON.stringify({ requiredPoints: 5, defaultPoints: 2, reviewerPoints: {} }),
+    getOwnership: async () => ({ totalLines: 100, reviewerLines: { writer: 9 } }),
+    api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
+  })
+  assert.equal(result.points, 3.08)
+  assert.equal(result.description, '3.08/5 approval points.')
+})
+
+test('publishes setup phases without evaluating or installing dependencies', async () => {
+  const states = []
+  const options = {
+    event: pullRequestEvent(), runUrl: 'https://github.example/run/1',
+    api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) },
+  }
+  await publishApprovalPhase({ ...options, phase: 'pending' })
+  await publishApprovalPhase({ ...options, phase: 'error' })
+  assert.deepEqual(states, ['pending', 'error'])
+  await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
+})

+ 1 - 0
.github/review-ownership/requirements.txt

@@ -0,0 +1 @@
+Pygments==2.19.2

+ 221 - 0
.github/review-ownership/test_blame_production.py

@@ -0,0 +1,221 @@
+"""Exercise production-line attribution against isolated real Git histories."""
+
+import base64
+import importlib.util
+import json
+import os
+import shutil
+import sys
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+spec = importlib.util.spec_from_file_location('blame_production', Path(__file__).with_name('blame-production.py'))
+blame = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(blame)
+
+
+class ProductionBlameTest(unittest.TestCase):
+    def setUp(self):
+        self.directory = tempfile.TemporaryDirectory(prefix='approval-blame-')
+        self.addCleanup(self.directory.cleanup)
+        self.root = Path(self.directory.name)
+        self.git('init', '-q')
+        self.git('config', 'user.name', 'Approval Fixture')
+        self.git('config', 'user.email', 'fixture@example.invalid')
+        self.git('config', 'commit.gpgsign', 'false')
+        self.git('config', 'core.hooksPath', str(self.root / 'no-hooks'))
+
+    def git(self, *args):
+        return subprocess.run(['git', '-C', str(self.root), *args], check=True,
+                              capture_output=True, text=True).stdout.strip()
+
+    def write(self, path, source):
+        file = self.root / path
+        file.parent.mkdir(parents=True, exist_ok=True)
+        file.write_text(source, encoding='utf-8')
+
+    def commit(self):
+        self.git('add', '.')
+        self.git('commit', '-qm', 'fixture')
+        return self.git('rev-parse', 'HEAD')
+
+    def test_code_tokens_keep_strings_and_mixed_lines(self):
+        cases = [
+            ('main.ts', '// comment\n/* block\n comment */\nconst url = "https://example.test" // tail\n\n', {4}),
+            ('main.html', '<!-- comment -->\n<div>content</div>\n', {2}),
+            ('main.tsx', 'const view = <div>{/* comment */}hello</div>\n', {1}),
+            ('main.css', '/* block\n comment */\n.x { color: red; /* mixed */ }\n', {3}),
+            ('main.py', '"""module docs\nmore docs"""\n# comment\nx = "# code"\n', {4}),
+            ('main.c', '#include <stdio.h>\n#define VALUE 1\n', {1, 2}),
+            ('main.c', '/* comment */\nint main() { return 0; } // tail\n', {2}),
+            ('main.ts', 'const text = `first\n// string content\nlast`\n', {1, 2, 3}),
+        ]
+        for path, source, expected in cases:
+            with self.subTest(path=path, source=source):
+                self.assertEqual(blame.code_lines(path, source), expected)
+
+    def test_excludes_nonproduction_paths_and_generated_headers(self):
+        for path in ('docs/a.ts', 'vendor/src/a.ts', 'packages/a/tests/a.ts',
+                     'packages/a/src/a.spec.ts', 'packages/support/a/src/a.ts',
+                     'packages/a/src/generated/a.ts', 'packages/a/src/a.d.ts',
+                     'packages/a/src/a.md', 'scripts/a.ts',
+                     'packages/core/tools/src/testing.ts',
+                     'packages/session/session-persistence-jsonl/src/testing/generation.ts'):
+            self.assertFalse(blame.production_path(path), path)
+        self.assertTrue(blame.production_path('packages/a/src/a.ts'))
+        for path in ('python/sdk-runtime/runtime-bootstrap.mjs', 'apps/desktop/renderer/startup.js',
+                     'apps/desktop/renderer/startup.html', 'packages/experimental/code-runtime-python/py/protocol.py',
+                     'packages/experimental/webworker-packer/bin.js'):
+            self.assertTrue(blame.production_path(path), path)
+        self.assertEqual(blame.code_lines('a.ts', '// Generated by schema\nconst a = 1\n'), set())
+
+    def test_attributes_only_changed_old_code_at_merge_base(self):
+        path = 'packages/a/src/a.ts'
+        self.write(path, '// first\nconst a = 1\nconst b = 2\n')
+        first = self.commit()
+        self.write(path, '// first\nconst a = 1\nconst b = 3\n')
+        base = self.commit()
+        self.write(path, '// different\nconst a = 4\nconst b = 5\nconst added = 6\n')
+        self.write('packages/a/src/new.ts', 'const entirelyNew = 1\n')
+        self.write('packages/a/tests/test.ts', 'const test = 1\n')
+        head = self.commit()
+        # Base advancement must not replace the common ancestor used for attribution.
+        self.git('checkout', '--detach', base)
+        self.write('packages/a/src/unrelated.ts', 'const unrelated = 1\n')
+        advanced_base = self.commit()
+        result = blame.measure(str(self.root), advanced_base, head)
+        self.assertEqual(result['mergeBase'], base)
+        self.assertEqual(result['totalLines'], 2)
+        self.assertEqual(result['commitLines'], {first: 1, base: 1})
+
+    def test_renames_deletions_and_unusual_paths(self):
+        path = 'packages/a/src/quoted " name.ts'
+        self.write(path, '// header\nconst a = 1\nconst b = 2\nconst c = 3\n')
+        self.write('packages/a/src/deleted.ts', 'const gone = 1\n// comment\n')
+        base = self.commit()
+        renamed = 'packages/a/src/renamed.ts'
+        self.git('mv', path, renamed)
+        self.write(renamed, '// header\nconst a = 1\nconst b = 2\nconst c = 4\n')
+        self.git('rm', 'packages/a/src/deleted.ts')
+        result = blame.measure(str(self.root), base, self.commit())
+        self.assertEqual(result['totalLines'], 2)
+        self.assertEqual(result['commitLines'], {base: 2})
+
+    def test_pure_additions_and_comment_only_changes_have_zero_denominator(self):
+        self.write('packages/a/src/a.ts', '// comment\nconst a = 1\n')
+        base = self.commit()
+        self.write('packages/a/src/a.ts', '// revised\nconst a = 1\nconst newLine = 2\n')
+        result = blame.measure(str(self.root), base, self.commit())
+        self.assertEqual(result['totalLines'], 0)
+        self.assertEqual(result['commitLines'], {})
+
+    def test_binary_marked_replacement_does_not_hide_old_production_lines(self):
+        path = 'packages/a/src/a.ts'
+        self.write(path, 'const old = 1\n')
+        base = self.commit()
+        self.write(path, '\0binary replacement\n')
+        self.assertEqual(blame.measure(str(self.root), base, self.commit())['totalLines'], 1)
+
+    def test_pure_rename_has_zero_denominator(self):
+        self.write('packages/a/src/a.ts', 'const a = 1\n')
+        base = self.commit()
+        self.git('mv', 'packages/a/src/a.ts', 'packages/a/src/b.ts')
+        self.assertEqual(blame.measure(str(self.root), base, self.commit())['totalLines'], 0)
+
+    def test_publisher_fetches_history_without_checking_out_pr_code(self):
+        self.write('packages/a/src/a.ts', 'const old = 1\n')
+        self.write('packages/a/src/base.ts', 'const base = 1\n')
+        base = self.commit()
+        self.write('packages/a/src/a.ts', 'throw new Error("PR code must not run")\n')
+        branch = self.commit()
+        self.git('checkout', '--detach', base)
+        self.write('packages/a/src/base.ts', 'const base = 2\n')
+        advanced = self.commit()
+        self.git('checkout', '--detach', branch)
+        self.git('merge', '--no-edit', advanced)
+        head = self.git('rev-parse', 'HEAD')
+        remote = self.root / 'remote' / 'owner' / 'repo.git'
+        remote.parent.mkdir(parents=True)
+        self.git('clone', '--bare', str(self.root), str(remote))
+        subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/heads/trusted', advanced], check=True)
+        subprocess.run(['git', '-C', str(remote), 'symbolic-ref', 'HEAD', 'refs/heads/trusted'], check=True)
+        subprocess.run(['git', '-C', str(remote), 'update-ref', 'refs/pull/42/head', advanced], check=True)
+        checkout = self.root / 'checkout'
+        self.git('clone', '--depth=1', remote.as_uri(), str(checkout))
+        wrappers = self.root / 'wrappers'
+        wrappers.mkdir()
+        trace = self.root / 'git-environment.json'
+        git_wrapper = wrappers / 'git'
+        git_wrapper.write_text(f"#!{sys.executable}\nimport json, os, sys\n"
+                               f"if 'fetch' in sys.argv: open({str(trace)!r}, 'w').write(json.dumps({{key: value for key, value in os.environ.items() if key.startswith('GIT_CONFIG_')}}))\n"
+                               f"os.execv({shutil.which('git')!r}, ['git', *sys.argv[1:]])\n")
+        git_wrapper.chmod(0o755)
+        module = Path(__file__).with_name('blame-ownership.mjs').resolve().as_uri()
+        program = f"""
+          import {{ productionOwnership }} from {json.dumps(module)};
+          const result = await productionOwnership({{repository:'owner/repo', number:42, headSha:{json.dumps(head)}}},
+            async path => path === '/graphql'
+              ? {{data:{{repository:{{c0:{{author:{{user:{{login:'writer'}}}}}}}}}}}}
+              : {{base:{{sha:{json.dumps(base)},ref:'trusted'}},head:{{sha:{json.dumps(head)}}}}});
+          console.log(JSON.stringify(result));
+        """
+        result = subprocess.run(['node', '--input-type=module', '-e', program], cwd=checkout,
+                                check=True, capture_output=True, text=True,
+                                env={**os.environ, 'GITHUB_TOKEN': 'fixture-token',
+                                     'GITHUB_SERVER_URL': (self.root / 'remote').as_uri(),
+                                     'PATH': str(wrappers) + os.pathsep + str(Path(sys.executable).parent) + os.pathsep + os.environ['PATH']})
+        self.assertEqual(json.loads(result.stdout), {'totalLines': 1, 'reviewerLines': {'writer': 1}})
+        self.assertEqual(subprocess.check_output(['git', '-C', str(checkout), 'rev-parse', 'HEAD'], text=True).strip(), advanced)
+        fetch_environment = json.loads(trace.read_text())
+        self.assertEqual(fetch_environment['GIT_CONFIG_COUNT'], '1')
+        self.assertEqual(fetch_environment['GIT_CONFIG_KEY_0'],
+                         f"http.{(self.root / 'remote').as_uri()}/.extraheader")
+        self.assertEqual(fetch_environment['GIT_CONFIG_VALUE_0'],
+                         'AUTHORIZATION: basic ' + base64.b64encode(b'x-access-token:fixture-token').decode())
+        config = (checkout / '.git' / 'config').read_text()
+        self.assertNotIn('fixture-token', config)
+        self.assertNotIn('AUTHORIZATION', config)
+
+    def test_merge_forward_excludes_base_only_edits(self):
+        path = 'packages/a/src/a.ts'
+        self.write(path, 'const a = 1\n')
+        self.write('packages/a/src/base.ts', 'const base = 1\n')
+        fork = self.commit()
+        self.write(path, 'const a = 2\n')
+        branch = self.commit()
+        self.git('checkout', '--detach', fork)
+        self.write('packages/a/src/base.ts', 'const base = 2\n')
+        advanced = self.commit()
+        self.git('checkout', '--detach', branch)
+        self.git('merge', '--no-edit', advanced)
+        result = blame.measure(str(self.root), advanced, self.git('rev-parse', 'HEAD'))
+        self.assertEqual(result['mergeBase'], advanced)
+        self.assertEqual(result['commitLines'], {fork: 1})
+
+    def test_non_utf8_blobs_and_commit_metadata_keep_old_lines(self):
+        path = 'packages/a/src/a.ts'
+        self.write(path, 'const a = "old"\n')
+        (self.root / path).write_bytes(b'const a = "\xff"\n')
+        self.git('add', '.')
+        subprocess.run(['git', '-C', str(self.root), '-c', 'i18n.commitEncoding=ISO-8859-1',
+                        'commit', '-q', '-F', '-'], input=b'metadata \xff', check=True, capture_output=True)
+        base = self.git('rev-parse', 'HEAD')
+        (self.root / path).write_bytes(b'\0new \xfe\n')
+        self.assertEqual(blame.measure(str(self.root), base, self.commit())['commitLines'], {base: 1})
+
+    def test_generated_words_in_code_do_not_exclude_handwritten_files(self):
+        self.assertEqual(blame.code_lines('a.ts', 'const text = "generated by an agent"\n'), {1})
+        self.assertEqual(blame.code_lines('a.ts', 'const a = 1\n// do not edit user data\n'), {1})
+
+    def test_rejects_shallow_history(self):
+        self.write('packages/a/src/a.ts', 'const a = 1\n')
+        base = self.commit()
+        (self.root / '.git' / 'shallow').write_text(base + '\n')
+        with self.assertRaisesRegex(ValueError, 'complete history'):
+            blame.measure(str(self.root), base, base)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 7 - 0
.github/workflows/ci.yml

@@ -468,6 +468,13 @@ jobs:
       - name: Install uv
         run: python -m pip install uv==0.11.23
 
+      - uses: actions/setup-node@v6
+        with:
+          node-version: 24
+
+      - name: Test production blame scoring
+        run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'
+
       - name: Run complete keyless Python suite
         run: uv run --python 3.10 --group test --project python/sdk pytest
 

+ 22 - 2
.github/workflows/weighted-approval.yml

@@ -2,7 +2,7 @@ name: weighted-approval
 
 on:
   pull_request_target:
-    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
+    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]
   workflow_run:
     workflows: [weighted-approval-review-event]
     types: [completed]
@@ -13,7 +13,7 @@ permissions:
   statuses: write
 
 concurrency:
-  group: weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}
+  group: weighted-approval-${{ github.event.pull_request.number && format('weighted-approval-review-event:{0}', github.event.pull_request.number) || github.event.workflow_run.display_title }}
   cancel-in-progress: false
 
 jobs:
@@ -30,8 +30,28 @@ jobs:
         with:
           ref: ${{ github.event.repository.default_branch }}
           persist-credentials: false
+      - name: Revoke previous approval status
+        id: revoke
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+          GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: node .github/review-ownership/check-approval.mjs pending
+      # SECURITY: dependency setup runs in the status-writing job.
+      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+        with:
+          python-version: '3.10'
+          cache: pip
+          cache-dependency-path: .github/review-ownership/requirements.txt
+      - name: Install production lexer
+        run: python3 -m pip install -r .github/review-ownership/requirements.txt
       - name: Publish weighted approval status
         env:
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
         run: node .github/review-ownership/check-approval.mjs
+      - name: Publish approval setup failure
+        if: failure() && steps.revoke.outcome == 'success'
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+          GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: node .github/review-ownership/check-approval.mjs error

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -77,6 +77,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
 | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
 | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
+| [`execa`](https://github.com/sindresorhus/execa) | MIT |
 | [`fflate`](https://github.com/101arrowz/fflate) | MIT |
 | [`immer`](https://github.com/immerjs/immer) | MIT |
 | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
@@ -194,7 +195,6 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`electron-builder`](https://github.com/electron-userland/electron-builder) | MIT |
 | [`esbuild`](https://github.com/evanw/esbuild) | MIT |
 | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
-| [`execa`](https://github.com/sindresorhus/execa) | MIT |
 | [`extract-zip`](https://github.com/maxogden/extract-zip) | BSD-2-Clause |
 | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
 | [`http-server`](https://github.com/http-party/http-server) | MIT |

+ 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: f2131121b46acc41e6f9a6db9751a8362a9e174a
-README.zh.md: 0fccc161a8bd0a6eaf07ef01fa7741dc15f88081
+README.md: 92bd282b1148217a41ce12bbb4d227df088b13a0
+README.zh.md: b64ed8f81fb28acb70d96b1b0c116ea03dbd5fbb

+ 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 and home-level patch files; `startup` applies them once.
+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. CLI package commands inherit authentication variables and terminal descriptors, including interactive build approval; service calls retain their scrubbed environment and captured diagnostics.
 
 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 与 home 级 patch 文件,`startup` 则只应用一次。
+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 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。
 
 配置树以空根为起点,依次叠加以下配置层:
 - `dsh.profile.bundles` 中各组合包的 patch

+ 8 - 2
apps/cli/composition.md

@@ -8,9 +8,13 @@ The dsh-base bundle patch shared by the web, headless, sdk, and acp profiles; th
 ```mermaid
 flowchart LR
   cfg["packages/bundle/base/cordis.patch.yml<br/>cordis.yml"]
+  plugin_dsh_base_tool_plugin_manager["tool-plugin-manager<br/>@deepseek-ai/dsh-plugin-manager/tools"]
+  cfg --> plugin_dsh_base_tool_plugin_manager
+  plugin_dsh_base_plugin_manager["plugin-manager<br/>@deepseek-ai/dsh-plugin-manager"]
+  cfg --> plugin_dsh_base_plugin_manager
   plugin_dsh_base_timer["timer<br/>@deepseek-ai/cordis-plugin-timer"]
   cfg --> plugin_dsh_base_timer
-  plugin_dsh_base_hmr["hmr<br/>@deepseek-ai/cordis-plugin-hmr"]
+  plugin_dsh_base_hmr["hmr<br/>@deepseek-ai/dsh-hmr"]
   cfg --> plugin_dsh_base_hmr
   plugin_dsh_base_llm["llm<br/>@deepseek-ai/dsh-llm"]
   cfg --> plugin_dsh_base_llm
@@ -186,8 +190,10 @@ flowchart LR
 
 | Plugin id | Package / module |
 | --- | --- |
+| `tool-plugin-manager` | `@deepseek-ai/dsh-plugin-manager/tools` |
+| `plugin-manager` | `@deepseek-ai/dsh-plugin-manager` |
 | `timer` | `@deepseek-ai/cordis-plugin-timer` |
-| `hmr` | `@deepseek-ai/cordis-plugin-hmr` |
+| `hmr` | `@deepseek-ai/dsh-hmr` |
 | `llm` | `@deepseek-ai/dsh-llm` |
 | `deepseek-llm-api-extensions` | `@deepseek-ai/dsh-deepseek-llm-api-extensions` |
 | `session` | `@deepseek-ai/dsh-session` |

+ 4 - 2
apps/cli/package.json

@@ -30,7 +30,7 @@
   "license": "MIT",
   "dependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/cordis-plugin-hmr": "workspace:^",
+    "@deepseek-ai/dsh-hmr": "workspace:^",
     "@deepseek-ai/cordis-plugin-include": "workspace:^",
     "@deepseek-ai/cordis-plugin-loader": "workspace:^",
     "@deepseek-ai/cordis-plugin-timer": "workspace:^",
@@ -101,7 +101,9 @@
     "js-yaml": "^4.2.0",
     "node-addon-require-builtin": "^0.1.6",
     "@deepseek-ai/dsh-http-proxy": "workspace:^",
-    "@deepseek-ai/dsh-mcp-resources": "workspace:^"
+    "@deepseek-ai/dsh-mcp-resources": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^",
+    "@deepseek-ai/dsh-atomic-write": "workspace:^"
   },
   "devDependencies": {
     "@agentclientprotocol/sdk": "1.4.0",

+ 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 服务。
 

+ 1 - 1
apps/cli/src/bin.ts

@@ -42,7 +42,7 @@ export async function runCli(): Promise<void> {
     }
     case 'plugin': {
       const { runPlugin } = await import('./plugin.ts')
-      process.exit(runPlugin(invocation.profile, invocation.args))
+      process.exit(await runPlugin(invocation.profile, invocation.args))
       break
     }
     case 'dump-config': {

+ 19 - 99
apps/cli/src/plugin.ts

@@ -1,105 +1,25 @@
-/**
- * `dsh plugin --profile <name> <args...>` — profile plugin management as a
- * thin pnpm forwarder: initialize the profile on first use, run
- * `pnpm <args...>` in the profile directory, then reconcile the
- * `dsh.profile.bundles` layer list against the installed state (a dependency
- * resolving to a package that declares `dsh.bundle` joins the layer stack; a
- * removed or bundle-less dependency leaves it). Reconciling by installed
- * state, not by dependency diff, means `update` activates a package that
- * gained its `dsh.bundle` declaration in a newer version.
- * @module @deepseek-ai/dsh/plugin
- */
-
-import { spawnSync } from 'node:child_process'
-import { existsSync } from 'node:fs'
-import { join, resolve } from 'node:path'
-import {
-  DEFAULT_PROFILE_BUNDLES,
-  initProfile,
-  PROFILE_TEMPLATES,
-  readProfilePlugins,
-  reconcileProfilePlugins,
-  resolveProfileDir,
-} from '@deepseek-ai/dsh-app-boot'
+/** dsh plugin forwards pnpm through the shared profile package operations. */
+import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
 import { INSTALL_ANCHOR } from './profile-boot.ts'
+import { resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
+import { join } from 'node:path'
 
-const NAME = 'dsh'
-
-/**
- * Rewrite relative filesystem specs against the user's invoking directory.
- * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
- * (or their `file:`/`link:` forms) would silently resolve inside the profile
- * — `add .` from a plugin checkout would self-link the profile. Absolute
- * specs, registry names, and every other pnpm argument pass through
- * untouched.
- * @param argument - one pnpm argument, verbatim from argv.
- * @param cwd - the directory `dsh` was invoked from.
- * @returns the argument with a relative path spec anchored to `cwd`.
- */
-function anchorPathSpec(argument: string, cwd: string): string {
-  const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
-  if (match?.groups?.path === undefined) return argument
-  // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's
-  // link-vs-copy semantics differ between `file:` and a plain directory
-  // path, and the anchor must not change which one the user asked for.
-  const prefix = match.groups.prefix ?? ''
-  return `${prefix}${resolve(cwd, match.groups.path)}`
-}
-
-/**
- * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
- * @param profile - the profile name.
- * @param args - pnpm arguments with relative path specs anchored to the invoking directory.
- * @returns the pnpm exit code.
+/** Run package management for a profile.
+ * @param profile Profile name.
+ * @param args Pnpm arguments relative to the invoking directory.
+ * @returns Pnpm exit code.
  */
-export function runPlugin(profile: string, args: readonly string[]): number {
-  const dir = resolveProfileDir(profile)
-  if (!existsSync(join(dir, 'package.json'))) {
-    const template = PROFILE_TEMPLATES[profile]
-    initProfile(
-      dir,
-      template?.bundles ?? DEFAULT_PROFILE_BUNDLES,
-      template?.patchReload,
-    )
-    process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
-  }
-  const location = { binName: NAME, profileDir: dir, installAnchor: INSTALL_ANCHOR }
-  const before = readProfilePlugins(location)
-  // Windows resolves pnpm through its .cmd shim, which spawn() refuses
-  // without a shell since the CVE-2024-27980 hardening.
-  const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
-    cwd: dir,
-    stdio: 'inherit',
-    shell: process.platform === 'win32',
+export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
+  const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, {
+    execution: 'cli',
+    outputBytes: 16384,
+    lockWaitMs: 120000,
+    onOutput: (text, stream) => { process[stream].write(text) },
   })
-  if (result.error !== undefined) {
-    const code = (result.error as NodeJS.ErrnoException).code
-    if (code === 'ENOENT') {
-      process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
-      return 127
-    }
-    throw result.error
-  }
-  const exitCode = result.status ?? 1
-  if (exitCode === 0) {
-    const result = reconcileProfilePlugins({ ...location, before, preserveDisabled: false })
-    for (const packageName of result.addedPlainDependencies) {
-      process.stderr.write(
-        `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
-        + '(a later update that gains one activates it automatically)\n',
-      )
-    }
-  } else {
-    // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
-    // one; the profile owns it, and the commonest failure here is pnpm ≥10
-    // blocking a git dependency's prepare (build) script until allowlisted.
-    process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
-    if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
-      process.stderr.write(
-        `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
-        + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
-      )
-    }
+  if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
+  if (result.exitCode !== 0) process.stderr.write(`dsh: pnpm failed; diagnostics: ${result.logPath}\n`)
+  if (result.exitCode !== 0 && args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
+    process.stderr.write(`dsh: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — add the exact key pnpm printed above under allowBuilds in ${join(resolveProfileDir(profile), 'pnpm-workspace.yaml')}, then re-run\n`)
   }
-  return exitCode
+  return result.exitCode
 }

+ 26 - 135
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
@@ -16,26 +15,24 @@ import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { FiberState, type Context } from '@deepseek-ai/cordis'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
 import {
   boot,
-  type ProfileResolutionMode,
-  type ProfileResolutionGeneration,
-  PluginPackages,
+  readProfilePatches,
   createProfileResolutionGeneration,
-  composeEntries,
   healProfilesModuleFallback,
   healIsolatedProfileModuleFallback,
   initProfile,
   installFailLoud,
-  loadOptionalPatches,
   loadOverlayPatches,
   loadProfile,
+  PluginPackages,
   PROFILE_PATCH_FILENAME,
   PROFILE_TEMPLATES,
   resolveProfileDir,
-  watchUserPatches,
+  type ProfileContext,
   type Profile,
+  type ProfileResolutionGeneration,
+  type ProfileResolutionMode,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
@@ -82,9 +79,6 @@ export function homePatchPath(): string {
 /** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
 export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
 
-/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
-const TELEMETRY_ROW_ID = 'session-telemetry-otel'
-
 /** The empty root entry list every profile tree patches over. */
 const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
 # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
@@ -97,7 +91,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.
@@ -144,7 +138,7 @@ export function initializeProfileFromDefault(
     )
   }
   try {
-    initProfile(dir, template.bundles, template.patchReload)
+    initProfile(dir, template.bundles)
   } catch (error) {
     try {
       rmSync(dir, { recursive: true, force: true })
@@ -157,23 +151,6 @@ export function initializeProfileFromDefault(
     throw error
   }
 }
-
-/**
- * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
- * value (including `'0'`/`'false'`) disables: a privacy switch prefers
- * off-by-mistake over on-by-mistake. A composition without the telemetry row
- * exports nothing, so the switch is then trivially satisfied and no patch is
- * generated — custom profiles need not mount telemetry to run with the
- * switch set.
- * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
- * @param hasRow - whether the composition carries the telemetry row.
- * @returns the disable patch, or `undefined` when no hard-disable patch is required.
- */
-export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
-  if ((disabledEnv ?? '') === '' || !hasRow) return undefined
-  return { id: TELEMETRY_ROW_ID, disabled: true }
-}
-
 /**
  * Load a resolved profile for `name` and (re)write the empty root config. The
  * root is always rewritten: the whole composition is patch layers, and the
@@ -198,26 +175,13 @@ export function prepareProfile(name: string, userLayer = true, fromDefaultProfil
 
 /** One profile's patch layers, in application order. */
 interface ComposedProfile {
-  resolution: ProfileResolutionGeneration
   profile: Profile
-  /** Bundle layers concatenated — the part below the user layers on a live reload. */
-  bundlePatches: PatchOptions[]
-  /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */
-  homePatches: PatchOptions[]
-  /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
+  /** Immutable package fallback selected before any plugin imports. */
+  resolution: ProfileResolutionGeneration
+  /** Command-line overlay contents, frozen for this invocation. */
   overlays: PatchOptions[]
 }
 
-/** The full patch stack of one composed profile, in application order. */
-function allPatches(composed: ComposedProfile): PatchOptions[] {
-  return [
-    ...composed.bundlePatches,
-    ...composed.profile.patches,
-    ...composed.homePatches,
-    ...composed.overlays,
-  ]
-}
-
 /**
  * Load `name` and compose its effective patch stack: bundle layers in
  * `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
@@ -228,7 +192,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
  * @param fromDefaultProfile - shipped template for a missing named profile.
- * @param resolvedProfile - application-owned profile and installation; bypasses named discovery.
+ * @param resolvedProfile - application-owned profile and installation.
  * @returns the profile and its patch layers.
  */
 async function composeProfile(
@@ -245,17 +209,8 @@ async function composeProfile(
   const resolution = resolutionMode === 'runtime' || resolvedProfile !== undefined
     ? await createProfileResolutionGeneration(resolutionOptions)
     : await healProfilesModuleFallback(resolutionOptions)
-  const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
-  const bundlePatches = profile.layers.flatMap(layer => layer.patches)
-  const rows = new Map<string, EntryOptions>()
-  for (const row of composeEntries([bundlePatches, profile.patches, homePatches, overlays])) {
-    if (typeof row.id === 'string') rows.set(row.id, row)
-  }
-  const composedOverlays = [...overlays]
-  const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
-  if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
-  return { profile, resolution, bundlePatches, homePatches, overlays: composedOverlays }
+  return { profile, resolution, overlays }
 }
 
 /** An application-owned profile and its independent installation fallback. */
@@ -268,8 +223,6 @@ export interface ResolvedProfileRuntime {
 
 /** Options for {@link runProfile}. */
 export interface RunProfileOptions {
-  /** Package lookup strategy; packaged executables always use runtime resolution. */
-  resolutionMode?: ProfileResolutionMode
   /** This run's frozen environment snapshot, provided before any entry mounts. */
   environment: LaunchEnvironmentSnapshot
   /** The profile name to boot. */
@@ -282,22 +235,8 @@ export interface RunProfileOptions {
   patchFiles: readonly string[]
   /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
   args: readonly string[]
-}
-
-/**
- * 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
+  /** Module fallback backend; pkg executables always use runtime resolution. */
+  resolutionMode?: ProfileResolutionMode
 }
 
 /**
@@ -305,7 +244,7 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown
  * mounted plugins (or to a one-shot runner the composition mounts).
  * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
  * @returns the settled root context and the shutdown controller.
- * @throws after disposing startup resources; a cleanup failure retains both errors in an AggregateError.
+ * @throws after disposing startup resources; cleanup failures retain the original error.
  */
 export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
   // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
@@ -352,28 +291,17 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     })
 
     const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
-    // Recomposition for the live user layers: bundle layers below, overlays
-    // above, so a user edit can never displace them. Parsed app arguments are
-    // not in here at all — they live in app-provided services that survive a
-    // recomposition. BOTH
-    // user files are re-read per generation (the HMR watcher hands us only the
-    // changed file's patches, which one of the reads duplicates — fresh reads
-    // keep the two watchers from stitching in each other's stale copy).
-    // Fresh clones per generation: the include pushes `insert` rows into the
-    // mounted tree BY REFERENCE and later id-targeted patches mutate those
-    // objects in place. Reusing one parsed patch object across applications
-    // would bake a user override into the bundle's in-memory insert row, so
-    // removing the override could never revert the row to the bundle default.
-    const composeLive = (): PatchOptions[] => structuredClone([
-      ...composed.bundlePatches,
-      ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [],
-      ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
-      ...composed.overlays,
-    ])
-    // Cloned for the same insert-aliasing reason as composeLive: the boot
-    // application must not mutate the objects later reloads recompose from.
-    const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => {
+    const profileContext: ProfileContext = {
+      name: options.profile,
+      dir: composed.profile.dir, patchPath: composed.profile.patchPath,
+      installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
+      startedBundles: composed.profile.layers.map(layer => layer.packageName),
+      cwd: process.cwd(), home: resolveDshHome(),
+      overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
+    }
+    const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {
       app.current = hostCtx
+      hostCtx.provide('profileContext', profileContext)
       // 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)
@@ -390,43 +318,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/cordis-plugin-hmr', config: { root: [] } })
-          await ctx.loader.await()
-        }
-        await watchUserPatches(ctx, {
-          binName: NAME,
-          filename: composed.profile.patchPath,
-          compose: composeLive,
-        })
-        await watchUserPatches(ctx, {
-          binName: NAME,
-          filename: homePatchPath(),
-          compose: composeLive,
-        })
-      } catch (error) {
-        suppressShutdownError(ctx, signalShutdown.signal, error)
-      }
-    }
     if (!signalShutdown.signal.aborted
       && ctx.fiber.state === FiberState.ACTIVE
       && ctx.get('loader') !== undefined) {

+ 142 - 17
apps/cli/tests/built-bin.e2e.ts

@@ -11,11 +11,12 @@ import {
   PROTOCOL_VERSION,
   type SessionNotification,
 } from '@agentclientprotocol/sdk'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
 import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
 import { execa } from 'execa'
 import * as yaml from 'js-yaml'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
 const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
@@ -114,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}`,
     '',
@@ -278,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]',
@@ -665,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')
@@ -801,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',
@@ -839,6 +846,114 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}\nsignal: ${String(result.signal)}`).toBe(0)
       expect(result.signal).toBeUndefined()
       expect(existsSync(fixture.disposed)).toBe(true)
+    } catch (error) {
+      child.kill('SIGKILL')
+      const result = await child
+      throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
+    } finally {
+      child.kill('SIGKILL')
+      await child
+      rmSync(fixture.home, { recursive: true, force: true })
+    }
+  }, SPAWN_TIMEOUT_MS + 30_000)
+
+  it('recomposes bundle selections after a shared profile transaction releases its lock', async () => {
+    const fixture = createProfileLifecycleFixture()
+    const dir = join(fixture.home, 'profiles', 'lifecycle')
+    const manifestPath = join(dir, 'package.json')
+    const bundleDir = join(dir, 'node_modules', 'extra-bundle')
+    const mounted = join(fixture.home, 'extra-mounted')
+    const unmounted = join(fixture.home, 'extra-unmounted')
+    mkdirSync(bundleDir, { recursive: true })
+    writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({
+      name: 'extra-bundle', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } },
+    }))
+    writeFileSync(join(bundleDir, 'cordis.patch.yml'), '- insert:\n    - id: extra\n      name: ./plugin.mjs\n')
+    writeFileSync(join(bundleDir, 'plugin.mjs'), `
+      import { writeFileSync } from 'node:fs'
+      export function apply(ctx) {
+        writeFileSync(${JSON.stringify(mounted)}, 'mounted')
+        ctx.effect(() => () => { writeFileSync(${JSON.stringify(unmounted)}, 'unmounted') })
+      }
+    `)
+    const child = startProfileLifecycle(fixture)
+    try {
+      await waitForFile(fixture.settled)
+      await withFileLock(manifestPath, async () => {
+        const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
+        manifest.dsh.profile.bundles.push('extra-bundle')
+        await writeFileAtomic(manifestPath, JSON.stringify(manifest), { mode: 0o600 })
+      })
+      await waitForFile(mounted)
+      await withFileLock(manifestPath, async () => {
+        const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
+        manifest.dsh.profile.bundles = manifest.dsh.profile.bundles.filter(name => name !== 'extra-bundle')
+        await writeFileAtomic(manifestPath, JSON.stringify(manifest), { mode: 0o600 })
+      })
+      await waitForFile(unmounted)
+      requestProfileShutdown(child, fixture)
+      expect((await child).exitCode).toBe(0)
+    } catch (error) {
+      child.kill('SIGKILL')
+      const result = await child
+      throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
+    } finally {
+      child.kill('SIGKILL')
+      await child
+      rmSync(fixture.home, { recursive: true, force: true })
+    }
+  }, SPAWN_TIMEOUT_MS + 30_000)
+
+  it('coordinates source-module replacement and profile patches through dsh-hmr', async () => {
+    const fixture = createProfileLifecycleFixture()
+    const dir = join(fixture.home, 'profiles', 'lifecycle')
+    const source = join(fixture.home, 'lifecycle-bundle', 'plugin.mjs')
+    const mounted = join(fixture.home, 'module-reloaded')
+    const echo = join(fixture.home, 'config-echo')
+    const original = readFileSync(source, 'utf8')
+      .replace('void ctx.loader.await().then', 'ctx.appReady.onReady')
+      + "\nexport const inject = ['hmr', 'appReady']\n"
+    writeFileSync(source, original)
+    const hmrPatch = [
+      '- 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')
+    writeFileSync(patch, hmrPatch)
+    const child = startProfileLifecycle(fixture)
+    try {
+      await waitForFile(fixture.settled)
+      await withFileLock(join(dir, 'package.json'), async () => {
+        writeFileSync(source, original.replace('  let active = true',
+          `  writeFileSync(${JSON.stringify(mounted)}, 'mounted')\n  let active = true`))
+        await writeFileAtomic(patch, hmrPatch
+          + '- id: profile-lifecycle-fixture\n  config:\n    generation: configuration-reloaded\n', { mode: 0o600 })
+        await waitForFile(mounted)
+        await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('configuration-reloaded') }, { timeout: SPAWN_TIMEOUT_MS })
+      })
+      const replacedAgain = join(fixture.home, 'module-reloaded-again')
+      writeFileSync(source, original.replace('  let active = true',
+        `  writeFileSync(${JSON.stringify(replacedAgain)}, 'mounted')\n  let active = true`))
+      await waitForFile(replacedAgain)
+      expect(readFileSync(echo, 'utf8')).toBe('configuration-reloaded')
+      const reconfiguredHmr = hmrPatch.replace('debounce: 0', 'debounce: 1')
+      await writeFileAtomic(patch, reconfiguredHmr
+        + '- id: profile-lifecycle-fixture\n  config:\n    generation: hmr-reconfigured\n', { mode: 0o600 })
+      await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('hmr-reconfigured') }, { timeout: SPAWN_TIMEOUT_MS })
+      await writeFileAtomic(patch, reconfiguredHmr, { mode: 0o600 })
+      await vi.waitFor(() => { expect(readFileSync(echo, 'utf8')).toBe('bundle-default') }, { timeout: SPAWN_TIMEOUT_MS })
+      requestProfileShutdown(child, fixture)
+      const result = await child
+      expect(result.exitCode).toBe(0)
+    } catch (error) {
+      child.kill('SIGKILL')
+      const result = await child
+      throw new Error(`${String(error)}\n${result.stderr}`, { cause: error })
     } finally {
       child.kill('SIGKILL')
       await child
@@ -924,6 +1039,20 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
+  it('forwards CLI authentication and stdin through pnpm while preserving its exit code', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-interaction-'))
+    try {
+      const child = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'interactive', 'exec', process.execPath, '-e',
+        "const fs = require('node:fs'); const input = fs.readFileSync(0, 'utf8'); const auth = ['NPM_TOKEN','NODE_AUTH_TOKEN','GH_TOKEN','GITHUB_TOKEN'].every(name => process.env[name] === 'fixture-auth'); process.stdout.write(JSON.stringify({ input, auth })); process.exit(42)",
+      ], {
+        input: 'fixture-input', timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false,
+        env: { DSH_HOME: home, NPM_TOKEN: 'fixture-auth', NODE_AUTH_TOKEN: 'fixture-auth', GH_TOKEN: 'fixture-auth', GITHUB_TOKEN: 'fixture-auth' },
+      })
+      expect(child.exitCode).toBe(42)
+      expect(child.stdout).toContain('{"input":"fixture-input","auth":true}')
+    } finally { rmSync(home, { recursive: true, force: true }) }
+  }, SPAWN_TIMEOUT_MS + 30_000)
+
   it('anchors a relative add spec to the invoking directory, not the profile', async () => {
     // `dsh plugin --profile x add .` from a plugin checkout must install THAT
     // checkout — pnpm's cwd is the profile directory, so an un-anchored `.`
@@ -973,7 +1102,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS * 2 + 30_000)
 
-  it('reconciles a real pnpm alias and keeps ordinary dependencies outside the bundle list', async () => {
+  it('reconciles a real pnpm alias without reactivating it and keeps ordinary dependencies outside the bundle list', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-alias-'))
     try {
       const bundle = join(home, 'bundle-source')
@@ -1003,7 +1132,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(refreshed.code).toBe(0)
       expect(refreshed.stderr).not.toContain('declares no dsh.bundle')
       const active = JSON.parse(readFileSync(manifestPath, 'utf8')) as { dsh: { profile: { bundles: string[] } } }
-      expect(active.dsh.profile.bundles).toContain('bundle-alias')
+      expect(active.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
       const removed = await runBuiltBin(['plugin', '--profile', 'alias', 'remove', 'bundle-alias'], { DSH_HOME: home }, home)
       expect(removed.code).toBe(0)
       const remaining = JSON.parse(readFileSync(manifestPath, 'utf8')) as {
@@ -1017,11 +1146,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   })
 
-  it('activates a dependency that gained dsh.bundle in a later update', async () => {
-    // Reconcile runs against the INSTALLED state on every successful pnpm
-    // run, so `update` (not only `add`) activates a package whose newer
-    // version declares dsh.bundle. Simulated without a registry: hand-place
-    // the installed package, flip its manifest, and run a benign pnpm verb.
+  it('keeps existing dependencies inactive when package metadata gains a bundle declaration', async () => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-update-'))
     try {
       const profileDir = join(home, 'profiles', 'up')
@@ -1048,7 +1173,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       const second = await runBuiltBin(['plugin', '--profile', 'up', 'root'], { DSH_HOME: home })
       expect(second.code).toBe(0)
       manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { dsh: { profile: { bundles: string[] } } }
-      expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base', 'late-bundle'])
+      expect(manifest.dsh.profile.bundles).toEqual(['@deepseek-ai/dsh-base'])
     } finally {
       rmSync(home, { recursive: true, force: true })
     }

+ 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 }[]

+ 20 - 0
apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts

@@ -336,6 +336,26 @@ 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)
+    // 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) {
+  writeFileSync(${JSON.stringify(f.patch)}, ${JSON.stringify(f.render('matrix-optional', 'import'))})`,
+    ))
+    const app = start(f)
+    try {
+      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'))
+      await app.serves()
+    } finally { exit(await app.close(), 0) }
+  })
+
   it.each(['startup', 'HMR'])('optional HTTP bind failure at %s leaves Web serving', async (phase) => {
     const f = fixture()
     const blocker = createServer()

+ 6 - 16
apps/cli/tests/resolved-profile-boot.spec.ts

@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
-import { boot, composeEntries, healIsolatedProfileModuleFallback, watchUserPatches, type Profile } from '@deepseek-ai/dsh-app-boot'
+import { boot, composeEntries, healIsolatedProfileModuleFallback, type Profile } from '@deepseek-ai/dsh-app-boot'
 import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { runProfile } from '../src/profile-boot.ts'
@@ -14,7 +14,6 @@ vi.mock('@deepseek-ai/dsh-app-boot', async importOriginal => ({
   boot: vi.fn(),
   healIsolatedProfileModuleFallback: vi.fn(),
   installFailLoud: vi.fn(),
-  watchUserPatches: vi.fn(),
 }))
 vi.mock('@deepseek-ai/dsh-http-proxy', () => ({ installProxyFromEnvironment: vi.fn() }))
 
@@ -49,14 +48,12 @@ describe('runProfile with an application-owned profile', () => {
     vi.mocked(installProxyFromEnvironment).mockResolvedValue(disposeProxy)
     vi.mocked(boot).mockImplementation(async (_name, _root, _patches, setup) => {
       await setup?.(ctx)
-      if (stage === 'boot') throw failure
-      return ctx
+      throw failure
     })
     if (stage === 'composition') vi.mocked(healIsolatedProfileModuleFallback).mockImplementationOnce(() => { throw failure })
-    if (stage !== 'composition' && stage !== 'boot') vi.mocked(watchUserPatches).mockImplementationOnce(() => { throw failure })
     const profile: Profile = {
       name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
-      patchReload: 'live', patches: [], layers: [],
+      patches: [], layers: [],
     }
     try {
       const application = runProfile({
@@ -79,7 +76,7 @@ describe('runProfile with an application-owned profile', () => {
     }
   })
 
-  it.each([['live', 'link'], ['startup', 'link'], ['startup', 'runtime']] as const)('uses shared layers, %s reload, %s resolution, and shutdown', async (patchReload, resolutionMode) => {
+  it.each(['link', 'runtime'] as const)('uses shared layers, %s resolution, and shutdown', async (resolutionMode) => {
     const home = mkdtempSync(join(tmpdir(), 'dsh-resolved-profile-'))
     homes.push(home)
     mkdirSync(join(home, 'runtime'))
@@ -108,7 +105,7 @@ describe('runProfile with an application-owned profile', () => {
     writeFileSync(overlay, '- id: target\n  config: { overlay: true, priority: overlay }\n')
     writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
     const profile: Profile = {
-      name: 'desktop', dir: home, patchPath: profilePatch, patchReload,
+      name: 'desktop', dir: home, patchPath: profilePatch,
       patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
       layers: [{
         packageName: 'test-bundle', packageDir: home, patchPath: join(home, 'bundle.yml'),
@@ -145,14 +142,7 @@ describe('runProfile with an application-owned profile', () => {
       ])
       expect(rows.find(row => row.id === 'target')?.config).toEqual({ overlay: true, priority: 'overlay' })
       expect(rows.find(row => row.id === 'session-telemetry-otel')?.disabled).toBe(true)
-      if (patchReload === 'live') {
-        expect(vi.mocked(watchUserPatches).mock.calls.map(call => call[1].filename)).toEqual([profilePatch, homePatch])
-        writeFileSync(homePatch, '- id: target\n  config: { home: updated }\n')
-        const live = vi.mocked(watchUserPatches).mock.calls[0]![1].compose!
-        expect(live([])[2]).toMatchObject({ id: 'target', config: { home: 'updated' } })
-      } else {
-        expect(watchUserPatches).not.toHaveBeenCalled()
-      }
+      expect(ctx.profileContext).toMatchObject({ dir: home, patchPath: profilePatch, installAnchor: runtime.installAnchor })
       await shutdown.shutdown(0)
       expect(dispose).toHaveBeenCalledOnce()
       expect(disposeProxy).toHaveBeenCalledOnce()

+ 1 - 1
apps/cli/tests/telemetry-switch.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { resolveTelemetryPatch } from '../src/profile-boot.ts'
+import { resolveTelemetryPatch } from '@deepseek-ai/dsh-app-boot'
 
 describe('resolveTelemetryPatch', () => {
   it('preserves the configured telemetry mode when the hard-disable switch is unset or empty', () => {

+ 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),

+ 6 - 0
apps/cli/tsconfig.json

@@ -70,6 +70,12 @@
     },
     {
       "path": "../../packages/shell/tool-bash"
+    },
+    {
+      "path": "../../packages/boot/plugin-manager"
+    },
+    {
+      "path": "../../packages/util/atomic-write"
     }
   ]
 }

+ 3 - 3
apps/desktop/src/project-manager.ts

@@ -313,7 +313,7 @@ export function createRuntimeProjectMetadata(projectDir: string, release: Deskto
     private: true,
     version: '0.0.0',
     dependencies: desktopCorePackageOverrides(packageSet),
-    dsh: { profile: { bundles: [...WEB_PROFILE.bundles], patchReload: WEB_PROFILE.patchReload } },
+    dsh: { profile: { bundles: [...WEB_PROFILE.bundles] } },
   }
   writeJson(join(projectDir, 'package.json'), manifest)
   writeFileSync(
@@ -338,7 +338,7 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De
       [DSH_PACKAGE]: release.version,
       [DESKTOP_HOST_PACKAGE]: release.version,
     },
-    dsh: { profile: { bundles: [...WEB_PROFILE.bundles], patchReload: WEB_PROFILE.patchReload } },
+    dsh: { profile: { bundles: [...WEB_PROFILE.bundles] } },
   }
   writeJson(join(projectDir, 'package.json'), manifest)
   writeFileSync(join(projectDir, 'pnpm-workspace.yaml'), workspaceFile(), { mode: 0o600 })
@@ -346,5 +346,5 @@ export function createDevelopmentProjectMetadata(projectDir: string, release: De
 
 /** Create the first external plugin profile without running a package manager. */
 export function createPluginProfile(projectDir: string): void {
-  initProfile(projectDir, WEB_PROFILE.bundles, WEB_PROFILE.patchReload)
+  initProfile(projectDir, WEB_PROFILE.bundles)
 }

+ 241 - 0
apps/web/tests/client-plugin-live.e2e.ts

@@ -0,0 +1,241 @@
+/** Real profile, Remote, bundle scripts and Cordis slots: page-local client lifecycle without navigation. */
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { chromium, type Page } from 'playwright'
+import { expect, it, onTestFailed, onTestFinished } from 'vitest'
+import { launchWebScaffold, watchConsole, captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
+import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-live-client', import.meta.url))
+const EXPECTED = fileURLToPath(new URL('./expected/client-plugin-live', import.meta.url))
+
+async function openInventory(page: Page, url: string) {
+  await page.goto(url, { waitUntil: 'load' })
+  await page.getByRole('button', { name: '设置', exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '设置' })
+  await dialog.getByRole('button', { name: '插件', exact: true }).click()
+  await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
+  await dialog.getByRole('searchbox', { name: '搜索插件' }).waitFor()
+  return dialog
+}
+
+it('synchronizes two pages, disposes effects and restores an offline page from the latest graph without navigation', async () => {
+  const scaffold = await launchWebScaffold({
+    extraInstallAnchors: [join(FIXTURE, 'package.json')],
+  })
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    const otherContext = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    const page = await context.newPage()
+    const other = await otherContext.newPage()
+    const consoles = [watchConsole(page), watchConsole(other)]
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live'))
+    await openInventory(page, scaffold.authenticatedUrl)
+    const otherInventory = await openInventory(other, scaffold.authenticatedUrl)
+    let entryId: string | undefined
+    const toggle = async () => {
+      if (entryId === undefined) entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+      else { scaffold.ctx.loader.remove(entryId); entryId = undefined }
+    }
+    let navigations = 0
+    for (const target of [page, other]) target.on('framenavigated', () => { navigations++ })
+    const live = (target: Page) => target.locator('[data-live-client]')
+    const dataset = (target: Page) => target.evaluate(() => ({
+      liveMounts: document.documentElement.dataset.liveMounts,
+      liveDisposals: document.documentElement.dataset.liveDisposals,
+      liveHits: document.documentElement.dataset.liveHits,
+    }))
+    const ping = (target: Page) => target.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
+    expect(await live(page).count()).toBe(0)
+    expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(false)
+
+    await toggle()
+    for (const target of [page, other]) {
+      await live(target).waitFor()
+      expect(await live(target).evaluate(el => getComputedStyle(el).color)).toBe('rgb(12, 34, 56)')
+      await ping(target)
+      expect((await dataset(target)).liveHits).toBe('1')
+    }
+    await compareOrRefreshGolden(join(EXPECTED, 'enabled.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
+
+    // The inventory filter is page-owned state that live composition must preserve.
+    const draft = otherInventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    await toggle()
+    for (const target of [page, other]) {
+      await expect.poll(() => live(target).count()).toBe(0)
+      await expect.poll(async () => (await dataset(target)).liveDisposals).toBe('1')
+      await ping(target)
+      expect((await dataset(target)).liveHits).toBe('1')
+      expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
+    }
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    await toggle()
+    for (const target of [page, other]) {
+      await live(target).waitFor()
+      expect(await live(target).count()).toBe(1)
+      expect((await dataset(target)).liveMounts).toBe('2')
+      expect(await target.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
+    }
+
+    const disconnected = other.waitForEvent('requestfailed', request => request.url().includes('/plugins/events'))
+    await otherContext.setOffline(true)
+    // Chromium offline emulation leaves established SSE sockets open. Cycling this
+    // fixture's transport plugin closes them without replacing the Host or profile.
+    const transport = [...scaffold.ctx.loader.entries()].find(entry => entry.options.name === '@deepseek-ai/dsh-client-hmr')!.fiber!
+    const reconnected = page.waitForResponse(response => response.url().includes('/plugins/events') && response.status() === 200)
+    await transport.restart()
+    await disconnected
+    await reconnected
+    await toggle()
+    await expect.poll(() => live(page).count()).toBe(0)
+    expect(await live(other).count()).toBe(1)
+    await otherContext.setOffline(false)
+    await expect.poll(() => live(other).count(), { timeout: 20_000 }).toBe(0)
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(navigations).toBe(0)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    for (const console of consoles) expect(console.pageErrors).toEqual([])
+  } finally {
+    await browser.close()
+    await scaffold.close()
+  }
+}, 90_000)
+
+it('keeps a failed client download local and retries without changing Host enablement', async () => {
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-live-retry'))
+    await openInventory(page, scaffold.authenticatedUrl)
+    const bundle = (url: URL) => url.pathname.startsWith('/plugins/') && url.search.includes('@fixture/live-client/client.js')
+    await page.route(bundle, route => route.abort())
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.waitFor()
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+    expect(await page.locator('[data-live-client]').count()).toBe(0)
+    expect(scaffold.ctx.clientModules.graph().entries.some(row => row.id === '@fixture/live-client')).toBe(true)
+    await page.unroute(bundle)
+    await failure.getByRole('button', { name: '重试本页面同步' }).click()
+    await page.locator('[data-live-client]').waitFor()
+    await expect.poll(() => failure.count()).toBe(0)
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+  } finally {
+    await browser.close()
+    await scaffold.close()
+  }
+}, 90_000)
+
+it('recovers an uncreated client entry with rebuilt factory code without navigation', async () => {
+  const fixture = await mkdtemp(join(tmpdir(), 'dsh-client-rebuild-'))
+  // Finished hooks unwind in reverse order, so the Host closes before its fixture is removed.
+  onTestFinished(() => rm(fixture, { recursive: true, force: true }))
+  await cp(FIXTURE, fixture, { recursive: true })
+  const file = join(fixture, 'client.js')
+  const source = await readFile(file, 'utf8')
+  const broken = source.replace("const React = require('react')", "throw new Error('fixture r0 factory failed')")
+  expect(broken).not.toBe(source)
+  await writeFile(file, broken)
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(fixture, 'package.json')] })
+  onTestFinished(() => scaffold.close())
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-factory-rebuild'))
+    const inventory = await openInventory(page, scaffold.authenticatedUrl)
+    const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.getByText(/fixture r0 factory failed/).waitFor()
+    const rebuilt = source.replace('动态插件已启用', '动态插件 r1 已启用').replace('Live plugin enabled', 'Live plugin r1 enabled')
+    await writeFile(file, rebuilt)
+    scaffold.ctx.clientModules.rebuilt('@fixture/live-client')
+    await page.getByText('动态插件 r1 已启用', { exact: true }).waitFor()
+    await expect.poll(() => failure.count()).toBe(0)
+    await compareOrRefreshGolden(join(EXPECTED, 'recovered.expected.md'), await captureStableAria(page, '[data-live-client]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(1)
+    expect(await page.evaluate(() => document.documentElement.dataset.liveMounts)).toBe('1')
+    expect(scaffold.ctx.loader.resolve(entryId).fiber?.state).toBe(2)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+  } finally {
+    await browser.close()
+  }
+})
+
+it('reports bootstrap rebuilds without remounting the settings page or navigating', async () => {
+  const scaffold = await launchWebScaffold()
+  onTestFinished(() => scaffold.close())
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    const console = watchConsole(page)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-client-bootstrap-rebuild'))
+    const inventory = await openInventory(page, scaffold.authenticatedUrl)
+    const draft = inventory.getByRole('searchbox', { name: '搜索插件' })
+    await draft.fill('unfinished-filter')
+    const originalInput = await draft.elementHandle()
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    scaffold.ctx.clientModules.rebuilt('@deepseek-ai/dsh-client-modules')
+    const failure = page.locator('[data-client-sync-failure]')
+    await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+    await failure.getByRole('button', { name: '重试本页面同步' }).click()
+    await failure.getByText(/replacing bootstrap module .* requires a page reload/).waitFor()
+    await compareOrRefreshGolden(join(EXPECTED, 'bootstrap-rebuild.expected.md'), await captureStableAria(page, '[data-client-sync-failure]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(await originalInput!.evaluate(input => input.isConnected)).toBe(true)
+    expect(await draft.inputValue()).toBe('unfinished-filter')
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+    expect(console.pageErrors).toEqual([])
+  } finally {
+    await browser.close()
+  }
+})
+
+it('removes the client UI and resources while Host cleanup is still pending', async () => {
+  const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
+  let release!: () => void
+  const cleanup = new Promise<void>((resolve) => { release = resolve })
+  onTestFinished(async () => { release(); await scaffold.close() })
+  const host = scaffold.ctx.loader.ctx.fiber.uid
+  const browser = await chromium.launch()
+  try {
+    const page = await browser.newPage({ locale: ZH_BROWSER_LOCALE })
+    await openInventory(page, scaffold.authenticatedUrl)
+    let navigations = 0
+    page.on('framenavigated', () => { navigations++ })
+    const entryId = await scaffold.ctx.loader.create({ name: '@fixture/live-client' })
+    const live = page.locator('[data-live-client]')
+    await live.waitFor()
+    const fiber = scaffold.ctx.loader.resolve(entryId).fiber!
+    let hostDisposed = false
+    fiber.ctx.effect(() => async () => { await cleanup; hostDisposed = true })
+    scaffold.ctx.loader.remove(entryId)
+    await expect.poll(() => live.count()).toBe(0)
+    await expect.poll(() => page.evaluate(() => document.documentElement.dataset.liveDisposals)).toBe('1')
+    expect(await page.locator('style[data-plugin="@fixture/live-client"]').count()).toBe(0)
+    await page.evaluate(() => { window.dispatchEvent(new Event('dsh-fixture-ping')) })
+    expect(await page.evaluate(() => document.documentElement.dataset.liveHits)).toBeUndefined()
+    expect(hostDisposed).toBe(false)
+    expect(scaffold.ctx.loader.ctx.fiber.uid).toBe(host)
+    expect(navigations).toBe(0)
+    release()
+    while (fiber.inertia !== undefined) await fiber.inertia
+    expect(hostDisposed).toBe(true)
+  } finally {
+    await browser.close()
+  }
+})

+ 4 - 0
apps/web/tests/expected/client-plugin-live/bootstrap-rebuild.expected.md

@@ -0,0 +1,4 @@
+- alert: 本页面的插件未能完成同步;服务端的启用状态保持不变。
+- list:
+  - listitem: "@deepseek-ai/dsh-client-modules: Error: client-modules: replacing bootstrap module @deepseek-ai/dsh-client-modules requires a page reload"
+- button "重试本页面同步"

+ 1 - 0
apps/web/tests/expected/client-plugin-live/enabled.expected.md

@@ -0,0 +1 @@
+- text: 动态插件已启用

+ 1 - 0
apps/web/tests/expected/client-plugin-live/recovered.expected.md

@@ -0,0 +1 @@
+- text: 动态插件 r1 已启用

+ 31 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/client.js

@@ -0,0 +1,31 @@
+/** Test package using the published registration protocol, locale and slots. */
+window.__ModuleLoader__.load({
+  id: '@fixture/live-client',
+  factory(require) {
+    const React = require('react')
+    const style = document.createElement('style')
+    style.dataset.plugin = '@fixture/live-client'
+    style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
+    document.head.append(style)
+    return {
+      inject: ['slots', 'locale'],
+      apply(ctx) {
+        const counters = document.documentElement.dataset
+        counters.liveMounts = String(Number(counters.liveMounts ?? 0) + 1)
+        ctx.effect(() => ctx.locale.register('fixtureLive', { zh: { active: '动态插件已启用' }, en: { active: 'Live plugin enabled' } }))
+        ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+          name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive',
+        }, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active'))))
+        ctx.effect(() => {
+          const ping = () => { counters.liveHits = String(Number(counters.liveHits ?? 0) + 1) }
+          window.addEventListener('dsh-fixture-ping', ping)
+          return async () => {
+            window.removeEventListener('dsh-fixture-ping', ping)
+            await Promise.resolve()
+            counters.liveDisposals = String(Number(counters.liveDisposals ?? 0) + 1)
+          }
+        })
+      },
+    }
+  },
+})

+ 3 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/cordis.patch.yml

@@ -0,0 +1,3 @@
+- insert:
+    - id: fixture-live-client
+      name: '@fixture/live-client'

+ 2 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/index.js

@@ -0,0 +1,2 @@
+/** Host companion for the live browser lifecycle fixture. */
+export function apply() {}

+ 13 - 0
apps/web/tests/fixtures/plugins/fixture-live-client/package.json

@@ -0,0 +1,13 @@
+{
+  "name": "@fixture/live-client",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "index.js",
+  "exports": { ".": "./index.js", "./client": "./client.js" },
+  "dsh": {
+    "title": "动态界面示例",
+    "bundle": { "patch": "./cordis.patch.yml" },
+    "client": { "platform": "web" }
+  }
+}

+ 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 }

+ 1 - 0
apps/web/tsconfig.json

@@ -46,6 +46,7 @@
     "tests/details-session-lifecycle.e2e.ts",
     "tests/document-preview.e2e.ts",
     "tests/plugin-config.e2e.ts",
+    "tests/client-plugin-live.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
     "tests/deepseek-messages-settings.e2e.ts",

+ 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: a476bb99bb79d2df0544fe032fb1fc06b6d0cbb7
-architecture.zh.md: a14748ce0b4220a04f05f79121e1a9e27fa360b8
+architecture.md: 30eabcd661147587144edacdcd16163b6264a5aa
+architecture.zh.md: f8b4146b367de5aee13677813854d1a6c3795b87

+ 3 - 1
docs/architecture.md

@@ -26,7 +26,9 @@ 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 override these defaults. HMR coordinates watching and reloads; the launcher provides profile data and readiness.
+
+Base includes [Plugin Manager](../packages/boot/plugin-manager/README.md) for Web and agents.
 
 To see the tree your machine boots:
 

+ 3 - 1
docs/architecture.zh.md

@@ -26,7 +26,9 @@
 
 各层按此顺序应用在空条目列表之上:先按 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)。
 
 要查看你的机器启动的配置树:
 

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

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

+ 17 - 0
docs/capability-seams.md

@@ -7,6 +7,13 @@ A service can be a core spine service, a swappable capability seam, or a bundle/
 
 ```mermaid
 flowchart LR
+  pkg_hmr["hmr"]
+  svc_hmr["ctx.hmr<br/>Serialized module and configuration reloads"]
+  pkg_app_boot["app-boot"]
+  pkg_plugin_manager["plugin-manager"]
+  svc_pluginManager["ctx.pluginManager<br/>Current-profile plugin and bundle management"]
+  pkg_ui_settings_plugin_inventory["ui-settings-plugin-inventory"]
+  svc_profileContext["ctx.profileContext<br/>Launcher-owned profile data"]
   pkg_mcp_resources["mcp-resources"]
   svc_mcpResources["ctx.mcpResources<br/>Scoped MCP resource access"]
   pkg_mcp_client["mcp-client"]
@@ -253,6 +260,7 @@ flowchart LR
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
+  pkg_app_boot --> svc_profileContext
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
   pkg_authorization --> svc_authorization
@@ -286,6 +294,7 @@ flowchart LR
   pkg_fs_sandbox --> svc_fs
   pkg_fs_ssh --> svc_fs
   pkg_goal --> svc_goals
+  pkg_hmr --> svc_hmr
   pkg_host_directory_picker --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
@@ -305,6 +314,7 @@ flowchart LR
   pkg_message_feedback --> svc_messageFeedback
   pkg_permission_presets --> svc_permissionPresets
   pkg_plan_mode --> svc_planMode
+  pkg_plugin_manager --> svc_pluginManager
   pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions
   pkg_ptc_runtime --> svc_ptcRuntime
   pkg_ptc_runtime_node --> svc_ptcRuntime
@@ -403,6 +413,7 @@ flowchart LR
   svc_fileReferences --> pkg_api_session_controller
   svc_fileUploads --> pkg_api_session_controller
   svc_fs --> pkg_tool_fs
+  svc_hmr --> pkg_app_boot
   svc_invariants --> pkg_agent
   svc_invariants --> pkg_agent_loop
   svc_invariants --> pkg_scope
@@ -415,6 +426,9 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_mcpResources --> pkg_mcp_resources
+  svc_pluginManager --> pkg_plugin_manager
+  svc_pluginManager --> pkg_ui_settings_plugin_inventory
+  svc_profileContext --> pkg_plugin_manager
   svc_ptcRuntime --> pkg_tools
   svc_ptcRuntime --> pkg_workflow_ptc
   svc_sandbox --> pkg_bash_sandbox
@@ -508,6 +522,9 @@ flowchart LR
 
 | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
 | --- | --- | --- | --- | --- | --- | --- |
+| `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | Owns module and exact configuration watchers; application mutations share its queue and automatic reloads await the application file lock. |
+| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | Shares profile package operations with the CLI and reports persisted and running state to Web and agent callers. |
+| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. |
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | One provider-owned name per service instance. Providers own their tools and browser resources per live Session; the shared service has no browser operation API. |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | One provider-owned name per service instance. Each provider also owns its model tools; the service has no common action API, runtime selection, or Session workflow lock. |

+ 17 - 0
docs/capability-seams.zh.md

@@ -9,6 +9,13 @@
 
 ```mermaid
 flowchart LR
+  pkg_hmr["hmr"]
+  svc_hmr["ctx.hmr<br/>Serialized module and configuration reloads"]
+  pkg_app_boot["app-boot"]
+  pkg_plugin_manager["plugin-manager"]
+  svc_pluginManager["ctx.pluginManager<br/>Current-profile plugin and bundle management"]
+  pkg_ui_settings_plugin_inventory["ui-settings-plugin-inventory"]
+  svc_profileContext["ctx.profileContext<br/>Launcher-owned profile data"]
   pkg_mcp_resources["mcp-resources"]
   svc_mcpResources["ctx.mcpResources<br/>Scoped MCP resource access"]
   pkg_mcp_client["mcp-client"]
@@ -255,6 +262,7 @@ flowchart LR
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
+  pkg_app_boot --> svc_profileContext
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
   pkg_authorization --> svc_authorization
@@ -288,6 +296,7 @@ flowchart LR
   pkg_fs_sandbox --> svc_fs
   pkg_fs_ssh --> svc_fs
   pkg_goal --> svc_goals
+  pkg_hmr --> svc_hmr
   pkg_host_directory_picker --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
@@ -307,6 +316,7 @@ flowchart LR
   pkg_message_feedback --> svc_messageFeedback
   pkg_permission_presets --> svc_permissionPresets
   pkg_plan_mode --> svc_planMode
+  pkg_plugin_manager --> svc_pluginManager
   pkg_plugin_package_inventory_deepseek --> svc_deepseekLlmApiExtensions
   pkg_ptc_runtime --> svc_ptcRuntime
   pkg_ptc_runtime_node --> svc_ptcRuntime
@@ -405,6 +415,7 @@ flowchart LR
   svc_fileReferences --> pkg_api_session_controller
   svc_fileUploads --> pkg_api_session_controller
   svc_fs --> pkg_tool_fs
+  svc_hmr --> pkg_app_boot
   svc_invariants --> pkg_agent
   svc_invariants --> pkg_agent_loop
   svc_invariants --> pkg_scope
@@ -417,6 +428,9 @@ flowchart LR
   svc_llm --> pkg_compaction_basic
   svc_lsp --> pkg_tool_lsp
   svc_mcpResources --> pkg_mcp_resources
+  svc_pluginManager --> pkg_plugin_manager
+  svc_pluginManager --> pkg_ui_settings_plugin_inventory
+  svc_profileContext --> pkg_plugin_manager
   svc_ptcRuntime --> pkg_tools
   svc_ptcRuntime --> pkg_workflow_ptc
   svc_sandbox --> pkg_bash_sandbox
@@ -510,6 +524,9 @@ flowchart LR
 
 | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
 | --- | --- | --- | --- | --- | --- | --- |
+| `ctx.hmr` | `core` | [`hmr`](../packages/boot/hmr) | - | [`app-boot`](../packages/boot/app-boot) | - | 负责模块和精确配置监听;应用修改共用其队列,自动重载等待应用文件锁。 |
+| `ctx.pluginManager` | `core` | [`plugin-manager`](../packages/boot/plugin-manager) | - | [`plugin-manager`](../packages/boot/plugin-manager), `ui-settings-plugin-inventory` | - | 与 CLI 共享 profile 包操作,并向 Web 和 Agent 调用方分别报告持久状态与运行状态。 |
+| `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 |
 | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 |
 | `ctx.browserUse` | `seam` | [`browser-use`](../packages/browser-use/browser-use) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp), [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp), [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | - | 每个服务实例注册一个提供方拥有的名称。提供方按实时 Session 拥有自己的工具与浏览器资源;共享服务不提供浏览器操作 API。 |
 | `ctx.computerUse` | `seam` | [`computer-use`](../packages/computer-use/computer-use) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp), [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | - | 每个服务实例只注册一个提供方自定的名称。各提供方也拥有自己的模型工具;服务不提供通用操作 API、运行时选择或 Session 流程锁。 |

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

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

+ 44 - 2
docs/config-catalog.md

@@ -7,7 +7,7 @@ Every `config:` block a `cordis.yml` entry can set: for each loadable harness pa
 
 This file is GENERATED from source (`scripts/gen-config-catalog.ts`) and verified fresh by `pnpm run verify-config-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks use a `ts config-catalog` fence (skipped by doc-typecheck, since a lone declaration referencing imports is not standalone-compilable). The generator also cross-checks the runtime schemastery schema against the pasted declaration — every schema-validated key, nested keys included, must be locatable on the declared config type — so the paste cannot hide a loader-accepted field.
 
-A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (`hmr`, the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.
+A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` tree must also load providers for those services. Scope is the harness tier (`packages/`); the vendored cordis plugins a config tree may also load (the console logger, …) are pinned upstream source ([vendoring policy](../vendor/README.md)) and not catalogued here.
 
 <a id="deepseek-aidsh-acp"></a>
 
@@ -444,7 +444,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
+Source: [`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-compaction-basic"></a>
 
@@ -915,6 +915,28 @@ export interface Config {
 
 Source: [`packages/bundle/headless/src/index.ts:42`](../packages/bundle/headless/src/index.ts)
 
+<a id="deepseek-aidsh-hmr"></a>
+
+## `@deepseek-ai/dsh-hmr`
+
+```ts config-catalog
+/** Module roots and watcher timing, with Chokidar deployment options. */
+export interface HmrConfig extends ChokidarOptions {
+  /** Directory resolved against the owning context's base URL. */
+  base?: string
+  /** Module watch roots; an empty list leaves only explicit configuration watches. */
+  root: string[]
+  /** Milliseconds for combining module changes. */
+  debounce: number
+  /** Glob patterns excluded from module watching. */
+  ignored: string[]
+}
+```
+
+Depends on: `ChokidarOptions` (`chokidar`)
+
+Source: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
+
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 
 ## `@deepseek-ai/dsh-hooks-claude-code`
@@ -1781,6 +1803,26 @@ export interface PlanModeConfig {
 
 Source: [`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
+<a id="deepseek-aidsh-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-plugin-manager`
+
+Requires: `loader` · `profileContext`
+
+```ts config-catalog
+/** Limits for package diagnostics and change notifications. */
+export interface Config {
+  /** Maximum retained pnpm diagnostic bytes per operation. */
+  outputBytes?: number
+  /** Delay for combining consecutive management notices in one durable injection. */
+  notificationDelayMs?: number
+  /** Maximum time to wait for another process's profile package operation. */
+  lockWaitMs?: number
+}
+```
+
+Source: [`packages/boot/plugin-manager/src/index.ts:21`](../packages/boot/plugin-manager/src/index.ts)
+
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
 ## `@deepseek-ai/dsh-plugin-package-inventory-deepseek`

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

@@ -9,7 +9,7 @@
 
 英文源文件由源代码(`scripts/gen-config-catalog.ts`)生成,并通过 `pnpm run verify-config-catalog`(`doc-sync` 的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块使用 `ts config-catalog` 围栏(doc-typecheck 会跳过它,因为单独引用导入项的声明无法独立编译)。英文生成器还会将运行时 schemastery schema 与粘贴的声明进行交叉核对——每个经 schema 验证的键(包括嵌套键)都必须能在声明的配置类型中找到——因此,粘贴内容无法隐藏加载器接受的字段。
 
-`Requires:` 行列出插件通过 `inject` 注入的服务键:其 `cordis.yml` 树还必须加载这些服务的提供者。范围限定为 harness 层级(`packages/`);配置树还可能加载的 vendored cordis 插件(`hmr`、控制台日志记录器等)固定为上游源代码(参见 [vendoring policy](../vendor/README.md)),未收录于此目录。
+`Requires:` 行列出插件通过 `inject` 注入的服务键:其 `cordis.yml` 树还必须加载这些服务的提供者。范围限定为 harness 层级(`packages/`);配置树还可能加载的 vendored cordis 插件(控制台日志记录器等)固定为上游源代码(参见 [vendoring policy](../vendor/README.md)),未收录于此目录。
 
 <a id="deepseek-aidsh-acp"></a>
 
@@ -446,7 +446,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
+来源:[`packages/client/hmr/src/index.ts:30`](../packages/client/hmr/src/index.ts)
 
 <a id="deepseek-aidsh-compaction-basic"></a>
 
@@ -917,6 +917,28 @@ export interface Config {
 
 来源:[`packages/bundle/headless/src/index.ts:42`](../packages/bundle/headless/src/index.ts)
 
+<a id="deepseek-aidsh-hmr"></a>
+
+## `@deepseek-ai/dsh-hmr`
+
+```ts config-catalog
+/** Module roots and watcher timing, with Chokidar deployment options. */
+export interface HmrConfig extends ChokidarOptions {
+  /** Directory resolved against the owning context's base URL. */
+  base?: string
+  /** Module watch roots; an empty list leaves only explicit configuration watches. */
+  root: string[]
+  /** Milliseconds for combining module changes. */
+  debounce: number
+  /** Glob patterns excluded from module watching. */
+  ignored: string[]
+}
+```
+
+依赖: `ChokidarOptions` (`chokidar`)
+
+来源: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
+
 <a id="deepseek-aidsh-hooks-claude-code"></a>
 
 ## `@deepseek-ai/dsh-hooks-claude-code`
@@ -1783,6 +1805,26 @@ export interface PlanModeConfig {
 
 来源:[`packages/plan/plan-mode/src/index.ts:64`](../packages/plan/plan-mode/src/index.ts)
 
+<a id="deepseek-aidsh-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-plugin-manager`
+
+依赖: `loader` · `profileContext`
+
+```ts config-catalog
+/** Limits for package diagnostics and change notifications. */
+export interface Config {
+  /** Maximum retained pnpm diagnostic bytes per operation. */
+  outputBytes?: number
+  /** Delay for combining consecutive management notices in one durable injection. */
+  notificationDelayMs?: number
+  /** Maximum time to wait for another process's profile package operation. */
+  lockWaitMs?: number
+}
+```
+
+来源: [`packages/boot/plugin-manager/src/index.ts:19`](../packages/boot/plugin-manager/src/index.ts)
+
 <a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
 
 ## `@deepseek-ai/dsh-plugin-package-inventory-deepseek`

+ 0 - 3
docs/cordis-api/inherited.md

@@ -18,7 +18,6 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
 - `ctx.root / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger` — Ambient handles onto the running context graph. ([`vendor/cordis/src/context.ts:16`](../../vendor/cordis/src/context.ts))
 - `ctx.timer (+ interval / timeout / throttle / debounce)` — Disposable timer helpers. The `timer` key is provided at runtime; the four supported helpers are mixed onto ctx directly (declared via Pick). ([`vendor/timer/src/index.ts:4`](../../vendor/timer/src/index.ts))
 - `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts))
-- `ctx.hmr` — The hot-module-reload watcher (present under the hmr plugin). ([`vendor/hmr/src/index.ts:15`](../../vendor/hmr/src/index.ts))
 
 ## Inherited events (cordis core + loader/hmr/timer)
 
@@ -30,8 +29,6 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie
 - `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts))
 - `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts))
 - `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts))
-- `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts))
-- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts))
 - `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))
 - `loader/config-update` — The loader config tree changed. ([`vendor/loader/src/index.ts:24`](../../vendor/loader/src/index.ts))
 - `loader/entry-init` — A config entry is being initialized. ([`vendor/loader/src/index.ts:25`](../../vendor/loader/src/index.ts))

+ 2 - 2
docs/cordis-tutorial/06-composition-and-hmr.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/cordis-tutorial/06-composition-and-hmr.md
-06-composition-and-hmr.md: 2b53aa28be99851e77a71de76337f2beb4003e8d
-06-composition-and-hmr.zh.md: 277e012378582844e71b68ca4f780c2ac8af70e3
+06-composition-and-hmr.md: 286e0fb58aad5619e83c018708af64be23fb67bd
+06-composition-and-hmr.zh.md: 49d80e3ff3d4d73922076cddf3ea74a56c38e70f

+ 2 - 2
docs/cordis-tutorial/06-composition-and-hmr.md

@@ -22,7 +22,7 @@ Groups nest a sub-list of entries that load and unload as one unit, and `isolate
 
 ## Hot module replacement
 
-Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@deepseek-ai/cordis-plugin-hmr` plugin watches your files and does exactly that on save.
+Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@deepseek-ai/dsh-hmr` plugin watches your files and does exactly that on save.
 
 In `tmp/cordis-tutorial`, write `cordis.yml`:
 
@@ -32,7 +32,7 @@ In `tmp/cordis-tutorial`, write `cordis.yml`:
 - id: timer
   name: '@deepseek-ai/cordis-plugin-timer'
 - id: hmr
-  name: '@deepseek-ai/cordis-plugin-hmr'
+  name: '@deepseek-ai/dsh-hmr'
   config:
     root: ['.']
 - id: hello

+ 2 - 2
docs/cordis-tutorial/06-composition-and-hmr.zh.md

@@ -22,7 +22,7 @@ Cordis 配置项除了 `name` 和 `config`,还接受其他元数据:
 
 ## 热模块替换
 
-卸载会释放 effect([第 2 章](02-lifecycle-and-effects.zh.md)),加载则遵循依赖关系([第 3 章](03-services.zh.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@deepseek-ai/cordis-plugin-hmr` 插件会监视文件,并在保存时执行这一过程。
+卸载会释放 effect([第 2 章](02-lifecycle-and-effects.zh.md)),加载则遵循依赖关系([第 3 章](03-services.zh.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@deepseek-ai/dsh-hmr` 插件会监视文件,并在保存时执行这一过程。
 
 在 `tmp/cordis-tutorial` 中编写 `cordis.yml`:
 
@@ -32,7 +32,7 @@ Cordis 配置项除了 `name` 和 `config`,还接受其他元数据:
 - id: timer
   name: '@deepseek-ai/cordis-plugin-timer'
 - id: hmr
-  name: '@deepseek-ai/cordis-plugin-hmr'
+  name: '@deepseek-ai/dsh-hmr'
   config:
     root: ['.']
 - id: hello

+ 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: 294fdfc288692385650d34d0aa0d272ef7919bb0
-event-producer-consumer.zh.md: cfb27d76b5a63dd477b49840e97c09be91179ed5
+event-producer-consumer.md: 3094730a1711409af356c6b01cbc0f8e276240bd
+event-producer-consumer.zh.md: a1ae2b199d428b1a9cda49f07af3dcb95f3ac9b2

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

@@ -45,6 +45,8 @@ 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/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../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 - 0
docs/event-producer-consumer.zh.md

@@ -47,6 +47,8 @@
 | `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/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
+| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../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: ed2db7575519e3f7ed036ae5a3c15309d980fc30
-module-graph.zh.md: c9339896380e02c6022b4de0d474c328d8d08c6b
+module-graph.md: 5206791a0e1e9898d124b2d3c154a64a2979f77d
+module-graph.zh.md: eaa5b4fc835904fbc86188f3099cab1ff84ffee1

+ 13 - 0
docs/module-graph.md

@@ -127,6 +127,8 @@ flowchart TD
   subgraph group_boot["packages/boot"]
     pkg_app_boot["app-boot"]
     pkg_cmdline["cmdline"]
+    pkg_hmr["hmr"]
+    pkg_plugin_manager["plugin-manager"]
   end
   subgraph group_browser_use["packages/browser-use"]
     pkg_browser_use["browser-use"]
@@ -490,6 +492,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
@@ -1081,6 +1085,13 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
+  pkg_plugin_manager --> pkg_agent
+  pkg_plugin_manager --> pkg_hmr
+  pkg_plugin_manager --> pkg_host_plugin_inventory
+  pkg_plugin_manager --> pkg_llm
+  pkg_plugin_manager --> pkg_subprocess
+  pkg_plugin_manager --> pkg_tools
+  pkg_plugin_manager --> pkg_typert_protocol
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
   pkg_headless --> pkg_fs
@@ -1420,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) |
@@ -1533,6 +1545,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 13 - 0
docs/module-graph.zh.md

@@ -129,6 +129,8 @@ flowchart TD
   subgraph group_boot["packages/boot"]
     pkg_app_boot["app-boot"]
     pkg_cmdline["cmdline"]
+    pkg_hmr["hmr"]
+    pkg_plugin_manager["plugin-manager"]
   end
   subgraph group_browser_use["packages/browser-use"]
     pkg_browser_use["browser-use"]
@@ -492,6 +494,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
@@ -1083,6 +1087,13 @@ flowchart TD
   pkg_acp --> pkg_session_persistence
   pkg_acp --> pkg_token_meter
   pkg_acp --> pkg_user_approval
+  pkg_plugin_manager --> pkg_agent
+  pkg_plugin_manager --> pkg_hmr
+  pkg_plugin_manager --> pkg_host_plugin_inventory
+  pkg_plugin_manager --> pkg_llm
+  pkg_plugin_manager --> pkg_subprocess
+  pkg_plugin_manager --> pkg_tools
+  pkg_plugin_manager --> pkg_typert_protocol
   pkg_headless --> pkg_agent
   pkg_headless --> pkg_agent_default_model
   pkg_headless --> pkg_fs
@@ -1422,6 +1433,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) |
@@ -1535,6 +1547,7 @@ flowchart TD
 | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
 | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent`](../packages/core/agent), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
 | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |

+ 2 - 2
docs/subsystems/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 docs/subsystems/README.md
-README.md: df5949b6d6a42392a4c64f4b3a619546c72763a0
-README.zh.md: 08bb60d7920fd2744b9af3a7585833084eed8464
+README.md: 01b9620c8e59793bd22cb484dc1c6e40296417c1
+README.zh.md: 6d4cbc8e4f104870d52257bc815d656ce5c29b6c

+ 1 - 0
docs/subsystems/README.md

@@ -6,6 +6,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
 
 | Page | Owns |
 |---|---|
+| [boot.md](boot.md) | Current-profile plugin management and launcher reload coordination |
 | [core.md](core.md) | how `packages/core` controls the agent loop: the package-by-package loop description, agent creation and ownership (`AgentHandle`), the `Agent` handle's delivery/cancellation/interception contracts, and the repo-wide type patterns (`…Map → derived-union`, branded ids) |
 | [llm-streaming.md](llm-streaming.md) | the `packages/llm` conversation types — `Message`/`ContentBlock`, the assembled model request, the `StreamChunk` wire protocol and adapter contract, `BlockAssembler`, and the `LlmAdapter` provider contract |
 | [token-meter.md](token-meter.md) | immutable scalar and positional replay measurements with consumed-log revisions |

+ 1 - 0
docs/subsystems/README.zh.md

@@ -6,6 +6,7 @@
 
 | 页面 | 负责内容 |
 |---|---|
+| [boot.md](boot.zh.md) | 当前 profile 插件管理与 launcher 重载协调 |
 | [core.md](core.zh.md) | `packages/core` 如何控制 agent loop(智能体循环):逐包的循环说明、agent 创建与所有权(`AgentHandle`)、`Agent` 句柄的投递/取消/拦截约定,以及全仓通用类型模式(`…Map → derived-union`、品牌化 id) |
 | [llm-streaming.md](llm-streaming.zh.md) | `packages/llm` 的对话类型——`Message`/`ContentBlock`、组装完成的模型请求、`StreamChunk` wire protocol 和适配器约定(adapter contract)、`BlockAssembler`,以及 `LlmAdapter` 提供方约定 |
 | [token-meter.md](token-meter.zh.md) | 不可变的标量与位置回放度量,附带已消费日志修订号 |

+ 6 - 0
docs/subsystems/boot.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write docs/subsystems/boot.md
+boot.md: b2507672c3728ebd426b089f4de33f2a83fcabee
+boot.zh.md: 1395b6de08f871e757a167ae3d655bcf41e36de8

+ 146 - 0
docs/subsystems/boot.md

@@ -0,0 +1,146 @@
+# Profile management
+
+English | [中文](boot.zh.md)
+
+The [boot package group](../../packages/boot/README.md) owns launcher-provided profile access and the plugin manager. [Plugin Manager](../../packages/boot/plugin-manager/README.md) documents persistence, reload and package-operation behavior.
+
+## Management records
+
+`PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id.
+
+`PluginInfo` carries module identity, effective enablement and fiber phase, plus a unique `patchId` or a `readOnlyReason`.
+
+`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability and optional resolution error.
+
+`InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer.
+
+`ChangeResult.changed` reports a disk edit independently of `application`: `applied`, `restart-required`, `overridden` or `failed`. `message` describes the result. Optional `packageResult` records the pnpm exit code, bounded output, truncation flag and complete diagnostic log path.
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxhmr--hmr"></a>
+
+### `ctx.hmr` — `Hmr`
+
+Hot reload service with Cordis-compatible module configuration and events.
+
+```ts cordis-catalog
+/** Serialize a caller-owned mutation with all automatic reload paths.
+ * @param operation Work that must not overlap module or configuration replacement.
+ * @returns The operation result after its asynchronous work completes.
+ */
+runExclusive<T>(operation: () => Promise<T>): Promise<T>
+
+/** Watch a configuration path through the same queue as module replacement.
+ * @param filename Absolute path, which may not exist yet.
+ * @param refresh Rebuilds configuration from its current files and awaits Loader completion.
+ * @returns Disposer closing this registration and waiting for its pending refresh.
+ */
+async watchConfig(filename: string, refresh: () => Promise<void>): Promise<() => Promise<void>>
+
+/** Read direct module dependency URLs from the active Node loader.
+ * @param url Module URL.
+ * @returns Linked module URLs, or an empty list for an uncached module.
+ */
+async getLinked(url: string): Promise<string[]>
+```
+
+Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+
+<a id="ctxpluginmanager--pluginmanager"></a>
+
+### `ctx.pluginManager` — `PluginManager`
+
+Manage profile files and apply their declared reload lifecycle.
+
+```ts cordis-catalog
+/** Read current plugins, including why a row cannot be changed through the profile patch.
+ * @returns Current runtime entries with persistent patch targets.
+ */
+@Remote async listPlugins(): Promise<PluginInfo[]>
+
+/** Read installed bundles and bundles supplied by this dsh installation.
+ * @returns Package versions, activation selections and removal availability.
+ */
+@Remote listBundles(): Promise<BundleInfo[]>
+
+/** Persist a plugin entry's desired enablement and apply it on live profiles.
+ * @param id Loader entry identity returned by listPlugins.
+ * @param enabled Whether the plugin should run.
+ * @returns Saved and runtime outcomes, including higher-priority overrides.
+ */
+@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>
+
+/** Select or remove a bundle layer while retaining installed dependencies.
+ * @param name Bundle package name.
+ * @param enabled Whether the bundle contributes its patch layer.
+ * @returns Persisted and runtime outcomes.
+ */
+@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
+
+/** Install a package using the same pnpm implementation as dsh plugin.
+ * @param spec One package spec, including local paths relative to the invocation directory.
+ * @param options Whether to activate the installed bundle; defaults to true.
+ * @returns Package-manager diagnostics and observed activation outcome.
+ */
+@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
+
+/** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
+ * @param name Installed dependency name.
+ * @returns Removal diagnostics and the remaining profile state.
+ */
+@Remote removeBundle(name: string): Promise<ChangeResult>
+```
+
+Source: [`packages/boot/plugin-manager/src/index.ts`](../../packages/boot/plugin-manager/src/index.ts)
+
+<a id="ctxprofilecontext--profilecontext"></a>
+
+### `ctx.profileContext` — `ProfileContext`
+
+Current profile facts; scheduling and mutation belong to their callers.
+
+Source: [`packages/boot/app-boot/src/profile-context.ts`](../../packages/boot/app-boot/src/profile-context.ts)
+
+<a id="hmr-events"></a>
+
+### `hmr/*` events
+
+<a id="hmrchange--emit"></a>
+
+#### `hmr/change` — emit
+
+A watched file has no module or configuration handler.
+
+```ts cordis-catalog
+/** A watched file has no module or configuration handler.
+ * @mode emit
+ * @param url Canonical file URL.
+ */
+'hmr/change'(url: string): void
+```
+
+Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+
+<a id="hmrreload--emit"></a>
+
+#### `hmr/reload` — emit
+
+Module replacements have finished loading.
+
+```ts cordis-catalog
+/** Module replacements have finished loading.
+ * @mode emit
+ * @param reloads Replaced plugins and their module locations.
+ */
+'hmr/reload'(reloads: Map<Plugin, Reload>): void
+```
+
+Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+<!-- END GENERATED cordis-surface -->

+ 146 - 0
docs/subsystems/boot.zh.md

@@ -0,0 +1,146 @@
+# Profile 管理
+
+[English](boot.md) | 中文
+
+[boot 包组](../../packages/boot/README.zh.md)负责 launcher 提供的 profile 访问与插件管理器。[插件管理器](../../packages/boot/plugin-manager/README.zh.md)文档说明持久化、重载与包操作行为。
+
+## 管理记录
+
+`PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。
+
+`PluginInfo` 包含模块标识、实际启停状态和 fiber 阶段,以及唯一的 `patchId` 或 `readOnlyReason`。
+
+`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性及可选的解析错误。
+
+`InstallBundleOptions.enabled` 默认为 true。False 表示安装但不选择该组合包层。
+
+`ChangeResult.changed` 独立报告磁盘修改,`application` 为 `applied`、`restart-required`、`overridden` 或 `failed`。`message` 描述结果。可选的 `packageResult` 记录 pnpm 退出码、有界输出、截断标记与完整诊断日志路径。
+
+<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+
+<a id="cordis-surface"></a>
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+<a id="ctxhmr--hmr"></a>
+
+### `ctx.hmr` — `Hmr`
+
+Hot reload service with Cordis-compatible module configuration and events.
+
+```ts cordis-catalog
+/** Serialize a caller-owned mutation with all automatic reload paths.
+ * @param operation Work that must not overlap module or configuration replacement.
+ * @returns The operation result after its asynchronous work completes.
+ */
+runExclusive<T>(operation: () => Promise<T>): Promise<T>
+
+/** Watch a configuration path through the same queue as module replacement.
+ * @param filename Absolute path, which may not exist yet.
+ * @param refresh Rebuilds configuration from its current files and awaits Loader completion.
+ * @returns Disposer closing this registration and waiting for its pending refresh.
+ */
+async watchConfig(filename: string, refresh: () => Promise<void>): Promise<() => Promise<void>>
+
+/** Read direct module dependency URLs from the active Node loader.
+ * @param url Module URL.
+ * @returns Linked module URLs, or an empty list for an uncached module.
+ */
+async getLinked(url: string): Promise<string[]>
+```
+
+Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+
+<a id="ctxpluginmanager--pluginmanager"></a>
+
+### `ctx.pluginManager` — `PluginManager`
+
+Manage profile files and apply their declared reload lifecycle.
+
+```ts cordis-catalog
+/** Read current plugins, including why a row cannot be changed through the profile patch.
+ * @returns Current runtime entries with persistent patch targets.
+ */
+@Remote async listPlugins(): Promise<PluginInfo[]>
+
+/** Read installed bundles and bundles supplied by this dsh installation.
+ * @returns Package versions, activation selections and removal availability.
+ */
+@Remote listBundles(): Promise<BundleInfo[]>
+
+/** Persist a plugin entry's desired enablement and apply it on live profiles.
+ * @param id Loader entry identity returned by listPlugins.
+ * @param enabled Whether the plugin should run.
+ * @returns Saved and runtime outcomes, including higher-priority overrides.
+ */
+@Remote setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>
+
+/** Select or remove a bundle layer while retaining installed dependencies.
+ * @param name Bundle package name.
+ * @param enabled Whether the bundle contributes its patch layer.
+ * @returns Persisted and runtime outcomes.
+ */
+@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
+
+/** Install a package using the same pnpm implementation as dsh plugin.
+ * @param spec One package spec, including local paths relative to the invocation directory.
+ * @param options Whether to activate the installed bundle; defaults to true.
+ * @returns Package-manager diagnostics and observed activation outcome.
+ */
+@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>
+
+/** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
+ * @param name Installed dependency name.
+ * @returns Removal diagnostics and the remaining profile state.
+ */
+@Remote removeBundle(name: string): Promise<ChangeResult>
+```
+
+Source: [`packages/boot/plugin-manager/src/index.ts`](../../packages/boot/plugin-manager/src/index.ts)
+
+<a id="ctxprofilecontext--profilecontext"></a>
+
+### `ctx.profileContext` — `ProfileContext`
+
+Current profile facts; scheduling and mutation belong to their callers.
+
+Source: [`packages/boot/app-boot/src/profile-context.ts`](../../packages/boot/app-boot/src/profile-context.ts)
+
+<a id="hmr-events"></a>
+
+### `hmr/*` events
+
+<a id="hmrchange--emit"></a>
+
+#### `hmr/change` — emit
+
+A watched file has no module or configuration handler.
+
+```ts cordis-catalog
+/** A watched file has no module or configuration handler.
+ * @mode emit
+ * @param url Canonical file URL.
+ */
+'hmr/change'(url: string): void
+```
+
+Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+
+<a id="hmrreload--emit"></a>
+
+#### `hmr/reload` — emit
+
+Module replacements have finished loading.
+
+```ts cordis-catalog
+/** Module replacements have finished loading.
+ * @mode emit
+ * @param reloads Replaced plugins and their module locations.
+ */
+'hmr/reload'(reloads: Map<Plugin, Reload>): void
+```
+
+Source: [`packages/boot/hmr/src/index.ts`](../../packages/boot/hmr/src/index.ts)
+<!-- END GENERATED cordis-surface -->

+ 2 - 2
docs/subsystems/client-modules.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
-client-modules.md: 74aa2b4983f3c8c7fc6b3ba9b03646ac8141f3f1
-client-modules.zh.md: 259717a44a3275ef5b94df698fc01b5de7431fd0
+client-modules.md: aff74a1da1ce524689d1a746a7267eb1544e4fef
+client-modules.zh.md: 8100708edd88d35f123d658de42525f04a90ebd6

+ 1 - 1
docs/subsystems/client-modules.md

@@ -100,7 +100,7 @@ interface ClientArtifactBaseline {
 
 `ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes the bundle bytes, and only a real revision change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
 
-In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts revision changes to the browser half over SSE. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Production graphs omit the HMR row entirely; the module host itself never watches files.
+[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 1 - 1
docs/subsystems/client-modules.zh.md

@@ -100,7 +100,7 @@ interface ClientArtifactBaseline {
 
 `ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它重新哈希 bundle 字节,只有 revision 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
-开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 revision 变化广播给浏览器半。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
+随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 

+ 2 - 2
docs/tool-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/tool-catalog.md
-tool-catalog.md: 8718a9336269277b2eedad15a330e813c41fc9c5
-tool-catalog.zh.md: 07070a9f7af1bb95560b791b9b61e823afc5b739
+tool-catalog.md: 999ca77d47c12d9b1d5cc749fc854069e597f767
+tool-catalog.zh.md: 32db6d25e5377b882de62c034343c1feb66abd16

+ 50 - 0
docs/tool-catalog.md

@@ -15,6 +15,7 @@ This table connects model-visible tool names to the plugin package and service s
 
 | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
 | --- | --- | --- | --- | --- | --- |
+| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager` | `tool/call`, `tool/result`, `user/message` | - | - |
 | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
@@ -44,6 +45,55 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
 
+<a id="deepseek-aidsh-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-plugin-manager`
+
+### `plugin_manager`
+
+List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately; startup profiles require restart.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "action": {
+      "type": "string",
+      "description": "Management operation.",
+      "enum": [
+        "list_plugins",
+        "list_bundles",
+        "set_plugin",
+        "set_bundle",
+        "install_bundle",
+        "remove_bundle"
+      ]
+    },
+    "target": {
+      "type": "string",
+      "description": "Plugin entry id, bundle package name, or installation spec, according to action."
+    },
+    "enabled": {
+      "type": "boolean",
+      "description": "Required for set operations; defaults to true for installation."
+    },
+    "offset": {
+      "type": "number",
+      "description": "Zero-based list offset; defaults to 0."
+    },
+    "limit": {
+      "type": "number",
+      "description": "List page size, from 1 to 100; defaults to 25."
+    }
+  },
+  "required": [
+    "action"
+  ]
+}
+```
+
+Source: [`packages/boot/plugin-manager/src/tools.ts`](../packages/boot/plugin-manager/src/tools.ts)
+
 <a id="deepseek-aidsh-mcp-resources"></a>
 
 ## `@deepseek-ai/dsh-mcp-resources`

+ 50 - 0
docs/tool-catalog.zh.md

@@ -19,6 +19,7 @@
 
 | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 |
 | --- | --- | --- | --- | --- | --- |
+| `@deepseek-ai/dsh-plugin-manager` | `plugin_manager` | `ctx.tools`, `ctx.pluginManager` | `tool/call`, `tool/result`, `user/message` | - | - |
 | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 |
@@ -48,6 +49,55 @@
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 |
 
+<a id="deepseek-aidsh-plugin-manager"></a>
+
+## `@deepseek-ai/dsh-plugin-manager`
+
+### `plugin_manager`
+
+列出当前 profile 的插件或组合包、启用或禁用它们、安装组合包或删除已安装的组合包。改动影响该 profile 中的每个会话。先查询列表以获取准确标识。包安装可能执行获准的构建脚本。live profile 立即应用配置变化;startup profile 需要重启。
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "action": {
+      "type": "string",
+      "description": "Management operation.",
+      "enum": [
+        "list_plugins",
+        "list_bundles",
+        "set_plugin",
+        "set_bundle",
+        "install_bundle",
+        "remove_bundle"
+      ]
+    },
+    "target": {
+      "type": "string",
+      "description": "Plugin entry id, bundle package name, or installation spec, according to action."
+    },
+    "enabled": {
+      "type": "boolean",
+      "description": "Required for set operations; defaults to true for installation."
+    },
+    "offset": {
+      "type": "number",
+      "description": "Zero-based list offset; defaults to 0."
+    },
+    "limit": {
+      "type": "number",
+      "description": "List page size, from 1 to 100; defaults to 25."
+    }
+  },
+  "required": [
+    "action"
+  ]
+}
+```
+
+来源: [`packages/boot/plugin-manager/src/tools.ts`](../packages/boot/plugin-manager/src/tools.ts)
+
 <a id="deepseek-aidsh-mcp-resources"></a>
 
 ## `@deepseek-ai/dsh-mcp-resources`

+ 2 - 2
docs/user/develop/framework/index.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/user/develop/framework/index.md
-index.md: 8cc673148d7fec4f7d9b994907e17293bc3a6a97
-index.zh.md: 8f55bd1cdd472b2f7b90e9280c58d15533ffce57
+index.md: e0e9ef44c44c502f86cf1f682560bf72c4c71b13
+index.zh.md: 835fc0beeba5c1386347ec839e27474436ee54ca

+ 1 - 1
docs/user/develop/framework/index.md

@@ -98,7 +98,7 @@ await fiber.dispose()
 
 ## Hot replacement (HMR)
 
-With `@deepseek-ai/cordis-plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers:
+With `@deepseek-ai/dsh-hmr` loaded from `cordis.yml`, editing a plugin source file triggers:
 
 1. Unload the old plugin and clean up its registrations.
 2. Load the new code.

+ 1 - 1
docs/user/develop/framework/index.zh.md

@@ -98,7 +98,7 @@ await fiber.dispose()
 
 ## HMR(热模块替换)
 
-通过 `cordis.yml` 加载 `@deepseek-ai/cordis-plugin-hmr` 后,修改插件源文件会触发:
+通过 `cordis.yml` 加载 `@deepseek-ai/dsh-hmr` 后,修改插件源文件会触发:
 
 1. 卸载旧插件(清理所有注册)
 2. 重新加载新代码

+ 1 - 1
package.json

@@ -58,7 +58,7 @@
     "test:bench:built": "vitest run --config vitest.bench.config.ts",
     "test:expected": "vitest run --config vitest.expected.config.ts",
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
-    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs",
+    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs .github/review-ownership/blame-ownership.test.mjs",
     "test:issue-management": "node .github/issue-management/policy.test.mjs",
     "test:snapshot": "vitest run --config vitest.snapshot.config.ts",
     "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",

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

@@ -87,6 +87,7 @@
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "zod": "^4.4.3",
     "@deepseek-ai/dsh-command-feedback": "workspace:^",
-    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^"
+    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
+    "@deepseek-ai/dsh-plugin-manager": "workspace:^"
   }
 }

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

@@ -7,6 +7,7 @@ import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/r
 import goalsRemote from '@deepseek-ai/dsh-goal/remote'
 import llmRemote from '@deepseek-ai/dsh-llm/remote'
 import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
+import pluginManagerRemote from '@deepseek-ai/dsh-plugin-manager/remote'
 import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
 import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
 import permissionPresetsRemote from '@deepseek-ai/dsh-permission-presets/remote'
@@ -21,6 +22,8 @@ import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 
 export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+export type { PluginInfo, BundleInfo, ChangeResult, PluginEntryId } from '@deepseek-ai/dsh-plugin-manager/types'
+export type {} from '@deepseek-ai/dsh-plugin-manager/remote'
 export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
 export type {} from '@deepseek-ai/dsh-agent-presets/remote'
 export type {} from '@deepseek-ai/dsh-commands/remote'
@@ -158,7 +161,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
   try {
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
-      pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
+      pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
       permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))

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

@@ -88,6 +88,9 @@
     },
     {
       "path": "../terminal-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../boot/plugin-manager"
     }
   ]
 }

+ 2 - 2
packages/api/session-controller/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/api/session-controller/README.md
-README.md: 9b2cdeed600cd597ecfab076fe98a3a334533ba8
-README.zh.md: 04ed7808dc7ea581b489921f88349479d46d8037
+README.md: 1e8089b19c2dcba4691345b44498a37b33670b64
+README.zh.md: ce406f16edea8b47bd584adfa68efea7987510c4

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
packages/api/session-controller/README.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
packages/api/session-controller/README.zh.md


+ 3 - 2
packages/api/session-controller/src/client/sessions/manager.ts

@@ -934,14 +934,15 @@ export class SessionManager {
       this.entryCache.set(entry.sessionId, entry)
       return entry
     })
+    const itemIds = new Set(items.map(entry => entry.sessionId))
     for (const id of this.entryCache.keys()) {
-      if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id)
+      if (!itemIds.has(id)) this.entryCache.delete(id)
     }
     const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
     if (!sameOrder) this.itemsCache = items
     const selected = this.selected
     const current = selected !== undefined
-      && (items.some(item => item.sessionId === selected) || this.addresses.has(selected))
+      && (itemIds.has(selected) || this.addresses.has(selected))
       ? selected
       : undefined
     return {

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