Ver código fonte

Merge remote-tracking branch 'origin/master' into feat/plugin-mgmt-4-web

Master now carries the final #4182 plugin manager and the live client
module graph of #4189, so the web management layer is re-ported onto
them:

- The manager keeps master's `ManagementError` codes, `ChangeResult`
  stages, `configure()` under `hmr.runExclusive`, `reload()` warnings
  and `writePluginEnabled` name matching, and adds back `inspect`,
  request-id installs with `plugin-manager/install-log` and
  `plugin-manager/install-state`, `cancelInstall`, `listBundles` titles,
  rows and overrides, `packageResult.kind`, `pnpmCommand` and
  `inspectTimeoutMs`, and `plugin-manager/changed` after each change and
  each `profile/reconciled`. A failed or cancelled installation restores
  the snapshotted manifest and lockfile instead of master's one-shot
  `pnpm remove` cleanup, so `cleanup` and `remainingDependencies` leave
  the result.
- App boot keeps master's reconciliation diagnostics and emits
  `profile/reconciled`; `watchUserPatches` goes with the launcher's
  watcher now that HMR owns profile reloads.
- The settings Plugin list tab stays read-only with #4189's page-local
  sync failures and retry; master's bundle form and row checkboxes are
  dropped again. The sidebar page words the Host's codes through its
  dictionary, and a client bundle change follows the live module graph
  without a page reload.
- The web e2e scaffold no longer chooses a reload policy: the base
  bundle's `hmr` row turns on with the profile context and application
  readiness, and `profile.hmr: false` disables it through an overlay for
  the startup-only scenario.
Yichen Jiang 3 semanas atrás
pai
commit
dc9256536b
100 arquivos alterados com 1928 adições e 198 exclusões
  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. 2 2
      .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.i18n.yaml
  5. 3 3
      .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md
  6. 3 3
      .agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.i18n.yaml
  11. 6 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
  12. 6 2
      .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.i18n.yaml
  14. 7 14
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md
  15. 24 32
      .agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md
  16. 2 2
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml
  17. 1 1
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
  18. 1 1
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md
  19. 2 2
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml
  20. 17 15
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
  21. 17 15
      .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
  22. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  23. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  24. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md
  25. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml
  26. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  27. 3 3
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  28. 2 2
      .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml
  29. 5 4
      .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
  30. 5 4
      .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md
  31. 6 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  32. 45 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  33. 45 0
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  34. 6 0
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.i18n.yaml
  35. 31 0
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md
  36. 31 0
      .agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md
  37. 6 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml
  38. 27 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
  39. 27 0
      .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md
  40. 6 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.i18n.yaml
  41. 29 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.md
  42. 29 0
      .agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.zh.md
  43. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  44. 8 4
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  45. 7 3
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  46. 6 0
      .agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.i18n.yaml
  47. 35 0
      .agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.md
  48. 35 0
      .agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md
  49. 6 0
      .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml
  50. 33 0
      .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md
  51. 33 0
      .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md
  52. 6 0
      .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.i18n.yaml
  53. 21 0
      .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.md
  54. 21 0
      .agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.zh.md
  55. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.i18n.yaml
  56. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.md
  57. 25 0
      .agents/notes/implemented/bug-fix/2026-09-15-linear-session-list-cache.zh.md
  58. 6 0
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml
  59. 41 0
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
  60. 41 0
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md
  61. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  62. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  63. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  64. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  65. 6 4
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  66. 6 4
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  67. 6 0
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml
  68. 49 0
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
  69. 49 0
      .agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md
  70. 6 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
  71. 31 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
  72. 31 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
  73. 6 0
      .agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.i18n.yaml
  74. 56 0
      .agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.md
  75. 56 0
      .agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.zh.md
  76. 2 2
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml
  77. 2 0
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md
  78. 2 0
      .agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md
  79. 6 0
      .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml
  80. 35 0
      .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md
  81. 35 0
      .agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md
  82. 1 0
      .github/review-ownership/.gitignore
  83. 9 5
      .github/review-ownership/README.md
  84. 86 0
      .github/review-ownership/blame-ownership.mjs
  85. 52 0
      .github/review-ownership/blame-ownership.test.mjs
  86. 135 0
      .github/review-ownership/blame-production.py
  87. 41 10
      .github/review-ownership/check-approval.mjs
  88. 132 2
      .github/review-ownership/check-approval.test.mjs
  89. 1 0
      .github/review-ownership/requirements.txt
  90. 221 0
      .github/review-ownership/test_blame_production.py
  91. 11 1
      .github/workflows/ci.yml
  92. 22 2
      .github/workflows/weighted-approval.yml
  93. 2 0
      .gitignore
  94. 4 2
      AGENTS.md
  95. 1 0
      THIRD_PARTY_NOTICES.md
  96. 2 2
      apps/cli/README.i18n.yaml
  97. 3 1
      apps/cli/README.md
  98. 3 1
      apps/cli/README.zh.md
  99. 12 3
      apps/cli/package.json
  100. 2 2
      apps/cli/reference/README.i18n.yaml

+ 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

Diferenças do arquivo suprimidas por serem muito extensas
+ 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 半。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-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-27-compiler-independent-typert-model.md
-2026-07-27-compiler-independent-typert-model.md: c9e7ec0e471c9deabcffda69346077a2a10ac42a
-2026-07-27-compiler-independent-typert-model.zh.md: 9bd419f39c9d8a490dfb0baf12a9852fdbb817a8
+2026-07-27-compiler-independent-typert-model.md: 5083c55176b2f4d342d14d0c1cf2a7f0c2c14b3c
+2026-07-27-compiler-independent-typert-model.zh.md: 70d6954fae0fcb48e76137231168d5f2417668bf

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.md

@@ -20,7 +20,7 @@ Each face independently owns a PackageModel and TypeGraph. Direct project refere
 
 PackageModel recognizes Cordis services, events, `@typert object` reference objects, and `@typert schema` data roots. Services and objects expose only public instance members, excluding constructors and static, private, and protected members; inheritance edges remain in TypeGraph instead of being copied into flattened members. When a public property, parameter, or return type lacks an annotation, `check` mode reports an error, while `write` mode writes the checker-inferred result, rebuilds the project, and analyzes it again in strict mode.
 
-[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and an optional Zod schema, and Cordis effect disposal revokes it. The registry neither analyzes TypeScript nor merges the two faces. JSON Schema is an on-demand projection of registered Zod schemas.
+[`dsh-typert-registry`](../../../../packages/typert/registry/README.md) provides `ctx.typert` and handles runtime registration only: one contribution atomically carries package-face reflection and optional Zod schema factories, and Cordis effect disposal revokes it. The registry validates factories without invoking them, then materializes and caches each schema on its first `get()`, `resolve()`, `list()`, or JSON Schema projection. It neither analyzes TypeScript nor merges the two faces.
 
 Package artifact publication remains explicit opt-in through package exports. When invoked, `WorkspaceTypertGenerator` validates that each requested host face exposes the user-facing subpath `package/typert` from the root artifact `package/lib/typert.host.{js,d.ts}`, or that each requested client face exposes `package/client/typert` from `package/lib/typert.client.{js,d.ts}`; it never edits those exports. The later [Typert Remote design](2026-08-02-typert-remote-method-calls.md) adds a whole-workspace Host contract pass to root build, typecheck, lint, and documentation typecheck. For opted-in Host packages, that pass emits both local reflection and strict Host-for-Client `/remote` contracts before consumers resolve them. Generated local declarations keep `TYPERT` typed as `unknown`, so business packages do not depend on the registry.
 
@@ -34,7 +34,7 @@ For every property in `SyntaxZoo`, the TypeScript printer normalizes the source
 
 Boundary cases pin explicit package imports within and across faces, cross-face named re-exports, exact export aliases, qualified `import()` links, and the External classification of global `@types` declarations; they reject TypeScript diagnostics originating in package-owned files, relative-path boundary crossings, references outside `package.json#exports`, and cross-face namespace re-exports without a model target. Interface declaration merging explicitly preserves every authored part; other merges that cannot be represented losslessly fail.
 
-For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute the JavaScript, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts.
+For each supported node kind and literal category, Zod emitter tests run both successful and failing parses; for each unsupported kind, they assert an explicit `TypertEmitError`. Emitter fixtures snapshot generated Zod JavaScript and `.d.ts` text, execute each schema factory, and typecheck the declarations. `dsh-typert-registry` tests pin atomic registration, first-use materialization, successful-result caching, retry after factory failure, queries, JSON Schema, and effect disposal; `dsh-typert-loader` tests also prove delayed mounting, unloading, and disposal while a dynamic import remains pending. A real `dsh-tools` vertical slice generates a contribution from the model, loads it through the runtime registry, and compares its service, event, and related-type records with the committed static `SERVICE_API`, `EVENT_API`, and `TYPE_API`. A full-workspace projector test regenerates the two Cordis catalog documents and the `tool-cordis` API catalog and requires all three texts to be byte-for-byte identical to the committed artifacts.
 
 ## Alternatives considered
 
@@ -50,4 +50,4 @@ For each supported node kind and literal category, Zod emitter tests run both su
 
 New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
 
-Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after importing them directly.
+Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them.

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-27-compiler-independent-typert-model.zh.md

@@ -20,7 +20,7 @@ TypeGraph 保存开发者写下的计算前类型结构,包括泛型参数与
 
 PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@typert schema` 数据根。service 与 object 只暴露 public instance member,排除 constructor、static、private 和 protected;继承边保留在 TypeGraph 中,不复制为扁平成员。缺少 public property、parameter 或 return 类型标注时,`check` 模式报错,`write` 模式写入 checker 推断结果后重建 project 并再次以严格模式分析。
 
-[`dsh-typert-registry`](../../../../packages/typert/registry/README.zh.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema,并随 Cordis effect 撤销。注册表不分析 TypeScript,也不合并两个 face。JSON Schema 是对已注册 Zod schema 的按需投影。
+[`dsh-typert-registry`](../../../../packages/typert/registry/README.zh.md) 提供 `ctx.typert`,且只负责运行时注册:一个 contribution 原子携带 package-face reflection 与可选 Zod schema factory,并随 Cordis effect 撤销。注册表校验 factory 时不会调用它;首次 `get()`、`resolve()`、`list()` 或 JSON Schema 投影才会物化并缓存各 schema。注册表不分析 TypeScript,也不合并两个 face。
 
 包产物发布仍通过 package exports 采用显式 opt-in。`WorkspaceTypertGenerator` 仅在被调用时校验所请求 face 的根目录产物协议:host face 必须通过面向用户的 subpath `package/typert` 暴露 `package/lib/typert.host.{js,d.ts}`,client face 必须通过 `package/client/typert` 暴露 `package/lib/typert.client.{js,d.ts}`;它不会修改这些 exports。后续的 [Typert Remote 设计](2026-08-02-typert-remote-method-calls.zh.md) 为根目录 build、typecheck、lint 与文档类型检查增加了全仓 Host 约定 pass。对于已 opt-in 的 Host 包,该 pass 会在消费方解析两者之前生成本地反射产物与严格的 Host-for-Client `/remote` 约定。生成的本地声明将 `TYPERT` 类型保持为 `unknown`,因此业务包不依赖注册表。
 
@@ -34,7 +34,7 @@ PackageModel 识别 Cordis service、event、`@typert object` 引用对象和 `@
 
 边界用例固定同 face 与跨 face 的显式包导入、跨 face 命名 re-export、精确 export alias、qualified `import()` link 和全局 `@types` External 归属,并拒绝 package 自有 TypeScript 诊断、相对路径越界、`package.json#exports` 之外的引用,以及尚无模型 target 的跨 face namespace re-export。interface declaration merging 显式保留每个 authored part,无法无损表示的其他 merge 失败。
 
-Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行 JavaScript,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。
+Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 parse,对不支持的节点逐类断言明确的 `TypertEmitError`。Emitter fixture 对生成的 Zod JavaScript 与 `.d.ts` 文本做快照,执行每个 schema factory,并对声明做类型检查。`dsh-typert-registry` 测试固定原子注册、首次使用物化、成功结果缓存、factory 失败后重试、查询、JSON Schema 和 effect 撤销,`dsh-typert-loader` 测试还证明延迟挂载、卸载及未完成 dynamic import 的释放行为。真实 `dsh-tools` 纵切从模型生成 contribution,经运行时注册表加载后,将其服务、事件与关联类型记录同已提交的静态 `SERVICE_API`、`EVENT_API` 和 `TYPE_API` 对照。全仓 projector 测试重新生成两份 Cordis catalog 文档与 `tool-cordis` API catalog,并要求三份文本同已提交产物逐字节一致。
 
 ## Alternatives considered
 
@@ -50,4 +50,4 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars
 
 新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
 
-包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方直接导入后仍持有的 Zod 实例,卸载流程无法控制。
+包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.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-08-02-typert-remote-method-calls.md
-2026-08-02-typert-remote-method-calls.md: 73ab996d408c71ab70d25058677d0d02efe05804
-2026-08-02-typert-remote-method-calls.zh.md: 06b3f9ad454ca905d33e8d08dde51e6c4e99427e
+2026-08-02-typert-remote-method-calls.md: b6551e1c7f8c94fb02a788aa62cb4acef1addffe
+2026-08-02-typert-remote-method-calls.zh.md: 058ec47e6749ee7576fd84fdcacfda350eec3fb8

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md

@@ -147,7 +147,7 @@ The strict generator writes `scope` only when a direct method has exactly one lo
 
 Parameter order comes from the method signature. HTTP fields come from parameter names or lookup declarations. A cancellation descriptor reserves only the final `signal` position and keeps it outside named `args`; Connection or a direct Gateway caller supplies the actual signal. The Gateway does not infer optional fields, Context types, lookup types, or missing arguments from request contents, and it does not synthesize business defaults.
 
-A LIB codec contains a Zod schema and a canonical `typeSymbol` consisting of "package + public subpath + export name." An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same Typert model and symbol keys.
+A LIB codec contains a success-cached Zod schema factory and a canonical `typeSymbol` consisting of "package + public subpath + export name." Host and Client gateways invoke the factory only when that boundary first encodes or decodes a value. An SRC codec is marked only as `src-json`. When the Host and consumer run in different JavaScript realms, each holds its own Zod instances, but both sets are generated from the same Typert model and symbol keys.
 
 Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Host uses its descriptor to decode and invoke the method, while the Client uses its corresponding descriptor to encode arguments and validate the result.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md

@@ -147,7 +147,7 @@ InvocationDescriptor {
 
 参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。
 
-LIB codec 带有 Zod schema 和「package + 公共 subpath + export name」的规范 `typeSymbol`;SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 Typert 模型和 symbol key 生成。
+LIB codec 带有只缓存成功结果的 Zod schema factory 和「package + 公共 subpath + export name」的规范 `typeSymbol`;Host 与 Client gateway 只在该边界首次编码或解码值时调用 factory。SRC codec 只标记 `src-json`。Host 和消费端运行在不同 JavaScript realm 时会各自持有 Zod 实例,但这些实例由同一 Typert 模型和 symbol key 生成。
 
 descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.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-08-15-client-shells-and-dynamic-packages.md
-2026-08-15-client-shells-and-dynamic-packages.md: 89f89784512b86b70ee1b9460850e6f2f951a082
-2026-08-15-client-shells-and-dynamic-packages.zh.md: 5512d9262e979a94a65c25a6c1271b030e23ec70
+2026-08-15-client-shells-and-dynamic-packages.md: ad2bb26b84253e859e567775eec3b06d73b0003b
+2026-08-15-client-shells-and-dynamic-packages.zh.md: 70563962b3c613caa611c61a0c690d2e0e777def

+ 6 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md

@@ -46,13 +46,17 @@ There is no general `dsh.client.provide` alias mechanism. Dynamic rows and stati
 The modules Node half injects the startup protocol into the served HTML in this order:
 
 1. Install `window.__ModuleLoader__` in queue mode with `pendingQueue`, `load()`, and `create()`.
-2. Start preloading every content-addressed application combo URL containing the rows other than modules.
+2. Start preloading every revisioned application combo URL containing the rows other than modules.
 3. Execute every blocking bootstrap combo URL; these currently contain the ordinary modules factory registration.
 4. Assign `window.__DSH_BOOT__`, including all scheduling descriptors and every row's one-resource HMR combo URL.
 5. Execute the Vite main module.
 
 The bootstrap combo currently registers only the modules factory. The startup kernel passes the raw graph and shell seeds to `__ModuleLoader__.create()`. The facade removes the modules registration, materializes it with a `require` function that rejects every external, and invokes its `createClientModuleSystem` export. The modules bundle parses the graph, constructs and returns `ClientModuleSystem`, caches its own exports as the modules row, and switches the same facade to live mode. The kernel installs that instance as its Loader's `internal`, and the modules plugin reads it there when it provides `ctx.modules`. The modules client face consequently has a zero-external bootstrap requirement and no module-global system identity.
 
+The Host publishes graph and combo descriptors without concatenating response bodies. Each script URL shares one lazy Promise that concatenates its captured bundle bytes on first `GET` and appends the corresponding map URL; each map URL has a separate lazy Promise that reads and composes source maps only on its first `GET`. `HEAD` requests trigger neither body. The Web URL remains gated by Loader settlement and the required-entry audit, but that readiness point does not materialize combo bodies; an index request reads the current graph.
+
+The theme Host contribution is prepended to index collection. CSS in the head selects the initial document canvas palette, using `prefers-color-scheme` directly for the `system` preference; a body script applies the existing palette attribute and font-size variable before the loading page and application module.
+
 After the `immediately` tier has registered its factories, the kernel creates all Loader entries, awaits Cordis quiescence, and requires every fiber to be ACTIVE. It then calls `ctx.uiRenderer.mount(container)`. The dynamic `ui-renderer` package owns React, slot rendering, hydration of the existing boot DOM, and the React root lifecycle; the startup kernel and failure page remain React-free.
 
 ### Dependency declarations
@@ -79,7 +83,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
 
 Bundle contents stay stable when an internal DSH relationship is development-only, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance. The shipped profile owns the complete Client package roster, so individual Client packages do not ask npm to solve the same graph again through peer placement.
 
-The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
+The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. Deferring response bodies moves concatenation to first access, while separately deferring maps keeps debugger-only work off script delivery. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
 
 The shell consumes built `lib/` products, so source and browser artifacts can drift until the relevant build or watcher runs. Typechecking source alone does not prove the served application uses the same code.
 

+ 6 - 2
.agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.zh.md

@@ -46,13 +46,17 @@ Client npm 依赖区段描述安装和开发关系,但不能可靠描述 bundl
 Modules Node 半按以下顺序向实际返回的 HTML 注入启动协议:
 
 1. 以 queue 模式安装 `window.__ModuleLoader__`,包含 `pendingQueue`、`load()` 与 `create()`。
-2. 开始预加载所有按内容寻址的 application combo URL,其中包含 modules 之外的 row。
+2. 开始预加载所有带 revision 的 application combo URL,其中包含 modules 之外的 row。
 3. 执行所有阻塞式 bootstrap combo URL;当前其中包含普通的 modules factory registration。
 4. 赋值 `window.__DSH_BOOT__`,其中包含全部调度描述及每个 row 的单资源 HMR combo URL。
 5. 执行 Vite 主模块。
 
 Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外壳 seed 传给 `__ModuleLoader__.create()`。Facade 移除 modules registration,用拒绝全部 external 的 `require` 函数将其物化,再调用其 `createClientModuleSystem` 导出。Modules bundle 解析图、构造并返回 `ClientModuleSystem`、把自身 exports 缓存为 modules row,并把同一 facade 切换到 live 模式。内核把该实例装成自身 Loader 的 `internal`,modules 插件从这里读取并提供 `ctx.modules`。因此 modules client face 保持零 external 的自举要求,也没有模块级系统身份。
 
+Host 发布 graph 与 combo descriptor 时不会拼接响应 body。每个脚本 URL 共用一个惰性 Promise,在首次 `GET` 时拼接捕获的 bundle 字节并追加对应的 map URL;每个 map URL 使用另一个惰性 Promise,只在首次 `GET` 时读取并组合 source map。`HEAD` 不触发任一 body。Web URL 仍由 Loader 结算和 required-entry audit 控制,但这个就绪点不会物化 combo body;index 请求读取当时的最新 graph。
+
+Theme 的 Host 贡献会前置到 index 收集顺序。head 中的 CSS 选择初始文档画布调色板,`system` 偏好直接使用 `prefers-color-scheme`;body 脚本在加载页面和应用模块之前应用既有的调色板属性与字号变量。
+
 `immediately` 层级完成 factory 注册后,内核创建全部 Loader entry,等待 Cordis 静止,并要求每个 fiber 都进入 ACTIVE。随后调用 `ctx.uiRenderer.mount(container)`。动态 `ui-renderer` 包拥有 React、slot 渲染、已有启动 DOM 的 hydrate 和 React root 生命周期;启动内核与失败页保持 React-free。
 
 ### 依赖声明
@@ -79,7 +83,7 @@ Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外
 
 内部 DSH 关系仅放在开发区段时,bundle 内容仍保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。发布 profile 拥有完整 Client 包名册,因此各 Client 包不再要求 npm 通过 peer placement 重复求解同一张图。
 
-启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
+启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。响应 body 的延迟生成会把拼接移到首次访问,而 map 的独立延迟生成会让仅供调试器使用的工作不进入脚本交付路径。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
 
 外壳消费已构建 `lib/` 产品,因此在相关 build 或 watcher 运行前,源码与浏览器产物可能漂移。仅源码 typecheck 通过不能证明实际服务的应用使用同一份代码。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.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-08-25-electron-desktop-packaging-and-updates.md
-2026-08-25-electron-desktop-packaging-and-updates.md: 4a4dfe8910905b3c35fbdfdcaedd34a556b532f3
-2026-08-25-electron-desktop-packaging-and-updates.zh.md: 69877127578ae4d61643653403b736dbb5e7b1e6
+2026-08-25-electron-desktop-packaging-and-updates.md: c532db9c3078a8c0cc67935f88457bf06b850141
+2026-08-25-electron-desktop-packaging-and-updates.zh.md: fad71d59ea720542bfdcd15a010ece60b295f2d8

Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 14
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md


+ 24 - 32
.agents/notes/implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md

@@ -6,6 +6,8 @@ Status: implemented
 
 profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
+[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
+
 ## 问题
 
 DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用无需系统 Node.js 或 pnpm 即可工作,通过应用内置 pnpm 安装 dsh 与桌面插件,并通过一个面向用户的流程更新完整桌面发布。
@@ -16,7 +18,7 @@ DeepSeek Harness 需要一个复用 Web UI 的 Electron 桌面应用。该应用
 
 ## 决策
 
-交付一个小型 Electron 壳,其中内置上游 Node.js 可执行文件和固定版本的 pnpm。Electron 把私有 Desktop Host 包作为隔离子进程启动;该包组合已安装的 dsh 后端与匹配的客户端图。Fetch 元数据及有界的原始请求与响应分块通过两条带版本的分帧字节管道传递,Node IPC 只承载就绪、致命失败和关闭,Electron 通过 `dsh-app://` 提供经过验证的资源;它不会打开监听端口。每个帧都包含固定标记、类型、单调 stream id、负载长度和经过验证的负载。串行 writer 遵守 pipe drain,请求或响应 stream 施加背压时 reader 会全局暂停,取消会关闭匹配的 stream,已退役 stream 的迟到响应帧保持无效。Connection 插件无需 `webServer` 即可提供与载体无关的 RPC 与 Fetch 注册表,Client Modules 则向 shell-owned carrier 提供与广告内容完全一致的组合 bundle 响应;Web 组合为两者挂载可选 HTTP route。渲染进程保留相同的 Fetch、RPC 与 Remote-stream 格式,子进程载体则避免 Base64 膨胀,也不依赖 Electron 与内置上游 Node.js 之间的 V8 序列化兼容性。发送 shutdown 后,Electron 会关闭自己持有的请求管道写端,以便在等待子进程退出前释放 Windows 上仍在进行的管道读取。该设计沿用 [GUI 分层与 RPC 协议 Agent Note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中的 Electron 预留。
+交付小型 Electron 壳和固定版本 pnpm;[运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)持有可执行文件选择。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责 Host 启动与传输:私有 Host 运行共享 Web profile runner,Electron 加载其认证 HTTP URL,子进程 IPC 承载生命周期消息。
 
 Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源存储、外部插件依赖、共享包链接和 profile 协调。私有 Desktop Host 保持独立于公共 CLI 包,且不会发布到 npm。
 
@@ -28,8 +30,8 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 | Owner | 职责 |
 |---|---|
-| Electron 壳 | 窗口与子进程生命周期、分帧字节管道、生命周期 IPC、自定义协议、保留 desktop profile、插件 GUI、更新协调 |
-| 内置 Node.js 与 pnpm | 执行 dsh 并安装桌面项目的精确依赖,不读取用户 `PATH` 或 pnpm 状态 |
+| Electron 壳 | 窗口与子进程生命周期、本地壳页面、保留 desktop profile、插件 GUI、更新协调 |
+| Electron RunAsNode 与 pnpm | 执行 dsh 并安装桌面项目依赖,使用 pnpm 的正常配置 |
 | Desktop profile | 由内置运行时决策定义的外部插件依赖、已启用 bundle 顺序和共享链接 |
 | 私有 Desktop Host 包 | 与 dsh 一起安装、但不进入公共 CLI 包或 npm 发布的 Electron 专用子进程入口与组合 overlay |
 | 已安装 dsh 包 | 后端、匹配的 Web UI、启动 manifest、客户端包和产品行为 |
@@ -42,26 +44,18 @@ Electron 拥有 `.dsh/profiles/desktop` 保留 profile。[内置运行时决策]
 
 ```text
 ~/.dsh/
-  desktop/
-    pnpm/
-      store/
-      cache/
-      state/
-      config/
   profiles/
     desktop/
       package.json
       pnpm-lock.yaml
       lock
-      desktop-packages-pending
       pnpm-workspace.yaml
-      desktop-runtime-state.json
       node_modules/
   sessions/
   storages/
 ```
 
-`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。插件包内容使用 `.dsh/desktop/pnpm/store`。
+`.dsh/profiles/desktop` 是唯一活动桌面 profile。其可执行包归属及允许解析的目录遵循[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)。pnpm 根据正常配置选择 store 和缓存。
 
 ## 安装与解析
 
@@ -81,42 +75,40 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
 
 ## 安全与发布策略
 
-核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装接受桌面策略允许的 registry 包规格,不接受原始 pnpm 命令。激活前要求精确版本、锁文件完整性、经过审查的 `allowBuilds` 集合和仅限用户访问的目录权限。
+核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
 
-Electron 发布产物必须签名;macOS 产物必须公证。发布自动化必须通过明确的环境变量提供应用 ID、macOS Developer ID 限定名、预期 Team ID 与一套完整的 notarytool 凭据。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Electron 发布产物必须签名;macOS 产物必须公证。打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 拥有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 [固定版本的 osx-sign 补丁](../../../../patches/@electron__osx-sign@1.3.3.patch)在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
 
-Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。自定义协议提供已安装的前端分发目录和活跃模块图点名的客户端文件,并拒绝路径穿越或访问这些根目录之外的内容。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
+Windows 发布打包通过 `/f` 向已配置且与 SafeNet 兼容的 SignTool 提供 `DSH_DESKTOP_WINDOWS_CER_FILE` 指定的公开 EV 叶证书,并通过必需的 `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` 标识匹配的私钥。证书文件保留在源码仓库之外,私钥仍留在 USB Token 上。electron-builder hook 把每个产物交给采用 CRLF 的 `windows-sign.cmd`;该 CMD 只调用一次 SignTool,并指定 SafeNet `/kc "[{{PIN}}]=容器"` 值与 CSP、SHA-256 文件摘要和 DigiCert SHA-256 RFC 3161 时间戳。hook 不会改用其他 SignTool,也不会重试失败的请求。打包编排不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程,只会把证书路径、SignTool 路径、密钥容器和 PIN 传入 electron-builder。签名器在已清理的 CMD 环境中只提供经过校验的签名字段;CMD 会禁用延迟展开,在 SignTool 启动前清除这些字段,并仅在 SignTool 必需的命令行中保留 PIN。所有对外诊断都会替换 PIN,而且只能允许专用构建账号和管理员检查该 runner。签名器会在企业 Code Integrity 检查 electron-builder 的临时 NSIS bootstrap 前先为该可执行文件签名;对于生成的可执行文件,只有证书表条目指向文件末尾之外时,才会在最终签名前清除该条目。SignTool、证书、容器、PIN、Token 或签名不可用时,打包会在产生未签名产物前失败。共享 Web server 负责前端与客户端模块响应。插件安装器 API 只对 Electron 持有的管理 GUI 可用,不存在于浏览器应用或后端 RPC 中。
 
 Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-Zip 24.09 编码器会为 ARM64 PE 文件自动选择 ARM64 过滤器,但 `nsis-resources-3.4.1` 中的 NSIS 解码器会在解压时遗漏这些条目。使用实际 NSIS 插件的原生解压验证表明,自动过滤会丢失两个 `node-pty` ARM64 二进制文件,而 BCJ 可以逐字节还原二者。使用兼容的过滤器能够保留依赖内容与运行时完整性,无需删除特定架构的文件或削弱校验。
 
 本地 Windows 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有 EV Token 时诊断安装问题,也能防止本地测试产物通过发布上传校验。
 
-NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录。Finish 启动应用后,默认退出清理可能与后端的文件读取重叠。[安装器 hook](../../../../apps/desktop/scripts/installer.nsh) 在 `customInstall` 阶段仅删除该解压目录,早于交互和静默启动分支。它保留包归档、插件 DLL、回滚目录、寄存器和错误状态;[原生清理 smoke](../../../../apps/desktop/tests/fixtures/installer-cleanup-smoke.nsi) 检查这些约束。把清理移入安装阶段并不会减少文件系统工作,因此必须分别测量安装总耗时与点击 Finish 到窗口出现的耗时。
-
-安装器不开启直接向应用目录执行 `Nsis7z::Extract`。原生[文件占用探针](../../../../apps/desktop/tests/fixtures/installer-write-failure-smoke.nsi)会在未报错的情况下留下被占用的旧文件和新资源;暂存后执行的 `CopyFiles` 在相同替换失败时会设置错误标志。Windows 上同一份 737,557,488 字节载荷经过解压、复制和清理耗时 172.625 秒,直接解压耗时 28.031 秒,但每条路径的单次样本不足以支持放弃失败检测。计时不包括注册表修改、旧版删除及解压后的验证,也没有清空系统缓存。桌面专用载荷过滤减少需要复制的文件,同时保留安装器的替换错误处理。这种处理并不承诺完整的安装回滚。
+Windows 应用替换遵循[目录安装决策](2026-09-11-windows-directory-installation.zh.md):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。
 
-打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 Node.js 可执行文件、pnpm 入口、dsh 资源 或活跃项目。
+打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 pnpm 入口、dsh 资源 或活跃项目。
 
-在种子 store 子集之外,内置上游 Node.js 与 pnpm 预计增加约 35–50 MB 压缩体积和 120–165 MB 安装体积。分架构构建必须报告实际组件级体积增量。
+分架构构建报告实际组件级压缩体积和安装体积。
 
 ## 实现
 
 | 表面 | 实现 |
 |---|---|
 | 壳 | `apps/desktop` 负责 Electron 窗口、受限 preload、自定义协议、子进程生命周期、项目事务、插件 GUI、更新协调和 electron-builder 配置。 |
-| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 从活跃项目启动无端口桌面组合,并通过经过验证的分帧字节管道流式传输 API 与资源响应。 |
-| 包状态 | 内置 Node.js 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
+| 已安装运行时 | 私有 `@deepseek-ai/dsh-desktop-host` 调用共享 profile runner,并向 Electron 报告认证 Web URL。 |
+| 包状态 | Electron RunAsNode 执行不可变核心资源;内置 pnpm 只修改 Desktop profile 中的外部插件依赖图。 |
 | 资格验证 | macOS 打包要求已配置的公司身份与公证凭据可用,在生成清单前验证每个原生运行时文件,验证完整应用签名,并要求应用和 DMG 都完成公证且通过 Gatekeeper。Windows 打包要求已配置的公开证书、SafeNet 私钥容器、Token Password 与 SignTool,并验证生成的每个签名。更新托管、跨上一版本的已安装产物测试和各平台 GUI 录制仍是发布环境门槛。 |
 
-`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。该模式的链接依赖图不是由 pnpm 安装的桌面项目,因此会禁用包修改。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
+`dev:desktop` 会构建当前 workspace,把已构建 CLI 包、私有 Desktop Host 包及其依赖链接投影为一次性项目,使用隔离的 Harness home,打开 Main、Renderer 和 Host 调试器,并在不准备发布资源的情况下启动未打包 Electron。开发运行时为独立的插件 profile 提供工作区链接;插件管理和恢复使用与打包应用相同的流程。固定的 macOS arm64、macOS x64 与 Windows x64 打包命令会把同一目标传给运行时准备、dsh 准备和 electron-builder;每条命令还提供未封装安装器的变体,用于在生成安装器前验证发布路径。
 
 ## 考虑过的替代方案
 
 **使用 Electron 的 Node.js 执行 dsh。** 这可以减小包体积,但会让 dsh 耦合到 Electron 的 Node 补丁、fuse、原生 ABI、TLS 行为和进程生命周期。内置上游 Node.js 可以让 dsh 继续使用其受支持运行时。
 
-**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** JSON IPC 可以只保留一种消息机制,但会膨胀每个请求与响应消息体、在两个进程中构造大字符串、在分派前缓冲完整请求,还会再次编码 RPC JSON 中已经表示为 Base64 的图片字节。原始分帧管道保留明确的带版本协议,同时不要求 Electron 与上游 Node.js 共享 V8 序列化行为。
+**通过 JSON IPC 以 Base64 承载 Fetch 消息体。** 这会膨胀请求与响应消息体、在两个进程中构造大字符串,并在分派前缓冲请求。原始分帧管道避免 Base64 膨胀,但仍需维护第二套传输;[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)选择已有的 Web HTTP 传输。
 
 **把产品 Web UI 永久打包进 Electron。** 独立 UI 与后端更新需要新的版本化兼容计划。从同一个 dsh 包安装后端与 Web UI 可以保持当前发布绑定。
 
@@ -124,13 +116,13 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 
 **让桌面 profile 使用 CLI 管理的包或插件。** 任一产品都可能改变另一产品的依赖图、Cordis 版本、插件版本或原生模块。Desktop 拒绝通过 CLI profile 回退目录解析包。
 
-**分离核心与插件解析,却不明确共享包归属。** 这会允许宿主模块重复以及不可控的 peer 回退。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)为分离的资源和 profile 目录提供明确链接及依赖验证。
+**分离核心与插件解析,却不提供共享包链接。** pnpm 未安装宿主 peer 包时,插件需要访问内置运行时。[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)通过明确链接提供 profile 缺失的包,同时保留 pnpm 安装的包。
 
 **从 registry 包删除非目标 Mach-O 文件。** 架构裁剪可以节省少量 运行时空间,但包可能有意附带多个架构变体,调用方也可以观察安装后的文件集。签署每个实际携带的 Mach-O 对象,无需发明 Desktop 专属包布局就能满足公证要求。
 
 **把 Windows EV 私钥导出到 PFX 文件。** 外部提供的公开叶证书让 SignTool 构造签名,`/csp` 与 `/kc` 则定位硬件密钥。EV 私钥保持不可导出,并留在 Token 上。
 
-**提交包含凭据的签名脚本或持久保存 Token Password。** 包含凭据的 CMD 文件、`.env` 或 Windows 用户/系统环境变量都会让 Token Password 以静态形式被读取。已提交的 CMD 只包含环境变量引用,打包步骤则把密码作为 runner 临时 secret 接收。
+**把凭据写进已跟踪脚本或系统环境。** 本地平台文件把配置限制在单个 checkout,并使打包输入明确。代价是凭据以明文落盘:构建账号需要限制文件访问权限,CI 必须清理临时配置,Git 与发布文件映射都必须排除真实配置。已提交的模板不含凭据;Windows CMD 只包含变量引用,签名串行执行并在首次失败后停止,文件格式不会免除 Token 的错误 PIN 计数。
 
 **让 electron-builder 或通用目录同步直接发布。** 直接发布可能在所有引用产物就绪前暴露频道元数据,可能把陈旧或其他目标的文件混入发布,也无法证明已完成签名的构建仍与当前 dsh 版本一致。目标专用且经过校验的上传可以明确控制发布顺序与发布身份。
 
@@ -139,7 +131,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 - 没有系统 Node.js 或 pnpm 的干净离线机器能够启动内置 dsh,无需安装核心依赖。
 - 签名应用记录最终运行时文件清单;每个 macOS 原生文件都具有发布 Developer ID、安全时间戳和 hardened runtime,每个 Windows 产物都具有配置的硬件 EV 签名。
 - `.dsh/profiles/desktop/node_modules` 能解析共享宿主链接和每个通过 GUI 安装的桌面插件。
-- 每个桌面 pnpm 操作都使用内置可执行文件和 `.dsh/desktop/pnpm/store`;不读取用户 `PATH`、配置、store 或 profile `node_modules`。
+- 每次 Desktop 包操作都使用内置 pnpm,并遵循用户的正常环境与 profile 配置。
 - Electron-only GUI 安装、删除和更新普通 npm 插件包,而不暴露原始 pnpm 参数。
 - 后端与浏览器应用不能修改桌面包。
 - npm/CLI dsh 与 Electron 绝不从对方的 `node_modules` 解析或安装插件。
@@ -147,7 +139,7 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 - 包操作或 Host 失败后保留部分 profile 修改,并提供恢复控件;不承诺自动回滚 profile。
 - 一个 Desktop 版本绑定 Electron 与 dsh;每次 dsh 更新都通过一个 Electron 更新弹窗交付,并产生一次用户可见的重启。
 - 共享 `.dsh` 数据在迁移或修改前拒绝不兼容的读取方。
-- 不打开回环监听端口,沙箱渲染进程不能访问任意文件系统或 Electron API。
+- Web 应用负责 HTTP 认证与服务;沙箱渲染进程不能访问任意文件系统或 Electron API。
 - Workspace 开发无需下载发布资源即可运行当前已构建代码,未封装安装器的应用验证仍保留生产安装路径。
 - Windows 发布打包要求已验证的 SignTool、EV Token、匹配的公开叶证书、Token Password 和明确的密钥容器,绝不会回退到未签名产物或可导出的密钥文件。
 - 目标更新只有在已完成签名的构建及其引用的每个产物通过发布校验后才能暴露新频道元数据;保留的历史产物继续供差分更新使用。
@@ -166,16 +158,16 @@ NSIS 先解压到私有的 `7z-out` 目录,再把文件复制到应用目录
 
 ## 风险
 
-插件生命周期脚本会执行第三方代码。在 GUI 安装功能交付前,获准 registry、包策略、精确版本、完整性、`allowBuilds` 和诊断都需要安全评审。
+插件生命周期脚本会执行第三方代码。profile 的 `allowBuilds` 配置决定哪些构建获准执行。
 
-更新绑定的 dsh 可能使插件 peer dependency 或原生模块失效。校准会验证变化的依赖并重建原生包;失败后需要通过恢复 UI 显式修复。
+更新绑定的 dsh 可能使插件 peer 依赖或原生模块不兼容。已安装插件文件保留原位;加载失败需要通过恢复 UI 显式修复。
 
 通过 npm 安装的 dsh 与桌面 dsh 可能在共享持久化数据时使用不同版本。每个共享 owner 都必须在读取、迁移或写入前执行格式版本与进程锁。
 
-中断的包操作保留未完成标记。已安装产物测试必须验证后续启动会重试锁定依赖的安装和获准的原生构建。
+包操作中断会保留部分变更。恢复操作重试实际 Host,或允许显式修复插件,不会自动重新安装包。
 
 代码签名、公证和更新托管需要生产发布基础设施。只运行仓库测试不能完成这些认证。
 
 ## 相关提案
 
-[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)取代本记录的离线 运行时准备与单项目包归属决策。发布身份、签名、无端口传输、进程归属及仅限 Electron 的包授权仍由本记录负责。
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)负责核心资源与外部插件依赖。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)取代无端口传输与私有后端组合。发布身份、签名、进程归属及仅限 Electron 的包授权仍由本记录负责。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
-2026-09-07-sidebar-responsive-tab-info.md: ade20148cccd2233224417f762e65492828943bb
-2026-09-07-sidebar-responsive-tab-info.zh.md: 0903e4ff06ca187fcc1fd0e9d8de63f70d5cc427
+2026-09-07-sidebar-responsive-tab-info.md: 96f7f047d00a33d6cd48b026ce7762929720d9d6
+2026-09-07-sidebar-responsive-tab-info.zh.md: f020068e71777272fd15a967de547ce153884402

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md

@@ -12,7 +12,7 @@ Tab extensions need consistent live information about their containing pane and
 
 The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel`, and `tab` fields. It composes the framework-bound layout and navigation hooks; extensions neither subscribe themselves nor receive a service object. Body visibility requires an active tab in an expanded Sidebar; title visibility does not require an active tab. Hiding or switching Sessions leaves the tab lifetime intact. Closing the record aborts its signal. Tab actions stay bound to their owning Session. Store adoption is a private capability of the plugin assembly, not a public controller operation.
 
-The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. Refresh restores defaults rather than persisting layout.
+The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. [Layout persistence and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) owns layout restoration after reload; frame width preferences still initialize from the viewport.
 
 Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled. On exit, the frame prepares the destination before the overlay retreats: no right track for close, a normal track for restore. Transition suppression survives clearing the fullscreen report and ends on the next geometry action.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 `tab` 字段。它组合框架绑定的布局与导航 hook;扩展既不自行订阅,也不接收服务对象。正文可见要求 Sidebar 展开且标签活跃;标题可见不要求标签活跃。隐藏或切换 Session 保留标签生命周期。关闭记录会中止其 signal。标签动作始终绑定到所属 Session。Store 收编是插件组装的私有能力,不是公共控制器操作。
 
-框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。刷新恢复默认值,不持久化布局。
+框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。[布局持久化与 provider 恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)负责刷新后的布局还原;框架宽度偏好仍根据视口初始化。
 
 全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。 退场时,框架先准备目标布局,再让覆盖层退出:关闭不留右轨道,恢复保留普通轨道。清除全屏报告时仍保留过渡抑制,直到下一次几何操作才结束。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md
-2026-09-08-desktop-bundled-runtime-and-external-plugins.md: 581a4ad31121bfda651cb99e550487a0a712e943
-2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: fbbb25a4a3446d52ac56dc35bb176c0b706d7cb4
+2026-09-08-desktop-bundled-runtime-and-external-plugins.md: b49ae76bfd8c468e9c971f803cf714755978e5b4
+2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md: 58dd72124c0fdeb53652a600d0d3c143feb1ff3d

+ 17 - 15
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md

@@ -6,6 +6,8 @@ English | [中文](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md
 
 Profile mutation and recovery follow the [in-place profile decision](2026-09-09-desktop-in-place-profile.md).
 
+The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
+
 ## Problem
 
 Installing the core dependency graph during Desktop initialization repeats work already done by the release builder. An offline store eliminates downloads but retains extraction, package-manager startup, and installation costs. Users need the application to start with its production packages present while retaining ordinary npm plugin installation and plugin state across application upgrades.
@@ -14,48 +16,48 @@ Separate package directories can load duplicate Cordis or service modules. Retai
 
 ## Decision
 
-[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. A bundled upstream Node process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`.
+[Runtime preparation](../../../../apps/desktop/scripts/prepare-dsh.ts) materializes the production graph once at build time and ships it through `extraResources/dsh`. The Electron shell stays in ASAR. An Electron RunAsNode process runs the private Desktop Host from resources and loads enabled plugins from `$DSH_HOME/profiles/desktop`.
 
-Desktop has not been released. This is its first installation format; there are no readers or migrations for the unpublished seed-based profile. This note supersedes core seed installation and single-project dependency ownership in the [Desktop packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That note continues to own release identity, signing, portless transport, process ownership, and Electron-only plugin authorization. No existing note is fully superseded or archived.
+This note owns core resource storage and external plugin dependencies. The [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) retains release identity, signing, process ownership, and Electron-only plugin authorization. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) owns shared Web boot and HTTP transport.
 
 ## Package ownership
 
 The resource descriptor records the exact release, Node version, platform, architecture, shared package versions, and final file hashes. The runtime tree contains ordinary files and directories, without links back to pnpm’s build store. Native Mach-O files are signed before hashing; the application signer preserves their bytes and checks the inventory after signing. An explicit `dsh/node_modules` resource mapping bypasses electron-builder’s root `node_modules` exclusion, and the copied tree is verified before any signing or notarization.
 
-The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts, clears inherited `NODE_OPTIONS`, and does not enable source mapping. Reviewed plugin lifecycle builds cover native dependencies, not arbitrary TypeScript compilation. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
+The [Desktop file policy](../../../../apps/desktop/scripts/runtime-file-policy.ts) applies after production npm installation and before native signing or descriptor generation. npm publication lists serve library consumers and can include declarations, maps, tests, and native build inputs; they do not identify the files needed by the Desktop process. The Desktop copy omits declarations and recognized source maps because Host execution uses JavaScript and generated Typert artifacts. The Host inherits the user environment. Published npm packages and external plugin directories retain their own files. Source debugger navigation is a development-package capability.
 
-Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the bundled-Node [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
+Package-specific exclusions remove Domino tests, fs-ext compilation outputs, Koffi's Windows import library, and non-target node-pty prebuilds and debug symbols. The policy retains native executable dependencies, node-pty's ConPTY source distribution, licenses, and unrecognized assets; broad `src`, `test`, `.ts`, or `.map` exclusions could remove executable code or runtime data. Copy tests preserve sentinel assets and seal the filtered inventory; the Electron [payload smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs) verifies PTY output, native file seeking, FFI, image conversion, and HTML parsing. Runtime preparation still verifies every retained byte and boots the complete Host with an external plugin.
 
-Every first-party package in the dsh and private Host production closures is shared. The profile contains directory symlinks to those resource packages, or junctions on Windows. Links resolve to real host package directories under normal Node resolution. Host and plugin imports therefore share the same module instance for each resolved export. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
+The shared profile runner projects missing installation and selected-bundle dependencies within the Desktop profile. pnpm-installed packages take precedence. Links resolve to real package directories under normal Node resolution, so Host and plugin imports reaching the same export share its module instance. Distinct ESM and CommonJS conditional exports remain distinct entry points; a link cannot merge a package’s dual implementations.
 
-External plugins declare shared host packages as peers. Ordinary dependencies remain plugin-owned and may differ from the versions used by dsh. Validation rejects incompatible enabled peers, nested or aliased copies of shared packages, private package links, and dependency resolution through CLI or other ancestor directories. A third-party package requiring host-wide instance identity must be explicitly added to the runtime’s shared inventory; matching version numbers alone are insufficient.
+External plugins use normal Node package resolution. Desktop does not recursively check peer versions, duplicate packages, linked packages, or ancestor dependency resolution. These checks duplicate package-manager and loader responsibilities and reject pnpm-supported installation sources. Shared fallback links supply missing packages, but a plugin can resolve another installed copy; incompatible plugins may fail during Host startup and require recovery through the independent shell UI.
 
-The profile manifest records exact installed plugin dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The shared links are Desktop-owned derived state, recorded separately from pnpm; package-manager operations run without those links, then Desktop recreates and validates them.
+The profile manifest records pnpm-installed dependencies separately from its enabled bundle list. Disabling a plugin preserves its package, lockfile entry, and user configuration. The [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md) assigns initialization, bundle reconciliation, and module links to shared app-boot helpers; Desktop holds no separate link ledger or runtime-state identity.
 
 ## Transactions and upgrades
 
-First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links and validate enabled peers in place. Node version, platform, or architecture changes reinstall the locked plugin graph and run approved native builds.
+First launch creates profile metadata and host links without running pnpm, preserving unrelated files. Compatible release changes or application relocation refresh links in place. Node version, platform, or architecture changes preserve installed plugins; pnpm and the loader report installation and compatibility failures.
 
 Native canonical paths identify shared package directories. Windows launchers can vary path casing without moving the application; string equality would trigger unnecessary profile preparation. Profile cleanup explicitly unlinks every nested directory link before removing real directories. A Windows fixture under Electron 44 reproduces recursive `fs.rmSync` deleting files through a nested junction, while bundled upstream Node 24.17 preserves them. Cleanup qualification therefore includes the real Electron runtime; Node-only tests do not establish target preservation.
 
-Dependency mutations install with scripts disabled, validate the plugin graph and host links, run the reviewed pending lifecycle builds, and validate again. This permits approved native dependencies to resolve host peers while preventing accidental duplicate host packages from reaching startup. The `allowBuilds` policy remains explicit; unsupported build-requiring dependencies fail the transaction.
+Dependency mutations use ordinary pnpm add and remove commands, including their configured lifecycle scripts. Installation sources and dependency resolution belong to pnpm. Bundle declarations select automatic activation; patch validation belongs to the bundle loader. Plain dependencies remain installed without activation. The bundled pnpm reads normal user and profile settings, including registry and build permissions. Desktop supplies no runtime build allowlist, strict-build setting, version-range restriction, or fixed profile identity. Unreadable package metadata does not prevent listing, disabling, or removing a dependency. Development uses the same manager against a separate plugin profile, with workspace packages supplied by the development runtime.
 
-Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and persistent retry state. Recorded host links identify owned directories independently of package-operation completion.
+Desktop stops the Host before package mutations and waits for pnpm exit before restarting it. Shared cleanup removes only fallback-owned links, preserving pnpm entries. The [in-place decision](2026-09-09-desktop-in-place-profile.md) owns partial failures and explicit recovery.
 
-The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded. Each backend launch requires the current runtime identity.
+The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns direct Host startup and recovery in the main window. Users can update, remove, disable, or re-enable plugins and retry startup. Incompatible plugins are not silently deleted or automatically downgraded.
 
 ## Alternatives considered
 
-Full runtime verification belongs to packaging. Startup reads the descriptor, checks shared package records and required Host entries, and uses the recorded runtime identity for profile reuse. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. It neither enumerates nor hashes installed runtime files, including on first launch or after an upgrade. Reading every file before backend loading adds startup I/O proportional to the distribution size. Installed content changes therefore are not detected by a startup checksum comparison; unusable modules fail when loaded. Build-time verification still rejects changed, missing, extra, or linked files against the recorded inventory.
+Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation decision](2026-09-09-desktop-build-release-validation.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
 
 - **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
-- **Link all host dependencies into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Only the explicit shared inventory is linked; private packages retain independent versions.
+- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. Shared fallback supplies missing packages while pnpm-owned entries retain independent versions.
 - **Use hardlinks.** They cannot represent directories, may not cross volumes, share writable bytes, and retain old inodes after application replacement. Directory symlinks and Windows junctions express the intended package target.
 - **Use `NODE_PATH` or preserve symlink paths.** These do not provide uniform ESM resolution or shared module identity. Normal package lookup through explicit links is directly testable.
-- **Keep core packages in ASAR.** The backend uses upstream Node rather than Electron’s patched filesystem. Ordinary `extraResources` also preserves native loading and subprocess paths.
+- **Keep core packages in ASAR.** Ordinary `extraResources` preserves native loading and subprocess paths. ASAR requires separate package-resolution qualification.
 
 ## Consequences
 
 Core package installation is absent from first launch and compatible upgrades. Metadata checks and backend loading still cost startup time; no release latency or download-size improvement is claimed without measurement. Plugin preservation is conditional on host API and native runtime compatibility, with a visible recovery path when that condition fails.
 
-The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, native rebuild selection, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.
+The [Desktop README](../../../../apps/desktop/README.md) owns operational guidance. Focused tests cover real pnpm installation and approved builds, shared ESM instance identity, private dependency versions, relocation, disabled plugins, runtime changes without automatic reinstalls, activation failures, and transaction locking. Signed installed-artifact upgrades, macOS notarization, Windows junction/native behavior, release size and startup benchmarks, and real-model GUI recordings remain release-environment qualification requirements; unit fixtures do not substitute for them.

+ 17 - 15
.agents/notes/implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md

@@ -6,6 +6,8 @@ Status: implemented
 
 profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)。
 
+[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
+
 ## 问题
 
 Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成的工作。离线 store 消除了下载,但仍有解压、包管理器启动和安装成本。用户需要应用在生产依赖已就绪时启动,同时保留普通 npm 插件安装能力,以及跨应用升级的插件状态。
@@ -14,48 +16,48 @@ Desktop 初始化时安装核心依赖图,会重复发布构建器已经完成
 
 ## 决策
 
-[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。内置上游 Node 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。
+[运行时准备](../../../../apps/desktop/scripts/prepare-dsh.ts)在构建时物化一次生产依赖图,并通过 `extraResources/dsh` 分发。Electron 壳保留在 ASAR 中。Electron RunAsNode 进程从资源启动私有 Desktop Host,并从 `$DSH_HOME/profiles/desktop` 加载已启用插件。
 
-Desktop 尚未发布。这是它的第一种安装格式;不提供未发布 seed profile 的读取器或迁移。本记录取代 [Desktop 打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的核心 seed 安装和单项目依赖归属部分。该记录继续负责发布身份、签名、无端口传输、进程归属和仅限 Electron 的插件授权。没有现有记录被完全取代或归档。
+本记录负责核心资源存储与外部插件依赖。[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)保留发布身份、签名、进程归属和仅限 Electron 的插件授权。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)负责共享 Web 启动与 HTTP 传输。
 
 ## 包归属
 
 资源描述文件记录精确发布版本、Node 版本、平台、架构、共享包版本和最终文件哈希。运行时树包含普通文件和目录,不包含指回 pnpm 构建 store 的链接。原生 Mach-O 文件先签名再哈希;应用签名器保留其字节,并在签名后检查清单。明确的 `dsh/node_modules` 资源映射绕过 electron-builder 对根 `node_modules` 的排除,并在任何签名或公证前验证复制后的依赖树。
 
-[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物,清除继承的 `NODE_OPTIONS`,且不开启源码映射。经过审核的插件生命周期构建面向原生依赖,不执行任意 TypeScript 编译。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
+[桌面文件规则](../../../../apps/desktop/scripts/runtime-file-policy.ts)在生产 npm 依赖安装之后、原生签名或描述文件生成之前执行。npm 发布列表服务于库的使用者,可以包含声明、map、测试和原生构建输入,不能直接表示桌面进程需要哪些文件。桌面副本排除声明和已识别的 source map,因为 Host 执行 JavaScript 和生成的 Typert 产物。Host 继承用户环境。已发布的 npm 包和外部插件目录保留各自的文件。源码调试导航由开发包提供。
 
-包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;内置 Node 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
+包专用排除项包括 Domino 测试、fs-ext 编译产物、Koffi 的 Windows 导入库,以及非目标平台的 node-pty 预构建文件和调试符号。规则保留原生可执行依赖、node-pty 的 ConPTY 源分发内容、许可证和未知资源;宽泛排除 `src`、`test`、`.ts` 或 `.map` 可能移除可执行代码或运行时数据。复制测试保留哨兵资源并封存过滤后的清单;Electron 的[产物 smoke](../../../../apps/desktop/tests/fixtures/runtime-payload-smoke.mjs)验证 PTY 输出、原生文件定位、FFI、图像转换和 HTML 解析。运行时准备仍会验证每个保留字节,并携带外部插件启动完整 Host。
 
-dsh 与私有 Host 生产闭包中的每个第一方包都共享。profile 包含指向这些资源包的目录软链接,在 Windows 上使用 junction。正常 Node 解析会把链接解析到实际宿主包目录。因此,宿主与插件对每个已解析导出的导入共享同一模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的两套实现。
+共享 profile runner 在 Desktop profile 内补全安装包与选中 bundle 缺失的依赖。pnpm 安装的包优先。链接通过正常 Node 解析指向真实包目录,因此 Host 与插件导入同一导出时共享其模块实例。不同的 ESM 与 CommonJS 条件导出仍是不同入口;链接不能合并包的双重实现。
 
-外部插件把共享宿主包声明为 peer。普通依赖由插件拥有,可以不同于 dsh 使用的版本。验证拒绝已启用插件的不兼容 peer、共享包的嵌套或别名副本、私有包链接,以及通过 CLI 或其他祖先目录解析依赖。如果第三方包需要宿主范围的实例身份,必须明确加入运行时共享清单;版本号相同并不足够。
+外部插件使用正常 Node 包解析。Desktop 不递归检查 peer 版本、重复包、链接包或上级目录依赖解析。这些检查重复包管理器及加载器的职责,并拒绝 pnpm 支持的安装来源。共享补全链接提供缺失的包,但插件可以解析到另一份已安装副本;不兼容插件可能在 Host 启动时失败,需要通过独立壳 UI 恢复。
 
-profile manifest 分别记录精确的已安装插件依赖和已启用 bundle 列表。停用插件会保留其包、锁文件条目和用户配置。共享链接是 Desktop 拥有的派生状态,独立于 pnpm 记录;包管理器操作不携带这些链接,随后 Desktop 重建并验证它们。
+profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。禁用插件保留其包、锁文件条目及用户配置。[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md)将初始化、bundle 协调和模块链接交给共享 app-boot helper;Desktop 不保存独立链接账本或运行时状态身份。
 
 ## 事务与升级
 
-首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接并验证已启用的 peer。Node 版本、平台或架构变化时,会重新安装锁定的插件依赖图并运行获准的原生构建。
+首次启动创建 profile 元数据和宿主链接,不运行 pnpm,并保留无关文件。兼容的发布变化或应用移动会直接刷新链接。Node 版本、平台或架构变化时保留已安装插件;安装和兼容性错误由 pnpm 与加载器报告。
 
 共享包目录使用原生规范路径识别。Windows 启动器可能改变路径大小写而不移动应用;字符串相等判断会触发不必要的 profile 准备。profile 清理在移除真实目录前,显式解除每一个嵌套目录链接。Windows 夹具在 Electron 44 下复现了递归 `fs.rmSync` 沿嵌套 junction 删除目标文件,而内置上游 Node 24.17 会保留它们。因此清理验收包含真实 Electron 运行时;仅在 Node 下测试不能证明目标文件会保留。
 
-依赖修改先禁用脚本安装,验证插件依赖图和宿主链接,运行经过审查的待执行生命周期构建,再次验证。这允许已批准的原生依赖解析宿主 peer,同时阻止意外的重复宿主包进入启动过程。`allowBuilds` 策略保持明确;不受支持且需要构建的依赖会使事务失败。
+依赖修改使用普通的 pnpm add 和 remove 命令,并遵循其生命周期脚本配置。安装来源和依赖解析由 pnpm 负责。bundle 声明决定自动启用;patch 验证由 bundle 加载器负责。普通依赖安装后不自动启用。内置 pnpm 读取正常的用户和 profile 设置,包括 registry 和构建权限。Desktop 不提供运行时构建许可列表、严格构建设置、版本范围限制或固定 profile 身份。包元数据不可读时,仍可列出、禁用或删除依赖。开发模式使用同一管理器操作独立的插件 profile,工作区包由开发运行时提供。
 
-Desktop 在包修改前停止 Host,并等待 pnpm 退出后再重启它。[直接修改决策](2026-09-09-desktop-in-place-profile.zh.md)规定部分失败和持久重试状态的处理方式。记录的宿主链接用于识别自有目录,与包操作是否完成相互独立。
+Desktop 在包变更前停止 Host,并等待 pnpm 退出后重新启动。共享清理仅移除模块补全逻辑拥有的链接,保留 pnpm 条目。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)负责部分失败与显式恢复。
 
-[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。每次后端启动都要求当前运行时标识。
+[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定实际 Host 启动和主窗口恢复。用户可以更新、删除、禁用或重新启用插件并重试启动。不兼容插件不会被静默删除或自动降级。
 
 ## 考虑过的替代方案
 
-完整运行时验证属于打包流程。启动读取描述文件,检查共享包记录和必要的 Host 入口,并使用记录的运行时身份复用 profile。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)把发布与目标兼容性检查交给打包流程。首次启动和升级后启动都不枚举已安装运行时文件或计算其哈希。在后端加载前读取每个文件,会增加与分发体积成正比的启动 I/O。因此,启动不会通过校验和比较检测已安装内容的变化;不可用模块在加载时失败。构建时验证仍按记录的清单拒绝内容变化、缺失、多余或链接文件。
+完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证决策](2026-09-09-desktop-build-release-validation.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
 
 - **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
-- **把所有宿主依赖链接给插件。** 这会让普通插件依赖与宿主产生不必要的耦合。只链接明确的共享清单;私有包保留独立版本。
+- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。共享模块补全提供缺失的包,pnpm 拥有的条目保留独立版本。
 - **使用硬链接。** 它不能表示目录,可能无法跨卷,共享可写字节,并在应用替换后保留旧 inode。目录软链接和 Windows junction 能表达预期的包目标。
 - **使用 `NODE_PATH` 或保留软链接路径。** 它们不能提供统一的 ESM 解析或共享模块身份。通过明确链接进行正常包查找可以直接测试。
-- **把核心包留在 ASAR。** 后端使用上游 Node,而不是 Electron 修改过的文件系统。普通 `extraResources` 也能保留原生加载和子进程路径。
+- **把核心包留在 ASAR。** 普通 `extraResources` 保留原生加载和子进程路径。ASAR 需要单独验证包解析。
 
 ## 影响
 
 首次启动和兼容升级不安装核心包。元数据检查和后端加载仍需要启动时间;没有测量前,不声称发布启动延迟或下载体积改善。插件保留以宿主 API 和原生运行时兼容为条件,条件不满足时提供可见的恢复入口。
 
-[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、原生重建选择、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。
+[Desktop README](../../../../apps/desktop/README.zh.md)负责操作说明。定向测试覆盖真实 pnpm 安装与已批准构建、共享 ESM 实例身份、私有依赖版本、应用移动、停用插件、运行时变化时保留插件、激活失败和事务锁。签名安装产物升级、macOS 公证、Windows junction 与原生行为、发布体积与启动基准,以及真实模型 GUI 录制仍是发布环境验收要求;单元夹具不能替代这些验证。

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
-2026-09-09-desktop-immediate-window-and-direct-start.md: 1c2209c044495de1fb9d3410e5ac777759e8fa6b
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 49b08e4eeea2703c9bcf868d7b290d56eba79542
+2026-09-09-desktop-immediate-window-and-direct-start.md: 461f82719fabba67bd93c4c1f2a37d02fac1c06b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: efbeeb20993fdc6baeb11ca1ea1d1af5501567dc

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md

@@ -12,11 +12,11 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 ## Decision
 
-Electron creates the main window with a local loading page before profile reconciliation or Host startup. The page depends only on packaged shell assets and receives starting, ready, or error state through the owned preload. Readiness loads the product UI in that window; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
+Electron creates the main window with the packaged Web loading page before profile reconciliation or Host startup. The Web entry draws its boot page before awaiting Host readiness. The owned preload delivers structured boot injections, and the existing document activates its client plugins after they are applied; startup failures display diagnostics and available recovery actions. Closing during loading cancels further startup work and waits for the pending child to exit.
 
-The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available only in a packaged application with loaded runtime metadata and available resources. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
+The main window owns recovery because the failed Host cannot supply its own controls. Error pages retain diagnostics, restart, and reinstallation guidance. Disabling plugins and resetting Desktop are available with loaded runtime metadata and available resources, including in development mode. Reset removes all profile contents except its held lock, without a backup; shared product data and the Harness-home environment file remain intact. The profile directory remains in place so another transaction cannot acquire a replacement lock during cleanup. Self-contained recovery controls use intercepted form navigation when preload is unavailable. A crashed renderer invalidates the navigation cache so the startup page loads again.
 
-Desktop starts the actual Host after preparing the profile in place, without booting a separate health-check backend. Package mutations retain dependency validation, approved lifecycle builds, runtime identity checks, and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
+Desktop starts the actual Host through the [shared Web runner](2026-09-10-desktop-web-wrapper.md) after preparing the profile in place. Readiness supplies the authenticated Host URL and boot injections. The shell exchanges the URL for a Host cookie, forwards application HTTP requests, and authenticates direct WebSocket requests only for the owned application origin. This carrier adaptation preserves Web route and stream semantics while allowing static HTML to appear before the Host. Package mutations retain pnpm lifecycle scripts and locking. Failures retain partial changes for explicit repair; there is no automatic profile rollback.
 
 This partially supersedes staged backend probes and waiting to create the main window in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Those notes retain release, signing, transport, resource ownership, and dependency-transaction rationale. Full runtime file verification remains a packaging operation.
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.zh.md

@@ -12,11 +12,11 @@ profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in
 
 ## 决策
 
-Electron 在 profile 校准或 Host 启动前创建带本地加载页的主窗口。该页面仅依赖已打包的壳资源,并通过自有 preload 接收 starting、ready 或 error 状态。就绪后在同一窗口加载产品 UI;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
+Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主窗口。Web 入口先显示启动页,再等待 Host 就绪。自有 preload 交付结构化启动注入,现有文档应用注入后激活客户端插件;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
 
-主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。只有已打包应用加载了运行时元数据且资源可用时,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
+主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。加载了运行时元数据且资源可用时,包括开发模式,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页。
 
-Desktop 直接准备 profile 后启动实际 Host,不另行启动健康检查后端。包修改保留依赖验证、获准生命周期构建、运行时标识检查和锁。失败后保留部分修改,供显式修复;不自动回滚 profile。
+Desktop 原位准备 profile 后,通过[共享 Web runner](2026-09-10-desktop-web-wrapper.zh.md)启动实际 Host。就绪消息提供认证 Host URL 与启动注入。壳使用该 URL 换取 Host cookie,转发应用 HTTP 请求,并仅为归属的应用 origin 认证直接 WebSocket 请求。这一载体适配保留 Web 路由与流语义,同时允许静态 HTML 在 Host 之前显示。包变更保留 pnpm 生命周期脚本与锁。失败保留部分变更以供显式修复,不会自动回滚 profile。
 
 本决策部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的 staging 后端探针与延迟创建主窗口。这两份记录仍保留发布、签名、传输、资源归属与依赖事务的理由。完整运行时文件验证仍属于打包操作。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
-2026-09-09-desktop-in-place-profile.md: 9a221bb334983a18366baeec00a179646dc76385
-2026-09-09-desktop-in-place-profile.zh.md: 53a744ae9ea284c32c3807f67ad365d270217287
+2026-09-09-desktop-in-place-profile.md: 425145ad89f9697c2420544b0ce2e70759313ffc
+2026-09-09-desktop-in-place-profile.zh.md: 8b98ec1c2e426b0019115a9a19eb84db4b9caef8

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md

@@ -10,13 +10,13 @@ Staging preserves an old plugin installation but adds profile copying, directory
 
 ## Decision
 
-Desktop stops the Host and modifies the current profile directly. Shared host links are detached for package changes and restored when the operation settles. Package locking, dependency validation, and approved native builds remain. Compatible upgrades refresh links without copying plugin files.
+Desktop stops the Host and modifies the current profile directly. Shared app-boot cleanup detaches its own fallback links before package changes; the Host’s shared profile runner supplies required links on startup. Package locking and configured lifecycle scripts remain. Upgrades refresh module links without copying plugin files.
 
 Package or Host failures retain partial changes for repair and retry. There is no staging profile, activation journal, directory-swap recovery, or automatic rollback. Existing scratch directories are not interpreted or deleted.
 
-This supersedes staging and rollback in [2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.md). Other release, module-identity, and window-lifecycle decisions remain active.
+This supersedes staging and rollback in [the packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md), [the bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md), and [the immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md). Host boot follows the [thin-wrapper decision](2026-09-10-desktop-web-wrapper.md); release, module ownership, and window lifecycle remain separate decisions.
 
-A persistent `desktop-packages-pending` marker precedes package writes or native-runtime rebuilding and is removed only after installation, approved builds, and validation succeed. A later launch with that marker reinstalls the locked graph and retries pending builds even when recorded runtime metadata already matches. Ordinary unchanged startups reuse the profile without scanning the plugin dependency graph; package mutations and runtime reconciliation retain validation.
+Desktop delegates installation and lifecycle scripts to pnpm, without a pending-operation startup gate, frozen-lockfile reinstall, or automatic rebuild. Failed package operations preserve partial changes and leave disable, remove, reset, and startup retry available. The Host inherits the user environment, and profiles may use directory links. An unchanged legacy Desktop-generated pnpm configuration is replaced with the Web defaults; customized configuration remains user-owned.
 
 ## Alternatives considered
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md

@@ -10,13 +10,13 @@ staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢
 
 ## 决策
 
-Desktop 停止 Host 后直接修改当前 profile。修改包前解除宿主共享链接,操作结束后恢复链接。保留包锁、依赖验证和已批准的原生构建。兼容升级只刷新链接,不复制插件文件。
+Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包变更前分离其拥有的模块补全链接;Host 的共享 profile runner 在启动时补全所需链接。包锁及配置允许的生命周期脚本保留。升级刷新模块链接,不复制插件文件。
 
 包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。
 
-本决策取代以下记录中的 staging 和回滚:[2026-08-25-electron-desktop-packaging-and-updates](2026-08-25-electron-desktop-packaging-and-updates.zh.md), [2026-09-08-desktop-bundled-runtime-and-external-plugins](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md), [2026-09-09-desktop-immediate-window-and-direct-start](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)。其他发布、模块实例和窗口生命周期决策继续有效。
+本记录取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)、[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)及[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的暂存与回滚。Host 启动遵循[薄壳决策](2026-09-10-desktop-web-wrapper.zh.md);发布、模块归属与窗口生命周期仍由各自决策负责。
 
-持久的 `desktop-packages-pending` 标记先于包写入或原生运行时重建,仅在安装、获准构建和验证成功后删除。后续启动发现该标记时,会重新安装锁定的依赖图并重试待执行构建,即使记录的运行时元数据已经匹配。普通未变化的启动复用 profile,不扫描插件依赖图;包修改和运行时校准保留验证。
+Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除、重置和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
 
 ## 考虑过的替代方案
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md
-2026-09-09-profile-resolution-generations.md: a06a527ef0988b0622370c4b6e592c6cc64af275
-2026-09-09-profile-resolution-generations.zh.md: 18e82e13b03ad024eacd8de81956cb6c5a922601
+2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5
+2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f

+ 5 - 4
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md

@@ -16,7 +16,7 @@ Profile startup computes one immutable `ResolutionGeneration` from the same depe
 
 ### One selection algorithm
 
-The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Direct callers can select link, dual, or runtime mode, while an omitted mode selects link. Runtime and dual remain internal migration and verification paths rather than user-facing launcher behavior.
+The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path.
 
 The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before.
 
@@ -74,11 +74,11 @@ Legacy disk state remains available to link-only launches, old processes, and ro
 
 Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route.
 
-The `dsh` launcher selects link mode when its caller omits `resolutionMode`, so supported profile startup keeps its existing filesystem behavior. Tests and low-level embedders select runtime or dual explicitly before any profile row mounts.
+The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly.
 
 Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver.
 
-Packaged-carrier selection, virtual-filesystem adaptation, and Electron ASAR launch behavior are separate decisions layered on this mode-neutral architecture.
+Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links.
 
 ### Performance and verification
 
@@ -106,6 +106,7 @@ Behavior tests compare the runtime generation with the disk materializer over th
 
 - One eager computation supplies the retained disk materializer and runtime generation.
 - Link-only, dual, and runtime-only tests consume the same generation; runtime startup neither writes nor retires module-resolution data.
+- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
 - ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
 - Production metadata lookup does not record Loader import results or wrap Entry, registry, tree, or HMR methods.
 - The Node compatibility matrix runs main-thread resolver specifications across supported loader interfaces; service and bootstrap specifications cover Worker environment-data and installation interfaces without launching a built Worker.
@@ -114,4 +115,4 @@ Behavior tests compare the runtime generation with the disk materializer over th
 
 ## Consequences
 
-Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the launcher default, and dual keeps a migration comparison path. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. Carrier-specific selection and virtual-filesystem launch integration remain separate work.
+Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart.

+ 5 - 4
.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md

@@ -16,7 +16,7 @@ profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一
 
 ### 唯一选包算法
 
-包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。直接调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。runtime 与 dual 仍是内部迁移和验证路径,不改变面向用户的 launcher 行为。
+包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。
 
 安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。
 
@@ -74,11 +74,11 @@ runtime-only 启动流程不创建、更新或退休 symlink 和代理包。reso
 
 link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。
 
-`dsh` launcher 在调用方省略 `resolutionMode` 时选择 link 模式,因此受支持的 profile 启动保留现有文件系统行为。测试与底层嵌入方会在挂载任何 profile 条目前显式选择 runtime 或 dual。
+普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。
 
 runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。
 
-打包载体选择、虚拟文件系统适配和 Electron ASAR 启动行为属于叠加在这套模式无关架构上的独立决策。
+pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接。
 
 ### 性能与验证
 
@@ -106,6 +106,7 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
 
 - 一次 eager 计算同时供应保留的磁盘 materializer 和运行时 generation。
 - link-only、dual 和 runtime-only 测试消费同一个 generation;runtime 启动既不写入也不退休模块解析数据。
+- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
 - ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
 - 生产 package metadata 查询不记录 Loader import 结果,也不包装 Entry、registry、tree 或 HMR 方法。
 - Node 兼容矩阵会在受支持的 loader 接口上运行主线程 resolver 规格;service 和 bootstrap 规格覆盖 Worker environment data 与安装接口,但不会启动构建后的 Worker。
@@ -114,4 +115,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
 
 ## Consequences
 
-runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持 launcher 默认值,dual 保留迁移比较路径。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。载体专用选择和虚拟文件系统启动集成仍属于独立工作。
+runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 的默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.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-10-desktop-web-wrapper.md
+2026-09-10-desktop-web-wrapper.md: 5b8df22e520af752ac1b8fefbf2cf2442ce17eca
+2026-09-10-desktop-web-wrapper.zh.md: 44c79d8c914f0b2bdeb69dbeb09f22ce84efe9c8

+ 45 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -0,0 +1,45 @@
+# Agent Note: Run Desktop through the shared Web application
+
+Status: implemented
+
+English | [中文](2026-09-10-desktop-web-wrapper.zh.md)
+
+The [Electron runtime decision](2026-09-11-desktop-electron-node-runtime.md) supersedes the separate upstream Node executable; other decisions in this note remain applicable.
+
+## Problem
+
+Separate Desktop composition and request transport require their own configuration, module loading, streaming, and asset-serving behavior. Those implementations can omit Web features even when the renderer is shared. Desktop needs its own installation and native controls without maintaining a second application backend.
+
+## Decision
+
+The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads packaged static Web assets before the child is ready. Child IPC carries readiness, structured boot injections, and shutdown. The [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md) owns local-document HTTP forwarding and authenticated WebSocket access; Web retains application dispatch and stream framing.
+
+The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop initializes profiles from the shared Web template's bundles and patch-reload policy and uses Web's automatic directory-picker selection, keeping application defaults under one owner. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
+
+The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled pnpm and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
+
+Independent package ownership prevents CLI and Desktop from modifying each other’s installations; it does not define a stricter Desktop plugin policy. Desktop delegates registry, store, Git, tarball, local-path, and ordinary-package installation to pnpm with normal user and profile configuration. The Host inherits `NODE_OPTIONS`, `NODE_PATH`, and npm/pnpm environment variables. User build configuration determines which dependency lifecycle scripts execute. This replaces Desktop-specific source, environment, and build restrictions with the same package-manager and loader responsibilities used by Web.
+
+App-boot owns installed-dependency discovery, installation-first bundle declaration resolution, and bundle-list updates after pnpm succeeds. CLI selects automatic activation; Desktop explicitly preserves bundles disabled through its UI. The policy difference belongs to the visible activation control, while metadata handling and reconciliation remain shared.
+
+Shared `initProfile` creates missing profile files and preserves existing content. The Host’s `healIsolatedProfileModuleFallback` is the sole owner of installation and bundle projections; package operations use shared `unlinkProfileModuleFallback` to detach only its own links before pnpm. pnpm-managed directories retain priority. Desktop maintains no second runtime-state, lockfile hash, or link reconciliation mechanism. One-time cleanup of `desktop-runtime-state.json` removes only matching recorded links and retires that metadata.
+
+This partially supersedes the private composition and portless transport in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md). That design avoided listening ports and used framed byte pipes to avoid Base64 expansion and cross-version V8 serialization. Shared HTTP gives up the portless guarantee and assigns serving and authentication to the existing Web implementation. Release identity, signing, process ownership, and native shell features remain active decisions.
+
+## Alternatives considered
+
+**Maintain a second backend composition and carrier.** This permits a portless application, but every Web route, reload behavior, authentication change, and stream capability needs a Desktop implementation or explicit omission. Reintroduction requires a desktop product requirement that cannot use the Web implementation and justifies that continuing cost.
+
+**Merge CLI and Desktop plugin installations.** Shared boot code does not require shared executable dependencies. Separate installations allow independently qualified releases and plugin versions while their existing data owners govern shared sessions and settings.
+
+**Keep a Desktop link ledger and manifest reconciler.** These duplicate shared profile mechanisms and can reject otherwise usable installations when derived metadata drifts. A single fallback owner can protect pnpm directories without maintaining release identity in the plugin profile.
+
+**Pin registry and store settings, filter runtime environment, and admit only approved plugin sources.** Those rules constrain execution and package selection, but make the same user configuration behave differently in Desktop and Web. Separate installation ownership remains useful without those restrictions. A Desktop-only restriction requires a distinct product requirement instead of following automatically from packaging or plugin isolation.
+
+## Consequences
+
+Desktop inherits Web features through the same boot and serving path. HTTP listener ownership and authentication remain part of application startup. Electron uses the reported Host address and preserves the existing Web document through readiness. The shared Web loading page is available before the Host starts; independent recovery resources remain available when startup fails.
+
+User-selected runtime options, package sources, and permitted lifecycle scripts can affect Host execution, load third-party code, or cause startup failure. Desktop accepts these effects under the same configuration ownership as Web; the signed core runtime does not attest to user-installed plugin code. Package or loading failures retain explicit repair and the independent recovery UI rather than triggering stricter admission checks or automatic rollback.
+
+Verification requires shared-runner coverage, authenticated HTTP asset and API delivery, configuration reload, native directory selection, child shutdown, and recovery after plugin failure. Installed-platform and real-model GUI qualification remain distinct from unit tests; this note records no measured startup or transfer improvement.

+ 45 - 0
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: 通过共享 Web 应用运行 Desktop
+
+Status: implemented
+
+[English](2026-09-10-desktop-web-wrapper.md) | 中文
+
+[Electron 运行时决策](2026-09-11-desktop-electron-node-runtime.zh.md)替代独立上游 Node 可执行文件的选择;本文其他决策仍然适用。
+
+## Problem
+
+独立的 Desktop 组合与请求传输需要分别维护配置、模块加载、流式响应与资源服务行为。即使共享渲染界面,这些实现也可能遗漏 Web 功能。Desktop 需要独立安装与原生控件,但不需要第二套应用后端。
+
+## Decision
+
+私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 在子进程就绪前加载打包静态 Web 资源。子进程 IPC 承载就绪、结构化启动注入与关闭。[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)规定本地文档 HTTP 转发与认证 WebSocket 访问;Web 保留应用分派与流帧处理。
+
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表和 patch 重载策略初始化 profile,并使用 Web 的自动目录选择机制,让应用默认值由一处维护。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+
+[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
+
+独立包归属防止 CLI 与 Desktop 修改彼此的安装,不代表 Desktop 采用更严格的插件策略。Desktop 将 registry、store、Git、tarball、本地路径及普通包安装交给 pnpm,并遵循正常用户与 profile 配置。Host 继承 `NODE_OPTIONS`、`NODE_PATH` 及 npm/pnpm 环境变量。用户构建配置决定哪些依赖生命周期脚本可以执行。这以 Web 使用的相同包管理器和加载器职责取代 Desktop 专用的来源、环境及构建限制。
+
+App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析及 pnpm 成功后的 bundle 列表更新。CLI 选择自动激活;Desktop 显式保留通过 UI 禁用的 bundle。策略差异属于可见的启用控件,元数据处理与协调逻辑仍然共享。
+
+共享 `initProfile` 创建缺失的 profile 文件并保留现有内容。Host 的 `healIsolatedProfileModuleFallback` 是安装包与 bundle 投影的唯一归属方;包操作在 pnpm 前通过共享 `unlinkProfileModuleFallback` 仅分离它自己拥有的链接。pnpm 管理的目录保持优先。Desktop 不维护第二套运行时状态、锁文件哈希或链接协调机制。`desktop-runtime-state.json` 的一次性清理仅移除与记录匹配的链接,并清除该元数据。
+
+本记录部分取代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中的私有组合与无端口传输。该设计避免监听端口,并使用分帧字节管道避免 Base64 膨胀与跨版本 V8 序列化。共享 HTTP 放弃无端口保证,将服务与认证交给已有 Web 实现。发布身份、签名、进程归属及原生壳功能仍是有效决策。
+
+## Alternatives considered
+
+**维护第二套后端组合与传输。** 这允许应用不监听端口,但每项 Web 路由、重载行为、认证变化和流式能力都需要 Desktop 实现或明确省略。只有无法使用 Web 实现、且足以承担持续维护成本的桌面产品需求,才支持重新引入这种方案。
+
+**合并 CLI 与 Desktop 插件安装。** 共享启动代码不要求共享可执行依赖。独立安装允许分别验收发布与插件版本,共享会话和设置则仍由已有数据归属方负责。
+
+**保留 Desktop 链接账本与 manifest 协调器。** 这些机制重复共享 profile 逻辑,并可能因派生元数据漂移而拒绝原本可用的安装。单一模块补全归属方可以保护 pnpm 目录,无需在插件 profile 中维护发布身份。
+
+**固定 registry 与 store、过滤运行时环境,并仅允许批准的插件来源。** 这些规则限制执行和包选择,却使同一用户配置在 Desktop 与 Web 中产生不同行为。独立安装归属无需这些限制仍然有用。Desktop 专用限制需要独立的产品需求,不能仅由打包或插件隔离推导而来。
+
+## Consequences
+
+Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;独立恢复资源在启动失败时仍可用。
+
+用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
+
+验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.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-10-windows-native-installer-pages.md
+2026-09-10-windows-native-installer-pages.md: 93aa4839620207447ad85b1027a68fcfdc5d3e2c
+2026-09-10-windows-native-installer-pages.zh.md: da28e22f82c6e403696904362bcbe6c8b96881b8

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.md

@@ -0,0 +1,31 @@
+# Agent Note: Native Windows installer pages
+
+Status: implemented
+
+English | [中文](2026-09-10-windows-native-installer-pages.zh.md)
+
+## Problem
+
+The Windows installation interface needs branded light and dark pages without introducing another application runtime or replacing the release mechanisms that extract, register, upgrade, and uninstall Desktop.
+
+## Decision
+
+The installer adds custom welcome, progress, and completion pages through electron-builder's NSIS include. The stock script remains responsible for installation and uninstaller generation. A custom full script bypasses electron-builder's separate uninstaller signing path and is therefore unsuitable for this interface change. The [Desktop release decision](2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity, update distribution, and signing requirements.
+
+NSIS native controls preserve directory editing, folder selection, checkbox state, and keyboard interaction. An x86 Win32/GDI+ helper retains DWM shadows and draws installation progress on the UI thread while the stock installation worker runs. Stock page visibility is suppressed even when NSIS shows the page after MUI's callback. Windows 11 supplies the outer corner radius; Windows 10 retains its supported frame appearance.
+
+Installation is per-user. Welcome-page leave validation reads the current edit control for mouse and keyboard navigation; the debounced inline hint is not an installation authority. Running-process checks match the affected executable path, leaving other installations independent. Completion-page leave honors the launch checkbox for both mouse and keyboard navigation. Electron-builder resolves the registered directory before custom initialization, so silent updates without `/D=` retain that directory. Directory staging and promotion retain the release installer’s rollback behavior; first-launch profile preparation remains outside the installer.
+
+## Alternatives considered
+
+**An Electron installer interface** adds a runtime before the application exists and requires a separate installation bridge. Native controls provide the required interaction within the existing installer.
+
+**Replacing the full NSIS script with the lightweight prototype** also replaces upgrade, registry, uninstaller, and signing behavior. The prototype's transaction mechanism requires separate release qualification and is excluded from the UI integration.
+
+**A window region for rounded corners** disables the DWM frame shadow. The system frame preserves the shadow while accepting the platform's corner radius.
+
+## Consequences
+
+Windows packaging additionally requires the x86 Visual C++ compiler and Windows SDK. The helper is signed before embedding by the same signer as other Windows artifacts. The preparation hook returns true on every platform so electron-builder collects production dependencies. The directory installer owns staging, promotion, registration, and recovery. Its extraction hook invokes the pinned 7-Zip executable with a dedicated progress pipe and a separate diagnostic file. The child inherits only its standard streams and joins a kill-on-close job at creation, so installer termination also stops extraction. Only a zero exit code permits promotion. The helper parses percentages across pipe-read boundaries; it does not implement archive extraction or recursive copying. Preparation, promotion, registration, and cleanup retain bounded estimates. Stage weights express completed work, not remaining time. Displayed progress never regresses, and captions follow the displayed stage. NSIS success authorizes a 600 ms fill animation and a brief 100% frame, with a 750 ms transition target; timers cannot authorize success. The frame stays hidden until branded controls are ready and is initialized once; page transitions preserve its position. Finish hides the window before creating the installed process directly under the current user. An explicitly elevated installer retains shell-mediated launch. Launch failure restores the finish page. Application startup time is independent of installer dismissal.
+
+The native installer regression builds English-only and Chinese-only variants and identifies their language from the visible welcome button; the Windows installation language does not determine test labels. Sequential runs use a unique product identity and private installation directories to verify path rejection, folder selection, launch choices, upgrade, running-process preservation, hidden stock progress, first-show readiness, and uninstall. Registered paths with trailing separators remain upgrade destinations, while drive roots remain invalid. Deterministic native progress tests cover fast and slow extraction, stalls, source resets, fragmented progress tokens, short cleanup, and success between UI ticks. Directory tests exercise extraction through the helper, long paths, new installation, replacement, locked-file recovery, missing staged directories, broken archives, and cancellation. Screenshots and expected behavior belong to Desktop tests rather than recorded Session snapshots. Signed release qualification still requires the configured certificate and token, and actual Windows update artifacts.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-10-windows-native-installer-pages.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 原生 Windows 安装页面
+
+Status: implemented
+
+[English](2026-09-10-windows-native-installer-pages.md) | 中文
+
+## Problem
+
+Windows 安装界面需要符合品牌设计的亮暗页面,同时避免引入额外的应用运行时,也不能替换 Desktop 的解压、注册、升级和卸载发布机制。
+
+## Decision
+
+安装程序通过 electron-builder 的 NSIS include 接入自定义欢迎页、进度页和完成页。原生脚本继续负责安装和卸载程序生成。完整自定义脚本会绕过 electron-builder 单独生成并签名卸载程序的流程,因此不适合这次界面改动。[Desktop 发布决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)继续负责发布身份、更新分发和签名要求。
+
+NSIS 原生控件保留目录编辑、文件夹选择、复选状态和键盘交互。x86 Win32/GDI+ 辅助库保留 DWM 阴影,并在原生安装工作线程运行期间,由界面线程绘制安装进度。即使 NSIS 在 MUI 回调之后重新显示页面,原生页面仍保持隐藏。Windows 11 提供外框圆角半径;Windows 10 保留其支持的窗口外观。
+
+安装面向当前用户。欢迎页离开校验在鼠标和键盘导航时均读取当前编辑框;防抖显示的行内提示不决定实际安装路径。进程检查匹配受影响的可执行文件路径,使其他安装保持独立。完成页离开回调在鼠标和键盘导航时均遵循启动复选框。electron-builder 在自定义初始化之前解析已登记目录,因此不带 `/D=` 的静默更新会保留该目录。目录暂存和替换保留发布安装器的回滚行为;首次启动的配置档案准备仍不属于安装程序。
+
+## Alternatives considered
+
+**Electron 安装界面**会在应用安装前引入运行时,并要求额外的安装通信机制。原生控件能在现有安装程序内提供所需交互。
+
+**使用轻量原型替换完整 NSIS 脚本**还会替换升级、注册表、卸载和签名行为。原型的事务机制需要单独进行发布验证,不纳入界面接入。
+
+**使用窗口区域裁剪圆角**会禁用 DWM 窗口阴影。系统窗口框架保留阴影,同时接受平台提供的圆角半径。
+
+## Consequences
+
+Windows 打包额外要求 x86 Visual C++ 编译器和 Windows SDK。辅助库在嵌入前使用与其他 Windows 产物相同的签名器签名。准备钩子在所有平台返回 true,使 electron-builder 收集生产依赖。目录安装器负责暂存、替换、注册和恢复。其解压钩子通过独立进度管道和单独的诊断文件调用锁定版本的 7-Zip 可执行文件。子进程仅继承标准流句柄,并在创建时加入关闭即终止的 Job,因此终止安装程序也会停止解压。只有退出码为零才允许替换目录。辅助库跨管道读取边界解析百分比,不自行实现压缩包解压或递归复制。准备、替换、注册和清理仍使用有界估算。阶段权重表示已完成工作量,而非剩余时间。显示进度不回退,文案跟随显示阶段。NSIS 成功信号允许执行 600 毫秒补满动画并短暂显示 100%,切换目标时长为 750 毫秒;计时器不能宣告成功。窗口框架在品牌控件准备完成前保持隐藏,且只初始化一次,页面切换保留其位置。点击完成后先隐藏窗口,再以当前用户直接创建已安装应用的进程;显式提权的安装程序保留通过用户桌面启动的方式。启动失败会恢复完成页。应用启动耗时与安装窗口关闭分别处理。
+
+原生安装回归构建仅英文和仅中文的变体,并从可见的欢迎页按钮识别语言;Windows 安装语言不决定测试文案。顺序执行的测试使用独立产品身份和私有安装目录,验证路径拒绝、文件夹选择、启动选项、升级、运行中进程保留、原生进度条隐藏、首次显示时就绪和卸载。末尾带分隔符的已登记路径仍可用于升级,磁盘根目录仍然无效。确定性的原生进度测试覆盖快速和慢速解压、停滞、进度来源重置、分片进度文本、短暂清理,以及两次界面刷新之间成功的情况。目录测试通过辅助库执行解压,覆盖长路径、新装、替换、文件占用恢复、暂存目录缺失、损坏压缩包和取消。截图和预期行为归属 Desktop 测试,不放入录制 Session 快照。签名发布仍需使用已配置的证书、Token 和真实 Windows 更新产物进行验证。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md
+2026-09-11-desktop-electron-node-runtime.md: 6743bbfcc9acffb04d28ef9f1540d516b10dd2fc
+2026-09-11-desktop-electron-node-runtime.zh.md: 73622e66183eb7ed94f654e861cb061e02be7a3d

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md

@@ -0,0 +1,27 @@
+# Agent Note: Use Electron as the Desktop Node runtime
+
+Status: implemented
+
+English | [中文](2026-09-11-desktop-electron-node-runtime.zh.md)
+
+## Problem
+
+Shipping an upstream Node executable alongside Electron duplicates the JavaScript runtime. Desktop needs one runtime for its Host and package scripts without requiring users to install Node.
+
+## Decision
+
+Desktop runs the shared Web Host and bundled pnpm through its own Electron executable with `ELECTRON_RUN_AS_NODE=1`. It ships no separate upstream Node executable. The target Electron distribution supplies both the packaging input and the runtime used to prepare and verify production dependencies; release metadata records its actual Node version. Development uses the installed Electron distribution.
+
+This supersedes the separate-Node choice in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Their independent plugin storage and ordinary resource-directory layout remain applicable. The [Web wrapper](2026-09-10-desktop-web-wrapper.md) retains the shared profile runner and HTTP transport.
+
+## Consequences
+
+Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
+
+Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
+
+Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
+
+## Alternatives considered
+
+A separate Node executable decouples the Host from Electron's runtime but adds another binary, download, signature, and version selection. Electron RunAsNode removes that duplication. Moving production packages into ASAR is a separate change involving native modules, package resolution, and subprocess paths; the Host continues to load ordinary resource files.

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 使用 Electron 作为 Desktop 的 Node 运行时
+
+Status: implemented
+
+[English](2026-09-11-desktop-electron-node-runtime.md) | 中文
+
+## 问题
+
+在 Electron 之外携带上游 Node 可执行文件会重复分发 JavaScript 运行时。Desktop 需要让 Host 和包脚本共用一个运行时,无需用户安装 Node。
+
+## 决策
+
+Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 pnpm,并设置 `ELECTRON_RUN_AS_NODE=1`。应用不携带独立的上游 Node 可执行文件。目标 Electron 分发包同时作为打包输入,以及准备和验证生产依赖的运行时;发布元数据记录其实际 Node 版本。开发模式使用已安装的 Electron 分发包。
+
+此决策替代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的独立 Node 选择。独立插件存储和普通资源目录布局仍然适用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)保留共享 profile runner 和 HTTP 传输。
+
+## 后果
+
+Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
+
+包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
+
+Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
+
+## 考虑过的替代方案
+
+独立 Node 可执行文件可以让 Host 与 Electron 运行时分离,但会增加另一份二进制文件、下载、签名和版本选择。Electron RunAsNode 消除这一重复。将生产包移入 ASAR 是另一项涉及原生模块、包解析和子进程路径的改动;Host 继续加载普通资源文件。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.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-11-windows-directory-installation.md
+2026-09-11-windows-directory-installation.md: 8208c6cc4b28de8d1ab947cb8e6afba7bfa4f673
+2026-09-11-windows-directory-installation.zh.md: d337397e8ea654d14cccd0839460763b66a19457

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.md

@@ -0,0 +1,29 @@
+# Agent Note: Replace Windows application directories after staging
+
+Status: implemented
+
+English | [中文](2026-09-11-windows-directory-installation.zh.md)
+
+## Problem
+
+The Desktop distribution contains thousands of small files. Extracting to the temporary directory, copying every file to the installation, and deleting the temporary tree repeats filesystem work. A 616,701,792-byte, 11,735-file payload took 101.235, 79.203, and 114.391 seconds through those three phases on Windows; copying accounted for 70.296, 56.688, and 85.281 seconds. These component measurements exclude old-version removal, registration, startup, and cache clearing.
+
+## Decision
+
+The [NSIS adapter](../../../../apps/desktop/scripts/windows-directory-installer.mjs) retains electron-builder's installer, signed uninstaller generation, registration, shortcuts, and updater cache. It stages the complete new application beside the destination before stopping the old application. The [directory transaction](../../../../apps/desktop/scripts/installer-directories.nsh) renames the old directory to a unique backup, renames the new directory to the destination, and removes the backup before launch. Both renames stay on the destination volume. This replaces the copy-based installation decision in the [packaging note](2026-08-25-electron-desktop-packaging-and-updates.md).
+
+The installer embeds its pinned 7-Zip command-line tool, signs its private copy for signed releases, and includes its license texts. Windows records the copied runtime inventory after its executable resources have been signed. A nonzero extraction exit leaves the old application intact. Explicit cleanup before upstream installer exits also covers silent cancellation. Same-path upgrades bypass the old uninstaller so it cannot delete the rollback copy or registration prematurely. Different-path and installation-scope migrations retain electron-builder's old-uninstaller behavior; the directory rollback does not undo those uninstall operations.
+
+## Consequences
+
+Directory cleanup, including uninstallation, uses extended-length Windows paths for deeply nested dependencies and backup suffixes. Failed promotion attempts restore the renamed old directory. If another process prevents restoration, the complete backup remains available. Abrupt process termination or power loss can leave staging or backup directories; the installer does not claim crash-atomic replacement across two renames. The transaction changes installation files, not the external Desktop plugin profile or product data.
+
+## Alternatives considered
+
+- **Direct extraction over the running installation.** The existing locked-file probe demonstrates that `Nsis7z::Extract` can retain an old file without setting the NSIS error flag. Staging through a tool with an exit status avoids accepting a mixed installation.
+- **Delete the old version before extraction.** A corrupt archive or failed write would remove the only usable version before replacement is ready.
+- **Put the Host in ASAR.** Native modules, package resolution, and subprocess paths require separate qualification; directory replacement preserves the resource layout.
+
+## Verification
+
+The [native directory smoke](../../../../apps/desktop/scripts/smoke-installer-directories.ps1) calls production macros against private directories. It covers fresh installation, obsolete-file removal on upgrade, locked-directory failure, restoration after the second rename fails, corrupt archives, and cancellation cleanup. Template tests pin staging before shutdown and promotion before registration. The signed 0.1.5-rc.2 installer passed fresh installation, same-path upgrade with obsolete-file removal, locked-file failure preserving the old installation, installation-location migration, and complete uninstallation on Windows. Each installation check compared all 11,736 payload files against the packaged tree and verified executable/uninstaller signatures and registration. The installed runtime passed native dependency, Web frontend, external plugin route, and actual Electron window startup/exit checks. The final installer sample took 38.0 seconds for fresh installation and 27.5 seconds for same-path upgrade, excluding verification and launch; caches were not cleared. These are full-installer observations, separate from the component baseline above. Hosted updater download and upgrades from historical published releases remain release qualification.

+ 29 - 0
.agents/notes/implemented/architecture/2026-09-11-windows-directory-installation.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 暂存完成后替换 Windows 应用目录
+
+Status: implemented
+
+[English](2026-09-11-windows-directory-installation.md) | 中文
+
+## 问题
+
+Desktop 分发包包含数千个小文件。先解压到临时目录,再将每个文件复制到安装位置,最后删除临时树,会重复执行文件系统操作。Windows 上一份 616,701,792 字节、11,735 个文件的载荷经过这三个阶段分别耗时 101.235、79.203 和 114.391 秒,其中复制占 70.296、56.688 和 85.281 秒。这些组件测量不包括旧版卸载、注册、启动,也没有清空缓存。
+
+## 决策
+
+[NSIS 适配器](../../../../apps/desktop/scripts/windows-directory-installer.mjs)保留 electron-builder 的安装器、签名卸载器生成、注册、快捷方式和更新缓存。它在停止旧应用前,将完整新应用暂存到目标目录旁。[目录事务](../../../../apps/desktop/scripts/installer-directories.nsh)把旧目录改名为唯一备份,将新目录改名为正式目标,并在启动前删除备份。两次改名都位于目标卷。这替代了[打包记录](2026-08-25-electron-desktop-packaging-and-updates.zh.md)中基于复制的安装决策。
+
+安装器嵌入固定版本的 7-Zip 命令行工具,为签名发布签署其私有副本,并携带许可证文本。Windows 在复制后的运行时可执行资源签名完成后记录文件清单。解压退出码非零时,旧应用保持完整。上游安装器退出前显式清理目录,也覆盖静默取消。同路径升级跳过旧卸载器,避免其提前删除回滚副本或注册信息。不同路径和安装范围迁移保留 electron-builder 的旧卸载器行为;目录回滚不会撤销这些卸载操作。
+
+## 影响
+
+目录清理(包括卸载)使用 Windows 扩展长度路径,覆盖深层依赖与备份后缀。新目录替换失败时,安装器尝试恢复已改名的旧目录。如果另一个进程阻止恢复,完整备份仍保留。进程被强制结束或断电可能留下暂存或备份目录;安装器不承诺跨两次改名的崩溃原子性。事务只改变安装文件,不改变外部 Desktop 插件 profile 或产品数据。
+
+## 考虑过的替代方案
+
+- **直接覆盖运行中的安装目录。** 现有文件占用探针证明,`Nsis7z::Extract` 可能保留旧文件而不设置 NSIS 错误标志。使用具有退出状态的工具进行暂存,可以避免接受混合版本安装。
+- **解压前删除旧版本。** 压缩包损坏或写入失败时,会在替换就绪前移除唯一可用版本。
+- **把 Host 放入 ASAR。** 原生模块、包解析和子进程路径需要单独验证;目录替换保留资源布局。
+
+## 验证
+
+[原生目录 smoke](../../../../apps/desktop/scripts/smoke-installer-directories.ps1)在私有目录中调用生产宏。它覆盖首次安装、升级时删除过时文件、目录占用失败、第二次改名失败后的恢复,损坏归档,以及取消清理。模板测试固定暂存早于关闭应用、正式替换早于注册。签名的 0.1.5-rc.2 安装器在 Windows 上通过了首次安装、同路径升级并移除过时文件、文件占用失败后保留旧安装、变更安装位置及完整卸载。每次安装检查都将全部 11,736 个载荷文件与打包目录比对,并验证程序、卸载器签名及注册信息。已安装运行时通过了原生依赖、Web 前端、外部插件路由及实际 Electron 窗口启动和退出检查。最终安装器的一次样本中,首次安装耗时 38.0 秒,同路径升级耗时 27.5 秒,不包含校验和启动,未清空缓存。这些完整安装器观测与上面的组件基线分别记录。线上更新下载和从历史已发布版本升级仍属于发布验收。

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
-2026-09-14-current-profile-plugin-management.md: 646b9f09cf1938524c487bb7f40afb0391667f08
-2026-09-14-current-profile-plugin-management.zh.md: 2671ba1ec0cdd74ead54ff8d25ca9ade14fbf5bb
+2026-09-14-current-profile-plugin-management.md: 54654a81e5e9a36a99b7d3013e2c1d622f575391
+2026-09-14-current-profile-plugin-management.zh.md: 58659ef2b1e6b73991b913c19aaf07032db6d78e

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

@@ -10,17 +10,21 @@ Web and agent controls need to change a running profile without creating an inde
 
 ## Decision
 
-[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles, reload policy and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and service mutations through one queue. The launcher registers the `hmr/before-reload` file-lock wrapper before plugin startup. Package mutations take that same file lock inside `hmr.runExclusive()`. Each generation re-reads the manifest, bundle layers and user patches while retaining invocation overlay precedence.
+[Plugin Manager](../../../../packages/boot/plugin-manager/README.md) and `dsh plugin` call the same asynchronous package operations. The launcher supplies `ctx.profileContext` as data: profile and resolution locations, startup bundles and invocation overlays. Shared functions compose the current files; this interface contains no callbacks or mutation methods. CLI and service mutations hold the profile manifest's writer lock; [DSH HMR](../../../../packages/boot/hmr/README.md) serializes module replacement, Include refresh, profile recomposition and 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.
 
-Profile files remain the persisted state: entry toggles edit only `disabled` in the YAML document, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
+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.
 
-This extends the [profile bundle composition decision](2026-08-05-profile-plugin-bundles.md). Startup profiles keep their process composition, and Desktop package management remains shell-owned. Web controls and explicitly enabled agent tools call the same service, whose batched durable notices inform live Agents without waking them. The agent tool is disabled by default in the base bundle and shipped presets. The browser-only worker preview has no host package installer; its module-proxy table refuses `execa` calls explicitly while retaining the management module for inventory discovery.
+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; a failed removal preserves the actual partial state and a diagnostic path, while a failed or cancelled installation restores the profile files it snapshotted.
+
+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.
 
-**A second desired-state database or automatic rollback.** These require synchronizing package-manager side effects with another state store. Profile files remain inspectable and repairable; partial loading failures are reported rather than concealed by an incomplete rollback. For installations this was later reversed: a failed or cancelled installation restores the manifest and lockfile ([guided plugin installation](2026-09-15-guided-plugin-installation.md)).
+**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest, so existing dependencies and successful installations whose activation fails remain in place. A failed or cancelled installation restores only the manifest and lockfile text snapshotted before pnpm ran ([guided plugin installation](2026-09-15-guided-plugin-installation.md)); downloaded files stay until the next package operation prunes them.
 
 **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.
 

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

@@ -10,17 +10,21 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 ## 决策
 
-[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包、重载策略和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与 service 修改。launcher 在插件启动前注册 `hmr/before-reload` 文件锁包装。包修改在 `hmr.runExclusive()` 内获取同一文件锁。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
+[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md)与 `dsh plugin` 调用同一套异步包操作。launcher 通过纯数据 `ctx.profileContext` 提供 profile 与解析位置、启动时组合包和调用级 overlay。共享函数组合当前文件;该接口不包含回调或修改方法。CLI 与 service 修改持有 profile manifest 的写锁;[DSH HMR](../../../../packages/boot/hmr/README.zh.md) 通过同一队列串行执行模块替换、Include 刷新、profile 重新组合与管理器配置变更。HMR 在自身初始化时注册 profile 监听,等待应用就绪后再处理编辑。manifest 通知只比较有序组合包列表;仅依赖字段变化不会触发配置重载。最终 YAML 组合决定是否运行 HMR,启动器不安装回退实例。pnpm 在 `hmr.runExclusive()` 外执行;只有配置变更和 Loader 更新进入该队列。HMR 不获取包操作写锁,因此安装不会阻塞其他由文件变化触发的配置更新。每次重载重新读取 manifest、组合包层与用户 patch,同时保留调用级 overlay 的优先级。
 
-profile 文件保持为持久状态:条目开关只修改 YAML 文档中的 `disabled`,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
+配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。
+
+profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败的删除保留实际的部分状态与诊断路径,失败或被取消的安装则恢复它快照的 profile 文件。
 
 这扩展了[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 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。
 
-**第二份目标状态数据库或自动回滚。** 这些方案需要将包管理器副作用与另一份状态同步。profile 文件保持可检查、可修复;加载的部分失败直接报告,不用不完整的回滚掩盖。安装这一路径后来被反转:失败或被取消的安装会恢复 manifest 与 lockfile([引导式插件安装](2026-09-15-guided-plugin-installation.zh.md))。
+**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用,因此已有依赖及安装成功但激活失败的包保留原处。失败或被取消的安装只恢复 pnpm 运行前快照的 manifest 与 lockfile 文本([引导式插件安装](2026-09-15-guided-plugin-installation.zh.md));已下载文件保留到下一次包操作清理为止。
 
 **包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.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-sidebar-layout-provider-recovery.md
+2026-09-14-sidebar-layout-provider-recovery.md: f7ec0324f9205a556750e0af54eb856002a4ea74
+2026-09-14-sidebar-layout-provider-recovery.zh.md: 02440391e5601e3ba125c33c4c8971d4d46cbaa8

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.md

@@ -0,0 +1,35 @@
+# Agent Note: Sidebar layout persistence and provider recovery
+
+Status: implemented
+
+English | [中文](2026-09-14-sidebar-layout-provider-recovery.zh.md)
+
+## Problem
+
+Reloading the browser loses open files, pane placement and selection. Recovering retained terminals by opening new tabs cannot reproduce that arrangement and can reopen a collapsed sidebar.
+
+## Decision
+
+The sidebar persists a validated current-layout snapshot and identity counter per Session, while the Client store keeps undo history in memory. Invalid saved types or layout references clear only that Session snapshot. Undo history resets on reload so persisted data does not grow with past interactions. It restores layout and tab identities before rendering bodies, including resource pins. Providers restore their own content; the sidebar has no terminal-specific state or reconnection logic. Transient navigation parameters and resource contents remain outside the layout snapshot.
+
+The terminal provider reads the adopted Session's tab records and restores terminal models before querying Host terminals that have no view. Its Client controller saves globally unique content-to-terminal identities as independent records before allocation, reuses them after reload, and removes them after recording explicit close intent. Layout-local tab ids identify separate live views but cannot identify persisted processes across windows; per-record writes and deletes avoid overwriting unrelated bindings. A restored identity never authorizes creating a replacement process. The Host owns process liveness, titles and screen contents; the saved association is only a recovery target. A saved association without a restored tab does not suppress Host discovery.
+
+OpenCode's `packages/app/src/context/layout.tsx` saves Session tabs, while `context/terminal.tsx` separately saves terminal identities. DSH uses its existing Session-scoped store and provider services for the same separation, retaining Session ownership for terminals.
+
+This partially supersedes the persistence exclusion in the [Web terminal decision](../feature/2026-09-09-web-sidebar-terminal.md). That note remains active because process ownership, cleanup, streaming and input-control decisions still apply.
+
+## Alternatives considered
+
+**Recover every retained terminal as a new tab.** Host discovery cannot recover tab placement, selection or a collapsed layout and duplicates restored tabs.
+
+**Put terminal recovery into the sidebar.** That would make the layout owner depend on a specific content provider. A generic read of committed tab records lets each provider restore its own state.
+
+**Persist process metadata and terminal output.** These values become stale independently of browser layout. Reattaching to the Host supplies current metadata and a consistent screen.
+
+## Consequences
+
+Layout survives reload within the same browser origin, with independent Session storage keys. Windows share the last saved layout for each Session; active windows retain their own layout until reload. Storage failure leaves memory state usable. Browser reload does not restart missing or exited processes; Host restart still cannot restore a shell. Provider state requires provider-owned recovery support, and transient file navigation parameters are not restored.
+
+Store tests cover layout round trips, bounded persisted state, invalid records, Session isolation and resource adoption. Terminal tests cover identity reuse, missing targets, inactive closes and discovery without a restored tab. Assembled-browser tests exercise file preview, split panes, selection, fullscreen and collapsed reload with real terminal process identities.
+
+The [two-hour unattended-terminal reclamation](../feature/2026-09-14-unattended-browser-terminal-reclamation.md) extends provider recovery with window-held terminal lifetimes while retaining this layout/content ownership split.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 侧栏布局持久化与 provider 恢复
+
+Status: implemented
+
+[English](2026-09-14-sidebar-layout-provider-recovery.md) | 中文
+
+## Problem
+
+刷新浏览器会丢失已打开文件、分栏位置和选中项。把保留的终端重新打开为新标签无法还原布局,还可能展开原本折叠的侧栏。
+
+## Decision
+
+侧栏按 Session 持久化经过校验的当前布局快照和身份计数,Client store 将撤销历史保留在内存中。保存的类型或布局引用无效时,只清理对应 Session 的快照。刷新后撤销历史清空,因此持久化数据不会随过去的操作持续增长。它在渲染正文前恢复布局和标签身份,并建立资源 pin。provider 恢复自身内容;侧栏不保存终端专属状态,也不负责重连。临时导航参数和资源内容不进入布局快照。
+
+terminal provider 读取已采用的 Session 标签记录,先恢复终端模型,再查询尚无视图的 Host 终端。其 Client controller 在分配前将全局唯一内容与终端身份的关联保存为独立记录,刷新后复用,并在记录显式关闭意图后删除。布局内的 tab id 标识独立活动视图,不能跨窗口标识持久化进程;逐条记录的写入和删除避免覆盖无关关联。恢复身份不允许创建替代进程。Host 负责进程存活状态、标题和屏幕内容;保存的关联只是恢复目标。没有对应恢复标签的关联不会阻止 Host 探测。
+
+OpenCode 的 `packages/app/src/context/layout.tsx` 保存 Session 标签,`context/terminal.tsx` 独立保存终端身份。DSH 使用现有 Session 作用域 store 和 provider 服务实现相同的职责划分,终端仍归 Session 所有。
+
+本决策部分替代 [Web 终端决策](../feature/2026-09-09-web-sidebar-terminal.zh.md)中不持久化的限定。原记录继续保留,因为进程归属、清理、流传输和输入控制决策仍适用。
+
+## Alternatives considered
+
+**把每个保留终端恢复为新标签。** Host 探测无法还原标签位置、选中项或折叠布局,还会重复创建已恢复的标签。
+
+**让侧栏负责终端恢复。** 布局所有者会因此依赖特定内容 provider。提供通用的已提交标签查询即可让各 provider 恢复自身状态。
+
+**持久化进程元数据和终端输出。** 这些值会独立于浏览器布局失效。重新连接 Host 能取得当前元数据和一致的屏幕。
+
+## Consequences
+
+同一浏览器站点内刷新会保留布局,各 Session 使用独立存储键。窗口共享每个 Session 最后保存的布局;活动窗口在刷新前保留各自布局。存储失败时内存状态仍可用。刷新不会重启缺失或已退出的进程;Host 重启仍无法恢复 shell。provider 状态需要自身支持恢复,临时文件导航参数不恢复。
+
+store 测试覆盖布局读写、有界持久化状态、无效记录、Session 隔离和资源采用。终端测试覆盖身份复用、目标缺失、未激活标签关闭,以及没有恢复标签时的探测。完整浏览器测试使用真实终端进程身份,验证文件预览、分栏、选中项、全屏和折叠状态下的刷新。
+
+[无人连接终端的两小时回收](../feature/2026-09-14-unattended-browser-terminal-reclamation.zh.md)为 provider 恢复增加窗口持有的终端生命周期,保留此处的布局/内容职责划分。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.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-14-transcript-width-handle-layering.md
+2026-09-14-transcript-width-handle-layering.md: 01d70e646e874d8266633597f1b510ad73fc2287
+2026-09-14-transcript-width-handle-layering.zh.md: c6e03ec5775256f5aac87d2f7c1ed66a12cf0c2a

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md

@@ -0,0 +1,33 @@
+# Agent Note: Transcript width handles stay behind chat content
+
+Status: implemented
+
+English | [中文](2026-09-14-transcript-width-handle-layering.zh.md)
+
+## Problem
+
+The Conversation shell renders each content-width handle as a full-height absolute strip beside the reading column. A high stacking level placed that strip above wide Markdown tables that legitimately overflow the column. The handle glow covered their content and its pointer target could replace their interaction target.
+
+## Decision
+
+The Conversation shell keeps width handles at level zero without creating a body-wide stacking context. Chat raises concrete table elements to level one, while the reading column, column-bounded tool cards, and table breakout wrapper create no new stacking level. A table therefore owns the gutter only where its painted box actually reaches it; a fitting four-column table leaves the adjacent gutter draggable. Fixed message tooltips remain outside the table stacking context and can still paint over the sticky composer.
+
+This rule relies on the supported Chromium engine not turning ChatView's inline-size query container into an intermediate stacking context. The same engine behavior already lets the back-to-bottom control outrank the sticky composer. The Chromium hit-test scenario pins that assumption; raising the whole Chat root would instead make its transparent full-width box reclaim the gutter.
+
+A width handle starts resizing only for the primary pointer button. Ordinary wheel input over the handle is normalized from pixel, computed line-height, or page units and forwarded to the direct sibling transcript scrollport, so hovering the gutter does not suspend reading. Ctrl+wheel is not forwarded because it represents browser zoom or a trackpad pinch gesture.
+
+Each side's hit strip is at most 10px wide. Its hover indicator uses the lower-contrast resting scrollbar tint and is a 2px line with a 16px solid center and 28px fades, for a 72px total visible length.
+
+Composer chrome retains its higher layer, so its full sticky footer band intentionally does not start a width resize. A handle whose pointer capture began above that band temporarily rises to level eight, keeping the indicator visible until release. A full-view composer takeover continues to hide the handles entirely.
+
+## Alternatives considered
+
+**Move the handles farther from the reading column.** Wide tables can use the available transcript width, so a fixed larger inset would only reduce the overlap for some window sizes and would make the handles harder to reach.
+
+**Disable the handles whenever a transcript contains wide content.** One wide row would remove resizing from the entire Session, including empty gutter beside unrelated rows.
+
+**Inspect the elements under the pointer in JavaScript.** Dynamic hit testing would duplicate browser stacking and pointer dispatch rules and could still disagree with new plugin renderers.
+
+## Consequences
+
+Visible Chat content owns pointer input only where its painted element extends into a width-handle strip, while bare gutter keeps a quieter resize affordance, primary-button drag, and transcript wheel scrolling. Unit tests pin the declared handle geometry, pointer-button behavior, direct scrollport lookup, zoom exclusion, and every DOM delta mode. Chromium `elementFromPoint` coverage proves an overflowing table wins the hit while a fitting `md-table-wide` wrapper does not, and a message-action test proves fixed tooltips still paint above the sticky composer.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 正文宽度拖拽条位于 Chat 内容下方
+
+Status: implemented
+
+[English](2026-09-14-transcript-width-handle-layering.md) | 中文
+
+## 问题
+
+Conversation shell 将每个正文宽度拖拽条渲染为阅读列旁的全高绝对定位区域。较高的堆叠层级使该区域盖在会合理溢出阅读列的宽 Markdown 表格之上。拖拽条的高亮会遮住表格内容,其指针目标也可能取代表格自身的交互目标。
+
+## 决策
+
+Conversation shell 将宽度拖拽条保持在第零层,不创建覆盖整个 body 的堆叠上下文。Chat 只将具体的表格元素提到第一层,阅读列、限定在列内的工具卡片与表格 breakout 包装层都不创建新的堆叠层级。因此只有表格的可见盒子实际延伸进沟槽时才会占有指针;能容纳在正文中的四列表格不会阻断旁边的沟槽拖拽。消息的固定定位 Tooltip 保持在表格堆叠上下文之外,仍可绘制在粘滞 composer 上方。
+
+该规则依赖交付的 Chromium 引擎不会把 ChatView 的 inline-size query container 变成中间堆叠上下文。back-to-bottom 控件能高于粘滞 composer 也已依赖相同的引擎行为。Chromium 命中测试固定这一假设;如果改为提高整个 Chat root,其透明全宽盒子反而会重新占用沟槽。
+
+宽度拖拽条仅使用主指针按键开始调整。拖拽条上的普通滚轮输入会从像素、计算后的行高或页面单位规整为像素,再转发给直接相邻的 transcript scrollport,因此指针悬停在沟槽时不会中断阅读滚动。Ctrl+滚轮表示浏览器缩放或触控板捏合手势,不会被转发。
+
+每侧的命中区域最宽为 10px。悬停指示线使用对比度更低的静止滚动条颜色,线宽 2px,中心实色段长 16px,两端各渐隐 28px,可见总长度为 72px。
+
+Composer chrome 保留更高层级,因此完整的粘滞底部区带刻意不会开始宽度调整。如果拖拽条在进入该区带之前已获得指针捕获,它会临时提到第八层,使指示线在松开前保持可见。占据完整 View 的 composer takeover 仍会完全隐藏拖拽条。
+
+## 考虑过的替代方案
+
+**将拖拽条移到离阅读列更远的位置。** 宽表格可以使用 transcript 的可用宽度,因此固定增加间距只能减少部分窗口尺寸下的重叠,还会让拖拽条更难触达。
+
+**只要 transcript 包含宽内容就禁用拖拽条。** 一行宽内容会让整个 Session 都无法调整宽度,包括与其他行相邻的空白沟槽。
+
+**在 JavaScript 中检查指针下方的元素。** 动态命中测试会重复浏览器的堆叠与指针分发规则,并且仍可能与新的插件 renderer 不一致。
+
+## 后果
+
+可见 Chat 内容只在其可见元素实际延伸进宽度拖拽区域时拥有指针输入,裸露沟槽则保留更轻的宽度调整提示、主按键拖拽和 transcript 滚轮滚动。单元测试固定声明的拖拽条尺寸、指针按键行为、直接 scrollport 查找、缩放排除和每种 DOM delta mode。Chromium `elementFromPoint` 覆盖证明溢出表格获得指针命中,而可容纳的 `md-table-wide` 包装层不会;消息操作测试证明固定定位 Tooltip 仍绘制在粘滞 composer 上方。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.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-14-windows-lock-release-probe.md
+2026-09-14-windows-lock-release-probe.md: 76a21f655fc8c1b299e3507beffabfacde3ac0d9
+2026-09-14-windows-lock-release-probe.zh.md: 292203d3931225f0000d56cd5cb5d700ecb0bcc7

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.md

@@ -0,0 +1,21 @@
+# Agent Note: Windows lock release before contention probing
+
+Status: implemented
+
+English | [中文](2026-09-14-windows-lock-release-probe.zh.md)
+
+## Problem
+
+Windows exclusive file creation can report `EPERM` while another writer owns the lock. The holder can remove the lock before the contender's `lstat`, so absence at that later observation does not prove the create failed for a persistent permission restriction. The profile module-fallback contention test exposed this race.
+
+## Decision
+
+`withFileLock` permits one unconfirmed `EPERM` retry per Windows acquisition, using the existing backoff and deadline. Confirmed contention still waits normally. A second unconfirmed permission failure is rethrown, and the protected operation runs only after exclusive creation succeeds.
+
+## Alternatives considered
+
+Retrying every permission error until timeout would obscure persistent permission failures. Requiring the lock to exist during the later probe rejects a valid release race. Delaying the test's holder release changes timing without repairing the writer protocol.
+
+## Consequences
+
+A persistent Windows permission failure can incur one backoff before failing. POSIX behavior is unchanged. The atomic-write tests force release before the probe and verify both successful acquisition and persistent permission rejection; the profile tests exercise real competing writers. Existing credential-registration notes retain ownership of write serialization and are not superseded by this acquisition rule.

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-14-windows-lock-release-probe.zh.md

@@ -0,0 +1,21 @@
+# Agent Note: Windows 锁在竞争探测前释放
+
+Status: implemented
+
+[English](2026-09-14-windows-lock-release-probe.md) | 中文
+
+## 问题
+
+Windows 独占创建文件时,若另一写入方持有锁,可能报告 `EPERM`。持锁方可能在竞争者执行 `lstat` 前删除锁,因此之后观察到锁不存在,并不能证明创建失败源于持续的权限限制。profile 模块回退的竞争测试暴露了这一竞态。
+
+## 决策
+
+`withFileLock` 在每次 Windows 获取锁的过程中允许对无法确认锁存在的 `EPERM` 重试一次,沿用现有退避与截止时间。确认存在的竞争仍正常等待。第二次无法确认锁存在的权限失败会重新抛出,且只有独占创建成功后才会运行受保护操作。
+
+## 考虑过的替代方案
+
+对所有权限错误一直重试到超时会掩盖持续的权限失败。要求锁在之后探测时仍存在会拒绝合法的释放竞态。延迟测试中持锁方的释放只改变时序,无法修复写入协议。
+
+## 后果
+
+Windows 持续的权限失败可能在一次退避后才报告。POSIX 行为不变。atomic-write 测试强制在探测前释放锁,并验证成功获取锁和持续权限失败;profile 测试覆盖真实的竞争写入方。现有凭据注册笔记仍负责写入串行化决策,不会被此获取锁规则取代。

+ 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/feature/2026-08-03-web-sticky-collapsible-headers.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/feature/2026-08-03-web-sticky-collapsible-headers.md
+2026-08-03-web-sticky-collapsible-headers.md: 6e8b4b94c9c6aa40bc7190e6705b84caa8189c52
+2026-08-03-web-sticky-collapsible-headers.zh.md: d4e1f69539db6870ae60d5c4b05cd52c7d129360

+ 41 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md

@@ -0,0 +1,41 @@
+# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling
+
+Status: implemented
+
+English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md)
+
+## Problem
+
+Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle.
+
+## Decision
+
+The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row.
+
+Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header.
+
+The pinned header's stacking rank differs by block, because their bodies differ. The Think body is plain text with no sticky descendant, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`).
+
+The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state.
+
+No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components.
+
+## Alternatives considered
+
+**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable.
+
+**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency.
+
+**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling.
+
+**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block.
+
+## Consequences
+
+The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets.
+
+## Testing
+
+The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration.
+
+The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls.

+ 41 - 0
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md

@@ -0,0 +1,41 @@
+# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住
+
+Status: implemented
+
+[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文
+
+## 问题
+
+会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。
+
+## 决策
+
+每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。
+
+这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。
+
+钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。
+
+规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。
+
+不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。
+
+## 曾考虑的替代方案
+
+**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。
+
+**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。
+
+**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。
+
+**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。
+
+## 后果
+
+很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。
+
+## 测试
+
+单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、直角圆角、两侧各自的 `z-index` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。
+
+真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.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/feature/2026-09-04-right-sidebar-docking-infrastructure.md
-2026-09-04-right-sidebar-docking-infrastructure.md: 936f73c9210baf4297eace2deb270bb9594d7e8d
-2026-09-04-right-sidebar-docking-infrastructure.zh.md: d234dbcbb2f60ba1dd3b3d9fca2936a16521751d
+2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02
+2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md

@@ -39,7 +39,7 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes;
 
 [Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes.
 
-`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
+`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. [Layout persistence and provider recovery](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) owns Session-scoped browser storage and reload. Layout is presentation state and never enters the session log.
 
 ### Beyond the surface
 
@@ -76,7 +76,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
 ## Consequences
 
 - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split.
-- Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls.
+- Layout is undoable, persisted and per Session. Undo is reachable only through `@internal` service methods; the product shows no history controls.
 - An expanded surface has no empty panes. Empty side panes merge away; an empty root receives the current default page only while expanded. New sessions and a collapsed surface whose last tab closed remain empty until expansion.
 - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide.
 - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement.

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md

@@ -39,7 +39,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 [默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。
 
-`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
+`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。[布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责 Session 作用域的浏览器存储和刷新。布局是呈现状态,永不进入会话日志。
 
 ### 面之外
 
@@ -76,7 +76,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 ## Consequences
 
 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。
-- 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。
+- 布局可撤销、可持久化且按 Session 隔离。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。
 - 展开的布局不保留空 pane。空侧 pane 被合并,空根 pane 只在展开时填入当前默认页。新会话以及关闭最后一个 tab 后收起的布局保持为空,直到下次展开。
 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。
 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: 9b0c1916872c04611f94bf00f7057cce99671c68
-2026-09-09-web-sidebar-terminal.zh.md: 5bc25f80cf6d33e07d2cec1b48e1f4ca7f7d750c
+2026-09-09-web-sidebar-terminal.md: 2649610e776029b10b11fc4ea87d75a24a58d32b
+2026-09-09-web-sidebar-terminal.zh.md: 9a3fd828ff52142cb945ff14d84d13c9e8522ecf

+ 6 - 4
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -18,9 +18,9 @@ The application theme supplies terminal default colors. The body reads resolved
 
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 
-Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry.
+[Sidebar layout persistence and provider recovery](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) owns browser reload: layout and tab identities are restored before the terminal provider reconnects its views. Listing still takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Only a new view may allocate a process; a recovered target that disappears reports an error. The Host remains authoritative for process state, titles and screen contents.
 
-The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments.
+The caller retains a terminal ID before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments.
 
 The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full.
 
@@ -30,7 +30,7 @@ The latest attachment controls input and dimensions; other attachments remain re
 
 **Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry.
 
-**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload.
+**Persist an authoritative active-process registry.** Browser process state can become stale independently of Host state. The [layout persistence decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) supersedes the exclusion of saved tab associations while retaining Host authority for process liveness.
 
 **Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead.
 
@@ -44,6 +44,8 @@ The latest attachment controls input and dimensions; other attachments remain re
 
 ## Consequences
 
-A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
+A kept-open terminal retains a process and bounded screen memory. Reload restores the sidebar layout and reconnects Host-retained terminals; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
 
 The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.
+
+The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-terminal-reclamation.md) adds an idle grace period after all frontend holders disconnect, preserving busy or uncertain work.

+ 6 - 4
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -18,9 +18,9 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 
-侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。
+[侧栏布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责浏览器刷新:先恢复布局和标签身份,再由 terminal provider 重连视图。列表仍直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。只有新视图可以分配进程,恢复目标消失时显示错误。进程状态、标题和屏幕内容仍以 Host 为准。
 
-调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。
+调用者在创建之前保留终端 ID。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。
 
 最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。
 
@@ -30,7 +30,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 **进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
 
-**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。
+**持久化作为权威的活跃进程注册表。** 浏览器进程状态可能独立于 Host 状态失效。[布局持久化决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)取代了不保存标签关联的限定,同时保留 Host 对进程存活状态的决定权。
 
 **共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。
 
@@ -44,6 +44,8 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 ## 影响
 
-保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
+保留终端会保留进程和有界屏幕内存。刷新恢复侧栏布局并重连 Host 保留的终端;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
 
 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。
+
+[无人连接终端的两小时回收](2026-09-14-unattended-browser-terminal-reclamation.zh.md)在全部前端持有者断开后设置空闲宽限期,保留忙碌或状态不确定的工作。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.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/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
+2026-09-13-macos-hidden-titlebar-vibrancy.md: b6b2f249a1843f937cf73d4eb193d47e1b9e8ad5
+2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 8d4a4d17bda2e8c7845b66443cc883c6177b7343

+ 49 - 0
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md

@@ -0,0 +1,49 @@
+# Agent Note: macOS hidden titlebar with vibrancy sidebar
+
+Status: implemented
+
+English | [中文](2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)
+
+## Problem
+
+The desktop app drew the stock macOS titlebar: an opaque bar above the web UI that repeats chrome the page already has, spends vertical space, and keeps the sidebar from reaching the window's top edge. The window looked like a browser tab rather than a macOS application, and no mechanism existed for platform-specific presentation — every pixel was identical on macOS, Windows, and the plain web.
+
+## Decision
+
+The Electron main process opens the main window on darwin with `titleBarStyle: 'hiddenInset'`, `trafficLightPosition: { x: 16, y: 18 }`, `vibrancy: 'sidebar'`, `visualEffectState: 'active'`, and a transparent `backgroundColor`. `'active'` keeps the material stable behind an unfocused window; `'followWindow'` washed the sidebar out on blur.
+
+Every web-side adjustment keys off `html[data-platform]`, which only the desktop preloads set (`document.documentElement.dataset.platform = process.platform`). The plain web and non-darwin desktop render exactly as before.
+
+**Transparency chain.** Vibrancy shows only through transparent pixels: on darwin `html`/`body` (ui-web base.css) and the AppFrame are transparent, the center column paints `--dsw-alias-bg-base` opaque, and the sidebar column paints a translucent `color-mix` tint of the sidebar fill so the material reads through it. SidebarRoot's own opaque fill moves to the frame column for the same reason.
+
+**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
+
+**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen.
+
+**Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs.
+
+**Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth.
+
+## Alternatives considered
+
+**`titleBarStyle: 'hidden'` with custom window controls.** Rebuilding the traffic lights forfeits native behavior (hover glyphs, fullscreen transitions) for no gain; `hiddenInset` keeps them native and only asks the page to route around them.
+
+**Keeping the 56px rail on darwin.** The rail under floating traffic lights doubled the chrome in the window's corner and wasted the width the collapse exists to reclaim; full hide with header-hosted reopen controls matches macOS sidebar conventions.
+
+**Mirroring the resolved theme instead of the preference.** Forwarding `light`/`dark` while the user preference is `system` would freeze the window material at the value resolved at send time; forwarding `system` lets macOS keep following the OS appearance natively.
+
+**Handling traffic-light clearance inside ui-dockkit.** The kit is host-agnostic and cannot know which host corner touches window chrome; publishing an inset variable keeps the policy in the host that owns the placement (ui-sidebar-right) and costs the kit one custom property.
+
+**A collapse-state prop pipe into the header controls.** The AppFrame already publishes `data-sidebar-collapsed`; CSS visibility against it avoids a second state path that could disagree with the frame's transition timeline.
+
+## Consequences
+
+- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
+- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
+- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches.
+- Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable.
+- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
+
+## Testing
+
+ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet.

+ 49 - 0
.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md

@@ -0,0 +1,49 @@
+# Agent Note: macOS hidden titlebar with vibrancy sidebar
+
+Status: implemented
+
+[English](2026-09-13-macos-hidden-titlebar-vibrancy.md) | 中文
+
+## Problem
+
+桌面应用此前使用 macOS 原生标题栏:Web UI 上方一条不透明的横条,重复页面已有的窗口装饰、占用纵向空间,并让侧边栏无法触及窗口顶边。窗口看起来像浏览器标签页而非 macOS 应用,且不存在任何按平台差异化呈现的机制——macOS、Windows 与纯 Web 上每个像素都完全一致。
+
+## Decision
+
+Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLightPosition: { x: 16, y: 18 }`、`vibrancy: 'sidebar'`、`visualEffectState: 'active'` 与透明 `backgroundColor` 打开主窗口。`'active'` 让窗口失焦时材质保持稳定;`'followWindow'` 会在失焦时把侧边栏冲淡。
+
+所有 Web 侧调整均以 `html[data-platform]` 为开关,该属性仅由桌面 preload 设置(`document.documentElement.dataset.platform = process.platform`)。纯 Web 与非 darwin 桌面的渲染与之前完全一致。
+
+**透明链。** 毛玻璃只透过透明像素显现:darwin 上 `html`/`body`(ui-web base.css)与 AppFrame 透明,中间列铺不透明的 `--dsw-alias-bg-base`,侧边栏列铺侧边栏底色的半透明 `color-mix`,让材质透出。SidebarRoot 自身的不透明底色出于同一原因移到框架列。
+
+**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
+
+**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。
+
+**拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。
+
+**全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。
+
+## Alternatives considered
+
+**`titleBarStyle: 'hidden'` + 自绘窗口控件。** 重造红绿灯会失去原生行为(悬停图形、全屏过渡)且无收益;`hiddenInset` 保留原生控件,只要求页面绕行。
+
+**darwin 上保留 56px rail。** 浮动红绿灯下的 rail 让窗口角落装饰翻倍,也浪费了收起本要回收的宽度;完全隐藏 + 头部承载重开控件符合 macOS 侧边栏惯例。
+
+**同步解析后的主题而非偏好。** 偏好为 `system` 时转发 `light`/`dark` 会把窗口材质冻结在发送时刻的解析值;转发 `system` 让 macOS 原生持续跟随系统外观。
+
+**在 ui-dockkit 内部处理红绿灯避让。** kit 与宿主无关,不可能知道哪个宿主角落贴着窗口装饰;发布内边距变量把策略留在拥有布局位置的宿主(ui-sidebar-right),kit 只付出一个自定义属性。
+
+**向头部控件加收起状态 prop 管道。** AppFrame 已发布 `data-sidebar-collapsed`;用 CSS 对其判断显隐,避免了可能与框架过渡时间线不一致的第二条状态路径。
+
+## Consequences
+
+- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
+- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
+- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。
+- 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。
+- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
+
+## Testing
+
+ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml

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

+ 31 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md

@@ -0,0 +1,31 @@
+# Agent Note: Desktop primary runtime
+
+Status: implemented
+
+English | [中文](2026-09-14-desktop-primary-runtime.zh.md)
+
+## Problem
+
+Desktop agents need predictable Python data-processing libraries and an independent Node interpreter on machines without development environments. System interpreter selection must remain under user control.
+
+## Decision
+
+Desktop ships Python, Node.js, pnpm, numpy and pandas as one release-bound payload. The path-query tool installs the payload from application resources into the fixed Harness-home directory and returns absolute paths. It does not change PATH, environment variables or package-manager configuration. pnpm uses its native global-install rules.
+
+The application version and component versions live in `runtime.json`, not the directory name. Installation publishes a completed staged copy and retains the previous directory until replacement succeeds. Matching releases reuse installed files; upgrades replace user-added Python dependencies inside the managed tree. The Desktop single-instance owner and the tool's shared installation promise serialize normal installation requests.
+
+Node downloads and hash-verifies the complete locked wheel set and unpacks these library-only archives into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Wheels with `.data` installation directories are rejected; command-line entry-point wrappers are outside this library payload. Native smoke executes the final payload after temporary files are removed, so interpreter links must survive relocation.
+
+macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
+
+## Alternatives considered
+
+**System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas.
+
+**PATH injection and dedicated pnpm global directories.** They change command selection or require pnpm's global command directory to be on PATH. Absolute interpreter paths and native pnpm behavior satisfy the requested scope without those changes.
+
+**Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable.
+
+## Consequences
+
+The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Desktop 第一方 Runtime
+
+Status: implemented
+
+[English](2026-09-14-desktop-primary-runtime.md) | 中文
+
+## Problem
+
+Desktop 代理需要在没有开发环境的机器上获得确定的 Python 数据处理库和独立 Node 解释器。系统解释器的选择必须继续由用户控制。
+
+## Decision
+
+Desktop 将 Python、Node.js、pnpm、numpy 和 pandas 作为绑定应用版本的产物交付。路径查询工具从应用资源将产物安装到 Harness home 下的固定目录,并返回绝对路径。它不修改 PATH、环境变量或包管理器配置。pnpm 使用原生全局安装规则。
+
+应用版本和组件版本记录在 `runtime.json` 中,不放在目录名里。安装发布完整的暂存副本,并在替换成功前保留之前的目录。同版本复用已安装文件;升级替换受管目录内用户添加的 Python 依赖。Desktop 单实例所有者和工具共享的安装 Promise 串行处理正常安装请求。
+
+Node 下载并校验完整锁定 wheel 集的哈希,将这些仅含库的压缩包解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。含 `.data` 安装目录的 wheel 会被拒绝;命令行入口包装器不属于该库产物。本机 smoke 在临时文件删除后执行最终产物,因此解释器链接必须在迁移后仍有效。
+
+macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
+
+## Alternatives considered
+
+**只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。
+
+**注入 PATH 并指定 pnpm 全局目录。** 这些方式会改变命令选择,或要求 pnpm 全局命令目录已在 PATH 中。绝对解释器路径和 pnpm 原生行为无需这些改动即可满足请求范围。
+
+**独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。
+
+## Consequences
+
+应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.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/feature/2026-09-14-unattended-browser-terminal-reclamation.md
+2026-09-14-unattended-browser-terminal-reclamation.md: 6eca6faa222ed744b8ebd4ef59b2dbaec25fd8ce
+2026-09-14-unattended-browser-terminal-reclamation.zh.md: 9065fe53a7d7970c4523beaffb08b3df30a4944f

+ 56 - 0
.agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.md

@@ -0,0 +1,56 @@
+# Agent Note: Two-hour reclamation of unattended browser terminals
+
+Status: implemented
+
+English | [中文](2026-09-14-unattended-browser-terminal-reclamation.zh.md)
+
+## Problem
+
+A browser can disappear without closing its terminal tabs. Keeping every abandoned terminal retains shells, descendant processes and screen buffers indefinitely. Output subscriptions do not identify abandonment: hidden tabs and inactive Sessions legitimately stop following the screen.
+
+## Decision
+
+A browser terminal is reclaimed only after no window holds it and the Host continuously confirms idle activity for two hours. Running work and unknown activity cancel the idle deadline. When work finishes, a fresh full grace period starts; command runtime and output silence never impose a deadline. Explicit tab close and Session or Host disposal retain their authority to terminate work.
+
+| Situation | Behavior |
+|---|---|
+| A connected window holds the tab, including a collapsed sidebar or inactive Session | Retain the terminal. |
+| One of several holding windows disappears | Retain while another window holds it. |
+| The final window disappears while a command is running, stopped, waiting for input, or running in the background | Preserve the work, regardless of runtime. |
+| No window holds a positively confirmed idle terminal | Start the full grace period and recheck before cleanup. |
+| A window returns before cleanup | Reattach the same process and cancel reclamation. |
+| The saved process is gone | Preserve the tab and show localized unavailability with a New terminal action. Only an explicit click replaces it in place with a fresh identity. |
+
+For example, a window disconnecting at 14:00 while a command runs until 20:00 cannot cause reclamation before 22:00. The deadline starts at the first subsequent confirmed idle observation.
+
+The [sidebar](../../../../packages/client/ui-sidebar-right/README.md#state) persists layout per Session and publishes `openTabs` metadata for saved and adopted Sessions. Startup discovery does not mount dormant content, pin files or activate Agents. Adopted stores are authoritative within their window; another window's storage writes cannot revoke those live holds. Permanent scope removal drops its metadata.
+
+The terminal provider intersects that inventory with its own saved terminal associations and unfinished close requests. Each window uses one `retain(sessionId, id, signal)` Remote stream per distinct terminal. Its acknowledgement grants a hold without screen output, Agent activation, input control or creation. Restored output waits for an acknowledged current hold. Transport generations cancel independently, and the existing Gateway reconnect and heartbeat mechanisms own transport liveness.
+
+The subprocess seam supplies `inspectActivity()` with a state and revision. Ordinary non-login Bash 4.4+ and Zsh launches support opt-in lifecycle records, combined with complete process-table observations and original process identities. Zsh distinguishes an empty top-level editor prompt from `vared`, selection and continuation input. Input invalidates prompt evidence; background and stopped descendants prevent idle. Native Linux also checks the systemd task count so escaped descendants still inside its owned scope remain protected. Custom traps, asynchronous Zsh descriptor handlers, unsupported launches and incomplete observations remain unknown. Lifecycle files are private and disappear after successful cleanup.
+
+The Host [terminal controller](../../../../packages/api/terminal-controller/README.md#use-this-package) owns configurable `unattendedTimeoutMs` (7200000), `activityPollIntervalMs` (30000) and `cleanupRetryMs` (60000). Zero disables automatic reclamation only. It uses a monotonic clock; stale observations after gaps longer than twice the polling interval reset the grace period. Input and hold changes invalidate in-flight observations. A successful final check marks the identity closed before asynchronous termination, preventing late creation and admission. Closed identities cannot be reused, and each cleanup remains attached to its original Session owner.
+
+Cleanup awaits provider quiescence and final screen output. Failure retains ownership, rejects new holders and schedules one retry without a new idle grace. Failed allocation cleanup follows the same retry policy. Owner disposal stops timers and streams and joins cleanup and observations before reporting failures, including when cleanup rejects before an observation settles. No Agent terminal tool, model input or Session event changes.
+
+Peer research on Codex thread unloading, OpenCode Location scopes and [VS Code PTY grace periods](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/ptyService.ts) informed separate references and delayed cleanup. The two-hour value is DSH product policy, not an industry default. The [browser-terminal decision](2026-09-09-web-sidebar-terminal.md) and [layout/provider recovery decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) remain active because their resource ownership and persistence separation still apply.
+
+## Alternatives considered
+
+**Count output followers.** Switching tabs or Sessions stops output subscriptions without abandoning the corresponding terminal. Window holds track open layout membership instead.
+
+**Kill every disconnected terminal after two hours.** A hard deadline would kill legitimate long-running commands. Busy and unknown work therefore has no automatic maximum runtime.
+
+**Infer idle from silence, low CPU or foreground identity.** Silent builds, sleeping jobs, shell builtins and commands waiting for input can satisfy these signals without completing. Positive lifecycle evidence and owned-job observations are both required.
+
+**Restore only the visible Session or retain every saved association.** The first loses dormant holds after refresh; the second lets obsolete associations keep abandoned processes alive. The layout/provider intersection represents the window's actual tabs.
+
+## Consequences
+
+Idle abandoned terminals have bounded retention while connected windows and long-running work survive refreshes and presentation changes. Busy, hung or permanently serving commands may remain indefinitely; so may unsupported shells and uncertain process observations. Explicit close remains available. PowerShell, fish, Windows, custom shell arguments and sandbox-wrapped launches currently report unknown activity.
+
+Root-shell exit does not authorize killing descendants. Failure to enumerate Linux processes is an unavailable observation, not an empty process range; activity stays unknown and cleanup retains ownership. Empty native Linux ranges and complete empty Linux sessions can permit cleanup of exited records. macOS cannot confirm an unobserved range after root exit and may retain its record until explicit close or owner disposal. Existing provider limits on escaped, unobserved descendants remain; the lifecycle record is not a security barrier against hostile same-user processes.
+
+Browser storage failure leaves current memory state usable but cannot guarantee recovery of dormant Sessions after reload. Host restart cannot restore PTYs. Screen history and per-Session terminal quotas retain their existing bounds; no separate unbounded expiry-reason cache is added.
+
+Fake-clock tests cover deadlines, reconnect races, observation invalidation and cleanup retries. Real PTY tests cover silent commands, builtins, background and stopped jobs, Zsh editing modes, startup files and traps. Browser tests cover persistent layouts, multiple windows, collapsed reload, transport loss, retained process identities, explicit replacement of unavailable tabs and reconnection to the original process after transport loss. The keyless recorded Session scenario verifies file-preview layout recovery.

+ 56 - 0
.agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.zh.md

@@ -0,0 +1,56 @@
+# Agent Note: Two-hour reclamation of unattended browser terminals
+
+Status: implemented
+
+[English](2026-09-14-unattended-browser-terminal-reclamation.md) | 中文
+
+## 问题
+
+浏览器可能在没有关闭终端标签页的情况下消失。如果始终保留无人使用的终端,shell、后代进程和屏幕缓冲区会无限期占用资源。输出订阅不能代表是否遗弃:隐藏标签和非当前 Session 合理地停止跟随屏幕。
+
+## 决策
+
+只有没有任何窗口持有终端,并且 Host 持续确认空闲满两小时,才回收浏览器终端。运行中的工作和未知活动都会取消空闲截止时间。工作完成后重新给予完整宽限期;命令执行时长和无输出时间都不是截止条件。明确关闭标签,以及 Session 或 Host 卸载,仍有权终止工作。
+
+| 情况 | 行为 |
+|---|---|
+| 已连接窗口持有标签,包括侧栏折叠或非当前 Session | 保留终端。 |
+| 多个持有窗口中的一个消失 | 只要还有其他窗口持有,就继续保留。 |
+| 最后一个窗口消失时,命令仍在运行、停止、等待输入或后台执行 | 保留工作,不限制执行时长。 |
+| 无窗口持有,且明确确认终端空闲 | 开始完整宽限期,清理前再次检查。 |
+| 窗口在清理前返回 | 重连原进程,取消回收。 |
+| 保存的进程已经不存在 | 保留标签,显示本地化的不可用提示和“新建终端”。只有明确点击后,才在原位置用全新身份替换。 |
+
+例如,窗口在 14:00 断开,而命令持续到 20:00,则最早也不能在 22:00 前回收。期限从之后第一次确认空闲的观察开始计算。
+
+[侧栏](../../../../packages/client/ui-sidebar-right/README.zh.md#state) 按 Session 持久化布局,并通过 `openTabs` 发布已保存和已采用 Session 的标签元数据。启动发现不会挂载非当前内容、pin 文件或激活 Agent。窗口内以已采用的 store 为准;其他窗口对 storage 的写入不能撤销当前窗口的活动持有关系。永久删除 scope 时移除相应元数据。
+
+终端 provider 将该清单与自己保存的终端关联及未完成的关闭请求取交集。每个窗口为每个不同终端使用一条 `retain(sessionId, id, signal)` Remote 流。确认帧授予持有关系,不发送屏幕输出、不激活 Agent、不接管输入,也不创建进程。恢复输出前等待当前持有关系确认。传输代次独立取消,传输存活由现有 Gateway 重连和心跳机制负责。
+
+subprocess 能力接口提供带状态和 revision 的 `inspectActivity()`。普通非登录的 Bash 4.4+ 和 Zsh 支持显式启用生命周期记录,并结合完整进程表观察和原始进程身份。Zsh 区分空的顶层编辑提示符与 `vared`、选择及续行输入。输入使提示符证据失效;后台和停止的后代阻止空闲判断。原生 Linux 还检查 systemd task 数,保护脱离进程树但仍在自有 scope 中的后代。自定义 trap、Zsh 异步文件描述符 handler、不支持的启动方式和不完整观察保持 unknown。生命周期文件为私有文件,在清理成功后删除。
+
+Host [终端控制器](../../../../packages/api/terminal-controller/README.zh.md#use-this-package) 管理可配置的 `unattendedTimeoutMs`(7200000)、`activityPollIntervalMs`(30000)和 `cleanupRetryMs`(60000)。零只禁用自动回收。控制器使用单调时钟;观察间隔超过轮询周期两倍时,旧证据失效并重置宽限期。输入和持有关系变化会使正在进行的观察失效。最终检查通过后,在异步终止前将身份标记为关闭,阻止迟到的创建和连接。已关闭身份不能复用,每个清理任务始终属于原来的 Session owner。
+
+清理等待 provider 范围完全停稳和最终屏幕输出排空。失败时保留所有权、拒绝新持有关系,并安排一次重试,不重新给予空闲宽限期。分配失败后的清理采用相同重试策略。owner 卸载会停止计时器和流,在清理与观察都结束后才报告失败,包括清理早于观察结束而拒绝的情况。Agent 终端工具、模型输入和 Session 事件均不改变。
+
+Codex 线程卸载、OpenCode Location scope 和 [VS Code PTY 宽限期](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/ptyService.ts) 的调研支持了独立引用和延迟清理的设计。两小时是 DSH 的产品策略,不是行业默认值。[浏览器终端决策](2026-09-09-web-sidebar-terminal.zh.md) 与[布局/provider 恢复决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md) 继续有效,因为其资源所有权和持久化职责划分仍然适用。
+
+## 考虑过的替代方案
+
+**统计输出订阅者。** 切换标签或 Session 会停止输出订阅,但并未放弃对应终端。窗口持有关系改为追踪布局中打开的标签。
+
+**断开满两小时就杀掉所有终端。** 固定截止时间会杀掉合理的长命令。因此运行中和未知状态的工作没有自动最长执行时间。
+
+**根据静默、低 CPU 或前台身份推断空闲。** 静默构建、休眠任务、shell 内建命令及等待输入的命令都可能满足这些条件却尚未完成。必须同时具有正面的生命周期证据和自有任务观察。
+
+**只恢复可见 Session,或保留每个保存的关联。** 前者在刷新后丢失非当前 Session 的持有关系;后者让过时关联保活无人使用的进程。布局与 provider 的交集才代表该窗口实际打开的标签。
+
+## 结果与代价
+
+无人使用的空闲终端具有保留期限,而已连接窗口和长任务能跨刷新及呈现变化继续存在。运行中、挂起或长期提供服务的命令可能无限期保留;不支持的 shell 和不确定的进程观察也是如此。仍可明确关闭。PowerShell、fish、Windows、自定义 shell 参数以及经过 sandbox 包装的启动当前返回 unknown。
+
+根 shell 退出不授予终止后代的权限。Linux 进程枚举失败表示观察不可用,而不是进程范围为空;活动保持 unknown,清理保留所有权。明确为空的原生 Linux 范围和完整且为空的 Linux 会话允许清理已退出记录。macOS 无法在根进程退出后确认未观察到的范围,可能保留记录直到明确关闭或 owner 卸载。provider 对已逃逸且未观察到的后代的限制仍然存在;生命周期记录不是用于防御同一用户恶意进程的安全屏障。
+
+浏览器 storage 失败时,当前内存状态仍可使用,但不能保证刷新后恢复非当前 Session。Host 重启不能恢复 PTY。屏幕历史和每个 Session 的终端配额保持原有上限,不新增无限增长的到期原因缓存。
+
+虚拟时钟测试覆盖截止时间、重连竞争、观察失效和清理重试。真实 PTY 测试覆盖静默命令、内建命令、后台和停止任务、Zsh 编辑模式、启动文件及 trap。浏览器测试覆盖持久化布局、多窗口、折叠后刷新、传输中断、保留的进程身份、明确操作后替换失效标签,以及传输中断后重连原进程。无需密钥的已记录 Session 场景验证文件预览布局恢复。

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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/process/2026-08-10-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: 77b73b8c461238ad80afd7abd609a68df0bcf058
-2026-08-10-npm-release-sequences.zh.md: f56234fc50d776658d2d9191056b0b04e1609f1e
+2026-08-10-npm-release-sequences.md: 6f52c00a31870939981994965b70504e8f8143ca
+2026-08-10-npm-release-sequences.zh.md: effa5538cefbc8f25b2e047ee40e0dc7306bb2fb

+ 2 - 0
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md

@@ -96,6 +96,8 @@ A dependency in `optionalDependencies`, or a peer carrying `peerDependenciesMeta
 
 A violation names the package, the declaration that made it optional, and the way out in order — import it as a type, which is all that declaration merging needs, or restructure so module scope does not need the package. A dynamic `import()` only moves the failure to first use, so it belongs to a caller that genuinely requires the package and handles its absence; reaching for it is a sign the dependency is not optional, and the gate does not offer it as the remedy.
 
+A required CommonJS-compatible Host dependency whose initialization is unrelated to startup may use `createLazyRequire(specifier, import.meta.url)`. The caller keeps a type-only import, supplies a literal dependency specifier, and invokes the returned loader at the owning operation. `verify-package-dependencies` recognizes that literal as a Host runtime edge, so Client/Host packages retain it in `dependencies` even though no static value import remains. The utility caches only a successful load and preserves caller-relative resolution; it does not make an optional dependency required or hide first-use failure.
+
 ### Release family objects
 
 The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a subclass and a workflow lane, not changing the core.

+ 2 - 0
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md

@@ -96,6 +96,8 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
 
 报错会点名这个包、点名是哪条声明把它标成 optional 的,并按顺序给出出路——把它作为类型引入(声明合并需要的仅此而已),或者调整写法让模块作用域不再需要这个包。动态 `import()` 只是把失败推迟到首次使用,它属于那种确实需要这个包、并且自己处理缺失的调用方;会想到它,往往说明这个依赖并不 optional,所以门禁不把它作为解法给出。
 
+初始化与启动无关、且兼容 CommonJS 的必需 Host 依赖可以使用 `createLazyRequire(specifier, import.meta.url)`。调用方保留 type-only import,传入字面量依赖 specifier,并在所属操作中调用返回的 loader。`verify-package-dependencies` 会把该字面量识别为 Host runtime edge,因此 Client/Host 包即使没有静态值 import,仍会把它保留在 `dependencies`。该工具只缓存成功加载,并保留调用方相对解析;它不会把 optional 依赖变成必需依赖,也不会隐藏首次使用失败。
+
 ### 发布族对象
 
 这个领域里的实体是**发布族**:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。

+ 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 代码的拉取过程。

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

+ 11 - 1
.github/workflows/ci.yml

@@ -346,7 +346,10 @@ jobs:
 
       - name: Install Playwright Chromium and hosted dependencies
         if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
-        run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
+        # Ephemeral runner images can carry Ubuntu mirrors whose HTTP endpoints are unreachable.
+        run: |
+          sudo find /etc/apt -maxdepth 2 -type f \( -name '*.list' -o -name '*.sources' \) -exec sed -i -E 's@http://([a-z]{2}\.)?archive\.ubuntu\.com/ubuntu@https://archive.ubuntu.com/ubuntu@g; s@http://security\.ubuntu\.com/ubuntu@https://security.ubuntu.com/ubuntu@g' {} +
+          pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
 
       # The persistent VM image owns Playwright's Linux system packages; do
       # not mutate the shared host with apt on every failover run.
@@ -465,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

+ 2 - 0
.gitignore

@@ -1,5 +1,7 @@
 CLAUDE.local.md
 .env
+apps/desktop/.env.windows
+apps/desktop/.env.macos
 node_modules/
 lib/
 *.tsbuildinfo

+ 4 - 2
AGENTS.md

@@ -108,7 +108,7 @@ If a required `gh`, `pnpm`, build, test, or generator command fails because the
 
 ### Run relevant checks locally
 
-Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass.
+Before pushing, follow [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md); report only commands run. After `gh stack sync`, validate immediately; do not merge before checks pass.
 
 - Match evidence to the surface: focused behavior tests, model/user-output snapshots, `doc-sync` for docs, built smokes for published paths, and real-API e2e for providers.
 - Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change.
@@ -116,7 +116,9 @@ Run checks before pushes via [dsh-pre-push-checks](.agents/skills/dsh-pre-push-c
 
 ## Secrets / .env
 
-Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
+Windows packaging/signing: [required reading](apps/desktop/README.md#windows-ev-signing).
+
+Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and root `.env`. cordis.yml allows `!!js` (never `!js`) under plugin `config` and entry `disabled`; other metadata stays literal, so conditional composition also uses overlays ([primer](docs/cordis-primer.md#loader-configuration)). Never commit credentials. CI e2e skips without a key; [testing.md](docs/testing.md) owns key policy.
 
 ## Conventions
 

+ 1 - 0
THIRD_PARTY_NOTICES.md

@@ -153,6 +153,7 @@ External packages **directly declared** for development, tests, types, or toolin
 | --- | --- |
 | [`@aws-sdk/client-s3`](https://github.com/aws/aws-sdk-js-v3) | Apache-2.0 |
 | [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT |
+| [`@electron/get`](https://github.com/electron/get) | MIT |
 | [`@electron/notarize`](https://github.com/electron/notarize) | MIT |
 | [`@lexical/headless`](https://github.com/facebook/lexical) | MIT |
 | [`@modelcontextprotocol/node`](https://github.com/modelcontextprotocol/typescript-sdk) | 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: 0f52e6afbe32a6be44f66afa11e5da1f4795f24c
-README.zh.md: 7fa8a27ed546adda1e54eab24dc9ac17a322b0cb
+README.md: 92bd282b1148217a41ce12bbb4d227df088b13a0
+README.zh.md: b64ed8f81fb28acb70d96b1b0c116ea03dbd5fbb

+ 3 - 1
apps/cli/README.md

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profiles
 
-A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list and `patchReload` lifecycle) and a `cordis.patch.yml` (the user's own patch layer). `patchReload: live` watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload; `startup` applies them once. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections.
+A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer). `dsh-hmr`, when enabled in YAML, watches the profile manifest and both profile and home patch files, then recomposes all layers through one serialized reload. Without HMR, changes apply on restart. Edits arriving during watcher registration use the same nonfatal reload reporting as later edits. [Plugin Manager](../../packages/boot/plugin-manager/README.md) shares package operations and the profile write lock with `dsh plugin`; package updates retain disabled bundle selections. 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
@@ -55,4 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f
 
 Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh <args...>` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
 
+The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
+
 The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.

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

@@ -34,7 +34,7 @@ dsh --help                          # the launcher's own help
 <a id="profiles"></a>
 ## Profile
 
-profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表与 `patchReload` 生命周期;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。`patchReload: live` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层;`startup` 则只应用一次。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。
+profile 目录包含一个 `package.json`,其中记录树外插件依赖,以及 profile manifest(元数据清单)`dsh.profile`、其中按顺序排列的 `bundles` 列表;还包含一个 `cordis.patch.yml`,其中保存用户自己的 patch 层。在 YAML 中启用的 `dsh-hmr` 监视 profile manifest、profile 与 home 级 patch 文件,再通过统一串行重载重新组合所有层。未启用 HMR 时,更改在重启后生效。监听器注册期间发生的编辑与后续编辑使用相同的非致命重载错误报告。[插件管理器](../../packages/boot/plugin-manager/README.zh.md) 与 `dsh plugin` 共享包操作和 profile 写锁;更新依赖会保留已停用的组合包选择。CLI 包操作继承认证环境和终端描述符,支持交互式构建批准;service 调用保留清理后的环境并捕获诊断。
 
 配置树以空根为起点,依次叠加以下配置层:
 - `dsh.profile.bundles` 中各组合包的 patch
@@ -55,4 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
 
 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。
 
+`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
+
 [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。

+ 12 - 3
apps/cli/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh",
   "description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
-  "version": "0.1.5-rc.2",
+  "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
   },
@@ -15,7 +15,8 @@
     "dsh": "lib/bin.js"
   },
   "files": [
-    "lib/*.js"
+    "lib/*.js",
+    "lib/types/*.d.ts"
   ],
   "dsh": {
     "configTrees": [
@@ -98,7 +99,7 @@
     "@deepseek-ai/schemastery": "workspace:^",
     "commander": "^15.0.0",
     "js-yaml": "^4.2.0",
-    "node-addon-require-builtin": "^0.1.4",
+    "node-addon-require-builtin": "^0.1.6",
     "@deepseek-ai/dsh-http-proxy": "workspace:^",
     "@deepseek-ai/dsh-mcp-resources": "workspace:^",
     "@deepseek-ai/dsh-plugin-manager": "workspace:^",
@@ -150,5 +151,13 @@
     "@types/ws": "8.18.1",
     "execa": "^10.0.0",
     "ws": "8.21.0"
+  },
+  "exports": {
+    "./profile-boot": {
+      "types": "./lib/types/profile-boot.d.ts",
+      "default": "./lib/profile-boot.js"
+    },
+    "./lib/*": "./lib/*",
+    "./package.json": "./package.json"
   }
 }

+ 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

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff