瀏覽代碼

Merge remote-tracking branch 'origin/master' into worktree/llm-deepseek-messages

Yichen Jiang 1 周之前
父節點
當前提交
e52ffcea87
共有 100 個文件被更改,包括 2867 次插入179 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
  5. 3 1
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
  6. 3 1
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-root-marker-metadata-failures.i18n.yaml
  11. 27 0
      .agents/notes/implemented/bug-fix/2026-09-03-root-marker-metadata-failures.md
  12. 27 0
      .agents/notes/implemented/bug-fix/2026-09-03-root-marker-metadata-failures.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.i18n.yaml
  14. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.md
  15. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.zh.md
  16. 6 0
      .agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.i18n.yaml
  17. 35 0
      .agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md
  18. 35 0
      .agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-07-16-harness-level-loop.i18n.yaml
  20. 2 2
      .agents/notes/implemented/feature/2026-07-16-harness-level-loop.md
  21. 2 2
      .agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml
  23. 5 4
      .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md
  24. 5 4
      .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md
  25. 2 2
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml
  26. 1 1
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
  27. 1 1
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md
  28. 2 2
      .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml
  29. 6 2
      .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md
  30. 6 2
      .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md
  31. 6 0
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
  32. 31 0
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
  33. 31 0
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
  34. 6 0
      .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml
  35. 41 0
      .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md
  36. 41 0
      .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md
  37. 6 0
      .agents/notes/implemented/process/2026-09-08-playwright-video-gif.i18n.yaml
  38. 29 0
      .agents/notes/implemented/process/2026-09-08-playwright-video-gif.md
  39. 29 0
      .agents/notes/implemented/process/2026-09-08-playwright-video-gif.zh.md
  40. 6 0
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml
  41. 47 0
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
  42. 47 0
      .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md
  43. 6 0
      .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.i18n.yaml
  44. 51 0
      .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md
  45. 51 0
      .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md
  46. 71 32
      .agents/skills/record-browser-gif/SKILL.md
  47. 92 34
      .agents/skills/record-browser-gif/scripts/encode_gif.py
  48. 103 0
      .agents/skills/record-browser-gif/scripts/test_encode_gif.py
  49. 60 0
      .github/review-ownership/CODEOWNERS
  50. 6 0
      .github/review-ownership/README.i18n.yaml
  51. 59 0
      .github/review-ownership/README.md
  52. 59 0
      .github/review-ownership/README.zh.md
  53. 526 0
      .github/review-ownership/request-review.mjs
  54. 510 0
      .github/review-ownership/request-review.test.mjs
  55. 22 7
      .github/workflows/ci.yml
  56. 31 0
      .github/workflows/request-review.yml
  57. 2 2
      apps/cli/README.i18n.yaml
  58. 2 1
      apps/cli/README.md
  59. 2 1
      apps/cli/README.zh.md
  60. 2 2
      apps/cli/reference/README.i18n.yaml
  61. 10 1
      apps/cli/reference/README.md
  62. 12 1
      apps/cli/reference/README.zh.md
  63. 16 4
      apps/cli/src/args.ts
  64. 7 1
      apps/cli/src/bin.ts
  65. 8 2
      apps/cli/src/dump-config.ts
  66. 77 5
      apps/cli/src/profile-boot.ts
  67. 22 0
      apps/cli/tests/args.spec.ts
  68. 91 0
      apps/cli/tests/built-bin.e2e.ts
  69. 18 0
      apps/cli/tests/fixtures/initialize-profile-from-default.ts
  70. 158 0
      apps/cli/tests/profile-initialization.spec.ts
  71. 2 1
      apps/web/package.json
  72. 1 1
      apps/web/tests/built-boot.expected.e2e.ts
  73. 1 1
      apps/web/tests/command-image-envelope.expected.e2e.ts
  74. 4 2
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  75. 4 2
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  76. 4 2
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  77. 8 0
      apps/web/tests/expected/goal-bar/inactive.expected.md
  78. 1 1
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  79. 4 2
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  80. 4 2
      apps/web/tests/expected/markdown-images/ui.expected.md
  81. 4 2
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  82. 4 2
      apps/web/tests/expected/math-rendering/ui.expected.md
  83. 1 1
      apps/web/tests/expected/reference-composer/order.expected.md
  84. 1 1
      apps/web/tests/expected/settings-chrome/dialog-en.expected.md
  85. 1 1
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  86. 7 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  87. 7 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  88. 4 2
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  89. 1 1
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  90. 7 2
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  91. 7 2
      apps/web/tests/expected/steer-all/settled.expected.md
  92. 1 1
      apps/web/tests/feedback-release.e2e.ts
  93. 15 2
      apps/web/tests/goal-bar.e2e.ts
  94. 3 3
      apps/web/tests/image-display.expected.e2e.ts
  95. 26 3
      apps/web/tests/lifecycle-chrome.e2e.ts
  96. 4 1
      apps/web/tests/live-interactions.e2e.ts
  97. 1 1
      apps/web/tests/math-rendering.e2e.ts
  98. 73 0
      apps/web/tests/open-in-app-ssh.e2e.ts
  99. 4 0
      apps/web/tests/plan-review.e2e.ts
  100. 1 1
      apps/web/tests/preview-boot.e2e.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md
-2026-07-29-projected-token-usage-and-request-context.md: d62f7dccd544a342da64fc35c24d32d53bf56231
-2026-07-29-projected-token-usage-and-request-context.zh.md: 6d2bb624ac11dbcdac30695913d3c16513bfdf9e
+2026-07-29-projected-token-usage-and-request-context.md: f96257243bef91ff6a73418231de5e777d8edb2e
+2026-07-29-projected-token-usage-and-request-context.zh.md: 7365d5d816f9f9b324f3e3d3b4db0d3346851bdf

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md

@@ -26,7 +26,7 @@ Capacity deliberately stays out of `EpochHeader`. That type is the reconstructio
 
 Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
 
-The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. The exact-overflow tooltip mounts its measuring child only for a non-empty line and retains one `ResizeObserver` while values change; text changes perform one direct measurement without replacing the observer.
+The Web [`StatsPills`](../feature/2026-09-07-composer-session-stats-pills.md) reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times as the no-projection fallback — those answer "what is on screen" and are correctly window-scoped. The durable usage pill remains when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the usage pill; context occupancy lives on the composer's ContextMeter ring. Exact token figures show in the usage pill's click-open dialog rather than a hover tooltip.
 
 ## Context occupancy is approximate, and that is the decision
 
@@ -48,7 +48,7 @@ That cost bought a worse display: occupancy went blank after every reconnect and
 
 **Resolve capacity inside token-meter.** The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written.
 
-**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but `StatsLine` lives in `ui-conversation` while the model directory lives in `ui-model-selection`, and `ui-conversation` cannot depend on `ui-model-selection`. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write.
+**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but the stats display (now `StatsPills`, ui-chat) and the model directory live in separate plugins with no dependency between them. Delivering it would have required either a second dock entry splitting one surface across two plugins, or a cross-plugin store write.
 
 **Add a context circle beside the model selector.** That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md

@@ -26,7 +26,7 @@ token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。
 
 两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
 
-Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。精确 overflow tooltip 只在统计行非空时挂载测量子组件,并在值变化期间保留同一个 `ResizeObserver`;文本变化只直接测量一次,不替换 observer
+Web [`StatsPills`](../feature/2026-09-07-composer-session-stats-pills.zh.md) 通过标准 `useProjection` 席位读取两者。窗口内节点仍作为无投影回退提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久用量 pill 仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉用量 pill;上下文占用率由输入框旁的 ContextMeter 圆环承载。精确 token 数字显示在用量 pill 点击展开的弹层里,而非悬停提示
 
 ## 上下文占用率是近似值,而这正是决策本身
 
@@ -48,7 +48,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节
 
 **在 token-meter 内部解析容量。** 该包自述与模型路由无关,且在其他方面是一个从不向日志追加内容的纯读取方。AgentLoop 在写入请求头的位置已经持有已解析的元数据。
 
-**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但 `StatsLine` 位于 `ui-conversation`,模型目录位于 `ui-model-selection`,而 `ui-conversation` 不能依赖 `ui-model-selection`。要送达它,就得增加第二个 dock 条目、把一行文本拆到两个插件里,或者做一次跨插件的 store 写入。
+**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但统计展示(现为 `StatsPills`,ui-chat)与模型目录位于两个互不依赖的插件。要送达它,就得增加第二个 dock 条目把一个表面拆到两个插件里,或者做一次跨插件的 store 写入。
 
 **在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。统计行可以承载该数字,无需引入重复的 UI 或数据路径。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
-2026-08-05-profile-plugin-bundles.md: ccfa3306fd88b4f291085cae2bd02305b2c11fc6
-2026-08-05-profile-plugin-bundles.zh.md: e15ad15978ab57dcada8ecc877e0036cfde6b21e
+2026-08-05-profile-plugin-bundles.md: 7e51345e7eba8a58db63807e31d4a11481e3ffea
+2026-08-05-profile-plugin-bundles.zh.md: b2631603737ea9412eb97029ff01d751d8084cec

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md

@@ -12,7 +12,7 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y
 
 Everything becomes a **profile**: a directory `$DSH_HOME/profiles/<name>` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](../../archived/architecture/2026-08-06-app-owned-command-line.md).
 
-The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
+The default Profile templates use `@deepseek-ai/dsh-base` as the shared core for `web`, `headless`, `sdk`, and `acp`, with one mode bundle above it. The [standalone `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.md) instead lists one bundle that owns its complete explicit tree. Generic `dsh --profile <name>` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, headless owns its task positional, and the protocol profiles accept no app options. Patch overlays use launcher-owned `--patch`. A new, non-shipped target can use `--from-default-profile <template>` to copy one default template's bundle list and patch-reload policy before boot or config dump. This creates an independent profile with empty dependencies and an empty user patch: it neither reads a local profile named by the template nor records an inheritance relationship. The launcher claims the complete target directory exclusively, so existing state and concurrent creators fail without modification. `dsh plugin --profile <name> <args...>` is a thin pnpm forwarder that initializes a base-backed profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract.
 
 Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch).
 
@@ -24,10 +24,12 @@ Two supporting refactors: the webserver's built-in static dist serving became th
 - **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
 - **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (`ctx.cmdlineArgs`, `ctx.appExit`, and the environment snapshot) are provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
 - **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
+- **Dynamic template inheritance or cloning a local profile**: recording a parent would require merge and upgrade rules for bundle membership, dependencies, and user patches, while copying local state would duplicate machine-specific choices. Template-based creation copies only installation-owned defaults once.
 
 ## Consequences
 
 - New composition surfaces (a TUI, provider packs) ship as ordinary npm packages installable per profile, without a repository row for every deployment shape.
+- Users can start an independent custom profile from any shipped application template without copying machine-local profile state.
 - `apps/cli` shrank to argv parsing, profile machinery consumption, and the pnpm forwarder; `AppCLIEntry` and the per-surface boot paths are gone.
 - The keyless web e2e scaffold boots the same bundle layers over the same empty-root shape as production, including the profiles module fallback, so composition drift between test and product fails loudly.
 - Under the pre-release stance, backends carry no compatibility behavior for old on-disk configuration; `$DSH_HOME/config.yaml` is ignored.

+ 3 - 1
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 一切都变成 **profile**:即目录 `$DSH_HOME/profiles/<name>`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](../../archived/architecture/2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。
 
-默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
+默认 Profile 模板为 `web`、`headless`、`sdk` 与 `acp` 使用 `@deepseek-ai/dsh-base` 作为共享核心,并在其上叠加一个模式组合包。[独立 `sdk-minimal` profile](../../../../packages/bundle/sdk-minimal/README.zh.md)则只列出一个拥有完整显式配置树的组合包。通用的 `dsh --profile <name>` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 持有任务位置参数,协议 profile 不接受应用选项。patch overlay 使用启动器持有的 `--patch`。新的非内置目标可以使用 `--from-default-profile <template>`,在启动或配置 dump 之前复制一个默认模板的 bundle 列表与 patch 重载策略。这会创建依赖为空、用户 patch 为空的独立 profile:它既不读取与模板同名的本地 profile,也不记录继承关系。launcher 会以独占方式领取完整的目标目录,因此既有状态和并发创建者都会在不作修改的情况下失败。`dsh plugin --profile <name> <args...>` 是一层薄薄的 pnpm 转发器,负责初始化一个以 base 为基础的 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。
 
 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。
 
@@ -24,10 +24,12 @@ Status: implemented
 - **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
 - **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(`ctx.cmdlineArgs`、`ctx.appExit` 与环境快照)在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
 - **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
+- **动态模板继承或克隆本地 profile**:记录父级会要求为 bundle 成员关系、依赖和用户 patch 制定合并与升级规则,而复制本地状态会重复机器特定选择。基于模板的创建只会一次性复制安装自有的默认值。
 
 ## Consequences
 
 - 新的组合表层(TUI、提供方扩展包)以普通 npm 包形式交付,可按 profile 安装,无需在仓库中为每种部署形态各留一行。
+- 用户可以从任意随附应用模板启动一个独立的自定义 profile,而不会复制机器本地的 profile 状态。
 - `apps/cli` 收缩为 argv 解析、profile 机制的消费方和 pnpm 转发器;`AppCLIEntry` 与各表层专属的启动路径全部移除。
 - 无密钥 web e2e 脚手架以与生产相同的空根形态启动相同的组合包层,包括 profiles 模块回退,因此测试与产品之间的组合漂移会响亮失败。
 - 按发布前姿态,后端不携带旧磁盘配置的兼容行为;`$DSH_HOME/config.yaml` 会被忽略。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 7f5b948f9083b20edd037fe32c55df7ba3378692
-2026-08-09-client-conversation-node-assembly.zh.md: 8716b25584a8cefd8d2239f242d430775ed2a8ef
+2026-08-09-client-conversation-node-assembly.md: 842dfb0218478591f975c97064f101a35ea2f211
+2026-08-09-client-conversation-node-assembly.zh.md: 6ab64c25957d33487461fe56e122faedb19f9421

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

@@ -315,7 +315,7 @@ The shell synchronously resolves the persisted selection when a Session binding
 
 Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
 
-[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
+[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsPills and mirrored into top-level public compatibility fields.
 
 Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces that key's value and publishes only its source. The Turn-process projector recalculates cross-Node presentation only for a Turn whose structure, specification, or status changed, then publishes only that Turn's process sources.
 
@@ -424,4 +424,4 @@ Inbox Context retention grows with splice count and claimed message count rather
 
 The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
 
-`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
+`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsPills and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -315,7 +315,7 @@ Session binding 可用、缓存的 binding 成为 current 或 View roster 变化
 
 普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
 
-[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
+[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsPills 使用并镜像到顶层公共兼容字段的 `legacy` slice。
 
 Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value 并发布其 source。Turn-process projector 仅为结构、规格或状态发生变化的 Turn 重算跨 Node 呈现,再只发布该 Turn 的 process source。
 
@@ -424,4 +424,4 @@ Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随
 
 代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
 
-`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
+`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsPills 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-root-marker-metadata-failures.i18n.yaml

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

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-03-root-marker-metadata-failures.md

@@ -0,0 +1,27 @@
+# Agent Note: Root marker metadata failures
+
+Status: implemented
+
+English | [中文](2026-09-03-root-marker-metadata-failures.zh.md)
+
+## Problem
+
+Project-root discovery probes each configured marker while walking upward from the session working directory. Treating every resolve or stat failure as a missing marker lets a permission, I/O, or provider failure continue into an ancestor project and load unrelated workspace instructions. The discovery result must distinguish confirmed absence from unavailable metadata.
+
+## Decision
+
+Root-marker discovery continues upward only when host stat reports `ENOENT` or `ENOTDIR`, or when a filesystem provider returns no stat information or reports `FS_NOT_FOUND` from resolution or stat. It rethrows every other marker error unchanged after checking cancellation. Instruction-file candidates keep their separate availability policy: resolution, stat, and read failures skip only that candidate because files can race with discovery without changing project identity.
+
+## Alternatives considered
+
+**Treat every marker failure as absence and continue upward.** Rejected because an inaccessible child directory could inherit instructions from an unrelated ancestor project while discovery reports success.
+
+**Stop at the first unavailable marker and use the session working directory as the root.** Rejected because it converts an unknown project root into a different project identity and can silently omit valid broader instructions.
+
+## Consequences
+
+Project-root discovery favors correct project identity over availability: one non-missing metadata failure anywhere in the ancestor walk rejects baseline loading with the original error. Instruction-file candidate failures retain their existing skip behavior. A failed baseline creates no workspace-context Session event, so the keyless recorded-session harness has no durable output for this path.
+
+## Verification
+
+Focused unit tests cover confirmed provider absence and unavailable host and provider marker metadata. The unavailable cases also prove that ancestor instructions do not enter derived model history.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-09-03-root-marker-metadata-failures.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 根标记元数据故障
+
+Status: implemented
+
+[English](2026-09-03-root-marker-metadata-failures.md) | 中文
+
+## 问题
+
+项目根发现从会话工作目录向上遍历时,会探测每个已配置的标记。把所有 resolve 或 stat 故障都当作标记缺失,会使权限、I/O 或提供方故障越过该目录继续搜索祖先项目,并加载无关的工作区指令。发现结果必须区分确认缺失与元数据不可用。
+
+## 决策
+
+只有当宿主 stat 报告 `ENOENT` 或 `ENOTDIR`,或文件系统提供方未返回 stat 信息,或从解析或 stat 报告 `FS_NOT_FOUND` 时,根标记发现才会继续向上。检查取消后,其他标记错误会原样重新抛出。指令文件候选项保留独立的可用性策略:解析、stat 和读取故障只会跳过该候选项,因为文件可能与发现过程发生竞争,而不会改变项目身份。
+
+## 考虑过的替代方案
+
+**把所有标记故障都当作缺失并继续向上。** 不予采用,因为无法访问的子目录可能继承无关祖先项目中的指令,而发现过程仍报告成功。
+
+**在第一个不可用标记处停止,并把会话工作目录用作根目录。** 不予采用,因为这会把未知的项目根转换为另一个项目身份,并可能静默省略有效的更宽泛指令。
+
+## 后果
+
+项目根发现优先保证项目身份正确,而非可用性:祖先遍历中任何不是缺失的元数据故障都会使基线加载以原始错误拒绝。指令文件候选项故障保留现有的跳过行为。失败的基线不会创建工作区上下文 Session event,因此无密钥录制会话 harness 没有可用于该路径的持久输出。
+
+## 验证
+
+聚焦单元测试覆盖确认的提供方缺失,以及不可用的宿主与提供方标记元数据。不可用情况还证明祖先指令不会进入派生模型历史。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.md
+2026-09-03-user-owned-goal-pause-activation.md: d58566938ef87ca2f25fd94d726dc28209f61b2b
+2026-09-03-user-owned-goal-pause-activation.zh.md: 675d61009f0a7d0f98c8f6d7568466d6651f2a42

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.md

@@ -0,0 +1,35 @@
+# Agent Note: User-owned goal pause exposes live activation
+
+Status: implemented
+
+English | [中文](2026-09-03-user-owned-goal-pause-activation.zh.md)
+
+## Problem
+
+The host-pause fix in [Host-initiated goal pause aborts the live turn](../../archived/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md) stopped the current model turn, but a later human turn could still use `update_goal resume` to lift a durable `paused` goal. The Web strip also read only the durable `goal` projection, so an active-but-disarmed goal and an armed goal rendered identically and offered the same pause action.
+
+## Decision
+
+`ctx.goals.get` is a read-only Remote method. `GoalService` emits `goal/activation-changed` whenever its process-local activation changes, with `{ sessionId, goal: { id, revision, activation } }` or no goal after a clear. The API Remote allowlist forwards that JSON payload to Web clients.
+
+The GoalBar consumes a registrant-private activation hook source created by its slot inject. The source starts while the framework hook observes it, reads `ctx.remote.goals.get`, subscribes to `goal/activation-changed`, and refreshes on running-state or connection resets. Activation edges advance an epoch that invalidates in-flight reads, so a stale HTTP response cannot overwrite a newer edge; running refreshes retain the last activation until the read resolves. Active goals render `Ongoing Goal` only when armed; active-but-disarmed goals render `Inactive Goal`, expose resume instead of pause, and durable paused goals keep exposing resume. Pause authority remains in the goal domain and human `/goal resume` command, which can still resume every resumable phase.
+
+The `update_goal resume` action rejects a durable paused goal with `GOAL_TOOL_RESUME_PAUSED` before calling the goal service. It still resumes an active-but-disarmed goal after session restore or fork and a blocked goal after human continuation. The model prompt and tool description state that the user owns durable paused resume.
+
+## Alternatives considered
+
+**Store activation in the durable `GoalSnapshot`.** Rejected: activation is process-local by the goal domain contract and must not survive restore or fork.
+
+**Add activation to the persisted session projection.** Rejected: projection state is checkpointed; a cached `armed` value would incorrectly outlive the process that armed it.
+
+**Forward the full scoped `goal/changed` event to clients.** Rejected: its `Agent` payload is not JSON wire data. The dedicated activation event carries only the session id, goal ref, and activation clients need.
+
+**Let the model resume durable paused goals from natural-language turns.** Rejected: a manual pause is a user control, and prompt-only restraint leaves the same turn-level undo available to the model.
+
+## Consequences
+
+The Web can distinguish running, disarmed, and paused goals without persisting activation. A durable paused goal is resumable only through the Web control, `/goal resume`, or another direct goal-service caller; model `update_goal resume` is limited to disarmed-active and blocked goals. The API surface gains one read and one forwarded live event; durable goal change payloads and projection state versions are unchanged. Components own no Remote subscriptions; the activation source follows the established inject-hooks live-data channel.
+
+## Testing
+
+Goal unit tests pin the activation event id and revision across create, session start, and resume. Tool tests pin rejection of a durable paused goal in a later human turn while restored disarmed-active goals still resume. API Remote tests pin JSON forwarding. Activation-source tests pin stale-read rejection and running-refresh retention. Web unit tests pin armed pause versus disarmed resume rendering. The assembled goal-bar browser scenario uses the fixture timing hook to pin both armed and active-disarmed goldens.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-user-owned-goal-pause-activation.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 用户独占的 goal 暂停并暴露实时激活态
+
+Status: implemented
+
+[English](2026-09-03-user-owned-goal-pause-activation.md) | 中文
+
+## 问题
+
+[宿主发起的 goal 暂停中止当前轮次](../../archived/bug-fix/2026-09-01-host-goal-pause-aborts-turn.md) 修复了当前模型轮次不停止的问题,但之后的人类轮次仍可通过 `update_goal resume` 解除持久的 `paused` goal。Web 条带也只读取持久的 `goal` 投影,因此 active-but-disarmed 的 goal 与 armed 的 goal 渲染相同,并提供相同的暂停动作。
+
+## 决策
+
+`ctx.goals.get` 现在是一个只读 Remote 方法。`GoalService` 在进程本地 activation 变化时发出 `goal/activation-changed`,载荷为 `{ sessionId, goal: { id, revision, activation } }`,clear 后则不携带 goal。API Remote 允许列表把这份 JSON 载荷转发给 Web 客户端。
+
+GoalBar 消费由 slot inject 创建的 registrant-private activation hook source。该 source 仅在框架 hook 观察期间启动,读取 `ctx.remote.goals.get`、订阅 `goal/activation-changed`,并在 running 状态或连接 reset 时刷新。activation 边界推进 epoch,使在途读取失效,因此较旧的 HTTP 响应不能覆盖更新的边界;running 刷新会保留最后一次 activation,直到读取完成。Active goal 仅在 armed 时渲染 `Ongoing Goal`;active-but-disarmed goal 渲染 `Inactive Goal`,暴露 resume 而不是 pause;持久 paused goal 继续暴露 resume。暂停权威仍属于 goal 领域和人类 `/goal resume` 命令,它们仍可恢复每个可恢复 phase。
+
+`update_goal resume` 会在调用 goal 服务前用 `GOAL_TOOL_RESUME_PAUSED` 拒绝持久 paused goal。它仍会在会话恢复或 fork 后恢复 active-but-disarmed goal,并在人类要求继续时恢复 blocked goal。模型提示词和工具描述说明持久 paused 的恢复由用户独占。
+
+## 考虑过的替代方案
+
+**把 activation 存入持久 `GoalSnapshot`。** 否决:按 goal 领域约定,activation 是进程本地的,绝不能跨恢复或 fork 存活。
+
+**把 activation 加入持久 session projection。** 否决:投影状态会写入检查点;缓存的 `armed` 会在武装它的进程消失后继续错误存在。
+
+**把完整的 scoped `goal/changed` 事件转发给客户端。** 否决:其 `Agent` 载荷不是 JSON wire 数据。专用 activation 事件只携带客户端需要的 session id、goal ref 与 activation。
+
+**允许模型从自然语言轮次恢复持久 paused goal。** 否决:人工暂停是用户控制,仅靠提示词约束仍会把同轮撤销能力留给模型。
+
+## 后果
+
+Web 无需持久化 activation 就能区分运行中、disarmed 与 paused goal。持久 paused goal 只能通过 Web 控件、`/goal resume` 或其他直接调用 goal 服务的调用方恢复;模型 `update_goal resume` 仅限 disarmed-active 与 blocked goal。API 表面新增一个读取和一个转发 live 事件;持久 goal change 载荷与投影 stateVersion 不变。组件不持有 Remote 订阅;activation source 遵循既有的 inject-hooks live-data 通道。
+
+## 测试
+
+Goal 单元测试固定 create、session start 与 resume 过程中 activation 事件的 id 与 revision。工具测试固定后续人类轮次中持久 paused goal 的拒绝,同时保留已恢复 disarmed-active goal 的恢复。API Remote 测试固定 JSON 转发。Activation-source 测试固定 stale read 拒绝与 running 刷新保留旧值。Web 单元测试固定 armed 显示 pause、disarmed 显示 resume;组装的 goal-bar 浏览器场景通过 fixture timing hook 同时固定 armed 与 active-disarmed golden。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md
+2026-09-04-busy-send-button-follows-enter-setting.md: 2db0848e3ce3b7a1b6b07ff6d6d23c06b23be5ef
+2026-09-04-busy-send-button-follows-enter-setting.zh.md: 7062f60320c4bc56f15f57388e79df9ce0e45c2f

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md

@@ -0,0 +1,35 @@
+# Agent Note: The busy Send button follows the Enter setting
+
+Status: implemented
+
+English | [中文](2026-09-04-busy-send-button-follows-enter-setting.zh.md)
+
+## Problem
+
+The Web composer offers one user-facing choice for submitting while the agent is running: the `ui-conversation.busyEnter` setting selects Queue or Steer. [Running drafts take the primary Send action](../../archived/bug-fix/2026-08-20-running-draft-primary-send.md) (archived) gave a running draft a pointer Send button, deliberately kept it off the preference to avoid an invisible mode on a button labeled only Send, and routed every click through the public `InputActions.submit()` face, which `SessionInputShell.actions` fixes to `'queue'`. A user who chose Steer in Settings got Steer from Enter and Queue from the button beside the same draft, with the button labeled only "Send message". Nothing in the composer explained the divergence, and the Settings row's title and description named only the Enter key, so the setting looked broken rather than deliberately partial.
+
+## Decision
+
+The running Send button delivers through the same mode as plain Enter. `InputBar` computes `resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)` once per render, where `steeringAvailable` is the same ordinary-Session-or-continuable-child predicate the keyboard path uses, applies it to the primary click through `ComposerKeyboard.submit(mode)`, and applies it to the primary label exactly when the click would deliver a plain message: the composer is running and steer-capable, the button is enabled (no file upload still pending), and the draft is non-empty, unclaimed, and not a `/` line headed for command adjudication. That state shows `input.send.queue` ("Queue message" / "排队发送") or `input.send.steer` ("Steer message" / "插话发送") as both the tooltip and the accessible name; every other state in which the seat is a Send button — idle sessions, one-shot children, locked composers, a continuable child's empty draft, drafts with a pending upload, and command drafts whose click executes the command rather than delivering a message — keeps `input.send` ("Send message"); an ordinary running session with an empty or owner-blocked draft shows Stop in that seat instead. Cmd/Ctrl+Enter still resolves to the opposite mode, and the empty-draft accelerated gesture still steers the whole queue. The [continuable subagent interrupt note](../feature/2026-08-06-continuable-subagent-interrupt.md) describes the child's Send with this delivery.
+
+The composer bar's inject face carries the live preference instead of a resolver closure. `ComposerBarInjected.hooks.busyEnter` publishes `ComposerSubmissionPolicy.busyEnter`, so the bar receives a `useBusyEnter` selector hook and re-renders the label when the Settings row or a Host settings update changes the value. `resolveSubmitMode` is a pure exported function in `submission-policy.ts` taking the preference explicitly; the policy class keeps only the store and its Host adoption and write-through.
+
+The Settings row is retitled to cover both inputs: "Send behavior while busy" / "繁忙时的发送行为", described as what Enter and the Send button do while the agent is running, with the Cmd/Ctrl+Enter opposite-mode note retained. The `busyEnter` field name, its `queue` default, and the Host schema are unchanged, so existing `settings.yaml` documents keep their meaning.
+
+## Verification
+
+`input-bar.client.spec.tsx` asserts that a running draft's button is labeled by mode and submits with that mode under both preferences, that flipping the preference store re-labels the mounted button before the next click, that idle Send keeps the plain label and Queue delivery regardless of the preference, that a continuable subagent's Send follows the same mode and label as an ordinary Session while its empty-draft disabled button and a one-shot child keep plain Send, and that a `/` line, a claimed command, and a draft with a still-uploading file keep plain Send while running. `submission-policy.client.spec.ts` pins `resolveSubmitMode` for every preference, running, gesture, and steering-availability combination. `enter-behavior-row.client.spec.tsx` and the `settings-chrome` ARIA goldens carry the new Settings copy. The keyless `live-interactions` Web scenario waits for "Queue message" on the parked running draft and asserts that no "Send message" button exists at that moment, and its `running-draft.expected.md` golden records the new name.
+
+## Alternatives considered
+
+**Keep the button on Queue and only reword the Settings row.** This preserves the earlier decision but leaves the composer with two submission paths for one draft under one setting. A user who prefers Steer still cannot get it by pointer, and the reworded row would have to document a keyboard-only scope that no other composer control shares.
+
+**Add a second running button, one per mode.** Both delivery modes become reachable by pointer without a hidden state, but the ordinary session has one primary seat that already alternates between Stop and Send; a permanent second control spends space and introduces a hierarchy the draft itself does not need. The single setting already expresses the user's default, and Cmd/Ctrl+Enter remains the per-message override.
+
+**Thread the mode through `InputActions.submit(mode)`.** Widening the public provide-channel face would let any session-scope slot pick a delivery mode, which no other consumer needs, and would move a composer presentation decision into the machine's public contract. The package-private `ComposerKeyboard.submit(mode)` already exists for exactly this purpose, so the button uses it.
+
+**Keep `resolveSubmitMode` as a closure on the inject face and add a separate `busyEnter` hook only for the label.** Two sources for one fact invite drift between what the label says and what the click does. Publishing the preference once and resolving it in the bar keeps label and delivery derived from the same value in the same render.
+
+## Consequences
+
+The setting governs every busy-state submission a user can trigger with a message, and the button announces which delivery it performs, so choosing Steer no longer produces a Queue row from the button beside the draft. Users who relied on the button as an always-Queue escape while their setting selected Steer now use Cmd/Ctrl+Enter for that. The running Send label changes for every user, including under the default Queue preference, which the Web e2e scenarios that click Send during a running turn account for; idle-session flows and one-shot subagent composers see no change. The archived running-draft note's clause that the pointer action ignores the preference is reversed here; its primary-seat, owner-block, and subagent-control decisions stand as shipped and are described by the `ui-conversation` README.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 繁忙态 Send 按钮跟随 Enter 设置
+
+Status: implemented
+
+[English](2026-09-04-busy-send-button-follows-enter-setting.md) | 中文
+
+## 问题
+
+Web composer 为 agent(智能体)运行期间的提交只提供一个面向用户的选择:`ui-conversation.busyEnter` 设置在 Queue 与 Steer 之间选择。[运行中草稿取得主 Send 操作](../../archived/bug-fix/2026-08-20-running-draft-primary-send.md)(已归档)为运行中的草稿提供了指针 Send 按钮,有意让它不跟随该偏好,以避免一个只标注为 Send 的按钮携带不可见模式,并把每次点击都路由到公共的 `InputActions.submit()` 接口,而 `SessionInputShell.actions` 把该接口固定为 `'queue'`。用户在设置中选择 Steer 后,Enter 得到 Steer,同一草稿旁的按钮却得到 Queue,且按钮只标注为"发送消息"。composer 中没有任何内容解释这一分歧,设置行的标题和描述也只提到 Enter 键,因此该设置看起来像是失效,而不是有意只覆盖一部分。
+
+## 决策
+
+运行中的 Send 按钮按与 plain Enter 相同的模式投递。`InputBar` 每次渲染计算一次 `resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)`,其中 `steeringAvailable` 与键盘路径使用同一个"普通 Session 或可继续 child"判定;用它通过 `ComposerKeyboard.submit(mode)` 执行主按钮点击,并且仅在点击会投递一条普通消息时用它决定主按钮标签:composer 运行中且可 steering、按钮可用(没有仍在上传的文件)、草稿非空、未被认领且不是将进入命令 adjudication 的 `/` 行。该状态把 `input.send.queue`("Queue message" / "排队发送")或 `input.send.steer`("Steer message" / "插话发送")同时用作 tooltip 与可访问名称;该位置仍为 Send 按钮的其余所有状态——空闲会话、one-shot child、锁定的 composer、可继续 child 的空草稿、带待上传附件的草稿,以及点击会执行命令而非投递消息的命令草稿——保留 `input.send`("Send message");普通运行中会话在空草稿或 owner block 时该位置显示的是 Stop。Cmd/Ctrl+Enter 仍解析为相反模式,空草稿下的加速手势仍对整个队列执行 steering(中途引导)。[可继续 subagent 中断 Agent Note](../feature/2026-08-06-continuable-subagent-interrupt.zh.md)以此投递方式描述 child 的 Send。
+
+composer bar 的 inject 接口携带实时偏好,而不是解析闭包。`ComposerBarInjected.hooks.busyEnter` 发布 `ComposerSubmissionPolicy.busyEnter`,因此 bar 获得 `useBusyEnter` 选择器 hook,并在设置行或 Host 设置更新改变该值时重新渲染标签。`resolveSubmitMode` 是 `submission-policy.ts` 中导出的纯函数,显式接收偏好值;policy 类只保留 store 及其 Host 采纳与写回。
+
+设置行重新命名以覆盖两种输入:"Send behavior while busy" / "繁忙时的发送行为",描述为 agent 运行时 Enter 与 Send 按钮的行为,并保留 Cmd/Ctrl+Enter 使用相反模式的说明。`busyEnter` 字段名、其 `queue` 默认值和 Host schema 均未改变,因此现有 `settings.yaml` 文档保持原有含义。
+
+## 验证
+
+`input-bar.client.spec.tsx` 断言运行中草稿的按钮在两种偏好下都按模式标注并以该模式提交,切换偏好 store 会在下一次点击前重新标注已挂载的按钮,空闲 Send 无论偏好如何都保留普通标签与 Queue 投递,可继续 subagent 的 Send 与普通 Session 遵循同一模式与标签,而其空草稿下的禁用按钮与 one-shot child 保留普通 Send,运行中的 `/` 行、已认领命令与带仍在上传文件的草稿也保留普通 Send。`submission-policy.client.spec.ts` 钉住 `resolveSubmitMode` 在偏好、运行状态、手势与 steering 可用性所有组合下的结果。`enter-behavior-row.client.spec.tsx` 与 `settings-chrome` ARIA golden 携带新的设置文案。无密钥的 `live-interactions` Web 场景在停住的运行中草稿上等待"Queue message",并断言此刻不存在"Send message"按钮,其 `running-draft.expected.md` golden 记录了新名称。
+
+## 备选方案
+
+**保持按钮使用 Queue,只改写设置行文案。** 这保留了先前决策,但让同一设置下的同一草稿拥有两条提交路径。偏好 Steer 的用户仍无法通过指针得到它,而改写后的设置行必须记录一种其他 composer 控件都不具备的仅键盘生效范围。
+
+**增加第二个运行中按钮,每种模式一个。** 两种投递模式都可以通过指针到达且没有隐藏状态,但普通会话只有一个主操作位置,且已在 Stop 与 Send 之间交替;永久增加第二个控件会占用空间,并引入草稿本身不需要的层级。单一设置已经表达了用户默认值,Cmd/Ctrl+Enter 仍是逐条消息的覆盖手段。
+
+**通过 `InputActions.submit(mode)` 传递模式。** 拓宽公共 provide 通道接口会让任何 session 作用域的 slot 都能选择投递模式,而没有其他消费者需要它,并且会把 composer 的呈现决策推入机器的公共契约。包内私有的 `ComposerKeyboard.submit(mode)` 正是为此存在,因此按钮直接使用它。
+
+**在 inject 接口上保留 `resolveSubmitMode` 闭包,仅为标签另加一个 `busyEnter` hook。** 同一事实有两个来源,会让标签所说与点击所做之间产生偏差。只发布一次偏好并在 bar 中解析,可以让标签与投递在同一次渲染中源自同一个值。
+
+## 影响
+
+该设置约束用户能以消息触发的每一种繁忙态提交,且按钮会声明它执行哪种投递,因此选择 Steer 后不再会从草稿旁的按钮产生 Queue 行。此前在设置为 Steer 时依赖按钮作为始终 Queue 逃生口的用户,现在改用 Cmd/Ctrl+Enter。运行中的 Send 标签对每位用户都会变化,包括默认的 Queue 偏好下,运行中点击 Send 的 Web e2e 场景已相应处理;空闲会话流程和 one-shot subagent composer 没有变化。已归档的运行中草稿 Agent Note 中"指针操作忽略偏好"的条款在此被反转;其主操作位置、owner block 与 subagent 控件决策按已交付状态继续有效,并由 `ui-conversation` README 描述。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-16-harness-level-loop.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-16-harness-level-loop.md
-2026-07-16-harness-level-loop.md: 90f9b9d9d78bab620a0150d6e480485e37cb762f
-2026-07-16-harness-level-loop.zh.md: c1709f8743e6fecbf576bc540cbba934e8bb23bd
+2026-07-16-harness-level-loop.md: 43b8f867ae9af92f22fdae0cecef37803b49be30
+2026-07-16-harness-level-loop.zh.md: 40af80d95127974bcbed4f7114dec048e0dbdc57

+ 2 - 2
.agents/notes/implemented/feature/2026-07-16-harness-level-loop.md

@@ -50,7 +50,7 @@ One session has at most one current goal. Every mutation commits through a durab
 
 Durable phases are only `active`, `paused`, `blocked`, and `complete`. A blocked goal carries a required `GoalBlockReason` with a stable lower-kebab-case `code` and a non-empty human-readable `message`; usage limits, round exhaustion, model failures, and policy rejection are reason codes rather than extra lifecycle phases. Separate activation is `armed` or `disarmed` and is never persisted. Creation and explicit resume arm a goal; stop transitions, session start, fork replay, driver replacement, and driver teardown leave it disarmed.
 
-This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read the goal and call `update_goal(..., action: 'resume')`. `/goal resume` is the direct human-command path. The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption.
+This separation makes session restoration observable and unsurprising. Reopening a session never starts goal work by itself. A later human prompt such as “continue”, “resume the goal”, or an equivalent request in any language gives the runtime-root model a new turn in which it may read an active-but-disarmed goal and call `update_goal(..., action: 'resume')`. A durable paused goal is resumed through `/goal resume`, the Web control, or another direct goal-service caller; the model tool rejects it under the [user-owned pause decision](../bug-fix/2026-09-03-user-owned-goal-pause-activation.md). The runtime authenticates that the request came from a live direct-human turn; prompt policy lets the model interpret whether the wording semantically authorizes creation or resumption.
 
 Forked sessions inherit the durable goal prefix because that is the natural replay result. The fork starts disarmed, so inheritance does not imply execution authority and no synthetic goal cancellation is inserted into history.
 
@@ -62,7 +62,7 @@ The goal-round driver owns at most one pending reservation per exact live agent.
 
 Only an admitted positive-round goal-sourced `user/message` charges a round. A stale reservation closes a blocked no-step turn without consuming the cap. A concurrent goal revision wins over settlement from an older round.
 
-Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting or quota exhaustion blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. An independently composed request-recovery plugin may retry transient provider failures within that same turn; the goal driver never invents another round after an abnormal terminal outcome. A human can later authorize resume through ordinary language or `/goal resume`.
+Normal turn completion schedules another round only while the goal remains active, armed, and below its cap. Cancellation pauses. Rate limiting or quota exhaustion blocks with code `usage-limited`; cap exhaustion blocks with `round-limit`; queue failure uses `queue-failed`; turn errors, max-token stops, policy rejection, and unknown terminal results use their corresponding blocker codes. An independently composed request-recovery plugin may retry transient provider failures within that same turn; the goal driver never invents another round after an abnormal terminal outcome. A human can later resume through `/goal resume` or the Web control; a blocked goal also remains eligible for model `update_goal resume`, while a durable paused goal does not.
 
 ### Human and model interactions
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-16-harness-level-loop.zh.md

@@ -50,7 +50,7 @@ Status: implemented
 
 持久阶段只有 `active`、`paused`、`blocked` 与 `complete`。阻塞目标必须携带 `GoalBlockReason`,其中包含稳定的小写 kebab-case `code` 与非空的人类可读 `message`;用量限制、Round 耗尽、模型失败与策略拒绝都是原因代码,而不是额外生命周期阶段。独立激活态是 `armed` 或 `disarmed`,且永不持久化。创建与显式恢复会激活目标;停止转换、会话启动、fork 回放、驱动器替换和驱动器拆卸都会让目标保持未激活。
 
-这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如「继续」、「恢复目标」或任何语言中的等价请求,会给运行时根 agent 的模型一个新轮次;模型可在其中读取目标并调用 `update_goal(..., action: 'resume')`。`/goal resume` 是直接人类命令路径。运行时认证请求来自实时直接人类轮次;提示策略让模型解释措辞在语义上是否授权创建或恢复。
+这种分离让会话恢复可观察且符合直觉。重新打开会话绝不会自行开始目标工作。随后的人类提示词,例如「继续」、「恢复目标」或任何语言中的等价请求,会给运行时根 agent 的模型一个新轮次;模型可在其中读取 active-but-disarmed 目标并调用 `update_goal(..., action: 'resume')`。持久的 paused 目标通过 `/goal resume`、Web 控件或其他直接调用 goal 服务的调用方恢复;模型工具依据[用户独占暂停决策](../bug-fix/2026-09-03-user-owned-goal-pause-activation.zh.md)拒绝它。运行时认证请求来自实时直接人类轮次;提示策略让模型解释措辞在语义上是否授权创建或恢复。
 
 fork 会话会继承持久目标前缀,因为这是自然的重放结果。fork 从未激活状态开始,因此继承不等于执行权限,历史中也不会插入合成目标取消。
 
@@ -62,7 +62,7 @@ Goal Round 驱动器为每个特定的实时 agent 至多拥有一个待定预
 
 只有已接纳、Round 为正数且带目标来源的 `user/message` 会计入一个 Round。陈旧预留会结束一个阻塞的零步骤轮次,不会消耗上限。并发目标修订会胜过旧 Round 的结算。
 
-普通轮次完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制或配额耗尽以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;轮次错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。独立组合的请求恢复插件可以在同一个轮次内重试暂时性提供方失败;目标驱动器绝不会在异常终止结果后凭空发起另一个 Round。人类随后可以通过普通语言或 `/goal resume` 授权恢复
+普通轮次完成后,只有目标仍活跃、已激活且低于上限时才会安排另一个 Round。取消会暂停。速率限制或配额耗尽以代码 `usage-limited` 阻塞;上限耗尽使用 `round-limit`;队列失败使用 `queue-failed`;轮次错误、max-token 停止、策略拒绝与未知终止结果使用各自对应的阻塞代码。独立组合的请求恢复插件可以在同一个轮次内重试暂时性提供方失败;目标驱动器绝不会在异常终止结果后凭空发起另一个 Round。人类随后可以通过 `/goal resume` 或 Web 控件恢复;blocked 目标也仍可由模型 `update_goal resume` 恢复,而持久 paused 目标不能
 
 ### 人类与模型交互
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.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-19-model-facing-goal-tools.md
-2026-07-19-model-facing-goal-tools.md: 67668652adfa29d0a702f363cd9b12367411382f
-2026-07-19-model-facing-goal-tools.zh.md: 1db8d53393146a333738ad0248aba5ccf968a566
+2026-07-19-model-facing-goal-tools.md: 2c05c8aa0ee2caecb0264fa87984085b3df5d785
+2026-07-19-model-facing-goal-tools.zh.md: f16055e52e4a3d6a4db423699a36929f6f8c4cd2

+ 5 - 4
.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md

@@ -16,9 +16,9 @@ The tool API also needs to preserve the separation between durable state and liv
 
 ### Tools and model contract
 
-`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
+`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. A durable paused goal rejects `resume` with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
 
-The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
+The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. The prompt does not announce the durable paused boundary; execution rejects that attempt with `GOAL_TOOL_RESUME_PAUSED`, and the user-facing resume path owns the transition. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
 
 All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state.
 
@@ -38,7 +38,7 @@ Complete and blocked accept either direct-human authority or the exact current g
 
 ## Testing
 
-Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/partial-edit/pause/resume behavior including strict-schema fillers, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
+Unit coverage pins registration and disposal, exclusive scheduling, generated prompt policy, filler-safe generic presentation, direct-human creation in a non-English turn, exact/stale/non-running agent and driver checks, live-child rejection, resumed-fork root authority, steering, mismatched initiators, read/create/partial-edit/pause behavior including strict-schema fillers, durable-paused resume rejection, conditional blocker explanations, rearming after a session-start edge, authority-before-conditional-argument failures, exact goal-round completion, autonomous-only terminal stopping, the configured blocking threshold, and immediate human blocking. A keyless replay snapshot mounts the goal domain and tools into the real headless one-shot application, drives a strict-filler `update_goal` probe plus `create_goal` and `get_goal` through the shipped loop and persistence stack, pins its stream-json transcript, and inspects the externally persisted goal change. The echo-agent fixture is intentionally not used as an application-UX surrogate.
 
 ## Alternatives considered
 
@@ -54,7 +54,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
 
 - Models receive a stable, compact lifecycle API without direct access to the goal service.
 - State-changing calls require a live runtime-root agent and a direct human message in the current turn, as well as durable compare-and-set references.
-- Human requests can create and rearm goals through ordinary natural language, while restored sessions remain inert until such input arrives.
+- Human requests can create goals and rearm restored or blocked goals through ordinary natural language; a durable paused goal requires the user-facing resume path.
 - Goal rounds can finish or report a repeated blocker but cannot broaden their own mandate.
 - Deployment policy selects the blocking lower bound; the same resolved value controls enforcement and prompt guidance.
 - Strict-schema provider fillers interoperate without allowing meaningful cross-action updates.
@@ -62,6 +62,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
 ## Known limitations and deferred work
 
 - Semantic classification of a substantial goal, a request to continue, objective completion, and the same blocking condition remains model judgment. An independent evaluator or completion certificate is deferred.
+- The model cannot resume a durable paused goal; that user-owned path is enforced by the separate [user-owned goal pause decision](../bug-fix/2026-09-03-user-owned-goal-pause-activation.md).
 - These tools mutate goal state but do not schedule goal rounds, classify abnormal driver stops, or cancel an active turn; the same-session driver owns those behaviors.
 - Goal-round authority is dormant unless a separately mounted continuation driver admits goal-sourced user turns; this tool package never manufactures that authority itself.
 - Human slash-command discovery and rendering are owned by the separate [`dsh-command-goal`](../../../../packages/goal/command-goal/README.md) plugin.

+ 5 - 4
.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md

@@ -16,9 +16,9 @@ Status: implemented
 
 ### 工具与模型约定
 
-`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
+`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。持久 paused goal 会以 `GOAL_TOOL_RESUME_PAUSED` 拒绝 `resume`;面向用户的命令或 Web 控件拥有该转换。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
 
-提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或 fork 后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
+提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或 fork 后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。提示词不会静态声明持久 paused 的边界;执行时以 `GOAL_TOOL_RESUME_PAUSED` 拒绝该尝试,面向用户的恢复路径拥有该转换。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
 
 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。
 
@@ -38,7 +38,7 @@ Status: implemented
 
 ## 测试
 
-单元测试固定注册与 dispose(资源释放)、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后 fork 根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停/恢复行为(包括严格 schema 占位值)、条件式阻塞说明、会话启动边沿后的重新激活、权限检查先于条件参数检查的失败行为、准确 Goal Round 的完成、仅自主 Round 触发终止、已配置的阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json transcript(文本记录),并检查外部持久化的目标变更。这里有意不把 echo-agent fixture(测试前置数据)当作应用 UX 的替代品。
+单元测试固定注册与 dispose(资源释放)、独占调度、生成的提示词策略、可安全处理占位值的通用展示、非英语轮次中的直接人类创建、精确/陈旧/非运行中智能体与驱动检查、实时子智能体拒绝、恢复后 fork 根的权限、steering、发起者不匹配、读取/创建/部分字段编辑/暂停行为(包括严格 schema 占位值)、持久 paused 的 resume 拒绝、条件式阻塞说明、会话启动边沿后的重新激活、权限检查先于条件参数检查的失败行为、准确 Goal Round 的完成、仅自主 Round 触发终止、已配置的阻塞阈值,以及人类立即阻塞。无密钥回放快照把目标领域和工具挂载到真实的 headless 单次运行应用中,通过随附循环与持久化栈驱动一次携带严格 schema 占位值的 `update_goal` 探测,以及对 `create_goal` 和 `get_goal` 的调用,固定 stream-json transcript(文本记录),并检查外部持久化的目标变更。这里有意不把 echo-agent fixture(测试前置数据)当作应用 UX 的替代品。
 
 ## 考虑过的替代方案
 
@@ -54,7 +54,7 @@ Status: implemented
 
 - 模型获得稳定而紧凑的生命周期 API,无需直接访问目标服务。
 - 改变状态的调用要求实时运行时根 agent、当前轮次中人类直接发送的消息,以及持久比较并交换引用。
-- 人类可以通过普通自然语言请求创建和重新激活目标,而恢复后的会话在收到此类输入前保持静止
+- 人类可以通过普通自然语言请求创建目标,并重新激活已恢复或 blocked 的目标;持久 paused goal 需要面向用户的恢复路径
 - Goal Round 可以完成或报告重复阻塞,但不能自行扩大任务权限。
 - 部署策略选择阻塞下限;同一个解析后的值同时控制执行与提示词指导。
 - 系统可兼容采用严格 schema 的提供方所填入的占位值,同时不会放行有实际意义的跨操作更新。
@@ -62,6 +62,7 @@ Status: implemented
 ## 已知限制与暂缓事项
 
 - 是否属于重大目标、是否要求继续、目标是否完成以及阻塞条件是否相同,仍由模型进行语义分类。独立评估器或完成证书予以延期。
+- 模型不能恢复持久 paused goal;该用户独占路径由独立的[用户独占 goal 暂停决策](../bug-fix/2026-09-03-user-owned-goal-pause-activation.zh.md)强制执行。
 - 这些工具会改变目标状态,但不调度 Goal Round、不分类异常驱动停止,也不取消活跃轮次;这些行为由同会话驱动器负责。
 - 除非另行挂载的继续执行驱动器接纳了目标来源的用户轮次,否则 Goal Round 权限路径处于休眠状态;本工具包本身不会制造这种权限。
 - 面向人类的斜杠命令发现与渲染由独立的 [`dsh-command-goal`](../../../../packages/goal/command-goal/README.zh.md) 插件负责。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
-2026-08-06-continuable-subagent-interrupt.md: c59601fd486d40c3a9367f2af132fb30207c35c9
-2026-08-06-continuable-subagent-interrupt.zh.md: b56b94e30fc544e77c0be99dcb759e56e47ef5d4
+2026-08-06-continuable-subagent-interrupt.md: e865415bbba4ea196afd7d075a079412f18e5a1e
+2026-08-06-continuable-subagent-interrupt.zh.md: ed4981bdac12a5c8b09b2e8dbc6363817e4d7dea

+ 1 - 1
.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md

@@ -39,7 +39,7 @@ A human or ancestor can stop a runaway continuable turn without losing the child
 
 The address-only RPC exposes one bit of live residency: an absent target is accepted while a live target under a mismatched parent returns `subagent-unauthorized`. The single-user local Host trust model accepts that observability; a future multi-principal Host must revisit both authority and response indistinguishability.
 
-The Web surface keeps Send and Stop as independent actions for a running continuable child: the client `Session.cancel()` routes Stop through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep their existing primary Send/Stop toggle through `session.cancel`), while Send continues to queue follow-ups. A running parent-offline continuable child keeps the default composer with input and Send disabled but Stop reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract).
+The Web surface keeps Send and Stop as independent actions for a running continuable child: the client `Session.cancel()` routes Stop through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep their existing primary Send/Stop toggle through `session.cancel`), while Send delivers follow-ups through the busy-Enter setting's mode — Queue by default, Steer when selected — exactly as plain Enter does ([busy Send button](../bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). A running parent-offline continuable child keeps the default composer with input and Send disabled but Stop reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract).
 
 The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` passes `exec.agent` as the `ancestor` authority and adds none of its own: the core primitive verifies live registry identity and recorded lineage, so the tool can name a direct child or a deeper descendant with the same generic `agent_id` parameter — deliberately not `subagent_id`, which would imply direct children only. Discovery rides `list_agents({ scope: 'descendants' })` over the new `SubagentRuntime.listDescendants()` one-trace pre-order walk with verified `parentId`/`depth` per entry ([durable catalog note](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) owns the listing contract); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md

@@ -39,7 +39,7 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回
 
 仅凭地址的 RPC 会暴露一项关于在线驻留状态的二值信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。
 
-在 Web 侧,正在运行的 continuable child 使用相互独立的 Send 与 Stop 操作:客户端 `Session.cancel()` 将 Stop 路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍通过 `session.cancel` 保留既有的 primary Send/Stop 切换),同时 Send 继续将后续消息加入队列。parent 离线但仍在运行的 continuable child 保留默认 composer,禁用输入区与 Send,但 Stop 仍然可达;停止后恢复为只读接管界面(周边目录与 composer 约定由 [Web subagent 对话](2026-07-27-web-subagent-conversations.zh.md)拥有)。
+在 Web 侧,正在运行的 continuable child 使用相互独立的 Send 与 Stop 操作:客户端 `Session.cancel()` 将 Stop 路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍通过 `session.cancel` 保留既有的 primary Send/Stop 切换),同时 Send 按繁忙态 Enter 设置的模式投递后续消息——默认 Queue,选择后为 Steer——与 plain Enter 完全一致([繁忙态 Send 按钮](../bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。parent 离线但仍在运行的 continuable child 保留默认 composer,禁用输入区与 Send,但 Stop 仍然可达;停止后恢复为只读接管界面(周边目录与 composer 约定由 [Web subagent 对话](2026-07-27-web-subagent-conversations.zh.md)拥有)。
 
 `dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是新的 `SubagentRuntime.listDescendants()` 单次追踪 pre-order 遍历,每个条目带经校验的 `parentId`/`depth`(列表约定由[持久化目录 note](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md
-2026-08-25-promote-open-anywhere-plugin.md: ee83c424d1454b26c1ce6cf6954105cdbfbb7419
-2026-08-25-promote-open-anywhere-plugin.zh.md: f1696cec10a683d44dcaa3db454d343821fc13c9
+2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f
+2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96

+ 6 - 2
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md

@@ -12,6 +12,8 @@ The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh
 
 The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination.
 
+The shared `launchedThroughSsh()` predicate in [launch-environment](../../../../packages/util/launch-environment/README.md) reads non-empty `SSH_CONNECTION` or `SSH_TTY` only from the inherited process layer. An SSH launch produces an empty application catalog before any probe. Project and user `.env` values cannot establish an SSH launch; Web browser handoff and the adaptive directory picker use the same predicate. The client hides the action even when it remembers a choice, and the existing availability checks reject icon and launch requests. SSH port forwarding changes HTTP reachability, not which machine owns the workspace or applications.
+
 The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring:
 
 - **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/<id>`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal.
@@ -28,6 +30,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t
 
 ## Alternatives considered
 
+**Offer VS Code's remote CLI during SSH sessions.** Its installed executable does not prove a usable editor connection: the inherited IPC socket belongs to a live VS Code connection and can disappear while Harness keeps running. Browser-side SSH-target configuration and local editor handoff remain outside this host-application feature.
+
 **Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have.
 
 **A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern.
@@ -50,8 +54,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t
 
 ## Consequences
 
-- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
+- Outside SSH sessions, the Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null).
 - The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls.
 - Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh.
 - The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon.
-- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin.
+- Resolver, icon, route, controller, and component tests cover platform discovery, launch outcomes, the availability cache, and HMR disposal. The [SSH Web snapshot](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) renders the shared recorded conversation with both Open In rows enabled and a remembered app choice, capturing only the Session header; composer and statistics output belong to their own snapshots. Inherited SSH markers make the empty catalog deterministic across platforms. Ordinary Web snapshots keep host-dependent application discovery disabled.

+ 6 - 2
.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。
 
+[launch-environment](../../../../packages/util/launch-environment/README.zh.md) 中共用的 `launchedThroughSsh()` 只从继承的进程层读取非空 `SSH_CONNECTION` 或 `SSH_TTY`。SSH 启动时会在任何探测开始前返回空应用目录。项目与用户 `.env` 中的值不能作为 SSH 启动的依据;Web 浏览器唤起和自适应目录选择器共用此判断。即使客户端记住了应用选择,也会隐藏操作入口;已有的可用性检查会拒绝图标和启动请求。SSH 端口转发只改变 HTTP 可达性,不改变工作区或应用所属的机器。
+
 该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app` 与 `ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring:
 
 - **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**:host 包的 `src/index.ts` 在 `ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps`、`GET /open-in-app/icon/<id>`、`POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。
@@ -28,6 +30,8 @@ Status: implemented
 
 ## 考虑过的替代方案
 
+**在 SSH 会话中提供 VS Code 的远端 CLI。** 已安装的可执行文件不能证明编辑器连接可用:继承的 IPC socket 属于一个仍在运行的 VS Code 连接,Harness 继续运行时它也可能消失。浏览器侧的 SSH 目标配置与本地编辑器唤起不属于这个主机应用功能。
+
 **将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。
 
 **用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。
@@ -50,8 +54,8 @@ Status: implemented
 
 ## 后果
 
-- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
+- 非 SSH 会话中,只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。
 - 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。
 - 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。
 - 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。
-- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app` 与 `ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住
+- 解析器、图标、路由、控制器与组件测试覆盖平台探测、启动结果、可用性缓存和 HMR 处置。[SSH Web 快照](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) 在启用两个 Open In 配置项并记住应用选择的条件下渲染共享的录制会话,并仅捕获会话头部;输入框和统计栏由各自的快照负责。继承的 SSH 标记使空应用目录在不同平台上保持确定。普通 Web 快照仍禁用依赖主机的应用探测

+ 6 - 0
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
+2026-09-07-composer-session-stats-pills.md: 818c3c156f72816eb6540d505782be8e03dc6aa0
+2026-09-07-composer-session-stats-pills.zh.md: 8a74cddd3176026d799fb5b4223a51371ee8e253

+ 31 - 0
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md

@@ -0,0 +1,31 @@
+# Agent Note: Composer session stats — two icon pills with click-open stat dialogs
+
+Status: implemented
+
+English | [中文](2026-09-07-composer-session-stats-pills.zh.md)
+
+## Problem
+
+The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `conversation.composer.dock`) rendered every figure as one resident text line: turn/step counts, LLM and tool wall times, TTFT/TPS averages, and compact token totals with cache-hit share. The line crowded as figures accumulated, exact token counts appeared nowhere (the `ResizeObserver`-measured hover tooltip only restated the same compact line when it truncated), and the flat text gave no grouping — time figures and billing figures read as one undifferentiated row. An in-page A/B against a two-pill variant settled the direction: the pills won on scannability and on giving each figure family a home.
+
+## Decision
+
+`StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
+
+- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, cache write, output — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually.
+- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it.
+- **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
+- **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent.
+
+## Alternatives considered
+
+- **The single-line variant (StatsLine, the A/B loser).** All figures resident in one text row, with a hover tooltip restating the full line when it truncated. Lost on crowding and reach: exact token counts appeared nowhere (the line and its tooltip both carried compact totals only), and one row gave time and billing figures no visual grouping.
+- **Three resident groups with one shared dialog.** An intermediate iteration kept counts, time, and tokens as three inline groups. Two pills won because the time/usage split matches the two underlying projections one-to-one and each pill's icon telegraphs its dialog.
+- **Extracting the dl bucket rows shared with `TurnUsagePanel`.** The session-total dialog and the per-turn panel render the same skin but different contracts (all buckets always present vs optional per-turn fields plus model routes); a shared component would be conditionals around nine lines. The mirror is marked `jscpd:ignore` with the reason inline.
+
+## Consequences
+
+- `ChatSnapshotBuilder`'s legacy slice now serves StatsPills; the [node-assembly note](../architecture/2026-08-09-client-conversation-node-assembly.md) tracks that consumer rename.
+- Exact token counts become reachable at all — one click — where `StatsLine` showed only compact totals; the strip itself carries only the two headline readings.
+- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin the two-pill structure, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
+- The `conversation.composer.dock` occupant in the generated slot catalog is `client-ui-chat StatsPills id 'stats'`.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 输入框下的会话统计 —— 双图标 pill 与点击展开的统计弹层
+
+Status: implemented
+
+[English](2026-09-07-composer-session-stats-pills.md) | 中文
+
+## 问题
+
+输入框下方的会话统计条(`StatsLine`,ui-chat,挂载于 `conversation.composer.dock`)把所有数字渲染成一整行常驻文本:轮/步计数、模型与工具用时、TTFT/TPS 均值、紧凑 token 总量与缓存命中率。数字越多行越拥挤,精确 token 计数无处可看(带 `ResizeObserver` 溢出测量的悬停提示只在截断时复述同一行紧凑文本),纯文本也没有分组——时间类数字和计费类数字读起来混作一行。一次页面内 A/B 对比双 pill 变体后方向定案:pill 方案在可扫读性和"每类数字有归属"上胜出。
+
+## 决定
+
+`StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
+
+- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、缓存写入、输出——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。
+- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它。
+- **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
+- **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。
+
+## 备选方案
+
+- **单行变体(StatsLine,A/B 落选方)。** 全部数字常驻一行文本,悬停提示只在截断时复述整行。败在拥挤与可达性:精确 token 计数无处可看(行内和提示里都只有紧凑总量),单行也无法给时间与计费数字分组。
+- **三个常驻分组共享一个弹层。** 中间迭代曾把计数、时间、token 保持为三个内联分组。双 pill 胜出是因为时间/用量的切分与两个底层投影一一对应,且每个 pill 的图标直接预告其弹层内容。
+- **抽出与 `TurnUsagePanel` 共享的 dl 分桶行。** 会话总量弹层与逐轮面板皮肤相同但契约不同(此处所有分桶恒在;逐轮字段可选且含模型路由);共享组件只是给九行代码包一层条件。该镜像以 `jscpd:ignore` 标注并内联说明理由。
+
+## 后果
+
+- `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。
+- 精确 token 计数第一次变得可达——一次点击即可;`StatsLine` 只展示过紧凑总量。统计条本身只承载两个头条读数。
+- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住双 pill 结构,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。
+- 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。

+ 6 - 0
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml

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

+ 41 - 0
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md

@@ -0,0 +1,41 @@
+# Agent Note: Exclude documentation and comment-only changes from review routing
+
+Status: implemented
+
+English | [中文](2026-09-08-comment-only-review-routing.zh.md)
+
+## Problem
+
+Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes.
+
+GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks.
+
+## Decision
+
+Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule.
+
+Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical.
+
+The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change.
+
+The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes.
+
+## Verification
+
+[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded.
+
+## Alternatives considered
+
+**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes.
+
+**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal.
+
+**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict.
+
+**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches.
+
+## Consequences
+
+Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file.
+
+Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior.

+ 41 - 0
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: 从评审路由中排除文档和纯注释变更
+
+Status: implemented
+
+[English](2026-09-08-comment-only-review-routing.md) | 中文
+
+## 问题
+
+只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。
+
+GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。
+
+## 决策
+
+评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。
+
+纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。
+
+扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。
+
+受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。
+
+## 验证
+
+[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。
+
+## 考虑过的替代方案
+
+**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。
+
+**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。
+
+**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。
+
+**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。
+
+## 后果
+
+文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。
+
+不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。

+ 6 - 0
.agents/notes/implemented/process/2026-09-08-playwright-video-gif.i18n.yaml

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

+ 29 - 0
.agents/notes/implemented/process/2026-09-08-playwright-video-gif.md

@@ -0,0 +1,29 @@
+# Agent Note: Playwright video captures continuous browser demos
+
+Status: implemented
+
+English | [中文](2026-09-08-playwright-video-gif.zh.md)
+
+## Problem
+
+A screenshot storyboard omits intermediate animation frames and can miss short-lived progress indicators. Increasing the encoded GIF frame rate cannot recover motion absent from its source images. Continuous capture supplies those frames while the available browser-control workflow handles interaction.
+
+## Decision
+
+The [recording skill](../../../skills/record-browser-gif/SKILL.md) keeps the available browser-control workflow preferred. When that workflow exposes `recordVideo`, video captures intermediate frames in the same controlled context; otherwise the workflow captures screenshots. Standalone repository-declared Playwright remains the fallback when browser control is unavailable. For video, viewport and recording dimensions match explicitly, avoiding Playwright's default scaling to fit 800×800. The recorder retains the page video, awaits context closure, and saves the completed WebM before encoding.
+
+One encoder accepts either a video file or a screenshot directory. Video input selects one continuous interval, applies a declared playback multiplier, and extends its final frame. The JSON summary records source duration, selected interval, speed, final hold, and encoded dimensions, duration, frame count, and size. Mode-inappropriate options and invalid intervals fail. The original video remains available for review; trimming and speed never establish model response latency.
+
+The [evidence-chain decision](2026-08-08-browser-gif-evidence-chain.md) owns browser-control selection, isolated application state, real model execution, exact commit attribution, and verified publication. Video adds a higher-cadence capture option within those rules. Failed recordings cannot contribute frames to a successful run.
+
+## Alternatives considered
+
+**Increase only the encoded frame rate.** Repeating sparse screenshots does not capture additional motion. Screenshots remain useful for explicit state holds, requested storyboards, and browser workflows without video support.
+
+**Install a separate recorder or capture the desktop.** The repository already declares Playwright. Another driver adds setup and version management; desktop capture can include unrelated windows and personal state.
+
+## Consequences
+
+Continuous recording preserves intermediate states, so reviewers must inspect the selected interval for sensitive content and readability. Raw video consumes additional scratch storage, and encoding may require trimming or scaling to meet the byte limit. Context closure is part of successful recording, not optional cleanup.
+
+The encoder's local Python unittest suite invokes real ffmpeg and ffprobe to check timing, palette order, screenshot holds, rejected options, overwrite protection, and size limits. It requires the skill's media prerequisites and is run explicitly; repository CI does not provision these media binaries. Product demonstrations additionally exercise the pull request's built server and real model flow.

+ 29 - 0
.agents/notes/implemented/process/2026-09-08-playwright-video-gif.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Playwright 视频录制连续浏览器演示
+
+Status: implemented
+
+[English](2026-09-08-playwright-video-gif.md) | 中文
+
+## 问题
+
+截图分镜省略了动画的中间帧,也可能遗漏短暂的进度指示。提高 GIF 编码帧率无法补回源图片未记录的运动。连续录制提供这些画面,同时由可用的浏览器控制工作流负责交互。
+
+## 决策
+
+[录制 skill](../../../skills/record-browser-gif/SKILL.md) 继续优先使用可用的浏览器控制工作流。该工作流提供 `recordVideo` 时,在同一受控上下文中用视频捕获中间帧;否则由该工作流截图。只有浏览器控制不可用时,才使用仓库已声明的独立 Playwright 作为备用方案。录制视频时,显式设置相同的 viewport 与录制尺寸,避免 Playwright 默认缩放到 800×800 以内。录制程序保留页面视频对象,等待上下文关闭,再保存完整 WebM 并编码。
+
+同一编码器接受视频文件或截图目录。视频输入选择一个连续区间,应用明确标注的播放倍率,并延长末帧。JSON 摘要记录源视频时长、所选区间、倍率、末帧停留时间,以及编码后的尺寸、时长、帧数和大小。与输入模式不符的选项及无效区间会失败。保留原视频供检查;裁剪与变速不能证明模型响应延迟。
+
+[证据链决策](2026-08-08-browser-gif-evidence-chain.zh.md)规定浏览器控制方式选择、应用状态隔离、真实模型执行、准确提交归属及发布验证。视频在这些规则内增加更高采样帧率的录制选项。失败录制的画面不得用于成功运行。
+
+## 曾考虑的替代方案
+
+**只提高编码帧率。**重复少量截图不能捕获更多运动。截图仍适合明确的状态停留、用户要求的分镜,以及不支持视频的浏览器工作流。
+
+**安装独立录制器或录制桌面。**仓库已经声明 Playwright。另一套驱动增加安装与版本管理成本;桌面录制可能包含无关窗口和个人状态。
+
+## 后果
+
+连续录制保留中间状态,因此检查者必须查看所选区间是否包含敏感内容,以及画面是否易读。原始视频占用额外临时空间,编码时可能需要裁剪或缩放以满足字节上限。关闭上下文是成功录制的一部分,不是可选清理。
+
+编码器的本地 Python unittest 测试调用真实 ffmpeg 和 ffprobe,检查时序、调色板顺序、截图停留、选项拒绝、覆盖保护和大小限制。测试依赖 skill 要求的媒体工具,需显式运行;仓库 CI 不预装这些媒体程序。产品演示还会执行 PR 构建后的服务器与真实模型流程。

+ 6 - 0
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
+2026-09-08-trusted-changed-file-review-routing.md: 9f59f336fb1f8d2fdd1e13f6751258a47518b4dc
+2026-09-08-trusted-changed-file-review-routing.zh.md: e845c79051858c11053597516f486cf29fa2805a

+ 47 - 0
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md

@@ -0,0 +1,47 @@
+# Agent Note: Route reviews from trusted changed-file policy
+
+Status: implemented
+
+English | [中文](2026-09-08-trusted-changed-file-review-routing.zh.md)
+
+## Problem
+
+GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision.
+
+Review routing needs an observable changed-file input, explicit owner rules, complete test exclusions, and a write-capable workflow that remains safe for pull requests from forks.
+
+## Decision
+
+The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns with one or two individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, more than two owners, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches.
+
+The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase.
+
+The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets.
+
+The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them.
+
+The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions.
+
+The workflow prints the changed code paths, each exclusion class, per-file owner matches, and final reviewer action before any review-request mutation. For a non-draft pull request, it removes the pull-request author and users who are already requested from the login-sorted union of matched individual owners, then fills at most two current individual review-request slots. Existing individual requests consume slots even when they do not match the ownership map. Login order deterministically selects candidates when the available slots cannot cover the remaining union. The workflow does not remove requests from a non-draft pull request. For a draft, it reads the complete review-request timeline and cancels current requests whose latest requester is `github-actions[bot]`; requests made by people remain unchanged.
+
+## Verification
+
+[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
+
+## Alternatives considered
+
+**Use native CODEOWNERS.** Native routing cannot ignore test-only changes and offers no repository-owned decision log before requesting reviewers.
+
+**Run under `pull_request` and check out the pull-request head.** A fork workflow does not receive a write-capable token, while granting a write token to code from an untrusted head is unsafe.
+
+**Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners.
+
+**Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch.
+
+## Consequences
+
+Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
+
+The workflow requests every matched owner rather than choosing one owner nondeterministically. Shared ownership therefore produces at most two requests for each changed module. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
+
+Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file or timeline API limit fail without applying a partial reviewer mutation.

+ 47 - 0
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md

@@ -0,0 +1,47 @@
+# Agent Note: 基于受信任的变更文件策略路由评审
+
+Status: implemented
+
+[English](2026-09-08-trusted-changed-file-review-routing.md) | 中文
+
+## 问题
+
+只要匹配路径发生变更,GitHub 原生 CODEOWNERS 就会请求评审者。它无法应用本仓库对需评审的实现或文档文件与纯测试证据的区分。使用原生 CODEOWNERS 文件还会让 GitHub 负责请求决策,而不是由可检查的仓库程序负责。
+
+评审路由需要可观测的变更文件输入、显式 owner 规则、完整的测试排除规则,以及对 fork PR 仍然安全且具备写权限的 workflow。
+
+## 决策
+
+仓库在 GitHub 原生 CODEOWNERS 路径之外的 [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS) 中保存兼容 CODEOWNERS 格式的映射。该映射只接受显式绝对目录模式,每条模式配置一至两名 GitHub 个人用户。通配符、隐藏目录模式、团队、超过两名 owner、重复模式和重复 owner 都会被拒绝。靠后的匹配模式会替换靠前的匹配结果。
+
+策略测试会统计匹配所有权规则的目录中的非测试跟踪文件行数。如果 `@turtle1999` 拥有的有效代码库超过三分之一,测试就会拒绝该映射。
+
+[`request-review` workflow](../../../../.github/workflows/request-review.yml) 在 PR 创建、同步、重新打开、标记为可评审和转为草稿时运行 `pull_request_target` 事件。具备写权限的 job 检出默认分支,只执行默认分支上的扫描器和所有权映射。它不会检出 PR 代码,也不会读取仓库 secret。
+
+扫描器在决策之前获取所有变更文件记录。如果 PR 报告的文件数超过 GitHub API 的 3,000 个文件上限,或者分页只返回了部分列表,扫描器就会失败。它会规范化仓库路径,分别检查重命名前后的路径,并在记录文件名之前进行转义。
+
+扫描器会在匹配 owner 之前排除纯测试路径。排除范围包括名为 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 或 `stress-tests` 的目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,以 `.bench.<ext>`、`.corpus.<ext>`、`.e2e.<ext>`、`.perf.<ext>`、`.snapshot.<ext>`、`.spec.<ext>`、`.stress.<ext>` 或 `.test.<ext>` 结尾的文件名,以及 Python 的 `test_*.py`、`*_test.py` 或 `*_tests.py` 文件。`vitest*.config.ts` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)记录额外的文档和注释排除规则。
+
+Workflow 会在发出任何评审请求变更之前,依次打印变更代码路径、每类排除项、逐文件 owner 匹配结果和最终评审操作。对于非草稿 PR,它会从按登录名排序并合并的个人 owner 中排除 PR 作者和已经收到评审请求的用户,然后填充最多两个当前个人评审请求名额。现有个人请求即使不匹配所有权映射,也会占用名额。当可用名额无法覆盖剩余候选集合时,登录名顺序会确定性地选择候选人。Workflow 不会从非草稿 PR 移除请求。对于草稿,它会读取完整的评审请求时间线,并取消最近一次请求者为 `github-actions[bot]` 的当前请求;由人员发出的请求保持不变。
+
+## 验证
+
+[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖允许的所有权语法、拒绝的语法、每类排除项、生产文件名负向对照、重命名、最后匹配规则、未匹配文件、完整分页、两个 3,000 项上限、先记录后修改的顺序、作者与现有评审者过滤、草稿取消来源和 API 失败。[Workflow 测试](../../../../scripts/ci-workflow.spec.ts)固定事件集合、最小权限、受信任的默认分支检出、不引用 PR head 和 secret,以及执行的命令。门禁图在静态 CI 和 `check-all` 中包含这两组测试。
+
+## 考虑过的替代方案
+
+**使用原生 CODEOWNERS。** 原生路由无法忽略纯测试变更,也无法在请求评审者之前提供由仓库控制的决策日志。
+
+**在 `pull_request` 下运行并检出 PR head。** Fork workflow 无法获得具备写权限的 token,而向不受信任 head 中的代码授予写权限 token 并不安全。
+
+**在 `pull_request_target` 下执行 PR 中的扫描器或 owner 映射。** 这会让不受信任的 PR 选择自己的写权限行为或 owner。
+
+**根据 patch 或语言解析器推断任意语义源码变更。** GitHub 可能省略或截断 patch,而且仓库包含多种语言。扫描器不会尝试证明两个程序行为相同。后续的[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)只在变更行计数能够证明 GitHub 提供了完整 patch 时执行有限的词法比较。
+
+## 后果
+
+评审请求修改可以根据受信任的策略、workflow 日志中打印的文件分类,以及 PR 时间线中的请求来源复现。被排除的变更不会请求 owner,草稿 PR 不会保留 workflow 发出的请求。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。
+
+Workflow 会请求所有匹配的 owner,不会随机选择一人。因此,共享所有权对每个变更模块最多产生两个请求。GitHub 使用 `GITHUB_TOKEN` 生成的评审请求事件可能不会启动依赖递归触发事件的其他 workflow;这些 workflow 不得把此请求作为唯一触发条件。
+
+已分配目录下不符合任何显式排除规则的变更仍符合请求条件。未匹配的路径会被记录,但不会请求任何人。超过文件或时间线 API 上限的 PR 会失败,并且不会执行不完整的评审者修改。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.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/testing/2026-09-06-pr-ci-runner-temporary-storage.md
+2026-09-06-pr-ci-runner-temporary-storage.md: 59d609c08e1b228ae9a9705140f2832305dfead5
+2026-09-06-pr-ci-runner-temporary-storage.zh.md: f6765212af2424f2080063946f9104125993b9d5

+ 51 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md

@@ -0,0 +1,51 @@
+# Agent Note: runner-owned temporary storage for PR CI
+
+Status: implemented
+
+English | [中文](2026-09-06-pr-ci-runner-temporary-storage.zh.md)
+
+## Problem
+
+The Linux failover pool runs multiple runner instances on one VM. PR coverage and snapshot processes use the operating-system temporary directory for transformed modules and fixtures. Files outside the runner's temporary directory escape its job cleanup, including when cancellation prevents process-level disposal. Exhausting that shared directory makes unrelated PRs fail before tests execute.
+
+## Decision
+
+The static, coverage, and consumer jobs in [PR CI](../../../../.github/workflows/ci.yml) export `TMPDIR=runner.temp` through `GITHUB_ENV` in their first step before any setup or test process starts. Node, Vite, tsx, and temporary test consumers inherit the runner-owned location. Each runner owns its directory and GitHub Actions clears its removable contents at job start and completion; fixtures still allocate unique children and retain their own cleanup.
+
+npm keeps its configured persistent cache, normally `$HOME/.npm` on POSIX, without a per-job override in the main CI or release workflows. The pnpm store remains shared at `$HOME/.local/share/pnpm/store`. Both retain cross-runner reuse under the package managers’ concurrent-access support; shared-cache capacity and filesystem failures remain operational responsibilities. The consumer job places Playwright browser downloads and installation locks beside `RUNNER_TEMP`; hosted cache restore uses that same location.
+
+The [release rehearsal decision](../process/2026-09-06-release-rehearsal-selfhosted.md) applies the same lifetime rule to release consumers. The [failover runbook](../process/2026-07-26-ci-failover-runbook.md) continues to own runner selection and shared-host capacity. This change does not retarget jobs, reduce concurrency, retry tests, weaken assertions, or modify master-only CI.
+
+## Recorded ACP completion order
+
+The [ACP diagnostic scenario](../../../../snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml) holds its scripted background response until `job_output` owns the completion wait. Without that synchronization, a fast child can publish a legitimate job notice between the recorded parent steps. A scenario-local wrapper releases the child after the jobs service registers the completion waiter; the mock watches an exclusive marker in the private test workspace and closes the watcher after release. The fixture restores the wrapped method on disposal. The recorded Session bytes and production job-notice behavior stay unchanged.
+
+## Workspace-grant fixture placement
+
+The headless `session-sandbox-root` fixture declares `workspace.parent: outside-temp`, not a home-filesystem dependency. Its allocator uses a sibling of the canonical platform temp root where the parent is writable and avoids system temporary grants, otherwise home, and rejects a cwd already covered by automatic temporary write grants. On the failover runner this keeps the test on the data volume without making its write succeed through a temporary-directory exemption. The filesystem-sandbox containment tests use the same allocator for their workspace and denied sibling; they register cleanup immediately after successful acquisition. Atomic workspace allocation, recorded Session bytes, and the independent expected file remain unchanged.
+
+## Live verification and browser fixture inputs
+
+The installed-wheel live SDK test externally replaces the created file with a fresh host-only challenge before asking the model to verify it; the verification prompt does not reveal that value. Both turns must contain model-requested tool calls, and the verifier compares the returned value and actual file bytes.
+
+The reference-composer fixture maps the known home-abbreviated workspace display to its existing cwd token and waits for the current exact suggestion set before selecting; neither host paths nor stale suggestions determine its result. The shared browser timezone, Inspector subscription synchronization, and PowerShell completion behavior follow the [existing platform-test decision](2026-09-07-pwsh-ci-observable-completion.md).
+
+The advanced Python snapshot pauses only its matching workflow child’s first pre-step until the parent’s durable workflow membership event is observed. The fixture supports either event-arrival order and cancels pending waits on abort or disposal. This pins the scenario’s cross-session ordering without sorting notifications or changing production scheduling.
+
+The queue snapshot moves the pointer away from the Stop/Send control and waits for its Send tooltip to close before capture. Workspace-management tests select the sole non-blank Session by its actions affordance, not row position, and select that Session before asserting that archiving it removes the empty Ungrouped bucket. Hover behavior, queue contents, durable archive identity, and reload assertions remain unchanged. The concurrent spill isolation test keeps each root paired with its run result rather than assuming filesystem allocation completion order matches input order.
+
+## Alternatives considered
+
+**Delete shared temporary files from a PR job.** Another runner may still own those files. Repository jobs must not reclaim a shared directory by pathname or age.
+
+**Retry tests or enlarge timeouts.** Neither recovers storage or gives residual files a cleanup owner.
+
+**Switch every job to hosted runners.** This avoids the affected VM but leaves the failover path defective and changes the operator's independent pool selection.
+
+## Consequences
+
+Output honoring `TMPDIR` follows the job lifetime instead of accumulating in unmanaged host storage. Package-manager and browser caches remain persistent. This does not reclaim existing shared temporary files, guarantee filesystem capacity, or clean files the runner account cannot remove. Operators still own historical residue, disk provisioning, and jobs outside this PR workflow.
+
+Linux bwrap and Landlock workspace-write profiles grant literal `/tmp` and the workspace, not an inherited `TMPDIR` outside it; confined fixtures must place temporary writes in those granted paths. The [snapshot spill helper](../../../../packages/test-support/session-snapshot/src/harness.ts) separates fixed-length logical locators from atomically allocated live storage. A fixture-only adapter delegates saves to the real local spill provider and resolves only locators saved by that run to their live files. Recorded preview lengths, omission counts, and retrieval assertions remain unchanged; no files are allocated at the logical `/tmp/dsh-acp-snap-*` prefix. This change does not widen product sandbox grants.
+
+The parsed-workflow cases in [ci-workflow.spec.ts](../../../../scripts/ci-workflow.spec.ts) require the assignment on all three workers and reject step-level overrides. They fail against the unmodified workflow. Independent-process smoke checks and repeated PR runs validate the actual tooling; the YAML assertions alone do not prove host capacity.

+ 51 - 0
.agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: PR CI 使用 runner 管理的临时存储
+
+Status: implemented
+
+[English](2026-09-06-pr-ci-runner-temporary-storage.md) | 中文
+
+## 问题
+
+Linux 故障切换池在同一台虚拟机上运行多个 runner 实例。PR 覆盖率和快照进程使用操作系统临时目录存放转换后的模块与测试夹具。runner 临时目录之外的文件不受作业清理管理,取消执行阻止进程自行清理时也不例外。该共享目录耗尽会让无关 PR 在测试执行前就失败。
+
+## 决策
+
+[PR CI](../../../../.github/workflows/ci.yml) 的静态检查、覆盖率和消费者作业在任何准备或测试进程启动前,在首个步骤通过 `GITHUB_ENV` 导出 `TMPDIR=runner.temp`。Node、Vite、tsx 和临时测试消费者继承 runner 管理的位置。每个 runner 管理自己的目录,GitHub Actions 在作业开始和完成时清除其中可删除的内容;测试夹具仍分配唯一子目录,并保留自身清理逻辑。
+
+npm 保留配置的持久化缓存,在 POSIX 上通常为 `$HOME/.npm`;主 CI 和发布工作流不设置每作业覆盖。pnpm store 保持共享于 `$HOME/.local/share/pnpm/store`。两者依靠包管理器的并发访问支持保留跨 runner 复用;共享缓存容量及文件系统故障仍由运维负责。消费者作业将 Playwright 浏览器下载和安装锁放在 `RUNNER_TEMP` 旁;托管缓存恢复使用同一位置。
+
+[发布演练决策](../process/2026-09-06-release-rehearsal-selfhosted.zh.md) 对发布消费者采用相同的生命周期规则。[故障切换运行手册](../process/2026-07-26-ci-failover-runbook.zh.md) 继续负责 runner 选择和共享主机容量。本变更不调整作业目标、不降低并发、不重试测试、不削弱断言,也不修改仅在 master 上执行的 CI。
+
+## ACP 完成顺序的录制
+
+[ACP 诊断场景](../../../../snapshots/session/subagent-acp-diagnostic/cordis.snapshot.yml) 暂停脚本化的后台响应,直到 `job_output` 开始等待完成。没有这种同步,快速子进程可能在录制的父步骤之间发布合法的作业通知。场景本地 wrapper 在 jobs 服务注册完成等待器后释放子进程;mock 在测试私有 workspace 中监听独占创建的标记,并在释放后关闭 watcher。夹具在销毁时恢复被包装的方法。录制的 Session 字节和生产作业通知行为保持不变。
+
+## Workspace 授权夹具的位置
+
+Headless 的 `session-sandbox-root` 夹具声明 `workspace.parent: outside-temp`,而不是依赖 home 所在文件系统。分配器在父目录可写且避开系统临时授权时选择规范化平台临时根目录的同级目录,否则使用 home,并拒绝已被自动临时写授权覆盖的 cwd。在故障切换 runner 上,这让测试留在数据卷中,同时不会让写入借助临时目录豁免而成功。文件系统沙箱的包含关系测试使用同一分配器创建 workspace 及被拒绝的同级目录,并在成功获取目录后立即注册清理。原子 workspace 分配、录制的 Session 字节以及独立预期文件保持不变。
+
+## 在线验证与浏览器夹具输入
+
+已安装 wheel 的在线 SDK 测试在要求模型验证前,由外部将创建的文件替换为新的、仅主机知道的挑战值;验证提示不暴露该值。两个 turn 必须包含模型请求的工具调用,验证器同时比较返回值及真实文件字节。
+
+Reference-composer 夹具将已知的 home 缩写 workspace 显示映射到既有 cwd token,并在选择前等待当前精确建议集;主机路径或过时建议都不决定测试结果。共享浏览器时区、Inspector 订阅同步及 PowerShell 完成行为遵循[既有平台测试决策](2026-09-07-pwsh-ci-observable-completion.zh.md)。
+
+高级 Python 快照仅暂停其匹配的 workflow 子进程首次 pre-step,直到观察到父 Session 的持久化 workflow 成员事件。夹具支持事件先到或等待先建立两种顺序,并在取消或销毁时结束未完成等待。这固定了场景的跨 Session 顺序,而不排序通知或改变生产调度。
+
+Queue 快照在捕获前将指针移离 Stop/Send 控件,并等待其 Send tooltip 关闭。Workspace-management 测试通过操作按钮定位唯一非空 Session,而不依赖行位置;在断言归档会移除空的 Ungrouped 分组前,先选中该 Session。Hover 行为、队列内容、持久化归档身份及重载断言保持不变。并发 spill 隔离测试将每个根目录与其运行结果关联,而不假设文件系统分配完成顺序与输入顺序一致。
+
+## 考虑过的替代方案
+
+**由 PR 作业删除共享临时文件。** 其他 runner 可能仍在使用这些文件。仓库作业不得按路径或文件年龄回收共享目录。
+
+**重试测试或增大超时。** 两者都不能恢复存储空间,也不能为残留文件指定清理责任方。
+
+**将所有作业切换到托管 runner。** 这能避开受影响的虚拟机,但故障切换路径的缺陷仍在,也会改变运维人员独立选择的执行池。
+
+## 影响
+
+遵循 `TMPDIR` 的输出随作业生命周期清理,而不累积于无人管理的主机存储。包管理器和浏览器缓存保持持久化。本方案不回收既有共享临时文件、不保证文件系统容量,也不清理 runner 账号无权删除的文件。历史残留、磁盘配置以及本 PR 工作流以外的作业仍由运维人员负责。
+
+Linux bwrap 和 Landlock 的 workspace-write profile 允许写入字面路径 `/tmp` 和 workspace,而不允许写入其外部继承的 `TMPDIR`;受限测试夹具必须将临时写入放在这些已授权路径中。[快照 spill helper](../../../../packages/test-support/session-snapshot/src/harness.ts) 将固定长度的逻辑定位符与原子分配的实际存储分开。仅用于夹具的适配器将保存操作委托给真实的本地 spill provider,并仅将本次运行已保存的定位符解析到实际文件。录制的预览长度、省略计数及检索断言保持不变;逻辑 `/tmp/dsh-acp-snap-*` 前缀下不分配文件。本变更不扩大产品沙箱授权。
+
+[ci-workflow.spec.ts](../../../../scripts/ci-workflow.spec.ts) 的 YAML 解析用例要求三个 worker 都包含该赋值,并拒绝步骤级别的覆盖。它们在未修改的工作流上失败。独立进程 smoke 检查和重复 PR 运行验证实际工具链;仅有 YAML 断言不能证明主机容量充足。

+ 71 - 32
.agents/skills/record-browser-gif/SKILL.md

@@ -1,11 +1,11 @@
 ---
 name: record-browser-gif
-description: Record browser or Web UI interaction demos as optimized GIFs using the available built-in browser, state-based frame capture, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow.
+description: Record browser or Web UI interaction demos as optimized GIFs using the available browser-control workflow, optional Playwright Videos for higher capture frame rates, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow.
 ---
 
 # Record Browser GIF
 
-Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the attach workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size.
+Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the attach workflow at the end of this skill. The available browser-control workflow remains preferred. Use [Playwright Videos](https://playwright.dev/docs/videos) when that workflow supports continuous capture at higher frame rates; use the bundled encoder for trimming, playback speed, final hold, dimensions, and size.
 
 The [evidence-chain decision](../../notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md) owns why one storyboard comes from one isolated run and why publication revalidates both the artifact and the demonstrated pull-request head.
 
@@ -17,7 +17,7 @@ The recording itself is part of the evidence: use a real server booted from that
 
 ## Keep recording separate from publication
 
-- Recording produces frame images and one local `.gif` artifact only; it never mutates remote state.
+- Recording produces local video or screenshots and one `.gif` artifact only; it never mutates remote state.
 - Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch.
 - Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture.
 - Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt.
@@ -33,49 +33,86 @@ A GIF for a specific pull request demonstrates that pull request's tree, so stag
 
 ## Record the flow
 
-1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required; state that exception in the provenance and do not claim fresh client state. If browser control is unavailable, use the repository-declared Playwright dependency in an isolated headless browser; do not install another driver or launch the user's browser. State that fallback in the provenance.
-2. Before recording, identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports.
-3. When a production default opens a native operating-system surface that headless automation cannot drive, select an official browser-operable production backend through the application's normal configuration. State the override in the provenance; a fixture, mock transport, or test-only hook is not an acceptable substitute.
-4. Choose three to six states that tell one story, such as typed, running, settled, and detail. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer.
-5. Keep one viewport and crop for every frame, and name frames lexically: `00-initial.png`, `01-typed.png`, and so on.
-6. Store frames under the repository's gitignored `.playwright-mcp/` directory — browser-tool screenshots can only be written under the tool's allowed roots, and relative filenames resolve against the repository root. Create the frame subdirectory first (`mkdir -p .playwright-mcp/gif-frames-<label>`); writing into a missing directory fails with ENOENT at capture time.
-7. Before each screenshot, wait for a concrete UI condition such as a unique label, enabled control, changed document title, or completed response. Require the locator to resolve exactly one element; for Playwright accessible-name locators, use `exact: true` when equality is intended because descendant text or a prompt echo can otherwise create a false match. Do not use a fixed delay as proof that the application reached the state.
-8. Make completion predicates match an exact-text element — for example, an element whose trimmed text equals the expected reply — never a substring check such as `body.textContent.includes(...)`, which the echo of the user's own prompt also satisfies.
-9. When the claim involves a tool call, rejection, or recovery, include a detail or trajectory frame that shows the tool identity, status or stable error code, and the downstream result. A chat-only outcome does not prove why the tool path behaved that way.
-10. Capture a transient state (spinner, running row) by driving a slow foreground operation — for example, a `sleep 15` bash command — and polling a concrete DOM marker (a `data-*` attribute) inside one browser-script call that also takes the screenshot. State polled across separate tool calls is lost, because the turn settles between calls.
-11. Engineer the prompt so the state you need actually occurs: instruct the model to wait in the foreground when it would otherwise background a slow command, and give it a settle sentinel such as "reply with the single word done" to anchor the completion predicate.
-12. Capture no secrets, personal data, unrelated tabs, or transient notifications. Stop any unnecessarily long real-API run after the demonstrated state is visible.
-
-Use the browser's own screenshot API. When it returns image bytes, save those bytes directly; the encoder detects image content independently of the filename extension.
+Follow the available browser-control workflow's setup, interaction, and cleanup instructions. When it exposes `recordVideo`, enable video on the same controlled context to capture more intermediate frames. Otherwise use [screenshot capture](#screenshot-capture) within that workflow; video availability does not determine which browser-control workflow to use. Existing user browser state remains an explicit provenance exception.
 
-## Encode the GIF
+Only when browser control is unavailable, use the repository-declared Playwright dependency in an isolated headless browser and state that fallback in the provenance. In this repository it resolves from `apps/web/package.json`; do not install another driver or open the user's browser.
+
+Before recording, identify the origin, built or development server, transport, and any mode overrides. When a production default opens a native surface that automation cannot drive, select an official browser-operable production backend through normal application configuration and disclose the override.
+
+Store the script, raw video, timing notes, QA frames, and GIF under the repository's gitignored `.playwright-mcp/` directory. Create the run directory first.
+
+### Capture video
+
+Match `viewport` and `recordVideo.size` explicitly: Playwright otherwise scales the video down to fit 800×800, which can make UI text unreadable.
+
+Configure video through the chosen browser-control workflow. The standalone Playwright fallback uses:
+
+```js
+const { chromium } = createRequire(join(repo, 'apps/web/package.json'))('playwright')
+const browser = await chromium.launch()
+const size = { width: 1440, height: 900 }
+const context = await browser.newContext({
+  viewport: size,
+  recordVideo: { dir: join(runDir, 'videos'), size },
+})
+try {
+  const page = await context.newPage()
+  const video = page.video()
+  // Navigate and exercise the real application here.
+  await context.close()
+  await video.saveAs(join(runDir, 'demo.webm'))
+} finally {
+  await context.close()
+  await browser.close()
+}
+```
+
+Import `createRequire` from `node:module` and `join` from `node:path`; set `repo` and a fresh `runDir` to absolute paths in the recording script. Retain the page's video handle before closing it. Await `context.close()` before `video.saveAs()` or encoding; closing only the browser does not guarantee the video's flush. Each page has its own video: choose the demonstrated page explicitly and do not concatenate unrelated pages or runs. Failed runs are diagnostic only.
+
+Choose a short story with three to six meaningful states. Wait for unique semantic locators before acting; use `exact: true` for accessible-name equality and exact-text completion predicates that cannot match a prompt echo. Fixed waits may provide a reading hold after the state is verified, but never establish readiness. When capturing video, preserve animations and scrolling.
+
+When demonstrating a tool call, rejection, or recovery, open its detail or trajectory so the video shows the tool identity, status or stable error code, and downstream result. If a transient running state matters, prompt for a slow foreground operation and observe its concrete DOM marker; continuous video captures its intermediate frames. Give the model a short final sentinel to anchor completion. Stop an unnecessarily long real-API run after the demonstrated state is visible.
 
-Require `python3`, `ffmpeg`, and `ffprobe`. If either media binary is missing, report the dependency instead of installing software without authorization.
+Capture no secrets, personal data, unrelated tabs, or notifications. Browser video contains page content, not browser chrome; avoid rendering credential-bearing URLs in the application. Review the whole selected interval, including intermediate states. Keep one viewport throughout.
 
-Export `GIF_SKILL_DIR` as this skill's absolute directory on its own line before the python command — an inline `GIF_SKILL_DIR=... python3 "$GIF_SKILL_DIR/..."` assignment fails, because the argument expands before the assignment takes effect:
+## Encode the GIF
+
+Require `python3`, `ffmpeg`, and `ffprobe`. If a media binary is missing, report the dependency instead of installing software without authorization. Export `GIF_SKILL_DIR` on its own line before using it; an inline assignment cannot affect argument expansion in the same command.
 
 ```sh
 export GIF_SKILL_DIR=/absolute/path/to/this/skill
 python3 "$GIF_SKILL_DIR/scripts/encode_gif.py" \
-  /absolute/path/to/frames \
+  /absolute/path/to/demo.webm \
   /absolute/path/to/demo.gif \
-  --durations 1.5,1.5,1.5,3.5 \
-  --fps 10 \
-  --max-width 1200 \
-  --colors 128
+  --start 2 --end 32 --speed 2 --final-hold 3 \
+  --fps 10 --max-width 1200 --colors 128
 ```
 
-One duration applies to every frame; otherwise provide one comma-separated positive duration per frame, holding the final settled state longest. The encoder rejects fewer than two frames, mismatched dimensions or durations, invalid limits, accidental overwrite, unexpected duration, and output above `--max-bytes`.
+`--start` and `--end` select one continuous source interval in seconds. Defaults retain the full video at 1× speed and add a two-second final hold. `--speed` changes playback speed; disclose it and the selected interval beside the GIF so the demo cannot imply measured response latency. Use observed video times, not guessed wall-clock offsets, and preserve the complete cause and outcome of the demonstrated behavior. The final hold repeats the last selected frame. `--fps` sets the encoded GIF frame rate; increasing it cannot recover motion that the source recording did not capture. Keep the original WebM for QA; do not splice separate runs or synthesize missing states.
+
+The encoder probes WebM container duration, applies trim and speed before palette conversion, and checks encoded duration, animation, width, and byte size. It refuses an empty or out-of-range interval, a selection shorter than two output frames, mode-inappropriate flags, and accidental overwrite. Reduce `--max-width`, then `--colors` or `--fps` for a large artifact; preserve readable text. Use `--force` only after resolving the exact output path.
 
-For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; retain readable text and the final state long enough to inspect. Use `--force` only after resolving the exact output path.
+### Screenshot capture
+
+When continuous video is unavailable or the user requests a storyboard, follow the available browser-control workflow. Capture three to six verified states from one isolated run with the browser's screenshot API. Save returned image bytes directly under one run directory as `00-initial.png`, `01-typed.png`, and so on; use identical dimensions and crop. For a transient state, poll its DOM marker and capture within the same browser-script call.
+
+```sh
+python3 "$GIF_SKILL_DIR/scripts/encode_gif.py" \
+  /absolute/path/to/frames /absolute/path/to/demo.gif \
+  --durations 1.5,1.5,1.5,3.5 --fps 10 --max-width 1200 --colors 128
+```
+
+One duration applies to every screenshot; otherwise supply one positive duration per frame and hold the settled state longest. Directory input rejects fewer than two frames and mismatched dimensions or duration counts. Video timing flags apply only to video files; `--durations` and `--pattern` apply only to screenshot directories.
 
 ## Verify the artifact
 
-1. Read the encoder's JSON summary and confirm the output path, source and encoded frame counts, dimensions, duration, and byte size.
+1. Read the encoder's JSON summary and confirm the output path, source interval and speed (or screenshot count), encoded frame count, dimensions, duration, and byte size.
 2. Visually read the encoded GIF itself, not only the source frames. Confirm that the transition is legible, the last state is held long enough, and no sensitive content appears. If the viewer renders only the first frame, decode representative frames from the encoded GIF with `ffmpeg` and inspect those; the pre-encode screenshots do not prove the encoded order, palette, or final hold.
-3. Run `git status --short` and confirm frames and the artifact landed only under ignored paths.
+3. Run `git status --short` and confirm raw video, QA frames, and the artifact landed only under ignored paths.
 4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. When the task does not include attaching the GIF to a pull request, stop here.
 
+Encoder maintenance: run `python3 -m unittest discover -s "$GIF_SKILL_DIR/scripts" -p 'test_*.py' -v` with the media prerequisites installed. These local media tests do not run in repository CI.
+
 ## Publish the GIF
 
 Perform this step only when the task includes attaching the GIF to a pull request.
@@ -92,14 +129,16 @@ Write the GIF into the body file as an ordinary local-path reference, using the
 ![<alt text>](<path/to/demo.gif>)
 ```
 
-Immediately before attaching, re-read the pull request's live head — for a new pull request, the pushed branch tip — and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. Then attach:
+The demonstrated pull request is normally the publication target. A tooling pull request may instead embed a clearly labeled example from another pull request; name that source PR and compare its live head in every check below. Never attribute the example to the tooling branch.
+
+Immediately before attaching, re-read the demonstrated pull request's live head — for a new demonstrated pull request, the pushed branch tip — and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. Then attach:
 
 ```sh
 gh pr create --body-file <body.md> --attach <path/to/demo.gif>     # new pull request
 gh pr edit <pr> --body-file <body.md> --attach <path/to/demo.gif>  # existing pull request
 ```
 
-`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '<path>#<alt text>'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected `<img>`, and fetch the uploaded URL once to confirm `200` and `image/gif`.
+`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '<path>#<alt text>'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the demonstrated live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected `<img>`, and fetch the uploaded URL once to confirm `200` and `image/gif`.
 
 ### Fall back to an assets branch
 
@@ -122,7 +161,7 @@ For a new series, make a fresh shallow scratch clone (`git clone --depth 1 <repo
 
 After pushing, use authenticated GitHub API or raw requests to confirm the remote path, byte size, checksum, `200` response, and `image/gif` content type. An anonymous `404` does not disprove a private-repository asset; authenticate the verification instead. This proves the repository-member review path, not public availability.
 
-Immediately before editing the pull-request body, re-read its live head and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. After the edit, re-read the live head and require it to remain at that recorded commit. Separately, render the body through GitHub's Markdown API and confirm that the expected `<img>` is present.
+Immediately before editing the pull-request body, re-read the demonstrated pull request's live head and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. After the edit, re-read the demonstrated live head and require it to remain at that recorded commit. Separately, render the body through GitHub's Markdown API and confirm that the expected `<img>` is present.
 
 Embed the GIF in the pull request body with the raw blob URL; the `?raw=true` suffix is required, because the plain blob URL renders GitHub's file page instead of the image:
 

+ 92 - 34
.agents/skills/record-browser-gif/scripts/encode_gif.py

@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""Encode lexically ordered browser screenshots into a verified GIF."""
+"""Encode a Playwright video or ordered browser screenshots into a verified GIF."""
 
 from __future__ import annotations
 
@@ -43,6 +43,17 @@ def positive_int(value: str) -> int:
     return parsed
 
 
+def nonnegative_float(value: str) -> float:
+    """Parse a finite nonnegative timestamp or hold duration."""
+    try:
+        parsed = float(value)
+    except ValueError:
+        fail(f"expected a number, got {value!r}")
+    if not math.isfinite(parsed) or parsed < 0:
+        fail(f"expected a nonnegative finite number, got {value!r}")
+    return parsed
+
+
 def parse_durations(value: str, frame_count: int) -> list[float]:
     """Expand one hold duration or validate one duration per source frame."""
     parts = [part.strip() for part in value.split(",")]
@@ -102,6 +113,17 @@ def probe_stream(ffprobe: str, path: Path) -> dict[str, object]:
     return streams[0]
 
 
+def video_duration(ffprobe: str, path: Path) -> float:
+    """Read container duration because Playwright WebM omits stream duration."""
+    result = run_json([
+        ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "json", str(path),
+    ])
+    metadata = result.get("format")
+    if not isinstance(metadata, dict):
+        fail(f"missing format metadata for {path}")
+    return positive_float(str(metadata.get("duration")))
+
+
 def stream_int(stream: dict[str, object], key: str, path: Path) -> int:
     """Read a positive integer stream field."""
     try:
@@ -134,14 +156,17 @@ def write_concat_manifest(path: Path, frames: list[Path], durations: list[float]
 def build_parser() -> argparse.ArgumentParser:
     """Build the command-line contract."""
     parser = argparse.ArgumentParser(description=__doc__)
-    parser.add_argument("frames", type=Path, help="directory containing lexically ordered frames")
+    parser.add_argument("input", type=Path, help="video file or directory of ordered screenshots")
     parser.add_argument("output", type=Path, help="output .gif path")
-    parser.add_argument("--pattern", default="*.png", help="frame glob within the input directory")
+    parser.add_argument("--pattern", help="screenshot glob (default: *.png; directory input only)")
     parser.add_argument(
         "--durations",
-        default="2",
-        help="one hold duration or one comma-separated value per frame",
+        help="screenshot holds (default: 2 seconds each; directory input only)",
     )
+    parser.add_argument("--start", type=nonnegative_float, help="video trim start in seconds (default: 0)")
+    parser.add_argument("--end", type=positive_float, help="video trim end in seconds (default: EOF)")
+    parser.add_argument("--speed", type=positive_float, help="video playback multiplier (default: 1)")
+    parser.add_argument("--final-hold", type=nonnegative_float, help="extra final video frame hold (default: 2 seconds)")
     parser.add_argument("--fps", type=positive_int, default=10, help="encoded frames per second")
     parser.add_argument(
         "--max-width",
@@ -168,11 +193,14 @@ def build_parser() -> argparse.ArgumentParser:
 def main() -> None:
     """Validate inputs, encode the GIF, verify it, and print a JSON summary."""
     args = build_parser().parse_args()
-    frame_dir = args.frames.resolve()
+    source = args.input.resolve()
     output = args.output.resolve()
 
-    if not frame_dir.is_dir():
-        fail(f"frame directory does not exist: {frame_dir}")
+    if not source.exists():
+        fail(f"input does not exist: {source}")
+    is_video = source.is_file()
+    if not is_video and not source.is_dir():
+        fail(f"input must be a video file or frame directory: {source}")
     if output.suffix.lower() != ".gif":
         fail(f"output must end in .gif: {output}")
     if output.exists() and not args.force:
@@ -182,33 +210,68 @@ def main() -> None:
     if args.fps > 30:
         fail("--fps must not exceed 30")
 
-    frames = sorted(path.resolve() for path in frame_dir.glob(args.pattern) if path.is_file())
-    if len(frames) < 2:
-        fail(f"expected at least two frames matching {args.pattern!r} in {frame_dir}")
-    if output in frames:
-        fail("output path must not match an input frame")
-
-    durations = parse_durations(args.durations, len(frames))
-    expected_duration = sum(durations)
     ffmpeg = require_binary("ffmpeg")
     ffprobe = require_binary("ffprobe")
-
-    dimensions = {
-        (stream_int(stream, "width", frame), stream_int(stream, "height", frame))
-        for frame in frames
-        for stream in [probe_stream(ffprobe, frame)]
-    }
-    if len(dimensions) != 1:
-        fail(f"all frames must have identical dimensions, got {sorted(dimensions)}")
+    details: dict[str, object]
+    if is_video:
+        if args.pattern is not None or args.durations is not None:
+            fail("--pattern and --durations require a screenshot directory")
+        if source == output:
+            fail("output path must not match the input video")
+        probe_stream(ffprobe, source)
+        source_duration = video_duration(ffprobe, source)
+        start = args.start if args.start is not None else 0.0
+        end = args.end if args.end is not None else source_duration
+        speed = args.speed if args.speed is not None else 1.0
+        final_hold = args.final_hold if args.final_hold is not None else 2.0
+        if not start < end <= source_duration:
+            fail(f"video trim must satisfy 0 <= start < end <= {source_duration:.3f}")
+        if (end - start) / speed < 2 / args.fps:
+            fail("video selection must span at least two output frames before the final hold")
+        expected_duration = (end - start) / speed + final_hold
+        details = {
+            "sourceVideo": str(source), "sourceDurationSeconds": source_duration,
+            "startSeconds": start, "endSeconds": end, "speed": speed,
+            "finalHoldSeconds": final_hold,
+        }
+    else:
+        if any(value is not None for value in (args.start, args.end, args.speed, args.final_hold)):
+            fail("--start, --end, --speed and --final-hold require a video file")
+        pattern = args.pattern if args.pattern is not None else "*.png"
+        if not pattern:
+            fail("--pattern must not be empty")
+        frames = sorted(path.resolve() for path in source.glob(pattern) if path.is_file())
+        if len(frames) < 2:
+            fail(f"expected at least two frames in {source}")
+        if output in frames:
+            fail("output path must not match an input frame")
+        durations = parse_durations(args.durations if args.durations is not None else "2", len(frames))
+        expected_duration = sum(durations)
+        dimensions = {
+            (stream_int(stream, "width", frame), stream_int(stream, "height", frame))
+            for frame in frames
+            for stream in [probe_stream(ffprobe, frame)]
+        }
+        if len(dimensions) != 1:
+            fail(f"all frames must have identical dimensions, got {sorted(dimensions)}")
+        details = {"sourceFrames": len(frames)}
 
     output.parent.mkdir(parents=True, exist_ok=True)
     with tempfile.TemporaryDirectory(prefix="record-browser-gif-") as temporary:
-        manifest = Path(temporary) / "frames.ffconcat"
-        write_concat_manifest(manifest, frames, durations)
+        if is_video:
+            inputs = ["-i", str(source)]
+            timing = f"trim=start={start}:end={end},setpts=(PTS-STARTPTS)/{speed},"
+            hold = f",tpad=stop_mode=clone:stop_duration={final_hold}"
+        else:
+            manifest = Path(temporary) / "frames.ffconcat"
+            write_concat_manifest(manifest, frames, durations)
+            inputs = ["-f", "concat", "-safe", "0", "-i", str(manifest)]
+            timing = ""
+            hold = ""
         scale = f"scale='min({args.max_width},iw)':-2:flags=lanczos"
         palette = f"palettegen=max_colors={args.colors}:stats_mode=full"
         filters = (
-            f"fps={args.fps},{scale},split[base][palette_input];"
+            f"{timing}fps={args.fps},{scale}{hold},split[base][palette_input];"
             f"[palette_input]{palette}[palette];"
             "[base][palette]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle"
         )
@@ -217,12 +280,7 @@ def main() -> None:
             "-hide_banner",
             "-loglevel",
             "error",
-            "-f",
-            "concat",
-            "-safe",
-            "0",
-            "-i",
-            str(manifest),
+            *inputs,
             "-vf",
             filters,
             "-loop",
@@ -261,7 +319,7 @@ def main() -> None:
         json.dumps(
             {
                 "output": str(output),
-                "sourceFrames": len(frames),
+                **details,
                 "encodedFrames": encoded_frames,
                 "width": width,
                 "height": height,

+ 103 - 0
.agents/skills/record-browser-gif/scripts/test_encode_gif.py

@@ -0,0 +1,103 @@
+"""Exercise GIF timing and rejection through real ffmpeg/ffprobe subprocesses."""
+
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+SCRIPT = Path(__file__).with_name('encode_gif.py')
+
+
+class EncodeGifTest(unittest.TestCase):
+    def setUp(self):
+        self.temporary = tempfile.TemporaryDirectory(prefix='gif-encoder-test-')
+        self.addCleanup(self.temporary.cleanup)
+        self.root = Path(self.temporary.name)
+        self.video = self.root / 'browser video.webm'
+        self.output = self.root / 'demo.gif'
+        subprocess.run([
+            'ffmpeg', '-v', 'error',
+            '-f', 'lavfi', '-i', 'color=red:s=64x48:r=10:d=2',
+            '-f', 'lavfi', '-i', 'color=lime:s=64x48:r=10:d=2',
+            '-f', 'lavfi', '-i', 'color=blue:s=64x48:r=10:d=2',
+            '-filter_complex', '[0:v][1:v][2:v]concat=n=3:v=1:a=0',
+            '-c:v', 'libvpx', str(self.video),
+        ], check=True, capture_output=True)
+
+    def encode(self, source=None, *options):
+        return subprocess.run([
+            sys.executable, str(SCRIPT), str(source or self.video), str(self.output), *options,
+        ], capture_output=True, text=True)
+
+    def assert_color(self, seconds, channel):
+        result = subprocess.run([
+            'ffmpeg', '-v', 'error', '-i', str(self.output), '-ss', str(seconds),
+            '-frames:v', '1', '-vf', 'scale=1:1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-',
+        ], check=True, capture_output=True)
+        pixel = result.stdout
+        self.assertEqual(len(pixel), 3)
+        self.assertGreater(pixel[channel], 200)
+        self.assertTrue(all(value < 40 for index, value in enumerate(pixel) if index != channel))
+
+    def test_video_trim_speed_final_hold_and_palette_order(self):
+        result = self.encode(None, '--start', '1', '--end', '5', '--speed', '2',
+                             '--final-hold', '1', '--max-width', '32')
+        self.assertEqual(result.returncode, 0, result.stderr)
+        summary = json.loads(result.stdout)
+        self.assertAlmostEqual(summary['durationSeconds'], 3, delta=0.2)
+        self.assertEqual((summary['width'], summary['height']), (32, 24))
+        self.assert_color(0.1, 0)
+        self.assert_color(0.8, 1)
+        self.assert_color(2.7, 2)
+
+    def test_video_defaults_keep_full_duration(self):
+        result = self.encode()
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertAlmostEqual(json.loads(result.stdout)['durationSeconds'], 8, delta=0.2)
+        self.assert_color(7.7, 2)
+
+    def test_invalid_video_options_do_not_write_output(self):
+        for options in [
+            ['--start', '-1'], ['--speed', 'nan'], ['--final-hold', 'inf'],
+            ['--start', '3', '--end', '3'], ['--end', '7'],
+            ['--speed', '1000'], ['--durations', '2'], ['--pattern', '*.png'],
+        ]:
+            with self.subTest(options=options):
+                result = self.encode(None, *options)
+                self.assertNotEqual(result.returncode, 0)
+                self.assertIn('error:', result.stderr)
+                self.assertFalse(self.output.exists())
+
+    def test_screenshot_durations_and_video_flag_rejection(self):
+        frames = self.root / 'frames'
+        frames.mkdir()
+        for index, color in enumerate([b'\xff\x00\x00', b'\x00\x00\xff']):
+            (frames / f'{index}.ppm').write_bytes(b'P6\n64 48\n255\n' + color * (64 * 48))
+        rejected = self.encode(frames, '--speed', '1')
+        self.assertNotEqual(rejected.returncode, 0)
+        self.assertIn('require a video file', rejected.stderr)
+        for options in [['--pattern', ''], ['--pattern', '*.ppm', '--durations', ''],
+                        ['--pattern', '*.ppm', '--durations', '1,2,3']]:
+            with self.subTest(options=options):
+                self.assertNotEqual(self.encode(frames, *options).returncode, 0)
+                self.assertFalse(self.output.exists())
+        result = self.encode(frames, '--pattern', '*.ppm', '--durations', '0.5,1.5')
+        self.assertEqual(result.returncode, 0, result.stderr)
+        self.assertEqual(json.loads(result.stdout)['sourceFrames'], 2)
+        self.assert_color(0.1, 0)
+        self.assert_color(1.8, 2)
+
+    def test_overwrite_and_size_limit(self):
+        self.output.write_bytes(b'keep')
+        rejected = self.encode()
+        self.assertNotEqual(rejected.returncode, 0)
+        self.assertEqual(self.output.read_bytes(), b'keep')
+        oversized = self.encode(None, '--force', '--max-bytes', '1', '--final-hold', '0.0')
+        self.assertNotEqual(oversized.returncode, 0)
+        self.assertIn('above --max-bytes', oversized.stderr)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 60 - 0
.github/review-ownership/CODEOWNERS

@@ -0,0 +1,60 @@
+# Custom static-scanner input. Its nested path keeps GitHub from loading it as
+# the repository's native CODEOWNERS file.
+/apps/cli/ @turtle1999
+/apps/web/ @imccyu
+/docs/ @turtle1999
+/native/ @mektpoy
+/patches/ @mektpoy
+/python/ @LegGasai
+/scripts/ @turtle1999
+/vendor/ @turtle1999
+/website/ @LegGasai
+/packages/acp/ @mektpoy
+/packages/api/ @imccyu
+/packages/attachment/ @CreatixChu
+/packages/boot/ @turtle1999
+/packages/bundle/ @turtle1999
+/packages/client/ @imccyu
+/packages/code-runtime/ @Chinesezjc
+/packages/compaction/ @imccyu
+/packages/context/ @turtle1999
+/packages/core/ @turtle1999 @mektpoy
+/packages/credentials/ @mektpoy
+/packages/e2b/ @mektpoy
+/packages/experimental/ @mektpoy
+/packages/extensions/ @mektpoy
+/packages/feedback/ @mektpoy
+/packages/fs/ @mektpoy
+/packages/goal/ @mektpoy
+/packages/guard/ @turtle1999
+/packages/hooks/ @mektpoy
+/packages/host/ @turtle1999
+/packages/identity/ @imccyu
+/packages/interaction/ @imccyu
+/packages/jobs/ @imccyu
+/packages/llm/ @LegGasai
+/packages/lsp/ @mektpoy
+/packages/mcp/ @mektpoy
+/packages/plan/ @mektpoy
+/packages/preset/ @LegGasai @turtle1999
+/packages/runtime-diagnostics/ @mektpoy
+/packages/sandbox/ @mektpoy
+/packages/schedule/ @imccyu
+/packages/sdk/ @mektpoy
+/packages/session/ @turtle1999 @mektpoy
+/packages/session-query/ @mektpoy
+/packages/settings/ @mektpoy
+/packages/shell/ @mektpoy
+/packages/skill/ @mektpoy
+/packages/spill/ @mektpoy
+/packages/storage/ @imccyu
+/packages/subagent/ @Dudu-0223
+/packages/subprocess/ @mektpoy
+/packages/terminal/ @imccyu
+/packages/todo/ @mektpoy
+/packages/typert/ @imccyu
+/packages/util/ @mektpoy
+/packages/web/ @imccyu
+/packages/webhook/ @mektpoy
+/packages/workflow/ @mektpoy
+/packages/workspace/ @imccyu

+ 6 - 0
.github/review-ownership/README.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 .github/review-ownership/README.md
+README.md: 4c412a7c47f114e0aa6aa9a3048fd7f833f0b0e3
+README.zh.md: 1db7d1b56d4927a83c502557f393cb6518dc09f1

+ 59 - 0
.github/review-ownership/README.md

@@ -0,0 +1,59 @@
+# Automated review requests
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
+
+## Table of Contents
+
+- [Routing](#routing)
+- [Review exclusions](#review-exclusions)
+- [Security](#security)
+- [Verification](#verification)
+- [Dev Note](#dev-note)
+
+<a id="routing"></a>
+
+## Routing
+
+Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
+
+For a non-draft pull request, the workflow requests missing matched owners while keeping the total number of current individual review requests at two or fewer. Existing individual requests consume those slots, including requests made by people outside the ownership map. When more candidates remain than available slots, login order selects the reviewers deterministically. The workflow does not remove requests from a non-draft pull request. For a draft, it reads the current requested reviewers and review-request timeline, then cancels each current request whose latest requester is `github-actions[bot]`. Current requests made by people remain unchanged. The workflow fails without cancellation when the timeline exceeds 3,000 events or contains invalid request provenance.
+
+The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author and users who are already requested are omitted from new requests.
+
+The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
+
+<a id="review-exclusions"></a>
+
+## Review exclusions
+
+Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files.
+
+Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name.
+
+Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies.
+
+For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable.
+
+<a id="security"></a>
+
+## Security
+
+The write-capable `pull_request_target` job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Pull-request filenames are treated as API data and escaped in logs.
+
+Ownership changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the routing program or its owner assignments for its own run.
+
+<a id="verification"></a>
+
+## Verification
+
+Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, pagination, logging order, draft cancellation, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
+
+<a id="dev-note"></a>
+
+## Dev Note
+
+The [review-routing decision](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) records the security model, test exclusions, and alternatives.

+ 59 - 0
.github/review-ownership/README.zh.md

@@ -0,0 +1,59 @@
+# 自动请求代码评审
+
+[English](README.md) | 中文
+
+## 概要
+
+[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它会对变更文件分类,为可评审代码请求尚未加入的 owner,并在 PR 转为草稿时取消自己尚未完成的请求。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。
+
+## 目录
+
+- [路由](#routing)
+- [评审排除规则](#review-exclusions)
+- [安全性](#security)
+- [验证](#verification)
+- [开发说明](#dev-note)
+
+<a id="routing"></a>
+
+## 路由
+
+PR 在创建、同步、重新打开、标记为可评审或转为草稿时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。
+
+对于非草稿 PR,workflow 会请求尚未加入的匹配 owner,同时确保当前个人评审请求总数不超过两个。现有个人请求会占用名额,包括由人员向所有权映射之外用户发出的请求。当剩余候选人数超过可用名额时,workflow 会按登录名顺序确定评审者。Workflow 不会从非草稿 PR 移除请求。对于草稿,workflow 会读取当前评审请求和评审请求时间线,然后取消最近一次请求者为 `github-actions[bot]` 的当前请求。由人员发出的当前请求保持不变。如果时间线超过 3,000 个事件或包含无效的请求来源,workflow 会失败且不执行取消操作。
+
+所有权映射只接受显式绝对目录模式,并允许每条模式配置一至两名 GitHub 个人用户。通配符、隐藏目录模式、团队、超过两名 owner,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器会在修改评审请求前,打印变更代码文件、排除的测试文件、文档文件和纯注释文件,逐文件 owner 匹配结果,以及将要请求或取消的评审者。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户不会收到新请求。
+
+策略测试会统计已匹配目录下的非测试跟踪文件行数,并要求 `@turtle1999` 拥有的有效代码库不超过三分之一。
+
+<a id="review-exclusions"></a>
+
+## 评审排除规则
+
+评审路由会排除仓库中的单元测试、端到端测试、预期输出、快照、基准测试、性能测试、压力测试、语料测试、原生测试和 Python 测试约定。其中包括 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 和 `stress-tests` 目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,可识别的测试文件名后缀,以及 Python 的 `test_*.py` 或 `*_test.py` 文件。
+
+能够改变证据生成方式的测试基础设施仍需评审,包括 `vitest*.config.ts` 和 `scripts` 下的门禁实现。生产文件不会仅因名称为 `test.ts`、`spec.ts` 或 `snapshot.ts` 而被排除。
+
+扩展名以不区分大小写方式匹配。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,不会贡献 owner;除非符合其他排除规则,否则 `.yml` 文件仍需评审。
+
+对于具有受支持源码扩展名的修改文件,扫描器会移除解析出的注释,再比较变更前后的文本。只有 GitHub 提供的 patch 中增删行数与文件记录一致、能够证明 patch 完整,且其余代码完全相同时,扫描器才会排除该文件。解析器会按声明的扩展名识别 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释与 HTML 注释。重命名、不受支持的语言、缺失或不完整的 patch,以及无法确定的注释形式仍需评审。
+
+<a id="security"></a>
+
+## 安全性
+
+具备写权限的 `pull_request_target` job 只检出仓库默认分支。它不会检出或执行 PR 代码,也不使用仓库 secret。PR 文件名仅作为 API 数据处理,并在日志中转义。
+
+所有权变更只有合并到默认分支后才会生效。这可以防止不受信任的 PR 为自身的 workflow 运行修改路由程序或 owner 分配。
+
+<a id="verification"></a>
+
+## 验证
+
+运行 `pnpm run test:request-review` 可检查所有权解析、文件分类、完整 patch 检查、注释解析、分页、日志顺序、草稿取消、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。
+
+<a id="dev-note"></a>
+
+## 开发说明
+
+[评审路由决策](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md)记录了安全模型、测试排除规则和备选方案。

+ 526 - 0
.github/review-ownership/request-review.mjs

@@ -0,0 +1,526 @@
+#!/usr/bin/env node
+
+import { readFileSync } from 'node:fs'
+import process from 'node:process'
+import { pathToFileURL } from 'node:url'
+
+const API_VERSION = '2026-03-10'
+const MAX_OWNERS_PER_RULE = 2
+const MAX_PULL_REQUEST_FILES = 3_000
+const MAX_REQUESTED_REVIEWERS = 2
+const MAX_TIMELINE_EVENTS = 3_000
+const PAGE_SIZE = 100
+const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]'
+const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
+const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u
+const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u
+const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu
+const C_STYLE_EXTENSIONS = new Set([
+  'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx',
+  'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx',
+])
+const BLOCK_COMMENT_EXTENSIONS = new Set(['css'])
+const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh'])
+const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html'])
+
+/**
+ * Parse the explicit directory subset accepted from the review ownership file.
+ * @param {string} source CODEOWNERS-compatible source text.
+ * @returns {Array<{pattern: string, prefix: string, owners: string[]}>} Ordered ownership rules.
+ */
+export function parseOwnership(source) {
+  const rules = []
+  const patterns = new Set()
+  for (const [index, rawLine] of source.split('\n').entries()) {
+    const line = rawLine.trim()
+    if (!line || line.startsWith('#')) continue
+    const [pattern, ...owners] = line.split(/\s+/u)
+    const location = `ownership line ${index + 1}`
+    if (!/^\/[^*?[\]#!\\]+\/$/u.test(pattern)) {
+      throw new Error(`${location}: expected one explicit absolute directory pattern`)
+    }
+    if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`)
+    if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`)
+    if (owners.length === 0) throw new Error(`${location}: expected at least one owner`)
+    if (owners.length > MAX_OWNERS_PER_RULE) {
+      throw new Error(`${location}: expected at most ${MAX_OWNERS_PER_RULE} owners`)
+    }
+    const normalizedOwners = []
+    const seenOwners = new Set()
+    for (const owner of owners) {
+      if (!/^@[A-Za-z0-9-]+$/u.test(owner)) {
+        throw new Error(`${location}: only individual GitHub users are supported`)
+      }
+      const key = owner.toLowerCase()
+      if (seenOwners.has(key)) throw new Error(`${location}: duplicate owner ${owner}`)
+      seenOwners.add(key)
+      normalizedOwners.push(owner)
+    }
+    patterns.add(pattern)
+    rules.push({ pattern, prefix: pattern.slice(1), owners: normalizedOwners })
+  }
+  if (rules.length === 0) throw new Error('ownership file contains no rules')
+  return rules
+}
+
+/**
+ * Normalize a repository-relative path received from GitHub.
+ * @param {unknown} value GitHub file path.
+ * @returns {string} Slash-normalized repository path.
+ */
+export function normalizeRepositoryPath(value) {
+  if (typeof value !== 'string' || value.length === 0) throw new Error('changed file has no path')
+  const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '')
+  if (
+    normalized.startsWith('/')
+    || normalized.includes('\0')
+    || normalized.split('/').some(segment => !segment || segment === '.' || segment === '..')
+  ) {
+    throw new Error(`invalid repository path ${JSON.stringify(value)}`)
+  }
+  return normalized
+}
+
+/**
+ * Decide whether a repository path belongs only to test evidence or test support.
+ * @param {string} value Repository-relative path.
+ * @returns {boolean} Whether reviewer routing must ignore the path.
+ */
+export function isTestPath(value) {
+  const file = normalizeRepositoryPath(value)
+  const segments = file.split('/')
+  if (segments[0] === 'benchmarks' || segments[0] === 'snapshots') return true
+  if (segments[0] === 'packages' && segments[1] === 'test-support') return true
+  if (segments[0] === 'scripts' && (segments[1] === 'fixtures' || segments[1] === 'snapshots')) return true
+  if (segments.some(segment => TEST_DIRECTORY_NAMES.has(segment))) return true
+  const basename = segments.at(-1) ?? ''
+  return TEST_FILE_MARKER.test(basename) || PYTHON_TEST_FILE.test(basename)
+}
+
+/**
+ * Decide whether a repository path is documentation excluded from review routing.
+ * @param {string} value Repository-relative path.
+ * @returns {boolean} Whether the path has an excluded documentation extension.
+ */
+export function isDocumentationPath(value) {
+  return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value))
+}
+
+/**
+ * Decide whether a complete modified-file patch changes comments only.
+ * @param {unknown} value GitHub changed-file record.
+ * @returns {boolean} Whether supported comment parsing removes every changed token.
+ */
+export function isCommentOnlyChange(value) {
+  if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string'
+    || typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions)
+    || value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false
+  const syntax = commentSyntax(value.filename)
+  if (syntax === undefined) return false
+  if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false
+  const hunks = parsePatchHunks(value.patch)
+  if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) {
+    return false
+  }
+  return hunks.values.every(({ before, after }) =>
+    normalizedCode(before, syntax) === normalizedCode(after, syntax))
+}
+
+function commentSyntax(filename) {
+  const normalized = normalizeRepositoryPath(filename)
+  const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
+  const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : ''
+  const line = []
+  const block = []
+  if (C_STYLE_EXTENSIONS.has(extension)) {
+    line.push('//')
+    block.push(['/*', '*/'])
+  }
+  if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/'])
+  if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.')
+    || basename === 'makefile' || basename.startsWith('makefile.')) line.push('#')
+  if (extension === 'sql') {
+    line.push('--')
+    block.push(['/*', '*/'])
+  }
+  if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['<!--', '-->'])
+  return line.length === 0 && block.length === 0 ? undefined : { line, block }
+}
+
+function parsePatchHunks(patch) {
+  const values = []
+  let current
+  let additions = 0
+  let deletions = 0
+  for (const line of patch.split('\n')) {
+    if (line.startsWith('@@')) {
+      current = { before: [], after: [] }
+      values.push(current)
+      continue
+    }
+    if (current === undefined || line.startsWith('\\ No newline at end of file')) continue
+    const prefix = line[0]
+    const content = line.slice(1)
+    if (prefix === ' ') {
+      current.before.push(content)
+      current.after.push(content)
+    } else if (prefix === '-') {
+      current.before.push(content)
+      deletions++
+    } else if (prefix === '+') {
+      current.after.push(content)
+      additions++
+    }
+  }
+  return values.length === 0 ? undefined : { values, additions, deletions }
+}
+
+function normalizedCode(lines, syntax) {
+  return stripComments(lines.join('\n'), syntax)
+    .split('\n')
+    .map(line => line.trimEnd())
+    .filter(line => line.trim().length > 0)
+    .join('\n')
+}
+
+function stripComments(source, syntax) {
+  let result = ''
+  let quote
+  let blockEnd
+  for (let index = 0; index < source.length;) {
+    if (blockEnd !== undefined) {
+      if (source.startsWith(blockEnd, index)) {
+        index += blockEnd.length
+        blockEnd = undefined
+      } else {
+        index++
+      }
+      continue
+    }
+    const character = source[index]
+    if (quote !== undefined) {
+      result += character
+      index++
+      if (character === '\\' && index < source.length) {
+        result += source[index]
+        index++
+      } else if (character === quote) {
+        quote = undefined
+      }
+      continue
+    }
+    if (character === '\'' || character === '"' || character === '`') {
+      quote = character
+      result += character
+      index++
+      continue
+    }
+    const block = syntax.block.find(([start]) => source.startsWith(start, index))
+    if (block !== undefined) {
+      index += block[0].length
+      blockEnd = block[1]
+      continue
+    }
+    const line = syntax.line.find(marker => source.startsWith(marker, index))
+    const lineStart = index === 0 || source[index - 1] === '\n'
+    const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '')
+    if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) {
+      const newline = source.indexOf('\n', index + line.length)
+      if (newline === -1) break
+      result += '\n'
+      index = newline + 1
+      continue
+    }
+    result += character
+    index++
+  }
+  return result
+}
+
+/**
+ * Expand changed-file records into reviewable, test, documentation, and comment-only paths.
+ * @param {unknown[]} files Pull-request file records from GitHub.
+ * @returns {{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths.
+ */
+export function classifyChangedFiles(files) {
+  const changedCodeFiles = new Set()
+  const excludedTestFiles = new Set()
+  const excludedDocumentationFiles = new Set()
+  const excludedCommentOnlyFiles = new Set()
+  for (const entry of files) {
+    if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry')
+    const paths = [normalizeRepositoryPath(entry.filename)]
+    const commentOnly = isCommentOnlyChange(entry)
+    if (entry.previous_filename !== undefined) {
+      paths.unshift(normalizeRepositoryPath(entry.previous_filename))
+    }
+    for (const file of paths) {
+      if (isTestPath(file)) excludedTestFiles.add(file)
+      else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file)
+      else if (commentOnly) excludedCommentOnlyFiles.add(file)
+      else changedCodeFiles.add(file)
+    }
+  }
+  return {
+    changedCodeFiles: [...changedCodeFiles].sort(),
+    excludedTestFiles: [...excludedTestFiles].sort(),
+    excludedDocumentationFiles: [...excludedDocumentationFiles].sort(),
+    excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(),
+  }
+}
+
+/**
+ * Match changed paths to owners with CODEOWNERS last-match semantics.
+ * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules.
+ * @param {string[]} changedCodeFiles Reviewable repository paths.
+ * @returns {{matches: Array<{file: string, owners: string[]}>, reviewers: string[]}} Routing plan.
+ */
+export function planReviewers(rules, changedCodeFiles) {
+  const matches = []
+  const reviewers = new Map()
+  for (const file of changedCodeFiles) {
+    let owners = []
+    for (const rule of rules) {
+      if (file.startsWith(rule.prefix)) owners = rule.owners
+    }
+    matches.push({ file, owners })
+    for (const owner of owners) reviewers.set(owner.toLowerCase(), owner.slice(1))
+  }
+  return {
+    matches,
+    reviewers: [...reviewers.values()].sort((left, right) => left.localeCompare(right, 'en')),
+  }
+}
+
+/**
+ * Create a repository-scoped GitHub JSON API caller.
+ * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies.
+ * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} API caller.
+ */
+export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) {
+  if (!token) throw new Error('GITHUB_TOKEN is not set')
+  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
+  const root = apiUrl.replace(/\/+$/u, '')
+  return async (path, { method = 'GET', body } = {}) => {
+    const response = await fetchImpl(`${root}${path}`, {
+      method,
+      headers: {
+        Accept: 'application/vnd.github+json',
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+        'User-Agent': 'deepseek-harness-request-review',
+        'X-GitHub-Api-Version': API_VERSION,
+      },
+      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+    })
+    if (!response.ok) {
+      const responseBody = await response.text()
+      throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`)
+    }
+    if (response.status === 204) return undefined
+    return response.json()
+  }
+}
+
+/**
+ * Fetch the complete pull-request file list or fail before routing a partial list.
+ * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @param {number} expectedCount Pull-request changed-file count.
+ * @returns {Promise<unknown[]>} Complete changed-file records.
+ */
+export async function listPullRequestFiles(api, repository, pullNumber, expectedCount) {
+  if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) {
+    throw new Error('pull request changed_files must be a non-negative integer')
+  }
+  if (expectedCount > MAX_PULL_REQUEST_FILES) {
+    throw new Error(`pull request has ${expectedCount} files; GitHub exposes at most ${MAX_PULL_REQUEST_FILES}`)
+  }
+  const files = []
+  for (let page = 1; files.length < expectedCount; page++) {
+    const response = await api(`/repos/${repository}/pulls/${pullNumber}/files?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response) || response.length === 0) {
+      throw new Error(`GitHub returned ${files.length} of ${expectedCount} changed files`)
+    }
+    files.push(...response)
+    if (files.length > expectedCount) {
+      throw new Error(`GitHub returned ${files.length} files but the pull request reports ${expectedCount}`)
+    }
+  }
+  return files
+}
+
+/**
+ * Fetch the pull request timeline used to identify workflow-authored review requests.
+ * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @returns {Promise<unknown[]>} Complete timeline event list within the supported limit.
+ */
+export async function listPullRequestTimeline(api, repository, pullNumber) {
+  const events = []
+  for (let page = 1; ; page++) {
+    const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array')
+    events.push(...response)
+    if (response.length < PAGE_SIZE) return events
+    if (events.length >= MAX_TIMELINE_EVENTS) {
+      throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`)
+    }
+  }
+}
+
+/** Return current requested reviewers whose latest request came from this workflow identity. */
+function workflowRequestedReviewers(events, requestedReviewers) {
+  const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login]))
+  const latestRequester = new Map()
+  for (const event of events) {
+    if (!isRecord(event) || event.event !== 'review_requested') continue
+    if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue
+    const key = event.requested_reviewer.login.toLowerCase()
+    if (!requested.has(key)) continue
+    if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') {
+      throw new Error('review-request timeline event has no requester login')
+    }
+    latestRequester.set(key, event.review_requester.login.toLowerCase())
+  }
+  return [...requested]
+    .filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER)
+    .map(([, login]) => login)
+}
+
+/** Extract and validate individual logins from GitHub's requested-reviewer response. */
+function requestedReviewerLogins(response) {
+  if (!isRecord(response) || !Array.isArray(response.users)) {
+    throw new Error('requested-reviewers response has no users array')
+  }
+  return response.users.map((user) => {
+    if (!isRecord(user) || typeof user.login !== 'string') {
+      throw new Error('requested-reviewers response contains an invalid user')
+    }
+    return user.login
+  })
+}
+
+/**
+ * Print changed paths, request missing owners on reviewable pull requests, and
+ * cancel workflow-authored requests on drafts.
+ * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, write?: (line: string) => void}} options Runtime inputs.
+ * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result.
+ */
+export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) {
+  const pull = pullRequestFromEvent(event)
+  write('This is by automated Angry Turtle Cyborg, not a human')
+  const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
+  const classified = classifyChangedFiles(files)
+  const plan = planReviewers(parseOwnership(ownershipSource), classified.changedCodeFiles)
+  writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file)))
+  writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file)))
+  writeList(
+    write,
+    'Excluded documentation files',
+    classified.excludedDocumentationFiles.map(file => JSON.stringify(file)),
+  )
+  writeList(
+    write,
+    'Excluded comment-only files',
+    classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)),
+  )
+  writeList(
+    write,
+    'Owners by changed file',
+    plan.matches.map(({ file, owners }) => `${JSON.stringify(file)}: ${owners.length ? owners.join(' ') : '(none)'}`),
+  )
+
+  const candidates = plan.reviewers.filter(login => login.toLowerCase() !== pull.author.toLowerCase())
+  if (pull.draft) {
+    const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
+    const requestedReviewers = requestedReviewerLogins(existing)
+    const reviewers = requestedReviewers.length === 0
+      ? []
+      : workflowRequestedReviewers(
+          await listPullRequestTimeline(api, pull.repository, pull.number),
+          requestedReviewers,
+        )
+    writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`))
+    if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
+
+    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
+      method: 'DELETE',
+      body: { reviewers },
+    })
+    const requestLabel = reviewers.length === 1 ? 'request' : 'requests'
+    write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`)
+    return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
+  }
+
+  if (candidates.length === 0) {
+    writeList(write, 'Reviewers to request', [])
+    return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
+  }
+  const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
+  const alreadyRequested = new Set(requestedReviewerLogins(existing).map(login => login.toLowerCase()))
+  const availableSlots = Math.max(0, MAX_REQUESTED_REVIEWERS - alreadyRequested.size)
+  const reviewers = candidates
+    .filter(login => !alreadyRequested.has(login.toLowerCase()))
+    .slice(0, availableSlots)
+  writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
+  if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
+
+  await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
+    method: 'POST',
+    body: { reviewers },
+  })
+  write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
+  return { ...classified, requestedReviewers: reviewers, cancelledReviewers: [] }
+}
+
+function pullRequestFromEvent(event) {
+  if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string') {
+    throw new Error('event has no repository.full_name')
+  }
+  if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)) {
+    throw new Error('event has no pull_request')
+  }
+  const { pull_request: pull } = event
+  if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
+  if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
+  if (typeof pull.user.login !== 'string' || !pull.user.login) throw new Error('pull request has no author login')
+  return {
+    repository: event.repository.full_name,
+    number: pull.number,
+    draft: pull.draft,
+    author: pull.user.login,
+    changedFileCount: pull.changed_files,
+  }
+}
+
+function writeList(write, title, entries) {
+  write(`${title}:`)
+  if (entries.length === 0) write('- (none)')
+  else for (const entry of entries) write(`- ${entry}`)
+}
+
+function isRecord(value) {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+async function main() {
+  const eventPath = process.env.GITHUB_EVENT_PATH
+  if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
+  const event = JSON.parse(readFileSync(eventPath, 'utf8'))
+  const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
+  const api = createGitHubApi({
+    token: process.env.GITHUB_TOKEN ?? '',
+    apiUrl: process.env.GITHUB_API_URL,
+  })
+  await requestReviews({ event, ownershipSource, api })
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  main().catch((error) => {
+    process.stderr.write(`request-review failed: ${error instanceof Error ? error.message : String(error)}\n`)
+    process.exitCode = 1
+  })
+}

+ 510 - 0
.github/review-ownership/request-review.test.mjs

@@ -0,0 +1,510 @@
+import assert from 'node:assert/strict'
+import { execFileSync } from 'node:child_process'
+import { existsSync, readFileSync } from 'node:fs'
+import test from 'node:test'
+
+import {
+  classifyChangedFiles,
+  createGitHubApi,
+  isCommentOnlyChange,
+  isDocumentationPath,
+  isTestPath,
+  listPullRequestFiles,
+  listPullRequestTimeline,
+  normalizeRepositoryPath,
+  parseOwnership,
+  planReviewers,
+  requestReviews,
+} from './request-review.mjs'
+
+const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8')
+
+const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } = {}) => ({
+  repository: { full_name: 'deepseek-harness/deepseek-harness' },
+  pull_request: {
+    number: 42,
+    draft,
+    changed_files: changedFiles,
+    user: { login: author },
+  },
+})
+
+test('loads the repository ownership policy without test-only directory rules', () => {
+  const rules = parseOwnership(ownershipSource)
+  const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners]))
+  assert.equal(rules.length, 58)
+  assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false)
+  assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false)
+  assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false)
+  assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999'])
+  assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999'])
+  assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@turtle1999', '@mektpoy'])
+  assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai'])
+  assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999'])
+  assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@turtle1999', '@mektpoy'])
+  assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223'])
+  assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu'])
+  assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai'])
+  assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai'])
+  assert.equal(rules.every(rule => rule.owners.length <= 2), true)
+  for (const excludedOwner of ['@tianyicui', '@kermeanx', '@pkh-xht']) {
+    assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false)
+  }
+})
+
+test('keeps turtle below one third of the eligible owned codebase', () => {
+  const rules = parseOwnership(ownershipSource)
+  const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
+    .split('\0')
+    .filter(file => file && existsSync(file))
+  let ownedLines = 0
+  let turtleLines = 0
+  for (const file of trackedFiles) {
+    if (isTestPath(file) || isDocumentationPath(file)) continue
+    const owners = planReviewers(rules, [file]).matches[0]?.owners ?? []
+    if (owners.length === 0) continue
+    const content = readFileSync(file)
+    const lines = content.length === 0
+      ? 0
+      : content.reduce((count, byte) => count + (byte === 10 ? 1 : 0), 0) + (content.at(-1) === 10 ? 0 : 1)
+    ownedLines += lines
+    if (owners.includes('@turtle1999')) turtleLines += lines
+  }
+  assert.ok(
+    turtleLines * 3 <= ownedLines,
+    `@turtle1999 owns ${turtleLines} of ${ownedLines} eligible owned lines`,
+  )
+})
+
+test('rejects ownership forms the requester cannot apply safely', () => {
+  for (const [source, message] of [
+    ['', /contains no rules/u],
+    ['* @owner\n', /explicit absolute directory/u],
+    ['/.github/ @owner\n', /hidden-directory/u],
+    ['/packages/*/ @owner\n', /explicit absolute directory/u],
+    ['/packages/core/\n', /at least one owner/u],
+    ['/packages/core/ @org/team\n', /individual GitHub users/u],
+    ['/packages/core/ @one @two @three\n', /at most 2 owners/u],
+    ['/packages/core/ @owner @OWNER\n', /duplicate owner/u],
+    ['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u],
+  ]) {
+    assert.throws(() => parseOwnership(source), message)
+  }
+})
+
+test('recognizes every repository test location and filename convention', () => {
+  for (const file of [
+    'apps/cli/tests/args.spec.ts',
+    'apps/cli/tests/harness.ts',
+    'apps/web/stress-tests/reasoning-chunks.stress.ts',
+    'benchmarks/session-open/workload.ts',
+    'native/landlock-run/test/entry.test.js',
+    'packages/core/agent/__tests__/agent.ts',
+    'packages/core/agent/benches/agent.rs',
+    'packages/core/agent/src/agent.compat.spec.ts',
+    'packages/core/agent/src/__snapshots__/agent.ts.snap',
+    'packages/session-query/session-query/tests/test-service.ts',
+    'packages/test-support/session-snapshot/src/index.ts',
+    'python/sdk/src/test_client.py',
+    'python/sdk/src/client_test.py',
+    'scripts/fixtures/translation-prompt/response.txt',
+    'scripts/session-snapshot-corpus.corpus.ts',
+    'scripts/snapshots/translation-prompt-v4/request-response.expected.json',
+    'snapshots/session/headless.snapshot.ts',
+  ]) {
+    assert.equal(isTestPath(file), true, file)
+  }
+})
+
+test('does not confuse production names with tests', () => {
+  for (const file of [
+    'apps/cli/src/testing.ts',
+    'packages/core/agent/src/contest.ts',
+    'packages/session/session-format/src/snapshot.ts',
+    'packages/session/session-format/src/spec.ts',
+    'packages/session/session-format/src/test.ts',
+    'scripts/run-gates.ts',
+    'vitest.config.ts',
+    'vitest.bench.config.ts',
+    'vitest.e2e.config.ts',
+    'vitest.snapshot.config.ts',
+    'vitest.web.perf.config.ts',
+    'website/docs.ts',
+  ]) {
+    assert.equal(isTestPath(file), false, file)
+  }
+})
+
+test('excludes Markdown and YAML documentation extensions', () => {
+  for (const file of [
+    'README.md',
+    'docs/architecture.MD',
+    'packages/subagent/subagent/guide.yaml',
+    'profiles/example.YAML',
+  ]) {
+    assert.equal(isDocumentationPath(file), true, file)
+  }
+  for (const file of [
+    '.github/workflows/request-review.yml',
+    'packages/subagent/subagent/src/index.ts',
+    'website/docs.ts',
+  ]) {
+    assert.equal(isDocumentationPath(file), false, file)
+  }
+})
+
+test('detects comment-only changes only from complete supported patches', () => {
+  for (const file of [
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"',
+    },
+    {
+      filename: 'python/sdk/src/client.py',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-value = 1  # old note\n+value = 1  # new note',
+    },
+    {
+      filename: 'native/landlock-run/src/main.rs',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */',
+    },
+  ]) {
+    assert.equal(isCommentOnlyChange(file), true, file.filename)
+  }
+
+  for (const file of [
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note',
+    },
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 2, deletions: 1,
+      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+    },
+    {
+      filename: 'packages/core/agent/src/data.json',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}',
+    },
+    {
+      filename: 'native/landlock-run/src/main.rs',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;',
+    },
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'renamed', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+    },
+  ]) {
+    assert.equal(isCommentOnlyChange(file), false, file.filename)
+  }
+})
+
+test('normalizes separators and rejects paths that are not repository-relative', () => {
+  assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts')
+  for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) {
+    assert.throws(() => normalizeRepositoryPath(file), /path/u, file)
+  }
+})
+
+test('classifies both sides of a rename independently', () => {
+  assert.deepEqual(
+    classifyChangedFiles([
+      {
+        filename: 'packages/core/agent/tests/moved.spec.ts',
+        previous_filename: 'packages/core/agent/src/moved.ts',
+      },
+      {
+        filename: 'packages/client/store/src/restored.ts',
+        previous_filename: 'packages/client/store/tests/restored.spec.ts',
+      },
+      { filename: 'packages/core/agent/README.md' },
+      {
+        filename: 'packages/core/agent/src/commented.ts',
+        status: 'modified', additions: 1, deletions: 1,
+        patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+      },
+    ]),
+    {
+      changedCodeFiles: [
+        'packages/client/store/src/restored.ts',
+        'packages/core/agent/src/moved.ts',
+      ],
+      excludedTestFiles: [
+        'packages/client/store/tests/restored.spec.ts',
+        'packages/core/agent/tests/moved.spec.ts',
+      ],
+      excludedDocumentationFiles: ['packages/core/agent/README.md'],
+      excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'],
+    },
+  )
+})
+
+test('uses the last matching ownership rule and keeps unmatched files visible', () => {
+  const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n')
+  assert.deepEqual(
+    planReviewers(rules, ['AGENTS.md', 'packages/core/agent/src/index.ts', 'packages/fs/fs/src/index.ts']),
+    {
+      matches: [
+        { file: 'AGENTS.md', owners: [] },
+        { file: 'packages/core/agent/src/index.ts', owners: ['@core', '@second'] },
+        { file: 'packages/fs/fs/src/index.ts', owners: ['@broad'] },
+      ],
+      reviewers: ['broad', 'core', 'second'],
+    },
+  )
+})
+
+test('fetches every declared changed file across pages', async () => {
+  const calls = []
+  const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` }))
+  const pageTwo = [{ filename: 'packages/core/file-100.ts' }]
+  const api = async (path) => {
+    calls.push(path)
+    return calls.length === 1 ? pageOne : pageTwo
+  }
+  const files = await listPullRequestFiles(api, 'owner/repo', 42, 101)
+  assert.equal(files.length, 101)
+  assert.deepEqual(calls, [
+    '/repos/owner/repo/pulls/42/files?per_page=100&page=1',
+    '/repos/owner/repo/pulls/42/files?per_page=100&page=2',
+  ])
+})
+
+test('fails closed when GitHub cannot provide the complete file list', async () => {
+  let calls = 0
+  await assert.rejects(
+    listPullRequestFiles(async () => {
+      calls++
+      return calls === 1 ? [{ filename: 'one.ts' }] : []
+    }, 'owner/repo', 42, 2),
+    /returned 1 of 2/u,
+  )
+  await assert.rejects(
+    listPullRequestFiles(async () => [], 'owner/repo', 42, 3_001),
+    /at most 3000/u,
+  )
+})
+
+test('fails closed when the review-request timeline exceeds its limit', async () => {
+  let calls = 0
+  await assert.rejects(
+    listPullRequestTimeline(async () => {
+      calls++
+      return Array.from({ length: 100 }, () => ({ event: 'commented' }))
+    }, 'owner/repo', 42),
+    /exceeds 3000 events/u,
+  )
+  assert.equal(calls, 30)
+})
+
+test('prints changed code files and limits current review requests to two people', async () => {
+  const trace = []
+  const files = [
+    { filename: 'packages/core/agent/src/index.ts' },
+    { filename: 'packages/preset/agent-presets/src/index.ts' },
+    { filename: 'packages/client/store/src/index.ts' },
+    { filename: 'packages/subagent/subagent/src/index.ts' },
+    { filename: 'packages/core/agent/tests/index.spec.ts' },
+    { filename: 'AGENTS.md' },
+  ]
+  const api = async (path, options = {}) => {
+    trace.push({ type: 'api', path, options })
+    if (path.endsWith('/files?per_page=100&page=1')) return files
+    if (path.endsWith('/requested_reviewers') && options.method !== 'POST') {
+      return { users: [{ login: 'imccyu' }], teams: [] }
+    }
+    if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+    throw new Error(`unexpected API path ${path}`)
+  }
+
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'turtle1999', changedFiles: files.length }),
+    ownershipSource,
+    api,
+    write: line => trace.push({ type: 'log', line }),
+  })
+
+  assert.deepEqual(result, {
+    changedCodeFiles: [
+      'packages/client/store/src/index.ts',
+      'packages/core/agent/src/index.ts',
+      'packages/preset/agent-presets/src/index.ts',
+      'packages/subagent/subagent/src/index.ts',
+    ],
+    excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
+    excludedDocumentationFiles: ['AGENTS.md'],
+    excludedCommentOnlyFiles: [],
+    requestedReviewers: ['Dudu-0223'],
+    cancelledReviewers: [],
+  })
+  assert.equal(trace[0].type, 'log')
+  assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
+  const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:')
+  const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST')
+  assert.ok(changedHeading >= 0 && changedHeading < post)
+  assert.deepEqual(trace[post], {
+    type: 'api',
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: {
+      method: 'POST',
+      body: { reviewers: ['Dudu-0223'] },
+    },
+  })
+})
+
+test('does not add an owner when two people are already requested', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent(),
+    ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts' }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'first' }, { login: 'second' }], teams: [] }
+      }
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result.requestedReviewers, [])
+  assert.equal(calls.some(call => call.options.method === 'POST'), false)
+})
+
+test('does not request reviewers for test, documentation, or comment-only changes', async () => {
+  const calls = []
+  const output = []
+  const files = [
+    { filename: 'apps/web/tests/chat.e2e.ts' },
+    { filename: 'packages/core/agent/tests/agent.spec.ts' },
+    { filename: 'packages/core/agent/README.md' },
+    { filename: 'packages/core/agent/examples.yaml' },
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+    },
+  ]
+  const result = await requestReviews({
+    event: pullRequestEvent({ changedFiles: files.length }),
+    ownershipSource,
+    api: async (path) => {
+      calls.push(path)
+      return files
+    },
+    write: line => output.push(line),
+  })
+  assert.deepEqual(result, {
+    changedCodeFiles: [],
+    excludedTestFiles: files.slice(0, 2).map(file => file.filename),
+    excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename),
+    excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'],
+    requestedReviewers: [],
+    cancelledReviewers: [],
+  })
+  assert.equal(calls.length, 1)
+  assert.deepEqual(output.slice(0, 4), [
+    'This is by automated Angry Turtle Cyborg, not a human',
+    'Changed code files:',
+    '- (none)',
+    'Excluded test files:',
+  ])
+})
+
+test('cancels workflow-authored review requests on draft pull requests', async () => {
+  const trace = []
+  const files = [
+    { filename: 'packages/subagent/subagent/src/index.ts' },
+    { filename: 'packages/subagent/subagent/tests/index.spec.ts' },
+    { filename: 'packages/subagent/subagent/README.md' },
+  ]
+  const result = await requestReviews({
+    event: pullRequestEvent({ draft: true, changedFiles: files.length }),
+    ownershipSource,
+    api: async (path, options = {}) => {
+      trace.push({ type: 'api', path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) return files
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) {
+        return [
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'Dudu-0223' },
+            review_requester: { login: 'maintainer' },
+          },
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'Dudu-0223' },
+            review_requester: { login: 'github-actions[bot]' },
+          },
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'manual-reviewer' },
+            review_requester: { login: 'github-actions[bot]' },
+          },
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'manual-reviewer' },
+            review_requester: { login: 'maintainer' },
+          },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => trace.push({ type: 'log', line }),
+  })
+  assert.deepEqual(result, {
+    changedCodeFiles: ['packages/subagent/subagent/src/index.ts'],
+    excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'],
+    excludedDocumentationFiles: ['packages/subagent/subagent/README.md'],
+    excludedCommentOnlyFiles: [],
+    requestedReviewers: [],
+    cancelledReviewers: ['Dudu-0223'],
+  })
+  const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE')
+  assert.deepEqual(remove, {
+    type: 'api',
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
+  })
+  assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false)
+  assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.')
+})
+
+test('sends authenticated JSON and escapes an API error body', async () => {
+  const requests = []
+  const api = createGitHubApi({
+    token: 'secret',
+    apiUrl: 'https://github.example/api/v3/',
+    fetchImpl: async (url, options) => {
+      requests.push({ url, options })
+      return new Response(JSON.stringify({ ok: true }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      })
+    },
+  })
+  assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true })
+  assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo')
+  assert.equal(requests[0].options.headers.Authorization, 'Bearer secret')
+  assert.equal(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10')
+  assert.equal(requests[0].options.body, '{"value":1}')
+
+  const failing = createGitHubApi({
+    token: 'secret',
+    fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }),
+  })
+  await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
+})

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

@@ -50,6 +50,11 @@ jobs:
       # does not keep burning enterprise runner time on the remaining gates.
       DSH_GATE_FAIL_FAST: '1'
     steps:
+      # Runner cleanup owns tool caches and fixtures, including cancelled runs.
+      - name: Use runner-owned temporary storage
+        run: |
+          echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
+
       # Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
       - uses: actions/checkout@v6
         with:
@@ -115,6 +120,11 @@ jobs:
       # multi-minute instrumented run.
       DSH_GATE_FAIL_FAST: '1'
     steps:
+      # Runner cleanup owns tool caches and fixtures, including cancelled runs.
+      - name: Use runner-owned temporary storage
+        run: |
+          echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
+
       - uses: actions/checkout@v6
         with:
           persist-credentials: false
@@ -141,9 +151,8 @@ jobs:
           store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
           echo "path=$store_path" >> "$GITHUB_OUTPUT"
 
-      # Skipped under failover: the self-hosted VM's persistent pnpm store
-      # already serves warm installs, while restoring the hosted archive
-      # would spend ~52 s pulling ~180 MB into that populated store.
+      # Self-hosted runners reuse the shared persistent pnpm store.
+      # Hosted runners restore the remote cache into their ephemeral store.
       - uses: actions/cache/restore@v4
         if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
@@ -235,6 +244,12 @@ jobs:
       # Failover halves snapshot concurrency for the shared 64-core VM.
       DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
     steps:
+      # Runner cleanup owns tool caches and fixtures, including cancelled runs.
+      - name: Use runner-owned temporary storage
+        run: |
+          echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"
+          echo "PLAYWRIGHT_BROWSERS_PATH=${RUNNER_TEMP%/*}/ms-playwright" >> "$GITHUB_ENV"
+
       - uses: actions/checkout@v6
         with:
           persist-credentials: false
@@ -264,8 +279,8 @@ jobs:
       # Pull requests restore the pnpm store and Playwright caches without paying
       # compression and upload on the required path. No master job saves these
       # hosted cache keys, so each restore-keys fallback hits the matching archived
-      # entry until it evicts, after which the store is cold. Skipped under failover
-      # — the self-hosted VM's persistent store is already warm.
+      # entry until it evicts, after which the store is cold. Self-hosted runners
+      # reuse the shared persistent store instead of restoring the remote archive.
       - uses: actions/cache/restore@v4
         if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
@@ -274,11 +289,11 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
 
-      # Skipped under failover: the VM's persistent browser cache is already warm.
+      # Self-hosted runners retain their own browser cache between jobs.
       - uses: actions/cache/restore@v4
         if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
         with:
-          path: ~/.cache/ms-playwright
+          path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }}
           key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
           restore-keys: |
             ${{ runner.os }}-playwright-

+ 31 - 0
.github/workflows/request-review.yml

@@ -0,0 +1,31 @@
+name: request-review
+
+on:
+  pull_request_target:
+    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
+
+permissions:
+  contents: read
+  pull-requests: write
+
+concurrency:
+  group: request-review-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+jobs:
+  request-review:
+    name: request-review
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      # SECURITY: the write-capable job executes policy from the trusted default
+      # branch and reads pull-request filenames only as API data.
+      - name: Check out trusted review policy
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          persist-credentials: false
+      - name: Request reviewers
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        run: node .github/review-ownership/request-review.mjs

+ 2 - 2
apps/cli/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: adab66bb1d7ed46039248a57c8722964f5aebd33
-README.zh.md: 4887a67872a569a0a54f0378aaceffe51cdc43c1
+README.md: 2e74ef68cf0b8487083a2e2af0f5175c78ec5212
+README.zh.md: 85a75f4c025e1bfe1461b9ffdaca533d308318ba

+ 2 - 1
apps/cli/README.md

@@ -9,6 +9,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
 | Command | Purpose |
 |---|---|
 | `dsh --profile <name>` | Boot the named profile under `$DSH_HOME/profiles/<name>`. |
+| `dsh --profile <name> --from-default-profile <template>` | Create a new custom profile from a shipped template, then boot it. |
 | `dsh --profile acp` | Serve automation clients over ACP stdio until disconnect. |
 | `dsh --profile headless "job"` | Run one fresh persisted session, print the final answer, and exit. |
 | `dsh --profile sdk` | Serve SDK clients over JSON-RPC stdio until shutdown or disconnect. |
@@ -16,7 +17,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
 | `dsh web` | Alias of `--profile web`. |
 | `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
 
-The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.
+The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.
 
 ## App arguments
 

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

@@ -9,6 +9,7 @@
 | 命令 | 用途 |
 |---|---|
 | `dsh --profile <name>` | 启动位于 `$DSH_HOME/profiles/<name>` 的指定 profile。 |
+| `dsh --profile <name> --from-default-profile <template>` | 从随附模板创建新的自定义 profile,然后启动它。 |
 | `dsh --profile acp` | 通过 ACP stdio 为自动化 client 提供服务,直至断开连接。 |
 | `dsh --profile headless "job"` | 运行一个全新的持久化会话,打印最终答案并退出。 |
 | `dsh --profile sdk` | 通过 JSON-RPC stdio 为 SDK client 提供服务,直至关闭或断开连接。 |
@@ -16,7 +17,7 @@
 | `dsh web` | `--profile web` 的别名。 |
 | `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
 
-运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI 会拒绝针对它的启动、配置 dump 和插件管理请求。
+运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI 会拒绝针对它的启动、配置 dump 和插件管理请求。
 
 ## 应用参数
 

+ 2 - 2
apps/cli/reference/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/cli/reference/README.md
-README.md: 5a276a3fa5bd0b96fcecb1e209146b17bcb4a95e
-README.zh.md: d5263652b909f13c8fc283b694eea1b18a0c037f
+README.md: 42fb2855e97465417ca284944102d3c6a610416d
+README.zh.md: 786774dae50a2dfcadbf3fd260b4680277c0d39e

+ 10 - 1
apps/cli/reference/README.md

@@ -12,9 +12,18 @@ Bundle names resolve from the dsh installation first, then from the profile dire
 
 The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loud with a hint to run `dsh plugin --profile <name> add <package>`.
 
+`dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list and `patchReload` value into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
+
+An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
+
+```sh
+dsh --profile rescue --from-default-profile web
+dsh --profile rescue
+```
+
 ### App arguments
 
-The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
+The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile rescue --from-default-profile web --no-open` therefore initializes before handing `--no-open` to Web, `dsh --profile web --port 8080` reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary.
 
 A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. In a `patchReload: live` profile, a patch-file edit re-evaluates expressions against services that are still up, so it cannot reset a served port.
 

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

@@ -4,6 +4,8 @@
 
 本参考定义 profile 启动、web 别名、插件管理和配置 dump 等命令模式。argv 由 [`src/args.ts`](../src/args.ts) 统一解析一次,[`src/bin.ts`](../src/bin.ts) 只会动态导入选中的运行器。
 
+<a id="profile-boot"></a>
+
 ## Profile 启动
 
 `dsh --profile <name>` 启动位于 `$DSH_HOME/profiles/<name>` 的 profile。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch <path>` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。`dsh.profile.patchReload` 可选择 `live` patch 文件监视或 `startup` 单次加载;自定义 profile 省略该值时默认使用 `live`。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。
@@ -12,9 +14,18 @@
 
 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile <name> add <package>`。
 
+`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的 bundle 列表和 `patchReload` 值复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置 bundle 仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
+
+profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在 bundle 解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
+
+```sh
+dsh --profile rescue --from-default-profile web
+dsh --profile rescue
+```
+
 ### 应用参数
 
-启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
+启动器自身的 flag 必须写在最前面,并在遇到第一个无法识别的 token 时结束;从该 token 开始的所有内容都会通过 `ctx.cmdlineArgs` 原样交给已启动的 profile,注入该 profile 的任意应用插件都可以解析这些内容([`dsh-cmdline`](../../../packages/boot/cmdline/README.zh.md))。因此,`dsh --profile rescue --from-default-profile web --no-open` 会先初始化,再把 `--no-open` 交给 Web;`dsh --profile web --port 8080` 会将 `--port` 交给 web 应用;`dsh --profile web --help` 只打印该应用的帮助信息,不启动应用;`dsh --help` 没有可供交付参数的 profile,因此会打印启动器自身的帮助信息。`-V`/`--version` 位于应用参数边界之前时,会打印启动器的版本。
 
 每套组合只会挂载一次。普通插件注入 `cmdlineArgs`,解析所属应用的参数,并将解析结果作为服务提供。每个从 flag 取值的配置行都会注入该服务;Loader 会等到服务激活后,再对该行的配置求值(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 的优先级高于配置行中写明的值。要维持这一优先级,配置行必须保留该表达式;如果用户 patch 用字面量替换整个 `config`,也会随之移除运行时读取。帮助参数和被拒绝的参数都会请求退出:参数被拒绝时以非零状态退出,显示帮助时以 0 退出;依赖该提供方服务的配置行不会激活。在 `patchReload: live` profile 中,编辑 patch 文件会根据仍在运行的服务重新计算表达式,因此不会重置当前正在使用的端口。
 

+ 16 - 4
apps/cli/src/args.ts

@@ -21,6 +21,8 @@ import { Command, CommanderError } from 'commander'
 interface ProfileInvocation {
   mode: 'profile'
   profile: string
+  /** Shipped template used once to initialize a missing profile. */
+  fromDefaultProfile?: string | undefined
   /** Extra patch-list overlays applied after the profile's own layer, in argv order. */
   patches: string[]
   /** Everything after the launcher's own flags, verbatim, for injected app plugins. */
@@ -31,6 +33,8 @@ interface ProfileInvocation {
 interface DumpConfigInvocation {
   mode: 'dump-config'
   profile: string
+  /** Shipped template used once to initialize a missing profile. */
+  fromDefaultProfile?: string | undefined
   /** Omit the profile's user layer and --patch overlays; print bundle layers only. */
   defaultOnly: boolean
   patches: string[]
@@ -52,6 +56,7 @@ interface BootOptions {
   patch?: string[]
   dumpConfig?: boolean
   dumpDefaultConfig?: boolean
+  fromDefaultProfile?: string
 }
 
 /**
@@ -70,6 +75,8 @@ function rejectElectronProfile(program: Command, profile: string): void {
 const HELP_EXAMPLES = `
 Examples:
   dsh --profile web                          boot the web profile (same as: dsh web)
+  dsh --profile rescue --from-default-profile web
+                                             create rescue from the shipped web template, then boot it
   dsh --profile headless "run the tests"     answer one task, print the result, and exit
   dsh --profile tui --patch ./extra.yml      boot a custom profile with one extra overlay
   dsh --profile tui --resume <session>       arguments after the launcher flags reach the app
@@ -89,8 +96,9 @@ Examples:
 function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation {
   const patches = options.patch ?? []
   if (patches.includes('')) program.error('error: --patch needs a path')
+  if (options.fromDefaultProfile === '') program.error('error: --from-default-profile needs a name')
   if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
-    return { mode: 'profile', profile, patches, args }
+    return { mode: 'profile', profile, fromDefaultProfile: options.fromDefaultProfile, patches, args }
   }
   if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
     program.error('error: --dump-config and --dump-default-config are mutually exclusive')
@@ -105,7 +113,7 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar
   if (defaultOnly && patches.length > 0) {
     program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
   }
-  return { mode: 'dump-config', profile, defaultOnly, patches }
+  return { mode: 'dump-config', profile, fromDefaultProfile: options.fromDefaultProfile, defaultOnly, patches }
 }
 
 /**
@@ -135,6 +143,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
     .enablePositionalOptions()
     .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile <name> --help)')
     .option('--profile <name>', 'the profile under $DSH_HOME/profiles to boot')
+    .option('--from-default-profile <name>', 'initialize a new custom profile from a shipped profile template')
     .option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
     .option('--dump-config', 'print the composed profile tree and exit')
     .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
@@ -155,8 +164,11 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
   const rejectParentOptions = (command: string): void => {
     const parent = program.opts<BootOptions & { profile?: string }>()
     if (parent.profile !== undefined || parent.patch !== undefined
-      || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
-      program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`)
+      || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined
+      || parent.fromDefaultProfile !== undefined) {
+      program.error(
+        `error: ${command} takes none of parent --profile, --from-default-profile, --patch, --dump-config, or --dump-default-config`,
+      )
     }
   }
 

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

@@ -34,6 +34,7 @@ export async function runCli(): Promise<void> {
       await runProfile({
         environment: loadLayeredEnv('dsh'),
         profile: invocation.profile,
+        fromDefaultProfile: invocation.fromDefaultProfile,
         patchFiles: invocation.patches,
         args: invocation.args,
       })
@@ -46,7 +47,12 @@ export async function runCli(): Promise<void> {
     }
     case 'dump-config': {
       const { runDumpConfig } = await import('./dump-config.ts')
-      runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
+      runDumpConfig(
+        invocation.profile,
+        invocation.defaultOnly,
+        invocation.patches,
+        invocation.fromDefaultProfile,
+      )
       break
     }
     default:

+ 8 - 2
apps/cli/src/dump-config.ts

@@ -26,9 +26,15 @@ const NAME = 'dsh'
  * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
  * never parsed).
  * @param patches - `--patch` overlay paths, in argv order.
+ * @param fromDefaultProfile - shipped template used once to initialize a missing profile.
  */
-export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
-  const loaded = prepareProfile(profile, !defaultOnly)
+export function runDumpConfig(
+  profile: string,
+  defaultOnly: boolean,
+  patches: readonly string[],
+  fromDefaultProfile?: string,
+): void {
+  const loaded = prepareProfile(profile, !defaultOnly, fromDefaultProfile)
   const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
     label: layer.packageName,
     patches: layer.patches,

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

@@ -11,8 +11,8 @@
  * @module @deepseek-ai/dsh/profile-boot
  */
 
-import { writeFileSync } from 'node:fs'
-import { join, resolve } from 'node:path'
+import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { FiberState, type Context } from '@deepseek-ai/cordis'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -21,11 +21,14 @@ import {
   boot,
   composeEntries,
   healProfilesModuleFallback,
+  initProfile,
   installFailLoud,
   loadOptionalPatches,
   loadOverlayPatches,
   loadProfile,
   PROFILE_PATCH_FILENAME,
+  PROFILE_TEMPLATES,
+  resolveProfileDir,
   watchUserPatches,
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
@@ -87,6 +90,69 @@ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tre
 /** Root config filename inside a profile directory. */
 export const PROFILE_ROOT_FILENAME = 'cordis.yml'
 
+/**
+ * Initialize a missing profile from one shipped template. This copies only
+ * the template's bundle list and patch-reload policy; local state from the
+ * same-named shipped profile is not read, and no inheritance metadata is
+ * persisted. Shipped profile names are reserved, and the target directory is
+ * claimed exclusively so existing or concurrent state is never reused.
+ * @param name - the new profile name.
+ * @param fromDefaultProfile - shipped profile template to copy.
+ * @param home - Harness home containing the profile directory.
+ * @throws when the template is unknown, the target name is shipped, or the target directory exists.
+ */
+export function initializeProfileFromDefault(
+  name: string,
+  fromDefaultProfile: string,
+  home: string = resolveDshHome(),
+): void {
+  const dir = resolveProfileDir(name, home)
+  const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)
+    ? PROFILE_TEMPLATES[fromDefaultProfile]
+    : undefined
+  if (template === undefined) {
+    const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')
+    throw new Error(
+      `${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
+    )
+  }
+  if (Object.hasOwn(PROFILE_TEMPLATES, name)) {
+    throw new Error(
+      `${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `
+      + 'omit --from-default-profile to use it',
+    )
+  }
+  mkdirSync(dirname(dir), { recursive: true })
+  try {
+    mkdirSync(dir)
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+    const manifestPath = join(dir, 'package.json')
+    if (existsSync(manifestPath)) {
+      throw new Error(
+        `${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `
+        + 'omit --from-default-profile to use it',
+      )
+    }
+    throw new Error(
+      `${NAME}: profile directory ${dir} already exists; choose an unused profile name`,
+    )
+  }
+  try {
+    initProfile(dir, template.bundles, template.patchReload)
+  } catch (error) {
+    try {
+      rmSync(dir, { recursive: true, force: true })
+    } catch (cleanupError) {
+      throw new AggregateError(
+        [error, cleanupError],
+        `${NAME}: profile initialization failed and ${dir} could not be removed`,
+      )
+    }
+    throw error
+  }
+}
+
 /**
  * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
  * value (including `'0'`/`'false'`) disables: a privacy switch prefers
@@ -114,9 +180,12 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b
  * the identical base).
  * @param name - the profile name.
  * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
+ * @param fromDefaultProfile - shipped template used once to initialize a missing profile.
  * @returns the loaded profile.
+ * @throws when explicit initialization names an unknown template or an existing profile.
  */
-export function prepareProfile(name: string, userLayer = true): Profile {
+export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
+  if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
   const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
   writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
   return profile
@@ -157,8 +226,9 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
 async function composeProfile(
   name: string,
   patchFiles: readonly string[],
+  fromDefaultProfile?: string,
 ): Promise<ComposedProfile> {
-  const profile = prepareProfile(name)
+  const profile = prepareProfile(name, true, fromDefaultProfile)
   await healProfilesModuleFallback({ installAnchor: INSTALL_ANCHOR, profile })
   const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
   const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
@@ -179,6 +249,8 @@ export interface RunProfileOptions {
   environment: LaunchEnvironmentSnapshot
   /** The profile name to boot. */
   profile: string
+  /** Shipped template used once to initialize a missing profile. */
+  fromDefaultProfile?: string | undefined
   /** `--patch` overlay paths, in argv order. */
   patchFiles: readonly string[]
   /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
@@ -217,7 +289,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     (message) => { process.stderr.write(`${NAME}: ${message}\n`) },
   )
 
-  const composed = await composeProfile(options.profile, options.patchFiles)
+  const composed = await composeProfile(options.profile, options.patchFiles, options.fromDefaultProfile)
   const app: { current?: Context } = {}
   const appReady = createAppReady()
   const shutdown = createProcessShutdown(async () => {

+ 22 - 0
apps/cli/tests/args.spec.ts

@@ -25,6 +25,8 @@ describe('parseDshArgs', () => {
     expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] })
     expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml']))
       .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] })
+    expect(parse(['--profile', 'rescue', '--from-default-profile', 'web']))
+      .toEqual({ mode: 'profile', profile: 'rescue', fromDefaultProfile: 'web', patches: [], args: [] })
     expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] })
     expect(parse(['web', '--patch', 'web.yml']))
       .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] })
@@ -43,6 +45,15 @@ describe('parseDshArgs', () => {
     // Launcher flags placed after that boundary belong to the app too.
     expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml']))
       .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] })
+    expect(parse(['--profile', 'rescue', '--resume', 'abc', '--from-default-profile', 'web']))
+      .toEqual({
+        mode: 'profile',
+        profile: 'rescue',
+        patches: [],
+        args: ['--resume', 'abc', '--from-default-profile', 'web'],
+      })
+    expect(parse(['web', '--from-default-profile', 'web']))
+      .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--from-default-profile', 'web'] })
   })
 
   it('routes the plugin pnpm forwarder', () => {
@@ -62,6 +73,14 @@ describe('parseDshArgs', () => {
       .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
     expect(parse(['--profile', 'web', '--dump-default-config']))
       .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
+    expect(parse(['--profile', 'rescue', '--from-default-profile', 'web', '--dump-config']))
+      .toEqual({
+        mode: 'dump-config',
+        profile: 'rescue',
+        fromDefaultProfile: 'web',
+        defaultOnly: false,
+        patches: [],
+      })
     expect(parse(['--profile', 'tui', '--dump-config', '--patch', 'x.yml']))
       .toEqual({ mode: 'dump-config', profile: 'tui', defaultOnly: false, patches: ['x.yml'] })
     expect(parse(['web', '--dump-config']))
@@ -77,6 +96,8 @@ describe('parseDshArgs', () => {
     expect(exitCode(['-p', 'task'])).toBe(1) // removed
     expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand
     expect(exitCode(['--profile', ''])).toBe(1)
+    expect(exitCode(['--profile', 'x', '--from-default-profile='])).toBe(1)
+    expect(exitCode(['--profile', 'x', '--from-default-profile'])).toBe(1)
     expect(exitCode(['--profile', 'x', '--patch='])).toBe(1)
     expect(exitCode(['--dump-config'])).toBe(1)
     expect(exitCode(['--profile', 'x', '--dump-config', '--dump-default-config'])).toBe(1)
@@ -102,6 +123,7 @@ describe('parseDshArgs', () => {
     expect(exitCode(['plugin', '--profile', 'desktop', 'add', 'x'])).toBe(1)
     expect(exitCode(['plugin', '--profile', 'Desktop', 'add', 'x'])).toBe(1)
     expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1)
+    expect(exitCode(['--from-default-profile', 'web', 'plugin', '--profile', 'x', 'add', 'y'])).toBe(1)
   })
 
   it('keeps its own help for an invocation with no app to hand it to', () => {

+ 91 - 0
apps/cli/tests/built-bin.e2e.ts

@@ -648,6 +648,74 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     }
   }, SPAWN_TIMEOUT_MS + 30_000)
 
+  it('creates a custom profile from the shipped web template before booting it', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-'))
+    try {
+      const created = await runBuiltBin(
+        ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
+        { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
+      )
+      expect(created.code).toBe(0)
+      expect(created.stderr).toBe('')
+      expect(created.stdout).toContain('Usage: dsh --profile web')
+
+      const dir = join(home, 'profiles', 'rescue')
+      const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
+        dependencies: Record<string, string>
+        dsh: { profile: { bundles: string[]; patchReload: string } }
+      }
+      expect(manifest.dependencies).toEqual({})
+      expect(manifest.dsh.profile).toEqual({
+        bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
+        patchReload: 'live',
+      })
+      expect(readFileSync(join(dir, 'cordis.patch.yml'), 'utf8')).toContain('[]')
+      expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
+
+      const repeated = await runBuiltBin(
+        ['--profile', 'rescue', '--from-default-profile', 'web', '--help'],
+        { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
+      )
+      expect(repeated.code).toBe(1)
+      expect(repeated.stdout).toBe('')
+      expect(repeated.stderr).toContain('profile "rescue" already exists')
+      expect(repeated.stderr).toContain('omit --from-default-profile to use it')
+
+      const reopened = await runBuiltBin(
+        ['--profile', 'rescue', '--help'],
+        { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
+      )
+      expect(reopened.code).toBe(0)
+      expect(reopened.stderr).toBe('')
+      expect(reopened.stdout).toContain('Usage: dsh --profile web')
+    } finally {
+      rmSync(home, { recursive: true, force: true })
+    }
+  }, SPAWN_TIMEOUT_MS * 3 + 30_000)
+
+  it('keeps a newly created profile when application boot rejects its arguments', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-from-default-profile-failed-boot-'))
+    try {
+      const failed = await runBuiltBin(
+        ['--profile', 'rescue', '--from-default-profile', 'web', '--port', 'not-a-number'],
+        { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
+      )
+      expect(failed.code).toBe(1)
+      expect(failed.stderr).toContain('--port must be a number')
+      expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
+
+      const retried = await runBuiltBin(
+        ['--profile', 'rescue', '--help'],
+        { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' },
+      )
+      expect(retried.code).toBe(0)
+      expect(retried.stderr).toBe('')
+      expect(retried.stdout).toContain('Usage: dsh --profile web')
+    } finally {
+      rmSync(home, { recursive: true, force: true })
+    }
+  }, SPAWN_TIMEOUT_MS * 2 + 30_000)
+
   it('uses the launching endpoint and managed credential through the published entry', async () => {
     const apiKey = 'built-home-layer-key'
     const server = await startMockLlmServer({
@@ -955,6 +1023,29 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
       expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
     }, SPAWN_TIMEOUT_MS + 30_000)
 
+    it('creates a custom profile from a shipped template before printing it', async () => {
+      const { stdout, code, stderr } = await runBuiltBin(
+        ['--profile', 'rescue', '--from-default-profile', 'web', '--dump-default-config'],
+        { DSH_HOME: home },
+      )
+      expect(code).toBe(0)
+      expect(stderr).toBe('')
+      expect(stdout).toContain('# == @deepseek-ai/dsh-web-app')
+      expect(existsSync(join(home, 'profiles', 'rescue', 'package.json'))).toBe(true)
+    }, SPAWN_TIMEOUT_MS + 30_000)
+
+    it('rejects an unknown source before creating the target profile', async () => {
+      const { stdout, code, stderr } = await runBuiltBin(
+        ['--profile', 'rescue', '--from-default-profile', 'unknown', '--dump-default-config'],
+        { DSH_HOME: home },
+      )
+      expect(code).toBe(1)
+      expect(stdout).toBe('')
+      expect(stderr).toContain('unknown default profile "unknown"')
+      expect(stderr).toContain('"web"')
+      expect(existsSync(join(home, 'profiles', 'rescue'))).toBe(false)
+    }, SPAWN_TIMEOUT_MS + 30_000)
+
     it('prints the headless profile without Host or browser layers', async () => {
       const { stdout, code, stderr } = await runBuiltBin(
         ['--profile', 'headless', '--dump-default-config'],

+ 18 - 0
apps/cli/tests/fixtures/initialize-profile-from-default.ts

@@ -0,0 +1,18 @@
+/** Synchronize competing source-process profile initialization for the race acceptance test. */
+
+import { existsSync, writeFileSync } from 'node:fs'
+import { setTimeout as delay } from 'node:timers/promises'
+import { initializeProfileFromDefault } from '../../src/profile-boot.ts'
+
+const [home, name, source, ready, gate] = process.argv.slice(2)
+if (home === undefined || name === undefined || source === undefined || ready === undefined || gate === undefined) {
+  throw new Error('profile initialization fixture requires home, name, source, ready, and gate')
+}
+
+writeFileSync(ready, '')
+const deadline = Date.now() + 30_000
+while (!existsSync(gate)) {
+  if (Date.now() >= deadline) throw new Error(`profile initialization fixture timed out waiting for ${gate}`)
+  await delay(20)
+}
+initializeProfileFromDefault(name, source, home)

+ 158 - 0
apps/cli/tests/profile-initialization.spec.ts

@@ -0,0 +1,158 @@
+/** One-time custom-profile initialization from shipped templates. */
+
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import {
+  initProfile,
+  PROFILE_PATCH_FILENAME,
+  PROFILE_TEMPLATES,
+  readProfileManifest,
+  resolveProfileDir,
+  writeProfileManifest,
+} from '@deepseek-ai/dsh-app-boot'
+import { describe, expect, it } from 'vitest'
+import { execa } from 'execa'
+import { initializeProfileFromDefault } from '../src/profile-boot.ts'
+
+const childEntry = fileURLToPath(new URL('./fixtures/initialize-profile-from-default.ts', import.meta.url))
+const tsxLoader = import.meta.resolve('tsx/esm')
+const CHILD_TIMEOUT_MS = 30_000
+
+/** Wait until a child has reached the shared creation barrier. */
+async function waitForFile(file: string): Promise<void> {
+  const deadline = Date.now() + CHILD_TIMEOUT_MS
+  while (!existsSync(file)) {
+    if (Date.now() >= deadline) throw new Error(`profile initialization marker did not appear: ${file}`)
+    await new Promise(resolve => setTimeout(resolve, 20))
+  }
+}
+
+/** Run one assertion against a private Harness home and remove it afterwards. */
+function withHome(assertion: (home: string) => void): void {
+  const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-'))
+  try {
+    assertion(home)
+  } finally {
+    rmSync(home, { recursive: true, force: true })
+  }
+}
+
+describe('initializeProfileFromDefault', () => {
+  it.each(Object.entries(PROFILE_TEMPLATES))(
+    'copies the %s template metadata into an independent profile',
+    (source, template) => {
+      withHome((home) => {
+        initializeProfileFromDefault('custom', source, home)
+        const dir = resolveProfileDir('custom', home)
+        const manifest = readProfileManifest('test', dir)
+        expect(manifest).toEqual({
+          name: 'dsh-profile-custom',
+          private: true,
+          dependencies: {},
+          dsh: { profile: { bundles: [...template.bundles], patchReload: template.patchReload } },
+        })
+        expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]')
+        expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted')
+      })
+    },
+  )
+
+  it('does not copy the local source profile dependencies or user patch', () => {
+    withHome((home) => {
+      const sourceDir = resolveProfileDir('web', home)
+      initProfile(sourceDir, ['local-bundle'], 'startup')
+      const sourceManifest = readProfileManifest('test', sourceDir)
+      sourceManifest.dependencies = { 'local-bundle': '1.0.0' }
+      writeProfileManifest(sourceDir, sourceManifest)
+      writeFileSync(join(sourceDir, PROFILE_PATCH_FILENAME), '- id: local-only\n  disabled: true\n')
+
+      initializeProfileFromDefault('rescue', 'web', home)
+
+      const targetDir = resolveProfileDir('rescue', home)
+      const target = readProfileManifest('test', targetDir)
+      expect(target.dependencies).toEqual({})
+      expect(target.dsh?.profile).toEqual({
+        bundles: [...PROFILE_TEMPLATES.web!.bundles],
+        patchReload: PROFILE_TEMPLATES.web!.patchReload,
+      })
+      expect(readFileSync(join(targetDir, PROFILE_PATCH_FILENAME), 'utf8')).not.toContain('local-only')
+    })
+  })
+
+  it('rejects an existing target without changing its files', () => {
+    withHome((home) => {
+      const dir = resolveProfileDir('rescue', home)
+      initProfile(dir, ['existing-bundle'], 'startup')
+      writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: existing\n  disabled: true\n')
+      const paths = ['package.json', PROFILE_PATCH_FILENAME, 'pnpm-workspace.yaml'].map(file => join(dir, file))
+      const before = paths.map(path => readFileSync(path))
+
+      expect(() => {
+        initializeProfileFromDefault('rescue', 'web', home)
+      })
+        .toThrow('profile "rescue" already exists')
+      expect(paths.map(path => readFileSync(path))).toEqual(before)
+    })
+  })
+
+  it('rejects a residual target directory without changing its contents', () => {
+    withHome((home) => {
+      const dir = resolveProfileDir('rescue', home)
+      mkdirSync(dir, { recursive: true })
+      const residual = join(dir, PROFILE_PATCH_FILENAME)
+      writeFileSync(residual, '- id: residual\n  disabled: true\n')
+      const before = readFileSync(residual)
+
+      expect(() => {
+        initializeProfileFromDefault('rescue', 'web', home)
+      })
+        .toThrow('profile directory')
+      expect(readFileSync(residual)).toEqual(before)
+      expect(existsSync(join(dir, 'package.json'))).toBe(false)
+    })
+  })
+
+  it.each(Object.keys(PROFILE_TEMPLATES))('rejects shipped target name %s without creating it', (name) => {
+    withHome((home) => {
+      expect(() => {
+        initializeProfileFromDefault(name, 'web', home)
+      })
+        .toThrow(`profile ${JSON.stringify(name)} is shipped`)
+      expect(existsSync(resolveProfileDir(name, home))).toBe(false)
+    })
+  })
+
+  it.each(['unknown', 'toString'])('rejects unknown template %s without creating the target', (source) => {
+    withHome((home) => {
+      expect(() => {
+        initializeProfileFromDefault('rescue', source, home)
+      })
+        .toThrow(`unknown default profile ${JSON.stringify(source)}`)
+      expect(existsSync(resolveProfileDir('rescue', home))).toBe(false)
+    })
+  })
+
+  it('allows only one of two synchronized processes to create the target', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'dsh-profile-from-default-race-'))
+    const gate = join(home, 'start')
+    const ready = [join(home, 'ready-1'), join(home, 'ready-2')]
+    const children = ready.map(marker => execa(
+      process.execPath,
+      ['--import', tsxLoader, childEntry, home, 'rescue', 'web', marker, gate],
+      { reject: false, timeout: CHILD_TIMEOUT_MS },
+    ))
+    try {
+      await Promise.all(ready.map(waitForFile))
+      writeFileSync(gate, '')
+      const results = await Promise.all(children)
+      expect(results.map(result => result.exitCode).sort()).toEqual([0, 1])
+      expect(readProfileManifest('test', resolveProfileDir('rescue', home)).dsh?.profile)
+        .toEqual(PROFILE_TEMPLATES.web)
+    } finally {
+      for (const child of children) child.kill('SIGKILL')
+      rmSync(home, { recursive: true, force: true })
+    }
+  }, CHILD_TIMEOUT_MS + 10_000)
+})

+ 2 - 1
apps/web/package.json

@@ -54,6 +54,7 @@
     "typescript": "^6.0.3",
     "vite": "^6.0.0",
     "vitest": "^4.1.8",
-    "ws": "8.21.0"
+    "ws": "8.21.0",
+    "@deepseek-ai/dsh-launch-environment": "workspace:^"
   }
 }

+ 1 - 1
apps/web/tests/built-boot.expected.e2e.ts

@@ -112,7 +112,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
   fireEvent.click(contextTrigger)
   const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
   within(contextPanel).getByText('System prompt')
-  within(contextPanel).getByText('Tools')
+  within(contextPanel).getByText('Tool definitions')
   within(contextPanel).getByText('Messages')
 
   // The write/edit turns render a real diff card through the assembled graph

+ 1 - 1
apps/web/tests/command-image-envelope.expected.e2e.ts

@@ -21,7 +21,7 @@ async function freshComposer(): Promise<HTMLElement> {
   fireEvent.click(start)
   return await waitFor(() => {
     const surface = document.querySelector<HTMLElement>(
-      '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
+      '[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
     )
     if (surface === null) throw new Error('composer surface missing')
     return surface

+ 4 - 2
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -192,7 +192,7 @@
 - text: {{clock}}
 - button "Back to bottom":
   - img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -202,4 +202,6 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}}
+- button "1 turns 2 steps":
+  - img
+  - text: 1 turns 2 steps

+ 4 - 2
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -50,7 +50,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -60,4 +60,6 @@
   - text: github-webhook-review-test/reply
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}}
+- button "1 turns 1 steps":
+  - img
+  - text: 1 turns 1 steps

+ 4 - 2
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -42,7 +42,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -52,4 +52,6 @@
   - text: github-webhook-review-test/reply
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}}
+- button "1 turns 1 steps":
+  - img
+  - text: 1 turns 1 steps

+ 8 - 0
apps/web/tests/expected/goal-bar/inactive.expected.md

@@ -0,0 +1,8 @@
+- img
+- text: Inactive Goal guard rapid clear clicks
+- button "Resume goal":
+  - img
+- button "Edit goal":
+  - img
+- button "Clear goal":
+  - img

+ 1 - 1
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -16,7 +16,7 @@
   - img
   - img
   - text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":

+ 4 - 2
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -50,7 +50,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -60,4 +60,6 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}}
+- button "1 turns 1 steps":
+  - img
+  - text: 1 turns 1 steps

+ 4 - 2
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -29,7 +29,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -39,4 +39,6 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}}
+- button "1 turns 1 steps":
+  - img
+  - text: 1 turns 1 steps

+ 4 - 2
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -41,7 +41,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -51,4 +51,6 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}}
+- button "1 turns 1 steps":
+  - img
+  - text: 1 turns 1 steps

+ 4 - 2
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -45,7 +45,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -55,4 +55,6 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}}
+- button "1 turns 1 steps":
+  - img
+  - text: 1 turns 1 steps

+ 1 - 1
apps/web/tests/expected/reference-composer/order.expected.md

@@ -15,7 +15,7 @@
 - button "Session recall Research notes":
   - img
   - text: Session recall Research notes
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":

+ 1 - 1
apps/web/tests/expected/settings-chrome/dialog-en.expected.md

@@ -44,7 +44,7 @@
   - button "Compact":
     - text: Compact
     - img
-  - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior
+  - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
   - button "Queue":
     - text: Queue
     - img

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

@@ -44,7 +44,7 @@
   - button "Compact":
     - text: Compact
     - img
-  - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
+  - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
   - button "排队发送":
     - text: 排队发送
     - img

+ 7 - 2
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -42,7 +42,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -53,4 +53,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok
+- button "1 turns 1 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 1 steps{{throughput}} tok/s
+- button "272 tok · Cache hit 0%":
+  - img
+  - text: 272 tokCache hit 0%

+ 7 - 2
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -34,7 +34,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -45,4 +45,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok
+- button "1 turns 1 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 1 steps{{throughput}} tok/s
+- button "272 tok · Cache hit 0%":
+  - img
+  - text: 272 tokCache hit 0%

+ 4 - 2
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -461,7 +461,7 @@
 - text: 7/25 {{clock}}
 - button "Back to bottom":
   - img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -471,4 +471,6 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 28 turns · 28 steps LLM {{duration}}
+- button "28 turns 28 steps":
+  - img
+  - text: 28 turns 28 steps

+ 1 - 1
apps/web/tests/expected/steer-all/mid-steer.expected.md

@@ -33,7 +33,7 @@
 - text: "Interjection: include the word ORANGE in your final reply."
 - button "Copy":
   - img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":

+ 7 - 2
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -52,7 +52,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -63,4 +63,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok
+- button "1 turns 2 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 2 steps{{throughput}} tok/s
+- button "40 tok · Cache hit 0%":
+  - img
+  - text: 40 tokCache hit 0%

+ 7 - 2
apps/web/tests/expected/steer-all/settled.expected.md

@@ -40,7 +40,7 @@
   - img
   - text: Ran for {{duration}}
 - text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
 - button "Commands":
   - img
 - button "Add attachment":
@@ -51,4 +51,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok
+- button "1 turns 2 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 2 steps{{throughput}} tok/s
+- button "40 tok · Cache hit 0%":
+  - img
+  - text: 40 tokCache hit 0%

+ 1 - 1
apps/web/tests/feedback-release.e2e.ts

@@ -267,7 +267,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
     await selectModel('DeepSeek-V4-Flash')
     expect(captured()).toHaveLength(releasedCount)
     await page.getByRole('button', { name: 'New session', exact: true }).last().click()
-    const input = page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
+    const input = page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
     await input.waitFor({ timeout: 15_000 })
     await input.fill('/feedback Feedback before any model request.')
     expect(captured()).toHaveLength(releasedCount)

+ 15 - 2
apps/web/tests/goal-bar.e2e.ts

@@ -16,6 +16,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url))
 const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
+const INACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'inactive.expected.md')
 const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
 const MODE = webSnapshotMode()
 
@@ -45,16 +46,28 @@ describe('web e2e: goal bar clear convergence', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
     // Startup reuses the fixture workspace's blank session, keeping this
     // command independent of alpha's running replay and pending question.
-    const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
+    const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
     await input.waitFor({ timeout: 10_000 })
     await input.fill('/goal guard rapid clear clicks')
     await input.press('Enter')
 
     const bar = page.locator('[data-goal-bar]')
     await bar.waitFor({ timeout: 10_000 })
+    await expect.poll(() => bar.getByRole('button', { name: 'Pause goal' }).count(), {
+      timeout: 10_000,
+    }).toBe(1)
     const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
 
+    await page.evaluate(() => {
+      (globalThis as unknown as { __fxTiming?: { disarmOnlyGoal(): void } }).__fxTiming?.disarmOnlyGoal()
+    })
+    await expect.poll(() => bar.getByRole('button', { name: 'Resume goal' }).count(), {
+      timeout: 10_000,
+    }).toBe(1)
+    const inactive = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(INACTIVE_EXPECTED, inactive, MODE)
+
     const clear = bar.getByRole('button', { name: 'Clear goal' })
     await clear.evaluate((button) => {
       const control = button as HTMLButtonElement
@@ -68,6 +81,6 @@ describe('web e2e: goal bar clear convergence', () => {
   }, 60_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md', 'inactive.expected.md'])
   })
 })

+ 3 - 3
apps/web/tests/image-display.expected.e2e.ts

@@ -90,7 +90,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
   // this assembled lane pins the intake chain over the built graph.
   const textarea = await waitFor(() => {
     const surface = document.querySelector<HTMLElement>(
-      '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
+      '[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
     )
     if (surface === null) throw new Error('composer surface missing')
     return surface
@@ -161,7 +161,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
   fireEvent.click(start)
   const textarea = await waitFor(() => {
     const surface = document.querySelector<HTMLElement>(
-      '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
+      '[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
     )
     if (surface === null) throw new Error('composer surface missing')
     return surface
@@ -216,7 +216,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
 
   const textarea = await waitFor(() => {
     const surface = document.querySelector<HTMLElement>(
-      '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
+      '[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
     )
     if (surface === null) throw new Error('composer surface missing')
     return surface

+ 26 - 3
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -23,7 +23,7 @@ import {
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import {
-  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft,
+  connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE,
 } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url))
@@ -31,6 +31,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
 const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
 const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
+const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md')
 const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
 const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
 const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md')
@@ -103,6 +104,26 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     await expect.poll(() => menu.count()).toBe(0)
   })
 
+  it.skipIf(MODE === 'record')('localizes slash-command descriptions from the browser language', async () => {
+    const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    const zhTripwire = watchConsole(zhPage)
+    onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-command-menu-zh'))
+    try {
+      await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+      await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      const launcher = zhPage.getByRole('button', { name: '指令' })
+      await launcher.click()
+      const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
+      await menu.waitFor({ timeout: 10_000 })
+      const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE)
+      expect(zhTripwire.pageErrors).toEqual([])
+      expect(zhTripwire.warnings).toEqual([])
+    } finally {
+      await zhPage.close()
+    }
+  })
+
   it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
     const activeScaffold = await launchWebScaffold()
     const activePage = await newEnglishPage(browser)
@@ -222,7 +243,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     ).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
-    await expect.poll(() => page.getByText('Cache hit 99.5%', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // The usage pill's one label span concatenates the billed total and the cache-hit share.
+    await expect.poll(() => page.getByRole('button', { name: /Cache hit 99\.5%/ }).count(), { timeout: 15_000 }).toBe(1)
     // Host: the session's durable header cwd is the folder the workspace
     // flow created and adopted (<workspaceCwd>/workspace) — the proof the
     // send went through workspace materialization rather than a bare
@@ -414,7 +436,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'session.v2.jsonl', 'replay.override.json', 'command-menu.expected.md',
-      'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md',
+      'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md',
+      'hero.expected.md', 'plan-active.expected.md',
       'reloaded.expected.md', 'reloaded-expanded.expected.md',
     ])
   })

+ 4 - 1
apps/web/tests/live-interactions.e2e.ts

@@ -166,9 +166,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
 
     const input = page.locator('[data-composer-input]').first()
     await input.fill(RUNNING_DRAFT)
-    const send = page.getByRole('button', { name: 'Send message', exact: true })
+    // The running primary names its delivery: the default busy-state
+    // preference is Queue, so the button reads Queue rather than plain Send.
+    const send = page.getByRole('button', { name: 'Queue message', exact: true })
     await send.waitFor({ timeout: 10_000 })
     expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).count()).toBe(0)
+    expect(await page.getByRole('button', { name: 'Send message', exact: true }).count()).toBe(0)
     const runningDraftSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(RUNNING_DRAFT_EXPECTED, runningDraftSnapshot, MODE)
     await send.click()

+ 1 - 1
apps/web/tests/math-rendering.e2e.ts

@@ -123,7 +123,7 @@ describe('web e2e: settled Markdown math rendering', () => {
     await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
     expect(await page.locator('.katex-error').count()).toBe(0)
     await expect.poll(
-      () => page.getByText('1 turns · 1 steps', { exact: false }).count(),
+      () => page.getByText('1 turns 1 steps', { exact: false }).count(),
       { timeout: 10_000 },
     ).toBe(1)
 

+ 73 - 0
apps/web/tests/open-in-app-ssh.e2e.ts

@@ -0,0 +1,73 @@
+/** SSH launch behavior over a recorded conversation and the shipped Web plugin rows. */
+import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/open-in-app-ssh', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url))
+const SEED_ID = 'open-in-app-ssh-web-e2e'
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({
+      openInAppEnvironment: createLaunchEnvironmentSnapshot([
+        { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } },
+      ]),
+    })
+    await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.addInitScript(() => {
+      localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode'))
+    })
+  })
+
+  afterAll(async () => {
+    const failures: unknown[] = []
+    await browser?.close().catch((error: unknown) => failures.push(error))
+    await scaffold?.close().catch((error: unknown) => failures.push(error))
+    if (failures.length > 0) throw new AggregateError(failures, 'Open In SSH scenario teardown failed')
+  })
+
+  it('hides a remembered app after the real host returns an empty catalog', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-open-in-app-ssh'))
+    const [response] = await Promise.all([
+      page.waitForResponse(response => new URL(response.url()).pathname === '/open-in-app/apps'),
+      (async () => {
+        await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+        const group = page.getByRole('treeitem').first()
+        await group.waitFor()
+        if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
+        await page.getByRole('treeitem').nth(1).click()
+        await page.getByText('DONE', { exact: true }).waitFor()
+      })(),
+    ])
+    expect(response.status()).toBe(200)
+    expect(await response.json()).toEqual({ apps: [] })
+    expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0)
+    expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0)
+    expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"')
+    const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md'])
+  })
+})

+ 4 - 0
apps/web/tests/plan-review.e2e.ts

@@ -97,6 +97,10 @@ describe('web e2e: plan review takeover round trip', () => {
     }
 
     await card.getByRole('button', { name: 'Approve' }).click()
+    // Park the pointer: the card unmounts and the ContextMeter ring lands
+    // under the click position, whose 200ms hover delay would arm a tooltip
+    // into the aria captures below.
+    await page.mouse.move(0, 0)
 
     const sessionId = await settled
     if (MODE === 'record') {

+ 1 - 1
apps/web/tests/preview-boot.e2e.ts

@@ -314,7 +314,7 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
     const configureLater = page.getByRole('button', { name: 'Configure later' })
     await configureLater.waitFor({ timeout: 30_000 })
     await configureLater.click()
-    await page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
+    await page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
       .waitFor({ timeout: 30_000 })
 
     const exercised = await page.evaluate(async () => {

部分文件因文件數量過多而無法顯示