Bläddra i källkod

Merge remote-tracking branch 'origin/worktree/code-diff-card' into worktree/changes-diff-preview

creatixchu 5 dagar sedan
förälder
incheckning
fac80a84fb
100 ändrade filer med 1819 tillägg och 581 borttagningar
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  5. 7 6
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  6. 8 6
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml
  11. 4 4
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md
  12. 4 4
      .agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md
  13. 2 2
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
  14. 10 12
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
  15. 10 12
      .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
  16. 2 2
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml
  17. 7 6
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
  18. 7 6
      .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md
  19. 2 2
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
  20. 1 1
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
  21. 1 1
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
  22. 2 2
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.i18n.yaml
  23. 1 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-immediate-window-and-direct-start.md
  24. 1 1
      .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 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.md
  27. 3 1
      .agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md
  28. 6 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.i18n.yaml
  29. 31 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md
  30. 31 0
      .agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md
  31. 2 2
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml
  32. 3 3
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
  33. 3 3
      .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md
  34. 2 2
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.i18n.yaml
  35. 5 3
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.md
  36. 5 3
      .agents/notes/implemented/architecture/2026-09-14-current-profile-plugin-management.zh.md
  37. 6 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.i18n.yaml
  38. 27 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md
  39. 27 0
      .agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md
  40. 6 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml
  41. 43 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
  42. 43 0
      .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md
  43. 6 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.i18n.yaml
  44. 25 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.md
  45. 25 0
      .agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.zh.md
  46. 6 0
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml
  47. 51 0
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md
  48. 51 0
      .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md
  49. 6 0
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.i18n.yaml
  50. 27 0
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md
  51. 27 0
      .agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md
  52. 2 2
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml
  53. 1 1
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
  54. 1 1
      .agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md
  55. 6 0
      .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml
  56. 44 0
      .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md
  57. 44 0
      .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md
  58. 2 2
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml
  59. 2 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md
  60. 2 0
      .agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md
  61. 2 2
      .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.i18n.yaml
  62. 1 1
      .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md
  63. 1 1
      .agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md
  64. 6 0
      .agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml
  65. 33 0
      .agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md
  66. 33 0
      .agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md
  67. 6 0
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
  68. 29 0
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
  69. 29 0
      .agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
  70. 6 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.i18n.yaml
  71. 30 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md
  72. 30 0
      .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.zh.md
  73. 2 2
      .agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml
  74. 2 14
      .agents/notes/proposed/feature/2026-08-04-task-surface.md
  75. 2 14
      .agents/notes/proposed/feature/2026-08-04-task-surface.zh.md
  76. 17 4
      .github/review-ownership/README.md
  77. 209 42
      .github/review-ownership/check-approval.mjs
  78. 515 3
      .github/review-ownership/check-approval.test.mjs
  79. 1 0
      .github/workflows/weighted-approval-review-event.yml
  80. 15 6
      .github/workflows/weighted-approval.yml
  81. 3 1
      apps/cli/package.json
  82. 2 2
      apps/desktop/README.i18n.yaml
  83. 15 16
      apps/desktop/README.md
  84. 15 16
      apps/desktop/README.zh.md
  85. 1 0
      apps/desktop/package.json
  86. 0 6
      apps/desktop/renderer/plugin-manager.html
  87. 0 8
      apps/desktop/renderer/plugin-manager.js
  88. 0 16
      apps/desktop/renderer/startup.css
  89. 0 26
      apps/desktop/renderer/startup.html
  90. 0 45
      apps/desktop/renderer/startup.js
  91. 1 1
      apps/desktop/scripts/dev.ts
  92. 3 10
      apps/desktop/scripts/smoke-windows.ps1
  93. 1 1
      apps/desktop/src/backend-controller.ts
  94. 73 0
      apps/desktop/src/fatal-recovery.ts
  95. 1 21
      apps/desktop/src/ipc.ts
  96. 14 22
      apps/desktop/src/locale.ts
  97. 57 136
      apps/desktop/src/main.ts
  98. 0 26
      apps/desktop/src/owned-directory.ts
  99. 7 22
      apps/desktop/src/preload-app.ts
  100. 0 11
      apps/desktop/src/preload.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md
-2026-07-19-gui-web-client-architecture.md: 55421d1ad6df192d08c431af3633675036a4a857
-2026-07-19-gui-web-client-architecture.zh.md: 6fb3f9a512389710f6708b7f36f42e90eef11b28
+2026-07-19-gui-web-client-architecture.md: 4d62d7ddbeddd2d5c02e42194b035ee08b5cf041
+2026-07-19-gui-web-client-architecture.zh.md: aeb6c89e677d7b65daef03d5d31372f484ae3117

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md

@@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 ```
 
 - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot<ConversationSnapshot>`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail.
-- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates.
+- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, running, removal, open state, paging, and prompt errors remain Session facts; pending Inbox values live in the generic Session projection store. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React reads Session lifecycle through `useSession(selector)` and domain projections through `useProjection(key, selector)`, so each hook isolates unrelated updates.
 - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation.
 - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned.
 - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering.

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md

@@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES──
 ```
 
 - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot<ConversationSnapshot>`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。
-- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。
+- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、running、removed、open state、paging 和 prompt error 仍是 Session 信息;待处理 Inbox 值则位于通用 Session projection store。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 通过 `useSession(selector)` 读取 Session lifecycle,通过 `useProjection(key, selector)` 读取领域投影,使每个 hook 都隔离无关更新。
 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。
 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。
 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。

+ 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: e9dc734c05eb5a73a7c8ef531bd3042548e0f06e
-2026-07-23-client-plugin-loading-model.zh.md: f4df6c0a108bd2b99756764df089fd3c7f58839f
+2026-07-23-client-plugin-loading-model.md: a8d016a70792ad4ac6a71083677ce5d82a30af0a
+2026-07-23-client-plugin-loading-model.zh.md: 1b94909c9611f01e5a6d7fcb50ec3f91ee88fab1

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


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

@@ -26,7 +26,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 
 ### 包成员与模块请求
 
-[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row,且每个包只有一个普通 `lib/client.js` factory bundle。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
+[Client 外壳分层 Note](2026-08-15-client-shells-and-dynamic-packages.zh.md)定义当前的静态、动态包集合及其 import 规则。装载机件把每个 `dsh.client` 包视为一个 host graph row;每个包都有一个普通 `lib/client.js` factory bundle,还可有编译器生成的 `lib/client.<name>.js` chunk。包声明携带 Cordis `inject` 边、同步模块表 `external` 请求,以及可选的 `immediately` 预取标记;负责组合的 app 只拥有挂载名册。
 
 Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules 本身是动态图 row,但 host parser 会在 Vite 主模块前送达其 factory。内核调用 `create()` 时,由 HTML 安装的 `__ModuleLoader__` facade 使用该 factory 构造模块系统。其他每个动态图 row 都归属一个 application combo 脚本;React、Cordis 与静态 UI 库的身份由外壳 seed 提供。
 
@@ -34,17 +34,19 @@ Web 内核保持不依赖框架,也不 import 任何动态包实体。Modules
 
 浏览器复刻 host 侧的分工。`dsh-client-modules`(`ClientModuleSystem`)坐上 host 侧由 Node 内部 ESM loader 占据的模块系统席位;同一份 vendored `@cordisjs/plugin-loader` 在两侧都坐治理席。二者的分界线一句话说尽:**模块系统拥有模块身份与字节——代码怎么到达、怎么登记、怎么变成导出内容;Loader 拥有插件生命周期——插件何时挂载、等待什么、如何拆除。**
 
-`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——bundle 调用 `window.__ModuleLoader__.load({ id, factory })`,此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。模块表按固定分支顺序解析:seed word → 记忆化记录 → graph row classic-script 登记 → 已登记 factory 物化 → 大声抛错。Modules factory 是自举例外:HTML facade 先物化它,构造过程再把同一 exports 直接写入记忆化表。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(丢弃非 bootstrap factory 与记录,下次到达即重新加载)
+`ClientModuleSystem` 是一张 lazy CJS 表。执行 bundle 只**登记**其 factory——入口调用 `window.__ModuleLoader__.load({ id, factory })`,chunk 还会提供其生成文件名——此外什么都不发生。模块体的一切副作用(包括 CSS 注入)都住在 factory 闭包里,在物化时运行:物化即该 id 的首次 `require`/import,此后记忆化。Import 和 prefetch 会先递归登记已声明的动态请求,再登记消费者;随后 factory 会同步物化任何已登记但尚未物化的请求。可调用的 `require` 解析同步模块表请求;其 `require.async` 操作返回 Promise,负责加载、登记并物化一个包内 chunk。共享 tsdown 预设把源码中针对包内 chunk 的 `import()` 表达式编译到这个独立操作。受支持的产物必须自包含:入口或 chunk 不能同步 require 另一个相对 `client*.js` 产物。模块表按固定分支顺序解析:seed word → 记忆化记录 → graph row classic-script 登记 → 已登记 factory 物化 → 大声抛错。Modules factory 是自举例外:HTML facade 先物化它,构造过程再把同一 exports 直接写入记忆化表。最后这一抛是构建期纯度门禁在运行时的镜像。系统还保管逐模块簿记——名下 `<style data-plugin>` 标签 id、观测到的 require 边——并暴露 HMR(热模块替换)需要的两个动词:`prefetch(id)`(登记所请求的动态 factory 和本 row 自身的 factory;并发到达共享一个任务)与 `invalidate(id)`(推进 owner 代次并丢弃非 bootstrap 包的入口和 chunk factory 及记录,让下次到达重新加载它们)。在旧代次捕获的 chunk 请求不能填充新代次
 
 vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点是 `tree.import`——并拥有一切 entry 形状的事务:entry 创建、fiber 经 cordis 服务等待的激活(注入的服务未就位即保持 PENDING,服务 provide 时级联激活)、update/refresh、拆除。治理代码按 vendor 政策与 host 侧逐字节相同。浏览器化是壳 vite 配置里的编译期映射:一个 `node:module` stub 别名加若干 `process.*` define,使 `ModuleLoader.fromInternal()` 返回 undefined——这正是留给壳来填的空槽。模块系统挂载为 `ctx.modules`。
 
 ### Combo 外部脚本到达与源码映射
 
-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,所以副作用边界依然是首次物化。
+Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的有序 row 划入一个或多个同源 classic script。它在更长的 map 形式请求 URL 保持在 3 KiB 以内时贪心填充每组,既保留 graph 顺序,也以增加请求代替超长 URL。每个脚本只包含 `client.js` 资源,并由这些 package 资源寻址,例如 `/plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>`。Host 不扫描同级文件,也不把 chunk 加入启动 combo。`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
+一次 `require.async("./client.<name>.js")` 调用会请求精确的带 revision URL:`/plugins/<package>/client.<name>.js?rev=<rev>`。Host 只在收到请求时读取该文件,把文件已有的 chunk 登记包装为一个脚本响应,并按 URL 缓存响应。并发调用共享一个进行中的脚本任务;成功结算要求 chunk 已登记,随后 loader 才会物化并记忆化其 exports。未知名称、缺失文件以及不同于所属 graph row 的 revision 都返回 404
 
-图为 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 的产物。
+共享 tsdown 预设为每个插件入口与 chunk 产出 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 名。绝对 combo 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>`。chunk 脚本同样指向自己的 map URL。只有在这些 map URL 收到 `GET` 后,source map 文件才会被读取和组合;`HEAD` 不会物化脚本或 map body。Vite 壳同样产出 sourcemap,使壳代码与经外部加载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
+
+图为 HMR 保留每个 row 带 revision 的单资源 combo URL,并为每个启动 combo 请求增加带 revision 的描述;多条描述可以使用同一调度阶段。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证已快照的单资源响应不可变。共享预设会在每个包输出写完后标记入口。watcher 观察到该构建完成标记后,`rebuilt(id)` 会把入口字节与其时间戳一起哈希并发布所得 revision;因此只重建 chunk 也会推进 owner revision,无需 Host 扫描 sibling。启动 combo revision 从有序 row revision 派生。脚本 body 在首次 `GET` 时组合;source map 文件在首次 map `GET` 时单独读取并组合。`HEAD` 不会物化任一 body。版本化脚本与 map 使用 immutable 缓存,无关图重组会保留相同 revision 下已经物化的 chunk 响应。Host 只提供精确生成的 URL;陈旧 revision 与未发布资源列表返回 404,不会别名到其他字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -78,7 +80,7 @@ Host SSE 适配器转发现有图变化通知,并在连接时发送当前完
 
 热重载是一项组合决策: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)` 是重哈希的唯一入口;它只哈希可执行 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
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获入口 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的入口则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制生成新 revision。共享 tsdown 预设会在每个 sibling 输出写完后标记 `client.js`;入口 mtime 或 size 变化、或 row 处于标脏状态时,`rebuilt(id)` 从入口字节与构建完成时间戳派生 revision。只修改 chunk 因此也会更换 revision;部分写入之后的完成标记还会提供一次更晚的 stat 变化以完成自愈。`rev` 变化时,node 半在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;每个 row 每个间隔只需一次入口 stat,轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物的进程必须使用共享 Client tsdown 预设;`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现,构建器与 host 之间没有通知协议
 
 浏览器侧的传输把代码替换交给负责图对账的同一个 modules 控制器:
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 4cdca4c1cb5b512c1696a397bbb6e4d070a6a487
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 0258609fd8df90dc2133fcb8bf6e7e50efaab941
+2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md

@@ -99,7 +99,7 @@ Slot scope is the closed set `root | session-maybe | session`:
 - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).
 - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`).
 
-### The read-only queue mirror
+### Input delivery
 
 - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md

@@ -99,7 +99,7 @@ slot scope 是闭集 `root | session-maybe | session`:
 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。
 
-### 队列只读镜像
+### 输入投递
 
 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.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-31-claimed-pre-step-inbox-lifecycle.md
-2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888
-2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18
+2026-07-31-claimed-pre-step-inbox-lifecycle.md: 3ebb73279e49c2aaffa05bea647fa3f93ba6a36f
+2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 809470e5b52b29cdcbe7b4d81bb584b476f5cfce

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md

@@ -18,13 +18,13 @@ Before every proposed step, the loop's package-internal `ReactLoopInbox` atomica
 
 The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields.
 
-`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes.
+`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context`. `AgentLoop` owns the standard projection registration for its service lifetime, keeping cold Inbox reads available without any live Agent; `ReactLoopInbox` only reads and mutates that shared state.
 
-The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value.
+The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentLoop` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the generic projection change feed and read the complete Inbox value directly.
 
 Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued.
 
-The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
+The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `AgentLoop` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror.
 
 ## Alternatives considered
 
@@ -36,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026
 
 ## Verification
 
-Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
+Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and Inbox availability across Agent unloads and projection removal when AgentLoop unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads.
 
 ## Consequences
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md

@@ -18,13 +18,13 @@ Status: implemented
 
 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。
 
-`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数
+`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`。`AgentLoop` 在服务生命周期内持有标准投影注册,让没有 live Agent 时的冷 Inbox 读取仍然可用;`ReactLoopInbox` 只读取和变更该共享状态
 
-两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement
+两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentLoop` 在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用通用投影变更流,直接读取完整的 Inbox 值
 
 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。
 
-已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。
+已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `AgentLoop` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。
 
 ## 曾考虑的替代方案
 
@@ -36,7 +36,7 @@ Status: implemented
 
 ## 验证
 
-agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
+agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及Agent 卸载后 Inbox 仍可读取,以及 AgentLoop 卸载时移除投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。
 
 ## 后果
 

+ 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: b6551e1c7f8c94fb02a788aa62cb4acef1addffe
-2026-08-02-typert-remote-method-calls.zh.md: 058ec47e6749ee7576fd84fdcacfda350eec3fb8
+2026-08-02-typert-remote-method-calls.md: b599b3f9a353738bbb44a4da9b5a72be8309e2fb
+2026-08-02-typert-remote-method-calls.zh.md: 64dc84f0ae07b10d50452ef7e36c30db102953cf

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

@@ -31,7 +31,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
 | `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser |
 | Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
 | Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
-| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results |
+| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, and invokes methods |
 | Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel |
 | API Gateway's Client face | `ctx.remote`, `ctx.remote.<namespace>` | Mounts Remote contributions, materializes each namespace as a traced `remote.<namespace>` child Service, and delegates canonical calls to `ctx.connection.rpc` |
 | API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations |
@@ -147,9 +147,9 @@ 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 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.
+A LIB codec contains a success-cached Zod schema factory and a canonical `typeSymbol` consisting of "package + public subpath + export name." The Host Gateway invokes parameter and identity factories when it first decodes strict input. The Client contribution retains the same codec metadata for strict input checks at mount but does not materialize invocation schemas; [Host-only Remote input validation](../simplification/2026-09-15-host-only-remote-input-validation.md) owns this placement. An SRC codec is marked only as `src-json`.
 
-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.
+Descriptors exist only in the local registry on each side. The wire carries only the `/api` channel, endpoint, and `{ args }` payload. The Client uses its descriptor to map positional arguments and Context identity into named fields. The Host uses its descriptor to validate those fields, resolve the receiver, and invoke the method.
 
 ## Typert runtime registry
 
@@ -181,7 +181,7 @@ Consequently, `SessionId`, the Agent wire ID, the request, and the result all re
 
 Remote methods themselves use declaration-map navigation. Typert anchors `InvocationModel.location` to the decorated Host method-name token and emits a source-map segment on the corresponding property of the namespace interface. For an adapter-backed endpoint, after the TypeScript editor resolves `ctx.remote.models.list` to its generated declaration, `typert.remote-client.d.ts.map` takes it to the Host Service's `remoteExportList` entry point. That entry point explicitly calls the existing, unrenamed `list()` method; the map does not misidentify the decorator, class, or full signature as the method definition.
 
-Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses it to validate input and encode results, while the Client Remote uses it to encode arguments and validate responses. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
+Typert generates a wire Zod codec for the same symbol key. The Host Gateway uses parameter and identity codecs to validate input. Client Remote trusts its generated TypeScript arguments and successful Host results instead of executing invocation codecs. If a complex type cannot produce a strict codec, the LIB build fails instead of degrading to `unknown` or unchecked JSON.
 
 Named business types referenced by Remote methods must be exported from public, type-only subpaths. If the only reachable entry also imports Host Services, Cordis `Context` merges, or Host-only implementations, the build fails and requires the business package to provide a safe type entry. Primitives, literals, and simple compositions explicitly supported by Typert need no additional names.
 
@@ -315,7 +315,7 @@ Client business packages depend only on `@deepseek-ai/dsh-api-remotes/client`, n
 
 `ctx.remote.$mount()` registers a contribution with `Typert.remotes`, installs its namespace Services and concrete methods, and resolves only after they are ready. Its disposer is owned by the Cordis fiber that called the method. Duplicate endpoints, conflicting invocation modes for the same namespace and method, or conflicts between a descriptor and an existing type identity fail immediately.
 
-The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.<namespace>` child Service. The function constructs named `args` in descriptor parameter order, applies the Client's strict codec, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently.
+The Client Remote Service materializes each `@Remote` descriptor as a real function on a `remote.<namespace>` child Service. The function checks positional arity, constructs named `args` in descriptor parameter order without runtime type parsing, and then calls `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`. For a cancellation-aware descriptor, the generated function accepts a final optional signal and combines it with the contribution mount lifetime; unmounting therefore cancels every in-flight carrier call, while a caller can cancel one call independently.
 
 Neither a direct descriptor with `scope` nor a `@RemoteScope` descriptor copies functions into every Agent Scope. The Client Remote Service creates one Cordis child Service per namespace, registered as `remote.<namespace>`, and materializes direct and scoped variants on it. Accessing a method through `agentCtx.remote.goals` captures the current Agent Context before returning the callable handle. The method then asks the corresponding Context binder for identity from that Context. A direct scoped projection substitutes this identity at the lookup position named by `scope.wire`; a Remote Scope descriptor writes the identity into the receiver's separate wire field. Both issue the same kind of `/api` call.
 
@@ -380,10 +380,9 @@ ctx.typertGateway.invoke({ namespace, method, args, signal })
 → direct 使用原 Service;context 先解析 scoped Context 和 Service
 → cancellation descriptor 存在时把 signal 追加到业务参数末尾
 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs)
-→ result codec 编码业务结果
 ```
 
-`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns only the encoded result or raises a Gateway error that the Connection RPC adapter maps for transport.
+`ctx.typertGateway.invoke()` is the carrier-independent Host entry point. It neither creates an rpcId, RPC envelope, nor HTTP response. It returns the business result without runtime output decoding or raises a Gateway error that the Connection RPC adapter maps for transport.
 
 ## The shared `/api` call chain
 
@@ -426,7 +425,7 @@ The complete path is:
 
 ```text
 ctx.remote.goals.create(sessionId, request, signal?)
-→ Client InvocationDescriptor 编码 { args: { agentId, request } }
+→ Client InvocationDescriptor 组装 { args: { agentId, request } }
 → Client 合并 caller signal 与 contribution mount lifetime
 → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal)
 → Connection 创建 rpcId 和既有 client-request envelope
@@ -435,9 +434,8 @@ ctx.remote.goals.create(sessionId, request, signal?)
 → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler
 → Typert interceptor 调用 ctx.typertGateway.invoke(..., request.signal)
 → Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply
-→ result codec 编码
 → Connection 写入既有 RPC result 并回送相同 rpcId
-→ Client result codec 验证并返回 CreateGoalResult
+→ Client 直接返回 CreateGoalResult
 ```
 
 Remote does not define a second-layer `{ ok, value/error }` response on the wire. Successful values and failures use the existing RPC response's `result` directly, and the failure branch carries the shared `{ code, message, details }` data. Owners, resolvers, and the Gateway all raise one class, `RemoteError`, whose code comes from the merged `RemoteErrorDetailsMap`: the Host encodes a structurally identified `RemoteError` onto the wire unchanged — including the Gateway's own `gateway/*` assembly codes and a resolver's `session/not-found` or `session/agent-busy` — and folds only an unclassified throw into `gateway/internal`, keeping its diagnostic in the message. The Client face rebuilds an instance for the `RemoteResult` error branch, so `throw result.error` keeps throw semantics. [The failure-vocabulary Agent Note](2026-08-28-ctx-remote-failure-vocabulary.md) owns the code table, its ownership rules, and why discrimination reads `code` instead of `instanceof`.
@@ -455,7 +453,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection
 - `@deepseek-ai/dsh-typert-protocol`: lightweight protocols for decorators, bindings, lookup, Remote Scope, and descriptors.
 - Typert generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information.
 - Typert runtime: separately stores the current environment's local reflection and imported Remote contributions.
-- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges.
+- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, validates input, performs lookup, resolves Context receivers, invokes methods, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges.
 - `@deepseek-ai/dsh-api-remotes`: the BFF layer; registers the application's forwarded Cordis event source and the Host home carried by generation readiness, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract.
 - Connection: owns the single HTTP Server/future WebSocket carrier, the shared `/api` route and its composite FetchHandler, owner-registered exact Fetch routes, the RPC envelope, rpcId, serialization, trust, and error transport.
 - Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries.
@@ -516,7 +514,7 @@ Canonical public types require business DTOs to have type-only entries, which ma
 
 Type imports and runtime contributions have different effects. `import type {}` extends only the static Remote surface. If a real calling environment omits the value contribution, the Client Remote Service must fail with an explicit "Remote not mounted" error.
 
-Browser and Host each hold their own Zod instances and cannot compare object identities across realms. Consistency is guaranteed only by canonical symbol keys, the same generated model, and wire behavior.
+Generated Host and Client artifacts carry matching Zod factories, but Client Remote does not materialize invocation schemas. Canonical symbol keys, the same generated model, and Host wire validation keep the two sides aligned without comparing schema object identities across realms.
 
 A consumer may import a Remote contract that is not currently mounted on the Host. The types mean "this protocol capability was selected by the consumer," not that a corresponding Service currently exists in the target process; an unavailable endpoint must fail explicitly at runtime.
 

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

@@ -31,7 +31,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
 | `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser |
 | Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
 | Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
-| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 |
+| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver 并调用方法 |
 | Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route |
 | API Gateway 的 Client face | `ctx.remote`、`ctx.remote.<namespace>` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.<namespace>` 子 Service,并把规范调用交给 `ctx.connection.rpc` |
 | API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 |
@@ -147,9 +147,9 @@ InvocationDescriptor {
 
 参数顺序来自方法签名,HTTP 字段来自参数名或 lookup 声明。取消 descriptor 只保留最后一个 `signal` 位置,并使其不进入具名 `args`;实际 signal 由 Connection 或直接调用 Gateway 的调用方提供。Gateway 不根据请求内容推断可选字段、Context 类型、lookup 类型或缺失参数,也不会合成业务默认值。
 
-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 生成
+LIB codec 带有只缓存成功结果的 Zod schema factory 和「package + 公共 subpath + export name」的规范 `typeSymbol`。Host Gateway 首次解码严格输入时调用参数与身份 factory。Client contribution 保留同一 codec 元数据,以在挂载时检查严格输入,但不实例化调用 schema;[仅在 Host 校验 Remote 输入](../simplification/2026-09-15-host-only-remote-input-validation.zh.md)规定了这个位置。SRC codec 只标记 `src-json`
 
-descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload;Host 用自己的 descriptor 解码和调用,Client 用自己的对应 descriptor 编码参数和验证结果
+descriptor 只存在于两端本地 registry。wire 上只有 `/api` channel、endpoint 和 `{ args }` payload。Client 用自己的 descriptor 把位置参数和 Context identity 映射为具名字段;Host 用自己的 descriptor 校验这些字段、解析 receiver 并调用方法
 
 ## Typert 运行时 registry
 
@@ -181,7 +181,7 @@ import type { CreateGoalRequest, CreateGoalResult } from '@deepseek-ai/dsh-goal/
 
 Remote 方法本身使用 declaration map 导航。Typert 把 `InvocationModel.location` 固定在 Host 被装饰方法的方法名 token,并在 namespace interface 的对应属性上写入 source-map segment。对于由适配器支撑的 endpoint,TypeScript editor 从 `ctx.remote.models.list` 取得生成 declaration 后,再沿 `typert.remote-client.d.ts.map` 跳到 Host Service 的 `remoteExportList` 远程出口。该出口继续显式调用不改名的存量 `list()`,map 不把 decorator、class 或整个签名误当成方法定义位置。
 
-Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用它校验输入和编码结果,Client Remote 用它编码参数并校验响应;复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。
+Typert 为同一 symbol key 生成 wire Zod codec。Host Gateway 用参数与 identity codec 校验输入;Client Remote 信任生成的 TypeScript 参数与成功的 Host 结果,不执行调用 codec。复杂类型无法生成严格 codec 时,LIB 构建失败,不降级为 `unknown` 或无校验 JSON。
 
 Remote 方法引用的命名业务类型必须从纯类型公共 subpath 导出。如果唯一可达入口会带入 Host Service、Cordis `Context` merge 或 Host-only 实现,构建失败并要求业务包提供安全的类型出口。原始值、字面量和 Typert 明确支持的简单组合不需要额外命名。
 
@@ -315,7 +315,7 @@ Client 业务包只引用 `@deepseek-ai/dsh-api-remotes/client`,不直接依
 
 `ctx.remote.$mount()` 把 contribution 注册到 `Typert.remotes`,安装它的 namespace Service 和具体方法,并在它们就绪后才 resolve。调用该方法的 Cordis fiber 持有 disposer。endpoint 重复、同一 namespace/method 模式冲突或 descriptor 与现有类型身份冲突时直接失败。
 
-Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.<namespace>` 子 Service 上的真实函数。函数按 descriptor 的位置参数顺序构造具名 `args`,执行 Client strict codec,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。
+Client Remote Service 把 `@Remote` descriptor 实体化为 `remote.<namespace>` 子 Service 上的真实函数。函数检查位置参数数量,按 descriptor 的参数顺序构造具名 `args`,不做运行时类型解析,然后调用 `ctx.connection.rpc.call('/api', endpoint, { args }, signal)`。对于支持取消的 descriptor,生成的函数接受最后一个可选 signal,并将其与 contribution 的挂载生命周期合并;因此卸载会取消所有正在进行的 carrier 调用,而调用方也可以单独取消一次调用。
 
 带 `scope` 的 direct descriptor 和 `@RemoteScope` descriptor 都不为每个 Agent Scope 复制函数。Client Remote Service 为每个 namespace 创建一个注册为 `remote.<namespace>` 的 Cordis 子 Service,并在其上实体化 direct 与 scoped 变体。通过 `agentCtx.remote.goals` 取得方法时,accessor 会在返回可调用句柄前捕获当前 Agent Context。方法再通过对应 Context binder 从该 Context 取得 identity。direct scoped 投影用 identity 替代 `scope.wire` 指定的 lookup 位置,Remote Scope descriptor 则把 identity 写入 receiver 的独立 wire 字段;两者都发起同一种 `/api` 调用。
 
@@ -380,10 +380,9 @@ ctx.typertGateway.invoke({ namespace, method, args, signal })
 → direct 使用原 Service;context 先解析 scoped Context 和 Service
 → cancellation descriptor 存在时把 signal 追加到业务参数末尾
 → Reflect.apply(receiver[implementation ?? method], receiver, orderedArgs)
-→ result codec 编码业务结果
 ```
 
-`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它只返回编码结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。
+`ctx.typertGateway.invoke()` 是 carrier-independent 的 Host 入口。它不创建 rpcId、RPC envelope 或 HTTP response;它直接返回未经运行时输出解码的业务结果,或产生由 Connection RPC adapter 映射的 Gateway 错误。
 
 ## 共享 `/api` 调用链
 
@@ -426,7 +425,7 @@ Remote payload 使用具名 JSON 对象,不使用位置数组,也不发送 `
 
 ```text
 ctx.remote.goals.create(sessionId, request, signal?)
-→ Client InvocationDescriptor 编码 { args: { agentId, request } }
+→ Client InvocationDescriptor 组装 { args: { agentId, request } }
 → Client 合并 caller signal 与 contribution mount lifetime
 → ctx.connection.rpc.call('/api', 'goals/create', { args }, signal)
 → Connection 创建 rpcId 和既有 client-request envelope
@@ -435,9 +434,8 @@ ctx.remote.goals.create(sessionId, request, signal?)
 → 复合 FetchHandler 判断 endpoint ownership 并选择目标 FetchHandler
 → Typert interceptor 调用 ctx.typertGateway.invoke(..., request.signal)
 → Host InvocationDescriptor 解码、lookup、receiver 解析并把 signal 注入 Reflect.apply
-→ result codec 编码
 → Connection 写入既有 RPC result 并回送相同 rpcId
-→ Client result codec 验证并返回 CreateGoalResult
+→ Client 直接返回 CreateGoalResult
 ```
 
 Remote 不在 wire 上定义第二层 `{ ok, value/error }` response。成功值与失败都直接使用既有 RPC response 的 `result`,失败分支携带共享的 `{ code, message, details }` 数据。owner、resolver 与 Gateway 抛的都是同一个类 `RemoteError`,其码来自合并后的 `RemoteErrorDetailsMap`:Host 把结构识别出的 `RemoteError` 原样编码上 wire——包括 Gateway 自己的 `gateway/*` 装配码,以及 resolver 的 `session/not-found`、`session/agent-busy`——只把未归类的 throw 折成 `gateway/internal`,并把诊断串留在 message 里。Client face 为 `RemoteResult` 的错误分支重建实例,因此 `throw result.error` 的 throw 语义成立。[失败词汇 Agent Note](2026-08-28-ctx-remote-failure-vocabulary.zh.md) 持有码表、落点规则,以及为什么判别读 `code` 而不用 `instanceof`。
@@ -455,7 +453,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H
 - `@deepseek-ai/dsh-typert-protocol`:轻量 decorator、binding、lookup、Remote Scope 和 descriptor 协议。
 - Typert generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。
 - Typert runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。
-- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。
+- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,校验输入,执行 lookup、解析 Context receiver、调用方法,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。
 - `@deepseek-ai/dsh-api-remotes`:BFF 层;注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host home,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。
 - Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与其复合 FetchHandler、各 owner 注册的精确 Fetch route、RPC envelope、rpcId、序列化、trust 和错误传输。
 - Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。
@@ -516,7 +514,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化
 
 类型 import 与运行时 contribution 是两种不同效果。`import type {}` 只扩展静态 Remote surface;真实调用环境遗漏 value contribution 时,Client Remote Service 必须以明确的「Remote 未挂载」错误失败。
 
-Browser 与 Host 各自持有 Zod 实例,不能依赖对象 identity 跨 realm 比较;一致性只由规范 symbol key、同一生成模型和 wire 行为保证
+生成的 Host 与 Client 产物携带匹配的 Zod factory,但 Client Remote 不实例化调用 schema。规范 symbol key、同一生成模型和 Host wire 校验让两侧保持一致,而无需跨 realm 比较 schema 对象 identity
 
 消费端可以导入 Host 当前未挂载的 Remote contract。类型表示「该协议能力已被消费端选择」,不保证目标进程当前存在对应 Service;运行时 endpoint 不可用必须明确失败。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md
-2026-08-18-session-history-and-event-transport.md: 8781ea265798ff200a6e0a58542b7d03693d8bd3
-2026-08-18-session-history-and-event-transport.zh.md: 1603f5aed8adb7a42fecab92511176d2147be5c3
+2026-08-18-session-history-and-event-transport.md: db5c403562101bca76c0d39a037a199b2ca164f3
+2026-08-18-session-history-and-event-transport.zh.md: 41ff3ea2dd538edb457e0db3108a900bab220e47

+ 7 - 6
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md

@@ -8,7 +8,7 @@ English | [中文](2026-08-18-session-history-and-event-transport.zh.md)
 
 The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay.
 
-These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
+These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.
 
 Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects.
 
@@ -158,7 +158,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca
 | `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background |
 | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent |
 | `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent |
-| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state |
+| `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox |
+| `session.cancel` | only the current live Agent | Does not resume vanished state |
 | `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it |
 | `create` and fork target | new Session/Agent | The user command supplies creation authority |
 
@@ -204,9 +205,9 @@ A terminal failure from the initial page, repair page, or follow enters the curr
 
 `session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript.
 
-Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents.
+Each generation emits a complete baseline first, followed by jobs and projection deltas. The baseline reads process-local registries and folded projection values without resuming cold Agents.
 
-Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline.
+Jobs use complete replacement values and apply last-wins. Projection updates carry monotonically increasing revisions, while a new baseline replaces the complete projection map. Session and owner disposal clear stale mirrors.
 
 The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests.
 
@@ -313,7 +314,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
 
 **Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently.
 
-**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
+**Move jobs, projections, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`.
 
 **Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics.
 
@@ -345,7 +346,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources, readi
 
 Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection.
 
-Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark.
+Session control tests pin baseline-first delivery, no cold-Session resume, jobs replacement, and the projection watermark.
 
 Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry.
 

+ 7 - 6
.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。
 
-这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
+这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;jobs、projection 值和 Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。
 
 观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。
 
@@ -158,7 +158,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类
 | `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 |
 | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent |
 | `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent |
-| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
+| `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session |
+| `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent |
 | `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 |
 | `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 |
 
@@ -204,9 +205,9 @@ initial page、repair page 或 follow 的 terminal failure 进入当前 Session
 
 `session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。
 
-每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。
+每个 generation 先发完整 baseline,再发 jobs 与 projection 增量帧。baseline 读取进程内 registry 和已折叠的 projection 值,不恢复冷 Agent。
 
-queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。
+jobs 使用完整 replacement 值并按 last-wins 应用。Projection update 携带单调递增 revision,新 baseline 则替换完整 projection map。Session 与 owner disposal 会清理陈旧镜像。
 
 原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。
 
@@ -313,7 +314,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
 
 **把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。
 
-**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
+**把 jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。
 
 **让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。
 
@@ -345,7 +346,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、ready
 
 Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。
 
-Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。
+Session control 测试固定 baseline-first、冷 Session 不恢复、jobs replacement 与 projection watermark。
 
 Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.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-global-main-panels.md
-2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3
-2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89
+2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2
+2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md

@@ -12,7 +12,7 @@ Plugins need application-wide views that do not belong to a Session. A Session-s
 
 The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
 
-The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel.
+The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel.
 
 One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
 
-侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
+侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
 
 渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
 

+ 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: 461f82719fabba67bd93c4c1f2a37d02fac1c06b
-2026-09-09-desktop-immediate-window-and-direct-start.zh.md: efbeeb20993fdc6baeb11ca1ea1d1af5501567dc
+2026-09-09-desktop-immediate-window-and-direct-start.md: 2d325d5c9f9c3fc8dd83816538d0a1b4b4c5480b
+2026-09-09-desktop-immediate-window-and-direct-start.zh.md: 8aebe6bc8c86cc14b78d9dc979eeb902493de8ae

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

@@ -14,7 +14,7 @@ Waiting for backend readiness leaves users without a window during profile prepa
 
 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 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.
+Fatal presentation follows [native Desktop recovery](2026-09-15-desktop-native-fatal-recovery.md). Window timing, direct Host startup, and shutdown ownership remain governed here.
 
 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.
 

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

@@ -14,7 +14,7 @@ profile 修改与恢复遵循[直接修改 profile 决策](2026-09-09-desktop-in
 
 Electron 在 profile 校准或 Host 启动前创建带打包 Web 加载页的主窗口。Web 入口先显示启动页,再等待 Host 就绪。自有 preload 交付结构化启动注入,现有文档应用注入后激活客户端插件;启动失败时显示诊断和可用恢复操作。加载期间关闭窗口会取消后续启动工作,并等待正在启动的子进程退出。
 
-主窗口提供恢复操作,因为失败的 Host 无法提供自身控件。错误页保留诊断、重启和重装指导。加载了运行时元数据且资源可用时,包括开发模式,才提供禁用插件和重置 Desktop。重置会删除 profile 中除所持锁文件外的所有内容,不保留备份;共享产品数据和 Harness-home 环境文件保持完整。profile 目录保持原位,避免清理期间另一事务获取替代锁。preload 不可用时,独立恢复控件使用被拦截的表单导航。渲染进程崩溃会使导航缓存失效,以重新加载启动页
+致命错误展示遵循[原生 Desktop 恢复](2026-09-15-desktop-native-fatal-recovery.zh.md)。窗口展示时机、直接启动 Host 和关闭所有权仍由本文规定
 
 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。
 

+ 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: 425145ad89f9697c2420544b0ce2e70759313ffc
-2026-09-09-desktop-in-place-profile.zh.md: 8b98ec1c2e426b0019115a9a19eb84db4b9caef8
+2026-09-09-desktop-in-place-profile.md: 6f0e9598903d922e4b765d785be6dd3aa79ce93c
+2026-09-09-desktop-in-place-profile.zh.md: bec79b3b9d4ce5439664959b16695932be4af4cd

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

@@ -10,13 +10,15 @@ Staging preserves an old plugin installation but adds profile copying, directory
 
 ## Decision
 
+Application-owned package retention is qualified by the [production cleanup decision](../bug-fix/2026-09-15-desktop-profile-core-cleanup.md).
+
 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 [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.
 
-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.
+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, 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 - 1
.agents/notes/implemented/architecture/2026-09-09-desktop-in-place-profile.zh.md

@@ -10,13 +10,15 @@ staging 能保留旧插件安装,但增加 profile 复制、目录移动、恢
 
 ## 决策
 
+应用管理包的保留范围受[生产清理决策](../bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md)限定。
+
 Desktop 停止 Host 后直接修改当前 profile。共享 app-boot 清理在包变更前分离其拥有的模块补全链接;Host 的共享 profile runner 在启动时补全所需链接。包锁及配置允许的生命周期脚本保留。升级刷新模块链接,不复制插件文件。
 
 包操作或 Host 失败会保留部分修改,供修复和重试。不使用 staging profile、激活日志、目录切换恢复或自动回滚。已有临时目录不会被解释或删除。
 
 本记录取代[打包决策](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 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除、重置和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
+Desktop 将安装和生命周期脚本交给 pnpm,不设置待完成操作启动门禁、不强制按锁文件重装,也不自动重建。包操作失败会保留部分变更,仍可禁用、删除和重试启动。Host 继承用户环境,profile 可以使用目录链接。未经修改的旧版 Desktop 生成 pnpm 配置替换为 Web 默认值;自定义配置仍由用户管理。
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.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-09-plugin-management-in-the-web-sidebar.md
+2026-09-09-plugin-management-in-the-web-sidebar.md: d2a3329e7c878f25ad5e50d98fc2e3ab39722887
+2026-09-09-plugin-management-in-the-web-sidebar.zh.md: aae035b3a93aec72e84e2792334c42c54d2bca62

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.md

@@ -0,0 +1,31 @@
+# Agent Note: Plugin management moves to the Web sidebar
+
+Status: implemented
+
+English | [中文](2026-09-09-plugin-management-in-the-web-sidebar.zh.md)
+
+## Problem
+
+Installed packages belong to the running profile, while Settings is a modal over a Session. The management page needs room for package details and installation output. The layout's [global main panels](2026-09-08-global-main-panels.md) provide that lifetime and space.
+
+## Decision
+
+**Management is a sidebar entry; configuration stays in Settings.** `ui-plugin-manager` registers a `sidebar.panellist` entry and the `main` panel it opens under `plugins`. The page manages the profile's bundles and their rows through the [plugin manager](2026-09-14-current-profile-plugin-management.md) Remote, displays install output and confirms uninstalls. The page lists installed bundles only. The Settings Plugins section keeps the global configuration cards beside the read-only Plugin list tab, where the installation's own bundles (`dsh-base`, `dsh-web-app`) are inspected; both of that tab's groups start collapsed, and the tab carries no management controls of its own.
+
+**One store follows Host state.** The manager controller joins `listBundles` with `listPlugins` into one view per bundle, decides availability from the inventory's `managementAvailable`, refreshes after management operations, on `plugin-manager/changed`, and on reconnect, and keeps installation progress under the owning job. Configuration cards use the existing global settings bindings.
+
+**Installation results belong to a request.** The dialog generates a fresh request id for every install or retry and filters Host progress, logs, and responses by that id. A cancellation acknowledgement can arrive before the original add response, so that response cannot settle a subsequent retry. Cancellation uses the manager's explicit cleanup acknowledgement; local RPC cancellation and connection loss never imply that pnpm has stopped. The application phase closes the cancellation window.
+
+## Alternatives considered
+
+**A settings section that opens the management page.** Rejected: the dialog covers the main column, so such an entry would have to close Settings to show the page.
+
+**Configuration on the plugin's page.** Rejected for now, for the reasons in the decision; it becomes a link from the plugin's page once Settings can be opened on one section.
+
+## Consequences
+
+The web bundle's panel list is no longer empty: the **Plugins** entry sits between New Session and the workspaces. The Settings Plugins section keeps two tabs: the configuration page and the read-only Plugin list. `apps/web/tests/plugin-manager.e2e.ts` reaches the manager through the sidebar, and the `plugin-config` and `settings-chrome` scenarios and goldens follow.
+
+## Testing
+
+`packages/client/ui-plugin-manager/tests` pin the two registrations under one id and the page's rendering; `packages/client/ui-settings-plugins/tests` the tab-less single contribution; the web e2e scenarios above drive the panel and the section over a scaffold.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-09-plugin-management-in-the-web-sidebar.zh.md

@@ -0,0 +1,31 @@
+# Agent Note:插件管理移到 Web 侧栏
+
+Status: implemented
+
+[English](2026-09-09-plugin-management-in-the-web-sidebar.md) | 中文
+
+## 问题
+
+已安装的包属于运行中的 profile,而设置是覆盖在 Session 上的弹窗。管理页面需要容纳包详情与安装输出。布局的[全局主面板](2026-09-08-global-main-panels.zh.md)提供对应的生命周期与空间。
+
+## 决定
+
+**管理位于侧栏,配置保留在设置中。** `ui-plugin-manager` 在 `plugins` 下注册 `sidebar.panellist` 入口与它打开的 `main` 面板。页面通过[插件管理器](2026-09-14-current-profile-plugin-management.zh.md)的 Remote 管理 profile 的组合包及其行、展示安装输出,并确认卸载。页面只列出已安装的组合包。设置的插件分区保留全局配置卡片,旁边是只读的「插件列表」标签页,随安装提供的组合包(`dsh-base`、`dsh-web-app`)在那里查看;该标签页的两个分组默认收起,且不带任何管理控件。
+
+**一个 store 跟随 Host 状态。** 管理器控制器把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,按清单的 `managementAvailable` 判定可用性,在管理操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并按所属 job 保存安装进度。配置卡片使用原有全局 settings 绑定。
+
+**安装结果属于一次请求。** 每次安装或重试都生成新的请求 ID,对话框按该 ID 筛选 Host 进度、日志和返回结果。取消确认可能先于原 add 响应到达,因此旧响应不能结束随后的重试。取消采用管理器的明确清理确认;本地 RPC 取消和断线均不表示 pnpm 已停止。进入配置应用阶段后关闭取消窗口。
+
+## 考虑过的替代方案
+
+**用一个设置分区打开管理页。** 否决:对话框盖住主列,这样的入口必须先关掉设置才能显示页面。
+
+**把配置放在插件页面上。** 暂不采纳,理由见决定;等设置能按分区打开后,它会变成插件页面上的一个链接。
+
+## 后果
+
+web bundle 的面板列表不再为空:**插件**入口位于新建会话与工作区之间。设置的「插件」分区保留两个标签页:配置页与只读的「插件列表」。`apps/web/tests/plugin-manager.e2e.ts` 经侧栏到达管理器,`plugin-config` 与 `settings-chrome` 的场景与 golden 随之更新。
+
+## 测试
+
+`packages/client/ui-plugin-manager/tests` 钉住同一 id 下的两处注册与页面的渲染;`packages/client/ui-settings-plugins/tests` 钉住没有标签条的单一贡献;上述 web e2e 场景在脚手架上驱动面板与分区。

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

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

@@ -14,7 +14,7 @@ Separate Desktop composition and request transport require their own configurati
 
 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 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 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.
 
@@ -38,8 +38,8 @@ This partially supersedes the private composition and portless transport in the
 
 ## 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.
+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; native recovery remains 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.
+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 native recovery dialog 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.

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

@@ -14,7 +14,7 @@ Status: implemented
 
 私有 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 负责。
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 以共享 Web 模板的 bundle 列表初始化 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。
 
@@ -38,8 +38,8 @@ App-boot 负责已安装依赖发现、安装目录优先的 bundle 声明解析
 
 ## Consequences
 
-Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;独立恢复资源在启动失败时仍可用。
+Desktop 通过相同启动与服务路径继承 Web 功能。HTTP 监听归属与认证仍属于应用启动。Electron 使用报告的 Host 地址,并在就绪前后保留现有 Web 文档。共享 Web 加载页在 Host 启动前可用;原生恢复在启动失败时仍可用。
 
-用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及独立恢复 UI,不触发更严格的准入检查或自动回滚。
+用户选择的运行时选项、包来源及允许的生命周期脚本可以影响 Host 执行、加载第三方代码或导致启动失败。Desktop 按与 Web 相同的配置归属接受这些影响;签名核心运行时不为用户安装的插件代码背书。包操作或加载失败保留显式修复及原生恢复对话框,不触发更严格的准入检查或自动回滚。
 
 验证需要覆盖共享 runner、认证 HTTP 资源与 API 传输、配置重载、原生目录选择、子进程关闭及插件失败恢复。安装后平台验收与真实模型 GUI 验收独立于单元测试;本记录不声称已测得启动或传输提升。

+ 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: f92337de4b3b38442ce6e875ebcd1b8c8ccbb01a
-2026-09-14-current-profile-plugin-management.zh.md: 645775fd8b2735d46887e1ddbea1ebeb66b616a1
+2026-09-14-current-profile-plugin-management.md: a59a79022bce88dcee7b1629ec35c9759489e34a
+2026-09-14-current-profile-plugin-management.zh.md: 7c3e8d470fcf87fb32cda8d9f68dd2ffe4b7fe15

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

@@ -14,17 +14,19 @@ Web and agent controls need to change a running profile without creating an inde
 
 Configuration watches use Chokidar write stabilization by default. Its ordinary change handler discards a second event within 50 ms, so a write immediately after activation can leave the previous bundle running. Stabilized delivery observes the final file instead; file-driven updates pay the stability delay, while direct manager transactions do not. A regression feeds consecutive changes through Chokidar’s real normalization and verifies both applied states.
 
-Profile files remain the persisted state: entry toggles edit only `disabled` in the last override matching the entry id and any module-name assertion, appending when none matches, and bundle toggles edit the ordered string list. Dependency updates do not reactivate retained disabled bundles. A service removal first applies the composition without the bundle and waits for old fibers to finish before deleting the dependency. Saved configuration, pnpm completion and runtime activation have separate outcomes; failure preserves the actual partial state and a diagnostic path.
+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.
+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. Management operations return results to callers without adding messages to live Agents. 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.
 
+Build approvals update pnpm 11's unresolved `allowBuilds` entries under the same profile lock and preserve unrelated YAML. They persist by exact package name rather than applying an unrestricted script policy. The retry accepts only names still pending, so stale requests cannot override a subsequent denial. Package cleanup leaves the approval settings intact; a later retry can use them without retaining partially installed dependencies. The service reports policy-only changes. Agent tools may grant approval on the user's behalf; their instructions require explicit conversational consent, while the service validates only pending package names. Approval rejects anchors and aliases inside `allowBuilds` to prevent shared YAML nodes from changing unrequested permissions.
+
 ## Alternatives considered
 
 **Spawning another dsh process from the service.** This duplicates lifecycle coordination and cannot establish that the current Loader finished unloading before pnpm removes files. Sharing the operation module retains one implementation while letting each caller own its presentation.
 
-**Restoring existing packages after failure.** Package versions, dependency trees and install-script effects cannot be reconstructed reliably from the previous manifest. Failed installation permits one removal attempt for an unambiguously identified new dependency. Existing dependencies and successful installations whose activation fails remain in place. Invalid leftover packages stay visible and removable.
+**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.
 

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

@@ -14,17 +14,19 @@ Web 和 Agent 控件需要修改运行中的 profile,同时避免另建包安
 
 配置监听默认使用 Chokidar 写入稳定检测。普通变化处理器会丢弃 50 ms 内的第二个事件,因此激活后立即再次写入可能让之前的组合包继续运行。稳定后交付事件会观察最终文件;文件驱动的更新承担稳定等待,直接管理器事务则不需要。回归测试通过 Chokidar 的真实规范化路径交付连续变化,验证两个状态均被应用。
 
-profile 文件保持为持久状态:条目开关只修改最后一条符合条目 id 及模块名称断言的覆盖项中的 `disabled`,没有匹配项时追加,组合包开关修改有序字符串列表。更新依赖不会重新激活保留的已停用组合包。service 删除组合包时,先应用去掉该组合包的配置,等待旧 fiber 完成卸载后再删除依赖。已保存配置、pnpm 完成状态与运行时激活分别报告;失败保留实际的部分状态与诊断路径。
+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` 调用,同时保留管理模块用于清单发现。
+这扩展了[profile 组合包决策](2026-08-05-profile-plugin-bundles.zh.md)。startup profile 保留进程组合,Desktop 包管理仍由 shell 持有。Web 控件与显式启用的 Agent 工具调用同一 service。管理操作向调用方返回结果,不向存活 Agent 添加消息。base 组合包和内置预设默认禁用该 Agent 工具。纯浏览器 worker 预览没有宿主包安装器;其模块代理表明确拒绝 `execa` 调用,同时保留管理模块用于清单发现。
 
 CLI 调用继承终端和认证环境;service 调用保留子进程凭据清理与有界诊断。管理结果提供错误码和参数,由 Web 词典呈现文案。重载前后比较 entry、fiber、配置与诊断:未变化的已有故障保留为警告,本次影响到的新故障使操作失败。显式启用的目标必须成功激活。
 
+构建审批在同一个 profile 写锁内更新 pnpm 11 尚未决定的 `allowBuilds` 条目,并保留无关 YAML。授权按准确包名持久化,不采用无条件允许脚本的策略。重试只接受仍在待审批列表中的包名,因此过期请求不能覆盖后续拒绝。包清理保留审批设置,后续重试可以复用授权而不必保留部分安装的依赖。service 报告仅涉及策略的变化。Agent 工具可以代表用户授权;工具说明要求用户在对话中明确同意,而 service 仅验证待审批包名。审批拒绝 `allowBuilds` 内的锚点和别名,避免共享 YAML 节点改变未请求的权限。
+
 ## 考虑过的替代方案
 
 **由 service 启动另一个 dsh 进程。** 这会重复生命周期协调,也无法确认当前 Loader 已完成卸载后才让 pnpm 删除文件。共享操作模块保留单一实现,同时让调用方持有各自的呈现方式。
 
-**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用。安装失败只允许对可唯一识别的新增依赖尝试一次删除。已有依赖及安装成功但激活失败的包保留原处。无效的残留依赖仍可见、可删除
+**失败后恢复已有包。** 无法仅凭原 manifest 可靠重建包版本、依赖树和安装脚本的副作用,因此已有依赖及安装成功但激活失败的包保留原处。失败或被取消的安装只恢复 pnpm 运行前快照的 manifest 与 lockfile 文本([引导式插件安装](2026-09-15-guided-plugin-installation.zh.md));已下载文件保留到下一次包操作清理为止
 
 **包更新时热替换源码模块。** 配置变化可以复用已加载模块缓存,替换已安装 JavaScript 则需要新的进程。替换已有依赖会报告需要重启。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-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-15-desktop-native-fatal-recovery.md
+2026-09-15-desktop-native-fatal-recovery.md: b2a0dfff1a630d1eedaa898e4a4c8144ff7ae776
+2026-09-15-desktop-native-fatal-recovery.zh.md: 0fee729e3be791d10966050d21482c5132b14fe6

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.md

@@ -0,0 +1,27 @@
+# Agent Note: Native Desktop fatal recovery
+
+Status: implemented
+
+English | [中文](2026-09-15-desktop-native-fatal-recovery.zh.md)
+
+## Problem
+
+A recovery document depends on the renderer and preload whose failure can prevent application startup. Multiple reports from one failed startup can also obscure the original diagnostic and interrupt recovery.
+
+## Decision
+
+Electron owns one native fatal dialog per application process. Explicit main-window creation, document-load, preload, renderer, Web initialization, and backend failures enter this path. Ordinary requests and package operations retain their local error handling; the Host restarts after failed package writes, and a failed Host restart enters native recovery; expected cancellation and shutdown do not enter recovery. No elapsed-time heuristic classifies a slow startup as fatal.
+
+The first report claims presentation before awaiting the dialog. Later reports remain in logs. The Electron console retains the complete reported diagnostic. The dialog bounds the first diagnostic to its final eight lines and limits the complete detail to 1,200 UTF-16 code units, including truncation notice and reinstall advice, because native dialogs cannot scroll. The dialog offers exit, restart, or disabling third-party bundles followed by a whole-application restart. Disabling writes activation metadata under the existing profile transaction lock after Host shutdown, without requiring runtime initialization or deleting installed files. An explicit recovery-operation failure is presented separately and does not count as another automatic fatal report.
+
+The Web document stays in place. A carrier callback owns startup failure presentation while the shared boot page retains its spinner; ordinary browser boot still renders its own failure report. Only the primary application frame may report a Web boot failure. The plugin window exposes package operations only; backend state remains in the main process, and native recovery directly owns disabling all third-party bundles. Desktop has no profile reset, plugin-window recovery controls, or emergency recovery document. A fatal backend failure requires one of the native recovery actions rather than an in-process retry.
+
+This supersedes recovery-page and reset behavior in the [immediate-window decision](2026-09-09-desktop-immediate-window-and-direct-start.md), whose immediate visibility and direct Host startup rationale remain active. The [in-place profile decision](2026-09-09-desktop-in-place-profile.md) still owns package transactions and partial changes.
+
+## Alternatives considered
+
+A Web modal depends on client initialization, while a second recovery document adds renderer resources and preload recovery paths. Native dialogs remain usable when those components fail. Automatically resetting configuration or restarting on every report can delete user configuration or create restart loops; explicit actions preserve user control.
+
+## Consequences
+
+Recovery cannot report a killed or crashed Electron main process, and a silent startup hang has no automatic timeout prompt. Invalid profile JSON can prevent disabling plugins; exit and restart remain available after the operation reports its failure. Focused lifecycle tests cover fatal signals, cancellation, first-report deduplication, and shutdown ordering; locale expectations record dialog diagnostics and actions, and boot tests retain ordinary browser failure presentation.

+ 27 - 0
.agents/notes/implemented/architecture/2026-09-15-desktop-native-fatal-recovery.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 原生 Desktop 致命错误恢复
+
+Status: implemented
+
+[English](2026-09-15-desktop-native-fatal-recovery.md) | 中文
+
+## Problem
+
+恢复文档依赖渲染器和 preload,而这些组件的故障本身就可能阻止应用启动。同一次启动失败的多个报告还可能掩盖原始诊断并打断恢复操作。
+
+## Decision
+
+Electron 在每个应用进程中提供一次原生致命错误对话框。明确的主窗口创建、文档加载、preload、渲染器、Web 初始化和后端失败进入此路径。普通请求和包操作保留局部错误处理;包写入失败后会重新启动 Host,Host 重启失败进入原生恢复;预期取消和关闭不进入恢复。不通过耗时推断慢启动为致命故障。
+
+首次报告在等待对话框前取得展示权。后续报告保留在日志中。Electron 控制台保留完整的已报告诊断。原生对话框无法滚动,因此仅显示首次诊断末尾八行,并将包含截断提示和重装建议的完整详情限制为 1,200 个 UTF-16 代码单元。对话框提供退出、重启或禁用第三方 bundle 后重启整个应用。禁用操作等待 Host 关闭后,在已有 profile 事务锁内写入启用元数据,不要求运行时初始化,也不删除安装文件。显式恢复操作失败会单独展示,不计为另一次自动致命报告。
+
+Web 文档保留在原位。宿主回调负责启动失败展示,共享启动页保留加载动画;普通浏览器启动仍显示自身的失败报告。只有主应用框架可以上报 Web 启动失败。插件窗口只暴露包操作;后端状态保留在主进程中,原生恢复直接负责禁用全部第三方 bundle。Desktop 不提供 profile 重置、插件窗口恢复控件或应急恢复文档。后端致命故障必须通过原生恢复操作处理,不在当前进程中重试。
+
+这取代了[立即显示窗口决策](2026-09-09-desktop-immediate-window-and-direct-start.zh.md)中的恢复页和重置行为;该决策关于立即可见性和直接启动 Host 的理由仍然有效。[原位 profile 决策](2026-09-09-desktop-in-place-profile.zh.md)仍负责包事务和部分变更。
+
+## Alternatives considered
+
+Web 模态框依赖客户端初始化,而第二份恢复文档会增加渲染器资源和 preload 恢复路径。原生对话框在这些组件失败时仍然可用。自动重置配置或每次报告都重启可能删除用户配置或形成重启循环;显式操作保留用户控制权。
+
+## Consequences
+
+恢复功能无法报告 Electron 主进程被终止或崩溃,静默启动挂起也没有自动超时提示。无效的 profile JSON 可能阻止禁用插件;操作报告失败后仍可退出和重启。定向生命周期测试覆盖致命信号、取消、首次报告去重和关闭顺序;语言预期记录对话框诊断和操作,启动测试保留普通浏览器失败展示。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-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-15-guided-plugin-installation.md
+2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be
+2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md

@@ -0,0 +1,43 @@
+# Agent Note: Guided plugin installation
+
+Status: implemented
+
+English | [中文](2026-09-15-guided-plugin-installation.zh.md)
+
+## Problem
+
+The install dialog put a spec straight into `pnpm add` and showed pnpm's terminal as the whole story: a typo, an installed package, a missing path, and a registry outage all ended in the same red exit code, the person read pnpm's output to learn which, and nothing could be stopped once started. A run that failed, or that added a package without a bundle patch, left the dependency in the profile with nothing in the list to show or remove it. Enabling was a checkbox to tick before knowing what would be installed, and a finished install left the new package somewhere in the list.
+
+## Decision
+
+**The Host reads a spec before installing it.** `PluginManager.inspect` sorts the spec — registry name, absolute path, git address, tarball — with `parseInstallSpec`, refuses what pnpm or the registry would refuse, and asks the registry through `pnpm view` or the directory through its `package.json` for the name, version, description, and bundle declaration. `pnpm view` runs in the profile directory so the registry and proxy settings match the install. A package without a bundle patch is refused here, before pnpm runs: the manager installs bundles only. The answer carries one of seven problems; the client renders each as a sentence under the field and keeps the spec editable. The dialog refuses a name the list already shows without asking the Host.
+
+**A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store.
+
+**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
+
+**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
+
+**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
+
+**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
+
+**Blocked install scripts are approved from the failed screen.** When pnpm 11 leaves a dependency's scripts undecided, the failed run reports the pending names ([the manager's approval](2026-09-14-current-profile-plugin-management.md)), and the failed screen shows them with **Allow these scripts and retry** in place of plain retry; the store runs the same checked subject again with `approvedBuilds`, and the installed screen names what was allowed. `pnpm-workspace.yaml` is not among the restored files for this reason. Without pending names the failure falls back to the manual instruction.
+
+## Alternatives considered
+
+**Validate specs on the client.** Rejected: the rules are pnpm's, the registry's, and the profile's, and the client cannot import the Host package that owns them.
+
+**Look the package up over HTTP instead of `pnpm view`.** Rejected: the registry, proxy, and auth settings that decide whether the install can succeed live in pnpm's configuration, which `pnpm view` reads and a direct fetch would have to reimplement.
+
+**Stop the run by aborting the add RPC.** Rejected: a dropped RPC does not say whether pnpm stopped or the manifest is back, so the dialog would offer the spec again over a run still writing to the profile. The manager's `cancelInstall` answers only after cleanup, and the dialog waits for it.
+
+**Keep listing dependencies that are not bundles.** Rejected: the manager manages bundles, and a package it refuses to install cannot be listed or removed through it; the check refuses such a package before anything is written.
+
+## Consequences
+
+`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card.
+
+## Testing
+
+`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry.

+ 43 - 0
.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md

@@ -0,0 +1,43 @@
+# Agent Note:引导式插件安装
+
+Status: implemented
+
+[English](2026-09-15-guided-plugin-installation.md) | 中文
+
+## 问题
+
+安装对话框把 spec 直接交给 `pnpm add`,并把 pnpm 的终端当作全部说明:打错字、已装过的包、不存在的路径、注册表不可用,最后都是同一个红色退出码,人得去读 pnpm 输出才知道是哪一种,而且一旦开始就停不下来。失败的运行,或者装进来一个没有组合包 patch 的包,会把依赖留在 profile 里,列表上却没有任何东西能显示或移除它。启用是一个在知道要装什么之前就得勾的选项,装完的包散在列表某处。
+
+## 决定
+
+**宿主先读 spec,再安装。** `PluginManager.inspect` 用 `parseInstallSpec` 把 spec 分成注册表名、绝对路径、git 地址、压缩包,拒绝 pnpm 或注册表不会接受的写法,再通过 `pnpm view` 问注册表、或读目录的 `package.json`,得到名字、版本、描述和组合包声明。`pnpm view` 在 profile 目录里运行,让注册表和代理设置与安装一致。没有组合包 patch 的包在这一步、在 pnpm 运行之前就被拒绝:管理器只安装组合包。答复带七种 problem 之一;客户端把每一种渲染成输入框下的一句话,spec 保留可改。列表里已有的名字由对话框直接拒绝,不问宿主。
+
+**失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。
+
+**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
+
+**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
+
+**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
+
+**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
+
+**被拦下的安装脚本在失败界面批准。** pnpm 11 把依赖脚本留作未决时,失败的运行报告待决定的包名([管理器的授权](2026-09-14-current-profile-plugin-management.zh.md)),失败界面列出它们并以**允许这些脚本并重试**取代普通重试;store 带着 `approvedBuilds` 用同一个已检查的 subject 再跑一次,安装完成界面说明允许了哪些脚本。正因如此,`pnpm-workspace.yaml` 不在恢复的文件之列。没有待决定的名字时,失败退回到手动放行的提示。
+
+## 考虑过的替代方案
+
+**在客户端校验 spec。** 否决:规则属于 pnpm、注册表和 profile,客户端无法导入拥有这些规则的宿主包。
+
+**用 HTTP 直接查注册表而不是 `pnpm view`。** 否决:决定安装能否成功的注册表、代理与认证设置都在 pnpm 的配置里,`pnpm view` 读得到,直接 fetch 得重新实现一遍。
+
+**通过中止 add RPC 来停止运行。** 否决:断掉的 RPC 说不清 pnpm 是否停了、manifest 是否已恢复,对话框会在一次仍在写 profile 的运行之上把 spec 重新交回。管理器的 `cancelInstall` 只在清理完成后答复,对话框等它。
+
+**继续列出不是组合包的依赖。** 否决:管理器管理的是组合包,它拒绝安装的包无法经由它列出或移除;检查在写入任何东西之前就拒绝这样的包。
+
+## 后果
+
+`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
+
+## 测试
+
+`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.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-15-narrow-pi-ai-runtime-imports.md
+2026-09-15-narrow-pi-ai-runtime-imports.md: 307d880f3fce22b0e1b96efb9c3a220a511eff15
+2026-09-15-narrow-pi-ai-runtime-imports.zh.md: 139983446a2f0cd5315629c4e0a632f8c125a837

+ 25 - 0
.agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.md

@@ -0,0 +1,25 @@
+# Agent Note: Narrow pi-ai runtime imports
+
+Status: implemented
+
+English | [中文](2026-09-15-narrow-pi-ai-runtime-imports.zh.md)
+
+## Problem
+
+The base bundle mounts `dsh-llm-pi-ai` with no configured routes so the Models settings page can offer pi-ai providers. Importing pi-ai's aggregate entry point for model helpers also evaluates its exported TypeBox namespace, adding hundreds of modules to every application startup even when all Sessions use `dsh-llm-deepseek`.
+
+## Decision
+
+`dsh-llm-pi-ai` has no runtime import of pi-ai's aggregate entry point. Catalog and login metadata continue through `providers/all`; protocol implementations use their existing `api/*.lazy` entries; overflow detection uses `utils/overflow`. Package-local `models.ts` supplies the three model helpers the adapter needs. Its collection comes from pi-ai's public `builtinModels()` implementation and is cleared before route providers are installed. Its provider constructor implements the static single-protocol case this adapter supplies. Its reasoning-level selection reads pi-ai's public `Model` metadata in pi-ai's escalation order.
+
+Type-only imports from the aggregate entry point remain because TypeScript erases them. Import profiling of the built package resolves 153 pi-ai modules and no TypeBox modules or pi-ai aggregate entry.
+
+## Alternatives considered
+
+- **Add a pi-ai `models` export.** Rejected because this package does not need an upstream export-map change to consume the public provider, API, utility, and model metadata interfaces already available.
+- **Dynamically import the aggregate entry point.** Rejected because the dormant adapter needs none of it; excluding the entry entirely removes the work instead of moving it to a later operation.
+- **Copy pi-ai's complete Models implementation.** Rejected because `builtinModels()` already returns the upstream implementation with its authentication and storage behavior. Clearing its providers preserves that implementation without maintaining a fork.
+
+## Consequences
+
+Applications still load `providers/all` so configuration and authorization surfaces retain the complete installed provider directory. Constructing an adapter snapshot briefly constructs and then clears the built-in provider set before installing the resolved route providers. The package-local provider constructor deliberately accepts only static models and one protocol implementation; adding dynamic models, filtering, or multi-protocol custom routes requires extending that local function together with its tests.

+ 25 - 0
.agents/notes/implemented/architecture/2026-09-15-narrow-pi-ai-runtime-imports.zh.md

@@ -0,0 +1,25 @@
+# Agent Note:收窄 pi-ai 运行时 import
+
+Status: implemented
+
+[English](2026-09-15-narrow-pi-ai-runtime-imports.md) | 中文
+
+## 问题
+
+基础 bundle 会在没有配置路由时挂载 `dsh-llm-pi-ai`,让 Models 设置页能够提供 pi-ai provider。为了 model helper 而 import pi-ai 聚合入口还会求值其导出的 TypeBox namespace,即使所有 Session 都使用 `dsh-llm-deepseek`,每次应用启动也会额外加载数百个模块。
+
+## 决策
+
+`dsh-llm-pi-ai` 不再运行时 import pi-ai 聚合入口。Catalog 与登录元数据继续使用 `providers/all`;协议实现继续使用现有 `api/*.lazy` 入口;overflow 检测使用 `utils/overflow`。包内 `models.ts` 提供适配器所需的三个 model helper。Collection 来自 pi-ai 公开的 `builtinModels()` 实现,并在安装路由 provider 前清空。Provider constructor 实现本适配器传入的静态单协议分支。Reasoning level 选择按照 pi-ai 的升级顺序读取其公开 `Model` 元数据。
+
+聚合入口的 type-only import 会被 TypeScript 擦除,因此予以保留。构建产物的 import profile 会解析 153 个 pi-ai 模块,不包含 TypeBox 模块或 pi-ai 聚合入口。
+
+## 考虑过的替代方案
+
+- **给 pi-ai 增加 `models` export。** 拒绝,因为本包可以直接使用已经公开的 provider、API、utility 与 model metadata interface,无需修改上游 export map。
+- **动态 import 聚合入口。** 拒绝,因为休眠适配器不需要其中任何内容;完全排除该入口会直接删除工作,而不是把工作移动到之后的操作。
+- **复制 pi-ai 完整的 Models 实现。** 拒绝,因为 `builtinModels()` 已经返回包含其认证与存储行为的上游实现。清空其中的 provider 可以保留该实现,无需维护 fork。
+
+## 后果
+
+应用仍会加载 `providers/all`,因此配置与授权界面保留完整的已安装 provider 目录。构建 adapter snapshot 时会短暂构造并清空内置 provider set,再安装已解析的路由 provider。包内 provider constructor 只接受静态 model 和一个协议实现;若要增加动态 model、filter 或多协议自定义路由,必须同时扩展该本地函数及其测试。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-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/bug-fix/2026-08-17-durable-web-queue-recovery.md
+2026-08-17-durable-web-queue-recovery.md: c235cc47d5a68623249765753c2757948721ff6e
+2026-08-17-durable-web-queue-recovery.zh.md: 2b224b5e1d63905e738dfda0772a3ea578c9aef3

+ 51 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md

@@ -0,0 +1,51 @@
+# Agent Note: Recover the Web queue from durable Inbox state
+
+Status: implemented
+
+English | [中文](2026-08-17-durable-web-queue-recovery.zh.md)
+
+## Problem
+
+Inbox acceptance records normalized `agent/inbox/spliced` events, but the Web queue used a separate mux baseline built by enumerating live Agents. After a Host process restart, a persisted ordinary Session remained cold until an operation needed its Agent, so the live-only baseline omitted accepted pending messages that were still present in the durable log.
+
+A reconnect-only repair would retain two recovery implementations: one for a live Inbox and one for cold Web reads. The correct owner is the Inbox domain, and the session-projection framework already provides live drive, cold folding, reconnect baselines, and cache restoration.
+
+## Decision
+
+When composed with the Session projection registry, `AgentLoop` registers the standard `inbox` projection at service activation so cold Sessions can be read without a live Agent. The [claimed Inbox lifecycle](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns splice normalization, message uniqueness, live notifications, and durable reconstruction. The projection shares one schema and `InboxState` definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator.
+
+The registry folds committed splices before `Session.append()` returns; each Agent's `ReactLoopInbox` command facade reads that same live state rather than keeping another fold.
+
+The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block in each `session.follow` opening snapshot, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration.
+
+A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all incoming baselines obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer list or history values.
+
+The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection.
+
+`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before mutating its Inbox. A restored pending row therefore remains editable, removable, or steerable after restart, while subagent ownership keeps the same fence as other Agent operations.
+
+No new session event or on-disk format is introduced. The existing splice stream remains the durable source of truth.
+
+## Verification
+
+Host projection coverage reads a detached persisted Session with pending input, returns `values.inbox` in the opening `session.follow` snapshot, and proves that no live Agent is required. Cold-operation coverage proves `session.updateQueue` resumes the Session and appends the durable removal splice.
+
+Client coverage pins generic Inbox projection delivery, reconnect invalidation for omitted cold Sessions, both baseline arrival orders, obsolete list request outcomes, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart.
+
+## Alternatives considered
+
+**Add cold Sessions to the old queue reconnect loop.** Rejected because it would duplicate the projection registry's cold fold and preserve separate implementations for live pushes, history, cache, and reconnect.
+
+**Register a Web-specific `queue` projection in Session Controller.** Rejected because pending input belongs to Inbox. Placement rows and a handoff list would introduce a second domain model solely for one client.
+
+**Store a complete Inbox snapshot on every splice event.** Rejected because the durable event is a normalized mutation, not a repeated aggregate. The projection framework owns aggregate reconstruction and checkpointing.
+
+**Reconstruct Inbox in the client from raw session events.** Rejected because pagination may omit the insertion that established current state and every client would duplicate splice semantics.
+
+**Resume every cold Agent while opening the mux stream.** Rejected because displaying durable state must not publish runtime resources, mount presets, or start lifecycle work.
+
+## Consequences
+
+Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks.
+
+Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change.

+ 51 - 0
.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 从持久 Inbox 状态恢复 Web Queue
+
+Status: implemented
+
+[English](2026-08-17-durable-web-queue-recovery.md) | 中文
+
+## 问题
+
+Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 Web Queue 使用另一份通过枚举 live Agent 构建的 mux 基线。Host 进程重启后,持久化的普通 Session 会保持冷状态,直到某项操作需要其 Agent,因此 live-only 基线会遗漏仍存在于持久日志中的已接受待处理消息。
+
+只修复重连逻辑仍会保留两套恢复实现:一套用于 live Inbox,另一套用于 Web 冷读取。正确的所有者是 Inbox 领域,而会话投影框架已经提供 live 驱动、冷折叠、重连基线和缓存恢复。
+
+## 决策
+
+组合了 Session projection registry 时,`AgentLoop` 在服务激活时注册标准 `inbox` 投影,使冷 Session 无需 live Agent 即可读取。[Inbox 认领生命周期](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md) 定义 splice 规范化、消息唯一性、live 通知和持久重建。投影共用一份 schema 与 `InboxState` 定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。
+
+注册表在 `Session.append()` 返回前折叠已提交的 splice;每个 Agent 的 `ReactLoopInbox` 命令 facade 都读取同一份 live 状态,而不另行维护折叠状态。
+
+通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在每次 `session.follow` 的起始快照中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。
+
+对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有收到的 baseline 都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 list 或 history 值。
+
+客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。
+
+`session.updateQueue` 在修改 Inbox 前通过共享 Agent 解析器解析普通冷 Session。因此,恢复出的待处理行在重启后仍可编辑、移除或 steering,而 subagent ownership 保持与其他 Agent 操作相同的 fence。
+
+系统没有引入新的会话事件或磁盘格式。既有 splice 流仍是持久真源。
+
+## 验证
+
+Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在 `session.follow` 的起始快照中返回 `values.inbox`,并证明不需要 live Agent。冷操作覆盖证明 `session.updateQueue` 会恢复 Session 并追加持久删除 splice。
+
+客户端覆盖固定通用 Inbox 投影投递、重连时清理遗漏冷 Session 的旧值、基线的两种到达顺序、过期 list 请求的结果、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。
+
+## 考虑过的替代方案
+
+**把冷 Session 加入旧 queue 重连循环。** 不予采纳,因为这会重复投影注册表的冷折叠,并让实时推送、历史、缓存和重连继续使用不同实现。
+
+**在 Session Controller 注册 Web 专属 `queue` 投影。** 不予采纳,因为待处理输入属于 Inbox。placement 行与 handoff 列表会只为一个客户端引入第二套领域模型。
+
+**在每条 splice 事件中保存完整 Inbox 快照。** 不予采纳,因为持久事件是规范化变更,不是重复聚合。聚合重建与 checkpoint 属于投影框架。
+
+**在客户端根据原始会话事件重建 Inbox。** 不予采纳,因为分页可能省略建立当前状态的插入事件,每个客户端也会重复实现 splice 语义。
+
+**打开 mux 流时恢复每个冷 Agent。** 不予采纳,因为展示持久状态不应发布运行时资源、挂载 preset 或启动生命周期工作。
+
+## 后果
+
+待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。
+
+客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.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-desktop-profile-core-cleanup.md
+2026-09-15-desktop-profile-core-cleanup.md: 7a5f3eea561751f88025f3d091e90f2b932e4f87
+2026-09-15-desktop-profile-core-cleanup.zh.md: 4a9cad94a89ca16779bd1a69bc4035f9b9c4c12c

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.md

@@ -0,0 +1,27 @@
+# Agent Note: Clean application-owned packages before Desktop production boot
+
+Status: implemented
+
+English | [中文](2026-09-15-desktop-profile-core-cleanup.zh.md)
+
+## Problem
+
+Old Desktop profiles contain installed core packages and local tarball dependency declarations. Local package precedence can combine an old Web frontend with new plugins even when the application carries a consistent release. Development fallback links also remain when users switch to an installed application.
+
+## Decision
+
+Production Desktop cleans profile copies of packages named in the verified runtime descriptor or the old Desktop package-set record before starting the Host, under the existing profile lock. Cleanup removes matching dependency declarations and pnpm overrides, invalidates the lockfile when package state changes, and unlinks fallback links without deleting their targets. Other plugins, bundle selections, configuration, and session data remain. Development skips cleanup.
+
+The implementation and its temporary enable constant live in `apps/desktop/src/profile-core-cleanup.ts`, with one call in profile preparation. Cleanup runs on every production startup because development or package operations can recreate residue. This qualifies package retention in the [in-place profile decision](../architecture/2026-09-09-desktop-in-place-profile.md); direct writes and failure recovery remain unchanged.
+
+## Alternatives considered
+
+**Installer-only cleanup** misses other user profiles and packages recreated after installation; both platforms use startup cleanup.
+
+**Deleting every organization-prefixed package** can remove optional official plugins. Explicit current and historical package inventories determine ownership.
+
+**Deleting package directories alone** lets pnpm reinstall the same old packages from retained declarations and overrides.
+
+## Consequences
+
+Production loses profile-local overrides of application-owned packages. A changed profile loses its lockfile and the next pnpm operation resolves remaining plugin dependencies again. Cleanup does not run pnpm or create rollback state; failures stop preparation and can be retried. Redirected package-parent directories fail before deletion. Focused tests cover retained plugins, declarations, repeated cleanup, development exclusion, retired packages, invalid records, and link targets.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-15-desktop-profile-core-cleanup.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 在 Desktop 生产启动前清理应用管理的包
+
+Status: implemented
+
+[English](2026-09-15-desktop-profile-core-cleanup.md) | 中文
+
+## Problem
+
+旧 Desktop profile 包含已安装的核心包及本地 tarball 依赖声明。即使应用携带一致的发布产物,本地包优先规则仍可能把旧 Web 前端与新插件组合起来。用户切换到已安装应用时,开发模式的回退链接也会残留。
+
+## Decision
+
+生产版 Desktop 在启动 Host 前,持有现有 profile 锁,清理已验证运行时描述符或旧 Desktop 包清单记录列出的包副本。清理删除对应依赖声明和 pnpm overrides,在包状态变化时使锁文件失效,并解除回退链接而不删除其目标。其他插件、bundle 选择、配置和会话数据保留。开发模式跳过清理。
+
+实现与临时启用常量集中在 `apps/desktop/src/profile-core-cleanup.ts`,由 profile 准备流程中的一个调用接入。每次生产启动都执行清理,因为开发模式或包操作可能重新产生残留。这限定了[原地修改 profile 决策](../architecture/2026-09-09-desktop-in-place-profile.zh.md)中的包保留范围;直接写入和失败恢复保持不变。
+
+## Alternatives considered
+
+**仅在安装器中清理**会遗漏其他用户的 profile 和安装后重新产生的包;两个平台都在启动时清理。
+
+**删除组织名前缀下的所有包**可能误删可选官方插件。明确的当前及历史包清单决定归属。
+
+**仅删除包目录**会让 pnpm 根据保留的声明和 overrides 重新安装相同旧包。
+
+## Consequences
+
+生产版不再使用 profile 对应用管理包的本地覆盖。发生变化的 profile 丢弃锁文件,下次 pnpm 操作会重新解析其余插件依赖。清理不运行 pnpm,也不创建回滚状态;失败会停止准备流程,之后可以重试。重定向的包父目录会在删除前报错。定向测试覆盖插件保留、声明、重复清理、开发模式排除、退役包、无效记录及链接目标。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.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-08-08-web-background-job-display.md
-2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27
-2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a
+2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17
+2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md

@@ -81,7 +81,7 @@ Four rules the carrier keeps:
 
 `SessionListState` carries `jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation.
 
-It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel.
+It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, no composer behavior depends on tasks, and a later sidebar indicator gets the data without opening a second channel.
 
 Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md

@@ -81,7 +81,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
 
 `SessionListState` 带有 `jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。
 
-它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。
+它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。
 
 两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.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-composer-selection-keyboard.md
+2026-09-14-composer-selection-keyboard.md: fb353a7072e7c2245aeeb22af9018edd9a6009f0
+2026-09-14-composer-selection-keyboard.zh.md: f51e5ca5096785ae6d059e3c8497292483490437

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 44 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 44 - 0
.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md


+ 2 - 2
.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.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-14-desktop-primary-runtime.md
-2026-09-14-desktop-primary-runtime.md: 54dfba5d786b0e557f3e52b104a89e53fb091ecb
-2026-09-14-desktop-primary-runtime.zh.md: f57f2dbf5efdda9c09aefa3fd78cb9f0f2a7b8ea
+2026-09-14-desktop-primary-runtime.md: 17d489e6c788c786cefcdddf5f1983ac48522bfe
+2026-09-14-desktop-primary-runtime.zh.md: f8be37801452ebf4f4623a1ad0342de770e21ad5

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

@@ -18,6 +18,8 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks these
 
 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.
 
+Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes.
+
 ## Alternatives considered
 
 **System interpreters only.** They do not provide predictable availability or preinstalled numpy and pandas.

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

@@ -18,6 +18,8 @@ Node 下载并校验完整锁定 wheel 集的哈希,将这些仅含库的压
 
 macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
 
+Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。
+
 ## Alternatives considered
 
 **只使用系统解释器。** 无法保证可用性或预装 numpy 和 pandas。

+ 2 - 2
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.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-09-12-default-product-experimental-isolation.md
-2026-09-12-default-product-experimental-isolation.md: d2312d6061f372e910bfd625512230ccd48a586a
-2026-09-12-default-product-experimental-isolation.zh.md: 6ea86f1f3e859719ffd8287e66ebe63df77d64c2
+2026-09-12-default-product-experimental-isolation.md: 5c2485e4f737b822088a680b0006f395a5479fa9
+2026-09-12-default-product-experimental-isolation.zh.md: 3a412a491ab8356a4e29d706fed55226dc0f6038

+ 1 - 1
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.md

@@ -10,7 +10,7 @@ Public npm availability does not make an experimental package part of the defaul
 
 ## Decision
 
-[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification.
+[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) runs in static CI and package hygiene. It follows runtime dependencies, optional dependencies, and peers from every app and the Python runtime, resolves workspace and npm aliases, and identifies experimental packages by their npm prefix or repository directory. Publication denylist membership has no effect on this classification. The bundles the launcher names in `OPTIONAL_BUNDLES` are the one declared exception ([shipped optional bundles](2026-09-15-shipped-optional-bundles.md)).
 
 The source check also reads runtime imports in the selected packages, installation-owned profile bundle lists, bundle patches, shipped agent presets, and declared configuration trees. It loads the default Web layers with the production patch parser and composes them with the same patch engine used at boot. The effective rows and patched Include trees are checked, so an id-only patch cannot hide a replacement group's plugins. Disabled plugin rows remain checked; ordinary plugin configuration data is not interpreted as another Loader entry list. Missing default roots fail the check.
 

+ 1 - 1
.agents/notes/implemented/process/2026-09-12-default-product-experimental-isolation.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。
+[`verify-default-product-isolation`](../../../../scripts/verify-default-product-isolation.ts) 在静态 CI 和包 hygiene 中运行。它从所有应用与 Python runtime 出发,遍历运行时依赖、可选依赖和 peer,解析 workspace 与 npm 别名,并按 npm 前缀或仓库目录识别实验包。发布 denylist 的成员关系不影响此分类。启动器在 `OPTIONAL_BUNDLES` 里点名的组合包是唯一声明的例外([随安装提供的可选组合包](2026-09-15-shipped-optional-bundles.zh.md))。
 
 源码检查还读取所选包的运行时导入、安装自带的 profile bundle 列表、bundle patch、随产品提供的 Agent preset,以及声明的配置树。它使用生产 patch 解析器加载默认 Web 各层,并使用启动时的同一个 patch 引擎完成组合。检查对象包括最终 entry 和应用 patch 后的 Include 树,因此仅按 id 覆盖 group 的 patch 也无法隐藏替换后的插件。禁用的插件行仍纳入检查;普通插件配置数据不会被解释为另一个 Loader entry 列表。默认入口缺失会使检查失败。
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.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-15-pr-approval-delegation.md
+2026-09-15-pr-approval-delegation.md: 4dcf0e8a8d23d66da11381586e376fdf39d23635
+2026-09-15-pr-approval-delegation.zh.md: bf21724d672aaf534bfe94540b76878177eeb57e

+ 33 - 0
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md

@@ -0,0 +1,33 @@
+# Agent Note: PR-scoped approval delegation
+
+Status: implemented
+
+English | [中文](2026-09-15-pr-approval-delegation.zh.md)
+
+## Problem
+
+A reviewer may trust another reviewer to decide a particular PR while retaining the score associated with their own repository role and code ownership. Counting both the original approval and transferred points would inflate that reviewer's contribution.
+
+## Decision
+
+The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. The command dismisses the sender's prior approvals and change requests through GitHub. Other effective blocking reviews still follow [blocking-review policy](2026-09-09-blocked-weighted-approvals-remain-pending.md).
+
+The latest surviving eligible command in comment creation order determines the recipient. Edited commands require the latest editor to match the author’s immutable account ID; other writers cannot transfer or cancel that author’s points by editing a comment. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher filters conversation comment changes for `/delegate`, resolves the live open PR head, and reads comments and editor identity as API data. Every evaluation reconciles active eligible commands, dismissing prior decision reviews and requesting recipients unless already approved or requested. This covers replaced queued events and draft-to-ready transitions; other requested reviewers remain unchanged. Scores refresh after dismissal, which retains the old submission timestamps and cannot cancel delegation. Removing the recipient's approval removes counted points while retaining delegation for their next approval. Drafts do not dismiss or request reviews. Dismissal failures fail evaluation; request failures are logged separately and retried on later evaluations. Closed or merged PRs are skipped before status writes and dependency setup. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md).
+
+## Alternatives considered
+
+**Count delegation as immediate approval.** This would approve a PR before the chosen reviewer makes a decision.
+
+**Add the sender's score without removing their direct contribution.** This would count one account twice and weaken the approval threshold.
+
+**Forward received points through delegation chains.** This would let a recipient transfer another person's points to an account that person did not name.
+
+**Only suppress the sender's old decision in the score.** GitHub would retain the old approval or blocking review. Dismissing it keeps native review state consistent with the handoff.
+
+**Keep delegation after the sender reviews.** A submitted review expresses the sender's own decision, so continuing to use someone else's approval would disregard it.
+
+**Persist commands separately from comments.** This would require additional storage and reconciliation for edits and deletions; current comments already provide an inspectable record.
+
+## Consequences
+
+Delegation preserves the sender's [production ownership weight](2026-09-11-production-blame-approval-weight.md) and the independent author-credit rule. Each evaluation needs complete comment and editor history and current participant permissions; missing history fails evaluation. Editing an older command does not change its priority, and deleting a newer command may reactivate an older one. Policy tests cover score conservation, review-driven revocation, prior-review dismissal, permission filtering, review requests, blockers, pagination, and failure publication; workflow tests pin trusted execution and shared per-PR concurrency. Native stale-review and latest-push requirements remain GitHub's responsibility.

+ 33 - 0
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: PR 范围内的批准委托
+
+Status: implemented
+
+[English](2026-09-15-pr-approval-delegation.md) | 中文
+
+## Problem
+
+评审人可能信任另一位评审人来决定某个 PR(Pull Request),同时保留自己的仓库角色和代码归属对应的分数。如果同时计入原始批准和转交的分数,就会放大该评审人的贡献。
+
+## Decision
+
+[批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。命令会通过 GitHub 撤销委托人原有的批准和变更请求。其他有效阻塞评审仍遵循[阻塞评审策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)。
+
+按评论创建顺序,最新、仍存在且符合资格的命令决定受托人。编辑过的命令要求最近编辑者与作者的不可变账户 ID 一致;其他有写权限的人不能通过编辑评论转交或取消作者的分数。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器按 `/delegate` 筛选普通评论变更,解析实时且未关闭的 PR 头提交,并将评论及编辑者身份作为 API 数据读取。每次评估都会处理所有有效且符合资格的命令,撤销旧的决定性评审,并请求尚未批准、尚未收到请求的受托人进行评审。这覆盖了排队事件被替换以及草稿转为 ready 的情况;其他 requested reviewers 保持不变。撤销后刷新分数;撤销保留旧提交时间戳,不会取消委托。移除受托人的批准只会移除计入的分数,委托仍然保留,供其下次批准使用。草稿 PR 不撤销或请求评审。撤销失败会使评估失败;请求失败单独记录日志,并在后续评估中重试。已关闭或合并的 PR 会在写入状态和安装依赖之前跳过。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。
+
+## Alternatives considered
+
+**把委托直接视为批准。** 这会在选定的评审人作出决定前批准 PR。
+
+**增加委托人的分数,同时保留其直接贡献。** 这会将同一账户计算两次,削弱批准阈值。
+
+**沿委托链继续转交收到的分数。** 这会允许受托人将他人的分数转给未经原委托人指定的账户。
+
+**只在分数中忽略委托人的旧决定。** GitHub 会保留原有批准或阻塞评审。撤销旧评审使原生评审状态与委托一致。
+
+**委托人提交评审后仍保留委托。** 已提交的评审表达了委托人本人的决定,继续使用他人的批准会忽略该决定。
+
+**在评论之外单独持久化命令。** 这需要额外存储,并协调编辑与删除;当前评论已经提供可检查的记录。
+
+## Consequences
+
+委托保留委托人的[生产代码归属权重](2026-09-11-production-blame-approval-weight.zh.md)和独立的作者信用规则。每次评估都需要完整评论与编辑者历史和参与者的当前权限;历史缺失会使评估失败。编辑较早命令不会改变其优先级,删除较新命令可能重新启用较早命令。策略测试覆盖分数守恒、评审触发的收回、旧评审撤销、权限过滤、评审请求、阻塞评审、分页和失败状态发布;工作流测试约束可信执行及每个 PR 共享的并发分组。过期评审和最新推送要求仍由 GitHub 原生规则负责。

+ 6 - 0
.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.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-15-shipped-optional-bundles.md
+2026-09-15-shipped-optional-bundles.md: 6de3eb7d39b86a2e5dd6db756d2bf6561aeb8ed4
+2026-09-15-shipped-optional-bundles.zh.md: 05c23c7c0cdd6a146b938370fec64377652e31d4

+ 29 - 0
.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md

@@ -0,0 +1,29 @@
+# Agent Note: Ship optional bundles with the installation
+
+Status: implemented
+
+English | [中文](2026-09-15-shipped-optional-bundles.zh.md)
+
+## Problem
+
+The Web plugin page manages only the bundles a person installed into the profile. An official experimental layer such as Agent Teams had to be found on npm and installed by name before it could be switched on, and [default-product isolation](2026-09-12-default-product-experimental-isolation.md) kept every experimental package out of the installation's runtime dependencies, so nothing shipped with dsh could offer it.
+
+## Decision
+
+The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts`, beside the profile templates) the bundles the installation ships for a person to switch on. Each must be a runtime dependency of `apps/cli` that declares `dsh.bundle.patch`, and no shipped profile template selects it. The plugin manager's `listBundles` reports such a bundle as `optional`: switched off until selected, never removable, resolved from the installation like any installation-supplied bundle. The Web plugin page lists optional bundles in a built-in group with an official tag beside the profile's own installed bundles.
+
+Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
+
+Agent Teams and Auto review ship this way first, as `@deepseek-ai/dsh-experimental-agent-team-profile`, `@deepseek-ai/dsh-experimental-agent-team-web-profile`, and `@deepseek-ai/dsh-experimental-auto-review`.
+
+## Alternatives considered
+
+**A catalog of installable official bundles.** The page would offer names to install from the registry on demand. That keeps the installation unchanged but needs network access at the moment of switching on and a version pin per release.
+
+**A flag on the bundle package.** A `dsh.bundle.optional` declaration would let any published bundle claim a place in the installation; the launcher's own list keeps the choice with the product.
+
+**A list in the installation's manifest.** `dsh.optionalBundles` in `apps/cli/package.json` was the first form; the maintainers keep product decisions in code, where the list is typed, read once, and shared by the manager and the gates.
+
+## Consequences
+
+Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically.

+ 29 - 0
.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Ship optional bundles with the installation
+
+Status: implemented
+
+[English](2026-09-15-shipped-optional-bundles.md) | 中文
+
+## 问题
+
+Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样的官方实验层,用户得先去 npm 找到包名并按名安装才能开启;而[默认产品隔离](2026-09-12-default-product-experimental-isolation.zh.md)又把所有实验包挡在安装的运行时依赖之外,所以随 dsh 一起交付的东西没有办法把它提供出来。
+
+## 决策
+
+启动器在 `OPTIONAL_BUNDLES`(`packages/boot/app-boot/src/profile.ts`,与 profile 模板并列)里点名安装随附、供用户开启的组合包。每一个都必须是 `apps/cli` 声明了 `dsh.bundle.patch` 的运行时依赖,且不被任何随附 profile 模板选中。插件管理器的 `listBundles` 把这类组合包报告为 `optional`:选中前保持关闭、永不可卸载、像其他安装提供的组合包一样从安装目录解析。Web 插件页把可选组合包放在带官方标签的内置分组里,与 profile 自己安装的组合包并列。
+
+默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
+
+Agent Teams 与 Auto review 首先以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile`、`@deepseek-ai/dsh-experimental-agent-team-web-profile` 与 `@deepseek-ai/dsh-experimental-auto-review`。
+
+## 考虑过的替代方案
+
+**可安装官方组合包目录。** 页面按需提供从注册表安装的包名。安装本身不变,但开启那一刻需要网络,并且每次发布都要钉一个版本。
+
+**组合包自身的标记。** `dsh.bundle.optional` 声明会让任何已发布的组合包都能自称随安装提供;由启动器自己的列表来决定,选择权留在产品手里。
+
+**放在安装 manifest 里的列表。** `apps/cli/package.json` 的 `dsh.optionalBundles` 是最初的形式;维护者把产品决定放在代码里,列表有类型、只读一次,管理器与门禁共用。
+
+## 影响
+
+可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md
+2026-09-15-host-only-remote-input-validation.md: 5c256fb5f5b78b646c645a44b77718fa40a62399
+2026-09-15-host-only-remote-input-validation.zh.md: 92a3f2415c6206a0513d8338bbb8d0844bf5d43d

+ 30 - 0
.agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.md

@@ -0,0 +1,30 @@
+# Agent Note: Validate Remote inputs only on the Host
+
+Status: implemented
+
+English | [中文](2026-09-15-host-only-remote-input-validation.zh.md)
+
+## Problem
+
+Generated Client Remote methods expose TypeScript signatures and forward calls through Connection to a Host Gateway that already checks exact argument fields, executes each strict input codec, and verifies JSON data before lookup or business invocation. Executing the corresponding schema for every Client argument duplicates this validation, materializes otherwise lazy Zod schemas in the Client, and gives invalid JavaScript calls a different failure path depending on which side rejects them first.
+
+The Client still needs descriptor metadata to check positional arity, map values to named wire fields, bind a scoped Context identity, omit an explicitly undefined optional value, and combine cancellation. None of these operations requires executing a runtime schema.
+
+## Decision
+
+Client Remote validates descriptor integrity when a contribution mounts, then forwards typed arguments and bound Context identities without calling invocation codec factories. It continues to reject wrong positional arity and missing Client Context bindings locally. Successful unary results and stream items also pass through without Client-side type parsing.
+
+The Host Gateway owns runtime input validation. It checks the exact named fields, executes strict parameter and identity codecs, verifies JSON values, and completes lookup before invoking business code. A JavaScript caller that bypasses the generated TypeScript API receives the Host's `gateway/input-invalid` result when the request reaches the Host; a value that cannot enter the carrier may instead fail serialization.
+
+Generated Remote contributions retain codec metadata because `InvocationDescriptor` remains shared between Host and Client artifacts and Client mounting still requires strict codecs for every Client-supplied field. The broader Remote architecture remains in [Typert-generated Remote method calls](../architecture/2026-08-02-typert-remote-method-calls.md); this decision supersedes only its Client-side invocation codec execution.
+
+## Alternatives considered
+
+- **Keep Client and Host input parsing.** This gives a malformed JavaScript caller an earlier local error and strips undeclared object properties before transport, but every valid call pays for duplicate schema materialization and parsing even though the Host must validate independently.
+- **Remove codec metadata from Remote Client artifacts.** This could reduce generated Client code further, but it changes the shared descriptor and generator protocol. Keeping lazy factories preserves strict contribution checks without paying runtime schema construction cost.
+
+## Consequences
+
+Normal Client calls allocate no invocation schema and perform no duplicate Zod parse. Host validation remains the authority before lookup and business execution, while Client code retains arity, Context binding, cancellation, and contribution-lifecycle failures.
+
+Malformed runtime values fail later than before. Undeclared object properties may cross the trusted carrier before the Host codec removes them, so callers that derive requests from untrusted or secret-bearing objects must construct the declared DTO rather than relying on Client parsing as a redaction step. Client tests pin unchanged forwarding, and Host tests pin strict and JSON input rejection.

+ 30 - 0
.agents/notes/implemented/simplification/2026-09-15-host-only-remote-input-validation.zh.md

@@ -0,0 +1,30 @@
+# Agent Note: 仅在 Host 校验 Remote 输入
+
+Status: implemented
+
+[English](2026-09-15-host-only-remote-input-validation.md) | 中文
+
+## Problem
+
+生成的 Client Remote 方法公开 TypeScript 签名,并通过 Connection 把调用转给 Host Gateway;Host Gateway 已经在 lookup 或业务调用前检查精确参数字段、执行每个严格输入 codec,并验证 JSON 数据。Client 再为每个参数执行对应 schema 会重复这次校验,在 Client 中实例化原本惰性创建的 Zod schema,还会让无效 JavaScript 调用根据哪一侧先拒绝而走不同的失败路径。
+
+Client 仍需要 descriptor 元数据来检查位置参数数量、把值映射为具名 wire 字段、绑定 scoped Context identity、省略显式为 undefined 的可选值,以及合并取消信号。这些操作都不需要执行运行时 schema。
+
+## Decision
+
+Client Remote 在挂载 contribution 时校验 descriptor 完整性,随后直接转发带类型的参数与绑定的 Context identity,不调用 invocation codec factory。位置参数数量错误与 Client Context binding 缺失仍在本地拒绝。成功的一元结果与流项同样不经 Client 侧类型解析直接传递。
+
+Host Gateway 拥有运行时输入校验。它检查精确具名字段、执行严格参数与 identity codec、验证 JSON 值,并在调用业务代码前完成 lookup。绕过生成 TypeScript API 的 JavaScript 调用方,其请求到达 Host 后会收到 Host 的 `gateway/input-invalid` 结果;无法进入载体的值则可能在序列化时失败。
+
+生成的 Remote contribution 继续携带 codec 元数据,因为 Host 与 Client 产物仍共享 `InvocationDescriptor`,Client 挂载也仍要求每个 Client 供值字段具备严格 codec。完整 Remote 架构见 [Typert 生成的 Remote 方法调用](../architecture/2026-08-02-typert-remote-method-calls.zh.md);本决策只取代其中 Client 侧执行调用 codec 的部分。
+
+## Alternatives considered
+
+- **同时保留 Client 与 Host 输入解析。** 这样能让畸形 JavaScript 调用方更早收到本地错误,并在传输前剔除对象中的未声明属性;但即使 Host 必须独立校验,每次有效调用仍要重复实例化并执行 schema。
+- **从 Remote Client 产物中移除 codec 元数据。** 这样可以进一步缩小生成的 Client 代码,但会改变共享 descriptor 与 generator 协议。保留惰性 factory 能继续检查严格 contribution,同时不产生运行时 schema 构造成本。
+
+## Consequences
+
+正常 Client 调用不再分配 invocation schema,也不再重复执行 Zod parse。Host 校验仍是 lookup 与业务执行前的权威检查,Client 代码则保留参数数量、Context binding、取消与 contribution 生命周期故障。
+
+畸形运行时值会比以前更晚失败。未声明的对象属性可能在 Host codec 剔除它们之前经过可信载体,因此从不可信对象或含秘密对象派生请求的调用方必须构造已声明 DTO,不能把 Client 解析当作脱敏步骤。Client 测试固定原样转发行为,Host 测试固定严格输入与 JSON 输入拒绝。

+ 2 - 2
.agents/notes/proposed/feature/2026-08-04-task-surface.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/proposed/feature/2026-08-04-task-surface.md
-2026-08-04-task-surface.md: d5d04a7ac8aee9936e1913430c2efa6e6c005206
-2026-08-04-task-surface.zh.md: ecba9ef7f9b45bb9f12673c30d9a9b47fb93a30e
+2026-08-04-task-surface.md: 749ce9e40eea7aeb7a813dc6b2d4c139cf00ac44
+2026-08-04-task-surface.zh.md: 4a63e671ffe05c80dd8bc8ed3a49ab6ff5c44b7e

+ 2 - 14
.agents/notes/proposed/feature/2026-08-04-task-surface.md

@@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource {
 }
 ```
 
-The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation:
-
-```ts ignore-check
-interface QueuedMessage {
-  id: InboxItemId
-  messageId: MessageId
-  placement: 'queued' | 'steering'
-  source: MessageSource
-  content: readonly ContentBlock[]
-  preview: string
-  text: string | null
-}
-```
+The standard `inbox` projection already carries each complete `UserMessage`, including its `MessageId`, source, and content, in the raw `next-turn` or `next-step` list. The client retains that value in its generic projection store, so this proposal needs no queue transport extension or second pending-message type.
 
 The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction.
 
@@ -198,7 +186,7 @@ The product shell owns collapse and dismiss. Collapse is local view state and se
 
 Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface.
 
-The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
+The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent claims that message for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before the durable deletion splice removes it from the generic `inbox` projection. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
 
 The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again.
 

+ 2 - 14
.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md

@@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource {
 }
 ```
 
-`session/queue` 协议条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息:
-
-```ts ignore-check
-interface QueuedMessage {
-  id: InboxItemId
-  messageId: MessageId
-  placement: 'queued' | 'steering'
-  source: MessageSource
-  content: readonly ContentBlock[]
-  preview: string
-  text: string | null
-}
-```
+标准 `inbox` 投影已经在原始 `next-turn` 或 `next-step` 列表中携带每条完整 `UserMessage`,包括 `MessageId`、source 与 content。客户端会把该值保存在通用 projection store 中,因此本提案不需要扩展 queue 传输,也不需要第二种待处理消息类型。
 
 浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。
 
@@ -198,7 +186,7 @@ interface QueuedMessage {
 
 客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中,Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。
 
-Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
+Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 为普通提示词接纳认领该消息时,服务会先同步把同一份待处理记录改为 `claiming`,随后持久删除 splice 才会从通用 `inbox` 投影移除该消息。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
 
 匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose(资源释放)时,系统会报告丢弃、清除认领状态,并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive`:`pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态,不是第二个持久权威来源;Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。
 

+ 17 - 4
.github/review-ownership/README.md

@@ -2,11 +2,12 @@
 
 ## Summary
 
-The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewer selection and review requests remain manual.
+The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewers are chosen manually; an eligible delegation command requests review from its recipient.
 
 ## Table of Contents
 
 - [Approval scoring](#approval-scoring)
+- [Delegating points](#delegating-points)
 - [Security](#security)
 - [Verification](#verification)
 - [Dev Note](#dev-note)
@@ -27,13 +28,23 @@ Production source means supported code files under `src/` in `packages/`, `apps/
 
 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, 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.
+The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment 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 conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
+
+<a id="delegating-points"></a>
+
+## Delegating points
+
+Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests.
+
+Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving eligible, author-authorized command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points.
+
+Each sender contributes at most once, using their own fixed weight or production-line ownership. A delegate can receive points from multiple senders, but can transfer only their own points: delegated points are not forwarded through another delegation. Author credit cannot be delegated. Other reviewers' effective `CHANGES_REQUESTED` decisions still block the PR. The sender's old reviews are dismissed through GitHub, so their old change request stops blocking only after dismissal succeeds. That dismissal retains the original submission timestamp and does not cancel delegation; only a review submitted by the sender at or after the command automatically reclaims their points. Dismissing or replacing the delegate's approval removes the counted points but leaves the delegation active for the delegate's next approval. Logs identify each counted score owner and their delegate. Comment history failures or the 3,000-record limit fail evaluation instead of accepting a partial history.
 
 <a id="security"></a>
 
 ## Security
 
-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, reviewer points plus author credit 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.
+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, reviewer points plus author credit 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 and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
 
 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.
 
@@ -41,10 +52,12 @@ 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, merged-history pagination and account matching, lazy author credit, 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.
+Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, 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
 
 [Production blame weighting](../../.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md) records the scoring rationale and measured costs.
+
+[PR-scoped delegation](../../.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md) records score ownership and revocation choices.

+ 209 - 42
.github/review-ownership/check-approval.mjs

@@ -1,6 +1,6 @@
 #!/usr/bin/env node
 
-import { readFileSync } from 'node:fs'
+import { appendFileSync, readFileSync } from 'node:fs'
 import process from 'node:process'
 import { pathToFileURL } from 'node:url'
 
@@ -8,7 +8,7 @@ import { productionOwnership, LOGIN } from './blame-ownership.mjs'
 import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs'
 
 const API_VERSION = '2026-03-10'
-const MAX_PULL_REQUEST_REVIEWS = 3_000
+const MAX_PULL_REQUEST_RECORDS = 3_000
 const PAGE_SIZE = 100
 const STATUS_CONTEXT = 'weighted approval'
 const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
@@ -116,22 +116,100 @@ export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetc
  * @returns {Promise<unknown[]>} Complete review list within the supported limit.
  */
 export async function listPullRequestReviews(api, repository, pullNumber) {
-  const reviews = []
+  return listRecords(api, `/repos/${repository}/pulls/${pullNumber}/reviews`, 'pull-request reviews')
+}
+
+async function listRecords(api, path, subject) {
+  const records = []
   for (let page = 1; ; page++) {
-    const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`)
-    if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array')
-    reviews.push(...response)
-    if (response.length < PAGE_SIZE) return reviews
-    if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) {
-      throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} records`)
+    const response = await api(`${path}?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response)) throw new Error(`${subject} response is not an array`)
+    records.push(...response)
+    if (response.length < PAGE_SIZE) return records
+    if (records.length >= MAX_PULL_REQUEST_RECORDS) {
+      throw new Error(`${subject} exceed ${MAX_PULL_REQUEST_RECORDS} records`)
+    }
+  }
+}
+
+async function delegationCommands(api, comments) {
+  const candidates = []
+  for (const comment of comments) {
+    if (!isRecord(comment)) throw new Error('pull-request comment is not an object')
+    if (comment.user === null) continue
+    if (typeof comment.body !== 'string') throw new Error('pull-request comment has no body')
+    const match = /^\/delegate @([^\s]+)$/u.exec(comment.body.trim())
+    if (!match || !LOGIN.test(match[1])) continue
+    const login = validateLogin(comment.user?.login, 'delegation author')
+    if (!Number.isSafeInteger(comment.id) || comment.id <= 0) throw new Error('delegation comment has no valid ID')
+    if (!comment.node_id || !comment.user.node_id) throw new Error('delegation comment has no account or node ID')
+    candidates.push({ comment, login, delegate: match[1], createdAt: timestamp(comment.created_at, 'delegation comment') })
+  }
+  const commands = []
+  for (let offset = 0; offset < candidates.length; offset += PAGE_SIZE) {
+    const batch = candidates.slice(offset, offset + PAGE_SIZE)
+    const response = await api('/graphql', {
+      method: 'POST',
+      body: {
+        query: `query($ids: [ID!]!) {
+          nodes(ids: $ids) { ... on IssueComment {
+            id body createdAt lastEditedAt
+            author { ... on Node { id } }
+            editor { ... on Node { id } }
+          } }
+        }`,
+        variables: { ids: batch.map(({ comment }) => comment.node_id) },
+      },
+    })
+    const nodes = response?.data?.nodes
+    if (response?.errors?.length || !Array.isArray(nodes) || nodes.length !== batch.length) {
+      throw new Error('delegation comment editor history is incomplete')
+    }
+    for (const [index, command] of batch.entries()) {
+      const node = nodes[index]
+      const { comment } = command
+      if (node?.id !== comment.node_id || node.body !== comment.body || node.createdAt !== comment.created_at
+        || node.author?.id !== comment.user.node_id || node.lastEditedAt === undefined) {
+        throw new Error('delegation comment changed or editor history is incomplete')
+      }
+      // Other writers can edit a comment without changing its original author.
+      if (node.lastEditedAt !== null && node.editor?.id !== node.author.id) continue
+      commands.push(command)
     }
   }
+  return commands
+}
+
+// Reviews must first pass effectiveReviewDecisions; commands retain creation order after edits.
+function effectiveDelegations(commands, reviews, writers) {
+  const delegations = new Map()
+  for (const { login, delegate, comment, createdAt } of commands) {
+    const key = login.toLowerCase()
+    if (!writers.has(key) || !writers.has(delegate.toLowerCase())) continue
+    if (key === delegate.toLowerCase()) delegations.delete(key)
+    else delegations.set(key, { delegate: delegate.toLowerCase(), commentId: comment.id, createdAt })
+  }
+  for (const review of reviews) {
+    if (review.user === null || review.state.toUpperCase() === 'PENDING') continue
+    const login = review.user.login.toLowerCase()
+    const delegation = delegations.get(login)
+    if (delegation && timestamp(review.submitted_at, 'submitted review') >= delegation.createdAt) {
+      delegations.delete(login)
+    }
+  }
+  return delegations
+}
+
+function timestamp(value, subject) {
+  const time = typeof value === 'string' ? Date.parse(value) : NaN
+  if (!Number.isFinite(time)) throw new Error(`${subject} has no valid timestamp`)
+  return time
 }
 
 /**
  * Evaluate approval points from current reviews and repository permissions.
  * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
- * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; null author credit means history was not evaluated.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated.
  */
 export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
   const pull = pullRequestFromEvent(event)
@@ -143,24 +221,43 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
   const reviews = await listPullRequestReviews(api, pull.repository, pull.number)
   const decisions = effectiveReviewDecisions(reviews)
     .filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
-  const permissions = []
-  for (const { login, state } of decisions) {
-    permissions.push({ login, state, permission: await reviewerPermission(api, pull.repository, login) })
+  const comments = await listRecords(api, `/repos/${pull.repository}/issues/${pull.number}/comments`, 'pull-request comments')
+  const commands = await delegationCommands(api, comments)
+  const participants = new Map(decisions.map(({ login }) => [login.toLowerCase(), login]))
+  for (const { login, delegate } of commands) {
+    participants.set(login.toLowerCase(), participants.get(login.toLowerCase()) ?? login)
+    participants.set(delegate.toLowerCase(), participants.get(delegate.toLowerCase()) ?? delegate)
   }
-  const approvals = []
-  const blockers = []
+  participants.delete(pull.author.toLowerCase())
+  const writers = new Set()
   const ignoredReviewers = []
-  for (const { login, state, permission } of permissions) {
-    if (!WRITABLE_PERMISSIONS.has(permission)) {
-      ignoredReviewers.push(login)
-    } else if (state === 'CHANGES_REQUESTED') {
-      blockers.push(login)
-    } else {
-      approvals.push({
-        login,
-        points: policy.reviewerPoints.get(login.toLowerCase()) ?? policy.defaultPoints,
-      })
-    }
+  for (const [key, login] of participants) {
+    const permission = await reviewerPermission(api, pull.repository, login)
+    if (WRITABLE_PERMISSIONS.has(permission)) writers.add(key)
+    else ignoredReviewers.push(login)
+  }
+  const delegations = effectiveDelegations(commands, reviews, writers)
+  const approvals = []
+  const activeDelegations = []
+  const blockers = decisions.filter(({ login, state }) => writers.has(login.toLowerCase()) && state === 'CHANGES_REQUESTED')
+    .map(({ login }) => login)
+  const approved = new Set(decisions.filter(({ state }) => state === 'APPROVED').map(({ login }) => login.toLowerCase()))
+  for (const key of writers) {
+    const delegation = delegations.get(key)
+    const target = delegation?.delegate
+    const delegatedTo = writers.has(target) ? target : undefined
+    if (delegatedTo) activeDelegations.push({
+      login: participants.get(key), delegatedTo: participants.get(delegatedTo), commentId: delegation.commentId, approved: approved.has(delegatedTo),
+      reviewIds: reviews.filter(review => review.user?.login.toLowerCase() === key
+        && ['APPROVED', 'CHANGES_REQUESTED'].includes(review.state.toUpperCase()))
+        .map(review => positiveInteger(review.id, 'delegated review ID')),
+    })
+    if (!approved.has(delegatedTo ?? key)) continue
+    approvals.push({
+      login: participants.get(key),
+      points: policy.reviewerPoints.get(key) ?? policy.defaultPoints,
+      ...(delegatedTo ? { delegatedTo: participants.get(delegatedTo) } : {}),
+    })
   }
   let authorCredit = null
   const reviewerPoints = approvals.reduce((sum, approval) => sum + approval.points, 0)
@@ -190,7 +287,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
   }, authorCredit?.points ?? 0)
   if (blockers.length > 0) {
     return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending',
-      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit)
+      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations)
   }
   // Tolerate floating-point addition error without rounding approval scores.
   const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending'
@@ -203,6 +300,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     state,
     `${Number(points.toFixed(3))}/${policy.requiredPoints} approval points${authorCredit ? ` (author ${authorCredit.points})` : ''}`,
     authorCredit,
+    activeDelegations,
   )
 }
 
@@ -217,6 +315,21 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
   let result
   try {
     result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
+    for (const delegation of result.delegations) {
+      for (const reviewId of delegation.reviewIds) {
+        const dismissed = await api(`/repos/${pull.repository}/pulls/${pull.number}/reviews/${reviewId}/dismissals`, {
+          method: 'PUT',
+          body: { message: `This is by automated Angry Turtle Cyborg, not a human. @${delegation.login} delegated approval to @${delegation.delegatedTo} via /delegate.`, event: 'DISMISS' },
+        })
+        if (!isRecord(dismissed) || dismissed.id !== reviewId || dismissed.state !== 'DISMISSED') {
+          throw new Error('delegated review dismissal was not confirmed')
+        }
+        write(`Dismissed @${delegation.login}'s review ${reviewId} for delegation to @${delegation.delegatedTo}.`)
+      }
+    }
+    if (result.delegations.some(({ reviewIds }) => reviewIds.length)) {
+      result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
+    }
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
@@ -225,12 +338,17 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
     ? `Author credit: ${result.authorCredit.points} (${result.authorCredit.mergedCount} merged PRs).`
     : `Author credit: not evaluated (${pull.draft ? 'draft' : result.blockers.length ? 'blocking review' : 'reviewer points suffice'}).`)
   write(`Approval score: ${result.points}/${result.requiredPoints}.`)
-  writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership }) =>
-    `@${login}: ${points}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
+  writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership, delegatedTo }) =>
+    `@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${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)
   write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
+  try {
+    await requestDelegatedReviews(result, api, write)
+  } catch (error) {
+    write(`Review request failed; approval score is unchanged and the next evaluation will retry: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
+  }
   return result
 }
 
@@ -248,7 +366,7 @@ export async function publishApprovalPhase({ event, api, runUrl, phase }) {
 /**
  * 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.
- * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the pull-request head changes.
+ * @returns {Promise<Record<string, unknown> | null>} Event with a current pull request, or null after the PR closes or its head changes.
  */
 export async function approvalEventFromWorkflowRun({ event, api }) {
   const repository = repositoryFromEvent(event)
@@ -269,11 +387,58 @@ export async function approvalEventFromWorkflowRun({ event, api }) {
   }
   const pull = await api(`/repos/${repository}/pulls/${pullNumber}`)
   if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`)
-  if (pull.head.sha !== expectedHeadSha) return null
+  if (!['open', 'closed'].includes(pull.state)) throw new Error('pull request has an invalid state')
+  if (pull.state === 'closed' || pull.head.sha !== expectedHeadSha) return null
   return { ...event, pull_request: pull }
 }
 
-function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null) {
+/**
+ * Resolve a PR conversation comment to the current pull request; ordinary issues are skipped.
+ * @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Comment event and API caller.
+ * @returns {Promise<Record<string, unknown> | null>} Current PR event, or null for an issue or closed PR.
+ */
+export async function approvalEventFromComment({ event, api }) {
+  const repository = repositoryFromEvent(event)
+  if (!isRecord(event.issue)) throw new Error('comment event has no issue')
+  if (!isRecord(event.issue.pull_request)) return null
+  const number = event.issue.number
+  if (!Number.isSafeInteger(number) || number <= 0) throw new Error('comment event has no valid pull-request number')
+  const pull = await api(`/repos/${repository}/pulls/${number}`)
+  if (!isRecord(pull) || pull.number !== number || !['open', 'closed'].includes(pull.state)) {
+    throw new Error('comment pull-request response is invalid')
+  }
+  if (pull.state === 'closed') return null
+  const resolved = { ...event, pull_request: pull }
+  pullRequestFromEvent(resolved)
+  return resolved
+}
+
+/**
+ * Refresh a pull-request event before any status write; closed PRs and stale heads are skipped.
+ * @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Pull-request event and API caller.
+ * @returns {Promise<Record<string, unknown> | null>} Live event, or null for a closed PR or superseded head.
+ */
+export async function approvalEventFromPullRequest({ event, api }) {
+  const expected = pullRequestFromEvent(event)
+  const resolved = await approvalEventFromComment({ event: { ...event, issue: { number: expected.number, pull_request: {} } }, api })
+  if (resolved === null || resolved.pull_request.head.sha !== expected.headSha) return null
+  return { ...event, pull_request: resolved.pull_request }
+}
+
+async function requestDelegatedReviews(result, api, write) {
+  const recipients = new Map(result.delegations.filter(({ approved }) => !approved)
+    .map(({ delegatedTo }) => [delegatedTo.toLowerCase(), delegatedTo]))
+  if (!recipients.size) return
+  const path = `/repos/${result.pull.repository}/pulls/${result.pull.number}/requested_reviewers`
+  const requested = await api(path)
+  if (!isRecord(requested) || !Array.isArray(requested.users)) throw new Error('requested reviewers response has no users array')
+  for (const user of requested.users) recipients.delete(validateLogin(user?.login, 'requested reviewer').toLowerCase())
+  if (!recipients.size) return
+  await api(path, { method: 'POST', body: { reviewers: [...recipients.values()] } })
+  write(`Requested delegated reviews from ${[...recipients.values()].map(login => `@${login}`).join(', ')}.`)
+}
+
+function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null, delegations = []) {
   return {
     pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
     state,
@@ -282,6 +447,7 @@ function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReview
     authorCredit,
     requiredPoints,
     approvals,
+    delegations,
     blockers,
     ignoredReviewers,
   }
@@ -391,20 +557,21 @@ async function main() {
     token: process.env.GITHUB_TOKEN ?? '',
     apiUrl: process.env.GITHUB_API_URL,
   })
-  if (isRecord(event) && isRecord(event.workflow_run)) {
-    const resolved = await approvalEventFromWorkflowRun({
-      event,
-      api,
-    })
-    if (resolved === null) {
-      process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
-      return
-    }
-    event = resolved
+  if (isRecord(event) && isRecord(event.issue)) {
+    event = await approvalEventFromComment({ event, api })
+  } else if (isRecord(event) && isRecord(event.workflow_run)) {
+    event = await approvalEventFromWorkflowRun({ event, api })
+  } else {
+    event = await approvalEventFromPullRequest({ event, api })
+  }
+  if (event === null) {
+    process.stdout.write('Skipped an issue, closed pull request, or superseded pull-request head.\n')
+    return
   }
   const phase = process.argv[2]
   if (phase) {
     await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
+    if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
     return
   }
   await runApprovalCheck({

+ 515 - 3
.github/review-ownership/check-approval.test.mjs

@@ -4,6 +4,8 @@ import test from 'node:test'
 
 import {
   approvalEventFromWorkflowRun,
+  approvalEventFromComment,
+  approvalEventFromPullRequest,
   createGitHubApi,
   effectiveReviewDecisions,
   evaluateApproval as evaluateWithHistory,
@@ -14,21 +16,23 @@ import {
 } from './check-approval.mjs'
 
 const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
-const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options })
-const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options })
+const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options)
+const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
+const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
 const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
 
 const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
   repository: { full_name: 'deepseek-harness/deepseek-harness' },
   pull_request: {
     number: 42,
+    state: 'open',
     draft,
     user: { login: author, node_id: 'author-id' },
     head: { sha: HEAD_SHA },
   },
 })
 
-const review = (login, state) => ({ user: { login }, state })
+const review = (login, state, submitted_at = '2026-09-14T00:00:00Z', id = 10) => ({ user: { login }, state, submitted_at, id })
 
 test('loads the repository approval score policy', () => {
   const policy = parseApprovalPolicy(policySource)
@@ -419,6 +423,7 @@ test('publishes error when production attribution fails', async () => {
     api: async (path, options) => {
       if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
       if (path.includes('/permission')) return { permission: 'write' }
+      if (path.includes('/comments?')) return []
       states.push(options.body.state)
       return {}
     },
@@ -555,6 +560,7 @@ test('the publisher counts merged history through the production API path', asyn
     event, policySource, runUrl: 'https://github.example/run/1', write: line => output.push(line),
     getOwnership: async () => ({ totalLines: 8, reviewerLines: { writer: 1 } }),
     api: async (path, options) => {
+      if (path.includes('/comments?')) return []
       if (path === '/graphql') {
         assert.equal(options.body.variables.owner, 'deepseek-harness')
         return { data: { repository: { pullRequests: {
@@ -587,6 +593,7 @@ test('sufficient reviewer points and drafts publish without querying author hist
         assert.notEqual(path, '/graphql')
         if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
         if (path.includes('/permission')) return { permission: 'write' }
+        if (path.includes('/comments?')) return []
         return {}
       },
     })
@@ -617,3 +624,508 @@ test('bot authors receive the same history credit', async () => {
   assert.equal(result.authorCredit.points, 0.6)
   assert.equal(result.state, 'success')
 })
+
+const comment = (login, body, created_at = '2026-09-15T00:00:00Z', id = 1) => ({
+  user: { login, node_id: `account:${login.toLowerCase()}` }, body, created_at, id, node_id: `comment:${login}:${id}:${body}:${created_at}`,
+})
+
+function delegationApi({ comments = [], reviews = [], permissions = {} } = {}) {
+  return async (path, options) => {
+    if (path === '/graphql') return { data: { nodes: options.body.variables.ids.map(id => {
+      const entry = comments.find(comment => comment.node_id === id)
+      return { id, body: entry.body, createdAt: entry.created_at, author: { id: entry.user.node_id }, lastEditedAt: null, editor: null,
+        ...entry.editorMetadata }
+    }) } }
+    if (path.includes('/comments?')) return comments
+    if (path.includes('/reviews?')) return reviews
+    const match = /\/collaborators\/([^/]+)\/permission$/u.exec(path)
+    if (match) return { permission: permissions[decodeURIComponent(match[1]).toLowerCase()] ?? 'write' }
+    throw new Error(`unexpected API path ${path}`)
+  }
+}
+
+const evaluateDelegation = options => evaluateWithHistory({
+  event: pullRequestEvent(), policySource,
+  getMergedCount: async () => 0,
+  getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
+  ...options,
+})
+
+test('delegates the sender fixed points once after the recipient approves, retaining both blockers', async () => {
+  for (const senderState of [undefined, 'APPROVED', 'CHANGES_REQUESTED']) {
+    for (const recipientState of [undefined, 'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED']) {
+      const result = await evaluateDelegation({ api: delegationApi({
+        comments: [comment('Turtle1999', '/delegate @Writer')],
+        reviews: [
+          ...(senderState ? [review('turtle1999', senderState)] : []),
+          ...(recipientState ? [review('writer', recipientState)] : []),
+        ],
+      }) })
+      const points = recipientState === 'APPROVED' ? 3 : 0
+      assert.equal(result.points, points)
+      assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending')
+      assert.deepEqual(result.blockers, [
+        ...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
+        ...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
+      ])
+      if (points) assert.deepEqual(result.approvals[0], { login: senderState ? 'turtle1999' : 'Turtle1999', points: 2, delegatedTo: 'writer' })
+    }
+  }
+})
+
+test('uses the newest surviving command and restores previous commands after edits or deletion', async () => {
+  const first = comment('turtle1999', '/delegate @writer')
+  for (const [comments, points] of [
+    [[first], 3],
+    [[first, comment('turtle1999', '/delegate @other')], 1],
+    [[first, comment('turtle1999', '/delegate @TURTLE1999')], 1],
+    [[comment('turtle1999', '/delegate @other')], 1],
+    [[first, comment('turtle1999', 'edited to ordinary text')], 3],
+    [[], 1],
+  ]) {
+    const result = await evaluateDelegation({ api: delegationApi({ comments, reviews: [review('writer', 'APPROVED')] }) })
+    assert.equal(result.points, points)
+  }
+  const restored = await evaluateDelegation({ api: delegationApi({
+    comments: [first, comment('turtle1999', '/delegate @turtle1999')],
+    reviews: [review('turtle1999', 'APPROVED')],
+  }) })
+  assert.deepEqual(restored.approvals, [{ login: 'turtle1999', points: 2 }])
+})
+
+test('commands must occupy the entire conversation comment', async () => {
+  for (const body of ['> /delegate @writer', '```\n/delegate @writer\n```', '/delegate @writer extra',
+    'Please /delegate @writer', '/delegate writer', '/delegate @bad_user', '/delegate @writer\n/delegate @other']) {
+    const result = await evaluateDelegation({ api: delegationApi({
+      comments: [comment('turtle1999', body)], reviews: [review('writer', 'APPROVED')],
+    }) })
+    assert.equal(result.points, 1, body)
+  }
+  const result = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('turtle1999', '\n/delegate @writer\r\n'), { user: null, body: '/delegate @writer' }],
+    reviews: [review('writer', 'APPROVED')],
+  }) })
+  assert.equal(result.points, 3)
+})
+
+test('both delegation participants need current write access and neither may be the PR author', async () => {
+  for (const [sender, target, permissions, expectedPoints] of [
+    ['reader', 'writer', { reader: 'read' }, 1],
+    ['turtle1999', 'writer', { turtle1999: 'none' }, 1],
+    ['turtle1999', 'writer', { writer: 'read' }, 0],
+    ['author', 'writer', {}, 1],
+    ['turtle1999', 'author', {}, 1],
+  ]) {
+    const result = await evaluateDelegation({ api: delegationApi({
+      comments: [comment(sender, `/delegate @${target}`)],
+      reviews: [review('writer', 'APPROVED'), review('author', 'APPROVED')], permissions,
+    }) })
+    assert.equal(result.points, expectedPoints)
+  }
+  const ignoredTarget = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('turtle1999', '/delegate @reader')], reviews: [review('turtle1999', 'APPROVED')],
+    permissions: { reader: 'read' },
+  }) })
+  assert.equal(ignoredTarget.points, 2)
+})
+
+test('delegated points retain the sender production ownership and transfer only one hop', async () => {
+  const api = delegationApi({
+    comments: [comment('owner', '/delegate @writer'), comment('writer', '/delegate @waiting')],
+    reviews: [review('writer', 'APPROVED')],
+  })
+  const result = await evaluateDelegation({ api,
+    getOwnership: async () => ({ totalLines: 8, reviewerLines: { owner: 1, writer: 7 } }),
+  })
+  assert.equal(result.points, 1.5)
+  assert.deepEqual(result.approvals, [{ login: 'owner', points: 1.5, delegatedTo: 'writer', ownership: { ownedLines: 1, totalLines: 8 } }])
+  const chain = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('owner', '/delegate @writer'), comment('writer', '/delegate @waiting')],
+    reviews: [review('waiting', 'APPROVED')],
+  }) })
+  assert.deepEqual(chain.approvals.map(({ login }) => login), ['waiting', 'writer'])
+})
+
+test('cycles do not create approvals and repeated commands do not multiply points', async () => {
+  const comments = [comment('one', '/delegate @two'), comment('one', '/delegate @two'), comment('two', '/delegate @one')]
+  for (const reviews of [[], [review('one', 'APPROVED'), review('two', 'APPROVED')]]) {
+    const result = await evaluateDelegation({ api: delegationApi({ comments, reviews }) })
+    assert.equal(result.points, reviews.length)
+  }
+})
+
+test('recipient dismissal revokes delegated approvals and comments do not reinstate them', async () => {
+  const result = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('turtle1999', '/delegate @writer')],
+    reviews: [review('writer', 'APPROVED'), review('writer', 'DISMISSED'), review('writer', 'COMMENTED')],
+  }) })
+  assert.equal(result.points, 0)
+})
+
+test('a sender submitted review takes back delegation, including comment-only and dismissed reviews', async () => {
+  for (const state of ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) {
+    for (const submittedAt of ['2026-09-15T00:00:00Z', '2026-09-16T00:00:00Z']) {
+      const result = await evaluateDelegation({ api: delegationApi({
+        comments: [comment('turtle1999', '/delegate @writer')],
+        reviews: [review('writer', 'APPROVED'), review('Turtle1999', state, state === 'PENDING' ? null : submittedAt)],
+      }) })
+      assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0)
+      assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1)
+      assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending')
+      if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined)
+    }
+  }
+})
+
+test('a fresh delegation after a review works, while editing an older command does not reactivate it', async () => {
+  const reviews = [review('writer', 'APPROVED'), review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z')]
+  const old = { ...comment('turtle1999', '/delegate @writer'), updated_at: '2026-09-18T00:00:00Z' }
+  for (const [comments, expected] of [
+    [[old], 1],
+    [[old, comment('turtle1999', '/delegate @writer', '2026-09-17T00:00:00Z', 2)], 3],
+  ]) {
+    const result = await evaluateDelegation({ api: delegationApi({ comments, reviews }) })
+    assert.equal(result.points, expected)
+  }
+})
+
+test('missing command identity or review timing fails evaluation instead of preserving delegation', async () => {
+  for (const [comments, reviews, message] of [
+    [[{ ...comment('turtle1999', '/delegate @writer'), id: undefined }], [], /valid ID/u],
+    [[{ ...comment('turtle1999', '/delegate @writer'), created_at: 'invalid' }], [], /valid timestamp/u],
+    [[comment('turtle1999', '/delegate @writer')], [review('turtle1999', 'COMMENTED', null)], /valid timestamp/u],
+  ]) {
+    await assert.rejects(evaluateDelegation({ api: delegationApi({ comments, reviews }) }), message)
+  }
+})
+
+test('an active delegate command requests review once and preserves other requested reviewers', async () => {
+  for (const action of ['created', 'edited']) {
+    for (const alreadyRequested of [false, true]) {
+      const requests = []
+      const output = []
+      const api = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')] })
+      const result = await runWithHistory({
+        event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action, comment: { id: 1 } },
+        policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0,
+        write: line => output.push(line),
+        api: async (path, options) => {
+          if (path.endsWith('/requested_reviewers')) {
+            if (options?.method === 'POST') { requests.push(options.body); return {} }
+            return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
+          }
+          if (path.includes('/statuses/')) return {}
+          return api(path, options)
+        },
+      })
+      assert.equal(result.points, 0)
+      assert.deepEqual(requests, alreadyRequested ? [] : [{ reviewers: ['writer'] }])
+      assert.equal(output.includes("Requested delegated reviews from @writer."), !alreadyRequested)
+    }
+  }
+})
+
+test('inactive commands and drafts do not request review', async () => {
+  const command = comment('turtle1999', '/delegate @writer')
+  for (const scenario of [
+    { comments: [comment('turtle1999', '/delegate @turtle1999')] },
+    { reviews: [review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z')] },
+    { permissions: { turtle1999: 'read' } },
+    { permissions: { writer: 'read' } },
+    { draft: true },
+  ]) {
+    const api = delegationApi({ comments: [command], reviews: [review('turtle1999', 'CHANGES_REQUESTED')], ...scenario })
+    await runWithHistory({
+      event: { ...pullRequestEvent({ draft: scenario.draft }),
+        ...(scenario.event ?? { issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }),
+      },
+      policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
+      api: async (path, options) => {
+        assert.ok(!path.endsWith('/requested_reviewers'))
+        if (path.includes('/statuses/')) return {}
+        return api(path, options)
+      },
+    })
+  }
+})
+
+test('failed review requests preserve the computed score and report the retry', async () => {
+  for (const failedRead of [false, true]) {
+    const statuses = []
+    const api = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('imccyu', 'APPROVED')] })
+    const output = []
+    await runWithHistory({
+      event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+      policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line),
+      api: async (path, options) => {
+        if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
+        if (path.endsWith('/requested_reviewers')) {
+          if (options?.method === 'POST') throw new Error('request rejected')
+          return failedRead ? {} : { users: [] }
+        }
+        return api(path, options)
+      },
+    })
+    assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u)
+    assert.match(output.at(-1), /score is unchanged.*will retry/u)
+    assert.deepEqual(statuses, ['pending', 'success'])
+  }
+})
+
+test('delegation dismisses only the sender old decisions and stays active across dismissal events', async () => {
+  const reviews = [review('turtle1999', 'APPROVED', undefined, 10), review('turtle1999', 'CHANGES_REQUESTED', undefined, 11),
+    review('turtle1999', 'COMMENTED', undefined, 12), review('turtle1999', 'PENDING', null, 13),
+    review('writer', 'APPROVED', undefined, 20), review('other', 'CHANGES_REQUESTED', undefined, 21)]
+  const calls = []
+  const states = []
+  const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
+  const api = async (path, options) => {
+    if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
+    if (path.endsWith('/dismissals')) {
+      calls.push(options)
+      const id = Number(path.split('/').at(-2))
+      const dismissed = reviews.find(review => review.id === id)
+      assert.ok([10, 11].includes(id))
+      dismissed.state = 'DISMISSED'
+      return dismissed
+    }
+    if (path.endsWith('/requested_reviewers')) {
+      if (options?.method === 'POST') calls.push(options)
+      return { users: [] }
+    }
+    return read(path, options)
+  }
+  const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+    policySource, api, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
+  })
+  assert.deepEqual(calls.map(call => call.method), ['PUT', 'PUT'])
+  assert.equal(calls[0].body.event, 'DISMISS')
+  assert.equal(calls[0].body.message, 'This is by automated Angry Turtle Cyborg, not a human. @turtle1999 delegated approval to @writer via /delegate.')
+  assert.deepEqual(result.blockers, ['other'])
+  assert.equal(result.delegations.length, 1)
+  assert.deepEqual(result.delegations[0].reviewIds, [])
+  assert.deepEqual(states, ['pending', 'pending'])
+  reviews.find(review => review.id === 21).state = 'DISMISSED'
+  for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) {
+    reviews.find(review => review.id === 20).state = state
+    const next = await evaluateDelegation({ api })
+    assert.equal(next.delegations.length, 1)
+    assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending')
+  }
+  reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30))
+  const reclaimed = await evaluateDelegation({ api })
+  assert.deepEqual(reclaimed.delegations, [])
+  assert.equal(reclaimed.points, 1)
+})
+
+test('a new sender review arriving during dismissal is not dismissed or delegated', async () => {
+  const reviews = [review('turtle1999', 'CHANGES_REQUESTED')]
+  const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews })
+  const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+    policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0,
+    api: async (path, options) => {
+      if (path.includes('/statuses/')) return {}
+      if (path.endsWith('/dismissals')) {
+        assert.ok(path.endsWith('/reviews/10/dismissals'))
+        reviews[0].state = 'DISMISSED'
+        reviews.push(review('turtle1999', 'CHANGES_REQUESTED', '2026-09-16T00:00:00Z', 11))
+        return reviews[0]
+      }
+      assert.ok(!path.endsWith('/requested_reviewers'))
+      return read(path, options)
+    },
+  })
+  assert.deepEqual(result.delegations, [])
+  assert.deepEqual(result.blockers, ['turtle1999'])
+})
+
+test('a failed or unconfirmed dismissal prevents review requests and publishes error', async () => {
+  for (const rejected of [false, true]) {
+    const states = []
+    const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('turtle1999', 'CHANGES_REQUESTED')] })
+    await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+      policySource, runUrl: 'https://github.example/run/1', write: () => {},
+      api: async (path, options) => {
+        if (path.includes('/statuses/')) { states.push(options.body.state); return {} }
+        if (path.endsWith('/dismissals')) {
+          if (rejected) throw new Error('dismissal denied')
+          return { id: 10, state: 'CHANGES_REQUESTED' }
+        }
+        assert.ok(!path.endsWith('/requested_reviewers'))
+        return read(path, options)
+      },
+    }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u)
+    assert.deepEqual(states, ['pending', 'error'])
+  }
+})
+
+test('reads all comment pages, logs the score owner, and fails closed on missing comment history', async () => {
+  const statuses = []
+  const output = []
+  const pageCommand = comment('turtle1999', '/delegate @writer')
+  const api = delegationApi({ comments: [pageCommand], reviews: [review('writer', 'APPROVED')] })
+  const run = comments => runWithHistory({
+    event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
+    getMergedCount: async () => 0, write: line => output.push(line),
+    api: async (path, options) => {
+      if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
+      if (path.includes('/comments?')) return comments(path)
+      return api(path, options)
+    },
+  })
+  const pages = []
+  await run(path => {
+    pages.push(path)
+    return path.endsWith('page=1') ? Array.from({ length: 100 }, () => comment('writer', 'text'))
+      : [pageCommand]
+  })
+  assert.equal(pages.length, 2)
+  assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
+  assert.deepEqual(statuses.splice(0), ['pending', 'success'])
+  for (const [comments, message] of [
+    [() => { throw new Error('comment API unavailable') }, /comment API unavailable/u],
+    [() => ({}), /comments response is not an array/u],
+    [() => [null], /comment is not an object/u],
+    [() => [{ user: { login: 'writer' } }], /comment has no body/u],
+    [() => [{ user: {}, body: '/delegate @writer' }], /delegation author has an invalid login/u],
+    [() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u],
+  ]) {
+    await assert.rejects(run(comments), message)
+    assert.deepEqual(statuses.splice(0), ['pending', 'error'])
+  }
+})
+
+test('comment events resolve the live PR head and skip ordinary issues and closed PRs', async () => {
+  const event = { repository: pullRequestEvent().repository, issue: { number: 42, pull_request: {} } }
+  const pull = { ...pullRequestEvent().pull_request, state: 'open' }
+  const resolved = await approvalEventFromComment({ event, api: async path => {
+    assert.equal(path, '/repos/deepseek-harness/deepseek-harness/pulls/42')
+    return pull
+  } })
+  assert.deepEqual(resolved.pull_request, pull)
+  assert.equal(await approvalEventFromComment({ event: { ...event, issue: { number: 42 } },
+    api: async () => assert.fail('ordinary issues must not fetch a PR'),
+  }), null)
+  assert.equal(await approvalEventFromComment({ event, api: async () => ({ ...pull, state: 'closed' }) }), null)
+  for (const number of [0, -1, '42', Number.MAX_SAFE_INTEGER + 1]) {
+    await assert.rejects(approvalEventFromComment({ event: { ...event, issue: { ...event.issue, number } },
+      api: async () => assert.fail('invalid number must not call GitHub'),
+    }), /valid pull-request number/u)
+  }
+  for (const response of [null, { ...pull, number: 1 }, { ...pull, state: 'unknown' }, { ...pull, head: {} }]) {
+    await assert.rejects(approvalEventFromComment({ event, api: async () => response }))
+  }
+})
+
+test('only the author can authorize a command edit, on every event reconstruction', async () => {
+  for (const editor of [{ id: 'account:attacker' }, null, { id: 'account:turtle1999' }]) {
+    const command = { ...comment('turtle1999', '/delegate @attacker'), editorMetadata: {
+      lastEditedAt: '2026-09-15T01:00:00Z', editor,
+    } }
+    const result = await evaluateDelegation({ api: delegationApi({ comments: [command], reviews: [review('attacker', 'APPROVED')] }) })
+    assert.equal(result.points, editor?.id === 'account:turtle1999' ? 3 : 1)
+    assert.equal(result.delegations.length, editor?.id === 'account:turtle1999' ? 1 : 0)
+  }
+})
+
+test('ineligible or third-party edited commands do not replace an earlier eligible delegation', async () => {
+  for (const later of [comment('turtle1999', '/delegate @reader'), comment('turtle1999', '/delegate @author'),
+    { ...comment('turtle1999', '/delegate @turtle1999'), editorMetadata: { lastEditedAt: '2026-09-15T01:00:00Z', editor: { id: 'account:attacker' } } }]) {
+    const result = await evaluateDelegation({ api: delegationApi({
+      comments: [comment('turtle1999', '/delegate @writer'), later],
+      reviews: [review('writer', 'APPROVED')], permissions: { reader: 'read' },
+    }) })
+    assert.equal(result.points, 3)
+    assert.equal(result.delegations[0].delegatedTo, 'writer')
+  }
+})
+
+test('missing, partial, changed, or failed editor metadata prevents scoring', async () => {
+  const command = comment('turtle1999', '/delegate @writer')
+  const read = delegationApi({ comments: [command], reviews: [review('writer', 'APPROVED')] })
+  for (const response of [null, {}, { data: { nodes: [] } }, { data: { nodes: [null] } }, { errors: [{ message: 'forbidden' }] }]) {
+    await assert.rejects(evaluateDelegation({ api: (path, options) => path === '/graphql' ? response : read(path, options) }), /editor history/u)
+  }
+  for (const patch of [{ body: '/delegate @attacker' }, { author: { id: 'account:attacker' } }, { lastEditedAt: undefined }, { id: 'other-comment' }]) {
+    await assert.rejects(evaluateDelegation({ api: delegationApi({ comments: [{ ...command, editorMetadata: patch }] }) }), /editor history/u)
+  }
+  await assert.rejects(evaluateDelegation({ api: (path, options) => {
+    if (path === '/graphql') throw new Error('editor API unavailable')
+    return read(path, options)
+  } }), /editor API unavailable/u)
+})
+
+test('editor verification batches complete command history', async () => {
+  const comments = Array.from({ length: 101 }, (_, index) => comment('turtle1999', '/delegate @writer', undefined, index + 1))
+  const read = delegationApi({ comments })
+  const batches = []
+  const result = await evaluateDelegation({ api: (path, options) => {
+    if (path.includes('/comments?')) return path.endsWith('page=1') ? comments.slice(0, 100) : comments.slice(100)
+    if (path === '/graphql') batches.push(options.body.variables.ids.length)
+    return read(path, options)
+  } })
+  assert.deepEqual(batches, [100, 1])
+  assert.equal(result.delegations[0].commentId, 101)
+})
+
+test('later events reconcile all active delegations after a draft or replaced comment event', async () => {
+  for (const action of ['ready_for_review', 'synchronize', 'submitted', 'deleted']) {
+    const reviews = [review('Turtle1999', 'CHANGES_REQUESTED', undefined, 10), review('second', 'APPROVED', undefined, 11)]
+    const read = delegationApi({ comments: [comment('Turtle1999', '/delegate @Writer'), comment('second', '/delegate @WRITER')], reviews })
+    const effects = []
+    const requested = []
+    const api = async (path, options) => {
+      if (path.includes('/statuses/')) return {}
+      if (path.endsWith('/dismissals')) {
+        const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`))
+        effects.push(old.id)
+        old.state = 'DISMISSED'
+        return old
+      }
+      if (path.endsWith('/requested_reviewers')) {
+        if (options?.method === 'POST') { effects.push(options.body); requested.push({ login: 'writer' }) }
+        return { users: requested }
+      }
+      return read(path, options)
+    }
+    const run = draft => runWithHistory({ event: { ...pullRequestEvent({ draft }), action }, policySource, api,
+      runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
+    })
+    await run(true)
+    assert.deepEqual(effects, [])
+    const result = await run(false)
+    assert.deepEqual(effects, [10, 11, { reviewers: ['Writer'] }])
+    assert.equal(result.delegations[0].login, 'Turtle1999')
+    await run(false)
+    assert.equal(effects.length, 3)
+  }
+})
+
+test('already approved delegates need no new request', async () => {
+  const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')],
+    reviews: [review('writer', 'APPROVED')],
+  })
+  const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
+    api: (path, options) => {
+      if (path.includes('/statuses/')) return {}
+      assert.ok(!path.endsWith('/requested_reviewers'))
+      return read(path, options)
+    },
+  })
+  assert.equal(result.state, 'success')
+})
+
+test('pull-request events use live state and skip closed, merged, and superseded PRs', async () => {
+  const event = pullRequestEvent()
+  for (const patch of [{ state: 'closed' }, { state: 'closed', merged: true, merge_commit_sha: HEAD_SHA },
+    { head: { sha: 'abcdef1234567890abcdef1234567890abcdef12' } }]) {
+    assert.equal(await approvalEventFromPullRequest({ event, api: async () => ({ ...event.pull_request, ...patch }) }), null)
+  }
+  const ready = await approvalEventFromPullRequest({ event: pullRequestEvent({ draft: true }), api: async () => event.pull_request })
+  assert.equal(ready.pull_request.draft, false)
+  assert.equal(await approvalEventFromWorkflowRun({ event: {
+    repository: event.repository,
+    workflow_run: { path: '.github/workflows/weighted-approval-review-event.yml', event: 'pull_request_review',
+      conclusion: 'success', head_sha: HEAD_SHA, display_title: 'weighted-approval-review-event:42', pull_requests: [] },
+  }, api: async () => ({ ...event.pull_request, state: 'closed', merged: true }) }), null)
+})

+ 1 - 0
.github/workflows/weighted-approval-review-event.yml

@@ -9,6 +9,7 @@ permissions: {}
 
 jobs:
   record-review-event:
+    if: github.event.pull_request.state == 'open'
     name: record weighted approval review event
     runs-on: ubuntu-latest
     timeout-minutes: 2

+ 15 - 6
.github/workflows/weighted-approval.yml

@@ -3,28 +3,34 @@ name: weighted-approval
 on:
   pull_request_target:
     types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]
+  issue_comment:
+    types: [created, edited, deleted]
   workflow_run:
     workflows: [weighted-approval-review-event]
     types: [completed]
 
 permissions:
   contents: read
-  pull-requests: read
+  pull-requests: write
   statuses: write
 
 concurrency:
-  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 }}
+  group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}
   cancel-in-progress: false
 
 jobs:
   publish-status:
-    if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
+    if: >-
+      (github.event_name != 'pull_request_target' || github.event.pull_request.state == 'open') &&
+      (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') &&
+      (github.event_name != 'issue_comment' || (github.event.issue.pull_request && github.event.issue.state == 'open' &&
+        (contains(github.event.comment.body, '/delegate') || contains(github.event.changes.body.from, '/delegate'))))
     name: weighted approval publisher
     runs-on: ubuntu-latest
     timeout-minutes: 5
     steps:
       # SECURITY: the status-writing job executes policy from the trusted default
-      # branch and reads pull-request reviews only as API data.
+      # branch and reads pull-request reviews and comments only as API data.
       - name: Check out trusted approval policy
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
         with:
@@ -37,20 +43,23 @@ jobs:
           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
+      - if: steps.revoke.outputs.active == 'true'
+        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
+        if: steps.revoke.outputs.active == 'true'
         run: python3 -m pip install -r .github/review-ownership/requirements.txt
       - name: Publish weighted approval status
+        if: steps.revoke.outputs.active == 'true'
         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'
+        if: failure() && steps.revoke.outputs.active == 'true'
         env:
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

+ 3 - 1
apps/cli/package.json

@@ -47,6 +47,9 @@
     "@deepseek-ai/dsh-compaction-basic": "workspace:^",
     "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^",
     "@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
+    "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
+    "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^",
+    "@deepseek-ai/dsh-experimental-auto-review": "workspace:^",
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-goal-round-driver": "workspace:^",
@@ -116,7 +119,6 @@
     "@deepseek-ai/dsh-credentials-local": "workspace:^",
     "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^",
     "@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
-    "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^",
     "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^",
     "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^",
     "@deepseek-ai/dsh-fs-observation-policy": "workspace:^",

+ 2 - 2
apps/desktop/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/desktop/README.md
-README.md: 2793dae3a6bd0ca9ce5b4a7c13e539a861741994
-README.zh.md: 2beb07eb7580154a3aafb6fbea37157529e8d4f8
+README.md: 9075e86b0817853b28b28aa1cee45f35904a38d0
+README.zh.md: c25cc17a89ff65a7deedceb78778651b2d4a5c1f

+ 15 - 16
apps/desktop/README.md

@@ -20,16 +20,15 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
 
 This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
 
-Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies.
+Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](scripts/primary-runtime-lock.json) pins interpreter archives and target-specific wheel URLs and hashes; pnpm follows the Desktop build dependency lock. The supported library wheels unpack directly into site-packages; wheels requiring other installation directories are rejected, and package command-line wrappers are not generated. Native-target checks execute the bundled interpreters and numpy/pandas operations after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
 
 | Decision | Why | Direct consequence |
 |---|---|---|
 | Release identity | The shell API, Web client, backend, and plugin graph are qualified as one combination; independent versions would create untested combinations and ambiguous update availability. | Electron and `@deepseek-ai/dsh` always have the same exact version. A dsh upgrade is a Desktop release, even when the shell code is unchanged. |
 | Runtime | The application must run without a system Node.js or pnpm installation. | dsh runs under Electron with `ELECTRON_RUN_AS_NODE=1` and `--expose-internals` and every package operation uses the bundled pnpm. Package-manager configuration and the Host environment follow the user's settings. Package scripts use a `node` shell launcher that forwards to Electron. |
 | Package sources | Core installation at startup adds work even when offline. | `app.asar/dsh` carries a complete production dependency tree; the profile installs only external plugins. |
-| Shared modules | Host APIs can depend on module identity. | The shared profile runner projects missing installation and bundle dependencies inside the Desktop profile; pnpm-managed packages take precedence. |
 | State ownership | Sharing executable dependency graphs would let CLI and Desktop change each other's dsh, Cordis, plugin, or native-module versions, while two desktop processes could race on the same profile. | Electron acquires its process-lifetime single-instance lock before any profile access and exclusively owns `$DSH_HOME/profiles/desktop` plus its package-manager state. CLI and Desktop share supported product data under `$DSH_HOME`, but never executable packages, plugin activation, lockfiles, or `node_modules`. |
-| Transport | Reusing Web serving and authentication keeps application behavior in one implementation. | Electron loads the Host’s authenticated HTTP URL directly; child IPC carries lifecycle messages, and the local shell protocol serves startup and management pages. |
+| Transport | Web serving and authentication share one implementation. | Electron loads packaged Web assets; the Host supplies boot injections and authenticated APIs. The shell protocol serves plugin management. |
 | Plugin changes | Package installation and Host startup can fail. | Desktop stops the Host and modifies the current profile directly. Failures retain partial changes for explicit repair; there is no automatic profile rollback. |
 | Updates | Independent shell and dsh updates would recreate version splits, while unchanged shell blocks should not require a complete transfer. | The Electron shell, matching dsh runtime and pnpm form one signed update unit. Platform update artifacts may reuse unchanged blocks, but runtime version selection never splits from the Desktop release. |
 
@@ -39,11 +38,11 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
 
 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
 
-The local startup page exposes startup status and available recovery actions. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
+The application preload exposes boot readiness and fatal startup reporting. The product renderer uses the Web application’s HTTP APIs. The separate plugin window receives structured list, install, remove, update, and update-check operations; neither renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
 
-The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles and patch-reload policy.
+The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
 
-Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, the startup page, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
+Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. Menus, native dialogs, and the plugin-management renderer use the same locale payload; the repository Client UI i18n gate checks these desktop sources.
 
 Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-all commands and platform shortcuts for the focused window. Right-clicking an editable field opens these commands without shortcut labels, with availability supplied by Chromium; selected read-only text offers Copy.
 
@@ -51,21 +50,21 @@ Electron's native Edit menu supplies undo, redo, cut, copy, paste, and select-al
 
 The signed `resources/app.asar/dsh/desktop-runtime.json` binds the shell version, Electron's Node version, platform, architecture, shared package versions, and final file inventory. Startup reads the metadata and checks shared package records. Release schema, shell version, target compatibility, and file integrity are verified during packaging. Core packages are never copied into profile storage or installed by pnpm at first launch.
 
-1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files. The actual Host starts once and supplies missing module links through the shared profile runner.
-2. On application upgrades, the shared profile runner refreshes its owned module links without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
+1. The main window displays the shared Web loading page from packaged static assets before profile preparation or backend startup. Shared profile initialization creates missing manifest, empty user patch, and pnpm workspace files without overwriting existing files.
+2. Before production Host startup, Desktop removes profile copies and fallback links for packages listed by the current runtime or the recorded Desktop package set. It removes their dependency declarations and overrides, and discards the lockfile when cleanup changes package state. Other plugin files, configuration, and versions remain; development skips this cleanup and startup never runs pnpm.
 3. Changes to Electron's Node version, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
 4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
-5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
+5. Plugin changes stop the backend before modifying the current profile. The Host restarts after every attempted package change, including failed package operations. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
 
 CLI and Desktop use the same installed-dependency inventory and bundle reconciliation. Bundle declarations resolve with the same installation-first precedence as startup. CLI operations automatically enable installed bundles; Desktop preserves bundles disabled through its UI across updates. Neither path requires readable installed metadata to list or remove a dependency.
 
-The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
+Fatal main-window creation, main-document loading, preload, renderer, Web initialization, or backend failures open one native recovery dialog per application process. It shows a bounded tail of the first error, notes any truncation, and offers Exit, Restart, and Disable all third-party plugins and restart. Startup failures retain the Web loading page and spinner; runtime failures retain the current page. Expected shutdowns, cancelled navigation, and ordinary requests do not trigger recovery. Package-operation errors stay in the plugin window when the Host restarts successfully; a Host startup failure after any plugin change enters native recovery. There is no startup timeout heuristic.
 
-Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
+Native dialog details include at most 1,200 UTF-16 code units and eight diagnostic lines; the complete reported error is written to the Electron console. Host error diagnostics retain only the last 64 Ki characters written to stderr. Earlier output is discarded so a long-running Host does not grow the shell’s diagnostic buffer indefinitely.
 
-Reset deletes every entry in `$DSH_HOME/profiles/desktop` except the held transaction lock, then initializes the built-in profile. It removes Desktop configuration and installed third-party packages without a backup. Shared tasks, settings, and the Harness-home `.env` are untouched. Shell resource and preload failures use a self-contained document with the available recovery actions and diagnostics; its controls do not require preload.
+Recovery waits for Host shutdown before changing plugin activation. The native recovery action disables third-party bundles by writing the profile under its transaction lock without loading runtime metadata or deleting files. Invalid profile data or write failures are reported as recovery-operation errors; Desktop does not restart as though disabling succeeded. Desktop has no profile-reset action or emergency HTML document.
 
-Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; the Host recreates needed links on startup. Reset preserves the profile directory and its lock until initialization and Host startup finish. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
+Package transactions hold `$DSH_HOME/profiles/desktop/lock` exclusively through pnpm process exit. Before pnpm runs, the shared module-fallback helper removes only its owned links and preserves pnpm-managed directories; development Host startup restores needed links. Link cleanup preserves target directories. Native builds follow pnpm’s configured build policy; release preparation owns its separate build-time allowlist.
 
 ## Develop
 
@@ -119,10 +118,10 @@ Production packages first pass through npm's publication rules and dependency in
 
 The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; `prepare:dsh` runs the [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) under Electron RunAsNode before the Host smoke and final inventory verification.
 
-Windows release qualification also runs [native cleanup and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Electron` to the prepared Electron executable and `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies Electron junction cleanup, directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
+Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
 
 ```powershell
-pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
 The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
@@ -212,7 +211,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
 
 ## Updates
 
-A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater. The next launch displays the local loading page while reconciling the version-bound runtime.
+A packaged application checks its target-specific release stream ten seconds after the main window opens; the localized **Check for Updates…** menu item triggers the same check manually. An available release opens one native confirmation dialog. Accepting it waits for an in-flight check, downloads and verifies the signed Desktop release, stops the dsh child, and hands installation plus restart to electron-updater.
 
 Signed packaging emits generic-provider channel metadata for the deployment selected by `DSH_DESKTOP_AUTO_UPDATE_ENV`. NSIS differential packages and the macOS ZIP target allow electron-updater to reuse unchanged blocks; the manually installed DMG is notarized without a blockmap because it is not a macOS updater payload. The runtime and shell still form one signed Desktop release. macOS signing and notarization credentials use electron-builder's standard environment; Windows EV signing uses the public certificate, validated SignTool, SafeNet container, and runner PIN described above. The required Desktop release environment selects the application and platform signature identities that the build verifies.
 

+ 15 - 16
apps/desktop/README.zh.md

@@ -20,16 +20,15 @@ Desktop 携带独立的 Python、Node.js 和 pnpm 分发包,并在 Python 的
 
 该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
 
-Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。
+Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](scripts/primary-runtime-lock.json)固定解释器压缩包及目标平台 wheel 的 URL 和哈希;pnpm 使用 Desktop 构建依赖锁。支持的库 wheel 直接解压到 site-packages;需要其他安装目录的 wheel 会被拒绝,不生成包的命令行包装器。本机目标检查在清理暂存目录后以及 macOS 签名后执行内置解释器及 numpy/pandas 运算。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
 
 | 决策 | 原因 | 直接结果 |
 |---|---|---|
 | 发布身份 | 桌面壳 API、Web 客户端、后端与插件依赖图作为一个组合完成验证;独立版本会产生未经验证的组合,并让更新可用性含糊不清。 | Electron 与 `@deepseek-ai/dsh` 始终使用同一精确版本。即使桌面壳代码不变,升级 dsh 也必须发布新 Desktop 版本。 |
 | 运行时 | 应用必须能够在没有系统 Node.js 或 pnpm 的机器上运行。 | dsh 通过设置 `ELECTRON_RUN_AS_NODE=1` 和 `--expose-internals` 的 Electron 运行,所有包操作都使用内置 pnpm。包管理器配置和 Host 环境遵循用户设置。包脚本通过 `node` shell 启动器转发给 Electron。 |
 | 包来源 | 即使离线,启动时安装核心依赖也会增加开销。 | `app.asar/dsh` 携带完整生产依赖树;profile 只安装外部插件。 |
-| 共享模块 | Host API 可能依赖模块身份。 | 共享 profile runner 在 Desktop profile 内补全安装包与 bundle 缺失的依赖;pnpm 管理的包优先。 |
 | 状态归属 | 共享可执行依赖图会让 CLI(命令行界面)与 Desktop 相互改变 dsh、Cordis、插件或原生模块版本,而两个桌面进程还可能争用同一个 profile。 | Electron 在访问任何 profile 前获取进程生命周期单实例锁,并独占 `$DSH_HOME/profiles/desktop` 及其包管理器状态。CLI 与 Desktop 共享 `$DSH_HOME` 下受支持的产品数据,但绝不共享可执行包、插件激活、锁文件或 `node_modules`。 |
-| 传输 | 复用 Web 服务与认证,让应用行为由同一份实现负责。 | Electron 直接加载 Host 的认证 HTTP URL;子进程 IPC 承载生命周期消息,本地壳协议提供启动和管理页面。 |
+| 传输 | Web 服务与认证共享一套实现。 | Electron 加载打包的 Web 资源;Host 提供启动注入和经过认证的 API。shell 协议提供插件管理页面。 |
 | 插件变更 | 包安装和 Host 启动可能失败。 | Desktop 停止 Host 后直接修改当前 profile。失败保留部分修改供用户修复,不自动回滚 profile。 |
 | 更新 | 桌面壳与 dsh 独立更新会重新产生版本分裂,而桌面壳未变化的数据块不应强制完整传输。 | Electron 壳、匹配的 dsh 运行时与 pnpm 组成一个已签名更新单元。平台更新产物可以复用未变化的数据块,但运行时版本选择绝不脱离 Desktop 发布。 |
 
@@ -39,11 +38,11 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载
 
 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
 
-本地启动页面展示启动状态及可用恢复操作。产品渲染进程使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两个渲染进程都不会获得文件系统、原始 Electron IPC、shell 或任意 pnpm 参数访问权
+应用 preload 暴露启动就绪和致命启动失败上报。产品渲染器使用 Web 应用的 HTTP API。独立插件窗口接收结构化的列表、安装、移除、更新和检查更新操作;两种渲染器都无法访问文件系统、原始 Electron IPC、shell 或任意 pnpm 参数。
 
-产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表和 patch 重载策略初始化新 profile。
+产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
 
-Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,并以英文作为 fallback。菜单、原生对话框、启动页与插件管理渲染进程使用同一 locale 数据;仓库的 Client UI i18n gate 会检查这些桌面源文件
+Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。菜单、原生对话框和插件管理渲染器使用同一份语言数据;仓库 Client UI i18n 检查覆盖这些桌面端源码
 
 Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、剪切、复制、粘贴和全选命令及平台快捷键。右键点击可编辑输入区域会打开不带快捷键标注的这些命令,其可用状态由 Chromium 提供;选中的只读文本提供“复制”命令。
 
@@ -51,21 +50,21 @@ Electron 原生“编辑”菜单为当前聚焦窗口提供撤销、重做、
 
 签名资源中的 `resources/app.asar/dsh/desktop-runtime.json` 绑定 shell 版本、Electron 的 Node 版本、平台、架构、共享包版本和最终文件清单。启动读取元数据,并检查共享包记录。发布 schema、shell 版本、目标兼容性和文件完整性在打包时验证。首次启动不会把核心包复制到 profile 存储或通过 pnpm 安装核心包。
 
-1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。实际 Host 仅启动一次,并通过共享 profile runner 补全缺失的模块链接。
-2. 应用升级时,共享 profile runner 刷新其拥有的模块链接,不检查插件 peer 要求。插件文件、配置、版本与锁文件保留原位;不运行 pnpm。
+1. 主窗口在 profile 准备或后端启动前,从打包静态资源显示共享 Web 加载页。共享 profile 初始化创建缺失的 manifest、空用户 patch 与 pnpm workspace 文件,不覆盖现有文件。
+2. 生产版在启动 Host 前,清理当前运行包清单或已记录 Desktop 包清单中各包的 profile 副本和回退链接,同时删除对应依赖声明与 overrides;清理改变包状态时丢弃锁文件。其他插件文件、配置和版本保留;开发模式跳过此清理,启动时不运行 pnpm。
 3. Electron 的 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
 4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
-5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
+5. 插件变更在直接修改当前 profile 前停止后端。每次包变更尝试结束后都会重新启动 Host,包括包操作失败的情况。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
 CLI 与 Desktop 共用已安装依赖清单及 bundle 列表协调逻辑。bundle 声明遵循与启动一致的安装目录优先解析顺序。CLI 操作自动启用已安装 bundle;Desktop 更新后保留通过 UI 禁用的 bundle 状态。两条路径都不要求已安装元数据可读才能列出或移除依赖。
 
-加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚
+主窗口创建、主文档加载、preload、渲染器、Web 初始化或后端的致命失败,会在每个应用进程中打开一次原生恢复对话框。对话框显示首次错误末尾的限长摘要,标明截断情况,并提供退出、重启、禁用全部第三方插件并重启。启动失败保留 Web 加载页和动画;运行中失败保留当前页面。预期关闭、取消导航和普通请求错误不会触发恢复。Host 成功重启时,包操作错误只在插件窗口报告;任何插件变更后的 Host 启动失败都会进入原生恢复。不通过启动超时推断故障
 
-Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
+原生弹窗详情最多包含 1,200 个 UTF-16 代码单元和八行诊断;完整的已报告错误写入 Electron 控制台。Host 错误诊断仅保留 stderr 输出的最后 64 Ki 个字符。更早的输出会被丢弃,避免长期运行的 Host 使壳的诊断缓冲区无限增长。
 
-重置删除 `$DSH_HOME/profiles/desktop` 中除所持事务锁外的所有条目,然后初始化内置 profile。它删除 Desktop 配置和已安装第三方包,不保留备份。共享任务、设置和 Harness-home `.env` 保持不变。壳资源和 preload 失败时使用独立文档显示可用恢复操作和诊断;其控件不依赖 preload
+恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作禁用第三方 bundle 时持有事务锁写入 profile,不加载运行时元数据,也不删除文件。profile 数据无效或写入失败会作为恢复操作错误报告;Desktop 不会假装禁用成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档
 
-包事务独占 `$DSH_HOME/profiles/desktop/lock` 直到 pnpm 进程退出。pnpm 运行前,共享模块补全 helper 仅移除其拥有的链接,并保留 pnpm 管理的目录;Host 在启动时重新创建所需链接。重置保留 profile 目录与锁,直到初始化和 Host 启动结束。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备负责独立的构建时许可列表。
+包事务独占 `$DSH_HOME/profiles/desktop/lock`,直到 pnpm 进程退出。pnpm 运行前,共享模块回退辅助函数只删除其拥有的链接,保留 pnpm 管理的目录;开发 Host 在启动时重建所需链接。链接清理保留目标目录。原生构建遵循 pnpm 配置的构建策略;发布准备使用独立的构建期允许列表。
 
 ## 开发
 
@@ -119,10 +118,10 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 
 
 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;`prepare:dsh` 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs)。
 
-Windows 发布验收还需在 Desktop 构建后手动运行[原生清理和替换检查](scripts/smoke-windows.ps1)。将 `$Electron` 设为已准备的 Electron 可执行文件,将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 Electron junction 清理、目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
+Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
 
 ```powershell
-pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Electron $Electron -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
+pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
 ```
 
 Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
@@ -212,7 +211,7 @@ pnpm run prepare:desktop
 
 ## 更新
 
-打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。下次启动在显示本地加载页的同时校准版本绑定的运行时。
+打包应用会在主窗口打开十秒后检查目标专用的发布流;本地化的 **检查更新…** 菜单项会手动触发同一检查。发现可用版本时,应用打开一个原生确认弹窗。用户确认后,应用等待正在进行的检查完成,下载并验证已签名的 Desktop 发布、停止 dsh 子进程,并把安装与重启交给 electron-updater。
 
 签名打包为 `DSH_DESKTOP_AUTO_UPDATE_ENV` 选择的部署生成 generic-provider 频道元数据。NSIS 差分包与 macOS ZIP 目标让 electron-updater 可以复用未变化的数据块;供手动安装的 DMG 经过公证,但不生成 blockmap,因为它不是 macOS updater 的载荷。运行时与桌面壳仍属于同一个签名 Desktop 发布。macOS 签名与公证凭据使用 electron-builder 的标准环境变量;Windows EV 签名使用上文所述的公开证书、已验证 SignTool、SafeNet 容器和 runner PIN。必填 Desktop 发布环境选择构建所验证的应用身份与平台签名身份。
 

+ 1 - 0
apps/desktop/package.json

@@ -48,6 +48,7 @@
     "electron": "^44.0.0",
     "electron-builder": "^26.15.3",
     "extract-zip": "^2.0.1",
+    "fflate": "^0.8.2",
     "js-yaml": "^4.2.0",
     "pnpm": "11.7.0",
     "tar": "^7.5.0",

+ 0 - 6
apps/desktop/renderer/plugin-manager.html

@@ -16,12 +16,6 @@
         </div>
         <button id="refresh" class="quiet" type="button"></button>
       </header>
-      <section id="recovery" hidden>
-        <p id="recovery-description"></p>
-        <p id="startup-error" role="alert"></p>
-        <button id="retry" type="button"></button>
-        <button id="disable-all" type="button"></button>
-      </section>
       <form id="install-form">
         <label id="package-label" for="package-spec"></label>
         <div class="install-row">

+ 0 - 8
apps/desktop/renderer/plugin-manager.js

@@ -14,9 +14,6 @@ async function main() {
   document.querySelector('#installed-heading').textContent = messages.installed
   document.querySelector('#empty').textContent = messages.noPlugins
 
-  document.querySelector('#recovery-description').textContent = messages.recoveryDescription
-  document.querySelector('#retry').textContent = messages.retry
-  document.querySelector('#disable-all').textContent = messages.disableAll
 
   const list = document.querySelector('#plugins')
   const empty = document.querySelector('#empty')
@@ -37,9 +34,6 @@ async function main() {
   }
 
   async function render() {
-    const backend = await api.backend.status()
-    document.querySelector('#recovery').hidden = backend.phase !== 'error'
-    document.querySelector('#startup-error').textContent = backend.phase === 'error' ? backend.message : ''
     const plugins = await api.plugins.list()
     list.replaceChildren(...plugins.map(plugin => {
       const item = document.createElement('li')
@@ -128,8 +122,6 @@ async function main() {
     updateForm.hidden = true
     updateTrigger.focus()
   })
-  document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
-  document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
 
   await load(messages.loadingPlugins, '')

+ 0 - 16
apps/desktop/renderer/startup.css

@@ -1,16 +0,0 @@
-:root { color-scheme: light dark; font-family: system-ui, sans-serif; color: #202124; background: #fafafa; }
-body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
-main { width: min(560px, calc(100vw - 64px)); padding: 40px 0; text-align: center; }
-h1 { margin: 24px 0 12px; font-size: 22px; font-weight: 600; }
-p { color: #666; line-height: 1.6; }
-#spinner { width: 32px; height: 32px; margin: auto; border: 3px solid #dedee3; border-top-color: #4d6bfe; border-radius: 50%; animation: spin 0.9s linear infinite; }
-#error { padding: 16px; border: 1px solid #e0e0e5; border-radius: 10px; max-height: 220px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; text-align: left; font: 13px/1.6 ui-monospace, monospace; }
-#actions { margin-top: 24px; }
-button { margin: 4px; padding: 10px 18px; border: 1px solid transparent; border-radius: 8px; color: #fff; background: #4d6bfe; font: inherit; cursor: pointer; }
-button.secondary { color: inherit; background: transparent; border-color: #c8c8d0; }
-button:disabled { opacity: 0.5; cursor: default; }
-button:focus-visible { outline: 2px solid #4d6bfe; outline-offset: 3px; }
-[hidden] { display: none !important; }
-@keyframes spin { to { transform: rotate(360deg); } }
-@media (prefers-color-scheme: dark) { :root { color: #ededf0; background: #171719; } p { color: #aaaab3; } #error { border-color: #38383f; } #spinner { border-color: #38383f; border-top-color: #6a85ff; } }
-@media (prefers-reduced-motion: reduce) { #spinner { animation: none; } }

+ 0 - 26
apps/desktop/renderer/startup.html

@@ -1,26 +0,0 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:">
-    <title id="page-title"></title>
-    <link rel="stylesheet" href="startup.css">
-  </head>
-  <body>
-    <main aria-busy="true">
-      <div id="spinner" aria-hidden="true"></div>
-      <h1 id="title" role="status" aria-live="polite"></h1>
-      <p id="description"></p>
-      <p id="reset-advice" hidden></p>
-      <p id="reinstall-advice" hidden></p>
-      <pre id="error" role="alert" hidden></pre>
-      <div id="actions" hidden>
-        <button id="restart" type="button"></button>
-        <button id="disable-plugins" type="button"></button>
-        <button id="reset-configuration" type="button"></button>
-      </div>
-    </main>
-    <script src="startup.js"></script>
-  </body>
-</html>

+ 0 - 45
apps/desktop/renderer/startup.js

@@ -1,45 +0,0 @@
-const api = window.dshDesktop
-
-async function main() {
-  const { id, messages } = await api.locale()
-  document.documentElement.lang = id
-  document.querySelector('#page-title').textContent = messages.startupLoading
-  document.querySelector('#restart').textContent = messages.restartApplication
-  document.querySelector('#disable-plugins').textContent = messages.disableThirdPartyPlugins
-  document.querySelector('#reset-configuration').textContent = messages.resetConfiguration
-  document.querySelector('#reset-advice').textContent = messages.startupConfigurationAdvice
-  document.querySelector('#reinstall-advice').textContent = messages.startupReinstallAdvice
-  function render(state) {
-    const failed = state.phase === 'error'
-    document.querySelector('main').setAttribute('aria-busy', String(!failed))
-    document.querySelector('#spinner').hidden = failed
-    document.querySelector('#title').textContent = failed ? messages.startupFailed : messages.startupLoading
-    document.querySelector('#description').textContent = failed ? messages.startupErrorDescription : messages.startupLoadingDescription
-    document.querySelector('#error').hidden = !failed
-    document.querySelector('#error').textContent = failed ? state.message : ''
-    document.querySelector('#actions').hidden = !failed
-    for (const button of document.querySelectorAll('#actions button')) button.disabled = !failed
-    for (const selector of ['#disable-plugins', '#reset-configuration', '#reset-advice']) {
-      document.querySelector(selector).hidden = !failed || !state.profileRecovery
-    }
-    document.querySelector('#reinstall-advice').hidden = !failed
-  }
-  let changed = false
-  const unsubscribe = api.backend.subscribe(state => { changed = true; render(state) })
-  window.addEventListener('pagehide', unsubscribe, { once: true })
-  const initial = await api.backend.status()
-  if (!changed) render(initial)
-  async function recover(operation) {
-    render({ phase: 'starting' })
-    try { await operation() }
-    catch (error) {
-      const current = await api.backend.status().catch(() => undefined)
-      render(current?.phase === 'error' ? current : { phase: 'error', message: error instanceof Error ? error.message : String(error) })
-    }
-  }
-  document.querySelector('#disable-plugins').addEventListener('click', () => { void recover(() => api.disablePlugins()) })
-  document.querySelector('#reset-configuration').addEventListener('click', () => { void recover(() => api.resetConfiguration()) })
-  document.querySelector('#restart').addEventListener('click', () => { void recover(() => api.restart()) })
-}
-
-void main()

+ 1 - 1
apps/desktop/scripts/dev.ts

@@ -113,7 +113,7 @@ async function main(): Promise<void> {
   await launchElectron()
 }
 
-main().catch((error: unknown) => {
+await main().catch((error: unknown) => {
   console.error(error instanceof Error ? error.message : error)
   process.exitCode = 1
 })

+ 3 - 10
apps/desktop/scripts/smoke-windows.ps1

@@ -1,14 +1,13 @@
-# Run native Electron cleanup and NSIS replacement checks against the prepared Windows target.
+# Run NSIS directory and replacement checks against the prepared Windows target.
 param(
-  [Parameter(Mandatory)][string]$Electron,
   [Parameter(Mandatory)][string]$Makensis,
   [Parameter(Mandatory)][string]$SevenZip,
   [Parameter(Mandatory)][string]$PluginDir
 )
 $ErrorActionPreference = 'Stop'
 $desktopRoot = Split-Path $PSScriptRoot -Parent
-$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName
 $fixtureRoot = Join-Path $desktopRoot 'tests/fixtures'
+$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-desktop-native-').FullName
 
 function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
   & $Executable @Arguments | Out-Host
@@ -17,12 +16,6 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
 
 try {
   & (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip
-  $previousRunAsNode = $env:ELECTRON_RUN_AS_NODE
-  try {
-    $env:ELECTRON_RUN_AS_NODE = '1'
-    Invoke-Checked $electron @((Join-Path $fixtureRoot 'owned-directory-smoke.mjs'))
-  } finally { $env:ELECTRON_RUN_AS_NODE = $previousRunAsNode }
-
   $payload = Join-Path $scratch 'payload'
   New-Item -ItemType Directory -Path $payload | Out-Null
   [System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
@@ -49,7 +42,7 @@ try {
       throw "Unexpected NSIS $mode replacement result"
     }
   }
-  Write-Output 'Electron cleanup and NSIS directory/replacement smokes passed.'
+  Write-Output 'NSIS directory/replacement smokes passed.'
 } finally {
   $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
   $resolvedScratch = [System.IO.Path]::GetFullPath($scratch)

+ 1 - 1
apps/desktop/src/backend-controller.ts

@@ -3,7 +3,7 @@
 import { desktopErrorState } from './startup-error.ts'
 
 /** Backend availability presented by the desktop window. */
-export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean }
+export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string }
 
 /** Child lifecycle owned by the desktop backend controller. */
 export interface DesktopBackendHost {

+ 73 - 0
apps/desktop/src/fatal-recovery.ts

@@ -0,0 +1,73 @@
+/** Native recovery for the first fatal failure in one Desktop process. */
+
+import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'
+import type { DesktopMessages } from './locale.ts'
+import { desktopErrorState } from './startup-error.ts'
+
+interface RecoveryOperations {
+  messages(): DesktopMessages
+  show(options: MessageBoxOptions): Promise<MessageBoxReturnValue>
+  stop(): Promise<void>
+  disablePlugins(): Promise<void>
+  exit(): void
+  restart(): void
+}
+
+function dialogDetail(error: string, messages: DesktopMessages): string {
+  const advice = `\n\n${messages.startupReinstallAdvice}`
+  const tail = error.split(/\r\n|[\n\r\u2028\u2029]/u).slice(-8).join('\n')
+  const budget = 1200 - advice.length - messages.diagnosticTruncated.length - 1
+  const shortened = tail.slice(-budget).replace(/^[\uDC00-\uDFFF]/u, '')
+  return `${shortened === error ? error : `${messages.diagnosticTruncated}\n${shortened}`}${advice}`
+}
+
+/** Deduplicates fatal reports while keeping explicit recovery-operation failures actionable. */
+export class DesktopFatalRecovery {
+  private reported = false
+
+  /** @param operations - Native presentation and application-owned shutdown operations. */
+  constructor(private readonly operations: RecoveryOperations) {}
+
+  /** Whether this process requires a native recovery action before further plugin changes. */
+  get active(): boolean { return this.reported }
+
+  /**
+   * Show the first fatal error; later reports cannot replace it or open another dialog.
+   * @param error - Fatal failure, including nested diagnostic causes.
+   * @returns Completion of the user's recovery action; duplicate reports resolve immediately.
+   */
+  async report(error: unknown): Promise<void> {
+    if (this.reported) return
+    this.reported = true
+    const messages = this.operations.messages()
+    let detail = desktopErrorState(error).message
+    let message = messages.fatalSummary
+    for (;;) {
+      const { response } = await this.operations.show({
+        type: 'error',
+        title: messages.startupFailed,
+        message,
+        detail: dialogDetail(detail, messages),
+        buttons: [messages.exitApplication, messages.restartApplication, messages.disableThirdPartyPlugins],
+        defaultId: 1,
+        cancelId: 0,
+        noLink: true,
+      })
+      if (response === 0) {
+        try { await this.operations.stop() } catch (failure) { console.error(failure) }
+        this.operations.exit()
+        return
+      }
+      try {
+        await this.operations.stop()
+        if (response === 2) await this.operations.disablePlugins()
+        this.operations.restart()
+        return
+      } catch (failure) {
+        console.error(failure)
+        message = messages.recoveryOperationFailed
+        detail = desktopErrorState(failure).message
+      }
+    }
+  }
+}

+ 1 - 21
apps/desktop/src/ipc.ts

@@ -2,23 +2,17 @@
 
 import type { DesktopPluginRecord } from './project-manager.ts'
 import type { DesktopLocale } from './locale.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
 
 /** IPC channel names kept private to the desktop application bundle. */
 export const DESKTOP_IPC = {
   localeGet: 'dsh-desktop:locale-get',
   boot: 'dsh-desktop:boot',
+  bootFailed: 'dsh-desktop:boot-failed',
   pluginsList: 'dsh-desktop:plugins-list',
   pluginsAdd: 'dsh-desktop:plugins-add',
   pluginsRemove: 'dsh-desktop:plugins-remove',
   pluginsUpdate: 'dsh-desktop:plugins-update',
   pluginsToggle: 'dsh-desktop:plugins-toggle',
-  pluginsDisableAll: 'dsh-desktop:plugins-disable-all',
-  backendStatus: 'dsh-desktop:backend-status',
-  backendRetry: 'dsh-desktop:backend-retry',
-  applicationRestart: 'dsh-desktop:application-restart',
-  configurationReset: 'dsh-desktop:configuration-reset',
-  backendState: 'dsh-desktop:backend-state',
   updatesCheck: 'dsh-desktop:updates-check',
   updatesInstall: 'dsh-desktop:updates-install',
   updatesState: 'dsh-desktop:updates-state',
@@ -42,12 +36,6 @@ export interface DshDesktopApi {
     remove(name: string): Promise<void>
     update(name: string, version: string): Promise<void>
     toggle(name: string, enabled: boolean): Promise<void>
-    disableAll(): Promise<void>
-  }
-  readonly backend: {
-    status(): Promise<DesktopBackendState>
-    retry(): Promise<void>
-    subscribe(listener: (state: DesktopBackendState) => void): () => void
   }
   readonly updates: {
     check(): Promise<DesktopUpdateState>
@@ -55,11 +43,3 @@ export interface DshDesktopApi {
     subscribe(listener: (state: DesktopUpdateState) => void): () => void
   }
 }
-
-/** Startup-page controls, unavailable to backend-provided application documents. */
-export interface DshDesktopStartupApi extends Pick<DshDesktopApi, 'protocolVersion' | 'locale'> {
-  readonly backend: Omit<DshDesktopApi['backend'], 'retry'>
-  disablePlugins(): Promise<void>
-  restart(): Promise<void>
-  resetConfiguration(): Promise<void>
-}

+ 14 - 22
apps/desktop/src/locale.ts

@@ -2,15 +2,14 @@
 
 export const en = {
   application: 'Application',
-  startupFailed: 'DeepSeek Harness could not start',
-  startupLoading: 'Starting DeepSeek Harness…',
-  startupLoadingDescription: 'Your workspace will open when it is ready.',
-  startupErrorDescription: 'Choose a recovery action below. Disabling third-party plugins retains their files.',
+  startupFailed: 'DeepSeek Harness is unavailable',
+  fatalSummary: 'The application could not start or stopped unexpectedly.',
+  diagnosticTruncated: '… Error details shortened. The full diagnostic was written to the Electron console.',
   startupReinstallAdvice: 'If application files are missing or damaged, close the application and reinstall it. Your tasks are stored separately.',
-  startupConfigurationAdvice: 'Reset Desktop deletes all Desktop profile configuration and third-party plugins without a backup, then starts a fresh profile. Shared tasks and settings are retained.',
-  restartApplication: 'Close and restart',
-  resetConfiguration: 'Reset Desktop and retry',
-  disableThirdPartyPlugins: 'Disable all third-party plugins and retry',
+  exitApplication: 'Exit',
+  restartApplication: 'Restart',
+  recoveryOperationFailed: 'The recovery operation failed',
+  disableThirdPartyPlugins: 'Disable all third-party plugins and restart',
   pluginsMenu: 'Desktop Plugins…',
   checkUpdatesMenu: 'Check for Updates…',
   updateCheckFailedTitle: 'Update Check Failed',
@@ -30,9 +29,6 @@ export const en = {
   enable: 'Enable',
   disable: 'Disable',
   disabled: 'Disabled',
-  retry: 'Retry startup',
-  disableAll: 'Disable all plugins and retry',
-  recoveryDescription: 'The backend could not start. Update or disable incompatible plugins, then retry. Installed plugins and configuration are retained.',
   changingActivation: 'Changing plugin activation…',
   npmPackage: 'npm package',
   install: 'Install',
@@ -56,15 +52,14 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
 
 export const zh = {
   application: '应用',
-  startupFailed: 'DeepSeek Harness 无法启动',
-  startupLoading: '正在启动 DeepSeek Harness…',
-  startupLoadingDescription: '准备就绪后将自动打开工作区。',
-  startupErrorDescription: '请选择下方的恢复操作。禁用第三方插件会保留插件文件。',
+  startupFailed: 'DeepSeek Harness 无法使用',
+  fatalSummary: '应用无法启动或已意外停止。',
+  diagnosticTruncated: '… 错误详情已截短,完整诊断已写入 Electron 控制台。',
   startupReinstallAdvice: '如果应用文件缺失或损坏,请关闭应用并重新安装。任务数据存储在独立位置。',
-  startupConfigurationAdvice: '重置 Desktop 会删除桌面端的全部 profile 配置和第三方插件,不保留备份,然后重新初始化并启动。共享任务和设置会保留。',
-  restartApplication: '关闭并重启',
-  resetConfiguration: '重置 Desktop 并重试',
-  disableThirdPartyPlugins: '禁用全部第三方插件并重',
+  exitApplication: '退出',
+  restartApplication: '重启',
+  recoveryOperationFailed: '恢复操作失败',
+  disableThirdPartyPlugins: '禁用全部第三方插件并重',
   pluginsMenu: '桌面插件…',
   checkUpdatesMenu: '检查更新…',
   updateCheckFailedTitle: '更新检查失败',
@@ -84,9 +79,6 @@ export const zh = {
   enable: '启用',
   disable: '禁用',
   disabled: '已禁用',
-  retry: '重试启动',
-  disableAll: '禁用全部插件并重试',
-  recoveryDescription: '后端无法启动。请更新或禁用不兼容插件,然后重试。已安装插件和配置会保留。',
   changingActivation: '正在更改插件启用状态…',
   npmPackage: 'npm 包',
   install: '安装',

+ 57 - 136
apps/desktop/src/main.ts

@@ -20,32 +20,34 @@ import { resolveDesktopPaths } from './paths.ts'
 import { DesktopProjectManager, type DesktopProjectHooks } from './project-manager.ts'
 import { DesktopHostProcess } from './host-process.ts'
 import { desktopNodeEnvironment } from './node-environment.ts'
-import { DesktopBackendController, type DesktopBackendState } from './backend-controller.ts'
+import { DesktopBackendController } from './backend-controller.ts'
 import { DESKTOP_IPC, type DesktopUpdateState } from './ipc.ts'
 import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
 import { claimDesktopSingleInstance } from './single-instance.ts'
 import { DesktopUpdateCoordinator } from './update-coordinator.ts'
-import { desktopErrorState } from './startup-error.ts'
 import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
-import { startupFailureDocument } from './startup-document.ts'
+import { DesktopFatalRecovery } from './fatal-recovery.ts'
 
 const SCHEME = 'dsh-app'
 let focusPrimaryWindow = (): void => {}
-type RecoveryAction = 'restart' | 'plugins' | 'reset'
-let profileRecoveryAvailable = (): boolean => false
-const emergencyPages = new WeakMap<BrowserWindow, { url: string; message: string; busy: boolean }>()
-let recoverApplication = (action: RecoveryAction): Promise<void> => {
-  if (action !== 'restart') return Promise.reject(new Error('Desktop recovery could not initialize; reinstall the application'))
-  app.relaunch()
-  app.quit()
-  return Promise.resolve()
-}
+let stopForRecovery = async (): Promise<void> => {}
+let shuttingDown = false
+const recovery = new DesktopFatalRecovery({
+  messages: () => resolveDesktopLocale(app.getLocale()).messages,
+  show: options => dialog.showMessageBox(options),
+  stop: () => { shuttingDown = true; return stopForRecovery() },
+  disablePlugins: async () => {
+    const manager = new DesktopProjectManager(resolveDesktopPaths(), runtimeResources())
+    await manager.disableAllPlugins()
+  },
+  exit: () => { app.quit() },
+  restart: () => { app.relaunch(); app.quit() },
+})
 
-async function showEmergencyDocument(window: BrowserWindow, message: string): Promise<void> {
-  const document = startupFailureDocument(resolveDesktopLocale(app.getLocale()), message, profileRecoveryAvailable())
-  const url = `data:text/html;charset=utf-8,${encodeURIComponent(document)}`
-  emergencyPages.set(window, { url, message, busy: false })
-  await window.loadURL(url)
+function reportFatal(error: unknown): void {
+  console.error(error)
+  if (shuttingDown) return
+  void recovery.report(error).catch((failure: unknown) => { console.error(failure); app.exit(1) })
 }
 
 protocol.registerSchemesAsPrivileged([{
@@ -153,15 +155,6 @@ function createWindow(preload: string, show = false): BrowserWindow {
       event.preventDefault()
       if (['http:', 'https:'].includes(destination.protocol)) void shell.openExternal(url)
     }
-    const page = emergencyPages.get(window)
-    if (page === undefined || page.busy || window.webContents.getURL() !== page.url) return
-    const action = new URL(url)
-    if (action.protocol !== 'dsh-recovery:' || !['restart', 'plugins', 'reset'].includes(action.hostname)) return
-    if (action.hostname !== 'restart' && !profileRecoveryAvailable()) return
-    page.busy = true
-    void recoverApplication(action.hostname as RecoveryAction).catch(async (error: unknown) => {
-      if (!window.isDestroyed()) await showEmergencyDocument(window, `${page.message}\n${desktopErrorState(error).message}`)
-    }).catch((error: unknown) => { console.error(error) }).finally(() => { page.busy = false })
   })
   return window
 }
@@ -201,8 +194,6 @@ async function main(): Promise<void> {
   const development = !app.isPackaged
   const activeProject = paths.profile
   const manager = new DesktopProjectManager(paths, resources)
-  profileRecoveryAvailable = () => manager.canRecoverProfile()
-  let pageError: Extract<DesktopBackendState, { phase: 'error' }> | undefined
   let quitting = false
   let startup: Promise<void> | undefined
   let mainWindow: BrowserWindow | undefined
@@ -213,44 +204,25 @@ async function main(): Promise<void> {
   const messages = locale.messages
   const appPreload = fileURLToPath(new URL('./preload-app.cjs', import.meta.url))
   const managementPreload = fileURLToPath(new URL('./preload.cjs', import.meta.url))
-  const startupUrl = `${SCHEME}://shell/startup.html`
   const applicationUrl = `${SCHEME}://app/`
   let hostUrl: string | undefined
   let hostCookie: string | undefined
   let injections: readonly unknown[] = []
   let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
-  let emergencyDocument = false
-
-  const showEmergencyError = async (error: unknown): Promise<void> => {
-    if (quitting || emergencyDocument) return
-    emergencyDocument = true
-    const diagnostic = desktopErrorState(error).message
-    pageError = { phase: 'error', message: diagnostic }
-    if (mainWindow !== undefined) await showEmergencyDocument(mainWindow, diagnostic)
-  }
-
   const navigateMain = (url: string): Promise<void> => {
     const window = mainWindow
-    if (quitting || emergencyDocument || window === undefined || window.isDestroyed()) return Promise.resolve()
+    if (quitting || window === undefined || window.isDestroyed()) return Promise.resolve()
     if (navigation?.window === window && navigation.url === url) return navigation.promise
     const next = { window, url, promise: Promise.resolve() }
     next.promise = window.loadURL(url).catch((error: unknown) => {
-      if (quitting || window.isDestroyed() || navigation !== next) return
+      if (quitting || shuttingDown || window.isDestroyed() || navigation !== next
+        || (error instanceof Error && 'code' in error && error.code === 'ERR_ABORTED')) return
       navigation = undefined
       throw error
     })
     navigation = next
     return next.promise
   }
-  const backendState = (): DesktopBackendState => {
-    const state = pageError ?? backend.state
-    return state.phase === 'error' ? { ...state, profileRecovery: profileRecoveryAvailable() } : state
-  }
-  const publishBackend = (state: DesktopBackendState): void => {
-    for (const window of BrowserWindow.getAllWindows()) {
-      window.webContents.send(DESKTOP_IPC.backendState, state)
-    }
-  }
   const backend = new DesktopBackendController((onFailure) => {
     const hostInspectPort = developmentHostInspectPort(development)
     const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
@@ -269,9 +241,7 @@ async function main(): Promise<void> {
       stop: () => host.stop(),
     }
   }, (state) => {
-    if (state.phase === 'starting' && !emergencyDocument) pageError = undefined
-    publishBackend(backendState())
-    if (state.phase === 'error') void navigateMain(startupUrl).catch((error: unknown) => { console.error(error) })
+    if (state.phase === 'error') reportFatal(new Error(state.message))
   })
 
   const publishUpdate = (state: DesktopUpdateState): DesktopUpdateState => {
@@ -287,42 +257,17 @@ async function main(): Promise<void> {
     afterChange: () => backend.start(async () => {}),
   }
 
-  recoverApplication = async (action): Promise<void> => {
-    await startup?.catch(() => undefined)
-    await backend.stop()
-    if (action === 'restart') {
-      app.relaunch()
-      app.quit()
-      return
-    }
-    if (!profileRecoveryAvailable()) throw new Error(messages.startupReinstallAdvice)
-    if (action === 'reset') await manager.resetConfiguration(hooks)
-    else await manager.mutate({ type: 'plugins-disable-all' }, hooks)
-    emergencyDocument = false
-    pageError = undefined
-    navigation = undefined
-    await navigateMain(applicationUrl)
-  }
+  stopForRecovery = () => backend.close()
 
-  const showStartupError = async (error: unknown): Promise<void> => {
-    if (quitting) return
-    pageError = desktopErrorState(error)
-    try { await navigateMain(startupUrl) }
-    catch (navigationError) {
-      await showEmergencyError(new AggregateError([error, navigationError], messages.startupFailed))
-    }
-    publishBackend(backendState())
-  }
   const reconcileBackend = (): Promise<void> => {
     startup ??= (async () => {
-      pageError = undefined
       await navigateMain(applicationUrl)
       await backend.start(async () => {
-        await manager.applyRelease()
+        await manager.applyRelease(app.isPackaged)
       })
       // The existing Web document resumes through the boot IPC response.
-    })().catch(async (error: unknown) => {
-      await showStartupError(error)
+    })().catch((error: unknown) => {
+      reportFatal(error)
       throw error
     }).finally(() => { startup = undefined })
     return startup
@@ -348,13 +293,7 @@ async function main(): Promise<void> {
       }
       return forwardWebRequest(request, hostUrl, hostCookie)
     }
-    if (url.hostname === 'shell') return serveShellAsset(request).then((response) => {
-      if (response.status >= 400 && ['/startup.html', '/startup.js', '/startup.css'].includes(url.pathname)) {
-        void showEmergencyError(new Error(`Desktop recovery resource could not be loaded: ${url.pathname} (HTTP ${response.status})`))
-          .catch((error: unknown) => { console.error(error) })
-      }
-      return response
-    })
+    if (url.hostname === 'shell') return serveShellAsset(request)
     return Promise.resolve(new Response(null, { status: 404 }))
   })
 
@@ -365,6 +304,15 @@ async function main(): Promise<void> {
     return { injections, streamBaseUrl: new URL(hostUrl).origin }
   })
 
+  ipcMain.handle(DESKTOP_IPC.bootFailed, (event, message: unknown) => {
+    assertDesktopSender(event, ['app'])
+    if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) {
+      throw new Error('dsh desktop: rejected startup failure from a non-primary frame')
+    }
+    if (typeof message !== 'string') throw new Error('dsh desktop: startup failure must be text')
+    reportFatal(new Error(message))
+  })
+
   session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
     if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
       callback({})
@@ -381,16 +329,17 @@ async function main(): Promise<void> {
   const mutate = async (event: IpcMainInvokeEvent, mutation: Parameters<DesktopProjectManager['mutate']>[0]): Promise<void> => {
     assertDesktopSender(event, ['shell'])
     await startup?.catch(() => undefined)
-    pageError = undefined
-    await navigateMain(startupUrl)
+    if (recovery.active) throw new Error(messages.fatalSummary)
     try {
       await manager.mutate(mutation, hooks)
-      await navigateMain(applicationUrl)
-    } catch (error) {
-      await showStartupError(error)
-      throw error
+    } finally {
+      if (backend.state.phase === 'ready') {
+        navigation = undefined
+        await navigateMain(applicationUrl).catch(reportFatal)
+      }
     }
   }
+
   ipcMain.handle(DESKTOP_IPC.localeGet, (event) => {
     assertDesktopSender(event, ['shell'])
     return locale
@@ -422,37 +371,6 @@ async function main(): Promise<void> {
     if (typeof name !== 'string' || typeof enabled !== 'boolean') throw new Error('dsh desktop: invalid plugin activation request')
     return mutate(event, { type: 'plugin-toggle', name, enabled })
   })
-  ipcMain.handle(DESKTOP_IPC.pluginsDisableAll, event => mutate(event, { type: 'plugins-disable-all' }))
-  ipcMain.handle(DESKTOP_IPC.backendStatus, (event) => {
-    assertDesktopSender(event, ['shell'])
-    return backendState()
-  })
-  ipcMain.handle(DESKTOP_IPC.backendRetry, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    await reconcileBackend()
-    focusPrimaryWindow()
-  })
-  ipcMain.handle(DESKTOP_IPC.applicationRestart, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    try {
-      await recoverApplication('restart')
-    } catch (error) {
-      await showStartupError(error)
-    }
-  })
-  ipcMain.handle(DESKTOP_IPC.configurationReset, async (event) => {
-    assertDesktopSender(event, ['shell'])
-    const failure = backendState()
-    if (failure.phase !== 'error') {
-      throw new Error('Desktop profile reset requires a startup failure')
-    }
-    await startup?.catch(() => undefined)
-    try {
-      await recoverApplication('reset')
-    } catch (error) {
-      await showStartupError(error)
-    }
-  })
   ipcMain.handle(DESKTOP_IPC.updatesCheck, async (event) => {
     assertDesktopSender(event, ['shell'])
     return updates.check()
@@ -535,23 +453,27 @@ async function main(): Promise<void> {
     const window = createWindow(appPreload, true)
     mainWindow = window
     window.on('closed', () => { if (mainWindow === window) mainWindow = undefined })
+    window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => {
+      if (isMainFrame && code !== -3 && !quitting && !window.isDestroyed()) {
+        reportFatal(new Error(`Desktop page failed to load: ${url} (${String(code)}: ${description})`))
+      }
+    })
     window.webContents.on('preload-error', (_event, _path, error) => {
-      void showEmergencyError(error).catch((failure: unknown) => { console.error(failure) })
+      if (!quitting && !window.isDestroyed()) reportFatal(error)
     })
     window.webContents.on('render-process-gone', (_event, details) => {
       navigation = undefined
-      emergencyDocument = false
-      void showStartupError(new Error(`Desktop renderer exited: ${details.reason}`))
-        .catch((failure: unknown) => { console.error(failure) })
+      if (!quitting && !window.isDestroyed() && details.reason !== 'clean-exit') {
+        reportFatal(new Error(`Desktop renderer exited: ${details.reason}`))
+      }
     })
     return window
   }
   focusPrimaryWindow = () => {
     const window = mainWindow
     if (window === undefined || window.isDestroyed()) {
-      createMainWindow()
-      void navigateMain(applicationUrl)
-        .catch((error: unknown) => { console.error(error) })
+      try { createMainWindow() } catch (error) { reportFatal(error); return }
+      void navigateMain(applicationUrl).catch(reportFatal)
       return
     }
     if (window.isMinimized()) window.restore()
@@ -566,6 +488,7 @@ async function main(): Promise<void> {
     if (process.platform !== 'darwin') app.quit()
   })
   app.on('before-quit', (event) => {
+    shuttingDown = true
     if (shellInstallerOwnsQuit || quitting) return
     event.preventDefault()
     quitting = true
@@ -594,9 +517,7 @@ if (ownsDesktopInstance) void app.whenReady().then(main).catch(async (error: unk
   if (diagnosticFile !== undefined) {
     await writeFile(diagnosticFile, `${error instanceof Error ? error.stack ?? message : message}\n`).catch(() => undefined)
   }
-  const window = BrowserWindow.getAllWindows()[0] ?? createWindow(fileURLToPath(new URL('./preload-app.cjs', import.meta.url)), true)
-  window.once('closed', () => { app.quit() })
-  await showEmergencyDocument(window, message)
+  reportFatal(error)
 }).catch((error: unknown) => {
   console.error(error)
   app.exit(1)

+ 0 - 26
apps/desktop/src/owned-directory.ts

@@ -1,26 +0,0 @@
-/** Desktop transaction cleanup that unlinks directory links without visiting their targets. */
-
-import { lstatSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs'
-import { join } from 'node:path'
-
-/**
- * Remove an owned directory and its contents, unlinking root and nested links.
- * Missing roots are ignored; existing roots must be directories or links.
- * @param path - Owned directory or link to remove; link targets are preserved.
- */
-export function removeOwnedDirectory(path: string): void {
-  const stat = lstatSync(path, { throwIfNoEntry: false })
-  if (stat === undefined) return
-  if (stat.isSymbolicLink()) {
-    unlinkSync(path)
-    return
-  }
-  if (!stat.isDirectory()) throw new Error(`desktop project: owned directory path is not a directory: ${path}`)
-  // Electron's recursive rm follows nested Windows junctions into installed resources.
-  for (const entry of readdirSync(path, { withFileTypes: true })) {
-    const child = join(path, entry.name)
-    if (entry.isDirectory()) removeOwnedDirectory(child)
-    else unlinkSync(child)
-  }
-  rmdirSync(path)
-}

+ 7 - 22
apps/desktop/src/preload-app.ts

@@ -1,32 +1,17 @@
-/** Startup controls for shell documents; application documents receive only the carrier marker. */
+/** Context-isolated application boot bridge and desktop carrier marker. */
 
 import { contextBridge, ipcRenderer } from 'electron'
-import { DESKTOP_IPC, type DshDesktopStartupApi } from './ipc.ts'
+import { DESKTOP_IPC } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
 import { syncNativeTheme } from './preload-theme.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
-
-const startup: DshDesktopStartupApi = {
-  protocolVersion: 1,
-  locale: () => ipcRenderer.invoke(DESKTOP_IPC.localeGet) as ReturnType<DshDesktopStartupApi['locale']>,
-  backend: {
-    status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopStartupApi['backend']['status']>,
-    subscribe(listener) {
-      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
-      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
-      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
-    },
-  },
-  disablePlugins: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
-  restart: () => ipcRenderer.invoke(DESKTOP_IPC.applicationRestart) as Promise<void>,
-  resetConfiguration: () => ipcRenderer.invoke(DESKTOP_IPC.configurationReset) as Promise<void>,
-}
 
 if (location.protocol === 'dsh-app:' && location.hostname === 'app') {
-  contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown> })
+  contextBridge.exposeInMainWorld('dshDesktopBoot', {
+    ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise<unknown>,
+    failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise<void>,
+  })
 }
 
 markDocumentPlatform()
 syncNativeTheme()
-contextBridge.exposeInMainWorld('dshDesktop', location.protocol === 'dsh-app:' && location.hostname === 'shell'
-  ? startup : { protocolVersion: 1 })
+contextBridge.exposeInMainWorld('dshDesktop', { protocolVersion: 1 })

+ 0 - 11
apps/desktop/src/preload.ts

@@ -3,7 +3,6 @@
 import { contextBridge, ipcRenderer } from 'electron'
 import { DESKTOP_IPC, type DshDesktopApi, type DesktopUpdateState } from './ipc.ts'
 import { markDocumentPlatform } from './preload-platform.ts'
-import type { DesktopBackendState } from './backend-controller.ts'
 
 const api: DshDesktopApi = {
   protocolVersion: 1,
@@ -13,18 +12,8 @@ const api: DshDesktopApi = {
     add: spec => ipcRenderer.invoke(DESKTOP_IPC.pluginsAdd, spec) as Promise<void>,
     remove: name => ipcRenderer.invoke(DESKTOP_IPC.pluginsRemove, name) as Promise<void>,
     toggle: (name, enabled) => ipcRenderer.invoke(DESKTOP_IPC.pluginsToggle, name, enabled) as Promise<void>,
-    disableAll: () => ipcRenderer.invoke(DESKTOP_IPC.pluginsDisableAll) as Promise<void>,
     update: (name, version) => ipcRenderer.invoke(DESKTOP_IPC.pluginsUpdate, name, version) as Promise<void>,
   },
-  backend: {
-    status: () => ipcRenderer.invoke(DESKTOP_IPC.backendStatus) as ReturnType<DshDesktopApi['backend']['status']>,
-    retry: () => ipcRenderer.invoke(DESKTOP_IPC.backendRetry) as Promise<void>,
-    subscribe(listener) {
-      const handle = (_event: Electron.IpcRendererEvent, state: DesktopBackendState): void => { listener(state) }
-      ipcRenderer.on(DESKTOP_IPC.backendState, handle)
-      return () => { ipcRenderer.off(DESKTOP_IPC.backendState, handle) }
-    },
-  },
   updates: {
     check: () => ipcRenderer.invoke(DESKTOP_IPC.updatesCheck) as Promise<DesktopUpdateState>,
     install: () => ipcRenderer.invoke(DESKTOP_IPC.updatesInstall) as Promise<void>,

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