Просмотр исходного кода

Merge remote-tracking branch 'upstream/master' into issue-1424-goal-resume-activation

mektpoy 2 недель назад
Родитель
Сommit
0dc6ce26d7
100 измененных файлов с 1512 добавлено и 532 удалено
  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-04-busy-send-button-follows-enter-setting.i18n.yaml
  14. 35 0
      .agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md
  15. 35 0
      .agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml
  17. 1 1
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
  18. 1 1
      .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md
  19. 6 0
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
  20. 31 0
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
  21. 31 0
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
  22. 2 2
      apps/cli/README.i18n.yaml
  23. 2 1
      apps/cli/README.md
  24. 2 1
      apps/cli/README.zh.md
  25. 2 2
      apps/cli/reference/README.i18n.yaml
  26. 10 1
      apps/cli/reference/README.md
  27. 12 1
      apps/cli/reference/README.zh.md
  28. 16 4
      apps/cli/src/args.ts
  29. 7 1
      apps/cli/src/bin.ts
  30. 8 2
      apps/cli/src/dump-config.ts
  31. 77 5
      apps/cli/src/profile-boot.ts
  32. 22 0
      apps/cli/tests/args.spec.ts
  33. 91 0
      apps/cli/tests/built-bin.e2e.ts
  34. 18 0
      apps/cli/tests/fixtures/initialize-profile-from-default.ts
  35. 158 0
      apps/cli/tests/profile-initialization.spec.ts
  36. 1 1
      apps/web/tests/built-boot.expected.e2e.ts
  37. 1 1
      apps/web/tests/command-image-envelope.expected.e2e.ts
  38. 4 2
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  39. 4 2
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  40. 4 2
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  41. 1 1
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  42. 4 2
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  43. 4 2
      apps/web/tests/expected/markdown-images/ui.expected.md
  44. 4 2
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  45. 4 2
      apps/web/tests/expected/math-rendering/ui.expected.md
  46. 1 1
      apps/web/tests/expected/reference-composer/order.expected.md
  47. 1 1
      apps/web/tests/expected/settings-chrome/dialog-en.expected.md
  48. 1 1
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  49. 7 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  50. 7 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  51. 4 2
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  52. 1 1
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  53. 7 2
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  54. 7 2
      apps/web/tests/expected/steer-all/settled.expected.md
  55. 1 1
      apps/web/tests/feedback-release.e2e.ts
  56. 1 1
      apps/web/tests/goal-bar.e2e.ts
  57. 3 3
      apps/web/tests/image-display.expected.e2e.ts
  58. 2 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  59. 4 1
      apps/web/tests/live-interactions.e2e.ts
  60. 1 1
      apps/web/tests/math-rendering.e2e.ts
  61. 4 0
      apps/web/tests/plan-review.e2e.ts
  62. 1 1
      apps/web/tests/preview-boot.e2e.ts
  63. 1 1
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  64. 4 2
      apps/web/tests/startup-auto-selection.e2e.ts
  65. 1 1
      apps/web/tests/stats-paged-history.e2e.ts
  66. 3 3
      apps/web/tests/subagent-conversation.e2e.ts
  67. 3 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  68. 2 2
      apps/web/tests/support.ts
  69. 2 2
      docs/development.i18n.yaml
  70. 8 0
      docs/development.md
  71. 8 0
      docs/development.zh.md
  72. 2 2
      docs/event-producer-consumer.i18n.yaml
  73. 5 5
      docs/event-producer-consumer.md
  74. 5 5
      docs/event-producer-consumer.zh.md
  75. 2 2
      docs/user/develop/basic/publish.i18n.yaml
  76. 1 1
      docs/user/develop/basic/publish.md
  77. 1 1
      docs/user/develop/basic/publish.zh.md
  78. 2 2
      packages/api/session-controller/README.i18n.yaml
  79. 0 1
      packages/api/session-controller/README.md
  80. 1 1
      packages/api/session-controller/README.zh.md
  81. 31 8
      packages/api/session-controller/src/commands.ts
  82. 6 1
      packages/api/session-controller/src/types.ts
  83. 8 0
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  84. 96 0
      packages/api/session-controller/tests/session-cold.host.spec.ts
  85. 2 2
      packages/boot/app-boot/README.i18n.yaml
  86. 1 1
      packages/boot/app-boot/README.md
  87. 1 1
      packages/boot/app-boot/README.zh.md
  88. 22 2
      packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css
  89. 2 1
      packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
  90. 12 0
      packages/client/ui-attachment/src/FileCard.module.css
  91. 2 4
      packages/client/ui-attachment/src/FileCard.tsx
  92. 4 1
      packages/client/ui-attachment/src/client/ComposerAttachments.module.css
  93. 2 2
      packages/client/ui-chat/src/client/apply.ts
  94. 0 26
      packages/client/ui-chat/src/client/chat/StatsLine.module.css
  95. 0 209
      packages/client/ui-chat/src/client/chat/StatsLine.tsx
  96. 68 0
      packages/client/ui-chat/src/client/chat/StatsPills.module.css
  97. 353 0
      packages/client/ui-chat/src/client/chat/StatsPills.tsx
  98. 2 93
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
  99. 16 69
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
  100. 94 0
      packages/client/ui-chat/src/client/chat/stat-dialog.module.css

+ 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-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-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'`。

+ 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

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

+ 1 - 1
apps/web/tests/goal-bar.e2e.ts

@@ -46,7 +46,7 @@ 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')

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

+ 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

@@ -23,7 +23,7 @@ const SEED_ID = 'stats-paged-history-web-e2e'
 
 /** Turn count: 2 surface 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 })
 }
 

+ 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: 66628ea0381c20e12ce9e06fa91499a3d0cd43e2
-event-producer-consumer.zh.md: 5e675905e4170447e783dcec82b47f0e47802e35
+event-producer-consumer.md: 99811d1dac8b51e96544eea95b1d67379cc15f14
+event-producer-consumer.zh.md: 68ea7460eeb2192b6d8d5195fec1254c457e7c16

+ 5 - 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:387`](../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` |

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

@@ -24,11 +24,11 @@
 | `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:387`](../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` |

+ 2 - 2
docs/user/develop/basic/publish.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/user/develop/basic/publish.md
-publish.md: 89a28e2e44a5171a3f5c48c003f5af5ec2e9e750
-publish.zh.md: 590fee6f035af3009e25bf3669ba4321951f7d07
+publish.md: 58abad728d5f684a72d855b77821a26a725de53f
+publish.zh.md: dbb49c0cd0e5ca26e42d17d94acb5f1601a27d15

+ 1 - 1
docs/user/develop/basic/publish.md

@@ -70,7 +70,7 @@ A profile directory holds two files:
 - `package.json` — the profile's out-of-tree plugin dependencies (managed by pnpm) plus the `dsh.profile` manifest with its ordered `bundles` list.
 - `cordis.patch.yml` — the user's own patch layer, applied after every bundle layer.
 
-You never write a profile manifest by hand: `dsh plugin` creates and maintains it. The next section shows the result.
+You never write a profile manifest by hand: `dsh --profile <name> --from-default-profile <template>` can create one from a shipped application template, while `dsh plugin` creates a base-backed profile and maintains its installed bundle list. The [CLI behavior reference](../../../../apps/cli/reference/README.md#profile-boot) owns the creation rules; the next section shows the plugin path.
 
 ## Install into a profile
 

+ 1 - 1
docs/user/develop/basic/publish.zh.md

@@ -70,7 +70,7 @@ profile 目录包含两个文件:
 - `package.json` — profile 的树外插件依赖(由 pnpm 管理),加上 `dsh.profile` manifest 及其有序的 `bundles` 列表。
 - `cordis.patch.yml` — 用户自己的 patch 层,在每个组合包层之后应用。
 
-profile manifest 从不需要手写:`dsh plugin` 负责创建和维护它。下一节展示其结果
+profile manifest 从不需要手写:`dsh --profile <name> --from-default-profile <template>` 可以从随附应用模板创建 profile,`dsh plugin` 则创建一个以 base 为基础的 profile,并维护其中已安装的 bundle 列表。创建规则以 [CLI(命令行界面)行为参考](../../../../apps/cli/reference/README.zh.md#profile-boot)为准;下一节展示插件路径
 
 ## 安装进 profile
 

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 6b56c4976b5ca66fbdbdb274bdf5bf8f38d52b1d
-README.zh.md: a9c53d37371e3280c5bc0ae21a93a511036a810d
+README.md: 8f7de90c2029117296bca02ce13dc5e769471434
+README.zh.md: b99589c4d7ed0a0105874bcbbd37dfd03927918d

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/api/session-controller/README.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/api/session-controller/README.zh.md


+ 31 - 8
packages/api/session-controller/src/commands.ts

@@ -58,6 +58,14 @@ interface SessionReadState {
   readonly events: readonly SessionEvent[]
 }
 
+type PromptContentCandidate =
+  | SessionPromptRequest['content'][number]
+  | Extract<SessionUpdateQueueRequest['action'], { readonly kind: 'edit' }>['content'][number]
+
+function hasPromptContent(content: readonly PromptContentCandidate[]): boolean {
+  return content.some(part => part.type !== 'text' || part.text.trim().length > 0)
+}
+
 /** Implements Session business commands delegated by the Session Controller Remote service. */
 export class SessionCommandController {
   /**
@@ -287,11 +295,18 @@ export class SessionCommandController {
   }
 
   /**
-   * Admit one browser prompt after explicit Agent resume and image validation.
+   * Reject empty content, then admit one prompt after Agent and attachment validation.
    * @param request - Session identity, prompt content, source metadata, and delivery mode.
    * @returns acknowledgement that the Agent accepted the prompt.
    */
   async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
+    if (!hasPromptContent(request.content)) {
+      throw new RemoteError(
+        'gateway/bad-request',
+        'prompt content must include non-whitespace text or an attachment',
+        {},
+      )
+    }
     const clientTimeZone = request.clientTimeZone === undefined
       ? undefined
       : canonicalClientTimeZone(request.clientTimeZone)
@@ -407,13 +422,21 @@ export class SessionCommandController {
    * @returns acknowledgement that the queue mutation was applied.
    */
   updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
-    if (request.action.kind === 'edit'
-      && request.action.content.some(block => block.type !== 'text')) {
-      throw new RemoteError(
-        'session/attachment-invalid',
-        'queue edits accept text content only',
-        { reason: 'QUEUE_EDIT_NON_TEXT' },
-      )
+    if (request.action.kind === 'edit') {
+      if (request.action.content.some(block => block.type !== 'text')) {
+        throw new RemoteError(
+          'session/attachment-invalid',
+          'queue edits accept text content only',
+          { reason: 'QUEUE_EDIT_NON_TEXT' },
+        )
+      }
+      if (!hasPromptContent(request.action.content)) {
+        throw new RemoteError(
+          'gateway/bad-request',
+          'queue edit content must include non-whitespace text',
+          {},
+        )
+      }
     }
     const agent = this.ctx.agents.get(request.sessionId)
     if (agent === undefined) {

+ 6 - 1
packages/api/session-controller/src/types.ts

@@ -151,7 +151,11 @@ export interface ModelCatalog {
 
 /** One client-requested mutation of a still-pending queue item. */
 export type QueueAction =
-  | { readonly kind: 'edit'; readonly content: readonly ContentBlock[] }
+  | {
+    readonly kind: 'edit'
+    /** Non-empty text-only replacement content. */
+    readonly content: readonly ContentBlock[]
+  }
   | { readonly kind: 'remove' }
   | { readonly kind: 'steer' }
 
@@ -308,6 +312,7 @@ export interface SessionPromptRequest {
   readonly requestId: SessionRequestId
   readonly sessionId: SessionId
   readonly mode: 'queue' | 'steer'
+  /** At least one non-whitespace text part or attachment. */
   readonly content: readonly PromptContentPart[]
   readonly clientTimeZone?: string
 }

+ 8 - 0
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -133,6 +133,14 @@ describe('Session queue commands', () => {
         }],
       },
     })), 'session/attachment-invalid')
+    for (const content of [[], [{ type: 'text' as const, text: ' \t\n' }]]) {
+      await expectFailure(Promise.resolve().then(() => controller.updateQueue({
+        sessionId: agent.id,
+        itemId: queued.id,
+        action: { kind: 'edit', content },
+      })), 'gateway/bad-request')
+    }
+    expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'queued' }])
     await expectFailure(Promise.resolve().then(() => controller.updateQueue({
       sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' },
     })), 'session/queue-item-not-found')

+ 96 - 0
packages/api/session-controller/tests/session-cold.host.spec.ts

@@ -17,6 +17,7 @@ import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
 import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
 import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import AttachmentStore from '@deepseek-ai/dsh-attachment'
 import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
 import {
   SessionPersistenceRevision,
@@ -678,6 +679,101 @@ describe('degenerate composition (no persistence, no factory)', () => {
 })
 
 describe('sessions.prompt synchronous rejection', () => {
+  it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(AgentRegistry)
+    const session = ctx.sessions.create(sid('session-empty-prompt'))
+    const followup = vi.fn()
+    const steer = vi.fn()
+    ctx.agents.register({
+      id: session.id,
+      session,
+      inbox: inboxFor(),
+      status: 'idle',
+      ctx,
+      followup,
+      steer,
+    } as unknown as Agent)
+    const savedImage = {
+      attachmentId: 'accepted-image',
+      mediaType: 'image/png' as const,
+      bytes: 1,
+      width: 1,
+      height: 1,
+    }
+    const saveImages = vi.fn(() => Promise.resolve([savedImage]))
+    ctx.provide('attachments', Object.setPrototypeOf(
+      { saveImages },
+      AttachmentStore.prototype,
+    ) as never)
+    ctx.provide('llm', {
+      listProviders: () => [{ id: 'p', name: 'Provider' }],
+      resolveModelInfo: () => Promise.resolve({
+        provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'],
+      }),
+    } as never)
+    const remote = createSessionTestRemote(ctx, {
+      defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+      cwd: '/tmp',
+    })
+    const initialEvents = session.snapshotEvents()
+    const rejectedContent: readonly SessionPromptRequest['content'][] = [
+      [],
+      [{ type: 'text', text: '' }],
+      [{ type: 'text', text: ' \t\n' }, { type: 'text', text: '' }],
+    ]
+
+    for (const [index, content] of rejectedContent.entries()) {
+      const response = await remote.prompt(promptRequest({
+        sessionId: session.id,
+        mode: index === 1 ? 'steer' : 'queue',
+        content,
+      }))
+      expect(response).toMatchObject({
+        ok: false,
+        error: {
+          code: 'gateway/bad-request',
+          message: 'prompt content must include non-whitespace text or an attachment',
+          details: {},
+        },
+      })
+    }
+    expect(followup).not.toHaveBeenCalled()
+    expect(steer).not.toHaveBeenCalled()
+    expect(session.snapshotEvents()).toEqual(initialEvents)
+
+    const queued = await remote.prompt(promptRequest({
+      sessionId: session.id,
+      mode: 'queue',
+      content: [{ type: 'text', text: ' queued ' }],
+    }))
+    const steered = await remote.prompt(promptRequest({
+      sessionId: session.id,
+      mode: 'steer',
+      content: [{ type: 'text', text: 'steered' }],
+    }))
+    const imageQueued = await remote.prompt(promptRequest({
+      sessionId: session.id,
+      mode: 'queue',
+      content: [{ type: 'image', mediaType: 'image/png', data: 'AQ==' }],
+    }))
+    expect(queued).toMatchObject({ ok: true, value: { accepted: true } })
+    expect(steered).toMatchObject({ ok: true, value: { accepted: true } })
+    expect(imageQueued).toMatchObject({ ok: true, value: { accepted: true } })
+    expect(followup).toHaveBeenCalledWith(expect.objectContaining({
+      content: [{ type: 'text', text: ' queued ' }],
+    }))
+    expect(steer).toHaveBeenCalledWith(expect.objectContaining({
+      content: [{ type: 'text', text: 'steered' }],
+    }))
+    expect(saveImages).toHaveBeenCalledOnce()
+    expect(followup).toHaveBeenCalledWith(expect.objectContaining({
+      content: [{ type: 'image', attachment: savedImage }],
+    }))
+    await ctx.fiber.dispose()
+  })
+
   it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)

+ 2 - 2
packages/boot/app-boot/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md
-README.md: 086424009c9f7157f687a00cbb50cdc80a210e11
-README.zh.md: 4d0e45dd6be961d22d638134541c80501edec505
+README.md: 197ed81c1025f7211c3fb7a694547c52fb058ee8
+README.zh.md: 5e465a86f5e624380710cc8c73d5356c649522e4

+ 1 - 1
packages/boot/app-boot/README.md

@@ -47,7 +47,7 @@ With that entry point, success looks like a running app with every plugin active
 
 Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data.
 
-A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
+A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/<name>` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile <name> --from-default-profile <template>` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. A missing bundle or one without a patch declaration fails startup loudly. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
 
 Your machine-local preferences also live in the Harness home:
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -47,7 +47,7 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
 
 Profile 与 bundle 的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
 
-profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
+profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/<name>`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh --profile <name> --from-default-profile <template>` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的 bundle。缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
 
 你的机器本地偏好同样位于 harness home 中:
 

+ 22 - 2
packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css

@@ -1,10 +1,25 @@
 /* Agent-preset chip on the new-session screen, beside the workspace picker.
    Geometry mirrors HeroShell's .workspace so the two read as one row. */
 
+/* Overflow-driven degradation (mirrors HeroShell's .workspace), no
+   breakpoint: the label shrinks with an ellipsis down to nothing, so the
+   preset icon and chevron always sit snug against whatever text remains —
+   full text, ellipsized text, or none. */
+/* The Menu anchor wrapper between the row and this chip: its flex
+   auto-minimum would hold the full label width and the row could never
+   compress the chip. The explicit floor is the icon-only end state — 8px
+   paddings + 16px icon + two 4px gaps + 14px chevron — so the glyphs never
+   crush once the label has ellipsized away to nothing. */
+.menuAnchor {
+  min-width: 54px;
+  max-width: 100%;
+}
+
 .seat {
   display: inline-flex;
   align-items: center;
   gap: 4px;
+  min-width: 0;
   max-width: min(100%, 240px);
   min-height: 28px;
   padding: 0 8px;
@@ -16,8 +31,6 @@
   line-height: 20px;
   font-weight: 500;
   white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
   cursor: pointer;
 }
 
@@ -36,6 +49,13 @@
   color: var(--dsw-alias-label-primary);
 }
 
+.seatLabel {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 /* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
    matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
    then the name's characters fade up on a stagger (delays set inline per

+ 2 - 1
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx

@@ -181,6 +181,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
         }}
         align="start"
         portal
+        className={css.menuAnchor}
         anchor={(
           <button
             type="button"
@@ -192,7 +193,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
             onClick={() => { setOpen(value => !value) }}
           >
             <IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
-            {shownLabel}
+            <span className={css.seatLabel}>{shownLabel}</span>
             <IconChevronDownOutline14 className={css.chevron} />
           </button>
         )}

+ 12 - 0
packages/client/ui-attachment/src/FileCard.module.css

@@ -106,6 +106,18 @@
   opacity: 1;
 }
 
+@media (pointer: coarse) {
+  .remove {
+    opacity: 1;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .remove {
+    transition: none;
+  }
+}
+
 .card:hover .name,
 .card:focus-within .name {
   padding-right: 18px;

+ 2 - 4
packages/client/ui-attachment/src/FileCard.tsx

@@ -1,4 +1,4 @@
-import { DocumentFileIcon, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import css from './FileCard.module.css'
 
 /** Localized strings consumed by one pending-file card. */
@@ -72,9 +72,7 @@ export function FileCard({
         aria-label={labels.remove}
         onClick={onRemove}
       >
-        <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
-          <path d="M3 3L13 13M13 3L3 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
-        </svg>
+        <IconCloseFill14 size={12} />
       </button>
       {state === 'uploading' && (
         <span className={css.progressTrack} aria-hidden>

+ 4 - 1
packages/client/ui-attachment/src/client/ComposerAttachments.module.css

@@ -1,6 +1,9 @@
 .rail {
   min-width: 0;
-  padding: 4px 12px 0;
+  padding: 2px 10px 0;
+  /* The card's flex gap (12px) separates every row pair; the thumbnails sit
+     closer to the draft text than the other rows, so pull back 6 here. */
+  margin-bottom: -6px;
 }
 
 .imageItem {

+ 2 - 2
packages/client/ui-chat/src/client/apply.ts

@@ -26,7 +26,7 @@ import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
 import { ApprovalCommand } from './chat/ApprovalCommand.tsx'
 import { ChatView } from './chat/ChatView.tsx'
 import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
-import { StatsLine } from './chat/StatsLine.tsx'
+import { StatsPills } from './chat/StatsPills.tsx'
 import { registerConversationNodes } from './conversation-nodes/register.ts'
 import { en, NS, zh } from './locale.ts'
 import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
@@ -165,7 +165,7 @@ export function apply(ctx: Context): void {
   ctx.slots.inject('conversation.composer.dock', () =>
     ctx.slots.register({
       name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,
-    }, StatsLine))
+    }, StatsPills))
 
   ctx.slots.inject('conversation.approval.detail', () =>
     ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand))

+ 0 - 26
packages/client/ui-chat/src/client/chat/StatsLine.module.css

@@ -1,26 +0,0 @@
-/* Session stats row: 13/20 tertiary text (the secondary content tier) under
-   the flow, aligned to the shared message column axis
-   (--dsh-chat-content-width). */
-
-.root {
-  /* Block, not flex: text-overflow only elides a block's inline content, so
-     an overlong line ends in … instead of a mid-glyph clip. */
-  display: block;
-  text-align: center;
-  max-width: var(--dsh-chat-content-width);
-  width: 100%;
-  margin: 0 auto;
-  box-sizing: border-box;
-  padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
-  color: var(--dsw-alias-label-tertiary);
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
-
-.sep {
-  color: var(--dsw-alias-separator-primary);
-  margin: 0 10px; /* carries the former flex gap */
-}

+ 0 - 209
packages/client/ui-chat/src/client/chat/StatsLine.tsx

@@ -1,209 +0,0 @@
-// Settled-node identity prevents stream-delta updates from rerendering this row.
-// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
-// active conversation scrollport (see ConversationRoot data-conversation-scroll).
-
-import { Fragment, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
-import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
-// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
-import type {} from '@deepseek-ai/dsh-session-stats/client'
-import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
-import type { ChatViewSlotProps } from '../contract/slots.ts'
-import type { ChatSnapshot } from '../contract/snapshot.ts'
-import { formatTokensPerSecond } from './message-chrome.ts'
-import { assistantStepReading } from '../contract/turn-metrics.ts'
-import { formatCacheHitPercent, formatTokens } from './token-format.ts'
-import css from './StatsLine.module.css'
-
-interface WindowStats {
-  turns: number
-  steps: number
-  /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
-  llmMs: number
-  /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
-  toolMs: number
-  /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
-  ttftMs: number
-  /** Steps carrying a recorded TTFT. */
-  ttftSteps: number
-  /** Summed decode wall time over steps that also report output tokens. */
-  decodeMs: number
-  /** Summed output tokens over the same decode-timed steps. */
-  decodeTokens: number
-}
-
-/**
- * Fold assistant and tool-result nodes into window-scoped display totals —
- * the FALLBACK for assemblies without the `sessionStats` projection.
- *
- * Every displayed figure rides that durable whole-log projection (and token
- * accounting rides `tokenUsage`) because the window is paged and compaction
- * rewrites it; this fold answers "what is on screen" only when no projection
- * value is served. Its field names deliberately mirror the projection's so
- * the two swap wholesale.
- * @param nodes - snapshot nodes.
- * @returns fallback counts and summed wall times.
- */
-export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
-  const turns = new Set<number>()
-  let steps = 0
-  let llmMs = 0
-  let toolMs = 0
-  let ttftMs = 0
-  let ttftSteps = 0
-  let decodeMs = 0
-  let decodeTokens = 0
-  for (const node of nodes) {
-    if (node.kind === 'tool-result') {
-      if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
-      continue
-    }
-    if (node.kind !== 'assistant') continue
-    turns.add(node.turn)
-    steps += 1
-    if (node.timing !== undefined && node.timing.stepStartTime !== null) {
-      llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
-    }
-    const reading = assistantStepReading(node)
-    if (reading.ttftMs !== null) {
-      ttftMs += reading.ttftMs
-      ttftSteps += 1
-    }
-    if (reading.decodeMs !== null && reading.outputTokens !== null) {
-      decodeMs += reading.decodeMs
-      decodeTokens += reading.outputTokens
-    }
-  }
-  return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
-}
-
-/**
- * Compact duration: 45.2s under a minute, 2m42s from there on.
- * @param ms - duration in milliseconds.
- * @returns display string.
- */
-export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
-  const s = ms / 1_000
-  if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 })
-  const whole = Math.round(s)
-  return t('duration.compactMinutes', {
-    minutes: Math.floor(whole / 60),
-    seconds: whole % 60,
-  })
-}
-
-/**
- * Display-ready cache-hit share of prompt-side input over the whole durable log.
- * @param usage - the session's token-usage projection value.
- * @returns integer text when integer rounding stays below 100, otherwise the
- * minimum decimal precision that still rounds below 100; a full hit returns
- * 100, and no billed input returns null.
- */
-export function cacheHitPercent(usage: TokenUsageProjection): string | null {
-  const denominator = billedInputTokens(usage)
-  return formatCacheHitPercent(usage.cacheReadTokens, denominator)
-}
-
-/**
- * Sum the three disjoint prompt-side billing buckets.
- * @param usage - the session's token-usage projection value.
- * @returns billed input tokens.
- */
-export function billedInputTokens(usage: TokenUsageProjection): number {
-  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
-}
-
-/** Props: the conversation-snapshot selector plus the projection read seat. */
-export interface StatsLineProps {
-  useChat: SnapshotSelectorHook<ChatSnapshot>
-  useProjection: UseProjection
-  /** The owning dock's locale seat. */
-  t: ChatViewSlotProps['t']
-}
-
-/** Render and measure one non-empty statistics line. */
-const StatsLineContent = memo(function StatsLineContent({
-  groups,
-  line,
-}: {
-  readonly groups: readonly string[]
-  readonly line: string
-}) {
-  const rootRef = useRef<HTMLDivElement | null>(null)
-  const [truncated, setTruncated] = useState(false)
-  const measure = useCallback(() => {
-    const el = rootRef.current
-    if (el === null) return
-    const next = el.scrollWidth > el.clientWidth
-    setTruncated(current => current === next ? current : next)
-  }, [])
-  useLayoutEffect(() => {
-    const el = rootRef.current
-    if (el === null || typeof ResizeObserver === 'undefined') return
-    const observer = new ResizeObserver(measure)
-    observer.observe(el)
-    return () => { observer.disconnect() }
-  }, [measure])
-  useLayoutEffect(measure, [line, measure])
-  return (
-    <Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
-      <div ref={rootRef} className={css.root}>
-        {groups.map((group, i) => (
-          <Fragment key={group}>
-            {i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
-            <span>{group}</span>
-          </Fragment>
-        ))}
-      </div>
-    </Tooltip>
-  )
-})
-
-export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) {
-  const settledNodes = useChat(s => s.legacy.nodes)
-  const usage = useProjection('tokenUsage')
-  // Every figure rides the durable sessionStats projection, so paging and
-  // compaction cannot change any of them; an assembly without the unit falls
-  // back to the window-scoped fold wholesale (same field names), paid only
-  // while no projection value is served.
-  const projected = useProjection('sessionStats')
-  const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
-  // Pipe-separated groups (figma stats strip); a group with no data drops out whole.
-  const groups: string[] = []
-  if (stats.steps > 0) {
-    groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
-    const durations: string[] = []
-    if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) }))
-    if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) }))
-    if (durations.length > 0) groups.push(durations.join(' · '))
-    const speeds: string[] = []
-    if (stats.ttftSteps > 0) {
-      speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) }))
-    }
-    if (stats.decodeMs > 0) {
-      speeds.push(t('stats.tokensPerSecond', {
-        throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
-      }))
-    }
-    if (speeds.length > 0) groups.push(speeds.join(' · '))
-  }
-  // Context occupancy deliberately lives on the composer's ContextMeter ring,
-  // not here — one home per fact.
-  // Billing rides the durable projection, so these survive paging and
-  // compaction. Gated on actual token activity: a session whose steps all
-  // settled without billing (e.g. every request failed) shows its counts
-  // without a zero-token group.
-  if (usage !== undefined
-    && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
-    const cacheHit = cacheHitPercent(usage)
-    if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
-    groups.push(t('stats.tokens', {
-      input: formatTokens(billedInputTokens(usage), t),
-      output: formatTokens(usage.outputTokens, t),
-    }))
-  }
-  const line = groups.join(' | ')
-  if (groups.length === 0) return null
-  return <StatsLineContent groups={groups} line={line} />
-})

