Forráskód Böngészése

Merge pinned master 84bde6c7 into V3 readiness repair

Tianyi Cui 1 napja
szülő
commit
96fa1dfac8
100 módosított fájl, 1447 hozzáadás és 238 törlés
  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-07-27-workspace-registration-deletion.i18n.yaml
  26. 1 1
      .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md
  27. 1 1
      .agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md
  28. 2 2
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml
  29. 1 1
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
  30. 1 1
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.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-playwright-video-gif.i18n.yaml
  35. 29 0
      .agents/notes/implemented/process/2026-09-08-playwright-video-gif.md
  36. 29 0
      .agents/notes/implemented/process/2026-09-08-playwright-video-gif.zh.md
  37. 6 0
      .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.i18n.yaml
  38. 51 0
      .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.md
  39. 51 0
      .agents/notes/implemented/testing/2026-09-06-pr-ci-runner-temporary-storage.zh.md
  40. 71 32
      .agents/skills/record-browser-gif/SKILL.md
  41. 92 34
      .agents/skills/record-browser-gif/scripts/encode_gif.py
  42. 103 0
      .agents/skills/record-browser-gif/scripts/test_encode_gif.py
  43. 22 7
      .github/workflows/ci.yml
  44. 2 2
      apps/cli/README.i18n.yaml
  45. 2 1
      apps/cli/README.md
  46. 2 1
      apps/cli/README.zh.md
  47. 2 2
      apps/cli/reference/README.i18n.yaml
  48. 10 1
      apps/cli/reference/README.md
  49. 12 1
      apps/cli/reference/README.zh.md
  50. 16 4
      apps/cli/src/args.ts
  51. 7 1
      apps/cli/src/bin.ts
  52. 8 2
      apps/cli/src/dump-config.ts
  53. 77 5
      apps/cli/src/profile-boot.ts
  54. 22 0
      apps/cli/tests/args.spec.ts
  55. 91 0
      apps/cli/tests/built-bin.e2e.ts
  56. 18 0
      apps/cli/tests/fixtures/initialize-profile-from-default.ts
  57. 158 0
      apps/cli/tests/profile-initialization.spec.ts
  58. 1 1
      apps/web/tests/built-boot.expected.e2e.ts
  59. 1 1
      apps/web/tests/command-image-envelope.expected.e2e.ts
  60. 4 2
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  61. 4 2
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  62. 4 2
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  63. 8 0
      apps/web/tests/expected/goal-bar/inactive.expected.md
  64. 1 1
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  65. 4 2
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  66. 4 2
      apps/web/tests/expected/markdown-images/ui.expected.md
  67. 4 2
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  68. 4 2
      apps/web/tests/expected/math-rendering/ui.expected.md
  69. 1 1
      apps/web/tests/expected/reference-composer/order.expected.md
  70. 1 1
      apps/web/tests/expected/settings-chrome/dialog-en.expected.md
  71. 1 1
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  72. 7 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  73. 7 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  74. 4 2
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  75. 1 1
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  76. 7 2
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  77. 7 2
      apps/web/tests/expected/steer-all/settled.expected.md
  78. 1 1
      apps/web/tests/feedback-release.e2e.ts
  79. 15 2
      apps/web/tests/goal-bar.e2e.ts
  80. 3 3
      apps/web/tests/image-display.expected.e2e.ts
  81. 2 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  82. 4 1
      apps/web/tests/live-interactions.e2e.ts
  83. 1 1
      apps/web/tests/math-rendering.e2e.ts
  84. 4 0
      apps/web/tests/plan-review.e2e.ts
  85. 1 1
      apps/web/tests/preview-boot.e2e.ts
  86. 3 0
      apps/web/tests/queue-actions.e2e.ts
  87. 13 5
      apps/web/tests/reference-composer.e2e.ts
  88. 24 2
      apps/web/tests/scaffold.ts
  89. 1 1
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  90. 4 2
      apps/web/tests/startup-auto-selection.e2e.ts
  91. 1 1
      apps/web/tests/stats-paged-history.e2e.ts
  92. 3 3
      apps/web/tests/subagent-conversation.e2e.ts
  93. 3 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  94. 2 2
      apps/web/tests/support.ts
  95. 48 38
      apps/web/tests/workspace-management.e2e.ts
  96. 2 2
      docs/development.i18n.yaml
  97. 8 0
      docs/development.md
  98. 8 0
      docs/development.zh.md
  99. 2 2
      docs/event-producer-consumer.i18n.yaml
  100. 6 5
      docs/event-producer-consumer.md

+ 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-07-27-workspace-registration-deletion.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-27-workspace-registration-deletion.md
-2026-07-27-workspace-registration-deletion.md: aa3b3aaa107a87fe55395ae8cba65ab0430b6fbf
-2026-07-27-workspace-registration-deletion.zh.md: 4552e54770eb85098093f0d8f366cd5f58fc3f31
+2026-07-27-workspace-registration-deletion.md: 1d9804cfc4e2d44ea6c44f69b69dd6b10578f08a
+2026-07-27-workspace-registration-deletion.zh.md: 54e10b67a7607e32b08609fe00191da87d75f7e8

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md

@@ -52,7 +52,7 @@ The menu, Modal, and buttons retain their existing structure and design tokens.
 
 Workspace package tests pin successful metadata-only deletion, same-path re-registration, unknown-id idempotence, table-failure rollback, explicit-marker restart recovery, unexplained-corruption rejection, and cache/table invariant behavior. Apiproxy and carrier tests pin the schema, handler, `workspace-not-found`, retained Session/folder, fresh-id re-registration, and committed `host/workspace-removed` frame. Client tests pin unary direct echo, duplicate removal, late changed frames, and deletion racing an in-flight baseline. Component tests pin confirmation, projection-settled closing, success-frame-before-unary ordering, failure, Cancel, Escape, and Close. The browser scenario observes every transient alert, slot error, console error, and page error while reusing a deleted title for a different directory.
 
-The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload.
+The assembled keyless Web scenario registers an existing temporary project directory, accounts a persisted Session, makes that Session current, confirms deletion in Chromium, and verifies the Workspace group disappears while Ungrouped retains the current Session. It checks the user file and JSONL log before and after deletion and repeats the UI, directory, and log assertions after reload. The scenario holds the seeded attachment frame until the browser selects the adoption-created New Session, proves that the seed is absent from the group, then delivers the frame and selects the only non-blank Session. Host attachment completion and a two-row count cannot prove browser membership: the group header plus New Session already satisfy that count, and the arriving seed can replace the positional locator between click and assertion.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md

@@ -52,7 +52,7 @@ Host 流在前一笔全局顺序写入期间继续保留其已提交 id 集合
 
 Workspace 包测试固定了仅删除元数据的成功路径、同路径重新注册、未知 id 的幂等行为、表操作失败回滚、明确标记的重启恢复、来源不明损坏的拒绝,以及缓存/表不变量行为。Apiproxy 与载体测试固定了 schema、处理器、`workspace-not-found`、保留会话/文件夹、使用新 id 重新注册,以及已提交的 `host/workspace-removed` 帧。客户端测试固定了一元直接回显、重复移除、延迟到达的 changed 帧,以及删除与进行中基线并发的行为。组件测试固定了确认交互、投影稳定后关闭、成功帧先于一元响应、失败、Cancel、Escape 与 Close。浏览器场景会在为不同目录复用已删除名称时,观测每一次瞬时 alert、slot error、console error 与 page error。
 
-组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。
+组装后的无密钥 Web 场景会注册一个已有临时项目目录,将持久化会话计入账本,把该会话设为当前会话,在 Chromium 中确认删除,并验证 Workspace 分组消失,而 Ungrouped 保留当前会话。该场景在删除前后检查用户文件和 JSONL 日志,并在刷新后重复验证 UI、目录与日志。场景会扣住种子会话的归属帧,直到浏览器选中接纳目录时创建的 New Session,确认分组中尚无种子会话,再交付该帧并选中唯一的非空白会话。Host 完成归属写入和行数达到二都不能证明浏览器已收到成员关系:分组标题加 New Session 已满足该行数,而到达的种子会话会使位置定位器在点击与断言之间指向另一行。
 
 ## 后果
 

+ 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 级的。
 

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

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

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

+ 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

+ 2 - 1
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -222,7 +222,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

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

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

+ 3 - 0
apps/web/tests/queue-actions.e2e.ts

@@ -222,6 +222,9 @@ describe('web e2e: queue row actions', () => {
     await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
       .toBe(2)
 
+    // Stop becomes Send under the pointer; dismiss its hover tooltip before capture.
+    await page.mouse.move(0, 0)
+    await expect.poll(() => page.getByRole('tooltip').filter({ hasText: 'Send message' }).count()).toBe(0)
     const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
     const expanded = await captureExpandedTurnProcessAria(

+ 13 - 5
apps/web/tests/reference-composer.e2e.ts

@@ -3,11 +3,13 @@
 // and projects each pick as a complete inline range without issuing a model call.
 import { mkdir, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
+import { homedir } from 'node:os'
 import { join } from 'node:path'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
 import {
   SESSION_FORMAT_VERSION,
   Session,
@@ -171,7 +173,10 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     // Session rows are dated from the live Host list, so their age bucket
     // advances while the suite runs.
     const snapshot = await captureStableAria(
-      page, '[role="listbox"]', scaffold.workspaceCwd, { normalizeAge: true },
+      page, '[role="listbox"]', scaffold.workspaceCwd, {
+        normalizeAge: true,
+        replacements: [[abbreviateHomePath(scaffold.workspaceCwd, homedir()), '{{cwd}}']],
+      },
     )
     await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
     expect(snapshot).toContain('Files & folders')
@@ -218,9 +223,10 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     const input = page.locator('[data-composer-input]').first()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
 
-    await writeComposerDraft(page, input, '@reference')
+    await writeComposerDraft(page, input, '@reference.txt')
     await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
-    await menu.getByRole('option', { name: /reference\.txt/ }).click()
+    await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual(['reference.txt'])
+    await menu.getByRole('option', { name: 'reference.txt', exact: true }).click()
     await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
 
     // The #2813 gesture: collapse the caret to the document start, directly
@@ -248,9 +254,10 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     const input = page.locator('[data-composer-input]').first()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
 
-    await writeComposerDraft(page, input, '@reference')
+    await writeComposerDraft(page, input, '@reference.txt')
     await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(0)
-    await menu.getByRole('option', { name: /reference\.txt/ }).click()
+    await expect.poll(() => menu.getByRole('option').allTextContents()).toEqual(['reference.txt'])
+    await menu.getByRole('option', { name: 'reference.txt', exact: true }).click()
     await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1)
 
     // First ArrowLeft crosses the trailing space; the second steps across the
@@ -351,6 +358,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     // A crumb above the current step re-lists that directory and keeps the
     // header, which now names the step it returned to.
     await writeComposerDraft(page, input, '@folderx/nested')
+    await expect.poll(() => menu.getByRole('option', { name: /child\.txt/ }).count()).toBe(0)
     const nested = menu.getByRole('option', { name: /^nested\// })
     await nested.waitFor()
     await nested.getByRole('button', { name: 'Browse folder' }).click()

+ 24 - 2
apps/web/tests/scaffold.ts

@@ -901,6 +901,28 @@ function mapJsonStringValues(value: unknown, map: (value: string) => string): un
   return value
 }
 
+/** Tokenize the browser timezone carried by user message sources. */
+function normalizeClientTimeZones(value: unknown): unknown {
+  if (Array.isArray(value)) return value.map(item => normalizeClientTimeZones(item))
+  if (value !== null && typeof value === 'object') {
+    const next = Object.fromEntries(Object.entries(value).map(([key, item]) => [
+      key,
+      normalizeClientTimeZones(item),
+    ]))
+    const source = (next as { source?: unknown }).source
+    if (source !== null && typeof source === 'object'
+      && (source as { kind?: unknown }).kind === 'user'
+      && typeof (source as { clientTimeZone?: unknown }).clientTimeZone === 'string') {
+      return {
+        ...next,
+        source: { ...source, clientTimeZone: '{{clientTimeZone}}' },
+      }
+    }
+    return next
+  }
+  return value
+}
+
 const WEB_PATH_TEXT_BOUNDARY_RE = /[\s<>'"`()\[\]{},;:!?=]/
 const WEB_FILE_URI_PATH_PREFIX_RE = /(?:^|[^a-z0-9+.-])file:\/\/\/?$/i
 
@@ -956,12 +978,12 @@ export function normalizeWebSessionVolatiles(log: string, workspaceCwd?: string)
     }))].sort((left, right) => right.length - left.length)
   return log.split(/\r?\n/).map((line) => {
     if (line.trim() === '') return line
-    const record = mapJsonStringValues(JSON.parse(line), (value) => {
+    const record = normalizeClientTimeZones(mapJsonStringValues(JSON.parse(line), (value) => {
       let normalized = value
         .replace(/Anonymous user: [0-9a-f-]{36}(?=\.$)/gi, 'Anonymous user: {{anonymousUserId}}')
       for (const cwd of cwdSpellings) normalized = replaceWebCwd(normalized, cwd)
       return normalized
-    }) as { type?: unknown; data?: { endpoint?: unknown } }
+    })) as { type?: unknown; data?: { endpoint?: unknown } }
     if (record.type === 'web/deepseek-search-llm-request' && typeof record.data?.endpoint === 'string') {
       record.data.endpoint = '{{webSearchEndpoint}}'
     }

+ 1 - 1
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -26,7 +26,7 @@
 - button "Copy"
 - code: "const first: number = 1 const second = \"two\" let tail"
 - status: Deep diving...
-- 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/startup-auto-selection.e2e.ts

@@ -42,7 +42,9 @@ describe('web e2e: startup auto-selection', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
     await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
     const headline = page.getByText('Into the Unknown', { exact: true })
-    const fishHitbox = headline.locator('xpath=preceding-sibling::span[1]')
+    // The headline text sits in its own span inside the title group; the fish
+    // hitbox precedes the group, not the text span.
+    const fishHitbox = headline.locator('xpath=../preceding-sibling::span[1]')
     const fish = fishHitbox.locator('svg')
     expect(await fish.evaluate(node => getComputedStyle(node).color))
       .toBe(await headline.evaluate(node => getComputedStyle(node).color))
@@ -130,7 +132,7 @@ describe('web e2e: startup auto-selection', () => {
       expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true)
 
       releaseOpening()
-      await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
+      await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
         .waitFor({ timeout: 15_000 })
       acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
 

+ 1 - 1
apps/web/tests/stats-paged-history.e2e.ts

@@ -25,7 +25,7 @@ const SEED_ID = 'stats-paged-history-web-e2e'
 
 /** Turn count: 2 chat messages per turn, so 28 turns overflow one 50-message page. */
 const TURNS = 28
-const FULL_COUNTS = `${TURNS} turns · ${TURNS} steps`
+const FULL_COUNTS = `${TURNS} turns ${TURNS} steps`
 
 /**
  * Generate the seed: TURNS closed single-step turns of one short user prompt

+ 3 - 3
apps/web/tests/subagent-conversation.e2e.ts

@@ -423,7 +423,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       expect(await page.locator('[data-composer-seat]').evaluate(element =>
         getComputedStyle(element).visibility)).toBe('hidden')
       releaseCatalog()
-      const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
+      const input = page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' })
       await input.waitFor({ timeout: 15_000 })
       await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
       acknowledgeReloadConnectionLoss(tripwire, warningStart)
@@ -447,7 +447,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         resolveEnded()
       })
     })
-    const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
+    const input = page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' })
     await input.fill(FOLLOWUP)
     await input.press('Enter')
     await expect.poll(
@@ -537,7 +537,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       .click()
     await page.getByRole('button', { name: '3 subagents' }).hover()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
-    await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor()
+    await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).waitFor()
     const forkResponse = page.waitForResponse(response =>
       new URL(response.url()).pathname === '/api/session/fork')
     await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()

+ 3 - 1
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -279,10 +279,12 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     expect(await input.isDisabled()).toBe(false)
 
     // Queue a follow-up through Send while independent Stop remains available.
+    // The running child's Send names its delivery like an ordinary session:
+    // the default busy-state preference is Queue.
     const promptResponse = page.waitForResponse(response =>
       new URL(response.url()).pathname === '/api/subagents/prompt')
     await input.fill(FOLLOWUP)
-    await page.getByRole('button', { name: 'Send message' }).click()
+    await page.getByRole('button', { name: 'Queue message' }).click()
     expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
       .toMatchObject({ ok: true })
 

+ 2 - 2
apps/web/tests/support.ts

@@ -113,7 +113,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
   await dialog.getByRole('button', { name: 'Open', exact: true }).click()
   // The pick connected the workspace: the blank session's live composer
   // replaces the locked placeholder and enables.
-  await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
+  await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
     .waitFor({ timeout: 15_000 })
 }
 
@@ -136,7 +136,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
   await pathInput.fill(join(root, name))
   await pathInput.press('Enter')
   await dialog.getByRole('button', { name: '打开', exact: true }).click()
-  await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或对话"]')
+  await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]')
     .waitFor({ timeout: 15_000 })
 }
 

+ 48 - 38
apps/web/tests/workspace-management.e2e.ts

@@ -42,6 +42,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
+  let holdAttachment = false
+  let releaseAttachment: (() => void) | undefined
 
   /**
    * Raise the region header's directory dialog and drive it to a directory via
@@ -124,6 +126,22 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
+    await page.routeWebSocket('**/api/remote.mux', (route) => {
+      const server = route.connectToServer()
+      server.onMessage((message) => {
+        const frame = JSON.parse(String(message)) as {
+          type: string
+          value?: { type: string; workspace?: { sessionIds: string[] } }
+        }
+        if (holdAttachment && frame.type === 'item' && frame.value?.type === 'upsert'
+          && frame.value.workspace?.sessionIds.includes(SEED_ID) === true) {
+          holdAttachment = false
+          releaseAttachment = () => { route.send(message) }
+          return
+        }
+        route.send(message)
+      })
+    })
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -131,6 +149,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
 
   afterAll(async () => {
     await browser?.close()
+    releaseAttachment = undefined
     await scaffold?.close()
   })
 
@@ -211,6 +230,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true })
     const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(scaffold.workspaceCwd)
     if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
+    holdAttachment = true
     await workspace.attachSession(SessionId(SEED_ID))
     const header = (await scaffold.ctx.sessionPersistence.list())
       .map(snapshot => snapshot.header)
@@ -220,23 +240,25 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
     await stat(seededLogPath)
 
-    // Open the seeded (first/accounted) Session so deletion must preserve the
-    // current selection while it moves into Ungrouped.
+    // The attachment stream can lag the Host commit while New Session is
+    // already visible. A row count or position cannot identify the seed.
     const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
     await groupRow.waitFor({ timeout: 10_000 })
     // The header row is wrapped by its HoverCard anchor span, so the section
     // is the nearest groupSection ancestor, not the immediate parent.
     const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
-    await expect.poll(async () => {
-      const count = await groupSection.locator('[role="treeitem"]').count()
-      if (count < 2 && await groupRow.getAttribute('aria-expanded') !== 'true') {
-        await groupRow.click()
-        await page.waitForTimeout(50)
-      }
-      return await groupSection.locator('[role="treeitem"]').count()
-    }, { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
-    const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
-    await seededRow.click()
+    if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
+    const blankRow = groupSection.getByRole('treeitem', { name: 'New Session', exact: true })
+    await expect.poll(() => blankRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
+    // The seed is this account's only non-blank Session; its title changes on resume.
+    const seededRow = groupSection.locator('[role="treeitem"][aria-selected]')
+      .filter({ hasNot: page.getByText('New Session', { exact: true }) })
+    await expect.poll(() => releaseAttachment, { timeout: 10_000 }).toBeDefined()
+    expect(await seededRow.count()).toBe(0)
+    const deliverAttachment = releaseAttachment!
+    releaseAttachment = undefined
+    deliverAttachment()
+    await seededRow.click({ timeout: 10_000 })
     await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
 
     await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`)
@@ -463,9 +485,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
   }, 60_000)
 
   /**
-   * Expand Ungrouped and return its seeded session row. The only visible child
-   * is the non-blank persisted Session; the blank Session created while
-   * adopting the Workspace stays hidden.
+   * Expand Ungrouped and return its only non-blank session row. A selected
+   * blank Session from a deleted Workspace may also be visible, without actions.
    * @returns the session row locator, already present.
    */
   async function seededSessionRow() {
@@ -480,9 +501,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
       }
       return await ungroupedRow.getAttribute('aria-expanded')
     }, { timeout: 5_000 }).toBe('true')
-    const row = ungroupedSection.locator('[role="treeitem"]').nth(1)
-    await row.waitFor({ timeout: 10_000 })
-    return row
+    // CSS includes the actions button while it is hidden until row hover.
+    const rows = ungroupedSection.locator('[role="treeitem"]')
+      .filter({ has: page.locator('button[aria-label^="Session actions for "]') })
+    await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1)
+    return rows.first()
   }
 
   it('shows the session hover card after a dwell on the row', async () => {
@@ -552,26 +575,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
 
   it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
-    // The seeded session lives under Ungrouped (expanded by the hover-card
-    // test's gesture; converge again for order independence).
-    const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
-    const ungroupedSection = ungroupedRow.locator('..')
-    await expect.poll(async () => {
-      if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
-        await page.getByText('Ungrouped', { exact: true }).click()
-        await page.waitForTimeout(50)
-      }
-      return await ungroupedRow.getAttribute('aria-expanded')
-    }, { timeout: 5_000 }).toBe('true')
-    // Anchor on session rows (the rows carrying a session actions button),
-    // not a positional index, and assert the single-stray assumption loudly
-    // so a fixture gaining a second stray fails here instead of archiving
-    // the wrong row. CSS attribute match, not getByRole: the button is
-    // display:none until its row hovers, and role queries skip hidden nodes.
-    const sessionRows = ungroupedSection.locator('[role="treeitem"]')
-      .filter({ has: page.locator('button[aria-label^="Session actions for "]') })
-    await expect.poll(() => sessionRows.count(), { timeout: 10_000 }).toBe(1)
-    const sessionRow = sessionRows.first()
+    const sessionRow = await seededSessionRow()
+    // Selecting the seed hides any blank stray left by Workspace deletion,
+    // so archiving this last visible Ungrouped Session must remove the bucket.
+    await sessionRow.click()
+    await expect.poll(() => sessionRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
+    const ungroupedSection = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..').locator('..')
+    await expect.poll(() => ungroupedSection.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(2)
     const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
     // Row menu: hover reveals the actions button; Archive session commits
     // without a confirmation dialog (non-destructive: log + accounting stay).

+ 2 - 2
docs/development.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/development.md
-development.md: 37028720ff2487a81caecb8b2e2c6e5bc26b2df5
-development.zh.md: 01e3e88605cb4f9ea0b14df4a0099f436c48c5e9
+development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2
+development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356

+ 8 - 0
docs/development.md

@@ -13,6 +13,14 @@ The setup tutorial takes a new contributor from prerequisites to a checked check
 - Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.
 - Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.
 
+### Windows and WSL 2
+
+On Windows, you can develop with native tools or use WSL 2 for a Linux environment. WSL 2 is useful for verifying Linux behavior and for using Linux toolchains when native dependency compilation or filesystem permissions obstruct Windows development. Each environment needs its own runtime, build tools, and permissions; WSL is optional.
+
+Keep the checkout, installed dependencies, and toolchain in the same operating system environment. For WSL 2, store the checkout in the Linux filesystem; for native Windows tools, use the Windows filesystem. Accessing files across the two filesystems adds overhead to I/O-intensive operations such as Git, dependency installation, and builds. See Microsoft's [file storage and performance guidance](https://learn.microsoft.com/en-us/windows/wsl/filesystems#file-storage-and-performance-across-file-systems).
+
+Install dependencies separately in each environment because native binaries and links can differ between operating systems. Test results apply to the environment where the tests ran; Windows-specific behavior still needs native Windows validation.
+
 ### First-time setup
 
 Install dependencies from the repo root:

+ 8 - 0
docs/development.zh.md

@@ -15,6 +15,14 @@
 - Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。
 - 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。
 
+### Windows 与 WSL 2
+
+在 Windows 上,可以使用原生工具开发,也可以通过 WSL 2 使用 Linux 环境。WSL 2 既可用于验证 Linux 行为,也可在原生依赖编译或文件系统权限阻碍 Windows 开发时提供使用 Linux 工具链的途径。每种环境都需要准备相应的运行时、编译工具和权限;WSL 是可选项。
+
+将检出目录、已安装的依赖和工具链放在同一操作系统环境中。使用 WSL 2 时,将检出目录放在 Linux 文件系统中;使用 Windows 原生工具时,则使用 Windows 文件系统。跨两种文件系统访问会给 Git、依赖安装和构建等 I/O 密集型操作增加开销。参见微软的[文件存储与性能指南](https://learn.microsoft.com/en-us/windows/wsl/filesystems#file-storage-and-performance-across-file-systems)。
+
+在每种环境中分别安装依赖,因为不同操作系统使用的原生二进制和链接可能不同。测试结果适用于执行测试的环境;Windows 特有行为仍需在原生 Windows 上验证。
+
 ### 首次搭建
 
 在仓库根目录安装依赖:

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 565048f488e9075fa7147dff1dd4f91cedc8a8ba
-event-producer-consumer.zh.md: 01947061da45c9da39f21225330409df7f2b76ec
+event-producer-consumer.md: f26413852cb817ee3fd31a2415e3eecdce0d2f35
+event-producer-consumer.zh.md: 2c21e8330e8cf1303f54291e47910ce35c5a978f

+ 6 - 5
docs/event-producer-consumer.md

@@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:316`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:277`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:391`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:571`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:577`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -43,6 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
+| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:72`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott