فهرست منبع

Merge remote-tracking branch 'origin/master' into feat/code-runtime-python-protocol

Chinesezjc 1 ماه پیش
والد
کامیت
48b7fc2bdc
100فایلهای تغییر یافته به همراه1573 افزوده شده و 562 حذف شده
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml
  2. 37 0
      .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md
  3. 37 0
      .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml
  5. 38 0
      .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md
  6. 38 0
      .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml
  8. 1 1
      .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md
  9. 1 1
      .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml
  11. 2 2
      .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md
  12. 2 2
      .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md
  13. 2 2
      README.i18n.yaml
  14. 1 1
      README.md
  15. 1 1
      README.zh.md
  16. 2 2
      apps/cli/README.i18n.yaml
  17. 11 2
      apps/cli/README.md
  18. 11 2
      apps/cli/README.zh.md
  19. 19 12
      apps/web/tests/chat-scroll-contract.e2e.ts
  20. 5 6
      apps/web/tests/complex-history.perf.ts
  21. 1 1
      apps/web/tests/math-rendering.e2e.ts
  22. 7 4
      apps/web/tests/plugin-config.e2e.ts
  23. 6 0
      apps/web/tests/seeded-history.e2e.ts
  24. 2 0
      apps/web/tests/settings-chrome.e2e.ts
  25. 0 3
      apps/web/tests/snapshots/agent-preset-authoring/created.expected.md
  26. 0 3
      apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md
  27. 0 3
      apps/web/tests/snapshots/agent-preset-authoring/section.expected.md
  28. 1 1
      apps/web/tests/snapshots/live-interactions/cancel.expected.md
  29. 1 0
      apps/web/tests/snapshots/live-interactions/error-auth.expected.md
  30. 1 1
      apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md
  31. 1 1
      apps/web/tests/snapshots/markdown-images/ui.expected.md
  32. 1 1
      apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md
  33. 1 1
      apps/web/tests/snapshots/math-rendering/ui.expected.md
  34. 0 3
      apps/web/tests/snapshots/models-settings/configured.expected.md
  35. 0 3
      apps/web/tests/snapshots/models-settings/declared-edit.expected.md
  36. 0 3
      apps/web/tests/snapshots/models-settings/declared.expected.md
  37. 0 3
      apps/web/tests/snapshots/models-settings/empty.expected.md
  38. 0 3
      apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md
  39. 0 3
      apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md
  40. 19 18
      apps/web/tests/snapshots/plugin-config/section.expected.md
  41. 1 1
      apps/web/tests/snapshots/queue-actions/preserved.expected.md
  42. 0 3
      apps/web/tests/snapshots/settings-chrome/dialog.expected.md
  43. 357 0
      apps/web/tests/snapshots/stats-paged-history/ui.expected.md
  44. 136 0
      apps/web/tests/stats-paged-history.e2e.ts
  45. 1 0
      apps/web/tsconfig.json
  46. 1 1
      docs/AGENTS.md
  47. 2 2
      docs/agent-lifecycle.i18n.yaml
  48. 4 4
      docs/agent-lifecycle.md
  49. 4 4
      docs/agent-lifecycle.zh.md
  50. 2 2
      docs/architecture.i18n.yaml
  51. 79 150
      docs/architecture.md
  52. 78 149
      docs/architecture.zh.md
  53. 2 2
      docs/config-catalog.i18n.yaml
  54. 1 0
      docs/config-catalog.md
  55. 1 0
      docs/config-catalog.zh.md
  56. 2 2
      docs/module-graph.i18n.yaml
  57. 8 1
      docs/module-graph.md
  58. 8 1
      docs/module-graph.zh.md
  59. 2 2
      docs/subsystems/core.i18n.yaml
  60. 1 1
      docs/subsystems/core.md
  61. 1 1
      docs/subsystems/core.zh.md
  62. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  63. 0 0
      docs/subsystems/llm-streaming.md
  64. 0 0
      docs/subsystems/llm-streaming.zh.md
  65. 2 2
      docs/user/guide/python-sdk.i18n.yaml
  66. 1 1
      docs/user/guide/python-sdk.md
  67. 1 1
      docs/user/guide/python-sdk.zh.md
  68. 1 1
      packages/bash/tool-bash/src/index.ts
  69. 5 0
      packages/bundle/web-app/cordis.patch.yml
  70. 1 0
      packages/bundle/web-app/package.json
  71. 2 2
      packages/client/README.i18n.yaml
  72. 2 2
      packages/client/README.md
  73. 2 2
      packages/client/README.zh.md
  74. 84 0
      packages/client/connection/src/client/fixture.ts
  75. 12 6
      packages/client/connection/tests/fixture.client.spec.ts
  76. 5 18
      packages/client/runtime/src/client/sessions/assistant-timing.ts
  77. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  78. 0 0
      packages/client/ui-conversation/README.md
  79. 0 0
      packages/client/ui-conversation/README.zh.md
  80. 2 0
      packages/client/ui-conversation/package.json
  81. 20 10
      packages/client/ui-conversation/src/client/chat/StatsLine.tsx
  82. 76 3
      packages/client/ui-conversation/tests/chat-stats.client.spec.tsx
  83. 5 3
      packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx
  84. 3 0
      packages/client/ui-conversation/tsconfig.json
  85. 2 2
      packages/client/ui-plugin-config/README.i18n.yaml
  86. 3 3
      packages/client/ui-plugin-config/README.md
  87. 3 3
      packages/client/ui-plugin-config/README.zh.md
  88. 1 1
      packages/client/ui-plugin-config/package.json
  89. 25 0
      packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx
  90. 51 2
      packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css
  91. 97 23
      packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx
  92. 66 18
      packages/client/ui-plugin-config/src/client/index.ts
  93. 11 7
      packages/client/ui-plugin-config/src/client/locales.ts
  94. 3 3
      packages/client/ui-plugin-config/src/index.ts
  95. 30 5
      packages/client/ui-plugin-config/tests/apply.client.spec.ts
  96. 88 12
      packages/client/ui-plugin-config/tests/section.client.spec.tsx
  97. 2 2
      packages/client/ui-plugins/README.i18n.yaml
  98. 3 3
      packages/client/ui-plugins/README.md
  99. 3 3
      packages/client/ui-plugins/README.zh.md
  100. 1 1
      packages/client/ui-plugins/package.json

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md
+2026-08-11-plugin-settings-tabs.md: c46276de2d0ffee141377190ccacf0e7884ec994
+2026-08-11-plugin-settings-tabs.zh.md: 00feb44f7b612e191c5b4c7dd3675ef34bf6e5ac

+ 37 - 0
.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md

@@ -0,0 +1,37 @@
+# Agent Note: Feature-owned tabs in Plugins settings
+
+Status: implemented
+
+English | [中文](2026-08-11-plugin-settings-tabs.zh.md)
+
+## Problem
+
+Plugin configuration and the read-only Loader inventory each registered a top-level `settings.section`. They described the same Plugins domain but occupied two navigation rows, split search and configuration into unrelated pages, and gave the Settings shell no principled way to present them together. Combining their components directly would instead make one feature plugin import and own the other feature's data lifecycle.
+
+## Decision
+
+`@deepseek-ai/dsh-client-ui-plugin-config` owns the single `settings.section` contribution with id `plugins`. It renders the shared title and compact tab chrome, declares the root-scoped list slot `settings.plugins.tab`, and projects that ledger's id, order, and locale-following label into its tabs. The slot's canonical type lives in `ui-settings`, so a tab contributor depends on the Settings domain contract rather than on another feature plugin.
+
+The section owner contributes a `configurable` tab that declares the existing nested `settings.plugin.item` list. Configuration cards keep their namespace bindings, draft state, validation, and writes unchanged. `@deepseek-ai/dsh-client-ui-plugins` contributes an `all` tab to `settings.plugins.tab`; its Host Loader observer, generated Remote namespace, DTO, and search semantics remain unchanged. Disabled inventory entries omit the redundant unmounted runtime state from summaries and details, while enabled entries continue to expose their Cordis phase.
+
+The first ordered tab is selected by default. A tab mounts only when first selected and then remains mounted but hidden while the Plugins section stays mounted. This delays the inventory RPC until the user opens **Plugin list** and preserves drafts, search text, disclosure state, and the fetched snapshot while switching tabs. Closing Settings unmounts the section, so reopening it obtains a fresh inventory snapshot when that tab is selected again.
+
+Both registrations use `ctx.slots.inject()`. If the section declarer unloads, the tab declaration and every contribution collapse with it; redeclaration lets each feature re-register without a static import or activation-order dependency.
+
+## Alternatives considered
+
+**Keep two Settings navigation rows and only rename them.** Rejected because the duplication is structural, not copy-related: both pages still represent the same Plugins domain and compete for navigation space.
+
+**Import the inventory component into `ui-plugin-config`.** Rejected because the configuration plugin would then own another plugin's Remote dependency and lifecycle. It would also turn an optional browser contribution into a package-level dependency.
+
+**Hard-code the two tab labels and components in the section owner.** Rejected because a third feature would require editing the owner, and HMR teardown could leave chrome for a contribution that no longer exists. The slot ledger already provides identity, ordering, localization, and cascade semantics.
+
+**Move Plugins aggregation into `ui-settings-general`.** Rejected because the Settings shell owns generic navigation and modal chrome, not feature content. Adding Plugins-specific tabs there would make every future Plugins view a shell change.
+
+## Consequences
+
+Settings has one Plugins navigation row, ordered before Agent Presets, with **Plugin configuration** and **Plugin list** tabs. Agent Presets remains an independent section because it edits per-session agent compositions rather than the live Host Loader tree.
+
+Feature ownership remains explicit: `ui-plugin-config` owns the Plugins page and editable cards, `ui-plugins` owns the read-only inventory view, and the Host/RPC path does not change. A new Plugins view can join by registering one `settings.plugins.tab` contribution.
+
+The aggregation depends on the section owner being composed: without `ui-plugin-config`, `ui-plugins` waits for a tab declaration and renders nothing. That is an intentional composition dependency carried by the slot registry rather than a static package import.

+ 37 - 0
.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: “插件”设置中的功能自有标签页
+
+Status: implemented
+
+[English](2026-08-11-plugin-settings-tabs.md) | 中文
+
+## 问题
+
+插件配置与只读 Loader 清单各自注册了一个顶层 `settings.section`。两者描述同一个“插件”领域,却占据两行导航,把搜索与配置拆成互不相关的页面,也没有给 Settings 外壳一个有原则的聚合方式。若直接合并两者的组件,则会让一个功能插件 import 并拥有另一个功能的数据生命周期。
+
+## 决策
+
+`@deepseek-ai/dsh-client-ui-plugin-config` 拥有唯一一个 id 为 `plugins` 的 `settings.section` 贡献。它渲染共享标题和紧凑标签栏,声明根级列表 slot `settings.plugins.tab`,并把该记录中的 id、order 与跟随语言的 label 投影成标签页。该 slot 的规范类型位于 `ui-settings`,因此标签页贡献方依赖设置领域约定,而不是依赖另一个功能插件。
+
+分区拥有方贡献 `configurable` 标签页,由它声明既有的嵌套 `settings.plugin.item` 列表。配置卡片原有的命名空间绑定、草稿状态、校验与写入均保持不变。`@deepseek-ai/dsh-client-ui-plugins` 向 `settings.plugins.tab` 贡献 `all` 标签页;它的 Host Loader 观察器、生成的 Remote 命名空间、DTO 与搜索语义保持不变。已停用的清单条目会在摘要和详情中省略重复的“未挂载”运行状态,已启用条目仍显示其 Cordis 阶段。
+
+默认选择顺序中的第一个标签页。某个标签页只有首次被选择时才挂载,之后在“插件”分区保持挂载期间只隐藏而不卸载。这样会把清单 RPC 延迟到用户打开**插件列表**时,并在切换标签页时保留草稿、搜索文本、折叠状态和已读取的快照。关闭 Settings 会卸载该分区,因此再次打开后,重新选择该标签页时会取得新的清单快照。
+
+两项注册都使用 `ctx.slots.inject()`。分区声明方卸载时,标签 slot 及其全部贡献随之折叠;重新声明后,每项功能都能重新注册,无需静态 import,也不依赖激活顺序。
+
+## 备选方案
+
+**保留两行 Settings 导航,只改名称。** 否决,因为重复是结构问题,而非文案问题:两个页面仍然代表同一个“插件”领域,并继续争夺导航空间。
+
+**把清单组件 import 进 `ui-plugin-config`。** 否决,因为配置插件会因此拥有另一个插件的 Remote 依赖与生命周期,也会把可选的浏览器贡献变成包级依赖。
+
+**在分区拥有方硬编码两个标签页的名称和组件。** 否决,因为第三项功能需要修改拥有方,HMR teardown 也可能留下已不存在贡献的界面框架。slot 记录已经提供标识、顺序、本地化与级联语义。
+
+**把“插件”聚合移入 `ui-settings-general`。** 否决,因为 Settings 外壳拥有通用导航与模态界面框架,而不拥有功能内容。把“插件”专属标签页放在那里,会让今后每一种“插件”视图都需要修改外壳。
+
+## 影响
+
+Settings 只有一行“插件”导航,排在“Agent 预设”之前,包含**插件配置**与**插件列表**两个标签页。“Agent 预设”仍是独立分区,因为它编辑每个会话的 agent 组装,而非实时 Host Loader 树。
+
+功能所有权保持明确:`ui-plugin-config` 拥有“插件”页面与可编辑卡片,`ui-plugins` 拥有只读清单视图,Host/RPC 路径不变。新的“插件”视图只需注册一个 `settings.plugins.tab` 贡献即可加入。
+
+该聚合依赖分区拥有方被组装:没有 `ui-plugin-config` 时,`ui-plugins` 会等待标签 slot 的声明且不渲染任何内容。这是通过 slot 注册表承载的有意组合依赖,而不是静态包 import。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md
+2026-08-12-full-session-turn-step-counts.md: ecfa00dc3e24101953a9d5a724dba17682d839bd
+2026-08-12-full-session-turn-step-counts.zh.md: 85e2a26b5af51296e20f29af49e909c6e182ea05

+ 38 - 0
.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md

@@ -0,0 +1,38 @@
+# Agent Note: Full-session stats-strip figures through a sessionStats projection
+
+Status: implemented
+
+English | [中文](2026-08-12-full-session-turn-step-counts.zh.md)
+
+## Problem
+
+The web chat stats strip folded `StatsLine`'s loaded conversation window (`deriveStats` over `chat.legacy.nodes`) for every non-token figure: the "N turns · M steps" counter, the LLM and tool wall times, and the TTFT/throughput averages. History is paged 50 messages at a time, so each 加载更早 (Load earlier) click grew the window and every figure with it — 7 turns · 44 steps became 10 turns · 89 steps after one page, and the LLM duration climbed the same way. The product expectation is whole-session figures independent of how much history a client has loaded. Token accounting in the same strip already had the correct architecture: the durable `tokenUsage` projection.
+
+## Decision
+
+A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (beside the `StreamChunk` type it discriminates) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline.
+
+`step/end` — not `assistant/message` — is the counted event, for two correctness reasons found while reviewing the obvious message-counting design:
+
+1. A max-tokens step appends an empty-content `assistant/message` that exists only to host usage and never reaches the surface; counting messages would count a step the transcript does not show.
+2. A cancelled step aborts before its message assembles (no `assistant/message` at all), yet the client synthesizes a visible interrupted assistant node; counting messages would silently drop common cancelled steps.
+
+`step/end` is appended exactly once per entered step, in the loop's `finally`, so completed, failed, cancelled, and max-tokens steps all land one — and the counter advances at step settlement, the same moment the window fold advanced, so live behavior does not shift.
+
+## Alternatives considered
+
+**Count `assistant/message` events.** Rejected for the two correctness defects above (overcounts usage-host messages, undercounts cancelled steps).
+
+**Count `step/start` events.** Equivalent coverage (it precedes every `step/end`), but the counter would advance when a step begins instead of when it settles — a visible live-behavior change with no benefit; `step/end`'s `finally` placement gives the same completeness.
+
+**Register the unit in `core/agent-loop` (the event producer).** The loop is the product spine; a UI read model there adds a session-projection dependency to every assembly, against "plugins, not loop changes" and "keep opt-ins out of shipped defaults".
+
+**Register the unit in `token-meter` (an existing fold over the same events).** Turn/step counting is not token measurement; every projection key lives in the package owning its domain.
+
+**Fold the full log client-side.** The client holds only the paged window by design; the projection RFC's no-client-folding rule exists exactly so figures survive paging, compaction, and cold reads.
+
+**Keep wall times, TTFT, and throughput window-scoped, reading them as "what is on screen".** Rejected: the same paging complaint applies to the LLM duration, and a strip mixing whole-log counts with window-scoped times reads as one inconsistent figure set. The projection carries the whole set, with the window fold demoted to the no-unit fallback.
+
+## Consequences
+
+The strip shows whole-log figures from the first tail page; paging leaves every group fixed. Defined edge differences from the old window semantics are documented in the package README: a step that produced no visible output (failed before content) still counts, a step interrupted by a crash counts once recovery closes it with a synthetic `step/end` on reload (`interruptedTurnClosers`), a cancelled step is counted but contributes no wall time (no message assembled), and a max-tokens usage-host message contributes model time the surface does not show. Every web tail page and list row carries one more small key, and the unit's internal state changes on step boundaries and first-token chunks, so the change feed emits a few value-identical frames per step; TUI and headless assemblies serve no `sessionStats` key and any consumer falls back to window folding. Two e2e probes that had parsed the strip as a loaded-window measure (`chat-scroll-contract`, `complex-history.perf`) now count mounted flow rows / turn-tail footers instead. The `stats-paged-history` web scenario seeds a 28-turn log cold and pins that the whole strip reads full totals on a partial tail page and does not move across Load earlier.

+ 38 - 0
.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md

@@ -0,0 +1,38 @@
+# Agent Note: 通过 sessionStats 投影提供全会话统计条数字
+
+Status: implemented
+
+[English](2026-08-12-full-session-turn-step-counts.md) | 中文
+
+## 问题
+
+Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载的会话窗口(`deriveStats` 遍历 `chat.legacy.nodes`):「N 轮 · M 步」计数、LLM 与工具墙钟时间、TTFT/吞吐平均值。历史按每页 50 条消息分页,因此每点一次「加载更早」窗口变大、所有数字随之增长——7 轮 · 44 步在翻一页后变成 10 轮 · 89 步,LLM 时长同样攀升。产品预期是与客户端加载了多少历史无关的全会话数字。同一统计条里的 token 账目早已采用正确架构:持久的 `tokenUsage` 投影。
+
+## 决定
+
+新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(与其判别的 `StreamChunk` 类型同处),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。
+
+计数事件选 `step/end` 而非 `assistant/message`,源于评审直觉方案(按消息计数)时发现的两个正确性问题:
+
+1. max-tokens 步会追加一条仅为承载 usage 而存在的空内容 `assistant/message`,它从不进入 surface;按消息计数会把 transcript 上看不到的步计进去。
+2. 被取消的步在消息组装前就中止(完全没有 `assistant/message`),但客户端会合成可见的 interrupted assistant 节点;按消息计数会悄悄丢掉常见的取消步。
+
+`step/end` 对每个进入的步在循环的 `finally` 中恰好追加一次,因此完成、失败、取消、max-tokens 的步都恰好落一条——且计数在步结算时推进,与窗口折算推进的时机相同,直播期行为不发生变化。
+
+## 备选方案
+
+**统计 `assistant/message` 事件。** 因上述两个正确性缺陷否决(多计 usage 宿主消息、少计被取消的步)。
+
+**统计 `step/start` 事件。** 覆盖等价(它先于每条 `step/end`),但计数会在步开始而非结算时推进——一个没有收益的可见直播期行为变化;`step/end` 的 `finally` 位置给出同等完整性。
+
+**把单元注册进 `core/agent-loop`(事件生产方)。** 循环是产品主干;把 UI 读模型放进去会给每个装配加上 session-projection 依赖,违反「用插件而非改循环」与「默认组合不带可选项」。
+
+**把单元注册进 `token-meter`(折叠同批事件的现有单元)。** 轮/步计数不是 token 度量;每个投影键都住在拥有其领域的包里。
+
+**在客户端折叠全量日志。** 客户端按设计只持有分页窗口;投影 RFC 的「不在客户端折叠」规则正是为了让数字在分页、压缩与冷读之间存活。
+
+**墙钟时间、TTFT 与吞吐保持窗口口径,解读为「屏幕上有什么」。** 否决:同样的分页问题一样落在 LLM 时长上,且全量计数与窗口时间混在一条统计条里读起来是一套自相矛盾的数字。投影携带完整集合,窗口折叠降级为无单元时的回退。
+
+## 后果
+
+统计条从第一个尾页起就显示全日志数字;翻页不再改变任何分组。与旧窗口语义的已定义边缘差异记录在包 README 中:未产生可见输出的步(在内容之前失败)仍计入;被崩溃打断的步在重新加载、恢复为其补写合成 `step/end` 后计入(`interruptedTurnClosers`);被取消的步计数但不计时(没有组装出消息);max-tokens 的 usage 宿主消息贡献 surface 上看不到的模型时间。每个 web 尾页与列表行多携带一个小键,且单元内部状态在步边界与首 token chunk 处变化,变更流每步会多发几帧值相同的推送;TUI 与 headless 装配不提供 `sessionStats` 键,其消费者回退窗口折叠。两个曾把统计条当作已加载窗口探针解析的 e2e(`chat-scroll-contract`、`complex-history.perf`)改为统计已挂载的消息流行/turn-tail 页脚。`stats-paged-history` web 场景冷种一份 28 轮日志,钉住整条统计条在不完整尾页上即读出全量数字、且「加载更早」前后不变。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-10-telemetry-default-off.md
-2026-08-10-telemetry-default-off.md: 8163079eb5f8d6170e329c164141364681030793
-2026-08-10-telemetry-default-off.zh.md: c8e16f84248bde5bdc2c1d4bdb814f0d80fcf77e
+2026-08-10-telemetry-default-off.md: c3e5d9e0b65449f91044f16e7649f4a5ff2f5b61
+2026-08-10-telemetry-default-off.zh.md: 8e2544eb7dee8b9bd6b8a4c81a28a8a03d3404d4

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md

@@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the
 
 Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings.
 
-The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. Telemetry consent is owned here; no SDK project configuration or toolchain may opt in on the launching environment's behalf.
+The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md).
 
 The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md

@@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础
 
 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。
 
-dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。遥测授权由本说明持有;SDK 项目配置或工具链不得代替启动环境显式启用遥测
+dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.md)删除之前,仅取代了启动器默认允许上报的规则
 
 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.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-10-web-plugin-configuration.md
-2026-08-10-web-plugin-configuration.md: 7375f496c7af1a695243444fe56aca7262d3dedd
-2026-08-10-web-plugin-configuration.zh.md: 59d65db39bcc2306983f2a26dcf252164d7a6f37
+2026-08-10-web-plugin-configuration.md: 29c69695141d4fd00f5b323232522c5298ce4e9b
+2026-08-10-web-plugin-configuration.zh.md: d7df9c332fc3cbca20ec75cb543600601bbac141

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md

@@ -12,7 +12,7 @@ The seam that made the Models page possible was already general: any plugin may
 
 ## Decision
 
-Three host-plane plugins register their own settings namespace, and one browser-side section renders whatever the deployment exposes.
+Three host-plane plugins register their own settings namespace, and one browser-side Plugins section aggregates feature-owned tabs. Its configurable tab renders whatever editable settings the deployment exposes.
 
 **Layering, unchanged.** A section resolves as schema defaults → the plugin's composition entry → the user layer. Each plugin passes its `cordis.yml` entry as the `base` and reads its config through a source thunk, so a stored change reaches the next use and a detaching settings provider leaves the composition entry running. Constraints the schema cannot express — positive and finite, the timer bound on `graceMs`, the parallel cap being a positive integer — become the section validator, so a bad value is refused at the write instead of at the next command.
 
@@ -24,7 +24,7 @@ Three host-plane plugins register their own settings namespace, and one browser-
 
 **Exposure stays a Host allowlist.** The three namespaces join `WEB_SETTINGS_NAMESPACES`; registration alone still never crosses the transport, and a namespace absent from that list answers `settings-not-exposed` exactly as an unregistered one does.
 
-**The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it.
+**The configurable tab knows no namespace.** `dsh-client-ui-plugin-config` owns the Plugins section, contributes its `configurable` page through `settings.plugins.tab`, and declares a nested `settings.plugin.item` slot there. It renders the cards registered into that nested slot, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it.
 
 **A card stages its edits and writes them on save.** Controls hold no draft of their own: the card's form owns the staged text, every control renders it, and only **Save** turns it into document mutations. A settings write is durable and revision-fenced, so a control that committed as it settled spent a revision on a value the user had not decided to store and could not preview; the reset stages the composed default the same way. Because the Host's validators own the constraints no schema can express, the form reads the section back after writing and reports a save that did not land instead of predicting the outcome, keeping those drafts for the user to correct. The credential control is staged with the rest even though it writes through the credentials domain, so one save covers everything the card shows.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧分区渲染该部署所暴露的一切。
+三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧“插件”分区聚合由各功能持有的标签页。它的“可配置”标签页渲染该部署所暴露的一切可编辑设置
 
 **分层不变。** 一个分节按 schema 默认值 → 插件的组装条目 → 用户层解析。每个插件把自己的 `cordis.yml` 条目作为 `base` 传入,并通过 source thunk 读取配置,因此存储的变更会作用于下一次使用,而脱离的 settings 提供方会让组装条目继续运行。schema 无法表达的约束——正有限、`graceMs` 的定时器上界、并行上限必须是正整数——成为分节的校验器,因此错误的值在写入时被拒绝,而不是到下一条命令时才失败。
 
@@ -24,7 +24,7 @@ Status: implemented
 
 **暴露仍是 Host 的白名单。** 这三个命名空间加入 `WEB_SETTINGS_NAMESPACES`;仅有注册依然不会跨越传输边界,而不在该名单中的命名空间会与未注册的命名空间得到完全相同的 `settings-not-exposed`。
 
-**该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。
+**“可配置”标签页不认识任何命名空间。** `dsh-client-ui-plugin-config` 拥有“插件”分区,通过 `settings.plugins.tab` 贡献自己的 `configurable` 页面,并在其中声明嵌套的 `settings.plugin.item` slot。它渲染注册进这个嵌套 slot 的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。
 
 **卡片暂存修改,保存时才写入。** 控件不持有自己的草稿:暂存文本归卡片的表单所有,所有控件渲染的都是它,只有**保存**才把它变成文档变更。settings 写入是持久且带 revision 栅栏的,因此「失焦即提交」的控件会为用户尚未决定存储、也无从预览的值花掉一个 revision;重置同样只是暂存组装默认值。schema 表达不了的约束归 Host 的校验器所有,所以表单在写入后回读分节、报告没有落盘的保存,而不是自行预测结果,并保留这些草稿供用户修改。密钥控件虽然经由 credentials 领域写入,也和其余字段一起暂存,因此一次保存覆盖卡片上的全部内容。
 

+ 2 - 2
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 README.md
-README.md: c471b1a2470a5f2161a7950d757c5784a8e27aee
-README.zh.md: a13a5e3acc90c77cad209f1825e385bff480ae56
+README.md: 05eee108fe57f75671a74cec5f3858b1abdfcef2
+README.zh.md: 1a25075e63f13a1f92c678b88b45e2ecca4c4c77

+ 1 - 1
README.md

@@ -29,7 +29,7 @@ Continue with the [Web UI guide](docs/user/guide/index.md).
 To run a repository checkout instead:
 
 ```sh
-git clone https://github.com/deepseek-harness/deepseek-harness.git
+git clone https://github.com/deepseek-ai/deepseek-harness.git
 cd deepseek-harness
 pnpm install
 pnpm run build

+ 1 - 1
README.zh.md

@@ -30,7 +30,7 @@ npx @deepseek-ai/dsh web
 如需从仓库源码运行:
 
 ```sh
-git clone https://github.com/deepseek-harness/deepseek-harness.git
+git clone https://github.com/deepseek-ai/deepseek-harness.git
 cd deepseek-harness
 pnpm install
 pnpm run build

+ 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: 4fbd0692a2df403c6395235e096e193c994ea198
-README.zh.md: 7861f5b4f8cb447aff01ec8b64b12fd298485b4b
+README.md: d36183c545475020207bbf23e58d3c98b07ac6a6
+README.zh.md: 4693339e836d81c0b069704fb43451fb22accfdb

+ 11 - 2
apps/cli/README.md

@@ -21,7 +21,7 @@ The launcher parses only its own flags and hands everything after them to the bo
 
 ```sh
 dsh --profile web --port 8080       # --port belongs to the web app
-dsh --profile tui --resume <id>     # --resume belongs to the terminal app
+dsh --profile tui --resume <id>     # example, assuming the tui profile is installed; --resume belongs to the terminal app
 dsh --profile headless "run the tests"
 dsh --profile web --help            # the web app's flags, not the launcher's
 dsh --help                          # the launcher's own help
@@ -29,7 +29,16 @@ dsh --help                          # the launcher's own help
 
 ## Profiles
 
-A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
+A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer).
+
+The tree composes over an empty root:
+- each bundle's patch in `dsh.profile.bundles` order
+- then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`
+- then `--patch` overlays
+
+Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins.
+
+Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
 
 The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
 

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

@@ -21,7 +21,7 @@
 
 ```sh
 dsh --profile web --port 8080       # --port belongs to the web app
-dsh --profile tui --resume <id>     # --resume belongs to the terminal app
+dsh --profile tui --resume <id>     # example, assuming the tui profile is installed; --resume belongs to the terminal app
 dsh --profile headless "run the tests"
 dsh --profile web --help            # the web app's flags, not the launcher's
 dsh --help                          # the launcher's own help
@@ -29,7 +29,16 @@ dsh --help                          # the launcher's own help
 
 ## Profile
 
-profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
+profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层)。
+
+配置树在空根之上组合:
+- 先按 `dsh.profile.bundles` 顺序应用各组合包的 patch
+- 然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`
+- 最后是 `--patch` overlay
+
+`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。
+
+使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
 
 [CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码执行。
 

+ 19 - 12
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -250,13 +250,16 @@ function scrollGeometry(page: Page): Promise<ScrollGeometry> {
   }))
 }
 
-async function conversationTurns(page: Page): Promise<number> {
-  const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
-  await stats.waitFor({ timeout: 15_000 })
-  const value = await stats.textContent()
-  const match = value?.match(/^(\d+) turns · \d+ steps$/)
-  if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
-  return Number(match[1])
+/**
+ * Rendered transcript rows in the loaded window. The stats strip cannot serve
+ * as this probe: its turn/step counts ride the whole-log sessionStats
+ * projection and stay fixed across paging by design, while the row count is
+ * exactly what grows when an older page prepends or a live turn streams in.
+ * @param page - the scenario page.
+ * @returns the number of mounted chat flow rows.
+ */
+async function loadedFlowRows(page: Page): Promise<number> {
+  return page.locator('[data-chat-flow-key]').count()
 }
 
 async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
@@ -425,9 +428,9 @@ async function loadEarlierWithAnchor(page: Page): Promise<void> {
   const older = page.getByRole('button', { name: 'Load earlier', exact: true })
   await older.waitFor({ timeout: 10_000 })
   const anchor = await visibleFlowAnchor(page)
-  const before = await conversationTurns(page)
+  const before = await loadedFlowRows(page)
   await older.click()
-  await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
+  await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before)
   await nextPaint(page)
   await expectSameFlowTop(page, anchor)
 }
@@ -498,7 +501,7 @@ describe('web e2e: long Chat scroll contract', () => {
         await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
         await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
         await wheelToHistoryStart(world.page)
-        const beforeTurns = await conversationTurns(world.page)
+        const beforeRows = await loadedFlowRows(world.page)
         await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
         await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
 
@@ -511,7 +514,7 @@ describe('web e2e: long Chat scroll contract', () => {
         ).toBeGreaterThan(chunksAfterAnchor + 5)
 
         releaseHistory()
-        await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
+        await expect.poll(() => loadedFlowRows(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeRows)
         await nextPaint(world.page)
         await expectSameFlowTop(world.page, readerAnchor)
       } finally {
@@ -531,7 +534,11 @@ describe('web e2e: long Chat scroll contract', () => {
         additionalPages += 1
       }
       expect(additionalPages).toBeGreaterThan(0)
-      expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
+      // The whole log is loaded: turn 1's unique marker renders in the
+      // transcript (scoped: the sidebar search row also carries it) and no
+      // page remains.
+      expect(await world.page.locator('[data-conversation-scroll]')
+        .getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1)
       expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
       assertClean(world)
     })

+ 5 - 6
apps/web/tests/complex-history.perf.ts

@@ -797,12 +797,11 @@ async function stableCount(
 }
 
 async function conversationTurns(page: Page): Promise<number> {
-  const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
-  await stats.waitFor({ timeout: 15_000 })
-  const value = await stats.textContent()
-  const match = value?.match(/^(\d+) turns · \d+ steps$/)
-  if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
-  return Number(match[1])
+  // Loaded-window turn count: one mounted turn-tail footer per settled turn in
+  // the window (context keys are `${kind.length}:${kind}${id}`). The stats
+  // strip cannot serve as this probe: its counts ride the whole-log
+  // sessionStats projection and stay fixed across paging by design.
+  return stableCount(page.locator('[data-chat-flow-key^="9:turn-tail"]'), count => count > 0)
 }
 
 function retainedDelta(

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

@@ -120,7 +120,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('Input 0 tok · Output 0 tok', { exact: false }).count(),
+      () => page.getByText('1 turns · 1 steps', { exact: false }).count(),
       { timeout: 10_000 },
     ).toBe(1)
 

+ 7 - 4
apps/web/tests/plugin-config.e2e.ts

@@ -1,5 +1,5 @@
-// Web e2e scenario: the Plugins settings section — the cards a deployment's
-// exposed host-plane namespaces produce, one field edited through the real
+// Web e2e scenario: the configurable tab in Plugins settings — the cards a
+// deployment's exposed host-plane namespaces produce, one field edited through the real
 // wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset
 // that layering produces. Zero model calls: everything is client state plus
 // the settings document on a blank frame, so there is no fixture and a stray
@@ -56,9 +56,12 @@ describe('web e2e: plugin configuration section', () => {
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
-    await dialog.getByRole('button', { name: '插件配置', exact: true }).click()
+    await dialog.getByRole('button', { name: '插件', exact: true }).click()
     await expect
-      .poll(() => dialog.getByRole('button', { name: '插件配置', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
+      .poll(() => dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current'), { timeout: 5_000 })
+      .toBe('true')
+    await expect
+      .poll(() => dialog.getByRole('tab', { name: '插件配置', exact: true }).getAttribute('aria-selected'), { timeout: 5_000 })
       .toBe('true')
     return dialog
   }

+ 6 - 0
apps/web/tests/seeded-history.e2e.ts

@@ -256,6 +256,12 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // client's "omitted key = capability absent → clear the row" rule from
     // wiping preset-owned projections on cold reads.
     expect(projections?.values).toHaveProperty('todos', null)
+    // The session-stats unit is a shipped web-app bundle row: whole-log
+    // turn/step counts ride the same tail block (the stats strip's source).
+    const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
+    expect(sessionStats).toBeDefined()
+    expect(sessionStats?.turns).toBeGreaterThanOrEqual(1)
+    expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0)
   })
 
   it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {

+ 2 - 0
apps/web/tests/settings-chrome.e2e.ts

@@ -99,6 +99,7 @@ describe('web e2e: settings modal and General preferences', () => {
     // an unrelated plugin does not rewrite this surface's golden.
     await dialog.getByRole('button', { name: '插件', exact: true }).click()
     await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('tab', { name: '插件列表', exact: true }).click()
     const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
     await pluginRow.waitFor({ timeout: 10_000 })
     const expectedPluginCount = [...scaffold.ctx.loader.entries()]
@@ -109,6 +110,7 @@ describe('web e2e: settings modal and General preferences', () => {
     expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
       .toBe(String(expectedPluginCount))
     expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true')
+    expect(await dialog.getByRole('tab', { name: '插件列表', exact: true }).getAttribute('aria-selected')).toBe('true')
     expect(await dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current')).toBeNull()
     const pluginsSnapshot = await captureStableAria(
       page,

+ 0 - 3
apps/web/tests/snapshots/agent-preset-authoring/created.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/agent-preset-authoring/section.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 1 - 1
apps/web/tests/snapshots/live-interactions/cancel.expected.md

@@ -31,4 +31,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps

+ 1 - 0
apps/web/tests/snapshots/live-interactions/error-auth.expected.md

@@ -27,3 +27,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
+- text: 1 turns · 1 steps

+ 1 - 1
apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md

@@ -53,4 +53,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}}

+ 1 - 1
apps/web/tests/snapshots/markdown-images/ui.expected.md

@@ -32,4 +32,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}}

+ 1 - 1
apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md

@@ -44,4 +44,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}}

+ 1 - 1
apps/web/tests/snapshots/math-rendering/ui.expected.md

@@ -48,4 +48,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps LLM {{duration}}

+ 0 - 3
apps/web/tests/snapshots/models-settings/configured.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/models-settings/declared-edit.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/models-settings/declared.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/models-settings/empty.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 0 - 3
apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 19 - 18
apps/web/tests/snapshots/plugin-config/section.expected.md

@@ -13,25 +13,26 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img
     - text: 关闭
-  - heading "插件配置" [level=2]
-  - paragraph: 配置本部署已安装的插件。
-  - list:
-    - listitem:
-      - 'button "展开设置: 终端"':
-        - text: 终端 限制 agent 运行的每一条命令。
-        - img
-    - listitem:
-      - 'button "展开设置: Agent 循环"':
-        - text: Agent 循环 Agent 如何派发工具调用。
-        - img
-    - listitem:
-      - 'button "展开设置: 网页搜索"':
-        - text: 网页搜索 DeepSeek 搜索提供方。
-        - img
+  - heading "插件" [level=2]
+  - paragraph: 配置和查看本部署已安装的插件。
+  - tablist "插件视图":
+    - tab "插件配置" [selected]
+    - tab "插件列表"
+  - tabpanel "插件配置":
+    - list:
+      - listitem:
+        - 'button "展开设置: 终端"':
+          - text: 终端 限制 agent 运行的每一条命令。
+          - img
+      - listitem:
+        - 'button "展开设置: Agent 循环"':
+          - text: Agent 循环 Agent 如何派发工具调用。
+          - img
+      - listitem:
+        - 'button "展开设置: 网页搜索"':
+          - text: 网页搜索 DeepSeek 搜索提供方。
+          - img

+ 1 - 1
apps/web/tests/snapshots/queue-actions/preserved.expected.md

@@ -50,4 +50,4 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
+- text: 1 turns · 1 steps

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

@@ -13,9 +13,6 @@
     - button "Agent 预设":
       - img
       - text: Agent 预设
-    - button "插件配置":
-      - img
-      - text: 插件配置
   - button "打开配置文件"
   - button "关闭":
     - img

+ 357 - 0
apps/web/tests/snapshots/stats-paged-history/ui.expected.md

@@ -0,0 +1,357 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "{{workspace}}" [disabled]
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- text: m1 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r1
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m2 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r2
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m3 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r3
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m4 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r4
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m5 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r5
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m6 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r6
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m7 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r7
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m8 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r8
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m9 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r9
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m10 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r10
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m11 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r11
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m12 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r12
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m13 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r13
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m14 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r14
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m15 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r15
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m16 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r16
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m17 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r17
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m18 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r18
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m19 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r19
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m20 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r20
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m21 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r21
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m22 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r22
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m23 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r23
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m24 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r24
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m25 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r25
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m26 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r26
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m27 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r27
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}} m28 7/25 {{clock}}
+- button "Copy":
+  - img
+- paragraph: r28
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: 7/25 {{clock}} Ran for {{duration}}
+- button "Back to bottom":
+  - img
+- textbox "Message the agent"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Send message" [disabled]
+- text: 28 turns · 28 steps LLM {{duration}}

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

@@ -0,0 +1,136 @@
+// Web e2e scenario: full-session stats over paged history. A deterministic
+// 28-turn log (56 surface messages — more than one 50-message history page)
+// seeded cold through the REAL persistence API must render whole-log turn/step
+// counts from the sessionStats projection on first open, and loading the
+// older page must NOT change them. This pins the bug the projection fixed:
+// the pre-projection window fold recounted per loaded page, so 加载更早 grew
+// the counter. Zero model calls; the seed is generated, not recorded, because
+// no line of it is model output.
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/stats-paged-history', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./snapshots/stats-paged-history/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+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`
+
+/**
+ * Generate the seed: TURNS closed single-step turns of one short user prompt
+ * and one short assistant reply each. Times are fixed so the fixture is
+ * byte-deterministic; message ids are synthetic uuids (aria normalizes them).
+ * @param turns - closed turns to generate.
+ * @returns session.jsonl text for {@link seedSession}.
+ */
+function buildSeed(turns: number): string {
+  const lines = [JSON.stringify({
+    type: 'session', version: 0, id: '{{sessionId}}', createdAt: 1784974100000, cwd: '{{cwd}}/workspace',
+  })]
+  let seq = 0
+  let time = 1784974100000
+  const at = (event: Record<string, unknown>): void => {
+    lines.push(JSON.stringify({ ...event, seq: seq++, time: time++ }))
+  }
+  for (let turn = 1; turn <= turns; turn++) {
+    at({ type: 'turn/start', data: { turn } })
+    at({
+      type: 'user/message',
+      data: { content: [{ type: 'text', text: `m${turn}` }], source: { kind: 'user' } },
+      surfaceOp: 'append',
+    })
+    at({ type: 'step/start', data: { turn, step: 1 } })
+    at({
+      type: 'assistant/message',
+      data: {
+        turn,
+        step: 1,
+        message: {
+          id: `00000000-0000-4000-8000-${String(turn).padStart(12, '0')}`,
+          role: 'assistant',
+          content: [{ type: 'text', text: `r${turn}` }],
+          source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' },
+        },
+      },
+      sourceEventSeqs: [],
+      surfaceOp: 'append',
+    })
+    at({ type: 'step/end', data: { turn, step: 1 } })
+    at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
+  }
+  return `${lines.join('\n')}\n`
+}
+
+describe('web e2e: whole-session stats survive history paging', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    if (MODE === 'record') throw new Error('stats-paged-history is a keyless assembled snapshot')
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, buildSeed(TURNS), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('renders full-session counts on the partial tail page and keeps them across load-older', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged'))
+    const groupRow = page.locator('[role="treeitem"]').first()
+    await groupRow.waitFor({ timeout: 15_000 })
+    await groupRow.click()
+    const sessionRow = page.locator('[role="treeitem"]').nth(1)
+    await sessionRow.waitFor({ timeout: 10_000 })
+    await sessionRow.click()
+    // Settled barrier: the newest recorded reply renders from the tail page.
+    await expect.poll(() => page.getByText(`r${TURNS}`, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // The tail page is partial (56 messages > one 50-message page): the first
+    // turns are NOT loaded, yet the strip already reports the whole log —
+    // the sessionStats projection, not the window fold.
+    expect(await page.getByText('m1', { exact: true }).count()).toBe(0)
+    await expect.poll(() => page.getByText(FULL_COUNTS, { exact: false }).count(), { timeout: 10_000 }).toBe(1)
+    const strip = page.getByText(FULL_COUNTS, { exact: false }).locator('..')
+    const stripBeforePaging = await strip.textContent()
+
+    // 加载更早: prepending the older page must not move ANY strip figure —
+    // counts, wall times, or token groups.
+    await page.getByRole('button', { name: 'Load earlier' }).click()
+    await expect.poll(() => page.getByText('m1', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    expect(await strip.textContent()).toBe(stripBeforePaging)
+    // With the whole log loaded, the window mounts one turn-tail footer per
+    // settled turn — the loaded-window probe the scroll/perf lanes count now
+    // that the strip is whole-log-scoped.
+    expect(await page.locator('[data-chat-flow-key^="9:turn-tail"]').count()).toBe(TURNS)
+  }, 60_000)
+
+  it('matches the paged-stats aria golden', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-paged-aria'))
+    const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+  })
+
+  it('issued zero model calls and stayed clean', async () => {
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -48,6 +48,7 @@
     "tests/replay-round-trip.e2e.ts",
     "tests/hmr-live.e2e.ts",
     "tests/seeded-history.e2e.ts",
+    "tests/stats-paged-history.e2e.ts",
     "tests/sidebar-scrollbar.e2e.ts",
     "tests/conversation-column-overflow.e2e.ts",
     "tests/code-mode-round.e2e.ts",

+ 1 - 1
docs/AGENTS.md

@@ -20,7 +20,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there.
 |---|---|---|
 | Root `AGENTS.md` | Standing orders: rules an agent needs in context in every session, one to three lines each, linking its home | Stories, worked examples, situational procedures, anything restated from a linked home |
 | Subtree `AGENTS.md` (`packages/`, `examples/`, `docs/`, `.agents/notes/`) | Orders specific to that subtree | Repo-wide rules the root file already carries |
-| [architecture.md](architecture.md) | System map: services, loop, capability seams, extension points — read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations |
+| [architecture.md](architecture.md) | Ordered map: composition, core packages, loop, seams, extension points; read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations |
 | [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) |
 | [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority |
 | [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — |

+ 2 - 2
docs/agent-lifecycle.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/agent-lifecycle.md
-agent-lifecycle.md: 99b7823a3824b695d227abb85c3c773749154376
-agent-lifecycle.zh.md: a54547f486c447fe83fbe6017d016102d66b8612
+agent-lifecycle.md: 84e235563d05d8105c55f9dc40ac0d791bd05dce
+agent-lifecycle.zh.md: df6fe19385f16477d60da260d72477a19526e686

+ 4 - 4
docs/agent-lifecycle.md

@@ -3,7 +3,7 @@
 
 # Agent Turn And Step Lifecycle
 
-This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.
+This sequence is the visual companion to [architecture.md](architecture.md#turn-flow). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.
 
 ```mermaid
 sequenceDiagram
@@ -21,15 +21,15 @@ sequenceDiagram
   Agent-->>SDK: <code>agent/inbox/inserted</code> { message }
   Agent->>Driver: queued work wakes driver
   Driver-->>SDK: <code>agent/status</code> running
+  Driver->>Session: <code>turn/start</code>
   Note over Agent,Driver: claim pending next-step input plus one queued prompt
   Driver-->>SDK: <code>agent/inbox/spliced</code> pure deletion
   Driver-->>SDK: <code>agent/inbox/claimed</code> { message, turn } per message
   Driver->>Hooks: <code>agent/pre-step</code> waterfall
   Hooks-->>Driver: authoritative reject or enter(messages)
   alt proposed step rejected or pre-step failed
-    Driver-->>Driver: claimed batch stays removed, no turn opens
+    Driver-->>Driver: claimed batch stays removed, the open turn spends no step
   else enter proposed step
-  Driver->>Session: <code>turn/start</code>
   Driver->>Session: <code>step/start</code>
   Driver->>Session: <code>user/message</code> per entered message
   Driver->>Prompt: <code>system-prompt/assemble</code> waterfall
@@ -66,8 +66,8 @@ sequenceDiagram
     Hooks-->>Driver: authoritative reject or enter(messages)
   end
   end
-  Driver->>Session: <code>turn/end</code>
   end
+  Driver->>Session: <code>turn/end</code>
   Driver-->>SDK: <code>agent/status</code> idle
 ```
 

+ 4 - 4
docs/agent-lifecycle.zh.md

@@ -5,7 +5,7 @@
 
 [English](agent-lifecycle.md) | 中文
 
-此时序图是 [architecture.md](architecture.md#default-loop-lifecycle) 的配套图示。持久的回放事实保存在 `session/event` 中,实时控制与状态则保存在 `agent/*` 中。
+此时序图是 [architecture.md](architecture.md#turn-flow) 的配套图示。持久的回放事实保存在 `session/event` 中,实时控制与状态则保存在 `agent/*` 中。
 
 ```mermaid
 sequenceDiagram
@@ -23,15 +23,15 @@ sequenceDiagram
   Agent-->>SDK: <code>agent/inbox/inserted</code> { message }
   Agent->>Driver: queued work wakes driver
   Driver-->>SDK: <code>agent/status</code> running
+  Driver->>Session: <code>turn/start</code>
   Note over Agent,Driver: claim pending next-step input plus one queued prompt
   Driver-->>SDK: <code>agent/inbox/spliced</code> pure deletion
   Driver-->>SDK: <code>agent/inbox/claimed</code> { message, turn } per message
   Driver->>Hooks: <code>agent/pre-step</code> waterfall
   Hooks-->>Driver: authoritative reject or enter(messages)
   alt proposed step rejected or pre-step failed
-    Driver-->>Driver: claimed batch stays removed, no turn opens
+    Driver-->>Driver: claimed batch stays removed, the open turn spends no step
   else enter proposed step
-  Driver->>Session: <code>turn/start</code>
   Driver->>Session: <code>step/start</code>
   Driver->>Session: <code>user/message</code> per entered message
   Driver->>Prompt: <code>system-prompt/assemble</code> waterfall
@@ -68,8 +68,8 @@ sequenceDiagram
     Hooks-->>Driver: authoritative reject or enter(messages)
   end
   end
-  Driver->>Session: <code>turn/end</code>
   end
+  Driver->>Session: <code>turn/end</code>
   Driver-->>SDK: <code>agent/status</code> idle
 ```
 

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: f5ebff879929079870c7936b424f03a02089c9d5
-architecture.zh.md: 00c0f51f6fb0df06f115b7ea31b9a98c17ddb1cd
+architecture.md: bd14a0645c13f9831f57e1966b53b82bd691dbb7
+architecture.zh.md: fd449382d7989c6874e8e8cc04096976fe920418

+ 79 - 150
docs/architecture.md

@@ -2,199 +2,128 @@
 
 English | [中文](architecture.zh.md)
 
-**DeepSeek Harness SDK** uses Cordis: **everything is a plugin**, including the loop.
+Read this before changing anything under `packages/`. It assumes you know Cordis; if you do not, start with the [primer](cordis-primer.md) or the [tutorial](cordis-tutorial/index.md).
 
-## Overview
+We recommend using an agent to explore the codebase and understand its architecture.
 
-Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, typed events, and disposable registrations. `packages/core/` groups the default flow; capabilities remain plugins.
+## Cordis
 
-### Default Services
+[Cordis](cordis-primer.md) is the framework under dsh: plugins contribute services, typed events, and reversible effects to a shared context. Every part of the product is a plugin, including the model adapter, the tool registry, the session log, and the agent loop itself, so every part is replaceable from configuration.
 
-| ctx key | Package | Role |
-|---|---|---|
-| — | [`dsh-scope`](../packages/core/scope/README.md) | scoped-context registrations and shared layer storage (library) |
-| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions |
-| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and variables |
-| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
-| `ctx.agents` | `dsh-agent` | live agents, delegated creation, `agent/*` events, process-local initiator scope |
-| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | Settings-backed model selection shared by Agent entry points |
-| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver |
-
-### Capability Services
+There is no privileged core to patch: you extend dsh by mounting a plugin beside the others, and registrations are effects that unwind when their plugin unloads.
 
-| ctx key | Package family | Role |
-|---|---|---|
-| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry, streaming model calls |
-| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request and surface pressure |
-| `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution |
-| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | executable lookup, managed trees, terminals |
-| `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions |
-| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy |
-| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home |
-| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution |
-| `ctx.fs` | [`fs/`](../packages/fs/README.md) | execution-world paths, bounded IO, and policy events |
-| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry |
-| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure |
-| `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries |
-| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning |
-| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers |
-| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state |
-| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls |
-| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
-| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
-| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract |
-| `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage |
-| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
-| `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider |
-| `ctx.settings` | [`settings/`](../packages/settings/README.md) | per-plugin user-settings namespaces layered over composition entries |
-| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration |
-| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) |
-| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas |
-| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | dispatches TypeRT Remote unary calls through the [API Gateway](api-gateway.md) |
-| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks |
-
-## Event
-
-Events are the service extension API ([subsystems](subsystems/core.md), [producer/consumer map](event-producer-consumer.md)).
-
-### Event Domains
-
-- **Session events** are durable log facts emitted through `session/event`.
-- **Agent events** carry live `Agent` for inbox, step, status, request, validation, and continuation.
-- **Capability events** attach policy and adapters without a loop import.
-
-### Interception Semantics
-
-Waterfalls are around-middleware: listeners delegate with `next()`; returning without it short-circuits or takes over ([semantics](cordis-primer.md#cordis-waterfall-semantics)).
-
-## Default Loop Lifecycle
-
-A **session** is append-only. A **turn** claims one queued follow-up, waits for its predecessor's checkpoint, and may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)); injection claims none. A **step** is one model request plus tools. Fresh creation and persisted resume first acquire an exact unpublished `SessionPreparation`; Agent and session publication happen only after private setup against that Session is ready ([decision](../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md)). Quotes in the [sequence](agent-lifecycle.md) mark durable events.
-
-Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication; setup failure emits `agent-loop/config-start-failed`.
-
-### Turn Flow
+## Profiles and bundles
 
-```text
-choose declarative identity and acquire fresh/restored SessionPreparation
-  -> prepare private agent.ctx around exact Session -> await unpublished setup -> invoke optional synchronous setup commit
-  -> enter session + agent -> session/created -> agent/created
-  -> enable driving -> agent/session-start(source) -> start driver
-forever:
-  waking inbox insertion starts the driver before send returns
-  -> emit agent/status(running) if starting an interval
-  -> 'turn/start'
-  claim next-step input plus one next-turn message
-  -> emit agent/inbox/claimed({ message, turn }) for each claimed message
-  -> assemble system prompt
-  -> agent/pre-step({ agent, messages, turn, step, signal })
-    reject, empty input, cancellation, or listener failure
-      -> the claimed batch stays removed; close the no-step turn; stop the driver
-    enter -> step loop:
-      'step/start'
-      append the returned batch as separate 'user/message' events
-      render the assembled prompt and tool schemas -> snapshot derived messages
-      agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound)
-      'assistant/chunk'
-      'assistant/message'
-      schedule tool calls by ctx.tools.executionMode:
-        exclusive -> barrier
-        parallel -> rolling pool, <= maxParallelToolCalls; reclassify at start
-        start -> 'tool/call' -> tools/pre-execute -> concurrent tools/execute
-        model-order result -> ordered tools/post-execute -> 'tool/result'
-      'step/end'
-      tools owe another request or next-step inbox is nonempty
-        -> claim -> agent/pre-step -> append entered batch -> continue
-      otherwise agent/turn-stopping -> re-check the next-step inbox
-    'turn/end'
-  start the next waking queued message, or emit agent/status(idle)
-
-idle inject:
-  queue non-waking next-step context
-  leave it pending until followup or steer wakes the driver
-```
+A running `dsh` is a plugin tree composed at boot from ordered layers.
 
-Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona; the loop supplies `provider`, `model`, and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
+A **profile** is a named composition stored in the Harness home. It lists the bundles it stacks, holds any out-of-tree plugins it installs, and keeps the user's own `cordis.patch.yml`. `web` and `headless` ship as templates.
 
-`inject()` queues non-waking `next-step` context; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. Post-tool `additionalContexts` use the same inbox. `agent/pre-step` receives the exclusive claimed batch and upcoming turn, step, and signal. Reject opens no step; enter supplies the complete batch appended after `step/start`. Empty tool continuations still traverse the waterfall, whose final value settles all rewrites.
+A **bundle** is a distribution format for Cordis config rows and the code they mount, so whatever it inserts stays patchable by the layers above it.
 
-Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize a same-step retry of the frozen prompt; cancellation wins. Adapter `retryPolicy` bounds normal mode, while always mode retries after specialized recovery ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). The generated [agent lifecycle](agent-lifecycle.md) owns exact event order, and the [agent-loop README](../packages/core/agent-loop/README.md) owns queue, steering, retry, and cancellation mechanics.
+Each declares itself in its own `package.json` under a `dsh` field: `dsh.profile` lists a profile's bundles, and `dsh.bundle` points at a bundle's patch file.
 
-### Failure Boundaries
+[`dsh-base`](../packages/bundle/base/README.md) is the first layer of every profile: model adapters, tools, persistence, sandbox and approval policy, settings, credentials, telemetry. [`dsh-web-app`](../packages/bundle/web-app/README.md) adds the browser application; [`dsh-headless`](../packages/bundle/headless/README.md) adds a one-shot runner with no server at all.
 
-Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls.
+Layers apply to an empty entry list in this order: each bundle in the profile's listed order, then the profile's `cordis.patch.yml`, then the home-level one, then any `--patch` overlay. A patch targets a row by id and replaces its whole config, or inserts new rows.
 
-Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. The driver processes waking input received after abort starts but before convergence; a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)).
+To see the tree your machine actually boots:
 
-Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](subsystems/session.md#why-a-turn-ended-turnendreasonmap).
+```sh
+dsh --profile web --dump-config
+```
 
-### Agent Handles
+Any row it prints can be replaced by a patch of your own.
 
-`ctx.agents` owns agents and returns `AgentHandle { agent, dispose() }`. Plugins use `send()` or its `followup()`, `steer()`, and `inject()` presets. `cancel()` and `whenIdle()` control lifecycle, while awaited disposal owns teardown. A follow-up `MessageId` follows durable inbox insertion, claiming, and discard notifications, not prompt output or turn ending; only an owner of a whole activity interval may summarize it as a run result ([decision](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md)).
+Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#profiles); config fields are in the generated [config catalog](config-catalog.md).
 
-### Agent Scope
+## Core packages
 
-Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
+Here are some core packages that contribute to the Cordis tree.
 
-## State
+| Package | Owns | `ctx` key |
+|---|---|---|
+| [`core/session`](subsystems/session.md) | The append-only `SessionEvent` log and in-memory store | `ctx.sessions` |
+| [`core/system-prompt`](subsystems/system-prompt.md) | Prompt-section and tool-schema assembly | `ctx.systemPrompt` |
+| [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` |
+| [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` |
+| [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` |
+| [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key |
+| [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` |
 
-### Session Log
+## Events
 
-The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream.
+Events are the extension points, and picking the right domain is the first decision in most changes.
 
-**Model-visible ⟺ logged**: messages entering at `step/start` plus the folded `request/header` reconstruct every request. The header marks adapter defaults so later proposals discard them and re-resolve the route without losing explicit settings. `request/context` separately records registration-bound provider, model, and capacity metadata when the route changes; it does not participate in request reconstruction or header equality. `dsh-agent-loop/invariant` asserts reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
+- **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload.
+- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
+- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
 
-Durability is a plugin concern. Backends copy synchronous `session/event` notifications into fixed-window durable batches; `session/flush` bypasses the wait before requests and top-level tool dispatch, and after `turn/end` before another turn or idle. `SessionPersistence` stores events and header metadata; JSONL defaults to checksummed Zstandard, and SQLite uses the same checkpoint and batching rules ([checkpoint decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md), [batching decision](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md)).
+The [event map](event-producer-consumer.md) lists every event's producers and consumers.
 
-Between turns, owners append log-only events through `Session`, flushing only for durability. `session/title` relies on bounded background persistence and lifecycle drains; manual compaction flushes its bracket before the operation completes. Title work never delays responses; the latest title event wins, and it records the source message seqs and whether the user, fallback, or provider supplied it. Title records are inherited fork boundaries ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)).
+## Turn flow
 
-### Model Content
+A **step** is one model request plus the tools it calls. A **turn** is zero or more steps: it opens before its first input is claimed and closes once nothing is owed.
 
-Messages use typed blocks from merge-extensible `ContentBlockMap`; the pattern also types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](subsystems/token-meter.md).
+```text
+turn/start
+  claim next-step input plus one queued message
+  assemble prompt sections + tool schemas
+  -> agent/pre-step                   reject | enter(messages)
+     reject, or a first enter rewritten empty -> close the turn with no step
+     step/start
+     append entered messages as user/message
+     derive model history from the log
+     agent/request -> llm/stream -> assistant/chunk* -> assistant/message
+     tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
+     step/end
+     tools owe another request, or next-step input arrived -> claim -> next step
+  -> agent/turn-stopping
+turn/end
+```
 
-Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report normalized failure facts, and a handling `agent/request-error` plugin returns a retry action. The loop logs chunks, the successful provider/model route, and replay state. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter instance ([contract](subsystems/llm-streaming.md)).
+`turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`.
 
-## Extension And Composition
+Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does.
 
-### Capability Pattern
+`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Each step reads the prompt sections and tool schemas that plugins registered.
 
-A **seam** is a swappable capability with **Service Definition**, **Service provider**, and **Consumer** roles. Packages may combine roles; individual roles are not seams. Filesystem and subprocess providers share one execution world; Bash, PTY, and LSP need no provider forks ([capability graph](capability-seams.md)).
+Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
 
-Exceptions combine LLM Service Definition/Consumer roles, filesystem policy, web registries, and skill/subagent providers. Subagents spawn fresh, fork a completed-turn prefix, use ACP children, or delegate a self-contained turn to Codex or another product provider ([subagent.md](subsystems/subagent.md)).
+## Session log
 
-`dsh-workspace-context` composes its baseline on the first `agent/pre-step` and folds it into the final entering batch right after the claimed prompt, so it reaches the first request with the direct prompt; rejection keeps it in the next-step inbox. When compaction removes that baseline from the visible surface, the next entering pre-step composes the current baseline and carries it in the same request. Filesystem changes projected after tools are likewise folded into the next entering pre-step instead of creating a later context-only step ([decision](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)). `dsh-paths` owns shared paths.
+The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream.
 
-### Bundles And Apps
+**Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log.
 
-`dsh-agent-spine-demo` bundles a spine and optional goals. App packages own CLI, ACP automation, and JSON-RPC entry points ([README](../packages/examples/agent-spine-demo/README.md), [acp/](../packages/acp/README.md), [interaction/](../packages/interaction/README.md)). `dsh-jsonrpc-agent` boots external `cordis.yml`; the Python SDK defaults when config is absent ([Python SDK](../python/README.md)). Thin deployments use swappable backends and optional tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)).
+## Capability seams
 
-### Agent Presets
+A **seam** is a swappable capability with three roles: a **Service Definition** declaring the interface, a **Service provider** implementing it, and a **Consumer** using it, commonly a model-facing tool. A package may combine roles, but one role alone is not a seam; adding a capability means designing all three ([capability graph](capability-seams.md)).
 
-A deployment may compose each session's model-facing plugin set separately. An **agent preset** is a directory holding one `agent.cordis.yml`, mounted as an `include` subtree under that agent's scope during `setup(agentCtx)`, so its tool and prompt registrations file into that agent's layer and unwind with it — no new tier in the registries. The host composition keeps what must be shared: the registries themselves, cross-session facilities, the sandbox and approval stack, the model route. `ctx.agentPresets` owns discovery and the guarded mount, rejecting a row that never activates or that publishes into the root service realm. Details: [per-session agent presets](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md), [preset/](../packages/preset/README.md).
+Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product.
 
-### Where New Behavior Goes
+## Where new behavior goes
 
-New behavior attaches to a documented extension point; a loop change updates this map.
+New behavior attaches to a documented extension point. Changing the loop itself updates this map.
 
 | Goal | Mechanism |
 |---|---|
 | Add a model provider | register its adapter on `ctx.llm` |
-| Add a model-facing capability | register on `ctx.tools`; schemas join prompt assembly |
-| Give one session a different capability set | compose it in an agent preset; a service row there needs an `isolate` realm |
-| Add shell execution | implement and register a `ctx.bash` backend; the local backend spawns through `ctx.subprocess` |
+| Add a model-facing capability | register on `ctx.tools`; its schema joins prompt assembly |
+| Give one session a different capability set | compose an agent preset; a service row there needs an `isolate` realm |
+| Add shell execution | register a `ctx.bash` backend; the local one spawns through `ctx.subprocess` |
 | Add persistent terminal execution | register a `ctx.pty` backend plus `dsh-tool-pty` |
-| Add a human command | register on `ctx.commands`; adapters discover and dispatch without a model turn |
-| Add background work | register on `ctx.tasks`; generic `task_*` tools collect or stop it |
-| Add filesystem access or policy | implement a `ctx.fs` provider or listen to `fs/*` policy events |
+| Add a human command | register on `ctx.commands`; it dispatches without a model turn |
+| Add background work | register on `ctx.tasks`; `task_*` tools collect or stop it |
+| Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events |
 | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning |
-| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` is the event that stops a turn |
-| Add model-facing context | call `agent.inject()` to queue sourced context for the next admitted request |
+| Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` stops a turn |
+| Add model-facing context | call `agent.inject()`; it lands in the next admitted request |
 | Add UI or editor integration | drive `ctx.agents` and render from `session/event` |
-| Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer |
+| Add a Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer |
 | Add durable session state | extend `SessionEventMap`; render and replay from the log |
-| Add asynchronous session-title generation | register the sole `ctx.sessionTitle` provider |
-| Manage a same-session objective | use `ctx.goals`; continue through `Agent` and `agent/*` |
-| Fork a live session | call `ctx.sessions.fork(source, boundary?, childSessionId?)` |
-| Scope a registration to one agent | use its `agent.ctx` (see Agent Scope) |
+| Generate session titles | register the sole `ctx.sessionTitle` provider |
+| Manage a same-session objective | use `ctx.goals`; continue through `agent/*` |
+| Fork a live session | `ctx.sessions.fork(source, boundary?, childSessionId?)` |
+| Scope a registration to one agent | use that agent's `agent.ctx` |
 
-[Extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities; guides cover [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), [Chat nodes](cookbook/adding-a-conversation-node.md), and [vendored packages](cookbook/adding-a-vendored-package.md).
+The [extension cookbook](cookbook/extension-cookbook.md) maps features to capabilities and indexes the step-by-step guides for [packages](cookbook/adding-a-package.md), [tools](cookbook/adding-a-tool.md), [LLM adapters](cookbook/adding-an-llm-adapter.md), and [Chat nodes](cookbook/adding-a-conversation-node.md).

+ 78 - 149
docs/architecture.zh.md

@@ -2,199 +2,128 @@
 
 [English](architecture.md) | 中文
 
-**DeepSeek Harness SDK** 使用 Cordis:**一切皆插件**,循环也不例外
+改动 `packages/` 下的任何内容之前,请先阅读本文。本文假定你已了解 Cordis;如果尚未了解,请先阅读[入门](cordis-primer.md)或[教程](cordis-tutorial/index.md)
 
-## 概览
+建议使用 agent(智能体)探索代码库并理解其架构。
 
-每个 harness 都是 [Cordis](cordis-primer.md) 上下文;各包贡献服务、类型化事件和可释放的注册项。`packages/core/` 汇集默认流程;各项能力仍以插件形式存在。
+## Cordis
 
-### 默认服务
+[Cordis](cordis-primer.md) 是 dsh 底层的框架:插件向共享上下文贡献服务、类型化事件和可逆的副作用。产品的每一部分都是插件,包括模型适配器、工具注册表、会话日志,以及 agent loop(智能体循环)本身,因此每一部分都可以从配置替换。
 
-| ctx 键 | 包 | 职责 |
-|---|---|---|
-| — | [`dsh-scope`](../packages/core/scope/README.md) | 作用域上下文注册项与共享层存储(库) |
-| `ctx.sessions` | `dsh-session` | 内存中的事件溯源会话 |
-| `ctx.systemPrompt` | `dsh-system-prompt` | 有序的提示词片段、工具 schema 和变量 |
-| `ctx.tools` | `dsh-tools` | 工具注册表和[执行流水线](tool-execution-pipeline.md) |
-| `ctx.agents` | `dsh-agent` | 活跃 agent(智能体)、委托创建、`agent/*` 事件、进程内发起方作用域 |
-| `ctx.agentDefaultModel` | [`dsh-agent-default-model`](../packages/core/agent-default-model/README.md) | 由 Settings 支撑、供 Agent 入口共享的模型选择 |
-| `ctx.agentLoop` | `dsh-agent-loop` | 具体 `Agent` 驱动器 |
-
-### 能力服务
-
-| ctx 键 | 包族 | 职责 |
-|---|---|---|
-| `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 |
-| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的请求压力与表面压力 |
-| `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 |
-| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 可执行文件查找、受管进程树、终端 |
-| `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 |
-| `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制与宿主共享文件系统和内核的进程 |
-| `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 |
-| `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | 执行模型编写的程序 |
-| `ctx.fs` | [`fs/`](../packages/fs/README.md) | 执行世界路径、有界 I/O 和策略事件 |
-| `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | 语义导航注册表 |
-| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill(技能)提供方注册表和渐进式披露 |
-| `ctx.web` | [`web/`](../packages/web/README.md) | 搜索与抓取提供方注册表 |
-| `ctx.compact`,`ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | 摘要压缩(compaction)和可选的无模型结果裁剪 |
-| `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | 具名委托提供方 |
-| `ctx.planMode` | [`plan/`](../packages/plan/README.md) | 落日志的 plan 协作状态 |
-| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 |
-| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
-| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
-| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 |
-| `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 |
-| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
-| `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 |
-| `ctx.settings` | [`settings/`](../packages/settings/README.md) | 按插件划分的用户设置命名空间,分层叠加在装配条目之上 |
-| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 |
-| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) |
-| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 |
-| `ctx.typertGateway` | [`api/gateway`](../packages/api/gateway/README.md) | 通过 [API Gateway](api-gateway.md) 分发 TypeRT Remote 一元调用 |
-| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
-
-## 事件
-
-事件就是服务的扩展 API([子系统](subsystems/core.md)、[生产方与消费方映射](event-producer-consumer.md))。
+不存在需要打补丁的特权内核:扩展 dsh 的方式是把插件挂载到其他插件旁边,而各项注册都是副作用,会在其插件卸载时撤销。
 
-### 事件域
+## Profile 与组合包
 
-- **会话事件**是通过 `session/event` 发出的持久日志事实。
-- **Agent 事件**携带活跃 `Agent`,用于 inbox、步骤、状态、请求、验证和续跑。
-- **能力事件**无需导入循环即可附加策略和适配器。
+运行中的 `dsh` 是一棵插件树,由启动时按序叠加的各层组合而成。
 
-### 拦截语义
+**profile** 是存放在 Harness home 中的具名组装。它列出自己叠放的组合包,存放自己安装的树外插件,并保存用户自己的 `cordis.patch.yml`。`web` 和 `headless` 作为模板随发行版交付。
 
-waterfall(瀑布式事件)是环绕中间件:监听器通过 `next()` 委托;不调用它而直接返回会短路或接管([语义](cordis-primer.md#cordis-waterfall-semantics))
+**组合包**是 Cordis 配置项及其挂载代码的分发格式,因此它插入的内容始终可被其上各层 patch。
 
-## 默认循环生命周期
+两者都在各自的 `package.json` 中通过 `dsh` 字段声明自己:`dsh.profile` 列出一个 profile 的组合包,`dsh.bundle` 指向一个组合包的 patch 文件。
 
-**会话**采用仅追加方式。一个**轮次**领取一条已排队的后续消息,等待前一轮次的检查点,并可与其共用 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md));注入不领取输入。一个**步骤**包含一次模型请求及其工具。新建与持久化恢复会先取得精确的未发布 `SessionPreparation`;只有基于该 Session 的私有设置准备完毕后,系统才会发布 agent 与会话([决策](../.agents/notes/implemented/architecture/2026-08-05-session-preparation.md))。[时序](agent-lifecycle.md)中的引号标记持久事件
+[`dsh-base`](../packages/bundle/base/README.md) 是每个 profile 的第一层:模型适配器、工具、持久化、沙箱与审批策略、设置、凭据、遥测。[`dsh-web-app`](../packages/bundle/web-app/README.md) 增加浏览器应用;[`dsh-headless`](../packages/bundle/headless/README.md) 增加一次性运行器,且完全不带服务器。
 
-创建时若未提供 id,流程会生成 `<config-id>-session-<uuid>`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度;初始化失败会发出 `agent-loop/config-start-failed`
+各层按此顺序应用在空条目列表之上:先按 profile 列出的顺序应用每个组合包,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的那份,最后是任意 `--patch` overlay。一条 patch 按 id 定位某个条目并替换其整个 config,或插入新条目。
 
-### 轮次流程
+要查看你的机器实际启动的配置树:
 
-```text
-choose declarative identity and acquire fresh/restored SessionPreparation
-  -> prepare private agent.ctx around exact Session -> await unpublished setup -> invoke optional synchronous setup commit
-  -> enter session + agent -> session/created -> agent/created
-  -> enable driving -> agent/session-start(source) -> start driver
-forever:
-  waking inbox insertion starts the driver before send returns
-  -> emit agent/status(running) if starting an interval
-  -> 'turn/start'
-  claim next-step input plus one next-turn message
-  -> emit agent/inbox/claimed({ message, turn }) for each claimed message
-  -> assemble system prompt
-  -> agent/pre-step({ agent, messages, turn, step, signal })
-    reject, empty input, cancellation, or listener failure
-      -> the claimed batch stays removed; close the no-step turn; stop the driver
-    enter -> step loop:
-      'step/start'
-      append the returned batch as separate 'user/message' events
-      render the assembled prompt and tool schemas -> snapshot derived messages
-      agent/request (config only) -> prepare adapter defaults/provenance + context capacity under turn signal -> log request/header (+ request/context on route change) -> llm/stream (frozen, registration-bound)
-      'assistant/chunk'
-      'assistant/message'
-      schedule tool calls by ctx.tools.executionMode:
-        exclusive -> barrier
-        parallel -> rolling pool, <= maxParallelToolCalls; reclassify at start
-        start -> 'tool/call' -> tools/pre-execute -> concurrent tools/execute
-        model-order result -> ordered tools/post-execute -> 'tool/result'
-      'step/end'
-      tools owe another request or next-step inbox is nonempty
-        -> claim -> agent/pre-step -> append entered batch -> continue
-      otherwise agent/turn-stopping -> re-check the next-step inbox
-    'turn/end'
-  start the next waking queued message, or emit agent/status(idle)
-
-idle inject:
-  queue non-waking next-step context
-  leave it pending until followup or steer wakes the driver
+```sh
+dsh --profile web --dump-config
 ```
 
-每个步骤都会组装有序的提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定;循环提供 `provider`、`model` 和 `cwd`([提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
-
-`inject()` 将不会唤醒驱动器的上下文排入 `next-step`;空闲驱动器会让它保持待处理,直至 `followup()` 或 `steer()` 唤醒。工具执行后的 `additionalContexts` 使用同一个 inbox。`agent/pre-step` 接收独占的已领取批次,以及即将使用的轮次、步骤和信号。拒绝则不进入步骤;进入则提供在 `step/start` 后追加的完整批次。空的工具续跑仍会经过 waterfall,其最终值一次性结算所有改写。
-
-裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以授权使用冻结提示词进行同步骤重试;取消优先。适配器的 `retryPolicy` 使 normal mode 保持有界,always mode 则在专门恢复后重试([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。精确事件顺序由生成的 [agent 生命周期](agent-lifecycle.md)定义;队列、steering(中途引导)、重试与取消机制由 [agent-loop README](../packages/core/agent-loop/README.md)定义。
+它打印出的任何条目,都可以由你自己的 patch 替换。
 
-### 失败边界
+组装机制见 [app-boot](../packages/boot/app-boot/README.md#profiles);配置字段见生成的[配置目录](config-catalog.md)。
 
-适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。
+## 核心包
 
-其他故障使用 `agent/error`;取消和 dispose(资源释放)优先于恢复。在提交请求头之前,轮次信号会取消能力准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会先报告原因,再执行清理和中止;空闲调用不发事件。driver 会处理在 abort 开始后、收敛前收到的唤醒输入;`disposed` 取消会让该输入保持待处理状态([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久性以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))
+以下是向 Cordis 树贡献内容的部分核心包。
 
-轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](subsystems/session.md#why-a-turn-ended-turnendreasonmap)。
-
-### Agent 句柄
-
-`ctx.agents` 拥有 agent 并返回 `AgentHandle { agent, dispose() }`。插件使用 `send()`,或使用其 `followup()`、`steer()` 和 `inject()` 预设。`cancel()` 与 `whenIdle()` 控制生命周期,拆卸由需等待完成的 dispose 负责。后续消息的 `MessageId` 跟踪持久 inbox 的插入、领取与丢弃通知,而不标识提示词输出或轮次结束;只有完整活动区间的所有者才能将其概括为一次运行结果([决策](../.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md))。
-
-### Agent 作用域
-
-每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events` 和 `scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop` 在 `ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`,但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
-
-## 状态
+| 包 | 职责 | `ctx` 键 |
+|---|---|---|
+| [`core/session`](subsystems/session.md) | 仅追加的 `SessionEvent` 日志和内存存储 | `ctx.sessions` |
+| [`core/system-prompt`](subsystems/system-prompt.md) | 提示词片段与工具 schema 的组装 | `ctx.systemPrompt` |
+| [`core/tools`](subsystems/tools.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` |
+| [`core/agent`](subsystems/core.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` |
+| [`core/agent-loop`](subsystems/core.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` |
+| [`core/scope`](subsystems/scope.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 |
+| [`llm/llm`](subsystems/llm-streaming.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` |
 
-### 会话日志
+## 事件
 
-会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流
+事件就是扩展点,而选对事件域是大多数改动的第一个决定。
 
-**模型可见 ⟺ 已记录**:在 `step/start` 进入的消息加上折叠后的 `request/header` 可以重建每个请求。该 header 会标记适配器默认值,使后续提议丢弃这些值并重新解析路由,同时不丢失显式设置。`request/context` 会在路由变化时另行记录与注册项绑定的提供方、模型及容量元数据;它不参与请求重建或 header 相等性判断。`dsh-agent-loop/invariant` 通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
+- **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
+- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
+- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
 
-持久性由插件负责。后端会将同步的 `session/event` 通知复制到固定窗口的持久化批次中;`session/flush` 会绕过等待,在请求与顶层工具分发之前执行,并在 `turn/end` 之后、另一个轮次或空闲状态之前执行。`SessionPersistence` 存储事件和 header 元数据;JSONL 默认采用带校验和的 Zstandard,SQLite 使用同样的检查点与批处理规则([检查点决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)、[批处理决策](../.agents/notes/implemented/architecture/2026-08-08-bounded-session-persistence-write-batching.md))。
+[事件映射](event-producer-consumer.md)列出每个事件的生产方与消费方。
 
-在轮次之间,事件所有方通过 `Session` 追加纯日志事件,仅为持久性而刷写。`session/title` 依赖有界后台持久化与生命周期排空;手动压缩会在操作完成前 flush 其标记对。标题工作绝不延迟响应;最新的标题事件生效,并记录来源消息 seq,以及标题由用户、后备逻辑还是提供方提供。标题记录是可继承的 fork 边界([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。
+## 轮次流程
 
-### 模型内容
+一个**步骤**是一次模型请求加上它调用的工具。一个**轮次**包含零个或多个步骤:它在领取首条输入之前打开,并在不再欠下任何工作时关闭。
 
-消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;同一模式也为 `MessageSource`、`FinishReason`、`TurnTrigger` 和 `TurnEndReason` 定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](subsystems/token-meter.md)。
+```text
+turn/start
+  claim next-step input plus one queued message
+  assemble prompt sections + tool schemas
+  -> agent/pre-step                   reject | enter(messages)
+     reject, or a first enter rewritten empty -> close the turn with no step
+     step/start
+     append entered messages as user/message
+     derive model history from the log
+     agent/request -> llm/stream -> assistant/chunk* -> assistant/message
+     tool/call* -> tools/pre-execute -> tools/execute -> tools/post-execute -> tool/result*
+     step/end
+     tools owe another request, or next-step input arrived -> claim -> next step
+  -> agent/turn-stopping
+turn/end
+```
 
-流式输出使用原始分片和 `BlockAssembler`。每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告标准化的故障事实,负责处理的 `agent/request-error` 插件会返回重试动作。循环会记录分片、成功使用的提供方/模型路由和回放状态。远程适配器使用逐次读取空闲看门狗。回放仅通过共用的适配器实例跨路由传递([约定](subsystems/llm-streaming.md))。
+`turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`
 
-## 扩展与组合
+输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。
 
-### 能力模式
+`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。每个步骤读取插件注册的提示词片段和工具 schema。
 
-一个 **seam** 是一项包含 **Service Definition**、**Service provider** 和 **Consumer** 三种角色的可替换能力。包可以合并承担多个角色;任何单一角色都不是 seam。文件系统与进程管理提供方共享一个执行世界,Bash、PTY 和 LSP 都在其中运行,无需提供方专用 fork([能力图](capability-seams.md))。
+详情见[时序图](agent-lifecycle.md)、[工具流水线](tool-execution-pipeline.md)和[取消与错误恢复](subsystems/core.md#the-agent-handle)
 
-例外情况包括 LLM(大语言模型)Service Definition/消费方角色合并、文件系统策略、web 注册表,以及 skill/subagent 提供方。subagent 可以通过 spawn 创建全新实例、fork 一个已完成轮次的前缀、使用 ACP(Agent Client Protocol)子 agent,或将一个独立完整的轮次委派给 Codex 或其他产品提供方([subagent.md](subsystems/subagent.md))。
+## 会话日志
 
-`dsh-workspace-context` 在第一次 `agent/pre-step` 组合基线并将它折入最终进入的批次、紧随已领取的直接提示词之后,使其与直接提示词一同抵达第一次请求;reject 则将它留在 next-step inbox。当压缩从可见表层移除该基线时,下一次进入步骤的 pre-step 会组合当前基线,并在同一请求中携带它。工具执行后投影的文件系统变更也会折入下一次进入步骤的 pre-step,而不会另外创建稍后的纯上下文步骤([决策](../.agents/notes/implemented/feature/2026-06-24-workspace-context.md))。`dsh-paths` 负责共享路径。
+会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流
 
-### 组合包与应用
+**模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。
 
-`dsh-agent-spine-demo` 组合一套主干和可选目标。应用包负责 CLI(命令行界面)、ACP 自动化和 JSON-RPC 入口([README](../packages/examples/agent-spine-demo/README.md)、[acp/](../packages/acp/README.md)、[interaction/](../packages/interaction/README.md))。`dsh-jsonrpc-agent` 启动外部 `cordis.yml`;Python SDK 在配置缺失时提供默认项([Python SDK](../python/README.md))。轻量部署使用可替换后端和可选工具([examples/](../examples/AGENTS.md)、[可运行接线](cookbook/extension-cookbook.md#runnable-wirings)、[图谱](graph-atlas.md))。
+## 能力 seam
 
-### Agent Preset
+一个 **seam** 是一项可替换能力,包含三种角色:声明接口的 **Service Definition**、实现它的 **Service provider**,以及使用它的 **Consumer**(通常是面向模型的工具)。一个包可以合并承担多个角色,但单一角色本身不是 seam;添加一项能力意味着把三者一并设计([能力图](capability-seams.md))。
 
-部署可为每个会话分别组装面向模型的插件集合。**agent preset** 是一个含 `agent.cordis.yml` 的目录,在 `setup(agentCtx)` 期间作为 `include` 子树挂到该 agent 的 scope 之下,其工具与提示词注册因而归档进该 agent 的分层并随之卸载,注册表无需新增层级。宿主组装保留必须共享的部分:注册表本身、跨会话设施、沙箱与审批栈、模型路由。`ctx.agentPresets` 负责发现与受控挂载,拒绝从未激活的行,以及把服务发布进根服务 realm 的行。详见 [按会话组装 agent preset](../.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md)、[preset/](../packages/preset/README.md)
+seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品
 
-### 新行为的归属位置
+## 新行为的归属位置
 
-新行为附加到已有文档记录的扩展点;循环发生变更时,本架构图随之更新。
+新行为附加到已有文档记录的扩展点。改动循环本身时,本映射随之更新。
 
 | 目标 | 机制 |
 |---|---|
 | 添加模型提供方 | 在 `ctx.llm` 上注册其适配器 |
-| 添加面向模型的能力 | 在 `ctx.tools` 上注册;schema 加入提示词组装 |
-| 让某个会话拥有不同的能力集合 | 在 agent preset 中组装它;其中的服务行需要 `isolate` realm |
-| 添加 shell 执行 | 实现并注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` spawn 进程 |
+| 添加面向模型的能力 | 在 `ctx.tools` 上注册;schema 加入提示词组装 |
+| 让某个会话拥有不同的能力集合 | 组装一个 agent preset;其中的服务行需要 `isolate` realm |
+| 添加 shell 执行 | 注册 `ctx.bash` 后端;本地后端通过 `ctx.subprocess` spawn 进程 |
 | 添加持久化终端执行 | 注册 `ctx.pty` 后端和 `dsh-tool-pty` |
-| 添加用户命令 | 在 `ctx.commands` 上注册;适配器无需模型轮次即可发现并分派 |
-| 添加后台工作 | 在 `ctx.tasks` 上注册;通用 `task_*` 工具负责收集或停止 |
-| 添加文件系统访问或策略 | 实现 `ctx.fs` 提供方,或监听 `fs/*` 策略事件 |
+| 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 |
+| 添加后台工作 | 在 `ctx.tasks` 上注册;`task_*` 工具负责收集或停止 |
+| 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 |
 | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv |
-| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 是停止轮次的事件 |
-| 添加模型可见上下文 | 调用 `agent.inject()`,将带来源的上下文排入下一次获准请求 |
+| 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 会停止轮次 |
+| 添加模型可见上下文 | 调用 `agent.inject()`;它会落到下一次获准的请求中 |
 | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 |
-| Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer |
+| 添加 Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer |
 | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 |
-| 添加异步会话标题生成 | 注册唯一的 `ctx.sessionTitle` 提供方 |
-| 管理同会话目标 | 使用 `ctx.goals`;通过 `Agent` 和 `agent/*` 续跑 |
-| fork 活跃会话 | 调用 `ctx.sessions.fork(source, boundary?, childSessionId?)` |
-| 将注册项限定到单个 agent | 使用其 `agent.ctx`(参见 Agent 作用域) |
+| 生成会话标题 | 注册唯一的 `ctx.sessionTitle` 提供方 |
+| 管理同会话目标 | 使用 `ctx.goals`;通过 `agent/*` 续跑 |
+| fork 活跃会话 | `ctx.sessions.fork(source, boundary?, childSessionId?)` |
+| 将注册项限定到单个 agent | 使用该 agent 的 `agent.ctx` |
 
-[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力;指南涵盖[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM 适配器](cookbook/adding-an-llm-adapter.md)、[Chat 节点](cookbook/adding-a-conversation-node.md)和 [vendored 包](cookbook/adding-a-vendored-package.md)
+[扩展实操手册](cookbook/extension-cookbook.md)将功能映射到能力,并索引[包](cookbook/adding-a-package.md)、[工具](cookbook/adding-a-tool.md)、[LLM(大语言模型)适配器](cookbook/adding-an-llm-adapter.md)和 [Chat 节点](cookbook/adding-a-conversation-node.md)的分步指南

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 3033ea80b3be3a59b13a0683bb005aef226728ad
-config-catalog.zh.md: 8b34f27a87f3162bb1d7154518dadf04de175e4a
+config-catalog.md: 918d12d524c63e962face0c96fa36e9baa00a7be
+config-catalog.zh.md: feb0d9aada54987a18021137d21cc313772682a0

+ 1 - 0
docs/config-catalog.md

@@ -2848,6 +2848,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
 - `@deepseek-ai/dsh-session-export` — requires `commands` ([`packages/session-query/session-export/src/index.ts`](../packages/session-query/session-export/src/index.ts))
 - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
+- `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
 - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
 - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -2849,6 +2849,7 @@ export interface Config {
 - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts))
 - `@deepseek-ai/dsh-session-export` — 需要 `commands`([`packages/session-query/session-export/src/index.ts`](../packages/session-query/session-export/src/index.ts))
 - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts))
+- `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts))
 - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts))
 - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
 - `@deepseek-ai/dsh-subagent`([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: aab9e0bbc383183ac0499c0774a382f468f621a4
-module-graph.zh.md: 8814fdaf5054d4789d75b827d0bc19ca4a118ada
+module-graph.md: 6787b0d097753b6b4ac4cbb2ea2d03be94ecf872
+module-graph.zh.md: 96e1fe0bd3bc014351015ff1f7c19aecbcc6615f

+ 8 - 1
docs/module-graph.md

@@ -275,6 +275,7 @@ flowchart TD
     pkg_session_persistence_sqlite["session-persistence-sqlite"]
     pkg_session_projection["session-projection"]
     pkg_session_projection_cache["session-projection-cache"]
+    pkg_session_stats["session-stats"]
     pkg_session_telemetry["session-telemetry"]
     pkg_session_telemetry_otel["session-telemetry-otel"]
     pkg_session_title["session-title"]
@@ -570,6 +571,10 @@ flowchart TD
   pkg_session_projection_cache --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_session_projection
   pkg_session_projection_cache --> pkg_storage_domain
+  pkg_session_stats --> pkg_invariants
+  pkg_session_stats --> pkg_llm
+  pkg_session_stats --> pkg_session
+  pkg_session_stats --> pkg_session_projection
   pkg_session_telemetry --> pkg_agent
   pkg_session_telemetry --> pkg_invariants
   pkg_session_telemetry --> pkg_session
@@ -1214,6 +1219,7 @@ flowchart TD
   pkg_client_ui_conversation --> pkg_compact
   pkg_client_ui_conversation --> pkg_invariants
   pkg_client_ui_conversation --> pkg_llm_retry
+  pkg_client_ui_conversation --> pkg_session_stats
   pkg_client_ui_conversation --> pkg_token_meter
   pkg_client_ui_conversation --> pkg_tools
   pkg_client_ui_directory_picker --> pkg_client_locale
@@ -1464,6 +1470,7 @@ flowchart TD
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
@@ -1568,7 +1575,7 @@ flowchart TD
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
-| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
 | [`client-ui-directory-picker`](../packages/client/ui-directory-picker) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |

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

@@ -277,6 +277,7 @@ flowchart TD
     pkg_session_persistence_sqlite["session-persistence-sqlite"]
     pkg_session_projection["session-projection"]
     pkg_session_projection_cache["session-projection-cache"]
+    pkg_session_stats["session-stats"]
     pkg_session_telemetry["session-telemetry"]
     pkg_session_telemetry_otel["session-telemetry-otel"]
     pkg_session_title["session-title"]
@@ -572,6 +573,10 @@ flowchart TD
   pkg_session_projection_cache --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_session_projection
   pkg_session_projection_cache --> pkg_storage_domain
+  pkg_session_stats --> pkg_invariants
+  pkg_session_stats --> pkg_llm
+  pkg_session_stats --> pkg_session
+  pkg_session_stats --> pkg_session_projection
   pkg_session_telemetry --> pkg_agent
   pkg_session_telemetry --> pkg_invariants
   pkg_session_telemetry --> pkg_session
@@ -1216,6 +1221,7 @@ flowchart TD
   pkg_client_ui_conversation --> pkg_compact
   pkg_client_ui_conversation --> pkg_invariants
   pkg_client_ui_conversation --> pkg_llm_retry
+  pkg_client_ui_conversation --> pkg_session_stats
   pkg_client_ui_conversation --> pkg_token_meter
   pkg_client_ui_conversation --> pkg_tools
   pkg_client_ui_directory_picker --> pkg_client_locale
@@ -1466,6 +1472,7 @@ flowchart TD
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
@@ -1570,7 +1577,7 @@ flowchart TD
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
-| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-attachment`](../packages/client/ui-attachment), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
 | [`client-ui-directory-picker`](../packages/client/ui-directory-picker) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |

+ 2 - 2
docs/subsystems/core.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/core.md
-core.md: 2e89bac4c0468c094814aa7137381f4be569fc29
-core.zh.md: e1ab879442937420553b09e37697532eb6ad4bd9
+core.md: 7e8870529a2df8660c5f5822d0de112486560fb4
+core.zh.md: 5e35c33a091eaecc5141330a04a963b7f7397665

+ 1 - 1
docs/subsystems/core.md

@@ -202,7 +202,7 @@ type AgentCancelCause =
 
 The cause is a TypeScript-enforced same-process input. An active cancellation holder copies it into the runtime-only `AbortSignal.reason`; a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; recording who requested cancellation would require a separate durable event rather than overloading the terminal result.
 
-The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
+The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
 
 ## Initiating Agent
 

+ 1 - 1
docs/subsystems/core.zh.md

@@ -206,7 +206,7 @@ type AgentCancelCause =
 
 cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有者会将它复制到仅运行时的 `AbortSignal.reason`;signal 不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录谁请求了取消,应使用单独的持久事件,而不是让终态结果承担额外含义。
 
-[事件分类](../architecture.md#event)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。
+[事件分类](../architecture.md#events)负责 `agent/*` 生命周期、检查点与 waterfall(瀑布式事件)约定。轮次和步骤边界是持久会话事件,而不是 agent emit。
 
 <a id="initiating-agent"></a>
 

+ 2 - 2
docs/subsystems/llm-streaming.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md
-llm-streaming.md: 41ae943b1945209bc7996379dc326434b5803d17
-llm-streaming.zh.md: 7303f02ce2fda84031843dba75eb4d969bc86e5d
+llm-streaming.md: 6b3b539fd874857f2b5e94cc05ee68ac4a1d0fb7
+llm-streaming.zh.md: d5bf02c20ead8a574075d996540efba427fe75ce

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
docs/subsystems/llm-streaming.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
docs/subsystems/llm-streaming.zh.md


+ 2 - 2
docs/user/guide/python-sdk.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/guide/python-sdk.md
-python-sdk.md: 5e1c31bf006fe22bf0c79bb3f80e96fe6ba3072f
-python-sdk.zh.md: ee2a7306ec4b322a77647ab97ffe4e24aba5fc80
+python-sdk.md: 71c588ce8c22a8de7ea6c8ed79989b310dcf812a
+python-sdk.zh.md: e273477a3d7639a1f8da39f1a3b5f7fd1d072f60

+ 1 - 1
docs/user/guide/python-sdk.md

@@ -17,7 +17,7 @@ This tutorial is the programmatic alternative to the Web UI. It installs the pub
 Clone the repository for its runnable example, create a virtual environment, and install the SDK with its same-version bundled runtime:
 
 ```sh
-git clone https://github.com/deepseek-harness/deepseek-harness.git
+git clone https://github.com/deepseek-ai/deepseek-harness.git
 cd deepseek-harness
 python -m venv .venv
 . .venv/bin/activate

+ 1 - 1
docs/user/guide/python-sdk.zh.md

@@ -17,7 +17,7 @@
 克隆仓库以使用其中的可运行示例,创建虚拟环境,并安装 SDK 及其同版本内置运行时:
 
 ```sh
-git clone https://github.com/deepseek-harness/deepseek-harness.git
+git clone https://github.com/deepseek-ai/deepseek-harness.git
 cd deepseek-harness
 python -m venv .venv
 . .venv/bin/activate

+ 1 - 1
packages/bash/tool-bash/src/index.ts

@@ -4,7 +4,7 @@
  * rather than the tool-call signal after an id is returned.
  *
  * TODO(permissions): deployment policy belongs in `tools/pre-execute` and
- * sandboxing executors; see docs/architecture.md § Extending The Harness.
+ * sandboxing executors; see docs/architecture.md § Where new behavior goes.
  * @module @deepseek-ai/dsh-tool-bash
  */
 

+ 5 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -78,6 +78,11 @@
         writeEveryEvents: 200
         writeIntervalMs: 5000
 
+    # Whole-log turn/step counts for the chat stats strip (the sessionStats
+    # projection key); the projection registry itself is a base-layer row.
+    - id: session-stats
+      name: '@deepseek-ai/dsh-session-stats'
+
     # Resolve bind host, SSH launch, and display once at boot, then mount the
     # matching dual-face directory picker. Mount -native or -browse directly in
     # an overlay to pin the interaction.

+ 1 - 0
packages/bundle/web-app/package.json

@@ -93,6 +93,7 @@
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-export": "workspace:^",
+    "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-storage-json": "workspace:^",

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: 236531281c17ef982982e97caad99491584bd0b5
-README.zh.md: 73bc3e31c90a4f12c4c5f11e9fd0552601dcc7a6
+README.md: b9452d1f763be5be6953cb7973da8a2c909ed979
+README.zh.md: 0dfb6e6b6d619f111e36d5bdf57d127ac1ca9ef5

+ 2 - 2
packages/client/README.md

@@ -35,13 +35,13 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
 | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. |
 | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
 | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
-| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. |
+| [`ui-plugin-config/`](ui-plugin-config/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards. |
 | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
 | [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
 | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
 | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
 | [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
-| [`ui-plugins/`](ui-plugins/README.md) | Shows the current Host Loader entries in a read-only Settings section. |
+| [`ui-plugins/`](ui-plugins/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings. |
 
 Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions.
 

+ 2 - 2
packages/client/README.zh.md

@@ -35,13 +35,13 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
 | [`ui-model/`](ui-model/README.md) | 在对话界面中提供模型选择。 |
 | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
 | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
-| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 |
+| [`ui-plugin-config/`](ui-plugin-config/README.md) | 拥有“插件”设置分区、它的标签页扩展点,以及可配置的宿主平面插件卡片。 |
 | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
 | [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并编写预设组合。 |
 | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
 | [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
 | [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
-| [`ui-plugins/`](ui-plugins/README.md) | 在只读设置分区中展示当前 Host Loader 条目。 |
+| [`ui-plugins/`](ui-plugins/README.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页。 |
 
 每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。
 

+ 84 - 0
packages/client/connection/src/client/fixture.ts

@@ -9,6 +9,7 @@ import {
   createAssistantMessage,
   createToolResultMessage,
   createUserMessage,
+  isTokenDelta,
 } from '@deepseek-ai/dsh-llm/message'
 import { CallId } from '@deepseek-ai/dsh-llm/brand'
 import type {
@@ -876,6 +877,76 @@ function tokenUsageOf(log: readonly SessionEvent[]): FixtureTokenUsageProjection
   return totals
 }
 
+/** Fixture parallel of session-stats' whole-log counting and wall-time fold. */
+function sessionStatsOf(log: readonly SessionEvent[]): {
+  turns: number
+  steps: number
+  llmMs: number
+  toolMs: number
+  ttftMs: number
+  ttftSteps: number
+  decodeMs: number
+  decodeTokens: number
+} {
+  const value = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
+  let lastTurn: number | null = null
+  let openStep: { turn: number; step: number; startTime: number; firstTokenTime: number | null } | null = null
+  const pendingCalls = new Map<string, number>()
+  for (const event of log) {
+    switch (event.type) {
+      case 'step/start':
+        openStep = { turn: event.data.turn, step: event.data.step, startTime: event.time, firstTokenTime: null }
+        break
+      case 'assistant/chunk':
+        if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step
+          && openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) {
+          openStep.firstTokenTime = event.time
+        }
+        break
+      case 'assistant/message': {
+        if (openStep === null || openStep.turn !== event.data.turn || openStep.step !== event.data.step) break
+        value.llmMs += Math.max(0, event.time - openStep.startTime)
+        if (openStep.firstTokenTime !== null) {
+          value.ttftMs += Math.max(0, openStep.firstTokenTime - openStep.startTime)
+          value.ttftSteps += 1
+          const outputTokens = event.data.usage?.outputTokens
+          if (typeof outputTokens === 'number' && Number.isFinite(outputTokens) && outputTokens >= 0) {
+            value.decodeMs += Math.max(0, event.time - openStep.firstTokenTime)
+            value.decodeTokens += outputTokens
+          }
+        }
+        openStep = null
+        break
+      }
+      case 'tool/call':
+        pendingCalls.set(event.data.callId, event.time)
+        break
+      case 'tool/result': {
+        const callId = event.data.message.source.callId
+        const dispatched = pendingCalls.get(callId)
+        if (dispatched === undefined) break
+        pendingCalls.delete(callId)
+        value.toolMs += Math.max(0, event.time - dispatched)
+        break
+      }
+      case 'step/end':
+        if (event.data.turn !== lastTurn) {
+          value.turns += 1
+          lastTurn = event.data.turn
+        }
+        value.steps += 1
+        openStep = null
+        break
+      case 'turn/end':
+        pendingCalls.clear()
+        break
+      default:
+        break
+    }
+  }
+  return value
+}
+
 interface FixtureRequestContext {
   provider: string
   model: string
@@ -994,6 +1065,8 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record<string, unknow
   values['contextPressure'] = contextPressureOf(log)
   // Always present (token-meter composed): heuristic request composition.
   values['contextBreakdown'] = contextBreakdownOf(log)
+  // Always present (session-stats unit composed): whole-log turn/step counts.
+  values['sessionStats'] = sessionStatsOf(log)
   // Always present (attachment service composed): the deployment image
   // limits, constant per boot (mirrors the attachment-local defaults).
   // Deliberate host divergence: the real gateway never pushes an imageLimits
@@ -1041,6 +1114,17 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
       seq: event.seq,
     })
   }
+  // The stats fold's view advances on message assembly and tool settlement
+  // (wall times) and on step close (counts).
+  if (type === 'assistant/message' || type === 'tool/result' || type === 'step/end') {
+    frames.push({
+      type: 'session/projection',
+      sessionId: id,
+      key: 'sessionStats',
+      value: sessionStatsOf(log),
+      seq: event.seq,
+    })
+  }
   if (frames.length > 0) return frames
   if (type === 'session/title') {
     const values = projectionValuesOf(log)

+ 12 - 6
packages/client/connection/tests/fixture.client.spec.ts

@@ -164,6 +164,10 @@ describe('createFixtureApi', () => {
           toolsTokens: 0,
           messageTokens: 0,
         },
+        // Session-stats unit composed: no figure accrues on the empty log.
+        sessionStats: {
+          turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
+        },
         imageLimits: {
           maxImageBytes: 5 * 1024 * 1024,
           maxImagesPerMessage: 20,
@@ -360,7 +364,7 @@ describe('createFixtureApi', () => {
       const envelopes: RpcRequest<MuxFrame>[] = []
       for await (const envelope of api.events.mux(req({}), abort.signal)) {
         envelopes.push(envelope)
-        if (envelopes.length >= 11) abort.abort()
+        if (envelopes.length >= 13) abort.abort()
       }
       return envelopes
     }
@@ -381,14 +385,16 @@ describe('createFixtureApi', () => {
       value: { systemTokens: 0, toolsTokens: 0 },
     })
     expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0)
-    expect(first[9]?.payload).toMatchObject({
+    expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' })
+    expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0)
+    expect(first[10]?.payload).toMatchObject({
       type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits',
       value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 },
     })
-    expect(first[10]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
-    expect(second[10]?.rpcId).toBe(first[10]?.rpcId) // stable rpcId across replays (host replay semantics)
-    expect(first[11]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
-    expect(second[11]?.rpcId).toBe(first[11]?.rpcId)
+    expect(first[11]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' })
+    expect(second[11]?.rpcId).toBe(first[11]?.rpcId) // stable rpcId across replays (host replay semantics)
+    expect(first[12]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' })
+    expect(second[12]?.rpcId).toBe(first[12]?.rpcId)
   })
 
   it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => {

+ 5 - 18
packages/client/runtime/src/client/sessions/assistant-timing.ts

@@ -2,9 +2,14 @@
 // history fold derive AssistantTiming from the same step/start -> first token
 // delta -> assistant/message sequence.
 
+import { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { AssistantTiming } from './conversation.ts'
 
+// The first-token predicate lives beside the StreamChunk type in dsh-llm;
+// re-exported here so Chat Definitions keep their client-runtime import.
+export { isTokenDelta } from '@deepseek-ai/dsh-llm/message'
+
 /** Pre-finalize timing boundaries for one assistant step (start + first token). */
 export interface AssistantStepMetadata {
   stepStartTime: number | null
@@ -21,24 +26,6 @@ export function assistantStepKey(turn: number, step: number): string {
   return `${turn}\u0000${step}`
 }
 
-/**
- * Whether a chunk carries visible model output (first-token boundary). Empty
- * deltas (heartbeats, empty tool-call frames) do not count as a first token.
- * @param chunk - the assistant/chunk payload.
- * @returns true when the chunk contains a non-empty text/reasoning/tool delta.
- */
-export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
-  switch (chunk.type) {
-    case 'text-delta':
-    case 'reasoning-delta':
-      return chunk.text !== ''
-    case 'tool-call-delta':
-      return chunk.argumentsDelta !== '' || chunk.name !== undefined
-    default:
-      return false
-  }
-}
-
 /**
  * Fold one event into the per-step timing index: step/start opens the entry,
  * the first non-empty token delta stamps first-token time once. Other event

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: b6a265c9f0a67d31ebeaa88bd59082c4be465983
-README.zh.md: 001e0a58badd6f31875c09a31085277928f1ae22
+README.md: 131f76fc8bc7b63449e022ad91fd8453c10d5f01
+README.zh.md: 8d0cd4e82b8bf4256db9b0f3cb868630f57ce68e

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
packages/client/ui-conversation/README.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
packages/client/ui-conversation/README.zh.md


+ 2 - 0
packages/client/ui-conversation/package.json

@@ -70,6 +70,7 @@
     "@deepseek-ai/dsh-compact": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
+    "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "react": "^18.2.0"
@@ -98,6 +99,7 @@
     "@deepseek-ai/dsh-permission": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 20 - 10
packages/client/ui-conversation/src/client/chat/StatsLine.tsx

@@ -6,6 +6,8 @@ import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'reac
 import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ConversationSnapshot, UseProjection } from '@deepseek-ai/dsh-client-runtime/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 { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import { formatTokensPerSecond } from './message-chrome.ts'
@@ -30,14 +32,16 @@ interface WindowStats {
 }
 
 /**
- * Fold assistant and tool-result nodes into the window-scoped display totals.
+ * Fold assistant and tool-result nodes into window-scoped display totals —
+ * the FALLBACK for assemblies without the `sessionStats` projection.
  *
- * Counts and wall times describe the loaded window on purpose — they answer
- * "what is on screen". Token accounting deliberately does NOT come from here:
- * the window is paged and compaction rewrites it, so billing rides the durable
- * `tokenUsage` projection instead.
+ * 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 visible counts and summed wall times.
+ * @returns fallback counts and summed wall times.
  */
 export function deriveStats(nodes: ConversationSnapshot['nodes']): WindowStats {
   const turns = new Set<number>()
@@ -158,8 +162,13 @@ export interface StatsLineProps {
 
 export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
   const settledNodes = useSession(s => s.chat.legacy.nodes)
-  const stats = useMemo(() => deriveStats(settledNodes), [settledNodes])
   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) {
@@ -168,7 +177,6 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t
     if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs) }))
     if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs) }))
     if (durations.length > 0) groups.push(durations.join(' · '))
-    // Window-scoped like the wall times above: averages describe loaded steps.
     const speeds: string[] = []
     if (stats.ttftSteps > 0) {
       speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps) }))
@@ -183,9 +191,11 @@ export const StatsLine = memo(function StatsLine({ useSession, useProjection, t
   // 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. Suppress the empty projection on a brand-new session.
+  // 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
-    && (stats.steps > 0 || billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
+    && (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', {

+ 76 - 3
packages/client/ui-conversation/tests/chat-stats.client.spec.tsx

@@ -98,9 +98,11 @@ describe('deriveStats', () => {
     ])
     expect(stats.turns).toBe(2)
     expect(stats.steps).toBe(3)
-    // Window-scoped by design: the paged window is not an accounting source, so
-    // the fold exposes no billing fields (billing rides the projection);
-    // decodeTokens is a throughput input, not a billed total.
+    // The window fold's counts are only the fallback for assemblies without
+    // the sessionStats projection; the paged window is not an accounting
+    // source either, so the fold exposes no billing fields (billing rides the
+    // tokenUsage projection); decodeTokens is a throughput input, not a
+    // billed total.
     expect(Object.keys(stats).sort()).toEqual(
       ['decodeMs', 'decodeTokens', 'llmMs', 'steps', 'toolMs', 'ttftMs', 'ttftSteps', 'turns'],
     )
@@ -169,6 +171,14 @@ describe('formatters', () => {
 describe('StatsLine', () => {
   const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
 
+  /** A whole-log sessionStats value: zeros plus overrides. */
+  function sessionStats(overrides: Record<string, number>): Record<string, number> {
+    return {
+      turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0,
+      ...overrides,
+    }
+  }
+
   /** Stub the projection seat: a key-addressed table of whole values. */
   function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
     return (key: string) => values[key]
@@ -281,6 +291,69 @@ describe('StatsLine', () => {
     expect(view.container.textContent).toBe('1 turns · 1 steps')
   })
 
+  it('renders whole-session counts from the sessionStats projection over the paged window', () => {
+    // The bug's acceptance at unit level: one loaded page must not scope the
+    // counter — the durable projection's totals win over the window fold.
+    const { source } = makeSource({ nodes: [assistant(1, 1)] })
+    const view = render(<StatsLine {...props(source, {
+      tokenUsage: USAGE,
+      sessionStats: sessionStats({ turns: 10, steps: 89 }),
+    })} />)
+    expect(view.container.textContent)
+      .toBe('10 turns · 89 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
+  })
+
+  it('treats a defined zero-count projection as empty, not as fallback', () => {
+    // A composed unit always serves the key; all-zero genuinely means no
+    // closed step in the whole log, so nothing renders on a brand-new session.
+    const empty = makeSource()
+    const view = render(<StatsLine {...props(empty.source, {
+      tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
+      sessionStats: sessionStats({}),
+    })} />)
+    expect(view.container.textContent).toBe('')
+  })
+
+  it('hides the zero-token group when steps closed without any billed activity', () => {
+    // A session whose only turn failed before billing (e.g. an auth error):
+    // the counts group renders alone, not an uninformative zero-token group.
+    const { source } = makeSource()
+    const view = render(<StatsLine {...props(source, {
+      tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
+      sessionStats: sessionStats({ turns: 1, steps: 1 }),
+    })} />)
+    expect(view.container.textContent).toBe('1 turns · 1 steps')
+  })
+
+  it('keeps the counts group over an empty visible window when the projection carries totals', () => {
+    // Extends the durable-groups guarantee: full-session counts survive a
+    // window that compaction (or paging) left without assistant nodes.
+    const { source } = makeSource()
+    const view = render(<StatsLine {...props(source, {
+      tokenUsage: USAGE,
+      sessionStats: sessionStats({ turns: 7, steps: 44 }),
+    })} />)
+    expect(view.container.textContent)
+      .toBe('7 turns · 44 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
+  })
+
+  it('renders whole-log wall times and speeds from the projection, not the loaded window', () => {
+    // The 加载更早 hazard beyond counts: LLM/tool durations and the TTFT and
+    // throughput figures must not grow per loaded page either. An untimed
+    // 1-node window renders the projection's whole-log figures verbatim.
+    const { source } = makeSource({ nodes: [assistant(1, 1)] })
+    const view = render(<StatsLine {...props(source, {
+      tokenUsage: USAGE,
+      sessionStats: sessionStats({
+        turns: 200, steps: 200, llmMs: 100_000, toolMs: 62_000,
+        ttftMs: 1_600, ttftSteps: 2, decodeMs: 3_000, decodeTokens: 60,
+      }),
+    })} />)
+    expect(view.container.textContent).toBe(
+      '200 turns · 200 steps| LLM 1m40s · Tool call 1m2s| TTFT avg 0.8s · 20 tok/s| Cache hit 90%| Input 100 tok · Output 5 tok',
+    )
+  })
+
   it('omits cache hit when nothing was billed on the input side', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsLine {...props(source, {

+ 5 - 3
packages/client/ui-conversation/tests/gate-branch-tails.client.spec.tsx

@@ -70,9 +70,11 @@ describe('render branch tails', () => {
     expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
   })
 
-  it('StatsLine counts window nodes but drops every token group without a projection', () => {
-    // Node `usage` is deliberately ignored: billing rides the durable
-    // tokenUsage projection, so an absent projection leaves counts only.
+  it('StatsLine falls back to window-node counts and drops every token group without projections', () => {
+    // No sessionStats key → the window fold supplies the counts (the
+    // assembly-without-the-unit fallback). Node `usage` is deliberately
+    // ignored: billing rides the durable tokenUsage projection, so an absent
+    // projection leaves counts only.
     const nodes = [
       { kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] },
       { kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },

+ 3 - 0
packages/client/ui-conversation/tsconfig.json

@@ -44,6 +44,9 @@
     {
       "path": "../../session/session-projection"
     },
+    {
+      "path": "../../session/session-stats"
+    },
     {
       "path": "../../llm/token-meter"
     },

+ 2 - 2
packages/client/ui-plugin-config/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md
-README.md: 7e530d70f6573d619378e43b0245345b45d6db18
-README.zh.md: fd4f980fcf71c00c2357017fb40c76a9ca7a72cc
+README.md: 1bf8dd60b966ee0c0e28ae931556182c5c2b2e87
+README.zh.md: bd87e06202b2b083a6e9e089eb91681deea9d678

+ 3 - 3
packages/client/ui-plugin-config/README.md

@@ -2,17 +2,17 @@
 
 English | [中文](README.zh.md)
 
-The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
+The **Plugins** settings section and its **Plugin configuration** tab. The section owns the heading and compact tab chrome; feature plugins contribute pages through `settings.plugins.tab`. This package's own tab shows one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed.
 
 ## What appears here
 
-A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs.
+A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the configurable tab reflects what this deployment actually runs.
 
 The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
 
 ## Extension point
 
-The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`.
+The section declares `settings.plugins.tab`, a root list slot whose labels become ordered tabs. It keeps a tab mounted after its first selection, so local drafts and read-only snapshots survive tab switches. The package registers its own `configurable` contribution, which declares the nested `settings.plugin.item` list slot. A plugin that ships a browser half registers its own card into that nested slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Both levels follow the contribution's `order`.
 
 ## Writes
 

+ 3 - 3
packages/client/ui-plugin-config/README.zh.md

@@ -2,17 +2,17 @@
 
 [English](README.md) | 中文
 
-**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
+**插件**设置分区及其**插件配置**标签页。该分区拥有标题与紧凑的标签栏;功能插件通过 `settings.plugins.tab` 贡献页面。本包自己的标签页为每个配置由用户拥有的 Host 插件展示一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。
 
 ## 这里会出现什么
 
-只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。
+只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此“插件配置”标签页反映的是该部署实际运行的东西。
 
 第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
 
 ## 扩展点
 
-本分区声明根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。
+本分区声明根级列表 slot `settings.plugins.tab`,其标签会成为有序标签页。某个标签页首次被选择后会保持挂载,因此本地草稿与只读快照在切换标签页时不会丢失。本包注册自己的 `configurable` 贡献,由它声明嵌套的 `settings.plugin.item` 列表 slot。带浏览器半侧的插件把自己的卡片注册进这个嵌套 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。两层排序都遵循贡献的 `order`。
 
 ## 写入
 

+ 1 - 1
packages/client/ui-plugin-config/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-plugin-config",
-  "description": "Plugin configuration section: host-plane plugin settings as expandable cards",
+  "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards",
   "version": "0.0.1-rc.2",
   "publishConfig": {
     "access": "restricted"

+ 25 - 0
packages/client/ui-plugin-config/src/client/ConfigurablePluginsTab.tsx

@@ -0,0 +1,25 @@
+/** Configurable Host plugins contributed to the shared Plugins section. */
+
+import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from './slot-contract.ts'
+import css from './PluginConfigSection.module.css'
+
+/** Registration-side business face for the configurable tab. */
+export interface ConfigurablePluginsTabInjected {
+  /** How many cards the slot ledger held when the tab registration mounted. */
+  cardCount: number
+}
+
+/** Props the renderer binds for the configurable tab. */
+export type ConfigurablePluginsTabProps =
+  PropsRuntime<'settings.plugins.tab'>
+  & PropsLocale<'settings.pluginConfig'>
+  & PropsRenderSlots<'settings.plugin.item'>
+  & InjectFace<ConfigurablePluginsTabInjected>
+
+/** Render cards registered by plugins that expose editable settings. */
+export function ConfigurablePluginsTab({ t, renderSlot, cardCount }: ConfigurablePluginsTabProps) {
+  return cardCount === 0
+    ? <p className={css.empty}>{t('empty')}</p>
+    : <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>
+}

+ 51 - 2
packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css

@@ -1,10 +1,10 @@
-/* Plugin configuration section: heading, intro, and the card list. */
+/* Plugins section: compact tabs plus the configurable plugin card list. */
 
 .section {
   display: flex;
   flex-direction: column;
   gap: 12px;
-  max-width: 720px;
+  max-width: 760px;
   color: var(--dsw-alias-label-primary);
 }
 
@@ -20,6 +20,55 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
+.tabs {
+  display: flex;
+  align-items: flex-end;
+  gap: 22px;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+  margin-top: 2px;
+}
+
+.tab {
+  position: relative;
+  border: 0;
+  padding: 7px 1px 9px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  font: inherit;
+  font-size: 13px;
+  line-height: 20px;
+  cursor: pointer;
+}
+
+.tab:hover,
+.tab[data-active='true'] {
+  color: var(--dsw-alias-label-primary);
+}
+
+.tab[data-active='true']::after,
+.tab:focus-visible::after {
+  position: absolute;
+  right: 0;
+  bottom: -1px;
+  left: 0;
+  height: 2px;
+  border-radius: 2px 2px 0 0;
+  background: var(--dsw-alias-label-primary);
+  content: '';
+}
+
+.tab:focus-visible {
+  outline: 2px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+  border-radius: 2px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.panel {
+  min-width: 0;
+  padding-top: 2px;
+}
+
 .cards {
   list-style: none;
   margin: 0;

+ 97 - 23
packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx

@@ -1,49 +1,123 @@
-/**
- * Plugin configuration section: the shell around the per-plugin cards. It
- * enumerates nothing itself — cards arrive through the `settings.plugin.item`
- * slot it declares, so a plugin that ships a browser half owns its own card
- * and this section never learns what a namespace means.
- */
-
-import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import type {} from './slot-contract.ts'
+/** Plugins settings section: localized tabs around feature-owned pages. */
+
+import { useEffect, useId, useRef, useState } from 'react'
+import type {
+  HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime,
+} from '@deepseek-ai/dsh-client-ui-slots'
 import type { PluginConfigKey } from './locales.ts'
 import css from './PluginConfigSection.module.css'
 
+/** One tab projected from a `settings.plugins.tab` contribution. */
+export interface PluginSettingsTabRow {
+  id: string
+  order: number
+  label: string
+}
+
 /** Registration-side business face for the section. */
 export interface PluginConfigSectionInjected {
-  /** How many cards the slot ledger currently holds; zero renders the empty line. */
-  cardCount: number
+  hooks: {
+    /** Ordered, locale-aware projection of the Plugins tab ledger. */
+    tabs: HostObservable<readonly PluginSettingsTabRow[]>
+  }
 }
 
 /** Props the renderer binds for the section. */
 export type PluginConfigSectionProps =
   PropsRuntime<'settings.section'>
   & PropsLocale<'settings.pluginConfig'>
-  & PropsRenderSlots<'settings.plugin.item'>
+  & PropsRenderSlots<'settings.plugins.tab'>
   & InjectFace<PluginConfigSectionInjected>
 
-/**
- * Render the plugin configuration section.
- * @param props - runtime slot rendering, locale copy, and the card count.
- * @returns the section.
- */
-export function PluginConfigSection(props: PluginConfigSectionProps) {
-  const { t, renderSlot, cardCount } = props
+/** Render one Plugins page whose contents arrive from feature-owned tabs. */
+export function PluginConfigSection({ t, renderSlot, useTabs }: PluginConfigSectionProps) {
+  const tabsId = useId()
+  const tabRefs = useRef<Array<HTMLButtonElement | null>>([])
+  const rows = useTabs(value => value)
+  const [activeId, setActiveId] = useState<string>()
+  const [visitedIds, setVisitedIds] = useState<ReadonlySet<string>>(() => new Set())
+  const active = rows.find(row => row.id === activeId)?.id ?? rows[0]?.id
+
+  // A tab mounts only when first selected, then stays mounted while hidden so
+  // local drafts, disclosure state, search, and the inventory snapshot survive
+  // switching between the two views.
+  useEffect(() => {
+    if (active === undefined) return
+    setVisitedIds((previous) => {
+      if (previous.has(active)) return previous
+      return new Set([...previous, active])
+    })
+  }, [active])
+
   return (
     <div className={css.section}>
       <h2 className={css.heading}>{t('title')}</h2>
       <p className={css.intro}>{t('intro')}</p>
-      {cardCount === 0
-        ? <p className={css.empty}>{t('empty')}</p>
-        : <ul className={css.cards}>{renderSlot('settings.plugin.item', {})}</ul>}
+      {rows.length === 0 ? <p className={css.empty}>{t('empty')}</p> : (
+        <>
+          <div className={css.tabs} role="tablist" aria-label={t('tabs')}>
+            {rows.map((row, index) => {
+              const selected = row.id === active
+              return (
+                <button
+                  key={row.id}
+                  ref={(element) => { tabRefs.current[index] = element }}
+                  id={`${tabsId}-tab-${row.id}`}
+                  type="button"
+                  role="tab"
+                  className={css.tab}
+                  aria-selected={selected}
+                  aria-controls={`${tabsId}-panel-${row.id}`}
+                  data-active={selected ? 'true' : undefined}
+                  tabIndex={selected ? 0 : -1}
+                  onClick={() => { setActiveId(row.id) }}
+                  onKeyDown={(event) => {
+                    let nextIndex: number
+                    switch (event.key) {
+                      case 'ArrowRight': nextIndex = (index + 1) % rows.length; break
+                      case 'ArrowLeft': nextIndex = (index - 1 + rows.length) % rows.length; break
+                      case 'Home': nextIndex = 0; break
+                      case 'End': nextIndex = rows.length - 1; break
+                      default: return
+                    }
+                    event.preventDefault()
+                    const nextRow = rows[nextIndex] as PluginSettingsTabRow
+                    const nextTab = tabRefs.current[nextIndex] as HTMLButtonElement
+                    setActiveId(nextRow.id)
+                    nextTab.focus()
+                  }}
+                >
+                  {row.label}
+                </button>
+              )
+            })}
+          </div>
+          {rows
+            .filter(row => row.id === active || visitedIds.has(row.id))
+            .map((row) => {
+              const selected = row.id === active
+              return (
+                <div
+                  key={row.id}
+                  id={`${tabsId}-panel-${row.id}`}
+                  className={css.panel}
+                  role="tabpanel"
+                  aria-labelledby={`${tabsId}-tab-${row.id}`}
+                  hidden={!selected}
+                >
+                  {renderSlot('settings.plugins.tab', {}, { only: row.id })}
+                </div>
+              )
+            })}
+        </>
+      )}
     </div>
   )
 }
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** Plugin configuration section and card copy. */
+    /** Plugins section, configurable-tab, and card copy. */
     'settings.pluginConfig': PluginConfigKey
   }
 }

+ 66 - 18
packages/client/ui-plugin-config/src/client/index.ts

@@ -1,13 +1,12 @@
 /**
- * Plugin configuration surface, browser half — one settings section holding
- * an expandable card per Host plugin whose configuration a user owns.
+ * Plugins settings surface, browser half — one section whose feature-owned
+ * tabs include configurable Host plugin cards and read-only inventory.
  *
- * The section owns no knowledge of any namespace: it declares the
- * `settings.plugin.item` slot and renders whatever cards were registered into
- * it, so a plugin that ships a browser half contributes its own card and its
- * own controls. The three cards this package registers are the host-plane
- * sections the deployment already exposes; each binds its namespace through
- * the client settings scope, which keeps them unaware of one another.
+ * The section declares `settings.plugins.tab`; its own `configurable` tab then
+ * declares `settings.plugin.item` and renders whatever cards were registered
+ * into it. The three cards this package ships are the host-plane sections the
+ * deployment already exposes; each binds its namespace through the client
+ * settings scope, which keeps them unaware of one another and of other tabs.
  */
 
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
@@ -18,11 +17,15 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 // through the service, never a value import (client bundle purity gate).
 import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: the ctx.remote Context merge and the forwarded-event key face.
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import { AgentLoopCard } from './AgentLoopCard.tsx'
 import { BashCard } from './BashCard.tsx'
+import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
+import type { ConfigurablePluginsTabInjected } from './ConfigurablePluginsTab.tsx'
 import { PluginConfigSection } from './PluginConfigSection.tsx'
+import type { PluginConfigSectionInjected, PluginSettingsTabRow } from './PluginConfigSection.tsx'
 import { WebSearchCard } from './WebSearchCard.tsx'
 import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts'
 import { BASH_NS, BashCardController } from './bash-store.ts'
@@ -30,6 +33,7 @@ import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts'
 import { en, zh } from './locales.ts'
 
 export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx'
+export type { ConfigurablePluginsTabInjected, ConfigurablePluginsTabProps } from './ConfigurablePluginsTab.tsx'
 export type { PluginCardProps } from './PluginCard.tsx'
 export type { SettingsPluginItemOwnerProps } from './slot-contract.ts'
 export type { FieldProps } from './fields.tsx'
@@ -67,23 +71,67 @@ export function apply(ctx: ClientContext): void {
     'ui-plugin-config: credential invalidations',
   )
 
-  // The section renders the empty line rather than an empty list when no plugin
-  // contributed a card. The count is read once: the renderer caches a root
-  // entry's inject face per registration, so this reports what was registered
-  // when the section mounted, not what is visible now. Both gaps are bounded by
-  // this deployment always registering the three cards below — a card that
-  // arrives later would not raise the count, and a namespace this deployment
-  // does not expose leaves its card rendering nothing inside a non-empty list.
+  let tabsVersion = -1
+  let tabsRevision = -1
+  let tabs: readonly PluginSettingsTabRow[] = []
+  const sectionInjected = (): PluginConfigSectionInjected => ({
+    hooks: {
+      tabs: {
+        getSnapshot: () => {
+          const version = ctx.slots.getVersion('settings.plugins.tab')
+          const revision = ctx.locale.getSnapshot().revision
+          if (version !== tabsVersion || revision !== tabsRevision) {
+            tabsVersion = version
+            tabsRevision = revision
+            tabs = ctx.slots.entries('settings.plugins.tab')
+              .map(entry => ({
+                /* v8 ignore next -- list-slot registration requires id */
+                id: entry.options.id ?? '',
+                order: entry.options.order ?? 0,
+                label: resolveSlotLabel(entry.options.label) ?? '',
+              }))
+              .sort((a, b) => a.order - b.order)
+          }
+          return tabs
+        },
+        subscribe: (listener) => {
+          const offLedger = ctx.slots.subscribe('settings.plugins.tab', listener)
+          const offLocale = ctx.locale.subscribe(listener)
+          return () => {
+            offLedger()
+            offLocale()
+          }
+        },
+      },
+    },
+  })
+
+  // This package owns the one Plugins navigation entry and the tab chrome;
+  // feature plugins contribute pages without competing for Settings nav rows.
   ctx.slots.inject('settings.section', () => ctx.slots.register({
     name: 'settings.section',
     id: 'plugins',
-    order: 30,
+    order: 15,
     label: () => t('nav'),
     locale: NS,
-    inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }),
-    children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
+    inject: sectionInjected,
+    children: { 'settings.plugins.tab': { kind: 'list', scope: 'root' } },
   }, PluginConfigSection))
 
+  // The existing configuration page is one ordinary tab. It keeps ownership
+  // of the card slot and the three shipped card contributions below.
+  ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
+    name: 'settings.plugins.tab',
+    id: 'configurable',
+    order: 0,
+    label: () => t('configurableTab'),
+    locale: NS,
+    inject: (): ConfigurablePluginsTabInjected => ({
+      cardCount: ctx.slots.entries('settings.plugin.item').length,
+    }),
+    children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } },
+  }, ConfigurablePluginsTab))
+
   ctx.slots.inject('settings.plugin.item', function* () {
     yield ctx.slots.register({
       name: 'settings.plugin.item',

+ 11 - 7
packages/client/ui-plugin-config/src/client/locales.ts

@@ -2,7 +2,7 @@
 
 /** Locale keys these surfaces render. */
 export type PluginConfigKey =
-  | 'nav' | 'title' | 'intro' | 'empty'
+  | 'nav' | 'title' | 'intro' | 'tabs' | 'configurableTab' | 'empty'
   | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse'
   | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber'
   | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint'
@@ -14,9 +14,11 @@ export type PluginConfigKey =
 
 /** English copy. */
 export const en: Record<PluginConfigKey, string> = {
-  nav: 'Plugin config',
-  title: 'Plugin configuration',
-  intro: 'Configure the plugins this deployment installed.',
+  nav: 'Plugins',
+  title: 'Plugins',
+  intro: 'Configure and inspect the plugins installed in this deployment.',
+  tabs: 'Plugin views',
+  configurableTab: 'Plugin configuration',
   empty: 'This deployment exposes no plugin settings.',
   overridden: 'Overridden',
   reset: 'Reset to default',
@@ -53,9 +55,11 @@ export const en: Record<PluginConfigKey, string> = {
 
 /** Simplified Chinese copy. */
 export const zh: Record<PluginConfigKey, string> = {
-  nav: '插件配置',
-  title: '插件配置',
-  intro: '配置本部署已安装的插件。',
+  nav: '插件',
+  title: '插件',
+  intro: '配置和查看本部署已安装的插件。',
+  tabs: '插件视图',
+  configurableTab: '插件配置',
   empty: '本部署没有开放任何插件设置。',
   overridden: '已覆盖',
   reset: '恢复默认',

+ 3 - 3
packages/client/ui-plugin-config/src/index.ts

@@ -1,7 +1,7 @@
 /**
- * Plugin configuration surface, node half. The empty apply exists so the
- * plugin appears in the host cordis.yml / Loader; the browser half ships the
- * settings section through exports["./client"], discovered from the
+ * Plugins settings surface, node half. The empty apply exists so the plugin
+ * appears in the host cordis.yml / Loader; the browser half owns the section
+ * and its configurable tab through exports["./client"], discovered from the
  * package.json dsh.client declaration. Every section this page edits is owned
  * by the Host plugin that registered it, so this package registers no
  * namespace of its own.

+ 30 - 5
packages/client/ui-plugin-config/tests/apply.client.spec.ts

@@ -8,6 +8,9 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
 import { SettingsScopeService } from '@deepseek-ai/dsh-client-ui-settings/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client'
+import type {
+  ConfigurablePluginsTabInjected, PluginConfigSectionInjected,
+} from '@deepseek-ai/dsh-client-ui-plugin-config/client'
 
 // The service reads its initial locale from the browser; these specs assert
 // the shipped Chinese copy, so they state the browser they assume.
@@ -46,16 +49,20 @@ describe('ui-plugin-config apply', () => {
     expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'settingsScope'])
   })
 
-  it('registers the section and declares the per-plugin card slot', async () => {
+  it('registers one Plugins section and declares the tab and card slots', async () => {
     const { ctx, slots } = await bench()
     declareRoot(slots)
 
     await ctx.plugin({ inject: [...inject], apply }).await()
 
     const section = slots.entries('settings.section')[0]!
-    expect(section.options).toMatchObject({ id: 'plugins', order: 30 })
+    expect(section.options).toMatchObject({ id: 'plugins', order: 15 })
     // The nav label is a locale-following thunk; owners resolve it at read time.
-    expect(resolveSlotLabel(section.options.label)).toBe('插件配置')
+    expect(resolveSlotLabel(section.options.label)).toBe('插件')
+    expect(slots.spec('settings.plugins.tab')).toMatchObject({ kind: 'list', scope: 'root' })
+    const tab = slots.entries('settings.plugins.tab')[0]!
+    expect(tab.options).toMatchObject({ id: 'configurable', order: 0 })
+    expect(resolveSlotLabel(tab.options.label)).toBe('插件配置')
     expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' })
   })
 
@@ -69,13 +76,30 @@ describe('ui-plugin-config apply', () => {
       .toEqual(['bash', 'agent-loop', 'web-search'])
   })
 
-  it('injects a live card count and one business face per card', async () => {
+  it('injects a live tab projection, a card count, and one business face per card', async () => {
     const { ctx, slots } = await bench()
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
 
     const section = slots.entries('settings.section')[0]!
-    expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 })
+    const sectionFace = (section.inject as unknown as () => PluginConfigSectionInjected)()
+    const initialTabs = sectionFace.hooks.tabs.getSnapshot()
+    expect(initialTabs).toEqual([
+      { id: 'configurable', order: 0, label: '插件配置' },
+    ])
+    expect(sectionFace.hooks.tabs.getSnapshot()).toBe(initialTabs)
+
+    const listener = vi.fn()
+    const unsubscribe = sectionFace.hooks.tabs.subscribe(listener)
+    slots.register({ name: 'settings.plugins.tab', id: 'plain' } as never, () => null)
+    expect(sectionFace.hooks.tabs.getSnapshot()).toEqual([
+      { id: 'configurable', order: 0, label: '插件配置' },
+      { id: 'plain', order: 0, label: '' },
+    ])
+    unsubscribe()
+
+    const tab = slots.entries('settings.plugins.tab')[0]!
+    expect((tab.inject as unknown as () => ConfigurablePluginsTabInjected)()).toEqual({ cardCount: 3 })
     for (const entry of slots.entries('settings.plugin.item')) {
       const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record<string, unknown> }
       // Each card injects exactly one snapshot store plus its own actions.
@@ -129,6 +153,7 @@ describe('ui-plugin-config apply', () => {
     await fiber.dispose()
 
     expect(slots.entries('settings.section')).toHaveLength(0)
+    expect(slots.spec('settings.plugins.tab')).toBeUndefined()
     expect(slots.spec('settings.plugin.item')).toBeUndefined()
   })
 })

+ 88 - 12
packages/client/ui-plugin-config/tests/section.client.spec.tsx

@@ -13,8 +13,10 @@ import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
 import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
 import { BashCard } from '../src/client/BashCard.tsx'
 import type { BashCardProps } from '../src/client/BashCard.tsx'
+import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx'
+import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
 import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx'
-import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx'
+import type { PluginConfigSectionProps, PluginSettingsTabRow } from '../src/client/PluginConfigSection.tsx'
 import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
 import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
 import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts'
@@ -46,15 +48,26 @@ function cardActions() {
   return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
 }
 
-function renderSection(cardCount: number, cards = 'cards') {
+function renderSection(rows: readonly PluginSettingsTabRow[]) {
   const props = {
     t,
-    cardCount,
-    renderSlot: () => <li>{cards}</li>,
+    useTabs: (selector: (value: readonly PluginSettingsTabRow[]) => unknown) => selector(rows),
+    renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
+      <span>{options.only}</span>
+    ),
   } as unknown as PluginConfigSectionProps
   render(<PluginConfigSection {...props} />)
 }
 
+function renderConfigurable(cardCount: number, cards = 'cards') {
+  const props = {
+    t,
+    cardCount,
+    renderSlot: () => <li>{cards}</li>,
+  } as unknown as ConfigurablePluginsTabProps
+  render(<ConfigurablePluginsTab {...props} />)
+}
+
 function renderBash(state: Partial<BashCardState> = {}) {
   const store = createSnapshotStore<BashCardState>({
     ...settled,
@@ -69,26 +82,89 @@ function renderBash(state: Partial<BashCardState> = {}) {
 }
 
 describe('PluginConfigSection', () => {
-  it('says so when no plugin contributed a card', () => {
-    renderSection(0)
+  it('says so when no plugin contributed a tab', () => {
+    renderSection([])
 
     expect(screen.getByText(en.empty)).toBeTruthy()
-    expect(screen.queryByText('cards')).toBeNull()
+    expect(screen.queryByRole('tab')).toBeNull()
   })
 
-  it('renders the card list once a plugin contributed one', () => {
-    renderSection(1)
+  it('defaults to the first ordered tab and mounts another only after selection', () => {
+    renderSection([
+      { id: 'configurable', order: 0, label: en.configurableTab },
+      { id: 'all', order: 10, label: 'Plugin list' },
+    ])
 
-    expect(screen.getByText('cards')).toBeTruthy()
-    expect(screen.queryByText(en.empty)).toBeNull()
+    const configurable = screen.getByRole('tab', { name: en.configurableTab })
+    const all = screen.getByRole('tab', { name: 'Plugin list' })
+    expect(configurable.getAttribute('aria-selected')).toBe('true')
+    expect(screen.getByText('configurable')).toBeTruthy()
+    expect(screen.queryByText('all')).toBeNull()
+
+    fireEvent.click(all)
+    expect(all.getAttribute('aria-selected')).toBe('true')
+    expect(screen.getByText('all')).toBeTruthy()
+    expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
+
+    fireEvent.click(configurable)
+    expect(configurable.getAttribute('aria-selected')).toBe('true')
+    expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
   })
 
   it('leads with its own heading and intro', () => {
-    renderSection(1)
+    renderSection([{ id: 'configurable', order: 0, label: en.configurableTab }])
 
     expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
     expect(screen.getByText(en.intro)).toBeTruthy()
   })
+
+  it('moves focus and selection with standard horizontal tab keys', () => {
+    renderSection([
+      { id: 'configurable', order: 0, label: en.configurableTab },
+      { id: 'all', order: 10, label: 'Plugin list' },
+      { id: 'diagnostics', order: 20, label: 'Diagnostics' },
+    ])
+
+    const configurable = screen.getByRole('tab', { name: en.configurableTab })
+    const all = screen.getByRole('tab', { name: 'Plugin list' })
+    const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
+    expect(configurable.getAttribute('tabindex')).toBe('0')
+    expect(all.getAttribute('tabindex')).toBe('-1')
+
+    configurable.focus()
+    fireEvent.keyDown(configurable, { key: 'ArrowRight' })
+    expect(document.activeElement).toBe(all)
+    expect(all.getAttribute('aria-selected')).toBe('true')
+
+    fireEvent.keyDown(all, { key: 'End' })
+    expect(document.activeElement).toBe(diagnostics)
+    fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
+    expect(document.activeElement).toBe(configurable)
+    fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
+    expect(document.activeElement).toBe(diagnostics)
+    fireEvent.keyDown(diagnostics, { key: 'Home' })
+    expect(document.activeElement).toBe(configurable)
+
+    fireEvent.keyDown(configurable, { key: 'Escape' })
+    expect(document.activeElement).toBe(configurable)
+    expect(configurable.getAttribute('aria-selected')).toBe('true')
+  })
+})
+
+describe('ConfigurablePluginsTab', () => {
+  it('says so when no plugin contributed a card', () => {
+    renderConfigurable(0)
+
+    expect(screen.getByText(en.empty)).toBeTruthy()
+    expect(screen.queryByText('cards')).toBeNull()
+  })
+
+  it('renders the card list once a plugin contributed one', () => {
+    renderConfigurable(1)
+
+    expect(screen.getByText('cards')).toBeTruthy()
+    expect(screen.queryByText(en.empty)).toBeNull()
+  })
 })
 
 describe('BashCard', () => {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-plugins/README.md
-README.md: bb487d5e2cbd34406d83867997ede4d70b190d70
-README.zh.md: 48a11911509ea260aa9727d55c0b4df6efbfb1c9
+README.md: a663665cc2d7ce26a2724da9aecc25f51515327a
+README.zh.md: 852ecbe6546f76a76fc6d9d3a8bb891539b0844f

+ 3 - 3
packages/client/ui-plugins/README.md

@@ -2,9 +2,9 @@
 
 English | [中文](README.zh.md)
 
-Read-only Plugins section for Web Settings. The browser plugin registers one localized `settings.section` contribution with id `plugin-inventory`, after Models, and lets the Settings shell supply its ordinary fallback icon. It performs no Remote read during plugin activation; mounting the section lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
+Read-only **Plugin list** tab for Web Settings. The browser plugin registers one localized `settings.plugins.tab` contribution with id `all`; the Plugins section owns the navigation entry and tab chrome. It performs no Remote read during plugin activation. Selecting the tab for the first time mounts it and lazily calls `ctx.remote.pluginInventory.list()` through [`api-remotes`](../../api/remotes/README.md).
 
-The page renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the local Loader id as its title, a colored root-Fiber status dot, and a small effective-enablement tag. Expanding one card reveals its Loader-tree entry value without a redundant field label, followed by the effective configuration and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late Settings declaration, redeclaration, locale changes, and teardown without owning another global store.
+The tab renders a searchable two-column catalog of compact disclosure cards. Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id without a redundant field label, followed by the effective configuration and, for enabled entries, Cordis status. Disabled entries omit the redundant unmounted runtime state. The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. The registration uses `ctx.slots.inject()`, so it follows late tab declaration, redeclaration, locale changes, and teardown without importing the section owner.
 
 ## Model Experience
 
@@ -16,5 +16,5 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **One snapshot per mount or retry** — the page does not subscribe to Loader changes or automatically refetch after reconnect; reopening the section obtains a new snapshot.
+- **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one.
 - **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls.

+ 3 - 3
packages/client/ui-plugins/README.zh.md

@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-Web 设置中的只读“插件”分区。浏览器插件在“模型”之后注册一个 id 为 `plugin-inventory` 的本地化 `settings.section` 贡献,并由 Settings shell 提供常规的回退图标。插件激活期间不会读取 Remote;挂载该分区时,组件才通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。
+Web 设置中的只读**插件列表**标签页。浏览器插件注册一个 id 为 `all` 的本地化 `settings.plugins.tab` 贡献;“插件”分区拥有导航入口与标签栏。插件激活期间不会读取 Remote;首次选择该标签页时才挂载组件,并通过 [`api-remotes`](../../api/remotes/README.md) 懒调用 `ctx.remote.pluginInventory.list()`。
 
-页以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用 Loader 本地 id 作为标题,以彩色圆点表示根 Fiber 状态,以小标签表示有效启停状态。展开卡片后会直接展示 Loader 树条目值,不附加重复的字段标题,并列出有效配置状态与 Cordis 状态。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随 Settings 的延迟声明、重新声明、本地化变化与 teardown,而不拥有另一份全局 store
+该标签页以可搜索的双列紧凑折叠卡片展示清单。每张收起的卡片使用模块短名称作为标题,以小标签表示有效启停状态;已启用的条目还会以彩色圆点表示根 fiber 状态。展开卡片后会直接展示 Loader 树条目 id,不附加重复的字段标题,并列出有效配置状态;已启用的条目还会列出 Cordis 状态,已停用的条目则省略重复的“未挂载”运行状态。条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标;代码不按字符串形状对它分类。加载、空结果、无匹配结果与通用失败状态只属于已挂载组件;读取失败后可以重试,且不会暴露传输细节。注册使用 `ctx.slots.inject()`,因此能跟随标签 slot 的延迟声明、重新声明、本地化变化与 teardown,而无需 import 分区拥有方
 
 ## 模型体验
 
@@ -16,5 +16,5 @@ Web 设置中的只读“插件”分区。浏览器插件在“模型”之后
 
 ## 已知限制与暂缓事项
 
-- **每次挂载或重试只读取一份快照** —— 页不订阅 Loader 变化,也不会在重连后自动重新读取;重新打开分区会取得新快照。
+- **每次 Settings 挂载或重试只读取一份快照** —— 标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。
 - **只读 Loader 视图** —— 本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。

+ 1 - 1
packages/client/ui-plugins/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-plugins",
-  "description": "Read-only Cordis Loader plugin inventory in Web settings",
+  "description": "Read-only Cordis Loader inventory tab in Web Plugins settings",
   "version": "0.0.1-rc.2",
   "publishConfig": {
     "access": "restricted"

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است