+ 68 - 0
packages/client/ui-chat/src/client/chat/StatsPills.module.css

@@ -0,0 +1,68 @@
+/* Two icon pills side by side under the composer, 13/20 tertiary text tier
+   with a hover-pill affordance; each pill's click-open panel wears the shared
+   stat-dialog.module.css skin. */
+
+.root {
+  display: flex;
+  justify-content: center;
+  gap: 12px;
+  max-width: var(--dsh-chat-content-width);
+  width: 100%;
+  margin: 0 auto;
+  box-sizing: border-box;
+  padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
+}
+
+/* Dialog anchor: shrink-wraps its pill so the panel clamp measures the pill. */
+.anchor {
+  display: inline-flex;
+  min-width: 0;
+}
+
+.pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  box-sizing: border-box;
+  max-width: 100%;
+  padding: 1px 8px;
+  border: none;
+  border-radius: 24px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  font: inherit;
+  font-variant-numeric: tabular-nums;
+  line-height: inherit;
+  white-space: nowrap;
+}
+
+.pill svg {
+  width: 14px;
+  height: 14px;
+  flex: none;
+}
+
+/* Only the button form (a pill with dialog rows to open) invites interaction;
+   the static span form keeps the resting tertiary reading. */
+button.pill {
+  cursor: pointer;
+}
+
+button.pill:hover,
+button.pill[aria-expanded='true'] {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
+}
+
+.label {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.sep {
+  color: var(--dsw-alias-separator-primary);
+  margin: 0 6px;
+}

+ 353 - 0
packages/client/ui-chat/src/client/chat/StatsPills.tsx

@@ -0,0 +1,353 @@
+// Session stats under the composer, split into two icon pills: a gauge pill
+// (turn/step counts + output speed) opening the time-and-speed dialog, and a
+// database pill (total tokens + cache hit) opening the token-usage dialog.
+// Settled-node identity prevents stream-delta updates from rerendering the row.
+// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
+// active conversation scrollport (see ConversationRoot data-conversation-scroll).
+
+import { memo, useMemo, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
+import type {} from '@deepseek-ai/dsh-session-stats/client'
+import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+import type { ChatSnapshot } from '../contract/snapshot.ts'
+import { formatTokensPerSecond } from './message-chrome.ts'
+import { assistantStepReading } from '../contract/turn-metrics.ts'
+import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
+import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts'
+import css from './StatsPills.module.css'
+import dialogCss from './stat-dialog.module.css'
+
+interface WindowStats {
+  turns: number
+  steps: number
+  /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
+  llmMs: number
+  /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
+  toolMs: number
+  /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
+  ttftMs: number
+  /** Steps carrying a recorded TTFT. */
+  ttftSteps: number
+  /** Summed decode wall time over steps that also report output tokens. */
+  decodeMs: number
+  /** Summed output tokens over the same decode-timed steps. */
+  decodeTokens: number
+}
+
+/**
+ * Fold assistant and tool-result nodes into window-scoped display totals —
+ * the FALLBACK for assemblies without the `sessionStats` projection.
+ *
+ * Every displayed figure rides that durable whole-log projection (and token
+ * accounting rides `tokenUsage`) because the window is paged and compaction
+ * rewrites it; this fold answers "what is on screen" only when no projection
+ * value is served. Its field names deliberately mirror the projection's so
+ * the two swap wholesale.
+ * @param nodes - snapshot nodes.
+ * @returns fallback counts and summed wall times.
+ */
+export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
+  const turns = new Set<number>()
+  let steps = 0
+  let llmMs = 0
+  let toolMs = 0
+  let ttftMs = 0
+  let ttftSteps = 0
+  let decodeMs = 0
+  let decodeTokens = 0
+  for (const node of nodes) {
+    if (node.kind === 'tool-result') {
+      if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
+      continue
+    }
+    if (node.kind !== 'assistant') continue
+    turns.add(node.turn)
+    steps += 1
+    if (node.timing !== undefined && node.timing.stepStartTime !== null) {
+      llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
+    }
+    const reading = assistantStepReading(node)
+    if (reading.ttftMs !== null) {
+      ttftMs += reading.ttftMs
+      ttftSteps += 1
+    }
+    if (reading.decodeMs !== null && reading.outputTokens !== null) {
+      decodeMs += reading.decodeMs
+      decodeTokens += reading.outputTokens
+    }
+  }
+  return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
+}
+
+/**
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
+ * @param ms - duration in milliseconds.
+ * @returns display string.
+ */
+export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
+  const s = ms / 1_000
+  if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 })
+  const whole = Math.round(s)
+  return t('duration.compactMinutes', {
+    minutes: Math.floor(whole / 60),
+    seconds: whole % 60,
+  })
+}
+
+/**
+ * Display-ready cache-hit share of prompt-side input over the whole durable log.
+ * @param usage - the session's token-usage projection value.
+ * @returns integer text when integer rounding stays below 100, otherwise the
+ * minimum decimal precision that still rounds below 100; a full hit returns
+ * 100, and no billed input returns null.
+ */
+export function cacheHitPercent(usage: TokenUsageProjection): string | null {
+  const denominator = billedInputTokens(usage)
+  return formatCacheHitPercent(usage.cacheReadTokens, denominator)
+}
+
+/**
+ * Sum the three disjoint prompt-side billing buckets.
+ * @param usage - the session's token-usage projection value.
+ * @returns billed input tokens.
+ */
+export function billedInputTokens(usage: TokenUsageProjection): number {
+  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
+}
+
+/** Props: the conversation-snapshot selector plus the projection read seat. */
+export interface StatsPillsProps {
+  useChat: SnapshotSelectorHook<ChatSnapshot>
+  useProjection: UseProjection
+  /** The owning dock's locale seat. */
+  t: ChatViewSlotProps['t']
+}
+
+function exactCount(value: number, t: ChatViewSlotProps['t']): string {
+  return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
+}
+
+/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
+type PillDialog = Pick<ReturnType<typeof useStatDialog>, 'open' | 'setOpen'>
+
+function TimePill({ stats, t, dialog }: {
+  stats: WindowStats
+  t: ChatViewSlotProps['t']
+  dialog: PillDialog
+}) {
+  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
+  const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
+  const tps = stats.decodeMs > 0
+    ? t('message.tokensPerSecond', {
+      tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
+    })
+    : null
+  const label = (
+    <span className={css.label}>
+      {counts}
+      {tps !== null && (
+        <>
+          <span className={css.sep} aria-hidden>·</span>
+          {tps}
+        </>
+      )}
+    </span>
+  )
+  // A window without one timed figure has no dialog rows to show, so the pill
+  // stays a plain reading instead of a button opening an empty dialog.
+  if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
+    return (
+      <span className={css.anchor}>
+        <span className={css.pill}>
+          <IconGaugeOutline16 />
+          {label}
+        </span>
+      </span>
+    )
+  }
+  return (
+    <span ref={rootRef} className={css.anchor}>
+      <button
+        type="button"
+        className={css.pill}
+        aria-haspopup="dialog"
+        aria-expanded={open}
+        aria-label={tps === null ? counts : `${counts} · ${tps}`}
+        onClick={() => { setOpen(!open) }}
+      >
+        <IconGaugeOutline16 />
+        {label}
+      </button>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={dialogCss.panel}
+          role="dialog"
+          aria-label={t('stats.dialog.title')}
+          style={pos ?? MEASURE_STYLE}
+        >
+          <div className={dialogCss.title}>
+            <span className={dialogCss.titleLabel}>
+              <IconGaugeOutline16 />
+              {t('stats.dialog.title')}
+            </span>
+          </div>
+          <div className={dialogCss.titleRule} aria-hidden />
+          <dl className={dialogCss.details} data-session-stats-details>
+            {stats.llmMs > 0 && (
+              <>
+                <dt>{t('stats.dialog.llmTime')}</dt>
+                <dd>{formatDuration(stats.llmMs, t)}</dd>
+              </>
+            )}
+            {stats.toolMs > 0 && (
+              <>
+                <dt>{t('stats.dialog.toolTime')}</dt>
+                <dd>{formatDuration(stats.toolMs, t)}</dd>
+              </>
+            )}
+            {stats.ttftSteps > 0 && (
+              <>
+                <dt>{t('stats.dialog.ttft')}</dt>
+                <dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
+              </>
+            )}
+            {stats.decodeMs > 0 && (
+              <>
+                <dt>{t('stats.dialog.speed')}</dt>
+                <dd>{t('message.tokensPerSecond', {
+                  tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
+                })}</dd>
+              </>
+            )}
+          </dl>
+        </div>,
+        document.body,
+      )}
+    </span>
+  )
+}
+
+function UsagePill({ usage, t, dialog }: {
+  usage: TokenUsageProjection
+  t: ChatViewSlotProps['t']
+  dialog: PillDialog
+}) {
+  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
+  // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
+  const total = billedInputTokens(usage) + usage.outputTokens
+  const totalText = t('message.turnUsage.count', { count: formatTokens(total, t) })
+  const cacheHit = cacheHitPercent(usage)
+  const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
+  return (
+    <span ref={rootRef} className={css.anchor}>
+      <button
+        type="button"
+        className={css.pill}
+        aria-haspopup="dialog"
+        aria-expanded={open}
+        aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
+        onClick={() => { setOpen(!open) }}
+      >
+        <IconDatabaseOutline16 />
+        <span className={css.label}>
+          {totalText}
+          {cacheHitText !== null && (
+            <>
+              <span className={css.sep} aria-hidden>·</span>
+              {cacheHitText}
+            </>
+          )}
+        </span>
+      </button>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={dialogCss.panel}
+          role="dialog"
+          aria-label={t('stats.dialog.usageTitle')}
+          style={pos ?? MEASURE_STYLE}
+        >
+          <div className={dialogCss.title}>
+            <span className={dialogCss.titleLabel}>
+              <IconDatabaseOutline16 />
+              {t('stats.dialog.usageTitle')}
+            </span>
+            <span className={dialogCss.titleValue}>{exactCount(total, t)}</span>
+          </div>
+          <div className={dialogCss.titleRule} aria-hidden />
+          {/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
+              TurnUsagePanel's per-turn dl: same skin, different data contract (all
+              buckets always present here; per-turn fields are optional). */}
+          <dl className={dialogCss.details} data-session-stats-usage>
+            {cacheHit !== null && (
+              <>
+                <dt>{t('message.turnUsage.cacheHit')}</dt>
+                <dd>{`${cacheHit}%`}</dd>
+              </>
+            )}
+            <dt>{t('message.turnUsage.input')}</dt>
+            <dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
+            <dt>{t('message.turnUsage.cacheRead')}</dt>
+            <dd>{exactCount(usage.cacheReadTokens, t)}</dd>
+            <dt>{t('message.turnUsage.cacheWrite')}</dt>
+            <dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
+            <dt>{t('message.turnUsage.output')}</dt>
+            <dd>{exactCount(usage.outputTokens, t)}</dd>
+          </dl>
+          {/* jscpd:ignore-end */}
+        </div>,
+        document.body,
+      )}
+    </span>
+  )
+}
+
+export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
+  const settledNodes = useChat(s => s.legacy.nodes)
+  const usage = useProjection('tokenUsage')
+  // One exclusive slot for both dialogs: opening either pill closes the other.
+  const [openPill, setOpenPill] = useState<'time' | 'usage' | null>(null)
+  // Every figure rides the durable sessionStats projection, so paging and
+  // compaction cannot change any of them; an assembly without the unit falls
+  // back to the window-scoped fold wholesale (same field names), paid only
+  // while no projection value is served.
+  const projected = useProjection('sessionStats')
+  const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
+  // Gated on actual token activity: a session whose steps all settled without
+  // billing (e.g. every request failed) shows its counts without a usage pill.
+  const hasTokens = usage !== undefined
+    && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
+  if (stats.steps === 0 && !hasTokens) return null
+  // data-composer-stats: InputBar's `.root:has([data-composer-stats])` rule
+  // tightens the composer's bottom clearance only while this row renders.
+  return (
+    <div className={css.root} data-composer-stats>
+      {stats.steps > 0 && (
+        <TimePill
+          stats={stats}
+          t={t}
+          dialog={{
+            open: openPill === 'time',
+            setOpen: (open) => { setOpenPill(open ? 'time' : null) },
+          }}
+        />
+      )}
+      {hasTokens && (
+        <UsagePill
+          usage={usage}
+          t={t}
+          dialog={{
+            open: openPill === 'usage',
+            setOpen: (open) => { setOpenPill(open ? 'usage' : null) },
+          }}
+        />
+      )}
+    </div>
+  )
+})

