1
0
Эх сурвалжийг харах

Merge remote-tracking branch 'origin/master' into codex/fix-bwrap-proc-root-escape

Hypatia May 1 сар өмнө
parent
commit
9003f459aa
95 өөрчлөгдсөн 1446 нэмэгдсэн , 850 устгасан
  1. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml
  2. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md
  3. 2 2
      .agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.i18n.yaml
  5. 29 0
      .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md
  6. 29 0
      .agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md
  7. 6 0
      .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml
  8. 39 0
      .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md
  9. 39 0
      .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
  11. 2 2
      .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
  12. 2 2
      .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml
  14. 2 2
      .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
  15. 2 2
      .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.i18n.yaml
  17. 2 2
      .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md
  18. 2 2
      .agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md
  19. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
  20. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
  21. 2 2
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
  22. 2 2
      .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
  23. 1 1
      .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
  24. 1 1
      .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
  25. 2 2
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml
  26. 3 3
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
  27. 3 3
      .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md
  28. 2 2
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.i18n.yaml
  29. 1 1
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
  30. 1 1
      .agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md
  31. 6 0
      .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.i18n.yaml
  32. 31 0
      .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.md
  33. 31 0
      .agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.zh.md
  34. 1 1
      .github/AGENTS.md
  35. 407 0
      .github/workflows/ci-master.yml
  36. 15 416
      .github/workflows/ci.yml
  37. 2 1
      .github/workflows/e2e.yml
  38. 5 5
      apps/web/tests/access-confirmation.e2e.ts
  39. 13 13
      apps/web/tests/settings-chrome.e2e.ts
  40. 4 4
      apps/web/tests/snapshots/access-confirmation/ui.expected.md
  41. 2 2
      apps/web/tests/snapshots/settings-chrome/dialog.expected.md
  42. 2 2
      docs/config-catalog.i18n.yaml
  43. 4 3
      docs/config-catalog.md
  44. 4 3
      docs/config-catalog.zh.md
  45. 2 2
      docs/module-graph.i18n.yaml
  46. 2 1
      docs/module-graph.md
  47. 2 1
      docs/module-graph.zh.md
  48. 2 2
      docs/persistence-catalog.i18n.yaml
  49. 7 4
      docs/persistence-catalog.md
  50. 7 4
      docs/persistence-catalog.zh.md
  51. 2 2
      docs/subsystems/permission-presets.i18n.yaml
  52. 15 3
      docs/subsystems/permission-presets.md
  53. 15 3
      docs/subsystems/permission-presets.zh.md
  54. 1 1
      examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl
  55. 0 135
      knip.json
  56. 8 4
      packages/client/runtime/src/client/contract/sessions-port.ts
  57. 10 2
      packages/client/runtime/src/client/sessions/manager.ts
  58. 6 1
      packages/client/runtime/src/client/sessions/service.ts
  59. 13 5
      packages/client/runtime/src/client/workspaces/service.ts
  60. 23 6
      packages/client/runtime/tests/workspaces-service.client.spec.ts
  61. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  62. 1 1
      packages/client/ui-conversation/README.md
  63. 1 1
      packages/client/ui-conversation/README.zh.md
  64. 9 3
      packages/client/ui-conversation/src/client/locales.ts
  65. 8 3
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  66. 35 19
      packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
  67. 54 17
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  68. 2 2
      packages/client/ui-permission-presets/README.i18n.yaml
  69. 3 2
      packages/client/ui-permission-presets/README.md
  70. 3 2
      packages/client/ui-permission-presets/README.zh.md
  71. 5 4
      packages/client/ui-permission-presets/src/client/PermissionRow.tsx
  72. 9 3
      packages/client/ui-permission-presets/src/client/index.ts
  73. 18 6
      packages/client/ui-permission-presets/src/client/locales.ts
  74. 30 3
      packages/client/ui-permission-presets/src/client/presentation.ts
  75. 3 2
      packages/client/ui-permission-presets/src/client/settings-store.ts
  76. 11 3
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  77. 20 20
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  78. 1 1
      packages/client/ui-permission-presets/tests/settings-store.client.spec.ts
  79. 5 0
      packages/extensions/tool-cordis/src/api-catalog.ts
  80. 3 1
      packages/host/apiproxy/package.json
  81. 16 4
      packages/host/apiproxy/src/api-proxy.ts
  82. 5 0
      packages/host/apiproxy/src/api/sessions.schema.ts
  83. 12 1
      packages/host/apiproxy/src/api/sessions.ts
  84. 57 1
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  85. 63 0
      packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
  86. 9 1
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  87. 3 0
      packages/host/apiproxy/tsconfig.json
  88. 2 2
      packages/interaction/permission-presets/README.i18n.yaml
  89. 5 5
      packages/interaction/permission-presets/README.md
  90. 5 5
      packages/interaction/permission-presets/README.zh.md
  91. 56 16
      packages/interaction/permission-presets/src/index.ts
  92. 88 3
      packages/interaction/permission-presets/tests/permission-presets.spec.ts
  93. 1 0
      packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
  94. 3 0
      pnpm-lock.yaml
  95. 62 45
      scripts/ci-workflow.spec.ts

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-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/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md
-2026-07-29-pnpm-setup-runner-isolation.md: 74b672b3f90ea445ad1a8e283a5904056059b2f8
-2026-07-29-pnpm-setup-runner-isolation.zh.md: fde4cb747e41ea7dafe87ca3ccd0e872c45dd3b1
+2026-07-29-pnpm-setup-runner-isolation.md: c7c076f34dcd4b905a6bb54411538d6cf61bc1d0
+2026-07-29-pnpm-setup-runner-isolation.zh.md: e0eec45d5c3ea53623e95b3ff5509df5ec22457f

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md

@@ -10,9 +10,9 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md)
 
 ## Decision
 
-Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md).
+Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md).
 
-[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary.
+[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md

@@ -10,9 +10,9 @@ Status: implemented
 
 ## 决策
 
-[主 CI 工作流](../../../../.github/workflows/ci.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md)。
+[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md)。
 
-[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
+[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。
 
 ## 曾考虑的替代方案
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.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-blank-permission-default-refresh.md
+2026-08-17-blank-permission-default-refresh.md: 6e54d030eed4523974c9ca55ff8a621e107704a9
+2026-08-17-blank-permission-default-refresh.zh.md: a415ed8e729023119d454ceaa24705745b883c70

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.md

@@ -0,0 +1,29 @@
+# Agent Note: Refresh blank session permission defaults
+
+Status: implemented
+
+English | [中文](2026-08-17-blank-permission-default-refresh.zh.md)
+
+## Problem
+
+The Web New Session flow reuses a workspace's blank session instead of minting another hidden placeholder. Permission defaults are pinned into a session at creation time, so changing the General settings permission row after a blank placeholder already existed left that placeholder on the previous preset. The next "new" conversation could therefore reuse a blank session whose permission chip contradicted the newly saved default.
+
+## Decision
+
+The Web workspace runtime owns candidate selection: a reusable session must be blank, belong to the selected Workspace, match its canonical cwd, and not be archived. Instead of returning that id directly, `WorkspaceRuntime.connectWorkspace` explicitly adopts it through `session.create` with `reuseWorkspaceBlank: true`. The host rechecks blankness, Workspace membership, cwd, and archive state before notification, and can resume a cold persisted placeholder before notifying optional default owners about the exact eligible session.
+
+`dsh-permission-presets` records each `permission/preset` origin as `default`, `selection`, or `inferred`. On confirmed reuse, it advances the session to the current `defaultPreset` only when no turn has started, the latest selection is default-origin, and the effective sandbox and approval knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, and independently changed knobs remain pinned. The update goes through the normal preset writer, so durable `permission/preset`, `sandbox/mode`, and `approval/policy` facts remain the source for projections and execution.
+
+This partially refines the earlier [permission default for new sessions](../feature/2026-07-31-permission-default-for-new-sessions.md) decision: a settings write alone does not mutate an existing session, while the later confirmed reuse of a default-origin Workspace blank may advance it after live or cold adoption.
+
+## Alternatives considered
+
+**Disable blank-session reuse after any permission settings change.** Rejected because it would leave extra hidden placeholders and make New Session less deterministic. The existing reuse policy is valuable; only stale permission defaults were wrong.
+
+**Have the client compare a blank session's permission projection with the Settings row.** Rejected because the workspace runtime would need to understand the permission settings namespace. The client reports only its reuse decision; the permission service owns the default-origin test and update.
+
+**Scan every live blank session when Settings changes.** Rejected because the live store omits cold persisted placeholders and includes blank sessions that Web cannot reuse, such as archived or non-member sessions. It also cannot distinguish an old default from an explicit selection after restart without a durable origin.
+
+## Consequences
+
+A Settings change does not rewrite an existing session. Confirmed New Session reuse may append permission facts to a live or cold default-origin placeholder, which remains blank because blankness is defined by the absence of `turn/start`. Started conversations, ordinary seeded resumes, explicit selections, and sessions outside the Web reuse decision keep their permission.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-17-blank-permission-default-refresh.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Refresh blank session permission defaults
+
+Status: implemented
+
+[English](2026-08-17-blank-permission-default-refresh.md) | 中文
+
+## Problem
+
+Web 新会话流程会复用工作区中的空白会话,而不是不断创建隐藏占位会话。权限默认值在会话创建时被固定到该会话中,因此当某个空白占位会话已经存在后,用户再修改「通用设置」里的权限默认值,这个占位会话仍会保留旧预设。下一次“新”对话复用它时,权限 chip 就会和刚保存的默认设置不一致。
+
+## Decision
+
+Web workspace runtime 负责选择候选会话:可复用会话必须保持空白、属于所选 Workspace、匹配其规范 cwd,并且未归档。`WorkspaceRuntime.connectWorkspace` 不再直接返回该 id,而是通过带 `reuseWorkspaceBlank: true` 的 `session.create` 显式接纳它。host 会在通知前重新检查空白状态、Workspace 成员关系、cwd 与归档状态,并且可以先恢复冷存储中的持久占位会话,再向可选的默认值所有者通知确实符合资格的会话。
+
+`dsh-permission-presets` 会把每条 `permission/preset` 的来源记录为 `default`、`selection` 或 `inferred`。复用确认后,只有在会话尚未开始轮次、最近选择来自默认值,并且有效沙箱与审批旋钮仍匹配该选择时,服务才会将它推进到当前 `defaultPreset`。显式选择、由旧旋钮推断或没有来源标记的旧选择,以及独立变更的旋钮都会保持固定。更新仍走常规 preset writer,因此持久的 `permission/preset`、`sandbox/mode` 与 `approval/policy` 事实继续作为投影和执行的来源。
+
+这项修复部分细化了较早的[新会话权限默认值](../feature/2026-07-31-permission-default-for-new-sessions.md)决策:单独写入设置不会改变既有会话,而 Web 之后确认复用、且权限来自默认值的 Workspace 空白会话可以在 live 或冷接纳后推进。
+
+## Alternatives considered
+
+**权限设置变化后禁用空白会话复用。** 拒绝,因为这会留下额外的隐藏占位会话,并让新会话行为更不确定。既有复用策略有价值;错误只在于权限默认值过期。
+
+**让客户端比较空白会话的权限投影和 Settings 行。** 拒绝,因为 workspace runtime 需要理解 permission settings namespace。客户端只报告自己的复用决定;权限服务拥有默认来源检查和更新。
+
+**Settings 变化时扫描所有 live 空白会话。** 拒绝,因为 live store 会漏掉冷存储中的持久占位会话,同时包含 Web 无法复用的空白会话,例如已归档或不属于 Workspace 的会话;重启后若没有持久来源,也无法区分旧默认值与显式选择。
+
+## Consequences
+
+Settings 变更不会改写既有会话。确认的新会话复用可能向 live 或冷存储中、权限来自默认值的占位会话追加权限事实;该会话仍保持 blank,因为 blankness 由是否缺少 `turn/start` 定义。已经开始的对话、普通 seed 恢复、显式选择,以及不在 Web 复用决定中的会话都会保留原权限。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.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-20-composer-reference-decoration-keys.md
+2026-08-20-composer-reference-decoration-keys.md: db565e89e1c8addcd1669e295b3be4433083d6bd
+2026-08-20-composer-reference-decoration-keys.zh.md: 950338bc5de5c5f45a33481865eacae53d97cb7d

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md

@@ -0,0 +1,39 @@
+# Agent Note: Composer reference decorations key by draft-order ordinal
+
+Status: implemented
+
+English | [中文](2026-08-20-composer-reference-decoration-keys.zh.md)
+
+## Problem
+
+The composer backdrop renders the draft as an array of segments: plain strings, a leading claim-token mark, one element per structured reference, and one mark per plain-text reference range. React reconciles that array by key.
+
+Structured references carry an identity — the occurrence table mints an `occurrenceId` that survives every edit — so their chips key by it. Plain-text reference ranges have no such identity: `scanTextRefs` re-derives them from the draft on every render, and nothing outside that scan remembers a range between two keystrokes.
+
+Keying those ranges by their draft offset made the key change whenever earlier text changed length. React then treated the range as a different element, unmounted the mark with its nested spans and inline glyph, and mounted a replacement. Every character typed or deleted ahead of a reference rebuilt every reference after the caret, and the work grew with the reference count. [Directory-syntax ranges](../feature/2026-07-27-web-file-and-session-references.md) made that path routine: they match on `@path/` syntax without a lexicon, and each one renders an icon.
+
+## Decision
+
+A plain-text reference mark keys by its index in the offset-sorted `textRefs` list, computed where the boundary list is assembled so a skipped boundary cannot shift it. The scan already returns the ranges in draft order, so the ordinal names the render slot a range occupies, which is the only identity a scan-derived range has.
+
+Structured chips keep `occurrenceId`. The two key strategies differ because the two range kinds differ in identity, not by oversight: a range the occurrence table owns keeps its node across reordering, and a range only a scan knows keeps its node across offset shifts.
+
+A range that stops matching the scan still loses its decoration, because it disappears from `textRefs` and the ordinal it held no longer exists.
+
+## Testing
+
+A component test holds the mark element and its glyph, types a character ahead of the range, and asserts the same nodes are still mounted; it then edits the token out of match shape and asserts the decoration is gone. The test fails against an offset-derived key.
+
+## Alternatives considered
+
+**Key by the range text.** Rejected: duplicate references collide on one key, and editing inside a range changes its key, which reintroduces the remount this fixes.
+
+**Give scan-derived ranges an identity table.** Rejected: it adds mutable state whose only consumer is a render key, and the scan would have to diff against the previous draft to maintain it. An edit that breaks a match simply dropping the range on the next scan is what keeps `scanTextRefs` a pure derivation.
+
+**Drop the keys and let React match by position.** Rejected: React requires keys on elements inside an array, and the plain string segments between them already match by index, so an unkeyed element warns without changing the outcome.
+
+## Consequences
+
+Typing ahead of a reference updates text nodes only; the mark and its icon stay mounted. The backdrop's per-keystroke DOM work no longer scales with the number of references in the draft.
+
+Because the key names a position, inserting a reference ahead of existing ones reuses the earlier nodes with new content instead of re-creating them. That is correct for these marks, which hold no focus, selection, or animation state, and it is the condition any future decoration on this layer meets before it keys by ordinal.

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: 输入框引用装饰按草稿顺序序号取 key
+
+Status: implemented
+
+[English](2026-08-20-composer-reference-decoration-keys.md) | 中文
+
+## 问题
+
+输入框 backdrop 把草稿渲染成一组片段:纯文本字符串、开头的 claim token 标记、每个结构化引用一个元素、每个纯文本引用范围一个标记。React 按 key 协调这个数组。
+
+结构化引用带有身份——occurrence 表铸造的 `occurrenceId` 在任何编辑后都保持不变——因此它们的 chip 用它作 key。纯文本引用范围没有这种身份:`scanTextRefs` 在每次渲染时从草稿重新推导它们,扫描之外没有任何东西在两次按键之间记住某个范围。
+
+用草稿偏移量给这些范围取 key,会让前面文本长度一变 key 就变。React 于是把该范围当作另一个元素,卸载带嵌套 span 和内联图标的标记,再挂载一个替代品。在引用前面输入或删除任意字符,都会重建光标之后的每一个引用,工作量随引用数量增长。[目录语法范围](../feature/2026-07-27-web-file-and-session-references.md)让这条路径成为常态:它们按 `@path/` 语法匹配,不依赖 lexicon,而且每个都渲染一个图标。
+
+## 决策
+
+纯文本引用标记以它在按偏移排序的 `textRefs` 列表中的下标作 key,在组装 boundary 列表处计算,因此被跳过的 boundary 不会让它偏移。扫描本身已按草稿顺序返回范围,所以该序号命名的是范围占据的渲染槽位,而这正是扫描推导出的范围唯一拥有的身份。
+
+结构化 chip 保留 `occurrenceId`。两种 key 策略不同,是因为两类范围的身份不同,而非疏漏:occurrence 表拥有的范围在重排后保住自己的节点,只有扫描知道的范围在偏移变化后保住自己的节点。
+
+不再匹配扫描规则的范围仍然失去装饰,因为它从 `textRefs` 中消失,它占据的序号也不复存在。
+
+## 测试
+
+组件测试持有标记元素及其图标,在范围之前输入一个字符,断言仍是同一批节点;随后把 token 编辑成不再匹配的形态,断言装饰消失。该测试在偏移量 key 下失败。
+
+## 备选方案
+
+**按范围文本取 key。** 拒绝:重复引用会撞同一个 key,且在范围内部编辑会改变 key,重新引入本次修复消除的重挂载。
+
+**为扫描推导的范围建立身份表。** 拒绝:这会引入唯一消费者是渲染 key 的可变状态,而且扫描必须与上一版草稿做 diff 才能维护它。破坏匹配的编辑在下一次扫描时直接丢掉该范围,正是这一点让 `scanTextRefs` 保持为纯推导。
+
+**去掉 key,让 React 按位置匹配。** 拒绝:React 要求数组内的元素带 key,而它们之间的纯文本片段本就按下标匹配,因此无 key 元素只会告警,不改变结果。
+
+## 后果
+
+在引用之前输入只更新文本节点;标记及其图标保持挂载。backdrop 每次按键的 DOM 工作量不再随草稿中的引用数量增长。
+
+由于 key 命名的是位置,在已有引用之前插入新引用会以新内容复用先前的节点,而不是重建它们。对这些不持有焦点、选择区或动画状态的标记而言这是正确的,这也是该图层上任何未来装饰按序号取 key 前需要满足的条件。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.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-07-31-gui-full-access-confirmation.md
-2026-07-31-gui-full-access-confirmation.md: f63502cd3e2306f36b136e6ed8543641449c3d83
-2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d
+2026-07-31-gui-full-access-confirmation.md: 5822ae26d36329b865a05c59dd672daa55fb6224
+2026-07-31-gui-full-access-confirmation.zh.md: e954e6f4ec1d5591a1d55dc1b98f2f0d01b7b242

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md

@@ -10,13 +10,13 @@ Switching the web client to `danger-full-access` was a single click on a permiss
 
 ## Decision
 
-**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
+**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the locale product label for full access; every dismissal path submits nothing.**
 
 - `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
 - The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
 - The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
 - The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
-- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
+- The full-access product label overrides the kebab-to-title display transform when the host keeps the canonical built-in name; an explicit host label wins. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English. The same conditional localization applies to the safer shipped presets ([blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md)).
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md

@@ -10,13 +10,13 @@ Status: implemented
 
 ## 决策
 
-**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
+**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以完全权限的本地化产品标签展示;所有取消路径都不作任何提交。**
 
 - `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
 - composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。
 - `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
 - 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
-- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
+- 当 host 保留内置预设的规范名称时,完全权限产品标签会覆盖 kebab 转 Title Case 的显示变换;显式 host 标签优先。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。同一个条件式本地化规则也适用于更安全的随附预设([空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md))。
 
 ## 考虑过的替代方案
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-07-31-permission-default-for-new-sessions.md
-2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd
-2026-07-31-permission-default-for-new-sessions.zh.md: c56a1b4ac3a6bc88a489dd7e945fa1c11581e7a6
+2026-07-31-permission-default-for-new-sessions.md: 818c105ce880f7b67bfd28030502bf6775e1a039
+2026-07-31-permission-default-for-new-sessions.zh.md: a443190c2e53b6195edc44e98085b276eb9cd8b4

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md

@@ -12,7 +12,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t
 
 `dsh-permission-presets` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
 
-The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
+The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records that the selection came from the default. Those facts pin the permission selected at creation, so a later Settings change does not change started conversations. When Web later confirms a Workspace blank for New Session reuse, the host explicitly adopts that live or cold session and advances it only if its latest selection remains default-origin and effective; this refinement is recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). An ordinary seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default merely by resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
 
 The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
 
@@ -20,7 +20,7 @@ ApiProxy explicitly adds `permission` to its Web settings allowlist beside the c
 
 ## Consequences
 
-Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
+Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter an existing session by itself. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. A Workspace blank may receive a new pinned triplet only when Web confirms it as the reuse target and its effective selection is still default-origin. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
 
 The assembled Web snapshot contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `workspace-write` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md

@@ -12,7 +12,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `
 
 `dsh-permission-presets` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。
 
-服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
+服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实会记录该选择来自默认值。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更不会改变已经开始的对话。Web 之后把某个 Workspace 空白会话确认为新会话复用目标时,host 会显式接纳这个 live 或冷会话,并且只在其最近选择仍来自默认值且继续有效时推进;这项细化记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md)。普通的带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;仅仅恢复会话时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
 
 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
 
@@ -20,7 +20,7 @@ ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入
 
 ## 后果
 
-在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。
+在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但单凭该操作不会改变既有会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。只有当 Web 把某个 Workspace 空白会话确认为复用目标,并且其有效选择仍来自默认值时,该会话才可能收到新的固定三元组。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。
 
 组装后的 Web 快照包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `workspace-write` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.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-07-31-workspace-write-surface-default.md
-2026-07-31-workspace-write-surface-default.md: 2ce4d7b45ad7058768f412302eb86ac332cabfe9
-2026-07-31-workspace-write-surface-default.zh.md: 40b97812eb17b5c11e422ff23cd718576c9c4364
+2026-07-31-workspace-write-surface-default.md: e096028e07f1e7905fef568c435ad7f3d1d19138
+2026-07-31-workspace-write-surface-default.zh.md: f1794ca6a1850441acb0c45274ee94b6dd7ae919

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.md

@@ -12,7 +12,7 @@ The shipped terminal and browser surfaces exposed the same coding tools under di
 
 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) owns one sandbox and permission stack for every shipped TUI, Web, and browser-backed headless session: `dsh-sandbox-local`, `dsh-sandbox-policy`, `dsh-bash-sandbox`, `dsh-fs-sandbox`, `dsh-user-approval`, and `dsh-permission-presets`. The composition fallback is the `workspace-write` preset, which bundles `workspace-write` file effects with the `ask` approval policy. `DSH_PERMISSION_MODE` remains an explicit process override; a stored `permission.defaultPreset` remains the user preference for later sessions and outranks the fallback through the Settings seam.
 
-A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Existing and resumed sessions retain their logged permission, and changing the General-settings default affects only sessions created afterward. The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there.
+A genuinely fresh session pins `permission/preset: workspace-write`, `sandbox/mode: workspace-write`, and `approval/policy: ask` before execution. Changing the General-settings default affects fresh sessions, while started and ordinary resumed sessions retain their logged permission. A later Web New Session action may advance a default-origin Workspace blank after confirming and adopting that exact reuse target, as recorded in [blank permission default refresh](../bug-fix/2026-08-17-blank-permission-default-refresh.md). The browser keeps its Access picker, answerable approval cards, and risk confirmation for Full access. The TUI gains the existing `/permission` command because the shared Permission service activates its command child there.
 
 The mode governs file effects only. Sandboxed bash and filesystem mutations admit the session workspace and platform temporary roots; reads, network access, and process visibility remain outside this policy. If no platform runner can enforce a confined bash call, execution fails closed instead of falling through to an unrestricted command.
 
@@ -30,6 +30,6 @@ The keyless shipped-TUI pseudo-terminal smoke boots the real Loader tree, reads
 
 ## Consequences
 
-Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. Stored user defaults and logged session permissions are not rewritten.
+Fresh sessions can modify the active workspace and temporary roots without extra prompts, while an attempted mutation elsewhere is denied before it reaches the target. Full access remains available by explicit selection, and browser selection retains its acknowledgement dialog. A Settings write does not rewrite logged session permissions; confirmed reuse may append a new default-origin permission triplet to the selected blank Workspace session.
 
 The browser-backed headless entry inherits the Web composition and therefore the same default. The TUI's missing approval answerer is a deliberate limitation of this change: automatic wider retries fail closed there instead of displaying a permission question.

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-workspace-write-surface-default.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml) 为所有已交付的 TUI、Web 以及由浏览器支撑的无头会话统一持有一套沙箱与权限栈:`dsh-sandbox-local`、`dsh-sandbox-policy`、`dsh-bash-sandbox`、`dsh-fs-sandbox`、`dsh-user-approval` 和 `dsh-permission-presets`。组合回退值为 `workspace-write` preset,其中包含 `workspace-write` 文件效果模式与 `ask` 审批策略。`DSH_PERMISSION_MODE` 仍是显式的进程级覆盖;已存储的 `permission.defaultPreset` 仍是面向后续会话的用户偏好,并通过 Settings seam 优先于该回退值。
 
-真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。现有会话和恢复的会话保留日志中记录的权限,更改「通用」设置中的默认值只影响之后创建的会话。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。
+真正的新会话会在执行前固定 `permission/preset: workspace-write`、`sandbox/mode: workspace-write` 和 `approval/policy: ask`。更改「通用」设置中的默认值会影响新建会话,而已经开始的会话和普通恢复会话保留日志中记录的权限。Web 之后执行新会话操作时,可以在确认并接纳确切复用目标后推进权限来自默认值的 Workspace 空白会话;该规则记录在[空白权限默认值刷新](../bug-fix/2026-08-17-blank-permission-default-refresh.md)。浏览器保留 Access 选择器、可应答的审批卡片,以及选择 Full access 时的风险确认。共享 Permission 服务在 TUI 中激活其命令子件,因此 TUI 会获得现有的 `/permission` 命令。
 
 该模式只管辖文件效果。受沙箱约束的 bash 与文件系统修改只允许写入会话工作区和平台临时根目录;读取、网络访问与进程可见性仍不受该策略约束。若没有平台 runner 能强制执行受限的 bash 调用,执行会以拒绝告终,不会退回不受限命令。
 
@@ -30,6 +30,6 @@ Status: implemented
 
 ## 后果
 
-全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。系统不会重写已存储的用户默认值和会话日志中记录的权限。
+全新的会话无需额外提示即可修改当前工作区与临时根目录,尝试修改其他位置则会在触及目标前被拒绝。Full access 仍可通过显式选择获得,浏览器选择时也仍会显示确认对话框。Settings 写入不会改写会话日志中的权限;复用确认可以向选中的空白 Workspace 会话追加新的默认来源权限三元组。
 
 由浏览器支撑的无头入口继承 Web 组合,因此默认值相同。TUI 缺少审批应答者是本次变更的明确限制:自动请求更宽权限的重试会在那里以拒绝告终,而不会显示权限询问。

+ 2 - 2
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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-07-21-serial-cross-platform-ci-reference.md
-2026-07-21-serial-cross-platform-ci-reference.md: d1ab9590df1252c9c91e7ec53dc1559e221d8f68
-2026-07-21-serial-cross-platform-ci-reference.zh.md: c9ffbac42858a19cca7c5fef6fd8f583030c195d
+2026-07-21-serial-cross-platform-ci-reference.md: bb2437139a3eb746ca9eb7752979cd0a6fa947d3
+2026-07-21-serial-cross-platform-ci-reference.zh.md: fd3f762c98fe58d184844297da2fc92a84489501

+ 2 - 2
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md

@@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
+[CI](../../../../.github/workflows/ci.yml) (pull-request-only) and [CI master](../../../../.github/workflows/ci-master.yml) (master-push + workflow_dispatch) give pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). There is no standard-hosted `serial / linux` definition; the standard-hosted `serial / macos` remains disabled under `TODO(hosted-serial-ci)` until its portable capacity can be restored. The current `serial / windows` definition is the in-house `dsh-win-ci` standby. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
 
 Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace.
 
@@ -26,7 +26,7 @@ The macOS reference runs the ordinary Vitest project in forked processes. Node 2
 
 The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
 
-Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
+Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
 
 The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。
+[CI](../../../../.github/workflows/ci.yml)(仅 pull request)与 [CI master](../../../../.github/workflows/ci-master.yml)(master 推送 + `workflow_dispatch`)为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。不存在标准托管的 `serial / linux` 定义;标准托管的 `serial / macos` 仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。当前 `serial / windows` 定义是公司自有 `dsh-win-ci` 池的 standby。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。
 
 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。
 
@@ -26,7 +26,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
 
 独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
 
-master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
+master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
 
 当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.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-07-23-portable-required-pull-request-ci.md
-2026-07-23-portable-required-pull-request-ci.md: 740ef20d6b7a1edc1a010f37bcbcddab2981e8ad
-2026-07-23-portable-required-pull-request-ci.zh.md: 5b24af59fb13e32a642e15c79aee3910601ad45b
+2026-07-23-portable-required-pull-request-ci.md: 6520a16fb4aa5f03e364a17392c87fe0df459ea1
+2026-07-23-portable-required-pull-request-ci.zh.md: 4cea1525d32af847d6936f348aaf2fba369e4dba

+ 1 - 1
.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md

@@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, the Python SDK unit suite, and the [release-shaped Linux x64 Python runtime validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), while the serial references remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
+[CI](../../../../.github/workflows/ci.yml) (pull-request-only) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces; an independent native `windows-2025` job starts automatically but does not participate in the aggregate ([dual Windows decision](2026-08-08-native-windows-pull-request-ci.md)). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, the Python SDK unit suite, and the [release-shaped Linux x64 Python runtime validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), while the serial references (in `ci-master.yml`) remain the complete unsharded cross-platform definitions. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
 
 The three Linux primary jobs, Node compatibility, Python SDK unit suite, Python runtime validation, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; `windows node 24 / native complete` is deliberately absent. Branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26、Python SDK 单元测试套件与[发布形态的 Linux x64 Python 运行时验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md),串行参考流程仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
+[CI](../../../../.github/workflows/ci.yml)(仅 pull request)在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node,覆盖阻断性检查范围;一个独立的原生 `windows-2025` 作业会自动启动,但不参与聚合流程([双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md))。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26、Python SDK 单元测试套件与[发布形态的 Linux x64 Python 运行时验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md),串行参考流程(在 `ci-master.yml` 中)仍是完整且未分片的跨平台定义。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
 
 三项 Linux 主作业、Node 兼容性、Python SDK 单元测试套件、Python 运行时验证和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;`windows node 24 / native complete` 被刻意排除。分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的约定,但无法产出缺失的必需结果。
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md
-2026-07-26-ci-failover-runbook.md: e8a1d1dc339cc5d9be3db3be395e2cddad93b6fc
-2026-07-26-ci-failover-runbook.zh.md: 8f92b7b60c075f21b6f2c83dc46a6e0e5d8acce2
+2026-07-26-ci-failover-runbook.md: bfed4e6e15311d0191c1379a5822b0daf46f4ed3
+2026-07-26-ci-failover-runbook.zh.md: 86007d5b189ccc883dc96d68bc9e54f38bb09e2a

+ 3 - 3
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md

@@ -12,11 +12,11 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym
 
 Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through a repository variable, and the switch is split by platform so an outage on one platform does not retarget the other. The three Linux workers and the `all checks passed` verdict (whose `needs` are the required Linux workers and which runs on the `vm-backup` pool) resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows job resolves through `DSH_CI_FAILOVER_WINDOWS`. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, the corresponding jobs retarget onto the in-house self-hosted pool: under `DSH_CI_FAILOVER_LINUX`, the Linux jobs and verdict move onto the `vm-backup` pool, snapshot concurrency drops to the shared-VM bound, and the hosted-path pnpm cache restores are skipped; under `DSH_CI_FAILOVER_WINDOWS`, the native Windows job moves onto the `dsh-win-ci` pool. Each switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push.
 
-`ci.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check.
+`ci-master.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check.
 
-The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as `cancelled` during busy periods. And the expression is evaluated against the *newly triggered* run, so a run whose own event is not `push` — a benchmark dispatched on master, sharing the group `CI-<ref>` — evaluates to `true` and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence.
+The exemption is narrower than "a drill always finishes", in two ways. GitHub keeps a single pending entry per group, so a newer pending run displaces an older one and intermediate push runs still end as `cancelled` during busy periods. And the expression is evaluated against the *newly triggered* run, so a run whose own event is not `push` — a benchmark dispatched on master within `ci-master.yml`, sharing its group `CI master-<ref>` — evaluates to `true` and does cancel a drill that is mid-flight. That is a rare manual action and the next master push restores the evidence, so it does not warrant further mechanism. What the carve-out buys is that the lane periodically reaches a verdict at all, which is what makes it usable as evidence.
 
-The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push carries only `wine-apt-cache` and these two drills; every other job is pull-request-gated, `workflow_dispatch`-gated, or `if: false`, and `scripts/ci-workflow.spec.ts` pins that set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs.
+The decision belongs at workflow level because cancellation applies to the whole superseded run: a job-level `concurrency` group does not exempt its job. The negated form is load-bearing rather than cosmetic: naming `pull_request` alone would also stop cancelling `workflow_dispatch`, and each runner benchmark fans out to twelve larger runners for up to fifteen minutes inside this same group on master, so a re-dispatch would queue ahead of a drill instead of replacing a stale measurement. What bounds the cost is that a master push in `ci-master.yml` carries only `wine-apt-cache` and these two drills; the pull-request jobs live in the separate `ci.yml` (which does not see `push`), and the benchmarks are `workflow_dispatch`-gated within `ci-master.yml`. `scripts/ci-workflow.spec.ts` pins that push-reachable set — classifying by exact condition, since a negated event test mentions the event it excludes — so a new push-reachable job cannot quietly start accumulating uncancelled runs.
 
 ### What the in-house pool is
 

+ 3 - 3
.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md

@@ -12,11 +12,11 @@ Status: implemented
 
 三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量解析运行器池,且开关按平台拆分,使一个平台的故障不会重定向另一个平台。三个 Linux 工作作业与 `all checks passed` 判定作业(其 `needs` 是必需的 Linux 工作作业,且运行在 `vm-backup` 池上)通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,对应作业切换到公司自有的自托管池:`DSH_CI_FAILOVER_LINUX` 下,Linux 作业与判定作业切到 `vm-backup` 池,快照并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;`DSH_CI_FAILOVER_WINDOWS` 下,原生 Windows 作业切到 `dsh-win-ci` 池。每个开关都是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。
 
-`ci.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。
+`ci-master.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。
 
-这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。其二,该表达式是针对**新触发的运行**求值的,因此自身事件不是 `push` 的运行——例如在 master 上派发的基准测试,与演练共用 `CI-<ref>` 组——求值为 `true`,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。
+这项豁免比「演练总能跑完」要窄,有两点限制。其一,GitHub 每个组只保留一个待运行条目,更新的待运行条目会顶掉更早的,繁忙时段中间的推送运行仍会以 `cancelled` 结束。其二,该表达式是针对**新触发的运行**求值的,因此自身事件不是 `push` 的运行——例如在 `ci-master.yml` 内的 master 上派发的基准测试,与其演练共用 `CI master-<ref>` 组——求值为 `true`,会取消正在运行中的演练。这属于罕见的手动操作,且下一次 master 推送即可恢复证据,因此不值得为它再加机制。这项豁免换来的是该通道**周期性**地得出结论,而这正是它能作为证据的前提。
 
-这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为一次 master 推送只承载 `wine-apt-cache` 和这两条演练;其余作业都受拉取请求门控、`workflow_dispatch` 门控或 `if: false`,并且 `scripts/ci-workflow.spec.ts` 会锁定这个集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。
+这个决定必须放在工作流级:取消作用于被取代的整个运行,作业级 `concurrency` 组并不能豁免其所属作业。采用否定式写法而非仅指名 `pull_request`,是有实质作用的:后者会连 `workflow_dispatch` 一起停止取消,而每次运行器基准测试会在 master 上的同一并发组内同时占用 12 台大规格运行器、最长 15 分钟,届时重复派发会排在演练之前,而不是替换掉已过时的测量。成本之所以可控,是因为 `ci-master.yml` 中一次 master 推送只承载 `wine-apt-cache` 和这两条演练;拉取请求作业位于独立的 `ci.yml`(不监听 `push`),而基准测试在 `ci-master.yml` 内受 `workflow_dispatch` 门控。`scripts/ci-workflow.spec.ts` 会锁定这个推送可达集合——按条件精确匹配,因为否定式事件判断会包含它所排除的事件名——使新的推送可达作业无法悄悄开始累积未取消的运行。
 
 ### 自有池是什么
 

+ 2 - 2
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.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-07-26-pnpm-action-setup-for-symmetric-ci-caching.md
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: 31d1ebf009a6e044722081985546e87b9d4ae0e2
-2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 84445abfdd42671f2f5c9de8403ed7891f9cc292
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md: c998986501eacfca88c8f7125f7ff4bc7f9a7101
+2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md: 6e1b61e64995d0fb68c7509a0cd374b2a98d16ae

+ 1 - 1
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md

@@ -12,7 +12,7 @@ Outside `landlock-run.yml`, each workflow that installed pnpm hand-provisioned i
 
 `pnpm/action-setup@v4` is the only pnpm provisioning mechanism in CI: no workflow runs `corepack enable`. The root dev dependency on `@yarnpkg/cli-dist` separately supplies the modern Yarn CLI exercised by the generated-project e2e; package-manager coverage therefore does not inherit the runner image's Yarn Classic. Caching remains per-job policy on top of pnpm provisioning, in three deliberate shapes:
 
-- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, and the node-compat and two benchmark jobs of `ci.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms.
+- **Symmetric cache** (restore and save): `actions/setup-node` with `cache: pnpm` — `e2e.yml`, `docs-pages.yml`, `pi-ai-provider-e2e.yml`, `build-exe-for-python-sdk.yml`, the node-compat job of `ci.yml`, and the two benchmark jobs of `ci-master.yml`. The larger-runner benchmark keeps its store cache Linux-only through a conditional `cache:` input; the consolidated benchmark caches on both platforms.
 - **Restore-only caching** (hand-rolled `actions/cache` steps): the three enterprise-runner PR jobs and the Wine-based required Windows job restore without saving, keeping cache compression/upload off their latency-sensitive paths — an asymmetry `setup-node`'s cache cannot express. Each configures a store outside the action's replaceable install directory and resolves that path. No master job produces these hosted caches, so these restores hit matching archived entries until they evict. The enterprise jobs skip restore during self-hosted failover because that VM's persistent store is already warm.
 - **Cache-less or persistent** (no store-cache action): the independent native Windows job, native serial-windows and serial-macos, plus `sandbox.yml` install from a cold or runner-local store. Extracting the many-file pnpm store costs more than a clean Windows install; the self-hosted standby and failover jobs instead reuse their VM's persistent pnpm store without transferring a hosted cache archive.
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 `pnpm/action-setup@v4` 是 CI 中提供 pnpm 的唯一机制:没有任何工作流运行 `corepack enable`。根目录的 `@yarnpkg/cli-dist` 开发依赖另行提供 generated-project e2e 所运行的现代 Yarn CLI(命令行界面);因此,用于包管理器覆盖率的 Yarn 不会沿用 runner 镜像里的 Yarn Classic。缓存仍是叠加在 pnpm 提供机制上的按作业策略,保留三种有意采用的形态:
 
-- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`,以及 `ci.yml` 的 node-compat 与两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。
+- **对称缓存**(既恢复也保存):带 `cache: pnpm` 的 `actions/setup-node`——`e2e.yml`、`docs-pages.yml`、`pi-ai-provider-e2e.yml`、`build-exe-for-python-sdk.yml`、`ci.yml` 的 node-compat 作业,以及 `ci-master.yml` 的两个 benchmark 作业。larger-runner benchmark 通过条件化的 `cache:` 输入让 store 缓存仅限 Linux;consolidated benchmark 在两个平台上都启用缓存。
 - **只恢复不上传**(手写的 `actions/cache` 步骤):企业 runner 上的三个 PR(Pull Request)作业和基于 Wine 的必需 Windows 作业只恢复不保存,把缓存压缩/上传挡在它们的延迟敏感路径之外——这种不对称是 `setup-node` 的缓存无法表达的。每个作业都在 action 可替换的安装目录之外配置 store,并解析该路径。没有任何 master 作业生产这些 hosted 缓存,这些恢复步骤只能命中仍有归档的旧条目,直至其被逐出;企业作业在自托管故障切换期间跳过恢复,因为该 VM 的持久 store 已经预热。
 - **无缓存或持久化**(不使用 store 缓存 action):独立的原生 Windows 作业、原生 serial-windows 和 serial-macos,以及 `sandbox.yml` 均从冷 store 或 runner 本地 store 安装。解压含有大量文件的 pnpm store,成本高于在 Windows 上进行一次全新安装;自托管热备与故障切换作业则复用其 VM 的持久 pnpm store,不传输托管缓存归档。
 

+ 6 - 0
.agents/notes/implemented/simplification/2026-08-19-knip-config-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/simplification/2026-08-19-knip-config-cleanup.md
+2026-08-19-knip-config-cleanup.md: 91bfd0b9cf19db0c62831f74a9621ac99b7d44f2
+2026-08-19-knip-config-cleanup.zh.md: 123434b9017b66dfa4f4b1c1cb4aa62d42db5d8c

+ 31 - 0
.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.md

@@ -0,0 +1,31 @@
+# Agent Note: Deleted stale and duplicative knip.json workspace entries
+
+Status: implemented
+
+[中文](2026-08-19-knip-config-cleanup.zh.md) | English
+
+## Problem
+
+`knip.json` carried workspace entries that did no work. Some pointed at packages that no longer exist, and some duplicated the `packages/*/*` glob default exactly. Both kinds made the file larger — 790 lines — and signaled a config that had outgrown the packages it described, so a reader could not tell which entries protected real behavior and which were inert.
+
+## Decision
+
+Deleted 15 `workspaces` entries: 2 stale keys naming packages absent from the working tree and from `HEAD`, and 13 entries whose `entry`/`project` values were byte-identical to the `packages/*/*` glob default.
+
+- Stale keys: `packages/util/home` (removed in `4a09d9b34d`, the harness-home resolver collapse) and `packages/client/web-ui` (no directory and no git history, an orphan key). knip 6.16 does not flag stale workspace keys — that stability check arrived in knip 6.18 — so these were inert config that only deleted when their packages disappeared.
+- Glob-duplicate entries: `packages/host/webserver`, `packages/client/runtime`, `packages/core/tools`, `packages/context/tmux-context`, `packages/util/timeout`, `packages/util/output-retention`, `packages/goal/goal-round-driver`, `packages/goal/tool-goal`, `packages/util/home-paths`, `packages/fs/tool-fs-search`, `packages/client/ui-settings`, `packages/client/modules`, `packages/client/hmr`. Each declared exactly `entry: ["tests/**/*.spec.ts"]` and `project: ["src/**/*.ts", "tests/**/*.ts"]`, which equals the `packages/*/*` glob, and each package still exists, so the glob now covers it identically.
+
+The change is a deletion only: `knip.json` went from 790 to 655 lines with no behavioral change. `pnpm run knip` runs clean (zero issues, exit 0) before and after, because knip selects one workspace config per matched key (`getConfigKeyForWorkspace` uses specificity, not array merge), so a removed entry either lost an unresolvable target or fell back to an identical glob config.
+
+## Alternatives considered
+
+- Fold `zod` and other workspace-level `ignoreDependencies` up to the root. Rejected: the root `ignoreDependencies` is a repository-wide fallback, and these exemptions are deliberately workspace-scoped (the README of `cordis-host-runner` records why `src` cannot import the flagged dependency while the generated TypeRT face in `lib` needs it). Widening scope would mask a genuinely misplaced dependency in any future package.
+- Upgrade knip to 6.18+ to get an automatic stale-workspace check. Deferred: 6.32.2 (latest at the time) re-flags many `@deepseek-ai/...` test dependencies as unused, i.e. it changes analysis semantics, not just adds hints. That is a separate dependency-upgrade decision with its own CI blast radius, not part of this cleanup.
+- Keep the entries as documentation of intent. Rejected: an entry identical to the glob it sits under documents nothing beyond the glob itself, and a key naming an absent package actively misleads.
+
+## Consequences
+
+- `knip.json` is 135 lines shorter and names only packages that exist with config that differs from the glob default.
+- Still-explicit entries (54) all carry a real reason to differ — an `e2e`/fixture/tsx `entry`, a `project` outside the default, or a workspace-scoped `ignoreDependencies`.
+- knip 6.16 cannot itself detect the next stale key, so a package removal must still remember to drop its `knip.json` key; upgrading to 6.18+ (after the analysis-semantics change is separately assessed) restores that guard.
+- This realizes the "never a restatement of the default stanza" criterion of the package-inventory proposal ([topic](../../proposed/process/2026-06-20-discover-package-inventory.md)); its remaining items — the e2e entry folding and the generated inventory — stay open there.

+ 31 - 0
.agents/notes/implemented/simplification/2026-08-19-knip-config-cleanup.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 删除 knip.json 中失效与重复的 workspace 条目
+
+Status: implemented
+
+[English](2026-08-19-knip-config-cleanup.md) | 中文
+
+## 问题
+
+`knip.json` 携带了大量不产生任何作用的 workspace 条目。其中一些指向已经不复存在的包,另一些与 `packages/*/*` 通配默认完全重复。这两类都让文件变大——790 行——并显现出配置已经超出了它所描述的包:读者无法分辨哪些条目在保护真实行为、哪些是惰性的。
+
+## 决策
+
+删除了 15 个 `workspaces` 条目:2 个指向工作树与 `HEAD` 中都不存在的包的失效键,以及 13 个 `entry`/`project` 与 `packages/*/*` 通配默认逐字节相同的条目。
+
+- 失效键:`packages/util/home`(在 `4a09d9b34d`,harness home 解析器的合并改动中删除)和 `packages/client/web-ui`(无对应目录、无 git 历史,是孤儿键)。knip 6.16 不会标记失效的 workspace 键——这项稳定性检查在 knip 6.18 才引入——所以这些是本应在包消失时一并删除、却残留的惰性配置。
+- 通配重复条目:`packages/host/webserver`、`packages/client/runtime`、`packages/core/tools`、`packages/context/tmux-context`、`packages/util/timeout`、`packages/util/output-retention`、`packages/goal/goal-round-driver`、`packages/goal/tool-goal`、`packages/util/home-paths`、`packages/fs/tool-fs-search`、`packages/client/ui-settings`、`packages/client/modules`、`packages/client/hmr`。每个都恰好声明了 `entry: ["tests/**/*.spec.ts"]` 和 `project: ["src/**/*.ts", "tests/**/*.ts"]`,与 `packages/*/*` 通配相等,且这些包仍然存在,因此通配现在以完全相同的方式覆盖它们。
+
+本改动只做删除:`knip.json` 从 790 行降到 655 行,行为不变。`pnpm run knip` 在改动前后都干净通过(零问题、退出码 0),因为 knip 为每个已匹配的键选取一条 workspace 配置(`getConfigKeyForWorkspace` 按特定优先、不做数组合并),所以被删条目要么丢掉了无法解析的目标,要么回退到一个完全相同的通配配置。
+
+## 备选方案
+
+- 把 `zod` 及其它 workspace 级 `ignoreDependencies` 上提到根级。否决:根级 `ignoreDependencies` 是全仓库兜底,而这些豁免是刻意限定在 workspace 的(`cordis-host-runner` 的 README 记录了为什么 `src` 无法 import 被标记的依赖、而生成的 `lib` 里的 TypeRT 契约面需要它)。扩大作用域会掩盖未来任何包里真正放错位置的依赖。
+- 升级 knip 到 6.18+ 以获得自动的失效 workspace 检查。延后:撰写时的最新版 6.32.2 会把大量 `@deepseek-ai/...` 测试依赖重新标记为未使用——也就是改变了分析语义,而不仅是新增提示。那是独立的依赖升级决定,带自己的 CI 影响面,不属于本次清理。
+- 保留这些条目作为意图的文档。否决:与它挂在下面的通配完全相同的条目,除了通配本身外不记录任何东西;而指向不存在包的键确实会误导人。
+
+## 结果
+
+- `knip.json` 缩短了 135 行,并且只列出确实存在、且配置与通配默认有差异的包。
+- 仍然显式的条目(54 个)都带有真实的特例理由——`e2e`/fixture/tsx 的 `entry`、超出默认的 `project`、或 workspace 级的 `ignoreDependencies`。
+- knip 6.16 自身无法检测下一个失效键,因此删除包时仍须记得清理它的 `knip.json` 键;升级到 6.18+(在分析语义的改动被单独评估之后)会恢复这道守卫。
+- 本改动落实了包清单提案中「绝不复述默认 stanza」的标准([议题](../../proposed/process/2026-06-20-discover-package-inventory.md));其剩余项——e2e 入口折叠与生成的清单——仍在提案中保持开放。

+ 1 - 1
.github/AGENTS.md

@@ -1,3 +1,3 @@
 # AGENTS.md — GitHub Actions
 
-Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` (or the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under `DSH_CI_FAILOVER_WINDOWS=selfhosted`) but reports independently. The master `serial-windows` standby continuously validates the self-hosted failover target — see the [failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md).
+Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` (or the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under `DSH_CI_FAILOVER_WINDOWS=selfhosted`) but reports independently. `ci.yml` is pull-request-only; the master `serial-windows` standby, the Linux `serial-linux-selfhosted` standby, the `wine-apt-cache` seeder, and the two manual runner benchmarks live in `ci-master.yml` (master-push + `workflow_dispatch`). Because `ci-master.yml` does not listen to `pull_request`, those master-only jobs never appear in PR check panels (a job a workflow defines for a given event is listed and shows `skipped` when its `if` is false); keeping them in a separate workflow is what stops PR check circles from showing gray segments. The master `serial-windows` standby continuously validates the self-hosted failover target — see the [failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md).

+ 407 - 0
.github/workflows/ci-master.yml

@@ -0,0 +1,407 @@
+name: CI master
+
+on:
+  push:
+    branches: [master]
+  workflow_dispatch:
+    inputs:
+      suite:
+        description: Manual CI suite to run
+        required: true
+        default: larger-runner-benchmark
+        type: choice
+        options:
+          - larger-runner-benchmark
+          - consolidated-runner-benchmark
+
+# A master push may carry only the two self-hosted standby drills and the Wine
+# apt cache seeder; those drills outlast the interval between master merges, so
+# push is exempt from cancellation (see ci-failover-runbook). workflow_dispatch
+# keeps cancelling: a re-dispatched runner benchmark holds up to 12 larger
+# runners for 15 minutes in this same group.
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ github.event_name != 'push' }}
+
+permissions:
+  contents: read
+
+env:
+  PRIMARY_NODE_VERSION: '24'
+  # CI runs must never report to the production telemetry endpoint baked
+  # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+  DSH_TELEMETRY_DISABLED: '1'
+
+jobs:
+  # Master seeds the Wine apt-archive cache in the default-branch scope,
+  # which every pull request's windows job can restore; saves from
+  # pull-request runs are scoped to their own merge ref and help nobody
+  # else. Runs in seconds when the image version already has a cache.
+  wine-apt-cache:
+    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+    name: wine apt cache
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - name: Compose Wine apt cache key
+        id: wine-cache-key
+        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
+
+      - uses: actions/cache@v4
+        id: wine-cache
+        with:
+          path: ~/wine-debs
+          key: ${{ steps.wine-cache-key.outputs.key }}
+
+      - name: Download the Wine dependency closure
+        if: steps.wine-cache.outputs.cache-hit != 'true'
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends --download-only wine
+          mkdir -p "$HOME/wine-debs"
+          cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/"
+          du -sh "$HOME/wine-debs"
+
+  # Hot-standby drill for the in-house self-hosted pool: every master move
+  # re-runs the complete unsharded aggregate on the persistent 64-core VM,
+  # continuously proving that environment can take over a required lane if
+  # the hosted pools degrade (the switch is then setting the writer-manageable
+  # DSH_CI_FAILOVER_LINUX variable — see the failover runbook, no merge required).
+  # Push-triggered, so this lane always executes the base branch's own
+  # workflow definition. This workflow never listens to pull_request, so the
+  # drill does not appear in PR checks. No cache steps because the VM's
+  # persistent pnpm store and tool caches make them redundant (and saving here
+  # would poison the hosted cache namespace with self-hosted paths).
+  serial-linux-selfhosted:
+    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+    name: serial / linux (self-hosted standby)
+    runs-on: [self-hosted, linux, x64, vm-backup]
+    steps:
+      # DSH_ARCHIVE_BASE_REF below compares the frozen-archive gate against
+      # github.event.before, so full history is required: depth 2 would miss it
+      # on multi-commit or force pushes; full fetch is cheap here because
+      # checkout resolves against the VM's local mirror.
+      - uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+
+      - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure persistent pnpm store
+        run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV"
+
+      - name: Install (immutable)
+        run: pnpm install --frozen-lockfile
+
+      # The persistent VM image owns Playwright's Linux system packages; this
+      # step also proves that browser provisioning remains usable for failover.
+      - name: Install Playwright Chromium
+        run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
+
+      - name: Prepare bubblewrap (unrestrict userns)
+        run: bash scripts/prepare-ci-bubblewrap.sh
+
+      - name: Run complete unsharded primary Node CI serially
+        env:
+          DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
+          DSH_COVERAGE_MAX_WORKERS: '1'
+          DSH_E2E_MAX_WORKERS: '1'
+          DSH_GATE_CONCURRENCY: '1'
+          DSH_OXLINT_THREADS: '1'
+          DSH_PUBLINT_CONCURRENCY: '1'
+          DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
+        run: pnpm run check:ci:linux-primary
+
+  # TODO(hosted-serial-ci): Re-enable the one remaining disabled hosted serial
+  # reference job (serial-macos) before release. Re-enabling serial-macos does
+  # not restore a Linux hosted-cache producer: decide whether to add a master
+  # seeder or remove the restore-only steps if cold starts become a concern.
+  serial-macos:
+    if: false
+    name: serial / macos
+    runs-on: macos-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Install (immutable)
+        run: pnpm install --frozen-lockfile
+
+      - name: Run complete unsharded primary Node CI serially
+        env:
+          DSH_COVERAGE_MAX_WORKERS: '1'
+          DSH_E2E_MAX_WORKERS: '1'
+          DSH_GATE_CONCURRENCY: '1'
+          DSH_PUBLINT_CONCURRENCY: '1'
+          DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
+        run: pnpm run check:ci
+
+  # Hot-standby drill for the in-house self-hosted Windows pool: every master
+  # move re-runs the complete unsharded Windows gate inventory on the persistent
+  # VM, continuously proving that environment can take over the required
+  # `windows` lane if the hosted pool degrades (the switch is setting the
+  # writer-manageable DSH_CI_FAILOVER_WINDOWS variable — see the failover
+  # runbook, no merge required). Push-triggered, so this lane always executes
+  # the base branch's own workflow definition. This workflow never listens to
+  # pull_request, so the drill does not appear in PR checks. No cache steps
+  # because the VM's persistent pnpm store and tool caches make them redundant
+  # (and saving here would poison the hosted cache namespace with self-hosted
+  # paths).
+  serial-windows:
+    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+    name: serial / windows (self-hosted standby)
+    runs-on: [self-hosted, dsh-win-ci, windows]
+    timeout-minutes: 120
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Enable Developer Mode (symlink support)
+        shell: pwsh
+        run: >-
+          reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
+          /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
+
+      - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure persistent pnpm store
+        shell: pwsh
+        run: |
+          $storeRoot = "$env:LOCALAPPDATA\pnpm\store"
+          echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
+
+      - name: Install (immutable)
+        shell: pwsh
+        run: pnpm install --frozen-lockfile
+
+      - name: Run complete unsharded Windows gate inventory serially
+        shell: pwsh
+        env:
+          DSH_COVERAGE_MAX_WORKERS: '1'
+          DSH_GATE_CONCURRENCY: '1'
+          DSH_PUBLINT_CONCURRENCY: '1'
+        run: pnpm run check:ci:windows-complete
+
+  # Manual, bounded comparison of the actual critical Linux and Windows lanes.
+  # The named pools are restricted at the organization level to this repository.
+  larger-runner-benchmark:
+    if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'
+    runs-on: ${{ matrix.runner }}
+    timeout-minutes: 15
+    strategy:
+      fail-fast: false
+      max-parallel: 12
+      matrix:
+        include:
+          - platform: linux
+            cores: '4'
+            runner: dsh-ubuntu-24-04-4core
+            workload: typecheck
+          - platform: linux
+            cores: '8'
+            runner: dsh-ubuntu-24-04-8core
+            workload: typecheck
+          - platform: linux
+            cores: '16'
+            runner: dsh-ubuntu-24-04-16core
+            workload: typecheck
+          - platform: linux
+            cores: '32'
+            runner: dsh-ubuntu-24-04-32core
+            workload: typecheck
+          - platform: linux
+            cores: '64'
+            runner: dsh-ubuntu-24-04-64core
+            workload: typecheck
+          - platform: linux
+            cores: '96'
+            runner: dsh-ubuntu-24-04-96core
+            workload: typecheck
+          - platform: windows
+            cores: '4'
+            runner: dsh-windows-2025-4core
+            workload: production-site
+          - platform: windows
+            cores: '8'
+            runner: dsh-windows-2025-8core
+            workload: production-site
+          - platform: windows
+            cores: '16'
+            runner: dsh-windows-2025-16core
+            workload: production-site
+          - platform: windows
+            cores: '32'
+            runner: dsh-windows-2025-32core
+            workload: production-site
+          - platform: windows
+            cores: '64'
+            runner: dsh-windows-2025-64core
+            workload: production-site
+          - platform: windows
+            cores: '96'
+            runner: dsh-windows-2025-96core
+            workload: production-site
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
+
+      # The benchmark's Windows lanes deliberately skip the store cache like
+      # the independent native Windows job; an empty input disables caching.
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+          cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }}
+
+      - name: Report runner capacity
+        run: >-
+          node -e "const os = require('node:os');
+          console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
+          memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
+
+      - name: Install (immutable)
+        run: pnpm install --frozen-lockfile
+
+      - name: Run critical Linux typecheck lane
+        if: matrix.platform == 'linux'
+        run: pnpm run typecheck
+
+      - name: Run critical Windows production-site lane
+        if: matrix.platform == 'windows'
+        run: pnpm run docs:build
+
+  # Manual comparison of the intended low-fanout topology. Linux runs the
+  # complete unsharded primary aggregate with bounded in-runner parallelism;
+  # Windows runs both blocking build targets concurrently through run-gates.
+  consolidated-runner-benchmark:
+    if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'
+    runs-on: ${{ matrix.runner }}
+    timeout-minutes: 15
+    strategy:
+      fail-fast: false
+      max-parallel: 12
+      matrix:
+        include:
+          - platform: linux
+            cores: '4'
+            runner: dsh-ubuntu-24-04-4core
+            workers: '4'
+          - platform: linux
+            cores: '8'
+            runner: dsh-ubuntu-24-04-8core
+            workers: '8'
+          - platform: linux
+            cores: '16'
+            runner: dsh-ubuntu-24-04-16core
+            workers: '16'
+          - platform: linux
+            cores: '32'
+            runner: dsh-ubuntu-24-04-32core
+            workers: '32'
+          - platform: linux
+            cores: '64'
+            runner: dsh-ubuntu-24-04-64core
+            workers: '32'
+          - platform: linux
+            cores: '96'
+            runner: dsh-ubuntu-24-04-96core
+            workers: '32'
+          - platform: windows
+            cores: '4'
+            runner: dsh-windows-2025-4core
+            workers: '2'
+          - platform: windows
+            cores: '8'
+            runner: dsh-windows-2025-8core
+            workers: '2'
+          - platform: windows
+            cores: '16'
+            runner: dsh-windows-2025-16core
+            workers: '2'
+          - platform: windows
+            cores: '32'
+            runner: dsh-windows-2025-32core
+            workers: '2'
+          - platform: windows
+            cores: '64'
+            runner: dsh-windows-2025-64core
+            workers: '2'
+          - platform: windows
+            cores: '96'
+            runner: dsh-windows-2025-96core
+            workers: '2'
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
+
+      # Unlike the larger-runner suite, both platforms cache the store here:
+      # the consolidated topology measures cache mechanics as workload.
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+          cache: pnpm
+
+      - name: Report runner capacity
+        run: >-
+          node -e "const os = require('node:os');
+          console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
+          memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
+
+      - name: Install and prepare Linux
+        if: matrix.platform == 'linux'
+        run: |
+          pnpm install --frozen-lockfile &
+          install_pid=$!
+          bash scripts/prepare-ci-bubblewrap.sh &
+          sandbox_pid=$!
+          install_status=0
+          wait "$install_pid" || install_status=$?
+          sandbox_status=0
+          wait "$sandbox_pid" || sandbox_status=$?
+          if (( install_status != 0 )); then exit "$install_status"; fi
+          exit "$sandbox_status"
+
+      - name: Install (immutable)
+        if: matrix.platform == 'windows'
+        shell: pwsh
+        run: pnpm install --frozen-lockfile
+
+      - name: Run complete unsharded primary Node CI concurrently
+        if: matrix.platform == 'linux'
+        env:
+          DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }}
+          DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
+          DSH_OXLINT_THREADS: ${{ matrix.workers }}
+          DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }}
+          DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }}
+        run: pnpm run check:ci
+
+      - name: Run blocking Windows builds concurrently
+        if: matrix.platform == 'windows'
+        shell: pwsh
+        env:
+          DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
+        run: pnpm run check:ci:windows-blocking

+ 15 - 416
.github/workflows/ci.yml

@@ -1,36 +1,7 @@
 name: CI
 
 on:
-  push:
-    branches: [master]
   pull_request:
-  workflow_dispatch:
-    inputs:
-      suite:
-        description: Manual CI suite to run
-        required: true
-        default: larger-runner-benchmark
-        type: choice
-        options:
-          - larger-runner-benchmark
-          - consolidated-runner-benchmark
-
-# Cancel a superseded run on every event EXCEPT push. A push run carries the two
-# self-hosted standby drills, which take longer than the interval between master
-# merges, so cancelling supersedes a drill before it reaches a verdict and the
-# lane yields no readiness evidence. Must be decided here: cancellation applies
-# to the whole superseded run, so a job-level group cannot exempt its job.
-# Negated rather than `== 'pull_request'` so workflow_dispatch keeps cancelling:
-# a re-dispatched runner benchmark holds up to 12 larger runners for 15 minutes
-# and shares this group with the drills on master, so queueing it would delay
-# them. The guarantee is narrow — evaluated against the newly triggered run, so a
-# dispatch on master still cancels a mid-flight drill, and a newer pending push
-# displaces an older one. Bounds and rationale:
-# .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md
-concurrency:
-  group: ${{ github.workflow }}-${{ github.ref }}
-  cancel-in-progress: ${{ github.event_name != 'push' }}
-
 permissions:
   contents: read
 
@@ -40,13 +11,14 @@ env:
   # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
   DSH_TELEMETRY_DISABLED: '1'
 
-jobs:
+# Cancel a superseded pull-request run on a new push so a fresh head does not
+# queue a second full 9-job run behind a stale one (paid enterprise runners
+# would otherwise stack with no auto-cancellation).
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
 
-  # TODO(hosted-serial-ci): Re-enable the one remaining disabled hosted serial
-  # reference job (serial-macos) before release. The self-hosted standby lane
-  # below remains active on every master push. Re-enabling serial-macos does not
-  # restore a Linux hosted-cache producer: decide whether to add a master seeder
-  # or remove the restore-only steps if cold starts become a concern.
+jobs:
 
   # Three enterprise jobs isolate coverage, static analysis, and the
   # build-backed consumer tail. The consumer job owns the only Linux build so
@@ -61,8 +33,9 @@ jobs:
   # vm-backup pool and re-running the failed jobs is the entire switch —
   # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
   # in-house pool's readiness is re-proven on every master push by the
-  # serial-linux-selfhosted standby lane below. The Windows failover switch is
-  # the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native job below.
+  # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
+  # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native
+  # job below.
   node-24:
     if: github.event_name == 'pull_request'
     runs-on: >-
@@ -376,9 +349,9 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
 
-      # Master's wine-apt-cache job seeds the default-branch scope every pull
-      # request can read; a save from this job only reaches reruns of the
-      # same merge ref.
+      # Master's wine-apt-cache job in ci-master.yml seeds the default-branch
+      # scope every pull request can read; a save from this job only reaches
+      # reruns of the same merge ref.
       - name: Compose Wine apt cache key
         id: wine-cache-key
         run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
@@ -423,35 +396,6 @@ jobs:
         if: always()
         run: wineserver -k 2>/dev/null || true
 
-  # Master seeds the Wine apt-archive cache in the default-branch scope,
-  # which every pull request's windows job can restore; saves from
-  # pull-request runs are scoped to their own merge ref and help nobody
-  # else. Runs in seconds when the image version already has a cache.
-  wine-apt-cache:
-    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
-    name: wine apt cache
-    runs-on: ubuntu-latest
-    timeout-minutes: 10
-    steps:
-      - name: Compose Wine apt cache key
-        id: wine-cache-key
-        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
-
-      - uses: actions/cache@v4
-        id: wine-cache
-        with:
-          path: ~/wine-debs
-          key: ${{ steps.wine-cache-key.outputs.key }}
-
-      - name: Download the Wine dependency closure
-        if: steps.wine-cache.outputs.cache-hit != 'true'
-        run: |
-          sudo apt-get update
-          sudo apt-get install -y --no-install-recommends --download-only wine
-          mkdir -p "$HOME/wine-debs"
-          cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/"
-          du -sh "$HOME/wine-debs"
-
   # Every pull request also gets a real Windows-kernel signal. This job keeps
   # its own unmasked conclusion but is deliberately absent from
   # all-checks-passed.needs, so it never delays or changes that required
@@ -510,358 +454,13 @@ jobs:
         shell: pwsh
         run: pnpm run check:ci:windows-complete
 
-  # Hot-standby drill for the in-house self-hosted pool: every master move
-  # re-runs the complete unsharded aggregate on the persistent 64-core VM,
-  # continuously proving that environment can take over a required lane if
-  # the hosted pools degrade (the switch is then setting the writer-manageable
-  # DSH_CI_FAILOVER_LINUX variable — see the failover runbook, no merge required).
-  # Push-triggered, so this lane always executes the base branch's own
-  # workflow definition. (Under failover, pull_request jobs do reach these
-  # runners with the PR merge ref's workflow — the boundary there is
-  # repository membership: private, forking disabled, Dependabot excluded.)
-  # Non-blocking for
-  # pull requests; no cache steps because the VM's persistent pnpm store and
-  # tool caches make them redundant (and saving here would poison the hosted
-  # cache namespace with self-hosted paths).
-  serial-linux-selfhosted:
-    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
-    name: serial / linux (self-hosted standby)
-    runs-on: [self-hosted, linux, x64, vm-backup]
-    steps:
-      # DSH_ARCHIVE_BASE_REF below compares the frozen-archive gate against
-      # github.event.before, so full history is required: depth 2 would miss it
-      # on multi-commit or force pushes; full fetch is cheap here because
-      # checkout resolves against the VM's local mirror.
-      - uses: actions/checkout@v6
-        with:
-          fetch-depth: 0
-
-      - uses: pnpm/action-setup@v4
-        with:
-          dest: ${{ runner.temp }}/setup-pnpm
-
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Configure persistent pnpm store
-        run: echo "PNPM_CONFIG_STORE_DIR=$HOME/.local/share/pnpm/store" >> "$GITHUB_ENV"
-
-      - name: Install (immutable)
-        run: pnpm install --frozen-lockfile
-
-      # The persistent VM image owns Playwright's Linux system packages; this
-      # step also proves that browser provisioning remains usable for failover.
-      - name: Install Playwright Chromium
-        run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
-
-      - name: Prepare bubblewrap (unrestrict userns)
-        run: bash scripts/prepare-ci-bubblewrap.sh
-
-      - name: Run complete unsharded primary Node CI serially
-        env:
-          DSH_ARCHIVE_BASE_REF: ${{ github.event.before }}
-          DSH_COVERAGE_MAX_WORKERS: '1'
-          DSH_E2E_MAX_WORKERS: '1'
-          DSH_GATE_CONCURRENCY: '1'
-          DSH_OXLINT_THREADS: '1'
-          DSH_PUBLINT_CONCURRENCY: '1'
-          DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
-        run: pnpm run check:ci:linux-primary
-
-  # The one remaining disabled hosted serial reference job; see
-  # TODO(hosted-serial-ci) above.
-  serial-macos:
-    if: false
-    name: serial / macos
-    runs-on: macos-latest
-    steps:
-      - uses: actions/checkout@v6
-
-      - uses: pnpm/action-setup@v4
-        with:
-          dest: ${{ runner.temp }}/setup-pnpm
-
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Install (immutable)
-        run: pnpm install --frozen-lockfile
-
-      - name: Run complete unsharded primary Node CI serially
-        env:
-          DSH_COVERAGE_MAX_WORKERS: '1'
-          DSH_E2E_MAX_WORKERS: '1'
-          DSH_GATE_CONCURRENCY: '1'
-          DSH_PUBLINT_CONCURRENCY: '1'
-          DSH_SNAPSHOT_MAX_CONCURRENCY: '1'
-        run: pnpm run check:ci
-
-  # Hot-standby drill for the in-house self-hosted Windows pool: every master
-  # move re-runs the complete unsharded Windows gate inventory on the persistent
-  # VM, continuously proving that environment can take over the required
-  # `windows` lane if the hosted pool degrades (the switch is setting the
-  # writer-manageable DSH_CI_FAILOVER_WINDOWS variable — see the failover
-  # runbook, no merge required). Push-triggered, so this lane always executes
-  # the base branch's own workflow definition. Non-blocking for pull requests;
-  # absent from all-checks-passed.needs by design — the required `windows` job
-  # owns the PR verdict. No cache steps because the VM's persistent pnpm store
-  # and tool caches make them redundant (and saving here would poison the
-  # hosted cache namespace with self-hosted paths).
-  serial-windows:
-    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
-    name: serial / windows (self-hosted standby)
-    runs-on: [self-hosted, dsh-win-ci, windows]
-    timeout-minutes: 120
-    steps:
-      - uses: actions/checkout@v6
-
-      - name: Enable Developer Mode (symlink support)
-        shell: pwsh
-        run: >-
-          reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
-          /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
-
-      - uses: pnpm/action-setup@v4
-        with:
-          dest: ${{ runner.temp }}/setup-pnpm
-
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-
-      - name: Configure persistent pnpm store
-        shell: pwsh
-        run: |
-          $storeRoot = "$env:LOCALAPPDATA\pnpm\store"
-          echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
-
-      - name: Install (immutable)
-        shell: pwsh
-        run: pnpm install --frozen-lockfile
-
-      - name: Run complete unsharded Windows gate inventory serially
-        shell: pwsh
-        env:
-          DSH_COVERAGE_MAX_WORKERS: '1'
-          DSH_GATE_CONCURRENCY: '1'
-          DSH_PUBLINT_CONCURRENCY: '1'
-        run: pnpm run check:ci:windows-complete
-
-  # Manual, bounded comparison of the actual critical Linux and Windows lanes.
-  # The named pools are restricted at the organization level to this repository.
-  larger-runner-benchmark:
-    if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
-    timeout-minutes: 15
-    strategy:
-      fail-fast: false
-      max-parallel: 12
-      matrix:
-        include:
-          - platform: linux
-            cores: '4'
-            runner: dsh-ubuntu-24-04-4core
-            workload: typecheck
-          - platform: linux
-            cores: '8'
-            runner: dsh-ubuntu-24-04-8core
-            workload: typecheck
-          - platform: linux
-            cores: '16'
-            runner: dsh-ubuntu-24-04-16core
-            workload: typecheck
-          - platform: linux
-            cores: '32'
-            runner: dsh-ubuntu-24-04-32core
-            workload: typecheck
-          - platform: linux
-            cores: '64'
-            runner: dsh-ubuntu-24-04-64core
-            workload: typecheck
-          - platform: linux
-            cores: '96'
-            runner: dsh-ubuntu-24-04-96core
-            workload: typecheck
-          - platform: windows
-            cores: '4'
-            runner: dsh-windows-2025-4core
-            workload: production-site
-          - platform: windows
-            cores: '8'
-            runner: dsh-windows-2025-8core
-            workload: production-site
-          - platform: windows
-            cores: '16'
-            runner: dsh-windows-2025-16core
-            workload: production-site
-          - platform: windows
-            cores: '32'
-            runner: dsh-windows-2025-32core
-            workload: production-site
-          - platform: windows
-            cores: '64'
-            runner: dsh-windows-2025-64core
-            workload: production-site
-          - platform: windows
-            cores: '96'
-            runner: dsh-windows-2025-96core
-            workload: production-site
-    steps:
-      - uses: actions/checkout@v6
-
-      - uses: pnpm/action-setup@v4
-        with:
-          dest: ${{ runner.temp }}/setup-pnpm
-
-      # The benchmark's Windows lanes deliberately skip the store cache like
-      # the independent native Windows job; an empty input disables caching.
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-          cache: ${{ matrix.platform == 'linux' && 'pnpm' || '' }}
-
-      - name: Report runner capacity
-        run: >-
-          node -e "const os = require('node:os');
-          console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
-          memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
-
-      - name: Install (immutable)
-        run: pnpm install --frozen-lockfile
-
-      - name: Run critical Linux typecheck lane
-        if: matrix.platform == 'linux'
-        run: pnpm run typecheck
-
-      - name: Run critical Windows production-site lane
-        if: matrix.platform == 'windows'
-        run: pnpm run docs:build
-
-  # Manual comparison of the intended low-fanout topology. Linux runs the
-  # complete unsharded primary aggregate with bounded in-runner parallelism;
-  # Windows runs both blocking build targets concurrently through run-gates.
-  consolidated-runner-benchmark:
-    if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'
-    runs-on: ${{ matrix.runner }}
-    timeout-minutes: 15
-    strategy:
-      fail-fast: false
-      max-parallel: 12
-      matrix:
-        include:
-          - platform: linux
-            cores: '4'
-            runner: dsh-ubuntu-24-04-4core
-            workers: '4'
-          - platform: linux
-            cores: '8'
-            runner: dsh-ubuntu-24-04-8core
-            workers: '8'
-          - platform: linux
-            cores: '16'
-            runner: dsh-ubuntu-24-04-16core
-            workers: '16'
-          - platform: linux
-            cores: '32'
-            runner: dsh-ubuntu-24-04-32core
-            workers: '32'
-          - platform: linux
-            cores: '64'
-            runner: dsh-ubuntu-24-04-64core
-            workers: '32'
-          - platform: linux
-            cores: '96'
-            runner: dsh-ubuntu-24-04-96core
-            workers: '32'
-          - platform: windows
-            cores: '4'
-            runner: dsh-windows-2025-4core
-            workers: '2'
-          - platform: windows
-            cores: '8'
-            runner: dsh-windows-2025-8core
-            workers: '2'
-          - platform: windows
-            cores: '16'
-            runner: dsh-windows-2025-16core
-            workers: '2'
-          - platform: windows
-            cores: '32'
-            runner: dsh-windows-2025-32core
-            workers: '2'
-          - platform: windows
-            cores: '64'
-            runner: dsh-windows-2025-64core
-            workers: '2'
-          - platform: windows
-            cores: '96'
-            runner: dsh-windows-2025-96core
-            workers: '2'
-    steps:
-      - uses: actions/checkout@v6
-
-      - uses: pnpm/action-setup@v4
-        with:
-          dest: ${{ runner.temp }}/setup-pnpm
-
-      # Unlike the larger-runner suite, both platforms cache the store here:
-      # the consolidated topology measures cache mechanics as workload.
-      - uses: actions/setup-node@v6
-        with:
-          node-version: ${{ env.PRIMARY_NODE_VERSION }}
-          cache: pnpm
-
-      - name: Report runner capacity
-        run: >-
-          node -e "const os = require('node:os');
-          console.log(JSON.stringify({ arch: process.arch, cpus: os.cpus().length,
-          memoryGiB: Math.round(os.totalmem() / 2 ** 30) }))"
-
-      - name: Install and prepare Linux
-        if: matrix.platform == 'linux'
-        run: |
-          pnpm install --frozen-lockfile &
-          install_pid=$!
-          bash scripts/prepare-ci-bubblewrap.sh &
-          sandbox_pid=$!
-          install_status=0
-          wait "$install_pid" || install_status=$?
-          sandbox_status=0
-          wait "$sandbox_pid" || sandbox_status=$?
-          if (( install_status != 0 )); then exit "$install_status"; fi
-          exit "$sandbox_status"
-
-      - name: Install (immutable)
-        if: matrix.platform == 'windows'
-        shell: pwsh
-        run: pnpm install --frozen-lockfile
-
-      - name: Run complete unsharded primary Node CI concurrently
-        if: matrix.platform == 'linux'
-        env:
-          DSH_COVERAGE_MAX_WORKERS: ${{ matrix.workers }}
-          DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
-          DSH_OXLINT_THREADS: ${{ matrix.workers }}
-          DSH_PUBLINT_CONCURRENCY: ${{ matrix.workers }}
-          DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ matrix.workers }}
-        run: pnpm run check:ci
-
-      - name: Run blocking Windows builds concurrently
-        if: matrix.platform == 'windows'
-        shell: pwsh
-        env:
-          DSH_GATE_CONCURRENCY: ${{ matrix.workers }}
-        run: pnpm run check:ci:windows-blocking
-
   # Single stable required check for branch protection: require "all checks
   # passed" instead of enumerating matrix legs whose names change as lanes and
   # node versions evolve. Every blocking job in THIS workflow must be listed in
   # `needs`. The required Wine job is listed as `windows`; `windows-native` is
   # deliberately absent so its independent result never delays or changes this
-  # verdict. (`needs` cannot reach across workflow files; e2e.yml stays its own
-  # check.)
+  # verdict. (`needs` cannot reach across workflow files; the master-only jobs in
+  # ci-master.yml are intentionally not part of this PR verdict.)
   # `if: always()` is load-bearing: without it a failed dependency
   # would SKIP this job, and GitHub counts a skipped required check as passing
   # — so this job always runs and fails on any non-success result, including

+ 2 - 1
.github/workflows/e2e.yml

@@ -107,7 +107,8 @@ jobs:
         run: pnpm run build:official
 
       # Real-API end-to-end tests only. The keyless gates (lint/typecheck/
-      # coverage/snapshot/etc.) already run in ci.yml on every push/PR.
+      # coverage/snapshot/etc.) already run in ci.yml (pull requests) and
+      # ci-master.yml (master push standby).
       # DEEPSEEK_BASE_URL is pinned to the external API; the secret is scoped to
       # this step (and preflight) only — never exposed to checkout/setup/install.
       - name: E2E tests (real DeepSeek API)

+ 5 - 5
apps/web/tests/access-confirmation.e2e.ts

@@ -50,13 +50,13 @@ describe('web e2e: Full access confirmation', () => {
     const access = page.locator('button[aria-label^="访问模式"]').first()
     await access.waitFor({ timeout: 10_000 })
 
-    expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
+    expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区')
 
     await access.click()
-    await page.getByRole('menuitem', { name: 'Full access' }).click()
-    const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+    await page.getByRole('menuitem', { name: '完全权限' }).click()
+    const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
     await dialog.waitFor({ timeout: 10_000 })
-    const enable = dialog.getByRole('button', { name: '启用 Full access' })
+    const enable = dialog.getByRole('button', { name: '启用完全权限' })
     expect(await enable.isDisabled()).toBe(true)
 
     // The modal is in this page's body (not a native/new window) and escapes
@@ -69,7 +69,7 @@ describe('web e2e: Full access confirmation', () => {
     expect(await enable.isEnabled()).toBe(true)
     await enable.click()
     await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
-      .toBe('访问模式,当前:Full access')
+      .toBe('访问模式,当前:完全权限')
     expect(await dialog.count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)

+ 13 - 13
apps/web/tests/settings-chrome.e2e.ts

@@ -62,7 +62,7 @@ describe('web e2e: settings modal and General preferences', () => {
     expect(await trigger.getAttribute('aria-expanded')).toBe('true')
     // General is active by default; Permission, Language and Appearance are functional.
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
-    await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '可写入工作区' }).waitFor({ timeout: 10_000 })
     await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
@@ -135,44 +135,44 @@ describe('web e2e: settings modal and General preferences', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
     const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
     expect(existing.events.find(event => event.type === 'permission/preset')?.data)
-      .toEqual({ preset: 'workspace-write' })
+      .toEqual({ preset: 'workspace-write', origin: 'default' })
 
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
-    const selector = dialog.getByRole('button', { name: 'Workspace Write' })
+    const selector = dialog.getByRole('button', { name: '可写入工作区' })
     await selector.waitFor({ timeout: 10_000 })
     await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
     await selector.click()
-    await page.getByRole('menuitem', { name: 'Read Only' }).click()
-    await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
+    await page.getByRole('menuitem', { name: '仅可查看' }).click()
+    await dialog.getByRole('button', { name: '仅可查看' }).waitFor({ timeout: 10_000 })
 
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('permission:')
     expect(document).toContain('defaultPreset: read-only')
     expect(existing.events.find(event => event.type === 'permission/preset')?.data)
-      .toEqual({ preset: 'workspace-write' })
+      .toEqual({ preset: 'workspace-write', origin: 'default' })
 
     const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
     expect(created.events.map(event => [event.type, event.data])).toEqual([
-      ['permission/preset', { preset: 'read-only' }],
+      ['permission/preset', { preset: 'read-only', origin: 'default' }],
       ['sandbox/mode', { mode: 'read-only' }],
       ['approval/policy', { policy: 'ask' }],
     ])
 
-    await dialog.getByRole('button', { name: 'Read Only' }).click()
-    await page.getByRole('menuitem', { name: 'Full access' }).click()
-    const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
-    const enable = confirmation.getByRole('button', { name: '启用 Full access' })
+    await dialog.getByRole('button', { name: '仅可查看' }).click()
+    await page.getByRole('menuitem', { name: '完全权限' }).click()
+    const confirmation = page.getByRole('dialog', { name: '确认启用完全权限?' })
+    const enable = confirmation.getByRole('button', { name: '启用完全权限' })
     expect(await enable.isDisabled()).toBe(true)
     await confirmation.getByRole('checkbox').click()
     await enable.click()
-    await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '完全权限' }).waitFor({ timeout: 10_000 })
     const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
     const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
     expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
-      ['permission/preset', { preset: 'danger-full-access' }],
+      ['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
       ['sandbox/mode', { mode: 'danger-full-access' }],
       ['approval/policy', { policy: 'never' }],
     ])

+ 4 - 4
apps/web/tests/snapshots/access-confirmation/ui.expected.md

@@ -1,10 +1,10 @@
-- dialog "确认启用 Full access?":
-  - heading "确认启用 Full access?" [level=2]
+- dialog "确认启用完全权限?":
+  - heading "确认启用完全权限?" [level=2]
   - button "Close":
     - img
   - img
-  - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+  - paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
   - checkbox "我已了解风险,并愿意继续"
   - text: 我已了解风险,并愿意继续
   - button "取消"
-  - button "启用 Full access" [disabled]
+  - button "启用完全权限" [disabled]

+ 2 - 2
apps/web/tests/snapshots/settings-chrome/dialog.expected.md

@@ -22,8 +22,8 @@
     - text: 标准模式
     - img
   - text: 权限 选择新会话的默认权限模式
-  - button "Workspace Write":
-    - text: Workspace Write
+  - button "可写入工作区":
+    - text: 可写入工作区
     - img
   - text: 语言
   - button "中文":

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

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

+ 4 - 3
docs/config-catalog.md

@@ -1437,8 +1437,9 @@ export interface Config {
    */
   presets?: Record<string, PresetSpec>
   /**
-   * Default for new sessions. When omitted, the preset matching the composed
-   * sandbox and approval defaults is used.
+   * Default for fresh sessions and eligible confirmed blank reuse. When
+   * omitted, the preset matching the composed sandbox and approval defaults
+   * is used.
    */
   defaultPreset?: string
 }
@@ -1458,7 +1459,7 @@ export interface PresetSpec {
 
 Depends on: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
 
-Source: [`packages/interaction/permission-presets/src/index.ts:140`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 

+ 4 - 3
docs/config-catalog.zh.md

@@ -1439,8 +1439,9 @@ export interface Config {
    */
   presets?: Record<string, PresetSpec>
   /**
-   * Default for new sessions. When omitted, the preset matching the composed
-   * sandbox and approval defaults is used.
+   * Default for fresh sessions and eligible confirmed blank reuse. When
+   * omitted, the preset matching the composed sandbox and approval defaults
+   * is used.
    */
   defaultPreset?: string
 }
@@ -1460,7 +1461,7 @@ export interface PresetSpec {
 
 依赖:[`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md)
 
-来源:[`packages/interaction/permission-presets/src/index.ts:140`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
 
 <a id="deepseek-aidsh-persona"></a>
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: f8eff01aeedb83f6824a605faf86aeec2b0385ab
-module-graph.zh.md: 372deaba770e468327b570fc9d1e022799e5cf69
+module-graph.md: 22532a825ccb65affceca16a23b73169828bf3f1
+module-graph.zh.md: 148e23eeb051c700715f242f37d98a21c9358754

+ 2 - 1
docs/module-graph.md

@@ -1050,6 +1050,7 @@ flowchart TD
   pkg_host_apiproxy --> pkg_agent_presets
   pkg_host_apiproxy --> pkg_cordis_host_runner
   pkg_host_apiproxy --> pkg_invariants
+  pkg_host_apiproxy --> pkg_permission_presets
   pkg_sdk_protocol --> pkg_invariants
   pkg_sdk_protocol --> pkg_llm
   pkg_sdk_protocol --> pkg_session
@@ -1623,7 +1624,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |

+ 2 - 1
docs/module-graph.zh.md

@@ -1052,6 +1052,7 @@ flowchart TD
   pkg_host_apiproxy --> pkg_agent_presets
   pkg_host_apiproxy --> pkg_cordis_host_runner
   pkg_host_apiproxy --> pkg_invariants
+  pkg_host_apiproxy --> pkg_permission_presets
   pkg_sdk_protocol --> pkg_invariants
   pkg_sdk_protocol --> pkg_llm
   pkg_sdk_protocol --> pkg_session
@@ -1625,7 +1626,7 @@ flowchart TD
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
-| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |

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

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

+ 7 - 4
docs/persistence-catalog.md

@@ -506,15 +506,18 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src
 
 ```ts persistence-catalog
 /**
- * Records the selected preset as durable, log-only user intent. The knob
+ * Records the selected preset and whether it came from the session
+ * default, an explicit selection, or legacy-knob inference. The knob
  * events follow in the same turn and control execution; this event stays
  * out of the model transcript and lets {@link effectivePermissionPreset}
- * preserve a selection when bundles match.
+ * preserve a selection when bundles match. `origin` is optional so logs
+ * written before origin tracking remain readable but are never mistaken
+ * for refreshable defaults.
  */
-'permission/preset': { preset: string }
+'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
 ```
 
-Source: [`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 

+ 7 - 4
docs/persistence-catalog.zh.md

@@ -508,15 +508,18 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 ```ts persistence-catalog
 /**
- * Records the selected preset as durable, log-only user intent. The knob
+ * Records the selected preset and whether it came from the session
+ * default, an explicit selection, or legacy-knob inference. The knob
  * events follow in the same turn and control execution; this event stays
  * out of the model transcript and lets {@link effectivePermissionPreset}
- * preserve a selection when bundles match.
+ * preserve a selection when bundles match. `origin` is optional so logs
+ * written before origin tracking remain readable but are never mistaken
+ * for refreshable defaults.
  */
-'permission/preset': { preset: string }
+'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
 ```
 
-来源:[`packages/interaction/permission-presets/src/index.ts:50`](../packages/interaction/permission-presets/src/index.ts)
+来源:[`packages/interaction/permission-presets/src/index.ts:53`](../packages/interaction/permission-presets/src/index.ts)
 
 ### `plan/*`
 

+ 2 - 2
docs/subsystems/permission-presets.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/permission-presets.md
-permission-presets.md: 16ce29a4c3b00fece089ebcdc959e57f419d35c9
-permission-presets.zh.md: d2e5eff6696d5dc6f925032d7eaafb7b19774f4e
+permission-presets.md: 0908e3dd22c16f94c4d09a2cc0d0e5efc487e6b3
+permission-presets.zh.md: 73cde1702a76bbef73b47353e0f312f57ad1d761

+ 15 - 3
docs/subsystems/permission-presets.md

@@ -34,8 +34,9 @@ interface Config {
    */
   presets?: Record<string, PresetSpec>
   /**
-   * Default for new sessions. When omitted, the preset matching the composed
-   * sandbox and approval defaults is used.
+   * Default for fresh sessions and eligible confirmed blank reuse. When
+   * omitted, the preset matching the composed sandbox and approval defaults
+   * is used.
    */
   defaultPreset?: string
 }
@@ -91,6 +92,17 @@ Owns the deployment's permission presets and their write path. Requires a confin
  */
 current(events: readonly SessionEvent[]): string
 
+/**
+ * Advance one blank session after the host has confirmed it as the exact
+ * Web New Session reuse target. Only a still-effective
+ * default-origin selection advances; a started session, an explicit pick,
+ * legacy origin-less data, or independently changed knobs remain pinned.
+ * This is the permission-side half of the Web candidate selection and the
+ * host's blankness, membership, cwd, and archive verification.
+ * @param session - the live session selected for Workspace blank reuse.
+ */
+refreshDefaultForReuse(session: Session): void
+
 /**
  * Build the whole select value for one folded knob state: every table
  * option in declaration order, `custom` appended exactly while derived.
@@ -127,5 +139,5 @@ set(session: Session, name: string): void
 
 Types: [Session](session.md) · [SessionEvent](session.md)
 
-Source: [`packages/interaction/permission-presets/src/index.ts:159`](../../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:172`](../../packages/interaction/permission-presets/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 15 - 3
docs/subsystems/permission-presets.zh.md

@@ -34,8 +34,9 @@ interface Config {
    */
   presets?: Record<string, PresetSpec>
   /**
-   * Default for new sessions. When omitted, the preset matching the composed
-   * sandbox and approval defaults is used.
+   * Default for fresh sessions and eligible confirmed blank reuse. When
+   * omitted, the preset matching the composed sandbox and approval defaults
+   * is used.
    */
   defaultPreset?: string
 }
@@ -91,6 +92,17 @@ Owns the deployment's permission presets and their write path. Requires a confin
  */
 current(events: readonly SessionEvent[]): string
 
+/**
+ * Advance one blank session after the host has confirmed it as the exact
+ * Web New Session reuse target. Only a still-effective
+ * default-origin selection advances; a started session, an explicit pick,
+ * legacy origin-less data, or independently changed knobs remain pinned.
+ * This is the permission-side half of the Web candidate selection and the
+ * host's blankness, membership, cwd, and archive verification.
+ * @param session - the live session selected for Workspace blank reuse.
+ */
+refreshDefaultForReuse(session: Session): void
+
 /**
  * Build the whole select value for one folded knob state: every table
  * option in declaration order, `custom` appended exactly while derived.
@@ -127,5 +139,5 @@ set(session: Session, name: string): void
 
 Types: [Session](session.md) · [SessionEvent](session.md)
 
-Source: [`packages/interaction/permission-presets/src/index.ts:159`](../../packages/interaction/permission-presets/src/index.ts)
+Source: [`packages/interaction/permission-presets/src/index.ts:172`](../../packages/interaction/permission-presets/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 1
examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl

@@ -1,5 +1,5 @@
 {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
-{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}}
+{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access","origin":"default"}}
 {"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}}
 {"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}}
 {"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}

+ 0 - 135
knip.json

@@ -79,24 +79,6 @@
         "@deepseek-ai/.+"
       ]
     },
-    "packages/util/home": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
-    "packages/host/webserver": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/host/directory-picker-auto": {
       "ignoreDependencies": [
         "@deepseek-ai/dsh-client-ui-directory-picker-browse",
@@ -125,24 +107,6 @@
         "tests/**/*.{ts,tsx}"
       ]
     },
-    "packages/client/web-ui": {
-      "entry": [
-        "tests/**/*.spec.{ts,tsx}"
-      ],
-      "project": [
-        "src/**/*.{ts,tsx}",
-        "tests/**/*.{ts,tsx}"
-      ]
-    },
-    "packages/client/runtime": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/api/remotes": {
       "entry": [
         "tests/**/*.e2e.ts"
@@ -247,15 +211,6 @@
         "tests/**/*.ts"
       ]
     },
-    "packages/core/tools": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/typert/generator": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -307,15 +262,6 @@
         "tests/**/*.ts"
       ]
     },
-    "packages/context/tmux-context": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/lsp/lsp-stdio": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -360,24 +306,6 @@
         "src/**/*.ts"
       ]
     },
-    "packages/util/timeout": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
-    "packages/util/output-retention": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/test-support/acp-snapshot": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -418,24 +346,6 @@
         "tests/**/*.ts"
       ]
     },
-    "packages/goal/goal-round-driver": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
-    "packages/goal/tool-goal": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/session-query/session-query-sqlite": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -526,15 +436,6 @@
         "tests/**/*.ts"
       ]
     },
-    "packages/util/home-paths": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/web/web-search-exa": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -673,15 +574,6 @@
         "tests/**/*.ts"
       ]
     },
-    "packages/fs/tool-fs-search": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/mcp/mcp-client": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -713,15 +605,6 @@
         "tests/**/*.tsx"
       ]
     },
-    "packages/client/ui-settings": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "apps/web": {
       "entry": [
         "tests/**/*.e2e.ts",
@@ -757,24 +640,6 @@
         "@deepseek-ai/.+"
       ]
     },
-    "packages/client/modules": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
-    "packages/client/hmr": {
-      "entry": [
-        "tests/**/*.spec.ts"
-      ],
-      "project": [
-        "src/**/*.ts",
-        "tests/**/*.ts"
-      ]
-    },
     "packages/subagent/subagent-dsh-sdk": {
       "entry": [
         "tests/**/*.spec.ts",

+ 8 - 4
packages/client/runtime/src/client/contract/sessions-port.ts

@@ -32,11 +32,15 @@ export interface SessionsPort {
   /** Observable list snapshot (read face only; writes stay inside the sessions domain). */
   readonly list: ObservableSnapshot<SessionsPortList>
   /**
-   * Create a session on the host.
-   * @param opts - target workspace.
-   * @returns the new session id.
+   * Create or explicitly adopt a session on the host.
+   * @param opts - target workspace and optional confirmed blank-reuse id.
+   * @returns the created or adopted session id.
    */
-  create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
+  create(opts: {
+    workspaceId: WorkspaceId
+    sessionId?: SessionId
+    reuseWorkspaceBlank?: true
+  }): Promise<SessionId>
   /**
    * Select a session as current.
    * @param id - session id (must exist in the list store).

+ 10 - 2
packages/client/runtime/src/client/sessions/manager.ts

@@ -534,10 +534,18 @@ export class SessionManager {
    * @returns the create result.
    */
   async create(
-    opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
+    opts: {
+      workspaceId?: WorkspaceId
+      cwd?: string
+      sessionId?: SessionId
+      reuseWorkspaceBlank?: true
+    } = {},
   ): Promise<RpcResult<{ sessionId: SessionId }>> {
     try {
-      const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
+      const shared = {
+        ...(opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }),
+        ...(opts.reuseWorkspaceBlank === undefined ? {} : { reuseWorkspaceBlank: opts.reuseWorkspaceBlank }),
+      }
       const payload = opts.workspaceId !== undefined
         ? { workspaceId: opts.workspaceId, ...shared }
         : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }

+ 6 - 1
packages/client/runtime/src/client/sessions/service.ts

@@ -482,7 +482,12 @@ export class SessionRuntime implements ISessions {
    * @returns the new session id.
    * @throws {SessionCreateError} with the requested id.
    */
-  async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
+  async create(opts: {
+    workspaceId?: WorkspaceId
+    cwd?: string
+    sessionId?: SessionId
+    reuseWorkspaceBlank?: true
+  } = {}): Promise<SessionId> {
     const result = await this.manager.create(opts)
     if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
     this.projectList()

+ 13 - 5
packages/client/runtime/src/client/workspaces/service.ts

@@ -53,7 +53,7 @@ export class WorkspaceRuntime implements IWorkspaces {
   readonly list: SnapshotStore<WorkspaceListState>
   /** Workspace baseline and frame owner. */
   private readonly manager: WorkspaceManager
-  /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
+  /** In-flight blank-session connects keyed by workspace (reuse or create). */
   private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
   /** Guards the runtime-owned one-shot initial-selection subscription. */
   private initialSelectionStarted = false
@@ -76,9 +76,11 @@ export class WorkspaceRuntime implements IWorkspaces {
 
   /**
    * Resolve the session a New Session flow lands in once this Workspace is
-   * chosen: reuse the workspace's existing blank session when one is in the
-   * list mirror, else create a fresh one on the host (`session.create` births
-   * the full Session+Agent — the client holds no intermediate state). The
+   * chosen: explicitly adopt the workspace's existing blank session when one
+   * is in the list mirror, else create a fresh one on the host
+   * (`session.create` births or resumes the full Session+Agent — the client
+   * holds no intermediate state). The adoption tells optional default owners
+   * that this exact session passed the reuse checks.
    * caller owns navigation: take the returned id to `sessions.open`.
    * Resolution guarantee (both arms): the returned id is already in the list
    * store and `sessions.binding(id)` resolves synchronously — draft hand-off
@@ -107,7 +109,13 @@ export class WorkspaceRuntime implements IWorkspaces {
       const summary = sessions.byId[id]
       if (summary !== undefined && summary.blank && summary.cwd === workspace.path
         && workspace.sessionIds.includes(summary.id)
-        && !archived.includes(summary.id)) return summary.id
+        && !archived.includes(summary.id)) {
+        return this.sessions.create({
+          workspaceId,
+          sessionId: summary.id,
+          reuseWorkspaceBlank: true,
+        })
+      }
     }
     const attempt = this.sessions.create({ workspaceId })
       .finally(() => { this.connecting.delete(workspaceId) })

+ 23 - 6
packages/client/runtime/tests/workspaces-service.client.spec.ts

@@ -242,20 +242,28 @@ describe('WorkspaceRuntime', () => {
         { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
       ] as never[],
     }))
+    api.onCreate = payload => Promise.resolve(ok({
+      sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'),
+    }))
     await Promise.all([workspaces.refresh(), sessions.refresh()])
     await Promise.resolve()
 
-    // Hit: same workspace → the parked member blank comes back (the earlier
-    // cwd-matching non-member stray is skipped), no create RPC.
+    // Hit: same workspace → the parked member blank is explicitly adopted
+    // after the earlier cwd-matching non-member stray is skipped.
     await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
-    expect(api.callsOf('session.create')).toEqual([])
+    expect(api.callsOf('session.create')).toEqual([{
+      workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true,
+    }])
     // Resolution guarantee: the id is binding-resolvable synchronously.
     expect(sessions.binding(sid('s-blank'))).toBeDefined()
 
     // Miss: beta has only a non-blank session → host create with workspaceId.
     api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
     await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
-    expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
+    expect(api.callsOf('session.create')).toEqual([
+      { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true },
+      { workspaceId: 'beta' },
+    ])
     // Same guarantee on the create arm (draft hand-off writes the machine pre-open).
     expect(sessions.binding(sid('s-fresh'))).toBeDefined()
 
@@ -263,7 +271,11 @@ describe('WorkspaceRuntime', () => {
     // never reused, a fresh accounted session is created instead.
     api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
     await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
-    expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
+    expect(api.callsOf('session.create')).toEqual([
+      { workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true },
+      { workspaceId: 'beta' },
+      { workspaceId: 'gamma' },
+    ])
 
     // Unknown workspace fails loud instead of silently creating in nowhere.
     await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
@@ -284,6 +296,9 @@ describe('WorkspaceRuntime', () => {
     api.onList = () => Promise.resolve(ok({
       items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
     }))
+    api.onCreate = payload => Promise.resolve(ok({
+      sessionId: (payload as { sessionId?: SessionId }).sessionId ?? sid('s-unexpected'),
+    }))
     await Promise.all([workspaces.refresh(), sessions.refresh()])
     await Promise.resolve()
     const session = sessions.binding(sid('s-blank'))!.session
@@ -292,7 +307,9 @@ describe('WorkspaceRuntime', () => {
     await Promise.resolve()
     // Failure leaves blank intact, so the same session is still the reuse hit.
     await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
-    expect(api.callsOf('session.create')).toEqual([])
+    expect(api.callsOf('session.create')).toEqual([{
+      workspaceId: 'alpha', sessionId: 's-blank', reuseWorkspaceBlank: true,
+    }])
   })
 
   it('returns created Workspaces and preserves Host business errors', async () => {

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: dae46f4398dcbb6dcd50c0d7bdfac13be14d5dc2
-README.zh.md: 0b7af0b42eee060f7c827cdc9e06bc07b7bcc4ef
+README.md: 71dafd1c4d683f2d6da8fefd88aec81d56149481
+README.zh.md: e4d4ab990c74229bd2e077b575e7c87bb648ade9

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -14,7 +14,7 @@ The view ring is a slot: the strict session-body registration declares the sessi
 
 Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints.
 
-Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
+Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-user-questions pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-user-questions) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose built-in preset ids render as localized product labels only when the host keeps their canonical names, while explicit host labels remain unchanged and unknown kebab-case names render in title case. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
 
 The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -24,7 +24,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
 
 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
 
-审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
+审批通过本包声明的链条接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-user-questions 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的会话也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-user-questions)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中内置预设 id 仅在 host 保留规范名称时渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名仍渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
 
 `TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(当前计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` 工具行属于 [`ui-tool`](../ui-tool/README.md)。
 

+ 9 - 3
packages/client/ui-conversation/src/client/locales.ts

@@ -66,11 +66,14 @@ export const zh = {
   'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
   'settings.enter.queue': '排队发送',
   'settings.enter.steer': '插话发送',
-  'access.confirm.title': '确认启用 Full access?',
-  'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
+  'access.preset.readOnly': '仅可查看',
+  'access.preset.workspaceWrite': '可写入工作区',
+  'access.preset.fullAccess': '完全权限',
+  'access.confirm.title': '确认启用完全权限?',
+  'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
   'access.confirm.acknowledge': '我已了解风险,并愿意继续',
   'access.confirm.cancel': '取消',
-  'access.confirm.enable': '启用 Full access',
+  'access.confirm.enable': '启用完全权限',
   'hero.headline': '探索未至之境',
   'hero.preview': '预览版',
   'hero.chooseWorkspace': '选择工作区',
@@ -243,6 +246,9 @@ export const en = {
   'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
   'settings.enter.queue': 'Queue',
   'settings.enter.steer': 'Steer',
+  'access.preset.readOnly': 'Read Only',
+  'access.preset.workspaceWrite': 'Workspace Write',
+  'access.preset.fullAccess': 'Full access',
   'access.confirm.title': 'Enable Full access?',
   'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
   'access.confirm.acknowledge': 'I understand the risks and want to continue',

+ 8 - 3
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -535,10 +535,10 @@ export function InputBar({
     }
     type Boundary =
       | { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] }
-      | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number] }
+      | { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number]; ordinal: number }
     const boundaries: Boundary[] = [
       ...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })),
-      ...deco.textRefs.map(ref => ({ at: ref.start, kind: 'text-ref' as const, ref })),
+      ...deco.textRefs.map((ref, ordinal) => ({ at: ref.start, kind: 'text-ref' as const, ref, ordinal })),
     ].sort((a, b) => a.at - b.at)
     for (const b of boundaries) {
       if (b.at < cursor) continue // claim-token overlap: the leading mark wins
@@ -570,9 +570,14 @@ export function InputBar({
       } else {
         // Plain-range highlight: the glyphs stay the
         // textarea's (advance untouched); the mark paints the chip look.
+        // The key is the draft-order ordinal: a fresh scan derives these
+        // ranges every render, so none of them carries identity past its
+        // position, and a draft-offset key would unmount the mark and its
+        // icon for every character typed ahead of it. Structured references
+        // key by occurrenceId, the identity their occurrence table owns.
         const text = draft.slice(b.ref.start, b.ref.end)
         backdrop.push(
-          <mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
+          <mark key={`ref-${b.ordinal}`} className={css.textRef} data-decoration="text-ref">
             {b.ref.appearance === 'folder'
               ? (
                 <>

+ 35 - 19
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx

@@ -15,14 +15,14 @@ const FULL_ACCESS = 'danger-full-access'
 
 const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
 
-const permissionGlyphs = {
-  'read-only': (
+const permissionGlyphs = new Map<string, ReactNode>([
+  ['read-only', (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
       <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
       <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
     </svg>
-  ),
-  'workspace-write': (
+  )],
+  ['workspace-write', (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
       <path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
       <path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
@@ -30,35 +30,44 @@ const permissionGlyphs = {
       <path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
       <path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
     </svg>
-  ),
-  [FULL_ACCESS]: (
+  )],
+  [FULL_ACCESS, (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
       <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
       <path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
       <path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
     </svg>
-  ),
-} as Record<string, ReactNode>
+  )],
+])
 
 /** Glyph for a permission option value; host-configured names outside the design set get none. */
 function permissionGlyph(value: string): ReactNode | undefined {
-  return permissionGlyphs[value]
+  return permissionGlyphs.get(value)
 }
 
 /**
- * Display transform: kebab-case machine names render as title-case labels
- * (`workspace-write` → `Workspace Write`); non-kebab host-configured names
- * pass through. Full access intentionally overrides the machine-name
- * transform so both permission surfaces use the product label `Full access`;
- * the warning body remains locale-aware.
+ * Display transform: built-in machine names render as locale product labels;
+ * non-kebab host-configured names pass through.
  */
 function displayName(name: string): string {
   if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
   return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
 }
 
-function optionLabel(option: PermissionSelectValue['options'][number]): string {
-  return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
+const BUILT_IN_PERMISSION_NAMES = new Map<string, string>([
+  ['read-only', 'Read Only'],
+  ['workspace-write', 'Workspace Write'],
+  [FULL_ACCESS, 'Full access'],
+])
+
+function permissionLabel(value: string, name: string, t: ComposerBarProps['t']): string {
+  const builtInName = BUILT_IN_PERMISSION_NAMES.get(value)
+  if (builtInName !== undefined && (name === value || name === builtInName)) {
+    if (value === 'read-only') return t('access.preset.readOnly')
+    if (value === 'workspace-write') return t('access.preset.workspaceWrite')
+    if (value === FULL_ACCESS) return t('access.preset.fullAccess')
+  }
+  return displayName(name)
 }
 
 export interface PermissionSelectProps {
@@ -86,13 +95,20 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
 
   const currentValue = pick ?? value.currentValue
   const current = value.options.find(option => option.value === currentValue)
+  const currentLabel = current === undefined
+    ? permissionLabel(currentValue, currentValue, t)
+    : permissionLabel(current.value, current.name, t)
   const busy = pick !== null || confirmation !== null
 
   const items: MenuEntry[] = value.options
     .filter(o => o.value !== 'custom')
     .map((option) => {
       const icon = permissionGlyph(option.value)
-      return { id: option.value, label: optionLabel(option), ...icon === undefined ? {} : { icon } }
+      return {
+        id: option.value,
+        label: permissionLabel(option.value, option.name, t),
+        ...icon === undefined ? {} : { icon },
+      }
     })
 
   const submit = (id: string): void => {
@@ -138,7 +154,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
           <button
             type="button"
             className={css.trigger}
-            aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
+            aria-label={t('input.accessMode', { name: currentLabel })}
             title={current?.description}
             disabled={locked || busy}
             onClick={() => { setOpen(!open) }}
@@ -146,7 +162,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
             {permissionGlyph(currentValue) !== undefined && (
               <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
             )}
-            <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
+            <span className={css.triggerLabel}>{currentLabel}</span>
             {/* Same glyph + open rotation as the sibling ModelSelect trigger. */}
             <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
               <IconChevronDownOutline14 />

+ 54 - 17
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -1265,6 +1265,24 @@ describe('decorations', () => {
     expect(mark?.querySelector('svg')).not.toBeNull()
     expect(shell.snapshot.draft).toBe('see @src/components/')
   })
+
+  it('a plain-text reference keeps its nodes while earlier text shifts its offset', () => {
+    const { view, textarea, shell } = bench()
+    act(() => { shell.setDraft('see @src/components/ here') })
+    const backdrop = view.container.querySelector('[data-input-backdrop]')!
+    const mark = backdrop.querySelector('[data-decoration="text-ref"]')!
+    const icon = mark.querySelector('svg')!
+    act(() => { fireEvent.change(textarea, { target: { value: 'X see @src/components/ here' } }) })
+    // Node identity, not text: an offset-derived key remounts the mark and its
+    // icon on every keystroke landing ahead of the range.
+    expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBe(mark)
+    expect(icon.isConnected).toBe(true)
+    expect(mark.textContent).toBe('@src/components/')
+    // A token edited out of match shape still loses its decoration.
+    act(() => { fireEvent.change(textarea, { target: { value: 'X see X@src/components/ here' } }) })
+    expect(backdrop.querySelector('[data-decoration="text-ref"]')).toBeNull()
+    expect(shell.snapshot.draft).toBe('X see X@src/components/ here')
+  })
 })
 
 describe('insertText (scoped event body)', () => {
@@ -1378,24 +1396,43 @@ describe('command launcher chrome and control seats', () => {
     }
     const { view } = bench({ permissions, command })
     const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
-    // Title-case display is presentation only; the menu ids stay machine names.
-    expect(trigger.textContent).toBe('Read Only')
+    // Product-label display is presentation only; the menu ids stay machine names.
+    expect(trigger.textContent).toBe('仅可查看')
     expect([...trigger.querySelectorAll('svg')]
       .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
     fireEvent.click(trigger)
     const items = view.getAllByRole('menuitem')
-    expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
+    expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
     fireEvent.click(items[1]!)
     // Optimistic pick + disable until admission resolves (command stub resolves true).
     const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
-    expect(busy.textContent).toBe('Workspace Write')
+    expect(busy.textContent).toBe('可写入工作区')
     expect(busy.disabled).toBe(true)
     expect(command).toHaveBeenCalledWith('/permission workspace-write')
     await act(async () => {})
     expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
   })
 
-  it('requires explicit risk acknowledgement before submitting Full access', async () => {
+  it('the Access chip preserves host labels for built-in preset values', () => {
+    const permissions = {
+      options: [
+        { value: 'read-only', name: 'Review Only' },
+        { value: 'workspace-write', name: 'Project Files' },
+        { value: 'danger-full-access', name: 'Operator Mode' },
+        { value: 'custom-mode', name: 'custom-mode' },
+        { value: '__proto__', name: '__proto__' },
+      ],
+      currentValue: 'workspace-write',
+    }
+    const { view } = bench({ permissions })
+    const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
+    expect(trigger.textContent).toBe('Project Files')
+    fireEvent.click(trigger)
+    expect(view.getAllByRole('menuitem').map(item => item.textContent))
+      .toEqual(['Review Only', 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__'])
+  })
+
+  it('requires explicit risk acknowledgement before submitting full access', async () => {
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
       options: [
@@ -1406,11 +1443,11 @@ describe('command launcher chrome and control seats', () => {
     }
     const { view } = bench({ permissions, command })
     fireEvent.click(view.getByLabelText(/^访问模式/))
-    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
 
     expect(command).not.toHaveBeenCalled()
-    expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
-    const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
+    expect(view.getByRole('dialog', { name: '确认启用完全权限?' })).toBeTruthy()
+    const enable = view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement
     expect(enable.disabled).toBe(true)
 
     fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
@@ -1420,11 +1457,11 @@ describe('command launcher chrome and control seats', () => {
     expect(command).toHaveBeenCalledOnce()
     expect(command).toHaveBeenCalledWith('/permission danger-full-access')
     expect(view.queryByRole('dialog')).toBeNull()
-    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
+    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('完全权限')
     await act(async () => {})
   })
 
-  it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
+  it('cancels a full access selection without changing permission and resets acknowledgement', () => {
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
       options: [
@@ -1436,21 +1473,21 @@ describe('command launcher chrome and control seats', () => {
     const { view } = bench({ permissions, command })
     const openConfirmation = () => {
       fireEvent.click(view.getByLabelText(/^访问模式/))
-      fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+      fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
     }
 
     openConfirmation()
     fireEvent.click(view.getByRole('checkbox'))
     fireEvent.click(view.getByRole('button', { name: '取消' }))
     expect(command).not.toHaveBeenCalled()
-    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
+    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区')
 
     openConfirmation()
     expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
-    expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement).disabled).toBe(true)
   })
 
-  it('revokes an open Full access confirmation when the task locks', () => {
+  it('revokes an open full access confirmation when the task locks', () => {
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
       options: [
@@ -1461,14 +1498,14 @@ describe('command launcher chrome and control seats', () => {
     }
     const { view, session } = bench({ permissions, command })
     fireEvent.click(view.getByLabelText(/^访问模式/))
-    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
     fireEvent.click(view.getByRole('checkbox'))
     act(() => { session.set(snapshotOf({ removed: true })) })
     expect(view.queryByRole('dialog')).toBeNull()
     expect(command).not.toHaveBeenCalled()
   })
 
-  it('resets an open Full access confirmation when switching tasks', () => {
+  it('resets an open full access confirmation when switching tasks', () => {
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
       options: [
@@ -1479,7 +1516,7 @@ describe('command launcher chrome and control seats', () => {
     }
     const { view, props } = bench({ permissions, command })
     fireEvent.click(view.getByLabelText(/^访问模式/))
-    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
     fireEvent.click(view.getByRole('checkbox'))
     view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
     expect(view.queryByRole('dialog')).toBeNull()

+ 2 - 2
packages/client/ui-permission-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
-README.md: 20529e6ce8a187254774fc85cfd981e4a9115937
-README.zh.md: afe7ff36e52511a182a783153879125d48cccca5
+README.md: 2d2f8243f878ef6adc5d969494bdc8fb5abf7ed1
+README.zh.md: 05d555d1dd30811c62b4e8ee2daa7e9242f99512

+ 3 - 2
packages/client/ui-permission-presets/README.md

@@ -2,9 +2,9 @@
 
 English | [中文](README.zh.md)
 
-Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. This value applies only when a later session is created; changing it does not switch the current session. Choosing Full access requires an explicit risk acknowledgement before the row writes it.
+Permission browser surfaces for two different lifetimes. The General-settings row reads the explicitly exposed `permission` Settings descriptor, derives its options from the host's dynamic `defaultPreset` enum, and writes one `settings.mutate` path operation with the descriptor revision. Its observable rides the slot system's `hooks` compartment, so the renderer owns React hook binding; a push invalidation refetches the descriptor. The value applies to fresh sessions; when Web later confirms an existing Workspace blank as the New Session reuse target, the host refreshes a still-default-derived permission after adopting that exact live or cold session. Choosing the full-access preset requires an explicit risk acknowledgement before the row writes it.
 
-The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
+The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.commandUi.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active, canonical built-in names rendered as localized product labels, explicit host labels preserved, and unknown kebab-case preset names rendered in title case. A pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
 
 The `/client` exports are the plugin body (`apply`/`inject`).
 
@@ -19,3 +19,4 @@ No direct invalidation; the knob consumers own any request-prefix changes.
 ## Known Limitations and Deferred Work
 
 - **The Settings row is Web-only** — non-Web clients may still switch the current session through `/permission`, but do not receive this browser contribution.
+- **Preset descriptions come from the host** — localized built-in labels may therefore appear beside a description written in another language.

+ 3 - 2
packages/client/ui-permission-presets/README.zh.md

@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值仅在后续会话创建时生效;改变它不会切换当前会话。选择 Full access 时必须先显式确认风险,该行随后才会写入。
+面向两种不同生命周期的浏览器权限界面。「通用」设置行读取显式暴露的 `permission` Settings 描述符,从 host 的动态 `defaultPreset` enum 中推导选项,并携带描述符的 revision 写入一条 `settings.mutate` 路径操作。它的 observable 经 slot 系统的 `hooks` 格传递,因此 React 钩子由渲染器绑定;推送的失效通知会重新获取描述符。这个值作用于新建会话;Web 之后把某个既有 Workspace 空白会话确认为新会话复用目标时,host 会接纳这个确切的 live 或冷会话,并刷新仍来自默认值的权限。选择完全权限预设时必须先显式确认风险,该行随后才会写入。
 
-当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,kebab-case 预设名渲染为 Title Case 标签(`workspace-write` → `Workspace Write`,与 composer chip 的显示变换孪生),选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
+当前会话界面仍是挂在 host `/permission` 命令上的 popupSelect **装饰**(`ctx.commandUi.decorate`)。装饰不是第二条命令——host 命令保留斜杠菜单行、带参路径(`/permission <preset>` 直接切换)与持久生命周期记账;装饰只把裸调用替换为选择框:一张扁平预设列表,当前值标记为 active,内置预设的规范名称渲染为本地化产品标签,显式 host 标签保持原样,未知 kebab-case 预设名仍渲染为 Title Case 标签。选中即提交 `/permission <preset>` 命令行。选项与 active 标记读取会话的 `permissions` 投影(与 composer chip 渲染的同一份 host 计算 select),因此两个当前会话界面共享同一读源与同一写路径,推送的投影帧是两者共同跟随的唯一确认。装饰恰在投影 key 存在时可用;无权限组合既不显示选择框,也不显示 Settings 行。
 
 `/client` 导出面为插件本体(`apply`/`inject`)。
 
@@ -19,3 +19,4 @@
 ## 已知限制与暂缓事项
 
 - **Settings 行仅在 Web 中可用**:非 Web 客户端仍可通过 `/permission` 切换当前会话,但不会获得这项浏览器贡献。
+- **预设描述来自 host**:本地化的内置标签旁边可能显示另一种语言编写的描述。

+ 5 - 4
packages/client/ui-permission-presets/src/client/PermissionRow.tsx

@@ -12,7 +12,7 @@ import {
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PermissionSettingsState } from './settings-store.ts'
 import type { PermissionSettingsKey } from './locales.ts'
-import { FULL_ACCESS_PRESET } from './presentation.ts'
+import { displayPermissionPreset, FULL_ACCESS_PRESET } from './presentation.ts'
 import css from './PermissionRow.module.css'
 
 /** Registration-side business face for the host-backed preference. */
@@ -58,8 +58,9 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
   if (state.status === 'unavailable') return null
   const selected = state.options.find(option => option.id === state.currentValue)
   const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
-  const label = selected?.label
-    ?? (busy ? t('loading') : t('unavailable'))
+  const optionLabel = (option: PermissionSettingsState['options'][number]): string =>
+    displayPermissionPreset(option.id, option.label, t)
+  const label = selected !== undefined ? optionLabel(selected) : (busy ? t('loading') : t('unavailable'))
   const description: string = state.error ?? t('description')
 
   return (
@@ -72,7 +73,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
         <Menu
           open={open}
           onClose={() => { setOpen(false) }}
-          items={state.options.map(option => ({ id: option.id, label: option.label }))}
+          items={state.options.map(option => ({ id: option.id, label: optionLabel(option) }))}
           selectedId={state.currentValue}
           onSelect={(id) => {
             setOpen(false)

+ 9 - 3
packages/client/ui-permission-presets/src/client/index.ts

@@ -10,8 +10,8 @@
  * write through one path and the pushed projection frame is the one
  * confirmation. The Full access row carries the same explicit risk gate as
  * the composer chip; the shared popup shell owns the modal mechanics.
- * The General-settings row separately writes the default preset for sessions
- * created later through the host Settings API.
+ * The General-settings row separately writes the default preset for fresh
+ * sessions and eligible confirmed blank reuse through the host Settings API.
  */
 import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
@@ -56,7 +56,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO
     .filter(option => option.value !== 'custom')
     .map(option => ({
       id: option.value,
-      label: displayPermissionPreset(option.value, option.name),
+      label: displayPermissionPreset(option.value, option.name, t),
       ...(option.description !== undefined ? { detail: option.description } : {}),
       ...(option.value === value.currentValue ? { active: true } : {}),
       ...(option.value === FULL_ACCESS_PRESET
@@ -87,6 +87,9 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(() => {
     const disposers = [
       ctx.locale.register(ACCESS_NS, 'zh', {
+        'preset.readOnly': accessZh['preset.readOnly'],
+        'preset.workspaceWrite': accessZh['preset.workspaceWrite'],
+        'preset.fullAccess': accessZh['preset.fullAccess'],
         'confirm.title': accessZh['confirm.title'],
         'confirm.description': accessZh['confirm.description'],
         'confirm.acknowledge': accessZh['confirm.acknowledge'],
@@ -94,6 +97,9 @@ export function apply(ctx: ClientContext): void {
         'confirm.enable': accessZh['confirm.enable'],
       }),
       ctx.locale.register(ACCESS_NS, 'en', {
+        'preset.readOnly': accessEn['preset.readOnly'],
+        'preset.workspaceWrite': accessEn['preset.workspaceWrite'],
+        'preset.fullAccess': accessEn['preset.fullAccess'],
         'confirm.title': accessEn['confirm.title'],
         'confirm.description': accessEn['confirm.description'],
         'confirm.acknowledge': accessEn['confirm.acknowledge'],

+ 18 - 6
packages/client/ui-permission-presets/src/client/locales.ts

@@ -6,11 +6,14 @@ export const zh = {
   'description': '选择新会话的默认权限模式',
   'loading': '加载中',
   'unavailable': '不可用',
-  'confirm.title': '确认启用 Full access?',
-  'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
+  'preset.readOnly': '仅可查看',
+  'preset.workspaceWrite': '可写入工作区',
+  'preset.fullAccess': '完全权限',
+  'confirm.title': '确认启用完全权限?',
+  'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
   'confirm.acknowledge': '我已了解风险,并愿意继续',
   'confirm.cancel': '取消',
-  'confirm.enable': '启用 Full access',
+  'confirm.enable': '启用完全权限',
 } satisfies Record<string, string>
 
 /** The settings.permission namespace key union. */
@@ -22,6 +25,9 @@ export const en = {
   'description': 'Choose the default permission mode for new sessions',
   'loading': 'Loading',
   'unavailable': 'Unavailable',
+  'preset.readOnly': 'Read Only',
+  'preset.workspaceWrite': 'Workspace Write',
+  'preset.fullAccess': 'Full access',
   'confirm.title': 'Enable Full access?',
   'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
   'confirm.acknowledge': 'I understand the risks and want to continue',
@@ -31,11 +37,14 @@ export const en = {
 
 /** Simplified Chinese dictionary for the current-session popup gate. */
 export const accessZh = {
-  'confirm.title': '确认启用 Full access?',
-  'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
+  'preset.readOnly': '仅可查看',
+  'preset.workspaceWrite': '可写入工作区',
+  'preset.fullAccess': '完全权限',
+  'confirm.title': '确认启用完全权限?',
+  'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
   'confirm.acknowledge': '我已了解风险,并愿意继续',
   'confirm.cancel': '取消',
-  'confirm.enable': '启用 Full access',
+  'confirm.enable': '启用完全权限',
 } satisfies Record<string, string>
 
 /** Current-session popup-gate key union. */
@@ -43,6 +52,9 @@ export type PermissionAccessKey = keyof typeof accessZh
 
 /** English dictionary for the current-session popup gate. */
 export const accessEn = {
+  'preset.readOnly': 'Read Only',
+  'preset.workspaceWrite': 'Workspace Write',
+  'preset.fullAccess': 'Full access',
   'confirm.title': 'Enable Full access?',
   'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
   'confirm.acknowledge': 'I understand the risks and want to continue',

+ 30 - 3
packages/client/ui-permission-presets/src/client/presentation.ts

@@ -1,6 +1,24 @@
 /** Machine value of the preset that requires an explicit GUI risk gate. */
 export const FULL_ACCESS_PRESET = 'danger-full-access'
 
+/** Locale dictionary key for a built-in permission preset label. */
+export type PermissionPresetLabelKey =
+  | 'preset.readOnly'
+  | 'preset.workspaceWrite'
+  | 'preset.fullAccess'
+
+const PRESET_LABEL_KEYS = new Map<string, PermissionPresetLabelKey>([
+  ['read-only', 'preset.readOnly'],
+  ['workspace-write', 'preset.workspaceWrite'],
+  [FULL_ACCESS_PRESET, 'preset.fullAccess'],
+])
+
+const DEFAULT_PRESET_LABELS: Record<PermissionPresetLabelKey, string> = {
+  'preset.readOnly': 'Read Only',
+  'preset.workspaceWrite': 'Workspace Write',
+  'preset.fullAccess': 'Full access',
+}
+
 /**
  * Convert conventional kebab-case preset names into user-facing title case.
  * @param name - host-supplied preset label or key.
@@ -15,8 +33,17 @@ export function displayPresetName(name: string): string {
  * Render a permission preset under its product label.
  * @param value - preset machine value.
  * @param name - host-supplied preset name.
- * @returns the Full access product label or the conventional display name.
+ * @param t - optional locale dictionary lookup for built-in product labels.
+ * @returns the built-in product label or the conventional display name.
  */
-export function displayPermissionPreset(value: string, name: string): string {
-  return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
+export function displayPermissionPreset(
+  value: string,
+  name: string,
+  t?: (key: PermissionPresetLabelKey) => string,
+): string {
+  const key = PRESET_LABEL_KEYS.get(value)
+  if (key !== undefined && (name === value || name === DEFAULT_PRESET_LABELS[key])) {
+    return t?.(key) ?? DEFAULT_PRESET_LABELS[key]
+  }
+  return displayPresetName(name)
 }

+ 3 - 2
packages/client/ui-permission-presets/src/client/settings-store.ts

@@ -20,7 +20,7 @@ import { displayPermissionPreset } from './presentation.ts'
 /** Permission's settings namespace on the host wire. */
 export const PERMISSION_SETTINGS_NS = 'permission'
 
-/** One selectable new-session default. */
+/** One selectable fresh-session and confirmed-reuse default. */
 export interface PermissionDefaultOption {
   /** Preset key written to Settings. */
   id: string
@@ -121,7 +121,8 @@ export class PermissionPresetSettingsController {
   }
 
   /**
-   * Persist one preset as the default for subsequently created sessions.
+   * Persist one preset as the default for fresh sessions and eligible
+   * confirmed blank reuse.
    * A selection made while one is already saving is ignored — the row's
    * control is disabled during the save, so this only drops programmatic
    * double-submits rather than user intent.

+ 11 - 3
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -128,7 +128,7 @@ describe('ui-permission browser plugin', () => {
     const again = await c.ui.options(proj, new AbortController().signal)
     expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
-    // Kebab-case names title-case; non-kebab host-configured names pass through.
+    // Built-ins use product labels; other kebab-case names title-case.
     expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
     expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
       title: 'Enable Full access?',
@@ -137,9 +137,17 @@ describe('ui-permission browser plugin', () => {
       cancelLabel: 'Cancel',
       confirmLabel: 'Enable Full access',
     })
-    b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
+    b.values.set(sid('s1'), { ...SELECT, options: [
+      { value: 'workspace-write', name: 'Project Files' },
+      { value: 'danger-full-access', name: 'Operator Mode' },
+      { value: 'custom-mode', name: 'custom-mode' },
+      { value: '__proto__', name: '__proto__' },
+      { value: 'plain', name: 'Ask Every Time' },
+    ] })
     const passthrough = await c.ui.options(proj, new AbortController().signal)
-    expect(passthrough[0]?.label).toBe('Ask Every Time')
+    expect(passthrough.map(option => option.label)).toEqual([
+      'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
+    ])
     // A projection that vanished between availability and open throws.
     expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
       .toThrow(/not available on this host/)

+ 20 - 20
packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx

@@ -5,9 +5,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
 import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
-import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
-import { en } from '../src/client/locales.ts'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
+import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
+import { zh } from '../src/client/locales.ts'
 import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
 
 const schema = new SettingsSchemaService(new Context())
@@ -47,7 +47,7 @@ function ok<T>(value: T) {
   return { rpcId: 'test', result: { ok: true as const, value } }
 }
 
-const dictionary: Record<string, string> = en
+const dictionary: Record<string, string> = zh
 const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
 const runtime = {
   useSessions: (() => { throw new Error('unused') }) as never,
@@ -76,7 +76,7 @@ describe('PermissionRow', () => {
       },
     })
     mount(controller)
-    const button = await screen.findByRole('button', { name: 'Read Only' })
+    const button = await screen.findByRole('button', { name: '仅可查看' })
     expect(button.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(button)
     expect(button.getAttribute('aria-expanded')).toBe('true')
@@ -86,15 +86,15 @@ describe('PermissionRow', () => {
     fireEvent.click(button)
     expect(button.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' }))
     expect(mutate).not.toHaveBeenCalled()
     fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
-    await screen.findByRole('button', { name: 'Workspace Write' })
+    fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
+    await screen.findByRole('button', { name: '可写入工作区' })
     expect(mutate).toHaveBeenCalledOnce()
   })
 
-  it('requires explicit acknowledgement before saving Full access', async () => {
+  it('requires explicit acknowledgement before saving full access', async () => {
     const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
     const controller = derivedController({
       settings: {
@@ -103,15 +103,15 @@ describe('PermissionRow', () => {
       },
     })
     mount(controller)
-    fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(await screen.findByRole('button', { name: '仅可查看' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
     expect(mutate).not.toHaveBeenCalled()
-    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
-    expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Read Only' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
-    const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
-    const enable = screen.getByRole('button', { name: 'Enable Full access' })
+    fireEvent.click(screen.getByRole('button', { name: '取消' }))
+    expect(screen.queryByRole('dialog', { name: '确认启用完全权限?' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: '仅可查看' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
+    const dialog = screen.getByRole('dialog', { name: '确认启用完全权限?' })
+    const enable = screen.getByRole('button', { name: '启用完全权限' })
     expect((enable as HTMLButtonElement).disabled).toBe(true)
     fireEvent.click(screen.getByRole('checkbox'))
     fireEvent.click(enable)
@@ -137,7 +137,7 @@ describe('PermissionRow', () => {
       },
     })
     mount(readonly)
-    expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
+    expect((await screen.findByRole('button', { name: '仅可查看' })).hasAttribute('disabled')).toBe(true)
   })
 
   it('shows loading and a contained write error', async () => {
@@ -158,11 +158,11 @@ describe('PermissionRow', () => {
       },
     })
     mount(controller)
-    expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
+    expect((await screen.findByRole('button', { name: '加载中' })).hasAttribute('disabled')).toBe(true)
     describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
-    const button = await screen.findByRole('button', { name: 'Read Only' })
+    const button = await screen.findByRole('button', { name: '仅可查看' })
     fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
     expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
   })
 })

+ 1 - 1
packages/client/ui-permission-presets/tests/settings-store.client.spec.ts

@@ -47,7 +47,7 @@ function permissionController(api: object) {
 }
 
 describe('permission settings store', () => {
-  it('derives dynamic options and host labels from the descriptor schema', () => {
+  it('derives dynamic options and preserves labels from the descriptor schema', () => {
     expect(resolveDefault(view('read-only'))).toEqual({
       currentValue: 'read-only',
       options: [

+ 5 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -1015,6 +1015,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'events', description: 'the session\'s events in log order.' }],
         returns: 'the effective preset name, or `custom` when nothing matches.',
       },
+      {
+        signature: 'refreshDefaultForReuse(session: Session): void',
+        description: 'Advance one blank session after the host has confirmed it as the exact Web New Session reuse target. Only a still-effective default-origin selection advances; a started session, an explicit pick, legacy origin-less data, or independently changed knobs remain pinned. This is the permission-side half of the Web candidate selection and the host\'s blankness, membership, cwd, and archive verification.',
+        parameters: [{ name: 'session', description: 'the live session selected for Workspace blank reuse.' }],
+      },
       {
         signature: 'selectFor(state: KnobState): PermissionSelect',
         description: 'Build the whole select value for one folded knob state: every table option in declaration order, `custom` appended exactly while derived.',

+ 3 - 1
packages/host/apiproxy/package.json

@@ -78,13 +78,15 @@
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^"
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-permission-presets": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent-presets": "workspace:^",
     "@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-permission-presets": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",

+ 16 - 4
packages/host/apiproxy/src/api-proxy.ts

@@ -19,6 +19,9 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
 import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session'
 import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+// Type-only: resolves the optional permission-default owner notified after
+// the Web proposes and the Host verifies a Workspace blank reuse target.
+import type {} from '@deepseek-ai/dsh-permission-presets'
 import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
 import { SubagentError } from '@deepseek-ai/dsh-subagent'
 import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
@@ -447,7 +450,9 @@ function jobViews(snapshots: readonly JobSnapshot[]): JobView[] {
  * turn is one model-loop execution). Standalone plugin events — command
  * lifecycle records, plan/mode, titles, goals — never open a turn, so
  * running `/plan` or `/goal` on a fresh session keeps it blank
- * (list-hidden, reusable).
+ * (list-hidden, reusable). `session.create` combines this predicate with the
+ * Workspace membership and archive state before a confirmed reuse can notify
+ * permission-default owners; they do not maintain a second blankness rule.
  */
 function sessionBlank(session: Session): boolean {
   return !session.events.some(event => event.type === 'turn/start')
@@ -2095,8 +2100,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         }
         const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
         const requestedPreset = request.payload.agentPreset
+        const refreshDefaultAfterReuse = request.payload.reuseWorkspaceBlank === true
+          && workspace !== undefined
+          && workspace.sessionIds.includes(sessionId)
+          && !ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)
+        let adopted: Agent
         try {
-          await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
+          adopted = await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
         } catch (error: unknown) {
           if (error instanceof AgentPresetConflict) {
             return err(request, {
@@ -2142,6 +2152,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             })
           }
         }
+        if (refreshDefaultAfterReuse && sessionBlank(adopted.session)) {
+          ctx.get('permissionPresets')?.refreshDefaultForReuse(adopted.session)
+        }
         // Echo the composition the session RUNS so a client can label it
         // without waiting for the next list refresh — the create is the commit
         // point that knows it (a caller that named none gets the default).
@@ -2150,8 +2163,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         // switched while blank runs a preset its header no longer names, so
         // echoing the header would contradict both the adoption this call just
         // allowed and the row `session.list` serves for the same session.
-        const created = ctx.agents.get(sessionId)
-        const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
+        const createdPreset = resolveSessionPreset(adopted.session)
         return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
       },
 

+ 5 - 0
packages/host/apiproxy/src/api/sessions.schema.ts

@@ -104,9 +104,14 @@ export const sessionCreateRequestSchema = z.object({
   cwd: z.string().optional(),
   sessionId: sessionIdSchema.optional(),
   agentPreset: z.string().optional(),
+  reuseWorkspaceBlank: z.literal(true).optional(),
 }).refine(
   payload => payload.workspaceId === undefined || payload.cwd === undefined,
   { message: 'session.create accepts workspaceId or cwd, not both' },
+).refine(
+  payload => payload.reuseWorkspaceBlank !== true
+    || (payload.workspaceId !== undefined && payload.sessionId !== undefined),
+  { message: 'session.create reuseWorkspaceBlank requires workspaceId and sessionId' },
 ) satisfies z.ZodType<Wire<RequestPayload<'session.create'>>>
 
 /** session.create response value. */

+ 12 - 1
packages/host/apiproxy/src/api/sessions.ts

@@ -250,6 +250,11 @@ export interface SessionsApi {
    * session, while a different cwd fails with `session-conflict`. Workspace
    * creation attaches the session after publication; an attach failure
    * returns `workspace-attach-failed` with the published session id.
+   * `reuseWorkspaceBlank: true` is valid only with both `workspaceId` and an
+   * existing `sessionId`; it reports the Web workspace runtime's New Session
+   * reuse candidate. The host notifies optional session-default owners only
+   * while that session is still blank, belongs to the Workspace, matches its
+   * cwd, and is not archived.
    *
    * `agentPreset` names the composition the new session's agent is built
    * from; omitted, the effective default applies — the user's stored choice
@@ -258,7 +263,13 @@ export interface SessionsApi {
    * id fails with `agent-preset-not-found`, and a preset whose composition
    * cannot be mounted fails with `agent-preset-invalid`.
    */
-  create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
+  create(request: RpcRequest<{
+    workspaceId?: WorkspaceId
+    cwd?: string
+    sessionId?: SessionId
+    agentPreset?: string
+    reuseWorkspaceBlank?: true
+  }>):
   Promise<RpcResponse<{ sessionId: SessionId; agentPreset?: string }>>
 
   /**

+ 57 - 1
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import SessionStore from '@deepseek-ai/dsh-session'
+import SessionStore, { Session } from '@deepseek-ai/dsh-session'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
 import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
@@ -214,6 +214,62 @@ describe('sessions.list cold merge', () => {
   })
 })
 
+describe('session.create cold blank reuse', () => {
+  it('resumes the persisted target before notifying the permission-default owner', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    await ctx.plugin(UserQuestionService)
+    const sessionId = sid('cold-workspace-blank')
+    const meta = header(sessionId, 1000)
+    const events = [
+      { type: 'permission/preset', seq: 0, time: 1, data: { preset: 'workspace-write', origin: 'default' } },
+      { type: 'sandbox/mode', seq: 1, time: 2, data: { mode: 'workspace-write' } },
+      { type: 'approval/policy', seq: 2, time: 3, data: { policy: 'ask' } },
+    ] as SessionEvent[]
+    ctx.provide('sessionPersistence', {
+      list: () => Promise.resolve([meta]),
+      inspect: () => Promise.resolve({ meta, events }),
+      locate: () => undefined,
+    } as never)
+    const resumedSession = Session.create(sessionId, events, meta)
+    const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent
+    const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
+      agent: resumedAgent,
+      dispose: () => Promise.resolve(),
+    })
+    const attachSession = vi.fn(() => Promise.resolve())
+    const workspace = {
+      id: 'workspace-1',
+      path: '/proj',
+      sessionIds: [sessionId],
+      attachSession,
+    }
+    ctx.provide('workspaceRegistry', {
+      get: () => workspace,
+      list: () => [workspace],
+      archivedSessionIds: [],
+    } as never)
+    const refreshDefaultForReuse = vi.fn()
+    ctx.provide('permissionPresets', { refreshDefaultForReuse } as never)
+    const api = createApiProxy(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/tmp',
+    })
+
+    const response = await api.sessions.create(request({
+      workspaceId: 'workspace-1' as never,
+      sessionId,
+      reuseWorkspaceBlank: true as const,
+    }))
+
+    expect(response.result.ok).toBe(true)
+    expect(resume).toHaveBeenCalledOnce()
+    expect(attachSession).toHaveBeenCalledWith(sessionId)
+    expect(refreshDefaultForReuse).toHaveBeenCalledWith(resumedSession)
+  })
+})
+
 describe('attached updatedAt tracks human prompts', () => {
   it('ignores pickup and non-prompt work after the latest human message', async () => {
     const ctx = new Context()

+ 63 - 0
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts

@@ -64,6 +64,7 @@ async function harness(
   extras: {
     openPath?: (path: string, signal: AbortSignal) => Promise<void>
     canOpenPath?: () => boolean
+    refreshDefaultForReuse?: (session: Session) => void
   } = {},
 ) {
   const ctx = new Context()
@@ -102,6 +103,11 @@ async function harness(
   // Structural picker fake: the gateway only reads capability(); a stable
   // object per harness mirrors the seam's stability contract.
   ctx.provide('directoryPicker', { capability: () => picker } as never)
+  if (extras.refreshDefaultForReuse !== undefined) {
+    ctx.provide('permissionPresets', {
+      refreshDefaultForReuse: extras.refreshDefaultForReuse,
+    } as never)
+  }
   const api = createApiProxy(ctx, {
     defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
     cwd: root,
@@ -365,6 +371,63 @@ describe('workspace.insertBefore', () => {
 })
 
 describe('session creation and Workspace membership', () => {
+  it('notifies the permission owner only while the confirmed reuse target remains eligible', async () => {
+    const refreshDefaultForReuse = vi.fn<(session: Session) => void>()
+    const { api, ctx, root } = await harness(undefined, undefined, { refreshDefaultForReuse })
+    const workspace = expectOk(await api.workspace.create(request({
+      path: stageDir(root, 'permission-refresh'),
+    }))).workspace
+    const reusedId = SessionId('session-reused-blank')
+    expectOk(await api.sessions.create(request({
+      workspaceId: workspace.workspaceId,
+      sessionId: reusedId,
+    })))
+    expect(refreshDefaultForReuse).not.toHaveBeenCalled()
+
+    expectOk(await api.sessions.create(request({
+      workspaceId: workspace.workspaceId,
+      sessionId: reusedId,
+      reuseWorkspaceBlank: true,
+    })))
+    expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
+    expect(refreshDefaultForReuse.mock.calls[0]?.[0].id).toBe(reusedId)
+
+    const reused = ctx.sessions.get(reusedId)
+    if (reused === undefined) throw new Error('reused session was not published')
+    reused.append('turn/start', { turn: 1 })
+    expectOk(await api.sessions.create(request({
+      workspaceId: workspace.workspaceId,
+      sessionId: reusedId,
+      reuseWorkspaceBlank: true,
+    })))
+    expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
+
+    const archivedId = SessionId('session-archived-blank')
+    expectOk(await api.sessions.create(request({
+      workspaceId: workspace.workspaceId,
+      sessionId: archivedId,
+    })))
+    expectOk(await api.workspace.archiveSession(request({ sessionId: archivedId })))
+    expectOk(await api.sessions.create(request({
+      workspaceId: workspace.workspaceId,
+      sessionId: archivedId,
+      reuseWorkspaceBlank: true,
+    })))
+    expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
+
+    const nonMemberId = SessionId('session-non-member-blank')
+    expectOk(await api.sessions.create(request({
+      cwd: workspace.path,
+      sessionId: nonMemberId,
+    })))
+    expectOk(await api.sessions.create(request({
+      workspaceId: workspace.workspaceId,
+      sessionId: nonMemberId,
+      reuseWorkspaceBlank: true,
+    })))
+    expect(refreshDefaultForReuse).toHaveBeenCalledOnce()
+  })
+
   it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
     const { api, ctx, root } = await harness()
     const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace

+ 9 - 1
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -193,8 +193,16 @@ describe('sessions domain schemas', () => {
     })).toThrow()
     expect(sessionCreateRequestSchema.parse({ cwd: '/w' }).cwd).toBe('/w')
     // The refine's both-sides branch: workspaceId alone passes, workspaceId+cwd rejects.
-    expect(sessionCreateRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).sessionId).toBe('s1')
+    expect(sessionCreateRequestSchema.parse({
+      workspaceId: 'w1', sessionId: 's1', reuseWorkspaceBlank: true,
+    }).reuseWorkspaceBlank).toBe(true)
     expect(() => sessionCreateRequestSchema.parse({ workspaceId: 'w1', cwd: '/w' })).toThrow(/not both/)
+    expect(() => sessionCreateRequestSchema.parse({
+      workspaceId: 'w1', reuseWorkspaceBlank: true,
+    })).toThrow(/requires workspaceId and sessionId/)
+    expect(() => sessionCreateRequestSchema.parse({
+      sessionId: 's1', reuseWorkspaceBlank: true,
+    })).toThrow(/requires workspaceId and sessionId/)
     expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
     expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
     expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()

+ 3 - 0
packages/host/apiproxy/tsconfig.json

@@ -80,6 +80,9 @@
     {
       "path": "../../interaction/commands"
     },
+    {
+      "path": "../../interaction/permission-presets"
+    },
     {
       "path": "../../interaction/user-approval"
     },

+ 2 - 2
packages/interaction/permission-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/interaction/permission-presets/README.md
-README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6
-README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621
+README.md: 7e993ece949aa54d0810d8a7434d3e74d86f4797
+README.zh.md: 0c107ddaaae37e158e2fcff205bd9585d4c4e6ae

+ 5 - 5
packages/interaction/permission-presets/README.md

@@ -4,17 +4,17 @@ English | [中文](README.zh.md)
 
 User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
 
-`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
+`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
 
-The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
+The service owns the `permission` Settings namespace. Its `defaultPreset` initializes fresh sessions and default-origin Workspace blanks that Web explicitly confirms for New Session reuse: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. Session creation reads the current setting and pins `permission/preset`, `sandbox/mode`, and `approval/policy`; the preset fact records whether it came from the default, an explicit selection, or legacy-knob inference. A committed setting change does not scan or rewrite existing sessions. When the Web workspace runtime selects a member blank with matching cwd that is not archived, the host rechecks those facts, adopts that exact session, including a cold persisted one, and advances it only when it has not started a turn, its latest selection is default-origin, and its effective knobs still match that selection. Explicit picks, inferred or origin-less legacy selections, independently changed knobs, and ordinary seeded resumes remain pinned. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
 
 The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
 
-Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
+Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
 
 ## Model Experience
 
-Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only.
+Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only.
 
 #### KV Cache effect
 
@@ -25,4 +25,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
 - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
 - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
-- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset.
+- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset.

+ 5 - 5
packages/interaction/permission-presets/README.zh.md

@@ -4,17 +4,17 @@
 
 通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
 
-`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
+`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
 
-该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
+该服务拥有 `permission` Settings namespace。其 `defaultPreset` 会初始化新建会话,以及 Web 明确确认为新会话复用目标、且权限来自默认值的 Workspace 空白会话:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。创建会话时会读取当前设置,并固定 `permission/preset`、`sandbox/mode` 和 `approval/policy`;preset 事实还会记录它来自默认值、显式选择还是旧旋钮推断。已提交的设置变更不会扫描或改写现有会话。当 Web workspace runtime 选中 cwd 匹配、属于该 Workspace 且未归档的空白会话时,host 会重新检查这些事实,并接纳这个确切会话(包括冷存储中的持久会话),而且只在它尚未开始轮次、最近选择来自默认值且有效旋钮仍匹配该选择时推进默认值。显式选择、由旧旋钮推断或没有来源标记的旧选择、独立变更的旋钮,以及普通 seed 恢复都会继续固定原权限。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
 
 该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
 
-两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
+两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permission` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
 
 ## 模型体验
 
-间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。
+间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
 
 #### KV Cache 影响
 
@@ -25,4 +25,4 @@
 - **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
 - **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。
 - **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。
-- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。
+- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。

+ 56 - 16
packages/interaction/permission-presets/src/index.ts

@@ -42,12 +42,15 @@ declare module '@deepseek-ai/cordis' {
 declare module '@deepseek-ai/dsh-session/types' {
   interface SessionEventMap {
     /**
-     * Records the selected preset as durable, log-only user intent. The knob
+     * Records the selected preset and whether it came from the session
+     * default, an explicit selection, or legacy-knob inference. The knob
      * events follow in the same turn and control execution; this event stays
      * out of the model transcript and lets {@link effectivePermissionPreset}
-     * preserve a selection when bundles match.
+     * preserve a selection when bundles match. `origin` is optional so logs
+     * written before origin tracking remain readable but are never mistaken
+     * for refreshable defaults.
      */
-    'permission/preset': { preset: string }
+    'permission/preset': { preset: string; origin?: 'default' | 'selection' | 'inferred' }
   }
 }
 
@@ -69,7 +72,7 @@ export interface PresetSpec {
  */
 export const CUSTOM_PRESET = 'custom'
 
-/** Settings namespace carrying the default for future sessions. */
+/** Settings namespace carrying the default for fresh sessions and confirmed reusable blanks. */
 export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
 
 /**
@@ -130,9 +133,18 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState {
   return state
 }
 
+/** Last recorded permission selection, including its durable origin. */
+function latestPermissionSelection(events: readonly SessionEvent[]): Extract<SessionEvent, { type: 'permission/preset' }> | undefined {
+  for (let index = events.length - 1; index >= 0; index -= 1) {
+    const event = events[index] as SessionEvent
+    if (event.type === 'permission/preset') return event
+  }
+  return undefined
+}
+
 /** User setting resolved when a new session receives its initial permission. */
 export interface PermissionSettings {
-  /** Preset pinned into a newly created session. */
+  /** Preset pinned into a fresh session or an eligible confirmed blank reuse. */
   defaultPreset: string
 }
 
@@ -145,8 +157,9 @@ export interface Config {
    */
   presets?: Record<string, PresetSpec>
   /**
-   * Default for new sessions. When omitted, the preset matching the composed
-   * sandbox and approval defaults is used.
+   * Default for fresh sessions and eligible confirmed blank reuse. When
+   * omitted, the preset matching the composed sandbox and approval defaults
+   * is used.
    */
   defaultPreset?: string
 }
@@ -212,8 +225,6 @@ export class PermissionPresetService extends Service {
       setSource: (current) => {
         this.defaultSettings = current
       },
-      // The source thunk reads the latest scope snapshot at session creation;
-      // no process-level registration needs replacement on change.
       onChange: () => {},
     })
 
@@ -270,7 +281,9 @@ export class PermissionPresetService extends Service {
           if (!this.names.includes(name)) {
             return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
           }
-          this.apply(agent.session, name, (policy) =>{  this.ctx.approval.setPolicy(agent, policy) })
+          this.apply(agent.session, name, (policy) => {
+            this.ctx.approval.setPolicy(agent, policy)
+          }, 'selection')
           return { kind: 'success', text: `preset ${name}` }
         },
       })
@@ -286,7 +299,7 @@ export class PermissionPresetService extends Service {
   }
 
   /**
-   * The preset currently selected as the default for future sessions.
+   * The preset currently selected for fresh sessions and confirmed blank reuse.
    * @returns the resolved settings value, or the composition default without
    * a mounted settings provider.
    */
@@ -305,6 +318,26 @@ export class PermissionPresetService extends Service {
     return this.derive(foldKnobs(events))
   }
 
+  /**
+   * Advance one blank session after the host has confirmed it as the exact
+   * Web New Session reuse target. Only a still-effective
+   * default-origin selection advances; a started session, an explicit pick,
+   * legacy origin-less data, or independently changed knobs remain pinned.
+   * This is the permission-side half of the Web candidate selection and the
+   * host's blankness, membership, cwd, and archive verification.
+   * @param session - the live session selected for Workspace blank reuse.
+   */
+  refreshDefaultForReuse(session: Session): void {
+    const events = session.events
+    if (events.some(event => event.type === 'turn/start')) return
+    const selected = latestPermissionSelection(events)
+    if (selected?.data.origin !== 'default') return
+    if (this.current(events) !== selected.data.preset) return
+    this.apply(session, this.defaultPreset, (policy) => {
+      setApprovalPolicy(session, policy)
+    }, 'default')
+  }
+
   /** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
   private derive(state: KnobState): string {
     const sandbox = state.sandbox ?? this.ctx.shell.sandboxMode
@@ -373,14 +406,21 @@ export class PermissionPresetService extends Service {
    * @param name - the preset to switch to; unknown names throw.
    */
   set(session: Session, name: string): void {
-    this.apply(session, name, (policy) =>{  setApprovalPolicy(session, policy) })
+    this.apply(session, name, (policy) => {
+      setApprovalPolicy(session, policy)
+    }, 'selection')
   }
 
   /** Apply one preset with the caller-selected live or initialization policy writer. */
-  private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
+  private apply(
+    session: Session,
+    name: string,
+    setApproval: (policy: ApprovalPolicy) => void,
+    origin: 'default' | 'selection' | 'inferred',
+  ): void {
     const spec = this.resolve(name)
     if (this.current(session.events) !== name) {
-      session.append('permission/preset', { preset: name })
+      session.append('permission/preset', { preset: name, origin })
     }
     const events = session.events
     if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) {
@@ -406,7 +446,7 @@ export class PermissionPresetService extends Service {
     if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
       const name = this.defaultPreset
       const spec = this.resolve(name)
-      session.append('permission/preset', { preset: name })
+      session.append('permission/preset', { preset: name, origin: 'default' })
       setSandboxMode(session, spec.sandbox)
       setApprovalPolicy(session, spec.approval)
       return
@@ -419,7 +459,7 @@ export class PermissionPresetService extends Service {
     }
     const effective = this.derive(state)
     if (selected === undefined && effective !== CUSTOM_PRESET) {
-      session.append('permission/preset', { preset: effective })
+      session.append('permission/preset', { preset: effective, origin: 'inferred' })
     }
     if (sandbox === undefined) {
       setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode)

+ 88 - 3
packages/interaction/permission-presets/tests/permission-presets.spec.ts

@@ -131,7 +131,7 @@ describe('PermissionPresetService', () => {
     const session = freshSession('sess-set')
     ctx.permissionPresets.set(session, 'danger-full-access')
     expect(session.events.map(e => [e.type, e.data])).toEqual([
-      ['permission/preset', { preset: 'danger-full-access' }],
+      ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }],
       ['sandbox/mode', { mode: 'danger-full-access' }],
       ['approval/policy', { policy: 'never' }],
     ])
@@ -154,7 +154,7 @@ describe('PermissionPresetService', () => {
     ctx.permissionPresets.set(session, 'danger-full-access')
     const tail = session.events.slice(4)
     expect(tail.map(e => [e.type, e.data])).toEqual([
-      ['permission/preset', { preset: 'danger-full-access' }],
+      ['permission/preset', { preset: 'danger-full-access', origin: 'selection' }],
       ['sandbox/mode', { mode: 'danger-full-access' }],
     ])
   })
@@ -197,14 +197,16 @@ describe('new-session default', () => {
     const ctx = await mountedStore()
     const first = ctx.sessions.create(SessionId('first'))
     expect(first.events.map(event => [event.type, event.data])).toEqual([
-      ['permission/preset', { preset: 'workspace-write' }],
+      ['permission/preset', { preset: 'workspace-write', origin: 'default' }],
       ['sandbox/mode', { mode: 'workspace-write' }],
       ['approval/policy', { policy: 'ask' }],
     ])
+    first.append('turn/start', { turn: 1 })
 
     await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
       defaultPreset: 'danger-full-access',
     })
+    ctx.permissionPresets.refreshDefaultForReuse(first)
     expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access')
     const second = ctx.sessions.create(SessionId('second'))
     expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write')
@@ -214,6 +216,89 @@ describe('new-session default', () => {
     ])
   })
 
+  it('advances a confirmed reusable blank session that still carries its default', async () => {
+    const ctx = await mountedStore()
+    const blank = ctx.sessions.create(SessionId('blank-placeholder'))
+    expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write')
+
+    await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+      defaultPreset: 'danger-full-access',
+    })
+    expect(ctx.permissionPresets.current(blank.events)).toBe('workspace-write')
+    ctx.permissionPresets.refreshDefaultForReuse(blank)
+
+    expect(ctx.permissionPresets.current(blank.events)).toBe('danger-full-access')
+    expect(blank.events.map(event => [event.type, event.data])).toEqual([
+      ['permission/preset', { preset: 'workspace-write', origin: 'default' }],
+      ['sandbox/mode', { mode: 'workspace-write' }],
+      ['approval/policy', { policy: 'ask' }],
+      ['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
+      ['sandbox/mode', { mode: 'danger-full-access' }],
+      ['approval/policy', { policy: 'never' }],
+    ])
+  })
+
+  it('leaves explicit, inferred, legacy, absent, and independently changed selections unchanged', async () => {
+    const ctx = await mountedStore()
+    const picked = ctx.sessions.create(SessionId('blank-explicit-pick'))
+    ctx.permissionPresets.set(picked, 'danger-full-access')
+    const pickedEvents = [...picked.events]
+
+    const restored = ctx.sessions.create(SessionId('blank-restored'), { seed: [] })
+    expect(ctx.permissionPresets.current(restored.events)).toBe('workspace-write')
+    const restoredEvents = [...restored.events]
+
+    const drifted = ctx.sessions.create(SessionId('blank-drifted-knob'))
+    drifted.append('sandbox/mode', { mode: 'read-only' })
+    const driftedEvents = [...drifted.events]
+
+    const legacy = freshSession('blank-originless-selection')
+    legacy.append('permission/preset', { preset: 'workspace-write' })
+    legacy.append('sandbox/mode', { mode: 'workspace-write' })
+    legacy.append('approval/policy', { policy: 'ask' })
+    const legacyEvents = [...legacy.events]
+
+    const absent = freshSession('blank-without-selection')
+
+    await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+      defaultPreset: 'danger-full-access',
+    })
+    await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+      defaultPreset: 'workspace-write',
+    })
+    ctx.permissionPresets.refreshDefaultForReuse(picked)
+    ctx.permissionPresets.refreshDefaultForReuse(restored)
+    ctx.permissionPresets.refreshDefaultForReuse(drifted)
+    ctx.permissionPresets.refreshDefaultForReuse(legacy)
+    ctx.permissionPresets.refreshDefaultForReuse(absent)
+
+    expect(picked.events).toEqual(pickedEvents)
+    expect(restored.events).toEqual(restoredEvents)
+    expect(drifted.events).toEqual(driftedEvents)
+    expect(legacy.events).toEqual(legacyEvents)
+    expect(absent.events).toEqual([])
+  })
+
+  it('refreshes a cold default-origin placeholder after resume', async () => {
+    const ctx = await mountedStore()
+    const source = ctx.sessions.create(SessionId('cold-placeholder-source'))
+    const stored = [...source.events]
+    await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {
+      defaultPreset: 'danger-full-access',
+    })
+    const resumed = ctx.sessions.create(SessionId('cold-placeholder-resumed'), { seed: stored })
+    expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
+
+    ctx.permissionPresets.refreshDefaultForReuse(resumed)
+
+    expect(ctx.permissionPresets.current(resumed.events)).toBe('danger-full-access')
+    expect(resumed.events.slice(-3).map(event => [event.type, event.data])).toEqual([
+      ['permission/preset', { preset: 'danger-full-access', origin: 'default' }],
+      ['sandbox/mode', { mode: 'danger-full-access' }],
+      ['approval/policy', { policy: 'never' }],
+    ])
+  })
+
   it('preserves a seeded legacy session instead of applying the latest user default', async () => {
     const ctx = await mountedStore()
     await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, {

+ 1 - 0
packages/subagent/subagent-codex/tests/subagent-codex.spec.ts

@@ -2243,6 +2243,7 @@ describe('run lifecycle and quiescence', () => {
       stopReason: 'error',
     })
     expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
+      argv: codexAppServerArgv(),
       env: { OPENAI_API_KEY: 'fake' },
       graceMs: 25,
       cwd: process.cwd(),

+ 3 - 0
pnpm-lock.yaml

@@ -5045,6 +5045,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-permission-presets':
+        specifier: workspace:^
+        version: link:../../interaction/permission-presets
       '@deepseek-ai/dsh-storage':
         specifier: workspace:^
         version: link:../../storage/storage

+ 62 - 45
scripts/ci-workflow.spec.ts

@@ -8,16 +8,19 @@ const runnerPrivatePnpmDestination = '${{ runner.temp }}/setup-pnpm'
 
 describe('CI workflow', () => {
   it('isolates every pnpm action setup destination per runner', () => {
-    const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/ci.yml'), 'utf8'))
-    if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('CI workflow must define jobs')
-
-    const setups = Object.entries(workflow.jobs).flatMap(([jobName, job]) => {
-      if (!isRecord(job) || !Array.isArray(job.steps)) return []
-      return job.steps.flatMap((step) => {
-        if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) return []
-        return [{ jobName, step }]
-      })
-    })
+    const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml']
+    const setups: Array<{ jobName: string; step: unknown }> = []
+    for (const file of files) {
+      const workflow: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'))
+      if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
+      for (const [jobName, job] of Object.entries(workflow.jobs)) {
+        if (!isRecord(job) || !Array.isArray(job.steps)) continue
+        for (const step of job.steps) {
+          if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) continue
+          setups.push({ jobName, step })
+        }
+      }
+    }
 
     expect(setups.length).toBeGreaterThan(0)
     for (const { jobName, step } of setups) {
@@ -29,22 +32,24 @@ describe('CI workflow', () => {
 
   it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => {
     const workflow = loadWorkflow('.github/workflows/ci.yml')
+    const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
     if (!isRecord(workflow.jobs)
       || !isRecord(workflow.jobs.windows)
       || !isRecord(workflow.jobs['windows-native'])
-      || !isRecord(workflow.jobs['wine-apt-cache'])
-      || !isRecord(workflow.jobs['serial-windows'])
       || !isRecord(workflow.jobs['node-24'])
       || !isRecord(workflow.jobs['node-24-coverage'])
       || !isRecord(workflow.jobs['node-24-consumers'])
-      || !isRecord(workflow.jobs['all-checks-passed'])) {
-      throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, node-24, node-24-coverage, node-24-consumers, and all-checks-passed jobs')
+      || !isRecord(workflow.jobs['all-checks-passed'])
+      || !isRecord(masterWorkflow.jobs)
+      || !isRecord(masterWorkflow.jobs['wine-apt-cache'])
+      || !isRecord(masterWorkflow.jobs['serial-windows'])) {
+      throw new TypeError('CI workflow must define windows, windows-native, node-24, node-24-coverage, node-24-consumers, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
     }
 
     const windows = workflow.jobs.windows
     const windowsNative = workflow.jobs['windows-native']
-    const wineAptCache = workflow.jobs['wine-apt-cache']
-    const serialWindows = workflow.jobs['serial-windows']
+    const wineAptCache = masterWorkflow.jobs['wine-apt-cache']
+    const serialWindows = masterWorkflow.jobs['serial-windows']
     const node24 = workflow.jobs['node-24']
     const node24Coverage = workflow.jobs['node-24-coverage']
     const node24Consumers = workflow.jobs['node-24-consumers']
@@ -80,11 +85,11 @@ describe('CI workflow', () => {
     ))
     expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete')
 
-    // wine-apt-cache: master-only, seeds the Wine apt cache.
+    // wine-apt-cache: master-only, seeds the Wine apt cache, lives in ci-master.
     expect(wineAptCache.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
     expect(wineAptCache['runs-on']).toBe('ubuntu-latest')
 
-    // serial-windows: master-only standby, self-hosted, non-blocking.
+    // serial-windows: master-only standby, self-hosted, non-blocking, lives in ci-master.
     expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
     expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows'])
     expect(serialWindows.name).toBe('serial / windows (self-hosted standby)')
@@ -108,10 +113,14 @@ describe('CI workflow', () => {
     expect(aggregate['runs-on']).toContain('vm-backup')
   })
 
-  it('exempts push from cancellation, so one master merge does not cancel the running drill', () => {
-    const workflow = loadWorkflow('.github/workflows/ci.yml')
+  it('exempts push from cancellation in ci-master, so one master merge does not cancel the running drill', () => {
+    const workflow = loadWorkflow('.github/workflows/ci-master.yml')
+    const prWorkflow = loadWorkflow('.github/workflows/ci.yml')
     if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) {
-      throw new TypeError('CI workflow must define jobs and a workflow-level concurrency block')
+      throw new TypeError('ci-master workflow must define jobs and a workflow-level concurrency block')
+    }
+    if (!isRecord(prWorkflow.jobs)) {
+      throw new TypeError('ci workflow must define jobs')
     }
 
     // Cancellation applies to the whole superseded RUN, so this has to be
@@ -120,11 +129,26 @@ describe('CI workflow', () => {
     // a drill takes longer than the interval between master merges. The negated
     // form is load-bearing: `== 'pull_request'` would also stop cancelling
     // workflow_dispatch, and a re-dispatched runner benchmark holds up to 12
-    // larger runners for 15 minutes in this same group on master. The
-    // expression is evaluated against the NEWLY TRIGGERED run, so a dispatch on
-    // master still cancels a mid-flight drill; the runbook records that bound.
+    // larger runners for 15 minutes in this same group on master.
     expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}")
 
+    // The PR-only ci.yml still cancels a superseded run on a new push, so a
+    // fresh head does not stack a second full 9-job run behind a stale one.
+    // Unlike ci-master it has no push carve-out: every PR event supersedes.
+    expect(prWorkflow.concurrency).toMatchObject({
+      'cancel-in-progress': true,
+    })
+
+    // The exact event sets are what keep master-only jobs out of the PR check
+    // panel: ci-master triggers only on push(master) + workflow_dispatch and
+    // never on pull_request; ci.yml is exactly pull_request-only. Assert the
+    // full sets so losing the wrong event, or gaining an extra one, fails.
+    if (!isRecord(workflow.on) || !isRecord(prWorkflow.on)) {
+      throw new TypeError('both CI workflows must define on')
+    }
+    expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch'])
+    expect(Object.keys(prWorkflow.on)).toEqual(['pull_request'])
+
     // Neither drill may carry a job-level group: it would not exempt the job
     // from run-scoped cancellation.
     for (const name of ['serial-linux-selfhosted', 'serial-windows']) {
@@ -138,14 +162,7 @@ describe('CI workflow', () => {
     // What bounds the cost of exempting push: a master push may only carry the
     // cache seeder and the two drills. Any job reachable on push would start
     // accumulating uncancelled runs, so the set is pinned here.
-    //
-    // Classification is an exact allowlist of the conditions in use, not a
-    // substring match: `github.event_name != 'pull_request'` mentions
-    // `pull_request` yet IS push-reachable, so matching on the event name alone
-    // would silently misclassify it as gated.
     const NOT_PUSH_REACHABLE = new Set([
-      "github.event_name == 'pull_request'",
-      "always() && github.event_name == 'pull_request'",
       "github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'",
       "github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'",
     ])
@@ -211,6 +228,20 @@ describe('CI workflow', () => {
   })
 })
 
+describe('DeepSeek e2e workflow', () => {
+  it('prepares bubblewrap from the pinned payload without a package transaction', () => {
+    const workflow = loadWorkflow('.github/workflows/e2e.yml')
+    const e2e = workflowJob(workflow, 'e2e')
+    if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
+
+    const steps = e2e.steps.filter(isRecord)
+    expect(steps.find(step => step.name === 'Prepare bubblewrap (unrestrict userns)')).toMatchObject({
+      run: 'bash scripts/prepare-ci-bubblewrap.sh',
+    })
+    expect(JSON.stringify(steps)).not.toContain('apt-get')
+  })
+})
+
 describe('E2B e2e workflow', () => {
   it('is manual-only and fails loud before running the focused live suite', () => {
     const workflow = loadWorkflow('.github/workflows/e2b-e2e.yml')
@@ -238,20 +269,6 @@ describe('E2B e2e workflow', () => {
   })
 })
 
-describe('DeepSeek e2e workflow', () => {
-  it('prepares bubblewrap from the pinned payload without a package transaction', () => {
-    const workflow = loadWorkflow('.github/workflows/e2e.yml')
-    const e2e = workflowJob(workflow, 'e2e')
-    if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
-
-    const steps = e2e.steps.filter(isRecord)
-    expect(steps.find(step => step.name === 'Prepare bubblewrap (unrestrict userns)')).toMatchObject({
-      run: 'bash scripts/prepare-ci-bubblewrap.sh',
-    })
-    expect(JSON.stringify(steps)).not.toContain('apt-get')
-  })
-})
-
 describe('Python release workflows', () => {
   it('keeps complete wheel validation separate from protected public publication', () => {
     const workflow = loadWorkflow('.github/workflows/python-release.yml')