+ 2 - 93
packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css

@@ -1,6 +1,5 @@
-/* Icon-row Turn-usage pill (data icon + turn total) plus its click-open
-   Turn-details dialog (menu surface: r12, elevation-prominent —
-   ContextMeter's panel skin). */
+/* Icon-row Turn-usage pill (data icon + turn total); its click-open dialog
+   wears the shared stat-dialog.module.css skin. */
 
 .root {
   display: inline-flex;
@@ -74,93 +73,3 @@
     margin-left: 0;
   }
 }
-
-/* Portal surface: fixed in the viewport, left/top supplied inline from the
-   anchored-position clamp so the panel keeps its 12px viewport margin instead
-   of hanging off the trigger and clipping at the window edge. Portaled panels
-   layer above modal overlays (z 1000). */
-.panel {
-  position: fixed;
-  z-index: 1100;
-  box-sizing: border-box;
-  /* Size to the widest row so provider/model stays on one line, within a cap.
-     Both bounds yield to a viewport narrower than themselves (12px margins),
-     so a fixed floor cannot push the panel past the placement clamp. */
-  width: max-content;
-  min-width: min(300px, calc(100vw - 24px));
-  max-width: min(440px, calc(100vw - 24px));
-  padding: 16px;
-  border: 0;
-  border-radius: 12px;
-  background: var(--dsw-specific-menu);
-  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
-  box-shadow: var(--dsw-elevation-prominent);
-  font-size: 12px;
-  line-height: 18px;
-  color: var(--dsw-alias-label-secondary);
-  cursor: default;
-}
-
-/* Dialog heading row: the section name left, the section's headline value
-   (the usage total) right, both at the primary weight. */
-.title {
-  display: flex;
-  justify-content: space-between;
-  gap: 16px;
-  margin-bottom: 8px;
-  color: var(--dsw-alias-label-primary);
-  font-weight: 500;
-}
-
-/* Rule under each section heading, above its rows. */
-.titleRule {
-  margin-bottom: 10px;
-  border-top: 0.5px solid var(--dsw-alias-border-l2);
-}
-
-.titleValue {
-  font-variant-numeric: tabular-nums;
-}
-
-/* Section-heading glyph seated left of the section name. */
-.titleLabel {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  min-width: 0;
-}
-
-.titleLabel svg {
-  width: 14px;
-  height: 14px;
-  flex: none;
-}
-
-.details {
-  display: grid;
-  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
-  gap: 6px 16px;
-  margin: 0;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.details dt,
-.details dd {
-  min-width: 0;
-  margin: 0;
-}
-
-.details dd {
-  color: var(--dsw-alias-label-secondary);
-  font-variant-numeric: tabular-nums;
-  text-align: right;
-}
-
-.details .route {
-  overflow-wrap: anywhere;
-}
-
-.reasoning {
-  color: var(--dsw-alias-label-tertiary);
-  white-space: nowrap;
-}

+ 16 - 69
packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx

@@ -3,16 +3,15 @@
 // turn wall time click-opens the Turn-time dialog. Both sit right of the
 // branch action in the tail's IconActions row, ahead of the plain clock text.
 
-import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
 import { createPortal } from 'react-dom'
-import {
-  IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, useDismissOnOutsidePointer,
-} from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconClockOutline16, IconDatabaseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
 import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
 import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
+import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts'
 import css from './TurnUsagePanel.module.css'
+import dialogCss from './stat-dialog.module.css'
 
 export interface TurnUsagePanelProps {
   usage: TurnTokenUsage
@@ -39,58 +38,6 @@ function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
   return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
 }
 
-/** Viewport margin the placement clamp keeps (the Menu portal margin). */
-const PANEL_MARGIN = 12
-
-/** Distance between the trigger's top edge and the panel's bottom. */
-const PANEL_GAP = 8
-
-/**
- * Unplaced portal panel: hidden but laid out so the clamp measures real
- * dimensions (the `useAnchoredPosition` measure pass).
- */
-const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
-
-interface StatDialogSeat {
-  open: boolean
-  setOpen: (open: boolean) => void
-  rootRef: MutableRefObject<HTMLSpanElement | null>
-  panelRef: MutableRefObject<HTMLDivElement | null>
-  pos: CSSProperties | null
-}
-
-/** One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close. */
-function useStatDialog(): StatDialogSeat {
-  const [open, setOpen] = useState(false)
-  const rootRef = useRef<HTMLSpanElement | null>(null)
-  const panelRef = useRef<HTMLDivElement | null>(null)
-
-  // Portal placement: the dialog is fixed above the trigger and clamped inside
-  // the viewport, so a trigger near the window edge cannot push it off-screen.
-  const pos = useAnchoredPosition({
-    open,
-    anchorRef: rootRef,
-    panelRef,
-    side: 'top',
-    gap: PANEL_GAP,
-    margin: PANEL_MARGIN,
-  })
-
-  // Outside pointerdown closes through the shared primitive; the portaled
-  // panel counts as inside. Escape close stays local, one listener while open.
-  useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
-  useEffect(() => {
-    if (!open) return
-    const onKeyDown = (e: KeyboardEvent): void => {
-      if (e.key === 'Escape') setOpen(false)
-    }
-    document.addEventListener('keydown', onKeyDown)
-    return () => { document.removeEventListener('keydown', onKeyDown) }
-  }, [open])
-
-  return { open, setOpen, rootRef, panelRef, pos }
-}
-
 /**
  * Turn-usage IconActions pill with a click-open Turn-usage details dialog.
  * @param props - Turn usage buckets and locale seat.
@@ -120,24 +67,24 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
       {open && createPortal(
         <div
           ref={panelRef}
-          className={css.panel}
+          className={dialogCss.panel}
           role="dialog"
           aria-label={t('message.turnUsage.title')}
           style={pos ?? MEASURE_STYLE}
         >
-          <div className={css.title}>
-            <span className={css.titleLabel}>
+          <div className={dialogCss.title}>
+            <span className={dialogCss.titleLabel}>
               <IconDatabaseOutline16 />
               {t('message.turnUsage.title')}
             </span>
-            <span className={css.titleValue}>{formatExactCount(usage.totalTokens, t)}</span>
+            <span className={dialogCss.titleValue}>{formatExactCount(usage.totalTokens, t)}</span>
           </div>
-          <div className={css.titleRule} aria-hidden />
-          <dl className={css.details} data-turn-usage-details>
+          <div className={dialogCss.titleRule} aria-hidden />
+          <dl className={dialogCss.details} data-turn-usage-details>
             {routes !== '' && (
               <>
                 <dt>{t('message.turnUsage.model')}</dt>
-                <dd className={css.route}>{routes}</dd>
+                <dd className={dialogCss.route}>{routes}</dd>
               </>
             )}
             {cacheHit !== null && (
@@ -164,7 +111,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
             <dd>
               {formatExactCount(usage.outputTokens, t)}
               {usage.reasoningTokens !== undefined && (
-                <span className={css.reasoning}>
+                <span className={dialogCss.reasoning}>
                   {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
                 </span>
               )}
@@ -199,19 +146,19 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan
       {open && createPortal(
         <div
           ref={panelRef}
-          className={css.panel}
+          className={dialogCss.panel}
           role="dialog"
           aria-label={t('message.turnTime.title')}
           style={pos ?? MEASURE_STYLE}
         >
-          <div className={css.title}>
-            <span className={css.titleLabel}>
+          <div className={dialogCss.title}>
+            <span className={dialogCss.titleLabel}>
               <IconClockOutline16 />
               {t('message.turnTime.title')}
             </span>
           </div>
-          <div className={css.titleRule} aria-hidden />
-          <dl className={css.details} data-turn-time-details>
+          <div className={dialogCss.titleRule} aria-hidden />
+          <dl className={dialogCss.details} data-turn-time-details>
             <dt>{t('message.turnTime.duration')}</dt>
             <dd>{formatRunDuration(runMs, t)}</dd>
             {tokensPerSecond !== undefined && (

+ 94 - 0
packages/client/ui-chat/src/client/chat/stat-dialog.module.css

@@ -0,0 +1,94 @@
+/* Stat-dialog surface skin shared by the Turn-stat pills (TurnUsagePanel) and
+   the session stats pills (StatsPills): menu surface, r12, elevation-prominent
+   — ContextMeter's panel skin. Placement comes from useStatDialog
+   (stat-dialog.ts). */
+
+/* Portal surface: fixed in the viewport, left/top supplied inline from the
+   anchored-position clamp so the panel keeps its 12px viewport margin instead
+   of hanging off the trigger and clipping at the window edge. Portaled panels
+   layer above modal overlays (z 1000). */
+.panel {
+  position: fixed;
+  z-index: 1100;
+  box-sizing: border-box;
+  /* Size to the widest row so provider/model stays on one line, within a cap.
+     Both bounds yield to a viewport narrower than themselves (12px margins),
+     so a fixed floor cannot push the panel past the placement clamp. */
+  width: max-content;
+  min-width: min(300px, calc(100vw - 24px));
+  max-width: min(440px, calc(100vw - 24px));
+  padding: 16px;
+  border: 0;
+  border-radius: 12px;
+  background: var(--dsw-specific-menu);
+  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
+  box-shadow: var(--dsw-elevation-prominent);
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+  cursor: default;
+}
+
+/* Dialog heading row: the section name left, the section's headline value
+   right, both at the primary weight. */
+.title {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 8px;
+  color: var(--dsw-alias-label-primary);
+  font-weight: 500;
+}
+
+/* Rule under each section heading, above its rows. */
+.titleRule {
+  margin-bottom: 10px;
+  border-top: 0.5px solid var(--dsw-alias-border-l2);
+}
+
+.titleValue {
+  font-variant-numeric: tabular-nums;
+}
+
+/* Section-heading glyph seated left of the section name. */
+.titleLabel {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+
+.titleLabel svg {
+  width: 14px;
+  height: 14px;
+  flex: none;
+}
+
+.details {
+  display: grid;
+  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
+  gap: 6px 16px;
+  margin: 0;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.details dt,
+.details dd {
+  min-width: 0;
+  margin: 0;
+}
+
+.details dd {
+  color: var(--dsw-alias-label-secondary);
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.details .route {
+  overflow-wrap: anywhere;
+}
+
+.reasoning {
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов