Browse Source

Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-recovery

_Kerman 1 month ago
parent
commit
9deabe717f
100 changed files with 2593 additions and 1065 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml
  8. 6 6
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
  9. 6 6
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
  11. 9 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
  12. 9 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml
  14. 705 0
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
  15. 705 0
      .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md
  16. 6 0
      .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.i18n.yaml
  17. 80 0
      .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md
  18. 80 0
      .agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md
  19. 6 0
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml
  20. 33 0
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md
  21. 33 0
      .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md
  22. 2 2
      .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml
  23. 1 1
      .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
  24. 1 1
      .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md
  25. 1 1
      AGENTS.md
  26. 2 0
      THIRD_PARTY_NOTICES.md
  27. 2 3
      apps/cli/package.json
  28. 3 25
      apps/cli/src/profile-boot.ts
  29. 80 34
      apps/cli/tests/web-agent-presets.e2e.ts
  30. 3 2
      apps/cli/tests/windows-shell.spec.ts
  31. 9 7
      apps/web/src/preview.ts
  32. 4 6
      apps/web/tests/agent-preset-authoring.e2e.ts
  33. 5 9
      apps/web/tests/agent-preset-selection.e2e.ts
  34. 265 28
      apps/web/tests/preview-boot.e2e.ts
  35. 10 18
      apps/web/tests/scaffold.ts
  36. 5 7
      apps/web/tests/seeded-history.e2e.ts
  37. 15 0
      apps/web/tests/snapshots/preview-boot/source-chooser.expected.md
  38. 2 1
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  39. 2 2
      docs/config-catalog.i18n.yaml
  40. 11 3
      docs/config-catalog.md
  41. 11 3
      docs/config-catalog.zh.md
  42. 2 2
      docs/cookbook/adding-a-tool.i18n.yaml
  43. 7 1
      docs/cookbook/adding-a-tool.md
  44. 7 1
      docs/cookbook/adding-a-tool.zh.md
  45. 2 2
      docs/event-producer-consumer.i18n.yaml
  46. 5 5
      docs/event-producer-consumer.md
  47. 5 5
      docs/event-producer-consumer.zh.md
  48. 2 2
      docs/module-graph.i18n.yaml
  49. 2 4
      docs/module-graph.md
  50. 2 4
      docs/module-graph.zh.md
  51. 2 2
      docs/subsystems/client-modules.i18n.yaml
  52. 5 6
      docs/subsystems/client-modules.md
  53. 5 6
      docs/subsystems/client-modules.zh.md
  54. 2 2
      docs/subsystems/session.i18n.yaml
  55. 1 1
      docs/subsystems/session.md
  56. 1 1
      docs/subsystems/session.zh.md
  57. 1 1
      examples/acp-agent/tests/acp.snapshot.ts
  58. 2 1
      knip.json
  59. 1 1
      packages/api/remotes/src/client/index.ts
  60. 2 2
      packages/api/session-controller/README.i18n.yaml
  61. 2 0
      packages/api/session-controller/README.md
  62. 2 0
      packages/api/session-controller/README.zh.md
  63. 1 4
      packages/api/session-controller/package.json
  64. 0 8
      packages/api/session-controller/src/client/sessions/session.ts
  65. 11 138
      packages/api/session-controller/src/history.ts
  66. 1 2
      packages/api/session-controller/src/index.ts
  67. 1 19
      packages/api/session-controller/src/types.ts
  68. 5 0
      packages/api/session-controller/tests/controller.host.spec.ts
  69. 1 1
      packages/api/session-controller/tests/event-script.client.ts
  70. 62 170
      packages/api/session-controller/tests/session-history-journal.host.spec.ts
  71. 15 27
      packages/api/session-controller/tests/session.client.spec.ts
  72. 0 127
      packages/api/session-controller/tests/transport.host.spec.ts
  73. 0 1
      packages/api/session-controller/tsconfig.client.json
  74. 0 1
      packages/api/session-controller/tsconfig.host.json
  75. 7 10
      packages/bundle/web-app/cordis.patch.yml
  76. 1 1
      packages/client/AGENTS.md
  77. 2 4
      packages/client/connection/package.json
  78. 0 1
      packages/client/connection/src/client/api.ts
  79. 154 250
      packages/client/connection/src/client/fixture.ts
  80. 0 1
      packages/client/connection/src/client/index.ts
  81. 43 1
      packages/client/connection/tests/fixture.client.spec.ts
  82. 8 6
      packages/client/modules/src/client/manifest.ts
  83. 13 3
      packages/client/modules/src/client/system.ts
  84. 20 4
      packages/client/modules/tests/loader.client.spec.ts
  85. 3 8
      packages/client/ui-chat/src/client/conversation-nodes/tool.ts
  86. 2 3
      packages/client/ui-chat/src/client/details/DetailsPanel.tsx
  87. 2 3
      packages/client/ui-chat/src/client/model/tool-call-tree.ts
  88. 2 2
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  89. 4 7
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  90. 16 7
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  91. 9 6
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  92. 3 3
      packages/client/ui-chat/tests/tool-call-tree.client.spec.ts
  93. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  94. 1 1
      packages/client/ui-conversation/README.md
  95. 1 1
      packages/client/ui-conversation/README.zh.md
  96. 1 3
      packages/client/ui-conversation/src/client/contract/conversation.ts
  97. 4 9
      packages/client/ui-conversation/src/client/contract/records.ts
  98. 1 1
      packages/client/ui-conversation/src/client/conversation/assembler.ts
  99. 1 4
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  100. 6 0
      packages/client/ui-conversation/src/client/locales.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md
-2026-07-10-single-file-executable-sdk-runtime-distribution.md: 2a39409c7db2bf1de75843e3642ef27051ccfb17
-2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 0f4bb7cf3e1914c008ae23590a3a26fdb87f0842
+2026-07-10-single-file-executable-sdk-runtime-distribution.md: e5438fc01fdd634cd4d8535dfa37a1de356da04d
+2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 218133f077bed7584b6a26fea5f939b64d4fd670

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md

@@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D
 
 Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
 
-The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-sdk-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
+The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-sdk-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
 
 The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md

@@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后
 
 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
 
-部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-sdk-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
+部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-sdk-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
 
 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md
-2026-08-03-per-session-agent-presets.md: 9999d0125de87c43a8aa3b6b6b7c9bc90a81da77
-2026-08-03-per-session-agent-presets.zh.md: 514f2d40c95b608cb8512fd27a56ddba222d3173
+2026-08-03-per-session-agent-presets.md: b2195004580e1f4bf4be5527c61a8ee9b816c964
+2026-08-03-per-session-agent-presets.zh.md: 9f689c74dd570d6ec9aaa7cd1274618326a836dd

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md

@@ -23,7 +23,7 @@ Composition splits into two planes, decided by what must be shared rather than b
 
 Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane.
 
-The presets the deployment ships are the directories under `apps/cli/config/agent-presets/`; the roster is that listing, not a list restated here.
+The presets the deployment ships are the directories under `packages/preset/agent-presets/presets/`; the roster is that listing, not a list restated here.
 
 Mounting is per-session by default. Measured cost for a twelve-row composition is ~3ms and ~600KB per session, so isolation is the cheaper default than any sharing scheme, and a preset authored by a user or by an agent then has the smallest possible blast radius. A preset that genuinely owns an expensive singleton opts into sharing with Cordis's own `isolate` vocabulary: a named realm label is process-global, so two subtrees naming the same label resolve one instance.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md

@@ -23,7 +23,7 @@ Status: implemented
 
 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。
 
-部署交付哪些 preset,取决于 `apps/cli/config/agent-presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。
+部署交付哪些 preset,取决于 `packages/preset/agent-presets/presets/` 下有哪些目录;清单是那份目录列表,而不是在此另抄一份。
 
 挂载默认按会话进行。实测一份十二行组装每会话约 3ms、约 600KB,因此隔离比任何共享方案都更划算;而由用户或 agent 写出的 preset 也因此拥有尽可能小的影响面。确实自带昂贵单例的 preset,可以用 Cordis 自身的 `isolate` 词汇显式选择共享:命名 realm 的 label 是进程级全局的,因此两棵子树只要写同一个 label 就解析到同一个实例。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
-2026-08-20-webworker-node-face.md: 08119cce96eff244f8e9ada3462ce5d35c1b538d
-2026-08-20-webworker-node-face.zh.md: 573c0be055d066d2d6d0db11a2476ba528517727
+2026-08-20-webworker-node-face.md: 41a30dedc7df9a882fbc1d8d3e3583c0a3602d81
+2026-08-20-webworker-node-face.zh.md: b57481335808f3e1a764da123a11ea74ba6cf371

+ 6 - 6
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md

@@ -6,21 +6,21 @@ English | [中文](2026-08-20-webworker-node-face.zh.md)
 
 ## Problem
 
-The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool, which mounted, advertised itself to the model, and then failed on every call while `node:child_process` was a structural stub.
+The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool. A structural `node:child_process` stub would let that tool mount and advertise itself to the model while every call fails.
 
 ## Decision
 
-**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly.
+**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. Its `createRequire` face supplies both `resolve()` and `resolve.paths()` against the image's package root, allowing unchanged packages to discover manifests without loading targets. The worker installs the `process` global itself and fills it into the table at assembly.
 
-**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. The hunt that produced this also fixed the silence around it: cordis's logger verbosity counts UP, so an exporter that declares no level drops every warning — `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts.
+**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. Committed mutations also drive the [Node-compatible watcher and confinement implementation](2026-08-23-webworker-vfs-watch-and-landlock.md). Boot diagnostics remain visible because cordis logger verbosity counts UP: `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts, while an exporter with no declared level drops every warning.
 
-**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. The table is the machine's whole `/bin`; an absent name reports `command not found` (127). Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub.
+**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. Ordinary commands resolve from that table; native-package protocols may contribute Worker-owned virtual executable wrappers through the [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md). A name in neither set reports `ENOENT` at direct spawn or `command not found` (127) inside shell source. Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub.
 
 ## Alternatives considered
 
 **Replacing `dsh-subprocess-local` or the bash executor.** The first would let the proxy table replace a workspace package against its own classification and invert the layering; the second trips `dsh-permission-presets`' boot-time `sandboxMode` validation and drops tested timeout/output behavior.
 
-**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); WASM was removed from this deployment by decision and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused.
+**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); this deployment excludes WASM and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused.
 
 **`SharedArrayBuffer` + `Atomics.wait` for a synchronous child filesystem.** Measured on the deployment target: without COOP/COEP headers `SharedArrayBuffer` is not defined, and GitHub Pages cannot set response headers. The asynchronous face is a superset; a SAB backend can slot under it later without touching a program.
 
@@ -28,7 +28,7 @@ The worker runs the web profile's Cordis configuration byte for byte — no work
 
 ## Consequences
 
-- Sandbox modes other than `danger-full-access` fail loud: `SandboxEnforcement` has no "nothing was enforced" value and a browser has no kernel, so `ctx.sandbox.confine` fails closed and the command never starts. Real enforcement at the VFS frame gate is a designed follow-up, not this note.
+- `read-only` and `workspace-write` interpret the native Landlock launcher protocol and enforce per-process grants at the VFS frame gate; `danger-full-access` keeps the direct process path. The [watcher and confinement decision](2026-08-23-webworker-vfs-watch-and-landlock.md) owns the narrower meaning of `full` in this execution world.
 - The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`.
 - Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts.
 - The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites.

+ 6 - 6
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md

@@ -6,21 +6,21 @@
 
 ## 问题
 
-worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层——在 `node:child_process` 还是结构桩的时期,工具照常挂载、向模型自我宣告,然后每次调用都失败。
+worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层。如果 `node:child_process` 只是结构桩,工具仍会照常挂载并向模型自我宣告,但每次调用都会失败。
 
 ## 决定
 
-**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。
+**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。它的 `createRequire` 面在镜像 package 根之上同时提供 `resolve()` 与 `resolve.paths()`,使未修改的包无需加载目标即可发现 manifest。`process` 全局由 worker 自装,装配期填入表中。
 
-**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。这场排查同时修掉了它周围的静默:cordis 日志器的详细度数值向上计数,未声明等级的 exporter 会丢掉所有 warning——`startWorkerHost` 在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter。
+**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。已提交的 mutation 还会驱动 [Node 兼容 watcher 与 confinement 实现](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)。Cordis 日志器的详细度数值向上计数,因此 `startWorkerHost` 会在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter,避免未声明等级的 exporter 丢掉所有 warning。
 
-**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。命令表就是这台机器的全部 `/bin`;不存在的名字报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。
+**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。普通命令从该表解析;native 包协议可以通过 [watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)提供 Worker 自有的虚拟 executable wrapper。两处都没有的名字在直接 spawn 时报告 `ENOENT`,在 shell source 中则报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。
 
 ## 曾考虑的替代方案
 
 **整包替换 `dsh-subprocess-local` 或替换 bash 执行器。** 前者让代理表首次替换 workspace 包、违背其自身分类并倒置分层;后者撞上 `dsh-permission-presets` 对 `sandboxMode` 的 boot 期硬校验,并丢掉执行器已被测试钉住的超时/输出行为。
 
-**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);WASM 已被本部署的决定排除,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。
+**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);本部署排除 WASM,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。
 
 **`SharedArrayBuffer` + `Atomics.wait` 给子进程同步文件系统。** 在部署目标实测:无 COOP/COEP 头时 `SharedArrayBuffer` 未定义,而 GitHub Pages 无法设置响应头。异步面是超集;SAB 后端将来可垫入其下而不动任何程序。
 
@@ -28,7 +28,7 @@ worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属
 
 ## 后果
 
-- `danger-full-access` 之外的沙箱档 fail loud:`SandboxEnforcement` 没有「未执法」值、浏览器没有内核,`ctx.sandbox.confine` 落闭、命令零启动。在 VFS 帧闸口做真执法是设计中的后续,不属本条。
+- `read-only` 与 `workspace-write` 解释 native Landlock launcher 协议,并在 VFS 帧闸口执行逐进程授权;`danger-full-access` 保持直接进程路径。[Watcher 与 confinement 决策](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)拥有该执行世界中 `full` 的更窄含义。
 - Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。
 - 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。
 - 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
-2026-08-20-webworker-pack-lowering-and-preview.md: 37c7730c5da664560156a8e4bd9ba594c78369ee
-2026-08-20-webworker-pack-lowering-and-preview.zh.md: 09d9356ee8744ba2406bd765a6ca6a060f93853a
+2026-08-20-webworker-pack-lowering-and-preview.md: 72a6ccf856c95f835e103bd355223bf3cf42f692
+2026-08-20-webworker-pack-lowering-and-preview.zh.md: 074d44833c34a2999a5c47da809533065201b580

+ 9 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md

@@ -14,6 +14,8 @@ The browser worker can neither compile modules at load nor be served by the prod
 
 **The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
 
+**The repository preview carries selectable filesystem sources.** The packer emits one base image and a small overlay archive for each named built-in fixture. Without a source query, `preview.html` waits at a chooser for an empty filesystem, the built-in fixtures, or the separately owned WebFS provider. A valid `preview-fixture=none|<built-in-id>` query selects directly and skips the chooser for deterministic browser runs; its distinct name avoids the Client's existing `fixture` transport switch. The Worker mounts the base and then applies the selected overlays in order, restricted to `home/` and `workspace/`, before it validates the base manifest or boots Cordis. `packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` supplies one built-in overlay without giving the packer Session or Workspace knowledge. Its plaintext JSONL logs use the persistence backend's real project/session directory layout, so Session Persistence reads them cold and Workspace Registry derives the Workspace from their `/dsh/workspace` headers. The main Session exceeds the Client's 50-message page and keeps representative tool results at its tail; persisted one-shot and continuable children exercise the subagent catalog. WebFS authorization and user data remain a separate provider and never share this fixture tree.
+
 Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`.
 
 ## Alternatives considered
@@ -26,6 +28,12 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental
 
 **Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering.
 
+**Generate example state in the Worker at startup.** A preview-only Session or Workspace creation branch would bypass cold persistence loading and make the runtime own test data. Static image files exercise the same discovery and pagination path as existing user data.
+
+**Seed the example through WebFS.** WebFS owns user-selected durable storage and its lifecycle. Coupling the built-in demonstration to it would make a static preview depend on browser persistence state and would blur which bytes came from the deployment.
+
+**Pack one complete base image per fixture.** Full-image variants duplicate the runtime package closure and make combinations quadratic. Restricted overlays keep one immutable base, let the chooser compose zero or more data sources, and give future WebFS hydration the same pre-boot application point.
+
 ## Consequences
 
 - `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed).
@@ -33,3 +41,4 @@ Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental
 - The transform corpus imports every built bundle through Node before comparing its lowered exports. Its pinned exemptions name the actual non-importable bundle and fail when one becomes importable: after Win32 process primitives became the Koffi type owner, `win32-process` carries the duplicate-type exemption and `sandbox-windows-acl` does not.
 - The served `<base href="/">` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base.
 - The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads.
+- The preview waits at a pre-boot source chooser. Its built-in example opens a reproducible Workspace and cold Session corpus suitable for inspecting tool cards, subagent navigation, and backward pagination without credentials or model calls; the empty selection preserves first-run coverage. Fixture tests validate the physical logs through production readers, and browser acceptance verifies both selections.

+ 9 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md

@@ -14,6 +14,8 @@
 
 **preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
 
+**仓库 preview 携带可选择的文件系统来源。** Packer 产出一份基础镜像,并为每套具名内置 fixture 产出一份小型 overlay 归档。没有来源 query 时,`preview.html` 会停在选择面板,可选择空文件系统、内置 fixtures,或归另一实现所有的 WebFS provider。合法的 `preview-fixture=none|<built-in-id>` query 会直接选择并跳过面板,供确定性的浏览器流程使用;该独立名称避开 Client 既有的 `fixture` transport 开关。Worker 先挂载基础镜像,再按顺序把所选 overlays 应用到仅限 `home/` 和 `workspace/` 的路径,随后才校验基础 manifest 并启动 Cordis。`packages/experimental/webworker-runtime/tests/fixtures/vfs-example/` 提供其中一套内置 overlay,Packer 无需理解 Session 或 Workspace。明文 JSONL 日志使用 persistence backend 的真实 project/session 目录布局,因此 Session Persistence 会冷读取它们,Workspace Registry 则根据其 `/dsh/workspace` header 派生 Workspace。主 Session 超过 Client 的 50-message page,并把代表性工具结果留在尾页;持久化的 one-shot 与 continuable child 用于验证 subagent catalog。WebFS 授权与用户数据仍属于独立 provider,绝不与该 fixture 共用目录。
+
 两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。
 
 ## 曾考虑的替代方案
@@ -26,6 +28,12 @@
 
 **用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。
 
+**在 Worker 启动时生成示例状态。** Preview 专用的 Session 或 Workspace 创建分支会绕过冷 persistence 读取,还会让 runtime 拥有测试数据。静态镜像文件与既有用户数据经过相同的发现和分页路径。
+
+**通过 WebFS 注入示例。** WebFS 拥有用户选定的 durable storage 及其生命周期。让内置演示依赖它,会使静态 preview 受浏览器持久化状态影响,并模糊哪些字节来自部署。
+
+**每套 fixture 各打一份完整基础镜像。** 完整镜像变体会重复 runtime package closure,并使组合数量平方增长。受限 overlay 只保留一个不可变基础镜像,选择面板可组合零到多个数据源,未来 WebFS 水合也能复用同一个 pre-boot 应用点。
+
 ## 后果
 
 - `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。
@@ -33,3 +41,4 @@
 - 转换 corpus 会先通过 Node 导入每个已构建 bundle,再比较 lowered export。固定豁免会点名真正不可导入的 bundle,并在其恢复可导入时失败:`win32-process` 是 Koffi 类型 owner 并承担重复类型豁免;`sandbox-windows-acl` 可正常导入,不承担该豁免。
 - served 的 `<base href="/">` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
 - 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。
+- Preview 会停在 pre-boot 来源选择面板。内置示例提供可复现的 Workspace 与冷 Session 语料,无凭据、零模型调用即可检查工具卡、subagent 导航和向前分页;空白选项保留首次启动覆盖。Fixture 测试通过生产 reader 校验物理日志,浏览器验收同时验证两种选择。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.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-23-client-derived-tool-presentation.md
+2026-08-23-client-derived-tool-presentation.md: 5598c1fbc6073a71f63a20274cd5a791b6b5e6b3
+2026-08-23-client-derived-tool-presentation.zh.md: 5a87433377af58b1ca9d378b76393dada4ca4a1e

+ 705 - 0
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md

@@ -0,0 +1,705 @@
+# Agent Note: Client-Derived Presentation from Raw Session Tool Events
+
+Status: implemented
+
+English | [中文](2026-08-23-client-derived-tool-presentation.zh.md)
+
+## Problem
+
+Session history is a durable journal interface, while tool cards are Client presentation. Computing card views during `page` or `follow` would couple history reads to the Tools registry, Agent presets, restored scopes, presenter execution, and transient UI types.
+
+A `tool/result` does not repeat the tool name or arguments. Host-side result presentation therefore requires either a call index or a backward scan by `callId`; repeated scans over a tool-dense page can approach quadratic work because `maxMessages` does not directly bound the event count.
+
+Host projection would also duplicate structured data. Read, diff, search, and web results already persist bounded facts in `tool/result.data.meta`; another view object increases Remote payload size and Client decoding without adding durable meaning.
+
+The Client already owns a complete tool-presentation entry point. `ui-chat` assembles `tool/call`, `tool/result`, and Code Dispatch events into stable `ToolCallBlock` values. `ui-tool` owns the recursive call tree, the `tool.call.toolview` keyed slot dispatched by tool name, the Generic fallback, card models, and details output. A business Client plugin can register a renderer for its own tool names.
+
+Splitting presentation between Host presenters and Client keyed renderers creates two interpretations of the same event. The keyed renderer is the Web extension point, so an intermediate Host view provides no independent Web capability.
+
+`ToolDefinition.presentCall` and `presentResult` remain useful Host APIs even though ACP is automation-only and the repository has no production TUI consumer. Removing their definitions is a separate decision from keeping Session reads independent of presentation.
+
+The required result is one raw Session journal and one Client presentation owner without visual degradation or incidental enhancement. Specialized cards, interactions, and Code Dispatch topology remain stable while the transport stops carrying transient views.
+
+## Decision
+
+The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view.
+
+The Client Conversation layer continues to own tool call/result identity, pairing, lifecycle, Code Dispatch topology, and stable Chat Nodes. It does not interpret individual tool names or produce terminal, diff, read, search, or web component props.
+
+Client `ui-tool` continues to own card models and concrete renderers. Each card model directly reads the tool name, raw arguments, result content, error, durable metadata, Session cwd, and Host home from `ToolCallBlock`, and produces the same component props as the current page.
+
+The Client has no second presenter registry. Tool-name dispatch uses only the existing `tool.call.toolview` keyed slot. Pure Client card-model helpers are renderer implementation details, not a Cordis service, public registry, or wire DTO.
+
+The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCallView`, `ToolResultView`, and existing tool presenter implementations remain. The Session Controller does not invoke them, and the Client does not import or consume them. A future non-Client consumer is outside this decision.
+
+`ToolOutputDefinition.presentationMeta` and durable `tool/result.data.meta` remain. They carry execution-result facts required by existing specialized cards that the model-visible result text cannot represent losslessly. The Client validates and consumes `meta` directly rather than requiring the Host to convert it into a view during history reads.
+
+### Goals and non-goals
+
+| Category | Decision |
+|---|---|
+| Absent | `SessionEventEntry.view`, `SessionToolView`, and `SessionToolCallView` |
+| Absent | `viewFor`, `backscanArgs`, `parseToolCall`, `jsonView`, and presenter-scope lookup from `history.ts` |
+| Absent | `openCalls` and fallback event scans used only for follow presentation |
+| Absent | the Client Session's parallel `views` array, Conversation input `view`, and Tool block `callView`/`resultView` |
+| Derived | terminal, diff, read, search, and web card models read raw blocks and metadata |
+| Derived | Deliverables reads successful mutation names and arguments |
+| Retained | Host `ToolDefinition.presentCall`/`presentResult` APIs, types, implementations, and direct tests |
+| Retained | `output.presentationMeta` and durable `tool/result.data.meta` |
+| Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology |
+| Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure |
+| Forbidden | a new Client presenter service, parallel registry, or wire renderer id |
+| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements |
+| Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field |
+
+## Terminology
+
+**Raw Session event** means a `SessionEvent` fact from the durable log, including the `name` and raw `arguments` string on `tool/call`, and the `content`, `isError`, structured error, and optional `meta` on `tool/result`.
+
+**Durable metadata** means the JSON value produced by `ToolOutputDefinition.presentationMeta` after a tool succeeds and stored in `tool/result.data.meta`. It is part of the result facts, not a pre-laid-out React or card DTO.
+
+**Host tool view** means the `ToolCallView` or `ToolResultView` returned by `ToolDefinition.presentCall` or `presentResult`. Session Remote does not transport it.
+
+**Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`.
+
+**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row.
+
+**Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls.
+
+**No enhancement** means that this decision does not give an input pinned to Generic fallback a new specialized card or expand an existing card's data or interactions.
+
+## Architecture and Ownership
+
+### Tool execution and persistence
+
+1. A tool registers `output.schema`, `output.render`, and optional `output.presentationMeta`.
+2. Successful execution produces a canonical JSON value.
+3. The Tools runtime snapshots, schema-validates, and freezes the value.
+4. `output.render(args, value)` produces model-visible `ContentBlock[]`.
+5. When a top-level call declares `output.presentationMeta`, the runtime also produces JSON-safe metadata.
+6. The agent loop writes the model-visible result and metadata into a `tool/result` Session event.
+7. The Session log does not store `ToolCallView` or `ToolResultView`.
+
+### Host journal reads
+
+1. `session.page` obtains attached or persisted events.
+2. `paginate()` cuts pages on append-origin user/assistant message boundaries.
+3. A tail page obtains its baseline from the registered projection snapshot/restore path.
+4. Every page entry contains only `{event}`.
+5. `session.follow` establishes its listener before catch-up reads, emits the opening cursor, and then streams contiguous `{event}` frames.
+6. Neither path resolves a preset or Tools scope for presentation, parses tool arguments, invokes presenters, or indexes calls.
+
+### Client data and presentation
+
+1. The Client Session stores one contiguous raw event window.
+2. `SessionEventSource` publishes `SessionEventEntry` values containing only events.
+3. `ui-conversation` folds each event without a presentation companion.
+4. The Chat and Trajectory Tool Definitions pair top-level calls and results by callId and assemble Code Dispatch subtrees.
+5. `RunningToolCall` and `ToolResultNode` retain raw facts, metadata, and existing parent identity.
+6. `ToolCallTree` dispatches `tool.call.toolview` by wire tool name.
+7. `ui-tool` derives card component props from the block at the render site.
+
+### Production consumer audit
+
+| Object | Producer | Production consumer | Decision |
+|---|---|---|---|
+| `presentCall`/`presentResult` | Host tools | non-Client callers, if any | retained outside Session Remote |
+| `SessionEventEntry.view` | none | none | absent from the wire |
+| `callView`/`resultView` | none | none | absent from the Client model |
+| `presentationMeta` | Tools runtime | `tool/result`, Client card models, and Host presenters | retained durable input |
+| fixture presenter mirror | none | none | fixtures send raw metadata |
+
+ACP does not consume a Session tool view or map Host render intent. The repository has no production TUI consumer. Host presenters remain available without making Session Remote their transport.
+
+## Data Flow
+
+```text
+Tool execute
+  -> canonical value
+  -> output.render(args, value)
+  -> model-visible result content
+  -> output.presentationMeta(args, value), when declared
+  -> durable tool/result event
+
+Session page/follow
+  -> raw Session event envelope
+  -> no tool lookup
+  -> no preset lookup for presentation
+  -> no call backscan
+  -> no render-intent serialization
+
+Client SessionEventSource
+  -> Conversation Tool Definition
+  -> root call/result pairing + Code Dispatch topology
+  -> ToolCallBlock(name, argsRaw, content, error, meta)
+  -> tool.call.toolview keyed dispatch
+  -> Client card model
+  -> existing React component
+```
+
+This path retains one durable metadata projection because it runs while the canonical result is still in memory. It removes the second presentation projection performed while reading history.
+
+### Layer responsibilities
+
+| Layer | Owns | Does not own |
+|---|---|---|
+| Tools runtime | execution, canonical value, model text, replayable metadata | Web card selection and component props |
+| Session log | durable facts, ordering, replay | transient card DTOs |
+| Session Controller | addressing, authority, cold reads, pagination, follow, projection baseline | tool lookup, presenters, presentation scope |
+| Client Session | Remote journal lifecycle and contiguous window | tool meaning and card types |
+| Conversation Tool Definition | call/result pairing, lifecycle, root/subcall topology | mapping a tool name to a component |
+| `ui-tool` | card models, Generic fallback, Chat/Details presentation | Session pagination and the Host registry |
+| Business Client plugin | keyed renderer for its own tool name | root/subcall assembly and a global registry |
+| `ui-deliverables` | produced paths for current first-party mutations | UI cards or Host render intent |
+
+## Remote and Durable Data Contracts
+
+### `SessionEventEntry`
+
+`SessionEventEntry` remains the journal-entry envelope and contains only `event: SessionWireEvent`. This change does not also turn page entries into bare events or refactor the general `RemoteJournalStream` entry contract.
+
+`SessionPage.events` remains `SessionEventEntry[]`.
+
+`SessionFollowFrame` remains either an opening frame or an event frame containing `event`.
+
+`SessionToolCallView`, `SessionToolView`, and `SessionEventEntry.view` are deleted.
+
+The Client connection stops re-exporting `ToolCallView` and `ToolResultView` from `dsh-tools/presentation` for Session consumers.
+
+Generated catalogs and graphs derive the narrowed Remote types and package dependencies from their owning sources.
+
+### Durable log
+
+- `tool/call.data.name` remains unchanged.
+- `tool/call.data.arguments` remains the model-produced raw JSON string.
+- `tool/result.data.message.content` remains the model-visible result.
+- `tool/result.data.error` remains the structured failure identity.
+- `tool/result.data.meta` remains a tool-private JSON value.
+- Client card models do not write to the Session log.
+- Renderer keys and Host tool implementation ids do not enter the Session log.
+- Existing durable Sessions need no migration, and `SESSION_FORMAT_VERSION` does not change.
+
+### `presentationMeta`
+
+`presentationMeta` is not a Host tool view. It reads the canonical value when tool execution completes, and that value is not persisted. Removing it would make the following existing presentation impossible to reconstruct losslessly:
+
+- read path, offset, lines, totalLines, and lang;
+- applied contextual hunks for write/edit;
+- grouped grep/glob results, truncation flag, and total;
+- web_search source fields and provider answer;
+- web_fetch final URL, HTTP status, and effective truncation flag.
+
+The Client narrows `meta` locally at runtime. Renaming `presentationMeta` to more neutral result metadata is outside this decision.
+
+## Host Design
+
+After obtaining source events, `SessionHistoryController.page()` performs only pagination and the existing projection-baseline calculation. Attached Sessions use the projection registry snapshot; detached Sessions use its restore path over the inspected log. History does not mount a preset to change the registered projection set.
+
+`SessionHistoryController.follow()` retains listener-first setup, opening cursors, gap-free replay, live buffering, cancellation, and teardown. It maintains no additional state for tool events.
+
+The controller has no `presenterScopeFor()`, `viewFor()`, `backscanArgs()`, `parseToolCall()`, or `jsonView()` path. Page state contains no presenter scope or argument resolver; follow state contains no `openCalls`, `fallbackEvents`, or presentation argument resolver. Each page/follow event is wrapped only as `{event}` while addressing, ownership, cursor, sequence, and projection logic remains intact.
+
+An immutable event-conversion helper may remain narrow or be inlined; its name is irrelevant as long as history performs no presentation work.
+
+Session Controller dependencies remain only when another package responsibility requires them. Manifest and project references contain no presentation-only dependency.
+
+### Performance constraints
+
+- `page()` performs no tool-specific work.
+- Adding tool results to a page does not cause repeated scans over existing page events.
+- `follow()` maintains no presentation index.
+- History does not trigger the Cordis `tools` service proxy.
+- History does not wait for a presenter standing scope.
+- History does not parse tool-argument JSON.
+- History does not perform tool-view JSON clones.
+- The Remote payload does not repeat structured data already expressed by `meta`.
+- The Client does not scan the complete Session event window to build one card.
+- The Client derives a card model again only when the corresponding immutable Tool block changes.
+
+## Client Session and Conversation
+
+The Client Session has no private `views` array parallel to the raw event window. `installWindow()`, `prependWindow()`, and `appendLive()` handle only event entries, cursor/hasMore state, queues, projection, and notifications.
+
+`ConversationEventInput` contains only `event`. The Conversation assembler does not know `SessionToolView`; its replace/prepend/append behavior, Context identity, Location, and publication cadence remain unchanged.
+
+The Chat and Trajectory Tool Definitions read no views. They derive the following data from events:
+
+- callId;
+- tool name;
+- raw arguments;
+- turn, step, seq, and time;
+- result content;
+- isError and structured error;
+- result metadata;
+- root/subcall parent-child topology;
+- synthetic interruption results.
+
+`RunningToolCall` has no `callView`.
+
+`ToolResultNode` has no `callView` or `resultView`.
+
+`ToolCallBlock` does not gain a generic `view`, `card`, `kind`, or `locations` field to replace the deleted fields. Concrete presentation remains the responsibility of `ui-tool` and keyed renderers.
+
+### Root and Code Dispatch subcalls
+
+Host presenter APIs describe top-level calls and results. Code Dispatch subcalls use the Generic, flattened Client presentation; recognizing a subcall name does not grant it a structured card.
+
+Code Dispatch start and result events already carry `parentCallId`. Conversation preserves that existing fact on each child `ToolCallBlock`; root Session calls omit it. The five structured card models accept only blocks without `parentCallId`, while existing renderers that intentionally support nested calls continue receiving the same child block.
+
+The Details panel delegates the selected block unchanged. The same card models observe `parentCallId` and keep a selected Code Dispatch child on the existing raw fallback, so the Details slot needs no placement field.
+
+The keyed slot continues dispatching every subcall by its real tool name. `parentCallId` controls only the terminal, diff, read, search, and web structured models covered by this decision. Existing specialized renderers such as Skill and Cordis, which already read raw blocks, remain unchanged.
+
+### Missing call head
+
+When a result node has no matching call in the current window, `ToolResultNode.call` remains `null`. The Client does not scan the window, issue another RPC, or infer a tool name from result text.
+
+A specialized derivation that needs the name or arguments uses the current Generic fallback when `call === null`. A model that could use result metadata alone does not gain new presentation, because the current Host `presentResult` must first recover the matching call.
+
+If a later older page supplies the call head, the Conversation Context rebuilds under existing replay rules and may then produce the already-supported specialized card.
+
+### Argument and metadata narrowing
+
+The Client parses JSON from `argsRaw`; a parse failure returns the Generic form instead of throwing a React render error.
+
+Chat and Details reuse parsing for the same block through pure helpers. Any future cache must use immutable block identity and must not create cross-Session global state keyed by callId.
+
+Each specialized model checks only the fields it needs. The Client does not copy complete Host tool schemas or invoke a Host `defineTool` validator.
+
+Valid first-party events must be equivalent to current presenter output. Malformed, old-version, or manually edited logs promise only a crash-free Generic fallback.
+
+## Client Card-Model Design
+
+The existing `ui-tool/src/client/tool/models/` directory remains the single source of shared derivation for Chat and Details. Helpers return component props directly; they do not return `ToolCallView` or `ToolResultView`, and they do not create an isomorphic `ClientToolView` union.
+
+Branches on tool name exist only in `ui-tool` card models, existing row-classification tables, or the Client plugin that owns a keyed renderer for that tool. They must not enter the Session Controller, Client Session, Conversation assembler, or generic Slot renderer.
+
+Unknown tools continue to use `GenericToolCard` with the name, raw arguments, result content, and error.
+
+### Generic tool row
+
+`toolRowModel()` derives the generic row directly from `toolName`, `argsRaw`, result content, error, cwd, and home. It preserves:
+
+- classification into `search`, `read`, `bash`, `write`, `edit`, `code`, and `others`;
+- existing titles and tool-specific titles;
+- summary-field priority and single-line truncation;
+- comma joining of multiple queries;
+- cwd-relative paths and home abbreviation;
+- file-path clicks;
+- pretty JSON arguments and non-JSON raw-text fallback;
+- flattened result content and structured-error fallback;
+- running, ok, error, and stopped states.
+
+The title, kind, rawInput, content, and locations from Generic Host `presentCall` do not currently drive an ordinary Web row. Generic `presentResult.content` also does not drive Web output, so the Client need not copy these unconsumed values.
+
+### Terminal card
+
+The Client terminal model derives existing `TerminalBlock` props from the tool name, call arguments, result content, error, existing `parentCallId`, and Session cwd.
+
+| Input | Preserved result |
+|---|---|
+| running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state |
+| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot |
+| `run_in_background:true` | Generic row and raw result |
+| tool execution error | Generic IN/OUT and error summary |
+| running persistent `bash`/`pwsh` | terminal prompt |
+| settled persistent `bash`/`pwsh` | Generic flattened result, with no new exit card |
+| foreground `terminal_send` | terminal prompt and output |
+| background/error `terminal_send` | Generic result |
+| Code Dispatch child | current flattened Generic form |
+
+Standard shell results continue parsing trailing `[exit code: N]` and `[killed by signal: X]` markers. A parsed marker is removed from the body; timeout, sandbox denial, and markers without a pill remain in the body.
+
+Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
+
+For `terminal_send`, non-empty input and the session id remain verbatim tool data; the empty-input fallback and session label resolve through the render site's conversation locale.
+
+Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation.
+
+`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
+
+### Diff card
+
+| Input | Preserved result |
+|---|---|
+| running `write` | intended added-only diff from `file_path` and `content` |
+| running `edit` | intended replacement diff from `file_path`, `old_string`, and `new_string` |
+| running `str_replace_editor create` | intended added-only diff from `path` and `file_text` |
+| running `str_replace_editor str_replace` | intended replacement diff from `path`, `old_str`, and `new_str` |
+| successful settled `write`/`edit` | applied contextual hunks from `meta.diffs` |
+| settled `str_replace_editor` | Generic, because the tool defines no result presenter |
+| write create or missing/malformed/empty applied metadata | current argument fallback |
+| error, malformed arguments, edit with malformed metadata, or Code Dispatch child | Generic |
+
+Paths, `oldText:null`, `newText`, result-over-call diff precedence, the eight-line Chat limit, full-height Details presentation, and file-opening behavior remain unchanged.
+
+### Read card
+
+A running `read` continues to show only the summary row. A successful settled `read` reads path, offset, lines, totalLines, and lang from result metadata and confirms that the result is one text block matching the read envelope.
+
+Missing metadata, malformed fields, a mismatched result envelope, an error, a missing call head, or a Code Dispatch child all use Generic. Cwd-relative path labels, home abbreviation, syntax language, total line count, the eight-line Chat limit, and full-height Details presentation remain unchanged.
+
+The Client does not need to construct Host `ReadResultView.content`; Generic fallback can always read raw result content directly.
+
+### Search card
+
+A running `grep` or `glob` continues to show only the argument summary. Successful results produce grouped matches or a path list from `meta.shape:'matches'` and `meta.shape:'paths'`, respectively.
+
+The Client validates path, lineNumber, line, truncated, and total. Empty matches or paths form a valid card. Missing or malformed metadata, an unknown shape, an error, a missing call head, or a Code Dispatch child uses Generic.
+
+When `truncated:true`, the card continues to show a recovery locator from raw result content. It does not show one when untruncated. The eight-line Chat limit, full-height Details presentation, and expansion behavior remain unchanged.
+
+### Web card
+
+A running `web_search` or `web_fetch` continues to show only the summary row. A successful search builds the card from `meta.sources`, `meta.answer`, and `meta.truncated`; a successful fetch builds it from `meta.url`, `meta.statusCode`, and `meta.truncated`.
+
+The Client validates every source's url, title, snippet, and publishedAt, and continues rendering only http/https URLs as links. Missing or malformed metadata, an error, a missing call head, or a Code Dispatch child uses Generic.
+
+Search answer text, source ordering, label fallback, and truncation notice remain unchanged. The fetch final URL, status, truncation notice, and raw body below Details remain unchanged.
+
+### Renderers already using raw blocks
+
+- Todo rows continue deriving completed/active summaries from arguments.
+- Question rows continue deriving waiting, answered, cancelled, and interrupted states from result content and errors.
+- Skill rows continue deriving names and states from calls and results.
+- Cordis define/run/action rows continue deriving from calls, results, and their own Client services.
+- These renderers retain their props, slot keys, registration order, and visible results.
+
+## Deliverables
+
+`ui-deliverables` derives mutation business facts independently of presentation intent, so produced-file behavior is not coupled to card screenshots.
+
+The Deliverables Definition observes root `tool/call` and successful `tool/result` events by callId and retains a minimal Client-owned mutation candidate without scanning the Session window or depending on a UI renderer.
+
+| Tool | Mutation condition | Path source |
+|---|---|---|
+| `write` | any successful call | `file_path` |
+| `edit` | any successful call | `file_path` |
+| `str_replace_editor` | `create`, `str_replace`, or `insert` | `path` |
+| `str_replace_editor` | `view` | produces no path |
+| Other | no current first-party mutation semantics | produces no path |
+
+Failures, interruptions, orphan results, missing paths, and malformed arguments produce no deliverable. Paths retain first-seen deduplication, and results settled after the closing Assistant seq remain excluded.
+
+This change does not add a general tool-side-effect registry. The ability for a Host-only third-party presenter to join Deliverables automatically through `kind:'edit'` or `locations` is intentionally removed. A future real third-party mutation requirement must use a Client business contribution and cannot restore Session views.
+
+## Fixtures and Test Data
+
+The Client fixture deletes its handwritten `presentCall()`, `presentResult()`, `viewFor()`, and fixture tool-view types. It continues producing the same raw calls, result content, and result metadata as a real log.
+
+| Fixture | Raw facts that must remain |
+|---|---|
+| terminal | arguments and real result status markers |
+| diff | arguments and result `meta.diffs` |
+| read | result metadata path/offset/lines/totalLines/lang |
+| grep/glob | result metadata shape/files or paths/truncated/total |
+| web | result metadata sources/answer or url/statusCode/truncated |
+| generic/custom | name, argsRaw, content, and error |
+
+The fixture does not import Host tool packages to compute page presentation and retains no presenter mirror. The same raw fixture continues to drive jsdom, built Web snapshots, and the `?fixture` browser path.
+
+## Presentation-Equivalence Matrix
+
+“Current presentation” is defined by committed component tests, assembly tests, and Web browser expected outputs. A transport or ownership refactor does not justify refreshing snapshots; an approved product change requires separate evidence.
+
+| Scenario | Required presentation |
+|---|---|
+| unknown tool, running | Generic row with tool name and argument summary |
+| unknown tool, settled | Generic row and raw output |
+| malformed arguments | safe Generic fallback |
+| orphan result | callId title and Generic output |
+| interrupted call | warning/stopped state |
+| foreground bash/pwsh | current terminal prompt, body, cwd, and state |
+| background/error bash/pwsh | current Generic IN/OUT |
+| persistent shell | current running terminal and settled Generic form |
+| terminal_send | current foreground terminal and background/error Generic form |
+| write/edit | current intended/applied diff and error fallback |
+| read | current running summary, settled ReadBlock, and error fallback |
+| grep/glob | current grouped/path card, truncation, and recovery |
+| web_search/web_fetch | current source/summary card and raw body |
+| Todo/Question/Skill/Cordis | current specialized rows |
+| Code Dispatch subcall | current Generic/flattened form |
+| Chat and Details | identical card fields for the same call |
+| Trajectory | current identity, tree, selection, and details |
+| Deliverables | current successful-mutation chips and links |
+
+## Client Extension Contract
+
+`tool.call.toolview` remains the sole tool UI registration mechanism. A tool that needs specialized Client presentation must have a Client plugin register its wire tool name.
+
+The registrant receives the raw `ToolCallBlock`, Session path information, and host actions, and validates the argument and metadata fields it recognizes. It does not call the Host tool registry, depend on `presentCall` or `presentResult`, or require `SessionEventEntry.view`.
+
+A tool with no Client renderer consistently degrades to Generic. Only one keyed registration for a tool name can be active, and duplicate keys continue to fail loudly.
+
+A Session-scoped slot can express Client-side Session differences, but no renderer variant is inferred from a preset. A Host-only presenter does not grant a Web rich card automatically. This is the explicit boundary between “the Host describes presentation” and “the Client plugin owns presentation.”
+
+## Failures and Fallback
+
+- The Client treats arguments and metadata as wire JSON and narrows them at the consumption site.
+- Argument JSON parse failure uses Generic.
+- A known tool missing required fields uses Generic.
+- Missing or malformed metadata uses Generic, except successful `write`, whose current presenter preserves its argument-derived whole-file diff.
+- An error result does not show a success card merely because metadata is present.
+- A missing call head does not trigger guesses about the tool name or arguments.
+- Unknown metadata fields are ignored.
+- A new metadata variant uses Generic in an older Client.
+- Card-model helpers catch expected parse failures instead of relying on a React error boundary for ordinary fallback.
+- Unexpected failures inside a keyed renderer remain isolated by existing Slot error handling.
+
+## Same-Named Host Providers
+
+The Host registry allows different scopes to provide different definitions under the same tool name. Through presenter scope, a Session view can theoretically select a different render intent by preset. After removing the view, the Client keyed slot observes only the wire name and cannot observe Host definition identity.
+
+The notable current first-party examples are standard and persistent `bash` and `pwsh`. Client derivation uses valid argument and result features to preserve their delivered differences without a provider-id wire field. Malformed or custom same-name provider input that cannot be distinguished uses Generic.
+
+This change does not promise to preserve differences expressed only through a Host presenter by third-party same-name providers. If the product later requires distinct Client presentation for same-name providers, it must define a stable, non-presentational Client identity and must not restore per-page Host view computation.
+
+## Shipped Scope
+
+### Session Controller
+
+- `SessionEventEntry` contains only the raw event.
+- Both Session tool-view types are absent.
+- History has no presentation imports, helpers, or page/follow presentation state.
+- Addressing, pagination, follow, and projection logic remain in the Session owner.
+- Host tests assert the raw journal contract.
+
+### Session Controller Client
+
+- `Session.views` is absent.
+- EventSource replace/prepend/append deltas remain unchanged.
+- Transport, fixture, and test-support types carry raw entries.
+- Event identity and reference stability remain unchanged.
+
+### UI Conversation, Chat, and Trajectory
+
+- Conversation input and Tool blocks contain no view fields.
+- Chat and Trajectory Tool Definitions read raw events.
+- Event pairing, Context replay, trees, and target snapshots remain unchanged.
+- Child Tool blocks preserve the existing Code Dispatch `parentCallId`; row and Details slot owner props add no separate placement field.
+
+### UI Tool and Deliverables
+
+- Card models derive from raw blocks and metadata.
+- Chat and Details share the same helpers.
+- Generic fallback and keyed dispatch remain unchanged.
+- Deliverables recognizes first-party mutation arguments.
+
+### Fixtures, documentation, and generated artifacts
+
+- Fixtures send only raw events and metadata.
+- Session Controller and Client README/JSDoc contracts describe the raw journal and Client presentation owner.
+- The tool cookbook documents the Web Client integration path.
+- This Agent Note is the decision owner; retained Host presenter notes keep their independent decisions.
+- Authored Remote types, dependencies, READMEs, pairing records, and generated references remain synchronized.
+
+## Verification Matrix
+
+### Host
+
+- page returns contiguous raw event entries.
+- follow returns an opening cursor and contiguous raw event entries.
+- page/follow behave identically without the Tools service.
+- A cold page does not resolve or mount a preset.
+- A tail page computes its baseline through the standard projection registry; provider availability follows the projection composition rather than a history-side setup path.
+- Addressing, ownership, message-aligned boundaries, and tail projection remain unchanged.
+- Listener-before-read, reconnect catch-up, and gap repair remain unchanged.
+- Many tool results do not trigger a backscan per result.
+- Wire results contain no view.
+
+`session-history-journal.host.spec.ts` owns pagination, continuity, and history error behavior without presenter assertions.
+
+### Client Conversation
+
+- replace, prepend, and append accept entries without views.
+- Chat and Trajectory root call/result pairing remains unchanged.
+- The Code Dispatch tree remains unchanged.
+- Result-only fallback remains unchanged.
+- A synthetic interruption result copies no view.
+- Node identity across registry rebuild, older prepend, and live append remains unchanged.
+
+### Client card model
+
+- terminal produces the pinned props from raw arguments/content.
+- diff produces the pinned diffs from arguments/metadata.
+- read produces the pinned lines from metadata/content.
+- search produces the pinned grouped/path card and recovery from metadata/content.
+- web produces the pinned sources/fetch summary from metadata/content.
+- unknown, malformed, error, missing-call, and missing-metadata cases remain Generic.
+- absent and present `parentCallId` cases prove that structured presentation does not reach Code Dispatch descendants.
+- Chat and Details produce identical card fields for the same block.
+
+### Deliverables
+
+- Successful write/edit calls produce `file_path`.
+- str_replace_editor create/str_replace/insert calls produce `path`.
+- str_replace_editor view produces no path.
+- failure, interruption, malformed input, and orphan results produce no path.
+- First-seen deduplication and the closing-seq cutoff remain unchanged.
+
+### Assembly and browser
+
+- terminal, diff, read, search, and web browser expected outputs all pass without refresh.
+- Visible assertions for the tool tree, details, trajectory, and deliverables retain their expected values.
+- The built Client still displays the same cards after obtaining raw events from real Remote page/follow operations.
+- Fixtures and the real Host use the same Client derivation.
+- A minimal preset independently pins persistent-shell behavior.
+
+### Static and documentation
+
+- Production code contains no `SessionToolView` or `SessionToolCallView`.
+- Session history does not reference `dsh-tools/presentation`, `ctx.tools`, `presenterScopeFor`, or `backscanArgs`.
+- Client Conversation does not reference `ToolCallView` or `ToolResultView`.
+- Client models do not read `callView` or `resultView`.
+- The fixture defines no presenter mirror.
+- Host `presentCall`, `presentResult`, and `presentationMeta` remain.
+- No new Client registry or Host-to-Client presentation hint exists.
+- Affected authored types, READMEs, Agent Notes, catalogs, and graphs are synchronized.
+
+## Verification Commands
+
+Changes to this decision use `dsh-pre-push-checks` to select commands for the final diff. Required evidence includes:
+
+- focused Session Controller history/transport tests;
+- ui-chat and ui-trajectory Tool Definition tests;
+- ui-tool terminal, diff, read, search, web, row, tree, and details tests;
+- ui-deliverables produced-file tests;
+- connection fixture and Client runtime tests;
+- affected Host and Client TypeScript faces;
+- lint and duplication;
+- per-file 100% coverage for affected source files;
+- `DSH_SNAPSHOT=replay pnpm run test:web`, without refreshing existing presentation goldens;
+- authored Remote type and TypeScript checks;
+- `pnpm run doc-sync`;
+- `git diff --check`.
+
+## Shipped Invariants
+
+- Session page/follow does not read the Tools registry or a presenter scope.
+- Session history has no callId backscan, presentation cache, or view clone.
+- A Remote Session entry carries no view.
+- The Session log and `SESSION_FORMAT_VERSION` remain unchanged.
+- Result metadata passes byte-for-byte through the log and Remote to the Client.
+- Conversation assembles `ToolCallBlock` only from raw events.
+- `ToolCallBlock` contains no Host render-intent fields.
+- The five structured card models read only raw blocks, their existing `parentCallId`, and Session path facts.
+- Generic, Todo, Question, Skill, and Cordis rows remain unchanged.
+- Deliverables does not depend on render intent and preserves current paths.
+- Text, components, expanded content, states, links, and ordering for all first-party top-level tools remain unchanged.
+- Malformed, missing-metadata, error, orphan, and unknown-tool cases continue to fall back safely.
+- Code Dispatch subcalls remain Generic and flattened.
+- Chat, Details, and Trajectory behavior remains unchanged.
+- Existing Web browser expected outputs pass without refresh.
+- Host presenter APIs, implementations, and direct tests remain unchanged.
+- ACP output remains unchanged.
+- No new downstream presentation field or second Client registry is introduced.
+- Pagination cost no longer grows as the number of results multiplied by page event count.
+- Downstream payloads no longer duplicate result metadata in a card DTO.
+
+## Alternatives considered
+
+### Optimize only `backscanArgs` and retain views
+
+Building one `callId → {name,args}` Map before processing a page would make backscan linear, and live follow already has an `openCalls` fast path. It would leave Host lookups, preset scopes, presenters, JSON clones, duplicate payloads, and dual ownership intact, so this alternative is rejected.
+
+### Add a presenter registry to the Client
+
+Copying the `presentCall` and `presentResult` interfaces into the browser would duplicate the registration, lifecycle, fallback, and override semantics of the `tool.call.toolview` slot. Renderers would still have to convert presenter DTOs into component props, so this alternative is rejected.
+
+### Have the Conversation Tool Definition produce one unified view
+
+This would put tool names and UI-card semantics into the target-neutral Conversation owner and recreate an intermediate DTO isomorphic to the Host view, so this alternative is rejected.
+
+### Delete `presentationMeta`
+
+Read line structure, applied diffs, search grouping, web sources, and effective truncation cannot be recovered losslessly from model text. Parsing free-form text would also bind the UI to output wording, so this alternative is rejected.
+
+### Persist canonical tool results
+
+This would enlarge the Session log, expose internal result structures, change the durable format, and potentially store objects far larger than presentation requires. Existing metadata is sufficient, so this alternative is rejected.
+
+### Delete Host presenter APIs
+
+Deleting them would shrink more code, but the decision preserves Host `presentCall` and `presentResult`. Their APIs, implementations, tests, and types remain independent of Session Remote.
+
+### Import Host tool implementations into the Client
+
+Tool packages include Node, filesystem, subprocess, or provider dependencies and cannot enter the browser bundle. The Client consumes only raw JSON and maintains narrow parsers inside its own renderers, so this alternative is rejected.
+
+### Query presentation from the Host per result
+
+An on-demand RPC would turn one page read into N network calls and would still require Host lookups, scope restoration, callId recovery, and error coordination, so this alternative is rejected.
+
+### Allow presentation enhancements
+
+The Client could produce more rich cards for Code Dispatch subcalls, missing call heads, or history whose Host presenter was unavailable. That would mix an ownership change with product behavior and prevent snapshots from proving equivalence, so this alternative is rejected.
+
+### Accept temporary Generic degradation
+
+Stopping view delivery before completing Client cards would temporarily degrade terminal, diff, read, search, web, and Deliverables behavior. Client-equivalent derivation and Host removal must land in the same releasable change.
+
+## Consequences
+
+The decision removes presentation work, repeated scans, and duplicate view payloads from Session reads. Its cost is that the retained Host presenter and Client card derivation can evolve independently, so both sides require owner-specific tests and Web equivalence remains an explicit product constraint.
+
+### Client and Host logic drift
+
+Each tool may have one Host render intent and one Client card derivation. They serve different consumers and do not share a runtime path. Unrefreshed browser expected outputs pin visual equivalence for the first-party Web experience, while Host presenter tests constrain only the Host API.
+
+### Same-named providers lack stable identity
+
+A raw event records the tool name but not the specific ToolDefinition. The Client uses valid event fields to preserve differences between standard and persistent shells. Ambiguous custom or malformed input falls back to Generic; the wire has no extra hint for theoretical extensibility.
+
+### Metadata is unknown JSON
+
+Old Sessions may lack fields, and manually edited logs may contain malformed values. Each Client model must narrow locally and cannot pass unknown arrays or objects directly into UI primitives.
+
+### Preset-owned projection availability
+
+History does not compensate for projection units absent from the current composition. A preset-owned unit that must remain visible across a cold read requires the shared Session preparation/projection composition to make its definition available before restore; history must not regain a preset-mount or presenter setup branch.
+
+### Two targets must stay synchronized
+
+Chat and Trajectory have separate Tool Definitions and both carry the raw fields. Card derivation remains only in `ui-tool` and cannot be copied into either Definition.
+
+### Deliverables has a hidden dependency
+
+Deliverables is not a visual component, so its mutation parser must remain synchronized with supported first-party write tools. Dedicated tests pin file chips and Markdown links independently of card screenshots.
+
+### Fixtures can create false confidence
+
+Fixtures send raw events and metadata rather than handwritten views. Real-Host assembly coverage remains necessary because fixture-only snapshots cannot prove the transport path.
+
+### Incorrectly refreshing snapshots
+
+This change promises unchanged user-visible output. A snapshot difference must be fixed in Client derivation. Expected outputs must not be refreshed unless the owner separately approves a specific visual change.
+
+### Documentation drift
+
+The Agent Note, package READMEs, cookbook, root rules, and generated references must change together whenever the raw journal or Client presentation owner changes. Host API documentation remains separate.
+
+### Remote protocol narrowing
+
+The absence of optional `view` is a prerelease wire-type decision shared by all consumers. There is no compatibility shim, dual-writing, or version negotiation.
+
+## Relationship to Existing Decisions
+
+This note partially supersedes the implementation fact in [Client tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) that “card models receive Host views.” Its core decisions remain: `ui-tool` owns presentation, business plugins use keyed slots, and Conversation owns only lifecycle and topology.
+
+This note preserves [toolview dissolution](2026-07-23-toolview-dissolution.md): the Client still has one slot registration model and does not restore `ToolViewRegistry`.
+
+This note narrows the consumer scope of the [render-intent union](2026-07-02-tool-render-intent-union.md). The Host APIs and types remain, while the Session Remote and Web Client do not consume them. This note owns the transport split without rewriting that presenter decision.
+
+This note updates the entry contract from [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md): the journal transports only raw events plus an independent projection baseline, not transient tool views.
+
+This note follows [Conversation Node assembly](2026-08-09-client-conversation-node-assembly.md): the Tool Definition owns event pairing and the call tree, while concrete card models remain in `ui-tool`.
+
+This note preserves result metadata from the [canonical tool output contract](2026-07-20-canonical-tool-output-contract.md), because it is the lossless, replayable input to Client derivation.
+
+## Deferred
+
+- A separate explicit decision may evaluate deleting Host presenters if they remain without production consumers; this decision does not prejudge it.
+- Specialized cards for Code Dispatch subcalls require a separate design and visible-snapshot updates; this decision preserves current behavior.
+- A third-party mutation tool that joins Deliverables requires a new Client-owned contribution; this decision does not create a registry for an absent consumer.
+- Distinct Client presentation for same-named providers first requires a stable, non-presentational identity; it must not restore per-page Host views.
+- If Client card-model performance needs measurement, an immutable-block microbenchmark can be added; the shipped architecture already prohibits scanning the Session window.

+ 705 - 0
.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md

@@ -0,0 +1,705 @@
+# Agent Note: Client 从原始 Session 工具事件派生展示
+
+Status: implemented
+
+[English](2026-08-23-client-derived-tool-presentation.md) | 中文
+
+## Problem
+
+Session 历史是持久 journal 接口,工具卡片属于 Client 展示。在 `page`/`follow` 中计算卡片 view 会让历史读取依赖 Tools registry、Agent preset、恢复后的 scope、presenter 执行和临时 UI 类型。
+
+`tool/result` 不重复记录工具名称和参数。Host 端结果展示因此需要 call index 或按 `callId` 回扫;`maxMessages` 不直接限制事件数量,工具密集页面上的重复扫描可能接近二次方成本。
+
+Host 投影还会重复结构化数据。read、diff、search 与 web 结果已在 `tool/result.data.meta` 中持久化有界事实;另一份 view 只增加 Remote payload 与 Client 解码成本,不增加持久语义。
+
+Client 已经拥有完整的工具展示入口。`ui-chat` 将 `tool/call`、`tool/result` 与 Code Dispatch 事件组装成稳定的 `ToolCallBlock`;`ui-tool` 拥有递归调用树、按工具名称分发的 `tool.call.toolview` keyed slot、Generic fallback、卡片模型和 details output;业务 Client 插件可以为自己的工具名称注册 renderer。
+
+Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的两套解释。keyed renderer 是 Web 扩展点,因此中间 Host view 不提供独立 Web 能力。
+
+`ToolDefinition.presentCall`/`presentResult` 仍是保留的 Host API;ACP 采用 automation-only 协议,仓库也没有生产 TUI consumer。是否删除这些定义与 Session 读取是否独立于展示是两个决定。
+
+所需结果是一条原始 Session journal 和一个 Client 展示 owner,且不发生可见退化或顺带增强。专用卡片、交互和 Code Dispatch 拓扑保持稳定,transport 不再携带临时 view。
+
+## Decision
+
+Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。
+
+Client Conversation 层继续负责工具调用与结果的 identity、配对、生命周期、Code Dispatch 拓扑和稳定 Chat Node。它不解释具体工具名称,也不生成 terminal、diff、read、search 或 web 组件 props。
+
+Client `ui-tool` 继续负责 card model 和具体 renderer。每个 card model 改为直接读取 `ToolCallBlock` 中的工具名称、原始参数、结果内容、错误、持久 metadata、Session cwd 与 Host home,并生成与现有页面相同的组件 props。
+
+Client 不建立第二套 presenter registry。工具名称分发只使用现有 `tool.call.toolview` keyed slot;Client 中的纯 card-model helper 属于 renderer 实现,不成为 Cordis service、公开 registry 或 wire DTO。
+
+Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCallView`、`ToolResultView` 及现有 presenter 实现全部保留。Session Controller 不调用它们,Client 不导入或消费它们;未来非 Client consumer 是否使用它们不属于本决定。
+
+`ToolOutputDefinition.presentationMeta` 与持久 `tool/result.data.meta` 保留。它们携带模型可见结果文本无法无损表达、而现有专用卡片需要的执行结果事实。Client 直接校验并消费 `meta`,不要求 Host 在历史读取时再把它转换成 view。
+
+### 目标与非目标
+
+| 类别 | 决定 |
+|---|---|
+| 不存在 | `SessionEventEntry.view`、`SessionToolView`、`SessionToolCallView` |
+| 不存在 | `history.ts` 的 `viewFor`、`backscanArgs`、`parseToolCall`、`jsonView` 与 presenter scope lookup |
+| 不存在 | follow 中只服务 presentation 的 `openCalls` 与 fallback event scan |
+| 不存在 | Client Session 的平行 `views` 数组、Conversation input 的 `view`、Tool block 的 `callView`/`resultView` |
+| 派生 | terminal、diff、read、search、web card model 读取 raw block/meta |
+| 派生 | Deliverables 读取成功 mutation 的名称与参数 |
+| 保留 | Host `ToolDefinition.presentCall`/`presentResult` API、类型、实现与直接测试 |
+| 保留 | `output.presentationMeta` 与持久 `tool/result.data.meta` |
+| 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology |
+| 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 |
+| 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id |
+| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强 |
+| 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 |
+
+## 术语
+
+**原始 Session event**指持久日志中的 `SessionEvent` 事实,包括 `tool/call` 的 `name` 与原始 `arguments` 字符串,以及 `tool/result` 的 `content`、`isError`、结构化错误和可选 `meta`。
+
+**持久 metadata**指 `ToolOutputDefinition.presentationMeta` 在工具成功执行时生成并写入 `tool/result.data.meta` 的 JSON 值。它是结果事实的一部分,不是预先排版的 React 或 card DTO。
+
+**Host tool view**指 `ToolDefinition.presentCall`/`presentResult` 返回的 `ToolCallView`/`ToolResultView`;Session Remote 不运输它。
+
+**Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。
+
+**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。
+
+**对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。
+
+**无增强**指本决定不让被固定为 Generic fallback 的输入获得新专用卡片,也不扩大已有卡片的数据或交互。
+
+## 架构与所有权
+
+### 工具执行与持久化
+
+1. 工具注册 `output.schema`、`output.render` 和可选 `output.presentationMeta`。
+2. 成功执行产生 canonical JSON value。
+3. Tools runtime 对 value 做快照、schema 校验和冻结。
+4. `output.render(args, value)` 生成模型可见 `ContentBlock[]`。
+5. 顶层调用若声明 `output.presentationMeta`,runtime 同时生成 JSON-safe metadata。
+6. Agent loop 把模型可见结果与 metadata 写入 `tool/result` Session event。
+7. Session log 不保存 `ToolCallView` 或 `ToolResultView`。
+
+### Host journal 读取
+
+1. `session.page` 取得 attached 或 persisted 事件。
+2. `paginate()` 按 append-origin user/assistant message 边界切页。
+3. tail page 通过已注册 projection 的 snapshot/restore 路径取得 baseline。
+4. 每个 page entry 只包含 `{event}`。
+5. `session.follow` 先建立 listener,再执行 catch-up read、发送 opening cursor 并流式下发连续 `{event}` frame。
+6. 两条路径都不为展示解析 preset/Tools scope、解析工具参数、调用 presenter 或建立 call index。
+
+### Client 数据与展示
+
+1. Client Session 保存一个连续 raw event window。
+2. `SessionEventSource` 发布只含 event 的 `SessionEventEntry`。
+3. `ui-conversation` 在没有 presentation companion 的情况下 fold 每个事件。
+4. Chat 与 Trajectory Tool Definition 按 callId 配对顶层 call/result,并组装 Code Dispatch 子树。
+5. `RunningToolCall` 与 `ToolResultNode` 保存 raw facts、metadata 与既有 parent identity。
+6. `ToolCallTree` 按 wire tool name 分发 `tool.call.toolview`。
+7. `ui-tool` 在 render site 从 block 派生 card component props。
+
+### 生产消费者审计
+
+| 对象 | 生产者 | 生产消费者 | 决定 |
+|---|---|---|---|
+| `presentCall`/`presentResult` | 各 Host 工具 | 可能存在的非 Client caller | 保留在 Session Remote 之外 |
+| `SessionEventEntry.view` | 无 | 无 | wire 不存在 |
+| `callView`/`resultView` | 无 | 无 | Client model 不存在 |
+| `presentationMeta` | Tools runtime | `tool/result`、Client card model 与 Host presenter | 保留的持久输入 |
+| fixture presenter mirror | 无 | 无 | fixture 下发 raw metadata |
+
+ACP 不消费 Session tool view,也不映射 Host render intent。仓库没有生产 TUI consumer;Host presenter 保留,但 Session Remote 不作为其 transport。
+
+## 数据流
+
+```text
+Tool execute
+  -> canonical value
+  -> output.render(args, value)
+  -> model-visible result content
+  -> output.presentationMeta(args, value), when declared
+  -> durable tool/result event
+
+Session page/follow
+  -> raw Session event envelope
+  -> no tool lookup
+  -> no preset lookup for presentation
+  -> no call backscan
+  -> no render-intent serialization
+
+Client SessionEventSource
+  -> Conversation Tool Definition
+  -> root call/result pairing + Code Dispatch topology
+  -> ToolCallBlock(name, argsRaw, content, error, meta)
+  -> tool.call.toolview keyed dispatch
+  -> Client card model
+  -> existing React component
+```
+
+这条链路保留一次持久 metadata 投影,因为它发生在 canonical result 尚在内存时;删除的是读取历史时的第二次展示投影。
+
+### 分层责任
+
+| 层 | 负责 | 不负责 |
+|---|---|---|
+| Tools runtime | 执行、canonical value、模型文本、可重放 metadata | Web 卡片选择和组件 props |
+| Session log | 持久事实、顺序、回放 | 临时 card DTO |
+| Session Controller | 地址、权限、冷读、分页、follow、projection baseline | tool lookup、presenter、展示 scope |
+| Client Session | Remote journal 生命周期与连续窗口 | 工具含义、卡片类型 |
+| Conversation Tool Definition | call/result 配对、lifecycle、root/subcall topology | 工具名到组件的解释 |
+| `ui-tool` | card model、通用 fallback、Chat/Details 展示 | Session 分页与 Host registry |
+| 业务 Client 插件 | 自有 tool name 的 keyed renderer | root/subcall 编排与全局 registry |
+| `ui-deliverables` | 当前第一方 mutation 的 produced path | UI card 或 Host render intent |
+
+## Remote 与持久数据约定
+
+### `SessionEventEntry`
+
+`SessionEventEntry` 保留为 journal entry envelope,只含 `event: SessionWireEvent`。本次不顺带把 page entries 改成裸事件,也不重构 `RemoteJournalStream` 的通用 entry 约定。
+
+`SessionPage.events` 仍是 `SessionEventEntry[]`。
+
+`SessionFollowFrame` 仍是 opening frame 或带 `event` 的 event frame。
+
+删除 `SessionToolCallView`、`SessionToolView` 和 `SessionEventEntry.view`。
+
+Client connection 不再从 `dsh-tools/presentation` 转出 `ToolCallView`/`ToolResultView` 供 Session 消费。
+
+生成 catalog 与 graph 从各自 source owner 派生已收窄的 Remote 类型和 package dependency。
+
+### 持久日志
+
+- `tool/call.data.name` 保持原样。
+- `tool/call.data.arguments` 保持模型产生的原始 JSON 字符串。
+- `tool/result.data.message.content` 保持模型可见结果。
+- `tool/result.data.error` 保持结构化失败身份。
+- `tool/result.data.meta` 保持工具私有 JSON 值。
+- Client card model 不写入 Session log。
+- renderer key 与 Host tool implementation id 不写入 Session log。
+- 现有持久 Session 无需迁移,`SESSION_FORMAT_VERSION` 不变。
+
+### `presentationMeta`
+
+`presentationMeta` 不是 Host tool view。它在工具执行完成时读取 canonical value,而该 value 不会持久化;删除它会使下列现有展示无法无损恢复:
+
+- read 的 path、offset、lines、totalLines 与 lang;
+- write/edit 的 applied contextual hunks;
+- grep/glob 的分组结果、截断标志与总数;
+- web_search 的来源字段与 provider answer;
+- web_fetch 的最终 URL、HTTP status 与有效截断标志。
+
+Client 对 `meta` 做局部运行时收窄。是否把 `presentationMeta` 改名为更中性的 result metadata 不属于本决定。
+
+## Host 端设计
+
+`SessionHistoryController.page()` 在取得 source events 后只执行分页与现有 projection baseline 计算。attached Session 使用 projection registry snapshot;detached Session 使用该 registry 对 inspected log 的 restore 路径。history 不通过挂载 preset 改变已注册的 projection 集合。
+
+`SessionHistoryController.follow()` 保留 listener-first、opening cursor、gap-free replay、live buffering、取消和 teardown;它不为工具事件维护额外状态。
+
+Controller 不存在 `presenterScopeFor()`、`viewFor()`、`backscanArgs()`、`parseToolCall()` 或 `jsonView()` 路径。page state 不含 presenter scope 或参数 resolver;follow state 不含 `openCalls`、`fallbackEvents` 或 presentation 参数 resolver。每个 page/follow event 只包装成 `{event}`,地址、ownership、cursor、seq 与 projection 逻辑保持完整。
+
+不可变 event 转换 helper 可以保持窄实现或内联;只要 history 不执行 presentation 工作,其名称没有语义。
+
+Session Controller dependency 只在其他 package responsibility 需要时保留;manifest 与 project reference 不含 presentation-only dependency。
+
+### 性能约束
+
+- `page()` 的工具相关工作为零。
+- 页面增加 tool result 不增加对既有页面事件的重复扫描。
+- `follow()` 不维护展示索引。
+- history 不触发 Cordis `tools` service proxy。
+- history 不等待 presenter standing scope。
+- history 不执行工具参数 JSON parse。
+- history 不执行 tool view JSON clone。
+- Remote payload 不重复携带 `meta` 已表达的结构化数据。
+- Client 不扫描完整 Session event window 生成单个卡片。
+- Client 只在对应 immutable Tool block 变化时重新派生 card model。
+
+## Client Session 与 Conversation
+
+Client Session 不含与 raw event window 平行的私有 `views` 数组。`installWindow()`、`prependWindow()` 和 `appendLive()` 只处理 event entries、cursor/hasMore、queue、projection 与通知。
+
+`ConversationEventInput` 只携带 `event`。Conversation assembler 不认识 `SessionToolView`,其 replace/prepend/append、Context identity、Location 与 publication cadence 不变。
+
+Chat 和 Trajectory 的 Tool Definition 都不读取 view,而从事件生成以下数据:
+
+- callId;
+- tool name;
+- raw arguments;
+- turn、step、seq 与 time;
+- result content;
+- isError 与 structured error;
+- result metadata;
+- root/subcall parent-child topology;
+- interruption synthetic result。
+
+`RunningToolCall` 不含 `callView`。
+
+`ToolResultNode` 不含 `callView` 与 `resultView`。
+
+`ToolCallBlock` 不新增通用 `view`、`card`、`kind` 或 `locations` 字段替代被删除字段。具体展示仍只属于 `ui-tool` 与 keyed renderer。
+
+### Root 与 Code Dispatch 子调用
+
+Host presenter API 描述顶层 call/result。Code Dispatch 子调用使用 Generic/flattened Client 展示;Client 能识别子调用名称并不赋予它结构化卡片。
+
+Code Dispatch start 与 result event 已经携带 `parentCallId`。Conversation 在每个 child `ToolCallBlock` 上保留这项现有事实,root Session call 则不携带它。五类结构化 card model 只接受没有 `parentCallId` 的 block,原本有意支持嵌套调用的 renderer 则继续收到同一个 child block。
+
+Details panel 原样委托选中的 block。同一组 card model 读取 `parentCallId`,让选中的 Code Dispatch child 保持现有 raw fallback,因此 Details slot 不需要 placement 字段。
+
+keyed slot 仍按每个子调用的真实 tool name 分发;`parentCallId` 只控制本决定覆盖的 terminal/diff/read/search/web 结构化模型。Skill、Cordis 等已经直接读取 raw block 的专用 renderer 保持现状。
+
+### 缺失调用头
+
+结果节点在当前窗口没有配对 call 时,`ToolResultNode.call` 保持 `null`。Client 不扫描窗口、不发额外 RPC,也不根据 result 文本猜测工具名称。
+
+需要名称或参数的专用派生在 `call === null` 时走当前 Generic fallback。只依赖 result metadata 的模型也不借机增强,因为当前 Host `presentResult` 必须先取得配对调用。
+
+older page 后续补入调用头时,Conversation Context 按既有 replay 规则重建,届时才允许生成当前已有的专用卡片。
+
+### 参数与 metadata 收窄
+
+Client 从 `argsRaw` 解析 JSON,解析失败返回 Generic,不抛出 React render 错误。
+
+Chat 与 Details 通过纯 helper 复用同一 block 的解析。未来缓存必须使用 immutable block identity,不能按 callId 建立跨 Session 全局状态。
+
+每个专用模型只检查它需要的字段。Client 不复制完整 Host tool schema,也不调用 Host `defineTool` validator。
+
+合法第一方事件必须与当前 presenter 输出等价。畸形、旧版本或手工修改日志只承诺不崩溃并使用 Generic fallback。
+
+## Client card-model 设计
+
+现有 `ui-tool/src/client/tool/models/` 继续是 Chat 与 Details 共享派生的唯一位置。helper 直接返回组件 props,不返回 `ToolCallView`/`ToolResultView`,也不创建同构的 `ClientToolView` union。
+
+工具名称分支只存在于 `ui-tool` card model、现有 row 分类表,或拥有该工具 keyed renderer 的 Client 插件;不得进入 Session Controller、Client Session、Conversation assembler 或通用 Slot renderer。
+
+未知工具继续由 `GenericToolCard` 显示 name、原始 args、结果 content 与错误。
+
+### 通用工具行
+
+`toolRowModel()` 直接从 `toolName`、`argsRaw`、result content、error、cwd 与 home 派生通用行,并保持以下行为:
+
+- `search`、`read`、`bash`、`write`、`edit`、`code` 与 `others` 分类;
+- 现有标题与工具专用标题;
+- summary 字段优先级和单行截断;
+- 多 query 的逗号拼接;
+- cwd 相对化与 home 缩写;
+- file path 点击;
+- args pretty JSON 与非 JSON 原文 fallback;
+- result content flatten 与 structured error fallback;
+- running、ok、error 与 stopped 状态。
+
+Generic Host `presentCall` 的 title、kind、rawInput、content 与 locations 当前并不驱动普通 Web 行;Generic `presentResult.content` 也不驱动 Web 输出,因此无需把这些未消费值复制到 Client。
+
+### Terminal 卡片
+
+Client terminal model 从工具名称、调用参数、结果 content、error、现有 `parentCallId` 与 Session cwd 派生现有 `TerminalBlock` props。
+
+| 输入 | 保持的结果 |
+|---|---|
+| 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 |
+| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 |
+| `run_in_background:true` | Generic 行与原始结果 |
+| 工具执行 error | Generic IN/OUT 与错误摘要 |
+| persistent `bash`/`pwsh` running | terminal prompt |
+| persistent `bash`/`pwsh` settled | Generic flattened result,不新增 exit card |
+| `terminal_send` 前台 | terminal prompt 与 output |
+| `terminal_send` background/error | Generic 结果 |
+| Code Dispatch child | 当前 flattened Generic 形态 |
+
+标准 shell 结果继续解析末尾 `[exit code: N]` 与 `[killed by signal: X]`。已解析的 marker 从正文移除;timeout、sandbox denial 与没有 pill 的 marker 留在正文。
+
+调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.`、`..`、盘符与 UNC root 的归一化。
+
+对于 `terminal_send`,非空 input 与 session id 保持为逐字工具数据;空 input fallback 与 session label 通过 render site 的 conversation locale 解析。
+
+同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。
+
+TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。
+
+### Diff 卡片
+
+| 输入 | 保持的结果 |
+|---|---|
+| running `write` | 从 `file_path` 与 `content` 生成 intended added-only diff |
+| running `edit` | 从 `file_path`、`old_string`、`new_string` 生成 intended replacement diff |
+| running `str_replace_editor create` | 从 `path` 与 `file_text` 生成 intended added-only diff |
+| running `str_replace_editor str_replace` | 从 `path`、`old_str` 与 `new_str` 生成 intended replacement diff |
+| settled `write`/`edit` success | 从 `meta.diffs` 生成 applied contextual hunks |
+| settled `str_replace_editor` | Generic,因为该工具没有 result presenter |
+| write create 或 applied metadata 缺失、畸形、为空 | 当前 args fallback |
+| error、畸形 args、edit 的 metadata 畸形、Code Dispatch child | Generic |
+
+路径、`oldText:null`、`newText`、结果覆盖调用时 diff、Chat 8 行上限、Details 全高显示和文件打开行为不变。
+
+### Read 卡片
+
+running `read` 继续只有摘要行。成功 settled `read` 从 result meta 读取 path、offset、lines、totalLines 与 lang,并确认结果是单个文本块且符合 read envelope。
+
+meta 缺失、字段畸形、result envelope 不匹配、error、缺失 call head 或 Code Dispatch child 都走 Generic。路径 label 的 cwd 相对化、home 缩写、语法语言、总行数、Chat 8 行上限与 Details 全高显示不变。
+
+Client 不需要构造 Host `ReadResultView.content`;Generic fallback 始终可直接读取原始 result content。
+
+### Search 卡片
+
+running `grep`/`glob` 继续只有参数摘要。成功结果分别从 `meta.shape:'matches'` 与 `meta.shape:'paths'` 生成 grouped matches 或 path list。
+
+Client 校验 path、lineNumber、line、truncated 与 total。空 matches/paths 是有效卡片;缺失/畸形 meta、未知 shape、error、缺失 call head 与 Code Dispatch child 走 Generic。
+
+`truncated:true` 时继续从原始 result content 显示 recovery locator;未截断时不显示。Chat 8 行上限、Details 全高显示和展开行为不变。
+
+### Web 卡片
+
+running `web_search`/`web_fetch` 继续只有摘要行。成功 search 从 `meta.sources`、`meta.answer`、`meta.truncated` 生成卡片;成功 fetch 从 `meta.url`、`meta.statusCode`、`meta.truncated` 生成卡片。
+
+Client 校验每个 source 的 url、title、snippet 与 publishedAt,并继续只把 http/https URL 渲染为链接。meta 缺失或畸形、error、缺失 call head 与 Code Dispatch child 走 Generic。
+
+search 的 answer、来源顺序、label fallback 与截断提示不变;fetch 的最终 URL、状态、截断提示与 Details 下方原始正文不变。
+
+### 已直接使用 raw block 的 renderer
+
+- Todo row 继续从 args 计算 completed/active 摘要。
+- Question row 继续从 result content 与 error 计算等待、回答、取消和中止状态。
+- Skill row 继续从 args/result 计算名称与状态。
+- Cordis define/run/action rows 继续从 args/result 与各自 Client service 计算。
+- 这些 renderer 的 props、slot key、注册顺序与可见结果不变。
+
+## Deliverables
+
+`ui-deliverables` 独立于展示意图派生 mutation 业务事实,因此 produced-file 行为不与卡片截图耦合。
+
+Deliverables Definition 按 callId 观察 root `tool/call` 与成功 `tool/result`,保存最小的 Client-owned mutation candidate,不扫描 Session window,也不依赖 UI renderer。
+
+| 工具 | mutation 判定 | path 来源 |
+|---|---|---|
+| `write` | 任意成功调用 | `file_path` |
+| `edit` | 任意成功调用 | `file_path` |
+| `str_replace_editor` | `create`、`str_replace`、`insert` | `path` |
+| `str_replace_editor` | `view` | 不产生 path |
+| 其他 | 无当前第一方 mutation 语义 | 不产生 path |
+
+失败、interrupted、orphan result、缺失 path 与畸形 args 不产生 deliverable。同一路径保持 first-seen 去重,closing Assistant seq 之后落定的结果继续排除。
+
+本次不新增通用“工具副作用”注册表。Host-only 第三方 presenter 通过 `kind:'edit'`/`locations` 自动加入 Deliverables 的能力被有意移除;未来若有真实第三方 mutation 需求,应由 Client 业务贡献表达,不能恢复 Session view。
+
+## Fixture 与测试数据
+
+Client fixture 删除手写 `presentCall()`、`presentResult()`、`viewFor()` 与 fixture tool-view 类型。它继续产生与真实日志相同的 raw call、result content 和 result meta。
+
+| Fixture | 必须保留的原始事实 |
+|---|---|
+| terminal | 参数与真实结果 status marker |
+| diff | 参数与 result `meta.diffs` |
+| read | result meta 的 path/offset/lines/totalLines/lang |
+| grep/glob | result meta 的 shape/files 或 paths/truncated/total |
+| web | result meta 的 sources/answer 或 url/statusCode/truncated |
+| generic/custom | name、argsRaw、content、error |
+
+fixture 不导入 Host 工具包来计算页面展示,也不保留 presenter 镜像。同一 raw fixture 继续驱动 jsdom、built Web snapshot 与 `?fixture` 浏览器路径。
+
+## 展示等价矩阵
+
+“当前展示”由已提交的组件测试、组装测试与 Web browser expected 共同定义。transport 或 ownership 重构不能作为 refresh snapshot 的理由;获批产品变化需要独立证据。
+
+| 场景 | 必须保持的展示 |
+|---|---|
+| 未知工具 running | Generic 行,工具名与 args 摘要 |
+| 未知工具 settled | Generic 行与原始 output |
+| malformed args | 安全 Generic fallback |
+| orphan result | callId 标题与 Generic output |
+| interrupted call | warning/stopped 状态 |
+| bash/pwsh 前台 | 当前 terminal prompt、正文、cwd 与状态 |
+| bash/pwsh background/error | 当前 Generic IN/OUT |
+| persistent shell | 当前 running terminal、settled Generic |
+| terminal_send | 当前前台 terminal、后台/error Generic |
+| write/edit | 当前 intended/applied diff 与 error fallback |
+| read | 当前 running 摘要、settled ReadBlock 与 error fallback |
+| grep/glob | 当前 grouped/path card、截断与 recovery |
+| web_search/web_fetch | 当前来源/摘要 card 与原始正文 |
+| Todo/Question/Skill/Cordis | 当前专用行 |
+| Code Dispatch subcall | 当前 Generic/flattened 形态 |
+| Chat 与 Details | 同一调用使用相同 card fields |
+| Trajectory | 当前 identity、树、选择和 details |
+| Deliverables | 当前成功 mutation chips 与链接 |
+
+## Client 扩展约定
+
+`tool.call.toolview` 继续是唯一工具 UI 注册机制。一个工具若要在 Client 获得专用表现,必须由 Client 插件注册自己的 wire tool name。
+
+注册方接收 raw `ToolCallBlock`、Session path 信息和宿主动作,自行校验它认识的 args/meta 字段。注册方不调用 Host tool registry,不依赖 `presentCall`/`presentResult`,也不能要求 `SessionEventEntry.view`。
+
+没有 Client renderer 的工具稳定降级为 Generic。同一 tool name 只能有一个生效 keyed registration,重复 key 继续 loud failure。
+
+Session-scoped slot 可以表达 Client 侧会话差异,但不从 preset 推断 renderer 变体。Host-only presenter 不自动赋予 Web rich card,这是“Host 描述展示”与“Client 插件拥有展示”的明确边界。
+
+## 失败与 fallback
+
+- Client 把 args 与 meta 当作 wire JSON,在消费点收窄。
+- 参数 JSON 解析失败走 Generic。
+- 已知工具缺少必要字段走 Generic。
+- metadata 缺失或畸形走 Generic;成功 `write` 例外,它按当前 presenter 行为保留由参数派生的整文件 diff。
+- error result 不因 metadata 存在而显示成功卡片。
+- 缺失 call head 不猜测工具名称或参数。
+- 未知 metadata 字段被忽略。
+- 新 metadata variant 在旧 Client 中走 Generic。
+- card-model helper 捕获可预期解析失败,不依赖 React error boundary 完成普通 fallback。
+- keyed renderer 自身的意外异常仍由现有 Slot error isolation 处理。
+
+## 同名 Host provider
+
+Host registry 允许不同 scope 为同一 tool name 提供不同定义;Session view 通过 presenter scope 理论上可以按 preset 选择不同 render intent。删除 view 后,Client keyed slot 只观察 wire name,不能观察 Host definition identity。
+
+当前第一方显著实例是普通与 persistent `bash`/`pwsh`。Client 派生使用有效参数与结果特征保持它们的已交付差异,不增加 provider-id wire 字段;无法判别的畸形或自定义同名 provider 输入采用 Generic。
+
+本次不承诺保留第三方同名 provider 仅通过 Host presenter 表达的差异。若未来产品确需同名 provider 的不同 Client 展示,必须定义稳定、非展示性的 Client identity;不得恢复按页 Host view 计算。
+
+## 已交付范围
+
+### Session Controller
+
+- `SessionEventEntry` 只包含 raw event。
+- 两个 Session tool-view 类型都不存在。
+- history 不含 presentation import、helper 或 page/follow presentation state。
+- 地址、分页、follow 与 projection 逻辑仍由 Session owner 负责。
+- Host 测试固定 raw journal 约定。
+
+### Session Controller Client
+
+- `Session.views` 不存在。
+- EventSource replace/prepend/append delta 保持不变。
+- transport、fixture 与 test-support 类型携带 raw entry。
+- event identity 与引用稳定性保持不变。
+
+### UI Conversation、Chat 与 Trajectory
+
+- Conversation input 与 Tool block 不含 view 字段。
+- Chat/Trajectory Tool Definition 读取 raw event。
+- event pairing、Context replay、树与 target snapshot 保持不变。
+- child Tool block 保留现有 Code Dispatch `parentCallId`;row 与 Details slot owner props 都不增加独立 placement 字段。
+
+### UI Tool 与 Deliverables
+
+- card model 从 raw block/meta 派生。
+- Chat 与 Details 复用相同 helper。
+- Generic fallback 与 keyed dispatch 保持不变。
+- Deliverables 识别第一方 mutation args。
+
+### Fixture、文档与生成物
+
+- fixture 只发 raw event/meta。
+- Session Controller 与 Client README/JSDoc 描述 raw journal 和 Client presentation owner。
+- 工具 cookbook 记录 Web Client 接入路径。
+- 本文是该决定的 owner;保留的 Host presenter Note 继续拥有各自决定。
+- 手写 Remote 类型、dependency、README、pairing record 与 generated reference 保持同步。
+
+## 验证矩阵
+
+### Host
+
+- page 返回连续 raw event entries。
+- follow 返回 opening cursor 与连续 raw event entries。
+- page/follow 在无 Tools service 时行为相同。
+- cold page 不解析或挂载 preset。
+- tail page 通过标准 projection registry 计算 baseline;provider 是否存在由 projection composition 决定,不引入 history 侧 setup 路径。
+- 地址、ownership、message-aligned boundary 与 tail projection 不变。
+- listener-before-read、reconnect catch-up 与 gap repair 不变。
+- 大量 tool results 不触发每结果回扫。
+- wire 结果不含 view。
+
+`session-history-journal.host.spec.ts` 负责分页、连续性和 history error 行为,不含 presenter 断言。
+
+### Client Conversation
+
+- replace、prepend 与 append 接受无 view entry。
+- Chat 与 Trajectory root call/result 配对不变。
+- Code Dispatch 树不变。
+- result-only fallback 不变。
+- interruption synthetic result 不复制 view。
+- registry rebuild、older prepend 与 live append 的 Node identity 不变。
+
+### Client card model
+
+- terminal 用 raw args/content 得到已固定的 props。
+- diff 用 args/meta 得到已固定的 diffs。
+- read 用 meta/content 得到已固定的 lines。
+- search 用 meta/content 得到已固定的 grouped/path card 与 recovery。
+- web 用 meta/content 得到已固定的 sources/fetch summary。
+- unknown、malformed、error、missing-call 与 missing-meta 继续 Generic。
+- `parentCallId` 缺失与存在的用例证明结构化展示不会到达 Code Dispatch descendant。
+- Chat 与 Details 对同一 block 得到相同 card fields。
+
+### Deliverables
+
+- write/edit 成功产生 `file_path`。
+- str_replace_editor create/str_replace/insert 产生 `path`。
+- str_replace_editor view 不产生 path。
+- failure、interrupted、malformed 与 orphan 不产生 path。
+- first-seen 去重与 closing seq cut 不变。
+
+### 组装与浏览器
+
+- terminal、diff、read、search、web browser expected 不刷新并全部通过。
+- tool tree、details、trajectory 与 deliverables 的可见断言不改预期。
+- built Client 通过真实 Remote page/follow 取得 raw events 后仍显示同样卡片。
+- fixture 与真实 Host 使用同一 Client derivation。
+- minimal preset 单独固定 persistent shell 行为。
+
+### 静态与文档
+
+- 生产代码不存在 `SessionToolView`/`SessionToolCallView`。
+- Session history 不引用 `dsh-tools/presentation`、`ctx.tools`、`presenterScopeFor` 或 `backscanArgs`。
+- Client Conversation 不引用 `ToolCallView`/`ToolResultView`。
+- Client model 不读取 `callView`/`resultView`。
+- fixture 不定义 presenter mirror。
+- Host `presentCall`/`presentResult` 与 `presentationMeta` 仍存在。
+- 没有新增 Client registry 或 Host→Client presentation hint。
+- 受影响的手写类型、README、Agent Note、catalog 与 graph 保持同步。
+
+## 验证命令
+
+修改本决定时使用 `dsh-pre-push-checks` 按最终 diff 选择命令;所需证据包括:
+
+- Session Controller history/transport 聚焦测试;
+- ui-chat 与 ui-trajectory Tool Definition 测试;
+- ui-tool terminal、diff、read、search、web、row、tree 与 details 测试;
+- ui-deliverables produced-files 测试;
+- connection fixture 与 Client runtime 测试;
+- 受影响 Host/Client TypeScript face;
+- lint 与 duplication;
+- 受影响源文件 per-file 100% coverage;
+- `DSH_SNAPSHOT=replay pnpm run test:web`,不得 refresh 现有展示 golden;
+- 手写 Remote 类型与 TypeScript 检查;
+- `pnpm run doc-sync`;
+- `git diff --check`。
+
+## 已交付不变量
+
+- Session page/follow 不读取 Tools registry 或 presenter scope。
+- Session history 不存在 callId backscan、presentation cache 或 view clone。
+- Remote Session entry 不携带 view。
+- Session 日志与 `SESSION_FORMAT_VERSION` 不变。
+- result meta 逐字节通过日志与 Remote 到达 Client。
+- Conversation 只从 raw event 组装 ToolCallBlock。
+- ToolCallBlock 不含 Host render-intent 字段。
+- 五类结构化 card model 只读 raw block、其现有 `parentCallId` 与 Session path facts。
+- Generic、Todo、Question、Skill 与 Cordis 行行为不变。
+- Deliverables 不依赖 render intent 且保持当前 paths。
+- 所有第一方顶层工具的文本、组件、展开内容、状态、链接与排序不变。
+- malformed、missing-meta、error、orphan 与 unknown-tool 继续安全 fallback。
+- Code Dispatch 子调用保持 Generic/flattened。
+- Chat、Details 与 Trajectory 行为不变。
+- 现有 Web browser expected 无需刷新即可通过。
+- Host presenter API、实现与直接测试不变。
+- ACP 输出不变。
+- 没有新下行展示字段或第二套 Client registry。
+- 分页成本不再随 result 数量乘以页面事件数增长。
+- 下行 payload 不再重复 result meta 的 card DTO。
+
+## Alternatives considered
+
+### 只优化 `backscanArgs`,保留 view
+
+page 前建立一次 `callId → {name,args}` Map 可以把回扫降为线性,live 已有 `openCalls` 快路径;但 Host lookup、preset scope、presenter、JSON clone、重复 payload 和双重所有权仍存在,因此拒绝。
+
+### 在 Client 建 presenter registry
+
+把 `presentCall`/`presentResult` 接口复制到浏览器会与 `tool.call.toolview` slot 重复注册、生命周期、fallback 和覆盖语义;renderer 仍需把 presenter DTO 转成组件 props,因此拒绝。
+
+### 让 Conversation Tool Definition 生成统一 view
+
+这会把工具名称和 UI card 语义放进 target-neutral Conversation owner,并重建与 Host view 同构的中间 DTO,因此拒绝。
+
+### 删除 `presentationMeta`
+
+read 行结构、applied diff、search 分组、web sources 和有效 truncation 无法从模型文本无损恢复;解析自由文本也会把 UI 绑到输出措辞,因此拒绝。
+
+### 持久化 canonical tool result
+
+这会扩大 Session log、暴露内部结果结构、改变持久格式,并可能保存远超展示所需的大对象;已有 metadata 足够,因此拒绝。
+
+### 删除 Host presenter API
+
+一并删除可以继续收缩代码,但本决定保留 Host `presentCall`/`presentResult`;其 API、实现、测试与类型独立于 Session Remote。
+
+### Client 导入 Host 工具实现
+
+工具包包含 Node、filesystem、subprocess 或 provider 依赖,不能进入浏览器 bundle;Client 只消费 raw JSON,并在自己的 renderer 内维护窄解析,因此拒绝。
+
+### 按结果向 Host 查询 presentation
+
+按需 RPC 会把一页读取变成 N 次网络调用,仍需 Host lookup、scope、callId 查找与错误协调,因此拒绝。
+
+### 允许展示增强
+
+Client 可以为 Code Dispatch 子调用、缺失 call head 或 Host presenter 不可用的历史生成更多 rich card,但这会混淆 ownership 变化与产品行为,并使快照无法证明对等,因此拒绝。
+
+### 接受临时 Generic 退化
+
+先停发 view 再逐步补 Client card 会让 terminal、diff、read、search、web 与 Deliverables 在中间版本退化。Client 对等实现与 Host 删除必须在同一可发布变更中完成。
+
+## Consequences
+
+本决定从 Session 读取中删除 presentation 工作、重复扫描和重复 view payload;代价是保留的 Host presenter 与 Client card derivation 可以独立演进,因此两侧都需要 owner 专属测试,Web 展示对等仍是明确产品约束。
+
+### Client 与 Host 逻辑漂移
+
+同一工具可以有一份 Host render intent 和一份 Client card derivation。两者面向不同消费方,不共享运行路径;不刷新的 browser expected 固定第一方 Web 视觉对等,Host presenter 测试只约束 Host API。
+
+### 同名 provider 无稳定 identity
+
+raw event 只记录 tool name,不记录具体 ToolDefinition。Client 使用有效事件字段保留普通与 persistent shell 的差异;无法判别的自定义或畸形输入回退 Generic,wire 不为理论扩展性增加 hint。
+
+### Metadata 是未知 JSON
+
+旧 Session 可能缺字段,手工修改日志可能带畸形值。每个 Client model 必须局部收窄,不能把未知数组或对象直接传给 UI primitive。
+
+### preset-owned projection 可用性
+
+history 不为当前组合中缺失的 projection unit 补偿。需要在冷读中保持可见的 preset-owned unit,必须由共享的 Session preparation/projection 组合在 restore 前提供其定义;history 不得重新增加 preset mount 或 presenter setup 分支。
+
+### 双 target 同步
+
+Chat 与 Trajectory 各有独立 Tool Definition,两者都携带 raw fields;card derivation 只能留在 `ui-tool`,不能复制进两个 Definition。
+
+### Deliverables 隐性依赖
+
+Deliverables 不是视觉组件,因此 mutation parser 必须与受支持的第一方写工具保持同步;专用测试独立于卡片截图固定 file chips 与 Markdown links。
+
+### Fixture 假绿
+
+fixture 下发 raw event/meta,不下发手写 view。真实 Host 组装覆盖仍然必要,因为 fixture-only snapshot 不能证明 transport 路径。
+
+### 错误刷新快照
+
+本次承诺用户可见输出不变。出现 snapshot diff 时必须修 Client 派生;除非 owner 单独批准具体视觉变化,否则不得 refresh expected。
+
+### 文档漂移
+
+raw journal 或 Client presentation owner 变化时,Agent Note、package README、cookbook、根规则与 generated reference 必须一起更新;Host API 文档保持独立。
+
+### Remote 协议收缩
+
+optional `view` 的缺失是所有 consumer 共同遵守的预发布 wire 类型决定;没有兼容 shim、双写或版本协商。
+
+## 与现有决策的关系
+
+本文部分取代 [Client 工具展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md) 中“card model 接收 Host view”的实现事实;`ui-tool` 拥有展示、业务插件使用 keyed slot、Conversation 只拥有生命周期与拓扑的核心决定保持不变。
+
+本文保留 [toolview 溶解](2026-07-23-toolview-dissolution.zh.md) 的决定:Client 仍只有 slot 注册模型,不恢复 `ToolViewRegistry`。
+
+本文收窄 [render-intent union](2026-07-02-tool-render-intent-union.zh.md) 的消费范围:Host API 与类型保留,Session Remote 与 Web Client 不消费它。本文独自规定 transport 拆分,不改写该 presenter 决策。
+
+本文更新 [Session 历史与 Remote 事件传输](2026-08-18-session-history-and-event-transport.zh.md) 的 entry 约定:journal 只运输原始 event 与独立 projection baseline,不承载临时 tool view。
+
+本文遵循 [Conversation Node 组装](2026-08-09-client-conversation-node-assembly.zh.md):Tool Definition 负责事件配对与调用树,具体 card model 留在 `ui-tool`。
+
+本文保留 [规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md) 的 result metadata,因为它是无损、可重放 Client 派生的输入。
+
+## Deferred
+
+- Host presenter 若长期没有生产消费者,可由另一项明确决策评估删除;本决定不预判。
+- Code Dispatch 子调用若要专用卡片,需单独设计并更新可见快照;本决定保持现状。
+- 第三方 mutation tool 若要加入 Deliverables,需新增 Client-owned 贡献;本决定不为尚无消费者的扩展性建 registry。
+- 同名 provider 若要不同 Client 展示,需先定义稳定、非展示性的 identity;不得恢复按页 Host view。
+- Client card model 若需量化性能,可以增加 immutable-block 微基准;已交付架构禁止扫描 Session window。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.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-23-webworker-vfs-watch-and-landlock.md
+2026-08-23-webworker-vfs-watch-and-landlock.md: 2705c63aa6bf0f2e2de33d00029a4f41e1b1d4af
+2026-08-23-webworker-vfs-watch-and-landlock.zh.md: 4470720e1ff968aa06578b231b054c9053f27283

+ 80 - 0
.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.md

@@ -0,0 +1,80 @@
+# Agent Note: Web Worker VFS watching and CLI-compatible confinement
+
+Status: implemented
+
+English | [中文](2026-08-23-webworker-vfs-watch-and-landlock.zh.md)
+
+## Problem
+
+The Web Worker preview boots the same Web profile and Agent presets as the Node host. Without a VFS change source, refusing `node:fs.watchFile` makes `skill-filesystem` return an incomplete observation and re-scan on every lookup, while an inert success leaves an existing root waiting forever for Chokidar's `ready`. Settings and credentials likewise need real external-edit events rather than a package-specific fake.
+
+The same composition mounts `sandbox-local`, whose Linux chain probes bwrap and then `@deepseek-ai/node-addon-landlock-run`. A Worker cannot execute either binary. Ending the selection there makes `workspace-write` and `read-only` unusable even though every shell filesystem operation already crosses a Host-side VFS call point.
+
+The filesystem compatibility boundary follows the [Worker Node face decision](2026-08-20-webworker-node-face.md): pure JavaScript watcher packages run unchanged over Node-compatible modules. Native or binary packages may keep their public JavaScript API and executable protocol while replacing the backend. An API that cannot preserve its caller-visible Node behavior remains explicitly unavailable; `node:vm` is outside this decision.
+
+## Decision
+
+### VFS mutation source and filesystem watchers
+
+`MemoryVfs` publishes committed `write`, `mkdir`, `remove`, and `chmod` mutations to any number of subscribers. Publication happens after state changes, failed operations publish nothing, image seeding stays silent, and one throwing subscriber cannot fail the filesystem operation or starve another subscriber. Rename is a source removal plus complete destination mkdir/write records; destination writes mark the directory entry as changed, so watchers report `rename` while a future durable sink receives the bytes needed to materialize the destination. Directory mtimes advance when their immediate entry set changes, so polling detects child creation and removal as Node does.
+
+The mutation record is shared with WebFS persistence rather than defining a second notification path. Writes carry their complete post-commit bytes and virtual permission bits, plus an append offset when only a tail changed. `MemoryVfs` accepts an optional asynchronous `VfsMutationSink`, sends the same records to that sink and live watcher subscribers, and exposes `flush()` through file-handle `sync()` and `datasync()`. Hydration supplies `{ mode, mtimeMs }` explicitly, so image permissions and durable timestamps cannot occupy the same positional argument. This change mounts no durable sink; it keeps the synchronous in-memory tree authoritative so an OPFS or user-directory mirror can hydrate before publication and write behind without changing `node:fs`.
+
+The `node:fs` implementation provides callback `stat` and `lstat`, `watch`, `watchFile`, `unwatchFile`, `FSWatcher`, and `StatWatcher`; `node:fs/promises.watch` provides the abortable async iterator. One path shares one `StatWatcher` across listeners, listener-specific unwatching leaves peers active, and missing paths report zero-valued Stats before later creation, deletion, and recreation transitions. Callback dispatch captures the registration-time async context and checks closure before every queued delivery. A pre-aborted callback watch returns its watcher before asynchronously closing it, while a pre-aborted promise watch rejects its first iterator read with `AbortError`.
+
+`fs.watch` maps entry creation, removal, and rename destinations to `rename`, and maps content or mode changes to `change`. Non-recursive directory watches report immediate child names; recursive watches report paths relative to the watched directory. The VFS has no symlinks, so this implementation does not invent symlink events.
+
+### Streams and unchanged npm packages
+
+`node:stream` uses the maintained `readable-stream` browser implementation for `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`, pipeline helpers, async iteration, backpressure, aborts, and teardown ordering. The compatibility module sets the byte high-water default to the 64 KiB value used by the repository's Node 22+ engines. VFS-backed `ReadStream` and `WriteStream` supply file descriptors, inclusive ranges, encoding, append or replace behavior, byte accounting, AbortSignal handling, and `open`/`ready`/`finish`/`end`/`close` ordering. Descriptors retain their opened file identity and access mode across rename, replacement, and unlink; hard links share that identity and subsequent content or mode changes, while truncation zero-fills growth.
+
+Chokidar and readdirp are ordinary image dependencies, not module replacements. Their package code runs unchanged and imports the Worker implementations of `node:fs`, `node:fs/promises`, `node:stream`, `node:events`, `node:path`, and `node:os`. Chokidar therefore retains its own initial scan, `ready`, polling, atomic-write normalization, write-settle delay, shared watcher, and close behavior.
+
+### Landlock CLI over per-process VFS grants
+
+`@deepseek-ai/node-addon-landlock-run` is an ordinary image dependency, not a module replacement. Its unchanged JavaScript entry runs through the Worker implementations of `node:child_process`, `node:module`, `node:path`, and `node:url`, so the package remains the sole owner of `LAUNCHER_BIN`, `LAUNCHER_FAILURE_EXIT`, `launcherPath()`, `grantArgs()`, and `probe()`. The image may include the matching Linux optional package, but package resolution does not decide whether the Worker platform supplies Landlock: the entry package's deterministic fallback path reaches the same platform executable implementation when that optional package is absent.
+
+The process layer has a table of Worker platform executables identified by logical executable name rather than one package-manager path. Its `landlock-run` provider accepts a bare command or an absolute launcher path, parses the native package's unchanged CLI, validates every grant root, and delegates the inner argv to the existing shell process runner. `node:child_process` performs only generic executable lookup, output delivery, and settlement. The unchanged package's synchronous `probe()` therefore observes the provider through `spawnSync` and reports `full`. A usage error, missing grant root, or unknown inner executable prints one `landlock-run: ...` line, exits `125`, and never runs the inner command. The bwrap probe remains unavailable, so the unmodified `sandbox-local` Linux chain selects this Landlock backend.
+
+Each launched process receives its own `ShellFileSystem` guard. `stat`, `list`, and `readText` require a read-only or read-write grant; `writeText`, `mkdir`, and `remove` require a read-write grant; `rename` requires both source and destination to be writable. Grant roots normalize trailing separators before containment checks. Denials carry `EACCES` and `permission denied`, preserving `bash-sandbox` denial classification. `/tmp` maps to the VFS `/dsh/tmp`, while `/dev/null` is a virtual empty-read and discarded-write file that stores no bytes.
+
+The Worker's `full` verdict covers every file operation expressible through its shell command table and Host-served VFS protocol. It does not claim Linux kernel Landlock, arbitrary native executable support, or protection against a future shell program that bypasses `ShellFileSystem`.
+
+### Explicitly deferred behavior
+
+`node:vm`, `node:worker_threads`, `node:net`, `node:sqlite`, native PTY, Sharp, and ripgrep remain outside this change. The VFS remains POSIX-only, in-memory, and symlink-free. Browser Workers have no libuv-style ref-counted event loop, so watcher `persistent`, `ref()`, and `unref()` preserve the API and observable state but cannot decide Worker lifetime.
+
+## Alternatives considered
+
+**Disable watcher and sandbox rows in the Worker profile.** A smaller composition would stop testing the same Host tree and would hide package integration failures specific to preview deployment.
+
+**Make `watchFile` an inert success.** Missing roots would never advance, and an existing root would wait forever for Chokidar `ready`.
+
+**Notify watchers only from `node:fs`.** Shell process requests and any direct VFS writer would bypass the notification point. The commit owner, `MemoryVfs`, is the only complete source.
+
+**Keep a VFS-specific Chokidar replacement.** This duplicates directory scans, ready accounting, write settling, atomic replacement, shared watcher ownership, and teardown already maintained upstream.
+
+**Replace the Landlock entry package with a Worker module.** Reimplementing its exported constants, grant builder, launcher resolution, and probe would create a second copy of a package contract that already runs over the Worker Node compatibility layer. Only the platform executable implementation differs.
+
+**Recognize one exact launcher path.** Optional-dependency installation and the entry package's documented fallback produce different absolute paths for the same executable. Package-manager layout is not the identity of a platform capability, so executable dispatch uses the logical `landlock-run` name.
+
+**Add a Worker branch to `sandbox-local`.** This would copy policy-to-grant mapping into a business package. Interpreting the existing launcher protocol preserves the provider, consumer, configuration, diagnostics, and native package API.
+
+**Store one active policy on the global VFS.** Concurrent foreground, background, and escalated commands would overwrite one another's authority. Grants belong to one process handle and its filesystem adapter.
+
+## Verification
+
+- `fs-watch-stream.spec.ts` compares missing/create/change/remove `watchFile` transitions and file-stream lifecycle, chunking, range, backpressure, byte count, defaults, and abort identity with the running Node version.
+- `chokidar.spec.ts` loads both lockfile-selected Chokidar and readdirp dependency pairs through the Worker transformer and module loader, then proves `ready`, callback watching, polling, missing-file creation, removal, and quiescent close over `MemoryVfs`.
+- `image-loadable.spec.ts` packs and loads the real `@deepseek-ai/node-addon-landlock-run` JavaScript, proves it is absent from the replacement table, and runs its fallback `launcherPath()` and `probe()` through the Worker platform executable. `child-process.spec.ts` and `sandbox-stack.spec.ts` then prove the launcher failure code, malformed argv and grant failures, `/tmp` and `/dev/null`, rename denial, all three permission modes, and concurrent process-local grants through the production sandbox and subprocess packages.
+- `preview-boot.e2e.ts` builds and boots the packed browser deployment, creates a Workspace and Session, advances missing skill roots into a live Chokidar watch, lists the catalog, and completes settings and credential writes without watcher warnings.
+
+## Consequences
+
+The preview now runs npm watcher consumers without source forks, and filesystem mutations observed from Host code or shell process Workers share one ordered commit source. A WebFS/OPFS integration remains an asynchronous mirror around this synchronous authority and consumes that same source; it does not add another Chokidar implementation or a competing mutation protocol.
+
+Worker `read-only` and `workspace-write` preserve the product's permission vocabulary and denial reporting without forking the Landlock npm package. Their security claim is narrower than native Landlock but complete inside the Worker execution world; any new filesystem message or shell program must continue through the guarded `ShellFileSystem`. Native-backed packages follow the same ownership rule: their JavaScript remains upstream, while the Worker platform replaces only the native artifact behind it.
+
+The worker bundle gains `readable-stream` and its small browser dependency closure. In return, stream state and backpressure remain maintained upstream instead of becoming local compatibility code.
+
+Watcher event timing is deterministic from VFS commits rather than inherited from an operating-system backend. This stays within Node's watcher contract, which does not guarantee native event coalescing, while tests pin every event distinction the current consumers require.

+ 80 - 0
.agents/notes/implemented/architecture/2026-08-23-webworker-vfs-watch-and-landlock.zh.md

@@ -0,0 +1,80 @@
+# Agent Note: Web Worker VFS 监听与 CLI 兼容 confinement
+
+Status: implemented
+
+[English](2026-08-23-webworker-vfs-watch-and-landlock.md) | 中文
+
+## Problem
+
+Web Worker preview 启动与 Node host 相同的 Web profile 和 Agent preset。缺少 VFS 变更源时,拒绝 `node:fs.watchFile` 会让 `skill-filesystem` 返回不完整观测并在每次查询时重新扫描,而无事件的成功调用会让已有根永远等待 Chokidar 的 `ready`。Settings 和 credentials 同样需要真实的外部编辑事件,而不是包专用 fake。
+
+同一组合挂载 `sandbox-local`,其 Linux 选择链依次探测 bwrap 和 `@deepseek-ai/node-addon-landlock-run`。Worker 无法执行这两个二进制文件。如果选择链到此结束,`workspace-write` 与 `read-only` 将不可用,尽管 shell 的每项文件系统操作已经经过 Host 侧 VFS 调用点。
+
+文件系统兼容边界遵循 [Worker Node face 决策](2026-08-20-webworker-node-face.zh.md):纯 JavaScript watcher 包在 Node 兼容模块之上保持原样运行。Native 或 binary 包可以保持公开 JavaScript API 与可执行文件协议,同时替换执行后端。无法维持调用方可见 Node 行为的 API 继续明确标记为不可用;`node:vm` 不属于本决策范围。
+
+## Decision
+
+### VFS mutation source 与文件 watcher
+
+`MemoryVfs` 向任意数量的订阅方发布已提交的 `write`、`mkdir`、`remove` 和 `chmod` mutation。状态改变后才发布,失败操作不发布,镜像 seed 保持无事件,一个抛错的订阅方也不能让文件系统操作失败或阻止其他订阅方。Rename 被表达为源路径删除与包含完整状态的目标 mkdir/write 记录;目标 write 会标记目录项已改变,因此 watcher 报告 `rename`,未来的 durable sink 同时拿到物化目标所需的字节。目录的直接条目集合改变时,其 mtime 会推进,因此 polling 能像 Node 一样发现子项创建和删除。
+
+Mutation record 与 WebFS 持久化共用,而不建立第二条通知路径。Write 记录携带提交后的完整字节与虚拟权限位,并在只有尾部变化时携带 append offset。`MemoryVfs` 接受可选的异步 `VfsMutationSink`,把同一批记录交给 sink 与实时 watcher 订阅方,并通过文件句柄的 `sync()` 和 `datasync()` 暴露 `flush()`。水合通过显式的 `{ mode, mtimeMs }` 传入元数据,因此镜像权限与持久化时间戳不会占用同一个位置参数。本次变更不挂载 durable sink;同步内存树继续作为权威,因此 OPFS 或用户目录 mirror 可以先水合、再异步写回,而无需改变 `node:fs`。
+
+`node:fs` 实现 callback `stat` 和 `lstat`、`watch`、`watchFile`、`unwatchFile`、`FSWatcher` 与 `StatWatcher`;`node:fs/promises.watch` 提供可由 abort 取消的异步迭代器。同一路径的 listener 共享一个 `StatWatcher`,按 listener 取消监听不会影响其他 listener;缺失路径先报告零值 Stats,随后再报告创建、删除和重建状态。Callback 分发捕获注册时的异步上下文,并在每次排队交付前检查 watcher 是否已经关闭。预先 abort 的 callback watcher 先返回对象、再异步关闭;预先 abort 的 promise watcher 在第一次读取 iterator 时以 `AbortError` 拒绝。
+
+`fs.watch` 把条目创建、删除和 rename 目标映射为 `rename`,把内容或 mode 变化映射为 `change`。非递归目录 watcher 报告直接子项名,递归 watcher 报告相对被监听目录的路径。VFS 没有符号链接,因此该实现不会制造符号链接事件。
+
+### Stream 与未修改的 NPM 包
+
+`node:stream` 使用维护中的 `readable-stream` 浏览器实现来提供 `Readable`、`Writable`、`Duplex`、`Transform`、`PassThrough`、pipeline helper、异步迭代、backpressure、abort 和 teardown 顺序。兼容模块把字节流 high-water mark 默认值设为仓库 Node 22+ 引擎使用的 64 KiB。VFS 支持的 `ReadStream` 与 `WriteStream` 提供文件描述符、闭区间范围、encoding、追加或替换行为、字节计数、AbortSignal 处理,以及 `open`、`ready`、`finish`、`end`、`close` 顺序。Descriptor 在 rename、replacement 和 unlink 后仍保留打开时的文件身份与访问模式;hard link 共享该身份及后续内容和 mode 变化,truncate 增长则用零字节填充。
+
+Chokidar 和 readdirp 作为普通镜像依赖运行,不属于模块 replacement。它们的包代码保持原样,并导入 Worker 实现的 `node:fs`、`node:fs/promises`、`node:stream`、`node:events`、`node:path` 与 `node:os`。因此,初次扫描、`ready`、polling、原子写归一化、写入稳定等待、共享 watcher 与关闭行为仍由 Chokidar 自己负责。
+
+### 基于逐进程 VFS 授权的 Landlock CLI
+
+`@deepseek-ai/node-addon-landlock-run` 是普通镜像依赖,不是模块 replacement。其未经修改的 JavaScript 入口通过 Worker 实现的 `node:child_process`、`node:module`、`node:path` 与 `node:url` 运行,因此该包仍是 `LAUNCHER_BIN`、`LAUNCHER_FAILURE_EXIT`、`launcherPath()`、`grantArgs()` 和 `probe()` 的唯一所有者。镜像可以包含匹配的 Linux optional package,但包解析不决定 Worker 平台是否提供 Landlock;缺少该 optional package 时,入口包产生的确定性 fallback 路径仍到达同一个平台可执行文件实现。
+
+进程层持有按逻辑可执行文件名识别的 Worker 平台可执行文件表,而不依赖某一个包管理器路径。其 `landlock-run` provider 接受裸命令或绝对 launcher 路径,解析 native 包未经修改的 CLI、校验每个授权根,并把内部 argv 交给既有 shell 进程 runner。`node:child_process` 只负责通用的可执行文件查找、输出投递与结束处理。因此,原包的同步 `probe()` 会通过 `spawnSync` 观察到该 provider 并报告 `full`。用法错误、缺失的授权根或未知内部可执行文件只输出一行 `landlock-run: ...`,以 `125` 退出,并且绝不运行内部命令。bwrap 仍探测为不可用,因此未修改的 `sandbox-local` Linux 选择链会选中该 Landlock 后端。
+
+每个已启动进程分别获得一个 `ShellFileSystem` guard。`stat`、`list` 和 `readText` 需要只读或读写授权;`writeText`、`mkdir` 和 `remove` 需要读写授权;`rename` 要求源和目标都可写。Grant root 在 containment 检查前去除尾部分隔符。拒绝错误包含 `EACCES` 与 `permission denied`,从而保持 `bash-sandbox` 的拒绝分类。`/tmp` 映射到 VFS 的 `/dsh/tmp`,`/dev/null` 则是空读、丢弃写入且不保存任何字节的虚拟文件。
+
+Worker 的 `full` 结论覆盖 shell 命令表和 Host 服务 VFS 协议能够表达的全部文件操作。它不表示 Linux 内核 Landlock、不支持任意 native 可执行文件,也无法约束未来绕过 `ShellFileSystem` 的 shell 程序。
+
+### 明确延后的行为
+
+`node:vm`、`node:worker_threads`、`node:net`、`node:sqlite`、native PTY、Sharp 和 ripgrep 不属于本次变更。VFS 仍然只支持 POSIX、内存存储且没有符号链接。Browser Worker 没有 libuv 风格的引用计数事件循环,因此 watcher 的 `persistent`、`ref()` 和 `unref()` 保留 API 与可观察状态,但不能决定 Worker 生存期。
+
+## Alternatives considered
+
+**在 Worker profile 中禁用 watcher 与 sandbox 配置项。** 缩减组合后将不再测试相同的 Host tree,还会隐藏 preview 部署特有的包集成故障。
+
+**让 `watchFile` 成为无事件的成功调用。** 缺失根永远无法推进,已有根则会永久等待 Chokidar `ready`。
+
+**只从 `node:fs` 通知 watcher。** Shell 进程请求以及直接写 VFS 的实现可以绕过通知点。只有提交状态的 `MemoryVfs` 才是完整真源。
+
+**保留 VFS 专用的 Chokidar replacement。** 这会重复实现上游已经维护的目录扫描、ready 计数、写入稳定等待、原子替换、共享 watcher 所有权和 teardown。
+
+**用 Worker 模块替换 Landlock 入口包。** 重新实现其导出常量、授权参数构造、launcher 解析和 probe,会为一个已经能在 Worker Node 兼容层上运行的包约定建立第二份副本。只有平台可执行文件实现需要不同。
+
+**只识别一个精确 launcher 路径。** Optional dependency 的安装状态与入口包已有的 fallback 会为同一个可执行文件产生不同的绝对路径。包管理器布局不是平台能力的身份,因此可执行文件分发使用逻辑名称 `landlock-run`。
+
+**在 `sandbox-local` 中增加 Worker 分支。** 这会把策略到授权的映射复制到业务包中。解释现有 launcher 协议可以保持 provider、consumer、配置、诊断和 native 包 API 不变。
+
+**在全局 VFS 上保存一个当前策略。** 并发前台、后台和升权命令会覆盖彼此的权限。授权必须归属于单个进程句柄及其文件系统适配器。
+
+## Verification
+
+- `fs-watch-stream.spec.ts` 对照当前 Node 版本验证缺失、创建、修改、删除的 `watchFile` 状态转换,以及文件流生命周期、分片、范围、backpressure、字节计数、默认值和 abort 身份。
+- `chokidar.spec.ts` 通过 Worker transformer 与模块 loader 加载 lockfile 选定的两组 Chokidar 和 readdirp 依赖,并在 `MemoryVfs` 上验证 `ready`、callback watcher、polling、缺失文件创建、删除和完全停稳的关闭。
+- `image-loadable.spec.ts` 打包并加载真实的 `@deepseek-ai/node-addon-landlock-run` JavaScript,验证它不在 replacement 表中,并让其 fallback `launcherPath()` 与 `probe()` 经过 Worker 平台可执行文件。`child-process.spec.ts` 与 `sandbox-stack.spec.ts` 随后通过生产 sandbox 和 subprocess 包验证 launcher 失败码、错误 argv 与授权失败、`/tmp` 与 `/dev/null`、rename 拒绝、三种权限模式和逐进程并发授权。
+- `preview-boot.e2e.ts` 构建并启动打包后的浏览器部署,创建 Workspace 与 Session,把缺失的 skill 根逐级推进到可用的 Chokidar watch,读取 catalog,并在没有 watcher 警告的情况下完成 settings 与 credential 写入。
+
+## Consequences
+
+Preview 现在可以在不 fork 源码的情况下运行 NPM watcher 消费方;Host 代码与 shell 进程 Worker 产生的文件系统 mutation 共享同一个有序提交源。WebFS/OPFS 集成仍是围绕该同步权威的异步 mirror,并消费同一个变更源;它不会增加另一份 Chokidar 实现或互相竞争的 mutation 协议。
+
+Worker `read-only` 与 `workspace-write` 在不 fork Landlock NPM 包的情况下保留产品权限词汇和拒绝报告。其安全结论比 native Landlock 更窄,但完整覆盖 Worker 执行世界;任何新的文件系统消息或 shell 程序都必须继续经过受 guard 保护的 `ShellFileSystem`。Native-backed 包遵循同一所有权规则:其 JavaScript 保持上游实现,Worker 平台只替换背后的 native artifact。
+
+Worker bundle 增加 `readable-stream` 及其少量浏览器依赖。相应地,stream 状态和 backpressure 继续由上游维护,不成为本地兼容代码。
+
+Watcher 事件时序由 VFS 提交确定,而不是继承操作系统后端。Node watcher 约定本身不保证 native 事件合并方式,因此该实现仍符合约定;测试固定当前消费方依赖的每一种事件区别。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md
+2026-08-20-plugin-owned-shipped-preset-root.md: 43bcc685c2edfa5d125139b998d75ce8b308f60d
+2026-08-20-plugin-owned-shipped-preset-root.zh.md: c2cc586a7a17d7cdb818523a74320fae106eb773

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.md

@@ -0,0 +1,33 @@
+# Agent Note: The shipped preset root is the plugin's own
+
+Status: implemented
+
+English | [中文](2026-08-20-plugin-owned-shipped-preset-root.zh.md)
+
+## Problem
+
+`composeProfile` delivered the shipped agent-preset root by pushing a boot-time overlay whose `config` spread the composed roster row and then hard-set `roots` to the shipped root alone. Because an id-targeted patch replaces the whole `config` value, the overlay squashed every root the profile's `cordis.patch.yml` (or the home layer, or a `--patch` overlay) had configured: a deployment pointing `agent-presets` at a shared preset directory booted with only the shipped root plus the roster's writable home root, and every custom preset vanished from the Web picker. `dsh --dump-config` composes only the file-backed layers, so the dump showed the configured roots intact while the boot dropped them. The overlay also froze the row's boot-time `config` above every live reload, so no `cordis.patch.yml` edit to the row took effect until restart. Externally reported with an accurate root cause in discussion #3636.
+
+Under the whole-`config`-replacement patch semantics, any "must survive user layers" value needs enforcement after composition — and review rejected keeping that enforcement in the launcher: `apps/cli` special-casing one plugin's row id, config keys, and precedence is coupling the composition machinery should not carry.
+
+## Decision
+
+The shipped presets are the plugin's own. The four built-in compositions moved from `apps/cli/config/agent-presets/` into `packages/preset/agent-presets/presets/`, listed in the package's `files`, and `dsh-agent-presets` resolves `SHIPPED_PRESET_ROOT` relative to its own module — the Loader imports the plugin by package name at runtime, so the directory exists on disk in both the source and installed layouts, the same mechanism that lets the `cordis` preset carry its skills inside its directory. `resolvedRoots` becomes shipped root (`system` trust) unless `includeShippedRoot` is false, then `config.roots` in order, then the derived writable home root unless `includeUserRoot` is false — prepended, so the shipped set always mounts and wins a duplicate id.
+
+This completes the [per-session preset roster](../architecture/2026-08-03-per-session-agent-presets.md) direction that #2278 started for the writable root: both non-configured roots are now the package's, the launcher composes patch layers with no plugin knowledge, and the squash, the reload freeze, and the dump divergence stop being possible rather than being corrected. The always-load guarantee no longer rides patch ordering: `includeShippedRoot` defaults true in the schema, so a user layer replacing the row's whole `config` keeps the shipped set, and only an explicit `false` — as deliberate as disabling the row — drops it. The compositions bind to the host's agent-plane services, not to the Web surface: no preset row names a client or web plugin, and a host lacking an injected service leaves that row waiting exactly as under any other root.
+
+## Testing
+
+`shipped-root.spec.ts` covers the plugin ownership directly: a bare roster lists the four shipped presets healthy and `system`-trusted (proving the moved files resolve from the package), the shipped root precedes configured roots and the derived user root with a fixture directory claiming a shipped id shadowed, and `includeShippedRoot: false` mounts the roster without the set. Existing suites that pin exact rosters opt out, which the option's documentation names as its second purpose. The Web composition e2e boots the real bundles with no roots anywhere in config and asserts the shipped four plus a configured shared root's preset, shipped-id shadowing, and a configured-root preset composing an agent; running it against the built `lib/` verifies the bundled layout resolves the directory too. Gate scripts (`verify-cordis-config`, `verify-runtime-closure`) scan the new location.
+
+## Alternatives considered
+
+**Keep the launcher patch but derive it per composition, prepending instead of replacing.** The first merged-nowhere iteration of this fix: correct on the squash, the reload freeze, and the dump (which gained the derived layer as a labeled dump layer), with the reporter's overlay-prepend shape as its core. Superseded in review because every variant keeps `apps/cli` special-casing the roster row; the coupling, not the mechanics, was the objection.
+
+**Have the bundle declare the shipped root itself (`!!js` package-relative path).** Removes the launcher coupling but hangs the always-load guarantee back on patch ordering: a user layer replacing the row's `config` drops the bundle's entry — the reported bug's shape again.
+
+**Provide the root out of band (a launcher-provided context value the plugin prepends).** The launcher still has to know to provide a preset fact; the special case survives in a different channel.
+
+## Consequences
+
+`config.roots` is purely deployment-added directories; the dump shows exactly that, and the shipped root is documented plugin behavior surfaced at runtime through `agentPresets.roots`. `apps/cli` ships no `config/` directory and its `files` entry is gone. Any composition that mounts the roster — and any embedder of the package — gets the shipped set by default and turns it off with one config line; embedders wanting bare machinery set `includeShippedRoot: false`. The presets' bare plugin names still resolve through the boot's flat installation fallback, unchanged by the move.

+ 33 - 0
.agents/notes/implemented/bug-fix/2026-08-20-plugin-owned-shipped-preset-root.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: The shipped preset root is the plugin's own
+
+Status: implemented
+
+[English](2026-08-20-plugin-owned-shipped-preset-root.md) | 中文
+
+## 问题
+
+`composeProfile` 交付内置 agent-preset 根目录的方式,是在启动时推入一个 overlay:其 `config` 展开已组合的 roster 行后,把 `roots` 硬设为仅含内置根。由于 id 定向补丁整体替换 `config` 值,这个 overlay 压掉了 profile 的 `cordis.patch.yml`(以及 home 层、`--patch` overlay)配置的全部根目录:把 `agent-presets` 指向共享 preset 目录的部署,启动后只剩内置根加 roster 的可写 home 根,所有自定义 preset 从 Web 选择器中消失。`dsh --dump-config` 只组合文件承载的层,dump 显示配置的根目录完好而启动却丢弃了它们。该 overlay 还把行的启动时 `config` 冻结在所有热重载之上,重启前对该行的任何 `cordis.patch.yml` 编辑都不生效。外部报告 discussion #3636 给出了准确根因。
+
+在"补丁整体替换 `config`"的语义下,任何"必须在用户层之后存活"的值都需要组合后的强制注入——而评审否决了把这份强制留在启动器里:`apps/cli` 对某一个插件的行 id、config 键与优先级做特判,是组合机器不应携带的耦合。
+
+## 决定
+
+内置 preset 归插件自有。四套内置组合从 `apps/cli/config/agent-presets/` 搬入 `packages/preset/agent-presets/presets/`,列入包的 `files`;`dsh-agent-presets` 相对自己的模块解析 `SHIPPED_PRESET_ROOT`——Loader 在运行时按包名导入插件,目录在源码与安装两种布局中都真实存在于磁盘上,与 `cordis` preset 目录内随行携带 skill 依赖的是同一机制。`resolvedRoots` 变为:除非 `includeShippedRoot` 为 false,先是内置根(`system` 信任),再按序 `config.roots`,最后除非 `includeUserRoot` 为 false 追加推导的可写 home 根——前置,因此内置集合始终挂载并赢得重复 id。
+
+这补全了 #2278 为可写根开启的[会话级 preset roster](../architecture/2026-08-03-per-session-agent-presets.zh.md) 方向:两个非配置根现在都属于本包,启动器不带任何插件知识地组合补丁层,压掉、重载冻结与 dump 分叉从"被修复"变为"不再可能发生"。"一定加载"的保证不再依赖补丁顺序:`includeShippedRoot` 在 schema 中默认 true,用户层整体替换该行 `config` 后内置集合依然保留,只有显式 `false`——与整行 disable 同级的故意行为——才会去掉它。组合绑定的是宿主的 agent-plane 服务而非 Web 表面:没有任何 preset 行引用 client 或 web 插件;宿主缺少被注入的服务时,该行保持等待,与任何其他根目录下的 preset 无异。
+
+## 测试
+
+`shipped-root.spec.ts` 直接覆盖插件所有权:裸 roster 列出四套内置 preset 且健康、`system` 信任(证明搬移后的文件能从包内解析);内置根前置于配置根与推导用户根之前,fixture 目录占用内置 id 时被遮蔽;`includeShippedRoot: false` 挂载不含内置集合的 roster。钉住确切 roster 的既有套件选择关闭,这正是该选项文档命名的第二用途。Web 组合 e2e 以 config 中零 roots 启动真实 bundle,断言内置四套加配置共享根的 preset、内置 id 遮蔽、以及配置根 preset 组合出 agent;对 built `lib/` 运行验证打包布局同样解析得到目录。门禁脚本(`verify-cordis-config`、`verify-runtime-closure`)扫描新位置。
+
+## 曾考虑的替代方案
+
+**保留启动器补丁但按组合派生、前置而非替换。** 本修复未曾合入的第一版:对压掉、重载冻结与 dump(曾以带标签层渲染派生补丁)判断均正确,核心即报告者的 overlay 前置形状。在评审中被替代,因为每个变体都让 `apps/cli` 对 roster 行做特判;被否决的是耦合而非机制。
+
+**由 bundle 自己声明内置根(`!!js` 包相对路径)。** 去掉启动器耦合,但把"一定加载"的保证重新挂回补丁顺序:用户层整体替换该行 `config` 时 bundle 的条目被丢弃——又回到所报 bug 的形状。
+
+**带外提供根目录(启动器提供的上下文值,由插件前置)。** 启动器仍需知道"要为 preset 提供一个事实";特判换了通道继续存在。
+
+## 后果
+
+`config.roots` 纯粹是部署追加的目录;dump 展示的正是它,内置根成为文档化的插件行为,运行时经 `agentPresets.roots` 呈现。`apps/cli` 不再携带 `config/` 目录,其 `files` 条目移除。任何挂载 roster 的组合——以及任何嵌入本包的使用方——默认获得内置集合,一行配置即可关闭;只要纯机制的嵌入方设 `includeShippedRoot: false`。preset 里的裸插件名仍经启动的扁平安装后备解析,搬移不改变这一点。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
-2026-07-29-persistent-bash-str-replace-editor.md: 982b0b87553e15fab8fd6a2718dbe2462cfb3bd9
-2026-07-29-persistent-bash-str-replace-editor.zh.md: 05935f8164018d0c476f85ccc5e6c0e87890f979
+2026-07-29-persistent-bash-str-replace-editor.md: e8e37b7e534773429a9c6fe0f63bb8d5460de364
+2026-07-29-persistent-bash-str-replace-editor.zh.md: 71034ba615e09e09ec03212b6d5535959df73f4a

+ 1 - 1
.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md

@@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m
 
 Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable.
 
-The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary.
+The shipped [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md

@@ -18,7 +18,7 @@ Status: implemented
 
 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。
 
-随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。
+随附的 [`minimal` agent preset](../../../../packages/preset/agent-presets/presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md)负责说明。
 
 ## 考虑过的替代方案
 

+ 1 - 1
AGENTS.md

@@ -124,7 +124,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)).
 - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)).
 - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless runnable-example snapshot; package, e2e-only, and mock-only tests do not substitute. Fixtures replay on macOS/Linux; fix fixtures, not normalizers.
-- **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)).
+- **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)).
 - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change.
 - **Both SDKs project the loop.** Agent-loop, session-lifecycle, and `SessionEventMap` changes update the TypeScript and Python SDK expected outputs in the same PR; `pnpm run test` covers neither ([surfaces](docs/testing.md#when-a-snapshot-test-is-required)).
 - **Choose PR history deliberately.** Split independent changes and fix the introducing PR before propagation. Standalone/stack branches may merge-forward or rebase. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; preserve an in-progress merge-forward checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)).

+ 2 - 0
THIRD_PARTY_NOTICES.md

@@ -87,6 +87,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
 | [`react`](https://github.com/facebook/react) | MIT |
 | [`react-dom`](https://github.com/facebook/react) | MIT |
+| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT |
 | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
 | [`shiki`](https://github.com/shikijs/shiki) | MIT |
 | [`supports-color`](https://github.com/chalk/supports-color) | MIT |
@@ -140,6 +141,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/readable-stream`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |

+ 2 - 3
apps/cli/package.json

@@ -15,12 +15,11 @@
     "dsh": "lib/bin.js"
   },
   "files": [
-    "lib/*.js",
-    "config"
+    "lib/*.js"
   ],
   "dsh": {
     "configTrees": [
-      { "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true }
+      { "mount": "config/agent-presets", "path": "../../packages/preset/agent-presets/presets", "scanRoster": true }
     ]
   },
   "license": "MIT",

+ 3 - 25
apps/cli/src/profile-boot.ts

@@ -30,10 +30,6 @@ import {
   type Profile,
 } from '@deepseek-ai/dsh-app-boot'
 import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
-
-/** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */
-const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url))
-
 import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
 import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
 import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
@@ -126,7 +122,7 @@ export function prepareProfile(name: string, userLayer = true): Profile {
   return profile
 }
 
-/** One profile's patch layers (application order) and the row index of its pre-flag composition. */
+/** One profile's patch layers, in application order. */
 interface ComposedProfile {
   profile: Profile
   /** Bundle layers concatenated — the part below the user layers on a live reload. */
@@ -135,11 +131,6 @@ interface ComposedProfile {
   homePatches: PatchOptions[]
   /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */
   overlays: PatchOptions[]
-  /**
-   * id → row of the composed tree (bundles + user layers + overlays), for the
-   * launcher's own row checks.
-   */
-  rows: ReadonlyMap<string, EntryOptions>
 }
 
 /** The full patch stack of one composed profile, in application order. */
@@ -161,7 +152,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
  * then the telemetry switch.
  * @param name - the profile name.
  * @param patchFiles - `--patch` overlay paths, in argv order.
- * @returns the profile, its patch layers, and the composed row index.
+ * @returns the profile and its patch layers.
  */
 function composeProfile(
   name: string,
@@ -176,22 +167,9 @@ function composeProfile(
     if (typeof row.id === 'string') rows.set(row.id, row)
   }
   const composedOverlays = [...overlays]
-  // The SHIPPED root is the part of the roster only this app can resolve: it
-  // sits beside this app's own config, in both the source and built layouts.
-  // The writable root the roster appends is `dsh-agent-presets`' own, so a
-  // launcher that never reaches this patch still finds a person's presets.
-  if (rows.has('agent-presets')) {
-    composedOverlays.push({
-      id: 'agent-presets',
-      config: {
-        ...(rows.get('agent-presets')?.config ?? {}) as Record<string, unknown>,
-        roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }],
-      },
-    })
-  }
   const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
   if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch)
-  return { profile, bundlePatches, homePatches, overlays: composedOverlays, rows }
+  return { profile, bundlePatches, homePatches, overlays: composedOverlays }
 }
 
 /** Options for {@link runProfile}. */

+ 80 - 34
apps/cli/tests/web-agent-presets.e2e.ts

@@ -11,7 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
-import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
+import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
 import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-compaction-basic'
@@ -21,7 +21,6 @@ import type {} from '@deepseek-ai/dsh-tools'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-token-meter'
 
-const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url))
 const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
 /** The shipped Web surface: the dsh-base and dsh-web-app bundle patches over an empty preset root. */
 const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
@@ -56,8 +55,8 @@ async function bootWeb(
     // The settings row defaults to `$DSH_HOME/settings.yaml`. Left alone it
     // reads the developer's own document — and since the default preset is a
     // setting, a stored `agent-presets.default` would decide this file's
-    // outcome. Point it at a temp file for the same reason the roster below
-    // names only the shipped root.
+    // outcome. Point it at a temp file for the same reason the roster row
+    // below pins `includeUserRoot` off.
     { id: 'settings', config: { path: settingsFile, watch: false } },
     // storage-json's root is anchored to the real $DSH_HOME. Unpinned, this
     // file writes the developer's own `~/.dsh/storages/` — and then reads it
@@ -95,18 +94,12 @@ async function bootWeb(
       { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
       { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
     ] },
-    // The roster AppCLIEntry would patch in; only the shipped root, so a
-    // developer's own `~/.dsh/.preset` cannot change this test's outcome.
+    // Pin the roster away from the developer's machine: `includeUserRoot`
+    // false keeps `~/.dsh/.agent-presets` from changing a test's outcome.
     // `default` here is the COMPOSITION default — the base layer the settings
-    // document overrides.
-    {
-      id: 'agent-presets',
-      config: {
-        default: 'standard',
-        roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
-        includeUserRoot: false,
-      },
-    },
+    // document overrides. No `roots` entry: the plugin bundles the shipped
+    // presets itself and prepends their root.
+    { id: 'agent-presets', config: { default: 'standard', includeUserRoot: false } },
     ...extra,
   ]
   // The surface is patch layers over an empty preset root, so the root sits
@@ -369,7 +362,7 @@ describe('the shipped Web composition', () => {
     // The preset's skill root is derived from its own `baseUrl`, so the skill
     // travels with the directory wherever the preset is installed.
     const skill = join(
-      CONFIG_DIR, 'agent-presets', 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
+      SHIPPED_PRESET_ROOT, 'cordis', 'skills', 'editing-cordis-compositions', 'SKILL.md',
     )
 
     expect((await readFile(skill, 'utf8')).startsWith('---\nname: editing-cordis-compositions')).toBe(true)
@@ -441,7 +434,7 @@ describe('the shipped Web composition', () => {
     // agent down disposes its whole subtree. Inherited, that rewrote the
     // shipped composition — truncating it to `[]` the first time a session
     // ended — so `PresetTree` refuses to write at all.
-    const path = join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml')
+    const path = join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml')
     const before = await readFile(path, 'utf8')
 
     const handle = await ctx.agents.create({
@@ -469,7 +462,7 @@ describe('product Bundle and user-preset intersection', () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
     const userRoot = join(root, 'presets')
     const settingsFile = join(root, 'settings.yaml')
-    const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8')
+    const standard = await readFile(join(SHIPPED_PRESET_ROOT, 'standard', 'agent.cordis.yml'), 'utf8')
     await writeFile(settingsFile, '{}\n')
     for (const id of presetIds) {
       let composition = standard
@@ -496,10 +489,8 @@ describe('product Bundle and user-preset intersection', () => {
         id: 'agent-presets',
         config: {
           default: 'standard',
-          roots: [
-            { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
-            { path: userRoot, trust: 'user' },
-          ],
+          // The shipped root is the plugin's own, prepended before this.
+          roots: [{ path: userRoot, trust: 'user' }],
           includeUserRoot: false,
         },
       },
@@ -736,15 +727,11 @@ describe('a launcher that configures no writable root', () => {
     )
     const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-preset-derived-settings-')), 'settings.yaml')
     await writeFile(settingsFile, '{}\n')
-    // Only the shipped root, exactly what `composeProfile` supplies; the
+    // No configured roots: the shipped one is the plugin's own, and the
     // writable one is the roster's own default rather than this patch's job.
     derivedCtx = await bootWeb(settingsFile, [{
       id: 'agent-presets',
-      config: {
-        default: 'standard',
-        roots: [{ path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }],
-        includeUserRoot: true,
-      },
+      config: { default: 'standard', includeUserRoot: true },
     }])
   }, 120_000)
 
@@ -787,12 +774,10 @@ describe('authoring a preset on the shipped composition', () => {
       id: 'agent-presets',
       config: {
         default: 'standard',
-        roots: [
-          { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' },
-          // The root does not exist yet: a deployment whose user has authored
-          // nothing is the normal first-run state.
-          { path: userRoot, trust: 'user' },
-        ],
+        // The root does not exist yet: a deployment whose user has authored
+        // nothing is the normal first-run state. The shipped root is the
+        // plugin's own, prepended before this.
+        roots: [{ path: userRoot, trust: 'user' }],
         includeUserRoot: false,
       },
     }])
@@ -899,3 +884,64 @@ describe('a session keeps the preset it was created with', () => {
     }
   })
 })
+
+describe('a composition that configures its own preset roots', () => {
+  let rootsCtx: Context
+  let teamRoot: string
+
+  beforeAll(async () => {
+    const home = await mkdtemp(join(tmpdir(), 'dsh-preset-roots-'))
+    const settingsFile = join(home, 'settings.yaml')
+    await writeFile(settingsFile, '{}\n')
+    // A workspace-shared root beside the deployment: one preset of its own,
+    // plus a directory that claims a shipped id.
+    teamRoot = join(home, 'team-presets')
+    const minimalComposition = await readFile(join(SHIPPED_PRESET_ROOT, 'minimal', 'agent.cordis.yml'), 'utf8')
+    for (const id of ['team-spec', 'minimal']) {
+      await mkdir(join(teamRoot, id), { recursive: true })
+      await writeFile(join(teamRoot, id, 'agent.cordis.yml'), minimalComposition)
+    }
+    // The user layer of the reported regression: a profile's cordis.patch.yml
+    // configuring a shared preset root. The plugin must EXTEND it with its
+    // own shipped root, never lose it.
+    rootsCtx = await bootWeb(settingsFile, [{
+      id: 'agent-presets',
+      config: {
+        default: 'standard',
+        roots: [{ path: teamRoot, trust: 'user' }],
+        includeUserRoot: false,
+      },
+    }])
+  }, 120_000)
+
+  afterAll(async () => {
+    await rootsCtx.fiber.dispose()
+  })
+
+  it('keeps configured roots alongside the always-prepended shipped root', async () => {
+    expect(rootsCtx.agentPresets.roots.map(root => root.path)).toEqual([
+      SHIPPED_PRESET_ROOT,
+      teamRoot,
+    ])
+
+    const listed = await rootsCtx.agentPresets.list()
+    expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec'])
+    expect(listed.every(preset => preset.broken === undefined)).toBe(true)
+    // The shipped root comes first: a configured directory claiming a shipped
+    // id is shadowed, never the other way around.
+    expect(listed.find(preset => preset.id === 'minimal')?.trust).toBe('system')
+    expect(listed.find(preset => preset.id === 'team-spec')?.trust).toBe('user')
+  })
+
+  it('composes an agent from a configured-root preset', async () => {
+    const handle = await rootsCtx.agents.create({
+      sessionId: SessionId('preset-team-spec'),
+      setup: agentCtx => rootsCtx.agentPresets.mount(agentCtx, 'team-spec').then(() => undefined),
+    })
+    try {
+      expect(toolNames(rootsCtx, handle.agent)).toEqual(['bash', 'str_replace_editor'])
+    } finally {
+      await handle.dispose()
+    }
+  })
+})

+ 3 - 2
apps/cli/tests/windows-shell.spec.ts

@@ -13,11 +13,12 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
+import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import yaml from 'js-yaml'
 import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
 import { evaluate } from '@deepseek-ai/cordis-plugin-loader'
+import { SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
 import { composeEntries, initProfile, loadProfile, PROFILES_DIR } from '@deepseek-ai/dsh-app-boot'
 
 /**
@@ -101,7 +102,7 @@ describe('the shipped shell composition (real bundle layers)', () => {
 })
 
 describe('shipped agent presets gate both shell tools by platform', () => {
-  const presetRoot = resolve(fileURLToPath(new URL('../package.json', import.meta.url)), '..', 'config', 'agent-presets')
+  const presetRoot = SHIPPED_PRESET_ROOT
 
   it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => {
     const entries: unknown = yaml.load(

+ 9 - 7
apps/web/src/preview.ts

@@ -1,12 +1,14 @@
 /**
  * Worker-preview bootstrap: the one module preview.html adds ahead of the
- * stock entry tag. Connecting the worker host installs the boot globals and
- * settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds,
- * so everything after this module is the served startup chain verbatim. A
- * failed handshake rejects the deferred into the boot page's failure
- * rendering; this module owns no page painting.
+ * stock entry tag. The runtime's optional source stage owns the pre-Cordis
+ * chooser; the unchanged Host connector then owns the Worker handshake.
+ * Everything after those calls is the served startup chain verbatim.
  */
 import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker'
-import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client'
+import {
+  chooseWorkerHostSource, connectWorkerHost, IMAGE_FILE_NAME,
+} from '@deepseek-ai/dsh-experimental-webworker-runtime/client'
 
-await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` })
+const image = `preview/${IMAGE_FILE_NAME}`
+const source = await chooseWorkerHostSource({ image })
+await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image, overlays: source.overlays })

+ 4 - 6
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -27,8 +27,8 @@ const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md')
 const COPY_DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'copy-dialog.expected.md')
 const CREATED_EXPECTED = join(SNAPSHOT_DIR, 'created.expected.md')
 const DAMAGED_EXPECTED = join(SNAPSHOT_DIR, 'damaged.expected.md')
-/** The shipped roster, beside the composition that names it. */
-const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
+/** The shipped roster, bundled inside the `dsh-agent-presets` package. */
+const SHIPPED_PRESETS = fileURLToPath(new URL('../../../packages/preset/agent-presets/presets', import.meta.url))
 const OVERLAY = fileURLToPath(new URL('./agent-preset-authoring.overlay.yml', import.meta.url))
 const MODE = webSnapshotMode()
 
@@ -60,10 +60,8 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     scaffold = await launchWebScaffold({
       extraOverlayPath: OVERLAY,
       agentPresets: {
-        roots: [
-          { path: SHIPPED_PRESETS, trust: 'system' },
-          { path: userRoot, trust: 'user' },
-        ],
+        // The shipped root is the plugin's own, prepended before this.
+        roots: [{ path: userRoot, trust: 'user' }],
         default: 'standard',
       },
     })

+ 5 - 9
apps/web/tests/agent-preset-selection.e2e.ts

@@ -1,7 +1,5 @@
-// Web e2e scenario: agent-preset selection. The roster's `roots` is an
-// assembly fact the CLI entry resolves and patches in, so every other lane
-// boots with an empty roster and no preset surface at all; this is the one
-// lane that mounts the SHIPPED presets and puts them in front of a browser.
+// Web e2e scenario: agent-preset selection. Every lane mounts the plugin's
+// own shipped presets; this is the lane that puts them in front of a browser.
 //
 // Two surfaces, one host rule: a session's composition is fixed when the
 // session starts. Before that, the new-session chip stages the choice beside
@@ -30,8 +28,6 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-preset-selection',
 const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
 const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
 const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
-/** The shipped roster, beside the composition that names it. */
-const SHIPPED_PRESETS = fileURLToPath(new URL('../../cli/config/agent-presets', import.meta.url))
 const MODE = webSnapshotMode()
 const SEED_ID = 'agent-preset-selection-web-e2e'
 /** A project skill only a preset that mounts `skill-filesystem` can discover. */
@@ -173,9 +169,9 @@ describe('web e2e: agent-preset selection', () => {
   let tripwire: ReturnType<typeof watchConsole>
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({
-      agentPresets: { roots: [{ path: SHIPPED_PRESETS, trust: 'system' }], default: 'standard' },
-    })
+    // The scaffold's default roster pin is exactly this scenario's shape: the
+    // plugin's shipped presets, default `standard`.
+    scaffold = await launchWebScaffold({})
     // A resumed session runs what it was created with; seeding one that
     // records `minimal` is what makes the header label a claim about the
     // session rather than an echo of the current default.

+ 265 - 28
apps/web/tests/preview-boot.e2e.ts

@@ -8,25 +8,33 @@
  * Two milestones prove that happened — the host's `tree active` boot line,
  * whose lowering contract must be the one this checkout's packer emits, and the
  * workspace hero, which paints only after the client tree comes up over the
- * tunnel.
+ * tunnel. The same page opens the seeded Workspace and showcase Session,
+ * verifies its tool/subagent/history examples, then writes through the
+ * settings and credentials providers. That keeps the upstream Chokidar
+ * instances exercised over the Worker filesystem implementation.
  *
  * The site is served the way a static host serves it: bytes from `dist/` with
  * no rewrite rules, so a missing file is a 404 rather than the index page.
  */
-import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { readFile } from 'node:fs/promises'
 import { createServer } from 'node:http'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { tmpdir } from 'node:os'
-import { extname, join, normalize } from 'node:path'
+import { dirname, extname, join, normalize } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { chromium } from 'playwright'
 import type { Browser } from 'playwright'
 import { expect, it } from 'vitest'
 import {
-  composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT,
+  composeProfile, configTrees, indexWorkspacePackages, packVfsImage, packVfsOverlay,
+  previewFixtures, WRAPPER_CONTRACT,
 } from '@deepseek-ai/dsh-experimental-webworker-packer'
-import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime'
+import {
+  IMAGE_FILE_NAME, PREVIEW_FIXTURE_MANIFEST_FILE, PREVIEW_FIXTURE_MANIFEST_VERSION,
+  type PreviewFixtureManifest,
+} from '@deepseek-ai/dsh-experimental-webworker-runtime'
+import { captureStableAria, compareOrRefreshGolden, webSnapshotMode } from './scaffold.ts'
 import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
 
 const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
@@ -34,9 +42,22 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
 /** Where the client looks for the image: the runtime's own name, beside the page. */
 const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
 
+/** Built-in source catalog read by the pre-boot chooser. */
+const FIXTURE_MANIFEST_FILE = join(DIST_ROOT, 'preview', PREVIEW_FIXTURE_MANIFEST_FILE)
+
+/** Keyless browser golden for the pre-Worker source chooser. */
+const SOURCE_CHOOSER_EXPECTED = fileURLToPath(new URL('./snapshots/preview-boot/source-chooser.expected.md', import.meta.url))
+
+const SNAPSHOT_MODE = webSnapshotMode()
+
 /** Profile the preview deployment composes; `build:preview` packs the same one. */
 const PROFILE = 'web'
 
+/** Stable labels authored by the deterministic VFS example fixture. */
+const SHOWCASE_TITLE = 'WebWorker Preview Showcase'
+const SHOWCASE_TAIL = 'Preview tour complete'
+const SHOWCASE_OLDEST = 'History checkpoint 01: verify deterministic preview state.'
+
 /** Pages the preview needs; the Vite build emits both. */
 const PAGES = ['index.html', 'preview.html']
 
@@ -75,6 +96,12 @@ interface Site {
   close(): Promise<void>
 }
 
+interface PreviewAssets {
+  /** Static-host-relative path to a generated file outside `dist/`. */
+  readonly overrides: ReadonlyMap<string, string>
+  cleanup(): void
+}
+
 /**
  * Fail before the browser opens a page the build never produced.
  * @throws When either preview page is missing from `dist/`.
@@ -87,20 +114,25 @@ function requirePreviewPages(): void {
 }
 
 /**
- * The image file to serve, packed here when `dist/` carries none: `pnpm run
- * build` emits the pages but only `build:preview` packs, so this lane packs
- * for itself rather than skipping the deployment it is here to accept. An
- * image already in place is used as it stands — the worker refuses one lowered
- * against another wrapper contract, and that refusal names the rebuild. A
- * self-packed image lands in a temp directory, never in `dist/`: the
+ * The base image, fixture manifest, and overlays to serve, packed here when
+ * `dist/` does not carry the complete set: `pnpm run build` emits the pages but
+ * only `build:preview` packs these files, so this lane packs for itself rather
+ * than skipping the deployment it accepts. A complete built set is used as it
+ * stands — the worker refuses a base lowered against another wrapper contract.
+ * Self-packed files land in a temp directory, never in `dist/`: the
  * client-artifact digest record treats `dist/` as build-owned, so a test write
  * there fails the record check for every later consumer.
- * @returns The file to answer `preview/<image>` with, and its teardown.
+ * @returns Static-path overrides and their teardown.
  * @throws When the closure leaves dependencies unresolved, which would pack an
  * incomplete image the tree fails on later and further from the cause.
  */
-function requireVfsImage(): { path: string; cleanup(): void } {
-  if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} }
+function requireVfsAssets(): PreviewAssets {
+  const fixtureDefinitions = previewFixtures(REPO_ROOT)
+  const fixtureFiles = fixtureDefinitions.map(fixture =>
+    join(DIST_ROOT, 'preview', 'fixtures', `${fixture.id}.tar.gz`))
+  if ([IMAGE_FILE, FIXTURE_MANIFEST_FILE, ...fixtureFiles].every(existsSync)) {
+    return { overrides: new Map(), cleanup: () => {} }
+  }
   const packed = packVfsImage({
     config: composeProfile(REPO_ROOT, PROFILE),
     profile: PROFILE,
@@ -112,23 +144,48 @@ function requireVfsImage(): { path: string; cleanup(): void } {
     throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
   }
   const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
-  const path = join(directory, IMAGE_FILE_NAME)
-  writeFileSync(path, packed.image)
-  return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
+  const overrides = new Map<string, string>()
+  const writeAsset = (relativePath: string, bytes: Uint8Array | string): void => {
+    const path = join(directory, relativePath)
+    mkdirSync(dirname(path), { recursive: true })
+    writeFileSync(path, bytes)
+    overrides.set(relativePath, path)
+  }
+  writeAsset(`preview/${IMAGE_FILE_NAME}`, packed.image)
+  const fixtures = fixtureDefinitions.map((fixture) => {
+    const relativePath = `preview/fixtures/${fixture.id}.tar.gz`
+    writeAsset(relativePath, packVfsOverlay(fixture.trees).image)
+    return {
+      id: fixture.id,
+      label: fixture.label,
+      description: fixture.description,
+      overlays: [`fixtures/${fixture.id}.tar.gz`],
+    }
+  })
+  const manifest: PreviewFixtureManifest = {
+    version: PREVIEW_FIXTURE_MANIFEST_VERSION,
+    defaultFixture: fixtures[0]?.id ?? null,
+    fixtures,
+  }
+  writeAsset(`preview/${PREVIEW_FIXTURE_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`)
+  return { overrides, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
 }
 
 /**
- * Answer one request with the file it names under `dist/`; the image path
- * answers from wherever {@link requireVfsImage} put the file.
+ * Answer one request with its generated override or the file under `dist/`.
  * @param request - Incoming request; only its path is read.
  * @param response - Response to write the bytes or the 404 to.
- * @param imagePath - File behind `preview/<image>`.
+ * @param overrides - Generated deployment files used when `dist/` has none.
  */
-async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise<void> {
+async function respond(
+  request: IncomingMessage,
+  response: ServerResponse,
+  overrides: ReadonlyMap<string, string>,
+): Promise<void> {
   const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
   const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '')
   try {
-    const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative))
+    const body = await readFile(overrides.get(relative) ?? join(DIST_ROOT, relative))
     response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' })
     response.end(body)
   } catch {
@@ -142,11 +199,11 @@ async function respond(request: IncomingMessage, response: ServerResponse, image
 
 /**
  * Serve `dist/` over loopback with static-host semantics.
- * @param imagePath - File behind `preview/<image>`.
+ * @param overrides - Generated deployment files used when `dist/` has none.
  * @returns The origin to navigate, and its teardown.
  */
-async function serveDist(imagePath: string): Promise<Site> {
-  const server = createServer((request, response) => { void respond(request, response, imagePath) })
+async function serveDist(overrides: ReadonlyMap<string, string>): Promise<Site> {
+  const server = createServer((request, response) => { void respond(request, response, overrides) })
   await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
   const address = server.address()
   if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
@@ -186,12 +243,13 @@ async function within<T>(work: Promise<T>, ms: number, stalled: string): Promise
 
 it('boots the packed worker deployment to an interactive page', async () => {
   requirePreviewPages()
-  const image = requireVfsImage()
+  const assets = requireVfsAssets()
   try {
-    const site = await serveDist(image.path)
+    const site = await serveDist(assets.overrides)
     try {
       const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
       try {
+        await bootEmptyPreview(site.origin, browser)
         await bootPreview(site.origin, browser)
       } finally {
         await browser.close()
@@ -200,7 +258,7 @@ it('boots the packed worker deployment to an interactive page', async () => {
       await site.close()
     }
   } finally {
-    image.cleanup()
+    assets.cleanup()
   }
 }, 600_000)
 
@@ -212,6 +270,7 @@ it('boots the packed worker deployment to an interactive page', async () => {
 async function bootPreview(origin: string, browser: Browser): Promise<void> {
   const page = await newEnglishPage(browser)
   const pageErrors: Error[] = []
+  const consoleErrors: string[] = []
   page.on('pageerror', (error) => { pageErrors.push(error) })
   // Registered before navigation: the worker reports its tree long before the
   // tunnel serves the client, so a listener added later would miss the line.
@@ -219,20 +278,136 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
     page.on('console', (message) => {
       const text = message.text()
       if (text.includes(TREE_ACTIVE)) reported(text)
+      if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text)
     })
   })
   try {
     await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' })
+    await page.getByRole('heading', { name: 'Choose Preview data' }).waitFor()
+    expect(await page.locator('input[name="preview-source"][value="vfs-example"]').isChecked()).toBe(true)
+    expect(await page.getByText('Empty environment', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('WebFS directory', { exact: true }).count()).toBe(1)
+    expect(await page.locator('input[name="preview-source"][value="webfs"]').isDisabled()).toBe(true)
+    expect(await page.getByRole('textbox', { name: 'Choose workspace' }).count()).toBe(0)
+    await compareOrRefreshGolden(
+      SOURCE_CHOOSER_EXPECTED,
+      await captureStableAria(page, '[data-preview-source-card]', '/__preview_no_workspace__'),
+      SNAPSHOT_MODE,
+    )
+    await page.getByRole('button', { name: 'Start Preview' }).click()
+    await page.getByText('Loading plugins…', { exact: true }).waitFor({ timeout: 10_000 })
     const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`)
     // The activated tree ran bodies lowered against the contract this
     // checkout's packer emits; a dist built before a contract change would
     // report the older one.
     expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
+    expect(bootLine).toContain('data overlays=1')
     // The hero's workspace picker is the client tree's first interactive
     // surface, so it appears only once the startup chain completed over the
     // tunnel.
     await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
+    const continueButton = page.getByRole('button', { name: 'Continue' })
+    await continueButton.waitFor({ timeout: 30_000 })
+    await continueButton.click()
+    const configureLater = page.getByRole('button', { name: 'Configure later' })
+    await configureLater.waitFor({ timeout: 30_000 })
+    await configureLater.click()
+    await page.locator('textarea:enabled[placeholder="Describe what you want to build"]')
+      .waitFor({ timeout: 30_000 })
+
+    const exercised = await page.evaluate(async () => {
+      type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } }
+      interface PreviewApi {
+        host: { createDirectory(payload: { path: string; name: string }): Promise<Result<{ path: string }>> }
+        skills: { list(payload: { sessionId: string }): Promise<Result<{ skills: unknown[] }>> }
+        settings: {
+          describe(payload: object): Promise<Result<{ namespaces: Array<{ ns: string; revision: number }> }>>
+          update(payload: { ns: string; patch: object; expectedRevision: number }): Promise<Result<unknown>>
+        }
+        credentials: {
+          set(payload: { ref: string; value: string }): Promise<Result<unknown>>
+          unset(payload: { ref: string }): Promise<Result<unknown>>
+          describe(payload: { refs: string[] }): Promise<Result<{
+            credentials: Record<string, { configured: boolean }>
+          }>>
+        }
+      }
+      interface PreviewTransport {
+        fetch(input: string, init: RequestInit): Promise<Response>
+        createApiClient(): PreviewApi
+      }
+      const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: PreviewTransport }).__DSH_TRANSPORT__
+      if (transport === undefined) throw new Error('preview transport is absent after boot')
+      const response = await transport.fetch('/api/session/list', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({
+          type: 'client-request', rpcId: 'preview-session-list', method: 'session/list',
+          payload: { args: { _request: {} } },
+        }),
+      })
+      const sessions = await response.json() as Result<{ items: Array<{ sessionId: string }> }>
+      if (!sessions.result.ok) throw new Error(`session/list failed: ${sessions.result.error.message}`)
+      const sessionId = sessions.result.value.items[0]?.sessionId
+      if (sessionId === undefined) throw new Error('workspace adoption created no Session')
+
+      const api = transport.createApiClient()
+      const skills = await api.skills.list({ sessionId })
+      if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`)
+      const createDirectory = async (path: string, name: string): Promise<void> => {
+        const created = await api.host.createDirectory({ path, name })
+        if (!created.result.ok) throw new Error(`host.createDirectory failed: ${created.result.error.message}`)
+        await new Promise((resolve) => { setTimeout(resolve, 250) })
+        const refreshed = await api.skills.list({ sessionId })
+        if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`)
+      }
+      await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created')
+      const settings = await api.settings.describe({})
+      if (!settings.result.ok) throw new Error(`settings.describe failed: ${settings.result.error.message}`)
+      const shell = settings.result.value.namespaces.find(namespace => namespace.ns === 'shell')
+      if (shell === undefined) throw new Error('settings.describe omitted the shell namespace')
+      const updated = await api.settings.update({ ns: 'shell', patch: { timeoutMs: 61_000 }, expectedRevision: shell.revision })
+      if (!updated.result.ok) throw new Error(`settings.update failed: ${updated.result.error.message}`)
+      const stored = await api.credentials.set({ ref: 'PREVIEW_TEST_SECRET', value: 'worker-only' })
+      if (!stored.result.ok) throw new Error(`credentials.set failed: ${stored.result.error.message}`)
+      const credentials = await api.credentials.describe({ refs: ['PREVIEW_TEST_SECRET'] })
+      if (!credentials.result.ok) throw new Error(`credentials.describe failed: ${credentials.result.error.message}`)
+      const removed = await api.credentials.unset({ ref: 'PREVIEW_TEST_SECRET' })
+      if (!removed.result.ok) throw new Error(`credentials.unset failed: ${removed.result.error.message}`)
+      await new Promise((resolve) => { setTimeout(resolve, 250) })
+      return {
+        skillCount: skills.result.value.skills.length,
+        credentialConfigured: credentials.result.value.credentials.PREVIEW_TEST_SECRET?.configured,
+      }
+    })
+    expect(exercised.skillCount).toBeGreaterThan(0)
+    expect(exercised.credentialConfigured).toBe(true)
+
+    const sessions = page.getByRole('tree', { name: 'Sessions' })
+    const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE })
+    await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1)
+    await showcase.click()
+    await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
+
+    expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
+    await page.getByText('PREVIEW.md', { exact: true }).waitFor()
+    await page.getByText('src/preview.ts', { exact: true }).waitFor()
+    await page.getByText('Update to-do list', { exact: true }).waitFor()
+    await page.getByText('Error: ENOENT: no such file, open missing.txt', { exact: true }).waitFor()
+
+    const subagents = page.getByRole('button', { name: '2 subagents' })
+    await subagents.waitFor({ timeout: 15_000 })
+    await subagents.hover()
+    const catalog = page.getByRole('tree', { name: 'Subagent sessions' })
+    await catalog.getByRole('treeitem', { name: /Review preview architecture/ }).waitFor()
+    await catalog.getByRole('treeitem', { name: /Continue preview verification/ }).waitFor()
+    await catalog.press('Escape')
+
+    await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
+    await page.getByText(SHOWCASE_OLDEST, { exact: true }).waitFor({ timeout: 15_000 })
     expect(pageErrors.map(error => error.message)).toEqual([])
+    expect(consoleErrors.filter(line =>
+      /watchFile|failed to watch|node-addon-landlock-run\.probe|sandbox backend is usable|SANDBOX_UNAVAILABLE/i.test(line))).toEqual([])
   } catch (error) {
     await saveFailureShot(page, 'preview-boot')
     throw pageErrors.length === 0
@@ -240,3 +415,65 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
       : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors')
   }
 }
+
+/** Verify the chooser can boot the untouched base image and reach first-run UI. */
+async function bootEmptyPreview(origin: string, browser: Browser): Promise<void> {
+  const page = await newEnglishPage(browser)
+  const pageErrors: Error[] = []
+  const consoleErrors: string[] = []
+  const failedResponses: string[] = []
+  page.on('pageerror', (error) => { pageErrors.push(error) })
+  page.on('response', (response) => {
+    if (response.status() >= 400) failedResponses.push(new URL(response.url()).pathname)
+  })
+  const treeActive = new Promise<string>((reported) => {
+    page.on('console', (message) => {
+      const text = message.text()
+      if (text.includes(TREE_ACTIVE)) reported(text)
+      if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(text)
+    })
+  })
+  try {
+    await page.goto(`${origin}/preview.html?preview-fixture=none`, { waitUntil: 'domcontentloaded' })
+    expect(await page.getByRole('heading', { name: '选择 Preview 数据源' }).count()).toBe(0)
+    const bootLine = await within(
+      treeActive,
+      BOOT_TIMEOUT_MS,
+      `empty preview boot: the worker never reported "${TREE_ACTIVE}"`,
+    )
+    expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
+    expect(bootLine).toContain('data overlays=0')
+    await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
+    const sessionCount = await page.evaluate(async () => {
+      const transport = (globalThis as typeof globalThis & {
+        __DSH_TRANSPORT__?: { fetch(input: string, init: RequestInit): Promise<Response> }
+      }).__DSH_TRANSPORT__
+      if (transport === undefined) throw new Error('empty preview transport is absent after boot')
+      const response = await transport.fetch('/api/session/list', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({
+          type: 'client-request', rpcId: 'empty-preview-session-list', method: 'session/list',
+          payload: { args: { _request: {} } },
+        }),
+      })
+      const body = await response.json() as {
+        result: { ok: true; value: { items: unknown[] } } | { ok: false; error: { message: string } }
+      }
+      if (!body.result.ok) throw new Error(`empty session/list failed: ${body.result.error.message}`)
+      return body.result.value.items.length
+    })
+    expect(sessionCount).toBe(0)
+    expect(pageErrors.map(error => error.message)).toEqual([])
+    expect(failedResponses).toEqual(['/plugins/events'])
+    expect(consoleErrors.filter(line => !line.includes('Failed to load resource: the server responded with a status of 404')))
+      .toEqual([])
+  } catch (error) {
+    await saveFailureShot(page, 'preview-boot-empty')
+    throw pageErrors.length === 0
+      ? error
+      : new AggregateError([error, ...pageErrors], 'empty preview boot failed, with uncaught page errors')
+  } finally {
+    await page.close()
+  }
+}

+ 10 - 18
apps/web/tests/scaffold.ts

@@ -105,8 +105,6 @@ const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
 const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
 /** The installation anchor whose dependency surface the profile module fallback mirrors. */
 const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
-/** The deployment's own agent-preset root, shipped beside the app's config. */
-const SHIPPED_PRESET_DIR = join(REPO_ROOT, 'apps/cli/config/agent-presets')
 
 // Replay publishes the provider catalog the gateway routes to (providers
 // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
@@ -272,15 +270,14 @@ export interface LaunchOptions {
     apiKeyEnv: string
   }
   /**
-   * Replace the roster the scaffold mounts by default (the shipped directory
-   * at `system` trust, default `standard`). Supply this only to change WHICH
-   * presets a scenario sees — a writable user root, a different default —
-   * never to turn the roster on: without one every session composes an agent
-   * with no tools, no persona, and no token meter, which is not a shape the
-   * product ever boots in. The patch lands after the default, so it wins.
+   * Replace the roster row the scaffold pins by default (no configured roots,
+   * default `standard` — the plugin's own shipped presets). Supply this only
+   * to change WHICH presets a scenario sees beyond the shipped set — a
+   * writable user root, a different default. The patch lands after the
+   * default, so it wins.
    */
   agentPresets?: {
-    /** Roots to discover, in precedence order; the shipped directory is `system`. */
+    /** Roots to discover after the plugin's shipped root, in precedence order. */
     roots: { path: string; trust: 'system' | 'user' }[]
     /** The preset a session that names none is composed from. */
     default: string
@@ -405,19 +402,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     ...basePatches,
     ...surfacePatches,
     ...extraOverlayPatches,
-    // The roster's `roots` is an assembly fact AppCLIEntry resolves and patches
-    // in, exactly like `distIndex` on the webserver row — the shipped preset
-    // directory sits beside the composition that names it, and no config author
-    // chooses it. This lane boots the shipped tree WITHOUT AppCLIEntry, so it
-    // has to supply the same fact or the roster resolves nothing and every
-    // session composes an agent with no tools, no persona, and no token meter.
-    // Only the shipped root: a developer's own `~/.dsh/.agent-presets` must not be
-    // able to change a golden.
+    // The roster's shipped presets are the plugin's own, bundled inside
+    // `dsh-agent-presets` and prepended by it. Pin only the machine-local
+    // root away: a developer's own `~/.dsh/.agent-presets` must not be able
+    // to change a golden.
     {
       id: 'agent-presets',
       config: {
         default: 'standard',
-        roots: [{ path: SHIPPED_PRESET_DIR, trust: 'system' }],
         includeUserRoot: false,
       },
     },

+ 5 - 7
apps/web/tests/seeded-history.e2e.ts

@@ -258,13 +258,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // The seed carries a session/title event: the title unit is host-plane, so
     // it folds the detached log and serves the value with nothing composed.
     expect(typeof projections?.values.title).toBe('string')
-    // `todos` IS here, as its empty fold (null). Its unit is registered by
-    // `tool-todo` inside the default preset's STANDING mount, which the read
-    // itself ensures — deterministically, not because some unrelated session
-    // happens to be composed. A present-but-null key is what keeps the
-    // client's "omitted key = capability absent → clear the row" rule from
-    // wiping preset-owned projections on cold reads.
-    expect(projections?.values).toHaveProperty('todos', null)
+    // `todos` is absent because its unit belongs to the agent preset and this
+    // directly seeded session never composed that preset. History computes
+    // the baseline through the standard projection registry without mounting
+    // an Agent composition as a read side effect.
+    expect(projections?.values).not.toHaveProperty('todos')
     // 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

+ 15 - 0
apps/web/tests/snapshots/preview-boot/source-chooser.expected.md

@@ -0,0 +1,15 @@
+- form "Choose Preview data":
+  - heading "Choose Preview data" [level=1]
+  - paragraph: Data mounts before the Worker and application start. Refresh to choose again.
+  - group "Filesystem source":
+    - text: Filesystem source
+    - radio "Empty environment Load only the base runtime to verify first launch and workspace creation."
+    - strong: Empty environment
+    - text: Load only the base runtime to verify first launch and workspace creation.
+    - radio "Built-in showcase Sample workspace, tool cards, subagents, and paged history." [checked]
+    - strong: Built-in showcase
+    - text: Sample workspace, tool cards, subagents, and paged history.
+    - radio "WebFS directory Requires directory access and will be available after the WebFS provider lands." [disabled]
+    - strong: WebFS directory
+    - text: Requires directory access and will be available after the WebFS provider lands.
+  - button "Start Preview"

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

@@ -203,6 +203,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
         name: 'Parent session offline; sending is unavailable but you can still stop the run',
       })
       await input.waitFor({ timeout: 15_000 })
+      await page.getByText(INITIAL, { exact: true }).waitFor({ timeout: 15_000 })
       expect(await input.isDisabled()).toBe(true)
       const stop = page.getByRole('button', { name: 'Stop generating' })
       expect(await stop.count()).toBe(1)
@@ -247,7 +248,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
       expect(scaffold.ctx.agents.get(childId)?.status).toBe('running')
     } finally {
-      await page.unroute(pattern)
+      await page.unrouteAll({ behavior: 'wait' })
     }
   }, 60_000)
 

+ 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: 098e9058f12e7d851deae5ad3cf01da35d3c75bc
-config-catalog.zh.md: bc766e140db9f036143d647e07ab7164510f69f8
+config-catalog.md: f0b4c89511b97060d739c2503ca752ab12d877f4
+config-catalog.zh.md: 50cdac870efb9a009ccd7d97b4f7cbac3cc400e7

+ 11 - 3
docs/config-catalog.md

@@ -124,9 +124,17 @@ export interface Config {
   default: string
   /** Scanned roots in precedence order; an earlier root wins a duplicate id. */
   roots: PresetRoot[]
+  /**
+   * Prepend this package's bundled shipped presets as a `system` root, before
+   * every configured root, so the shipped set always mounts and wins a
+   * duplicate id. The default survives a whole-`config` patch replacement;
+   * only an explicit `false` — a deployment supplying purely its own presets,
+   * or an embedder using the roster as bare machinery — drops the set.
+   */
+  includeShippedRoot: boolean
   /**
    * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
-   * configured root. False mounts a roster over `roots` alone.
+   * configured root. False mounts a roster without the derived writable root.
    */
   includeUserRoot: boolean
 }
@@ -269,7 +277,7 @@ Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/co
 
 ## `@deepseek-ai/dsh-api-session-controller`
 
-Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry`
+Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry`
 
 ```ts config-catalog
 /** Session Controller deployment policy. */
@@ -2990,7 +2998,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-Source: [`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts)
 
 <a id="deepseek-aidsh-typert-loader"></a>
 

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

@@ -126,9 +126,17 @@ export interface Config {
   default: string
   /** Scanned roots in precedence order; an earlier root wins a duplicate id. */
   roots: PresetRoot[]
+  /**
+   * Prepend this package's bundled shipped presets as a `system` root, before
+   * every configured root, so the shipped set always mounts and wins a
+   * duplicate id. The default survives a whole-`config` patch replacement;
+   * only an explicit `false` — a deployment supplying purely its own presets,
+   * or an embedder using the roster as bare machinery — drops the set.
+   */
+  includeShippedRoot: boolean
   /**
    * Append the harness home's `USER_PRESET_DIR` as a `user` root, after every
-   * configured root. False mounts a roster over `roots` alone.
+   * configured root. False mounts a roster without the derived writable root.
    */
   includeUserRoot: boolean
 }
@@ -271,7 +279,7 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-api-session-controller`
 
-需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `tools` · `typert` · `workspaceRegistry`
+需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionQuery` · `typert` · `workspaceRegistry`
 
 ```ts config-catalog
 /** Session Controller deployment policy. */
@@ -2992,7 +3000,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-来源:[`packages/core/tools/src/index.ts:654`](../packages/core/tools/src/index.ts)
+来源:[`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts)
 
 <a id="deepseek-aidsh-typert-loader"></a>
 

+ 2 - 2
docs/cookbook/adding-a-tool.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/cookbook/adding-a-tool.md
-adding-a-tool.md: 37516521de4d00de964003fd6f877831774fdcd3
-adding-a-tool.zh.md: 6a24d5dc303990f9fe13e77c9a92b3a24ca16647
+adding-a-tool.md: 4e07c33dd372ae95391fcad5236832a6f7662e82
+adding-a-tool.zh.md: 17a024a0db0d63ec9ef8c9407e77a471263427c9

+ 7 - 1
docs/cookbook/adding-a-tool.md

@@ -87,7 +87,13 @@ Hard rules (they bite if broken):
 - **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path—none of these belongs in the canonical value or Native content merely to serve a UI. `output.render` owns model-facing prose; `presentationMeta` plus the card presenters own replayable UI state. A `terminal` result view carries raw output and the adapter adds any fallback framing.
 - **`defineTool` soft-validates the display path.** Malformed or older logged arguments make the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay.
 
-The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Host/client runtimes map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations.
+The neutral vocabulary lives in `dsh-tools`; tools never import a UI or transport type. Consumers of this API map each `card` into their own view. The design and the why are in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations.
+
+## Web Client presentation
+
+The built-in Web Client does not consume `presentCall` or `presentResult`. Session `page` and `follow` transport raw `tool/call` and `tool/result` events, including persisted `result.meta`. A Client plugin registers its wire tool name in the `tool.call.toolview` keyed slot and derives component props from the `ToolCallBlock` arguments, content, error, metadata, existing Code Dispatch `parentCallId`, and Session path facts. It validates these wire values locally and returns the generic row for malformed or unsupported input.
+
+Use `output.presentationMeta(args, value)` when an existing Web card needs bounded structured result facts that model-facing content cannot preserve losslessly. Do not store React props or a selected card in metadata, import a Host tool implementation into a browser bundle, or create another Client presenter registry. Defining Host presentation methods alone does not add a specialized Web card. The [Client-derived presentation Agent Note](../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md) defines ownership, fallback, and equivalence requirements.
 
 ## Verification
 

+ 7 - 1
docs/cookbook/adding-a-tool.zh.md

@@ -89,7 +89,13 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的
 - **UI 格式不进入模型结果。** 围栏 ` ```console ` 块、diff、相对化路径均不应仅为服务 UI 而进入规范值或 Native 内容。`output.render` 负责模型可见的自然语言;`presentationMeta` 和卡片展示器负责可回放的 UI 状态。`terminal` 结果视图携带原始输出,由适配器按需添加回退格式。
 - **`defineTool` 对展示路径做软校验。** 格式错误或旧版日志中的参数会使包装器返回 `undefined`(通用回退)而非抛异常——展示绝不能导致回放崩溃。
 
-中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。host/client 运行时将每个 `card` 映射到各自的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。
+中性词汇定义在 `dsh-tools` 中;工具绝不导入 UI 或传输类型。使用该 API 的消费方把每个 `card` 映射到自己的视图。设计与原因见[渲染意图联合体 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md);`dsh-tool-fs`(generic/diff)和 `dsh-tool-bash`(terminal)是参考实现。
+
+## Web Client 展示
+
+内置 Web Client 不消费 `presentCall` 或 `presentResult`。Session `page` 与 `follow` 运输原始 `tool/call` 和 `tool/result` 事件,包括持久化的 `result.meta`。Client 插件在 keyed slot `tool.call.toolview` 中注册自己的 wire 工具名称,并从 `ToolCallBlock` 的参数、内容、错误、metadata、现有 Code Dispatch `parentCallId` 与 Session 路径事实派生组件 props。插件在本地校验这些 wire 值,并让格式错误或不受支持的输入回退到 generic 行。
+
+现有 Web 卡片需要模型可见内容无法无损保存的有界结构化结果事实时,使用 `output.presentationMeta(args, value)`。不要在 metadata 中保存 React props 或预选卡片,不要把 Host 工具实现导入浏览器 bundle,也不要建立另一套 Client presenter registry。只定义 Host 展示方法不会增加专用 Web 卡片。[Client 派生展示 Agent Note](../../.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md)规定 owner、fallback 与对等要求。
 
 ## 验证
 

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

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

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

@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

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

@@ -23,11 +23,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:462`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:442`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:469`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:448`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:455`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:444`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:424`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:451`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:430`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:437`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 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: 2e5389ea502c3e4361cdeeaa8e3f65de84dba776
-module-graph.zh.md: 8ca0d3d55a8ee1adae1b2cd6d411a6ffbfe95542
+module-graph.md: d394ec02798b6c4ccc217f3a93098165c91cd43c
+module-graph.zh.md: b797852b2349cc8b4e785599129d95ae6a279cd9

+ 2 - 4
docs/module-graph.md

@@ -1086,7 +1086,6 @@ flowchart TD
   pkg_client_connection --> pkg_llm
   pkg_client_connection --> pkg_session
   pkg_client_connection --> pkg_tool_todo
-  pkg_client_connection --> pkg_tools
   pkg_compaction_tool_result_pruner --> pkg_compaction
   pkg_compaction_tool_result_pruner --> pkg_invariants
   pkg_compaction_tool_result_pruner --> pkg_llm
@@ -1259,7 +1258,6 @@ flowchart TD
   pkg_api_session_controller --> pkg_session_query
   pkg_api_session_controller --> pkg_session_title
   pkg_api_session_controller --> pkg_subagent
-  pkg_api_session_controller --> pkg_tools
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_util_workspace_path
@@ -1824,7 +1822,7 @@ flowchart TD
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
-| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) |
+| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -1846,7 +1844,7 @@ flowchart TD
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |

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

@@ -1088,7 +1088,6 @@ flowchart TD
   pkg_client_connection --> pkg_llm
   pkg_client_connection --> pkg_session
   pkg_client_connection --> pkg_tool_todo
-  pkg_client_connection --> pkg_tools
   pkg_compaction_tool_result_pruner --> pkg_compaction
   pkg_compaction_tool_result_pruner --> pkg_invariants
   pkg_compaction_tool_result_pruner --> pkg_llm
@@ -1261,7 +1260,6 @@ flowchart TD
   pkg_api_session_controller --> pkg_session_query
   pkg_api_session_controller --> pkg_session_title
   pkg_api_session_controller --> pkg_subagent
-  pkg_api_session_controller --> pkg_tools
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_util_workspace_path
@@ -1826,7 +1824,7 @@ flowchart TD
 | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
 | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
 | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) |
-| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) |
+| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) |
 | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent) |
 | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -1848,7 +1846,7 @@ flowchart TD
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |

+ 2 - 2
docs/subsystems/client-modules.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/client-modules.md
-client-modules.md: ae767c6a098cb1b7188c06e61efd2798eb93ff66
-client-modules.zh.md: 710453e3e10c298ed8c2b87bbab6e8e9eb154a87
+client-modules.md: 84d673669825495b7e22933da238daba24ec7b13
+client-modules.zh.md: 7cc353007732188f06738a745b750161a11366a5

+ 5 - 6
docs/subsystems/client-modules.md

@@ -14,11 +14,10 @@ The graph is the wire single source between the Node and browser halves: the hos
 /**
  * One composed client entry pushed by the host (a graph row). Wire
  * single source: the host node half (package root) produces this same shape.
- * `immediately` marks stage-one prefetch; `inject` is informational graph
- * metadata (the authoritative edges live in each package's `dsh.client`
- * declaration and reach fibers through entry creation). `external` carries
- * module-graph edges: unlike `inject`, they constrain code arrival because
- * `require` is synchronous (see {@link WebBootGraph.entries}).
+ * `immediately` marks stage-one prefetch. `inject` names package rows whose
+ * factories must arrive before this row materializes, while Cordis separately
+ * uses the same package edges to compose entries. `external` carries exact
+ * non-inject module requests (see {@link WebBootGraph.entries}).
  */
 interface WebBootEntry {
   /** Entry name == package name. */
@@ -27,7 +26,7 @@ interface WebBootEntry {
   url: string
   /** Bundle content hash (cache-busting consistency anchor). */
   rev: string
-  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
+  /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
   /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
   immediately?: boolean

+ 5 - 6
docs/subsystems/client-modules.zh.md

@@ -14,11 +14,10 @@ Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 
 /**
  * One composed client entry pushed by the host (a graph row). Wire
  * single source: the host node half (package root) produces this same shape.
- * `immediately` marks stage-one prefetch; `inject` is informational graph
- * metadata (the authoritative edges live in each package's `dsh.client`
- * declaration and reach fibers through entry creation). `external` carries
- * module-graph edges: unlike `inject`, they constrain code arrival because
- * `require` is synchronous (see {@link WebBootGraph.entries}).
+ * `immediately` marks stage-one prefetch. `inject` names package rows whose
+ * factories must arrive before this row materializes, while Cordis separately
+ * uses the same package edges to compose entries. `external` carries exact
+ * non-inject module requests (see {@link WebBootGraph.entries}).
  */
 interface WebBootEntry {
   /** Entry name == package name. */
@@ -27,7 +26,7 @@ interface WebBootEntry {
   url: string
   /** Bundle content hash (cache-busting consistency anchor). */
   rev: string
-  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
+  /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
   /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
   immediately?: boolean

+ 2 - 2
docs/subsystems/session.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/session.md
-session.md: edb8f4ebb427bfce6e4def023e65f4697608ceb2
-session.zh.md: 7b3c7a8e50688ba19694d5f45e43d224c2245ef1
+session.md: b7806a4989684be7585d8d42ac215fe1ab1540f0
+session.zh.md: a80a3146b50c4c0fdcf4c3e54e1dc4943eb28642

+ 1 - 1
docs/subsystems/session.md

@@ -696,7 +696,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
 /**
  * Read one cold-safe, message-aligned Session history page.
  * @param request - durable address, backward cursor, and page budget.
- * @param signal - cancellation for persistence and presentation reads.
+ * @param signal - cancellation for persistence reads.
  * @returns one chronological page and optional latest projections.
  */
 @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>

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

@@ -700,7 +700,7 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
 /**
  * Read one cold-safe, message-aligned Session history page.
  * @param request - durable address, backward cursor, and page budget.
- * @param signal - cancellation for persistence and presentation reads.
+ * @param signal - cancellation for persistence reads.
  * @returns one chronological page and optional latest projections.
  */
 @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>

+ 1 - 1
examples/acp-agent/tests/acp.snapshot.ts

@@ -39,7 +39,7 @@ const AGENT = {
   tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
 }
 const EDITING_CORDIS_SKILL = fileURLToPath(new URL(
-  '../../../apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md',
+  '../../../packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md',
   import.meta.url,
 ))
 

+ 2 - 1
knip.json

@@ -241,7 +241,8 @@
     "packages/experimental/webworker-runtime": {
       "entry": [
         "tests/**/*.spec.ts",
-        "tests/compile/transform-corpus-check.ts"
+        "tests/compile/transform-corpus-check.ts",
+        "tests/fixtures/vfs-example/workspace/src/preview.ts"
       ],
       "project": [
         "src/**/*.ts",

+ 1 - 1
packages/api/remotes/src/client/index.ts

@@ -52,7 +52,7 @@ export type {
   MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection,
   RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId,
   SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk,
-  SubagentAddress, SubagentCatalog, ToolCallView, ToolResultView,
+  SubagentAddress, SubagentCatalog,
 } from '@deepseek-ai/dsh-client-connection/client'
 export type {} from '@deepseek-ai/dsh-api-gateway/client'
 export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote'

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: cda9349e432472a0ed9fd623afef0b689ff72f73
-README.zh.md: 2aaee8f968cf7373110e291c197adbeca21f490e
+README.md: 7631e1623f90f9349eca78bc76d46505d13d2e0e
+README.zh.md: 7a733b45b1cdbb17096d1e76bb25b54d3bdc0e06

+ 2 - 0
packages/api/session-controller/README.md

@@ -4,6 +4,8 @@ English | [中文](README.zh.md)
 
 `@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state.
 
+History pages and follow event frames carry only raw `SessionWireEvent` values. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
+
 Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces.
 
 The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.

+ 2 - 0
packages/api/session-controller/README.zh.md

@@ -4,6 +4,8 @@
 
 `@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。
 
+历史页与 follow event frame 只携带原始 `SessionWireEvent`。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
+
 每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。
 
 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。

+ 1 - 4
packages/api/session-controller/package.json

@@ -96,7 +96,6 @@
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
@@ -106,8 +105,7 @@
     "@deepseek-ai/dsh-jobs": { "optional": true },
     "@deepseek-ai/dsh-session-persistence": { "optional": true },
     "@deepseek-ai/dsh-session-projection": { "optional": true },
-    "@deepseek-ai/dsh-session-projection-cache": { "optional": true },
-    "@deepseek-ai/dsh-tools": { "optional": true }
+    "@deepseek-ai/dsh-session-projection-cache": { "optional": true }
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -131,7 +129,6 @@
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",

+ 0 - 8
packages/api/session-controller/src/client/sessions/session.ts

@@ -22,7 +22,6 @@ import type {
   SessionQueuedItem,
   SessionRequestId,
   SessionError,
-  SessionToolView,
 } from '../../types.ts'
 import type { ClientFailure, ClientResult } from '../contract/result.ts'
 import { transportResult } from '../contract/result.ts'
@@ -74,9 +73,6 @@ export interface SessionOptions {
 export class Session implements SessionFace {
   // ---- Window and derived state (all private; the snapshot is the only read API) ----
   private eventWindow: SessionEvent[] = []
-  /** Wire views aligned with `eventWindow` by index (envelope annotations; undefined = no view).
-   *  Kept parallel so `eventWindow` remains the raw log slice (model-visible ⟺ logged). */
-  private views: (SessionToolView | undefined)[] = []
   private baseSeq = 0
   private hasMore = false
   private openState: OpenState = 'cold'
@@ -405,7 +401,6 @@ export class Session implements SessionFace {
     this.openState = 'cold'
     this.openError = null
     this.eventWindow = []
-    this.views = []
     this.baseSeq = 0
     this.notifier.markDirty()
     await this.open()
@@ -582,7 +577,6 @@ export class Session implements SessionFace {
   /** Replace the complete contiguous window and apply page-owned projection metadata. */
   private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
     this.eventWindow = entries.map(entry => entry.event as SessionEvent)
-    this.views = entries.map(entry => entry.view)
     this.baseSeq = this.eventWindow[0]?.seq ?? 0
     this.hasMore = hasMore
     if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
@@ -594,7 +588,6 @@ export class Session implements SessionFace {
   /** Prepend one stream-validated history page. */
   private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
     this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
-    this.views = [...entries.map(entry => entry.view), ...this.views]
     this.baseSeq = this.eventWindow[0]?.seq ?? 0
     this.hasMore = hasMore
     this.eventSource.prepend(entries, hasMore)
@@ -604,7 +597,6 @@ export class Session implements SessionFace {
   private appendLive(entry: SessionEventEntry): boolean {
     const event = entry.event as SessionEvent
     this.eventWindow.push(event)
-    this.views.push(entry.view)
     const awaitingFirstTurn = this.firstPromptPendingTurn
     if (event.type === 'turn/start') this.firstPromptPendingTurn = false
     const queueChanged = this.queueMirror.acceptDurable(event)

+ 11 - 138
packages/api/session-controller/src/history.ts

@@ -1,14 +1,10 @@
 /** Cold Session history pagination and live-event source. */
 
 import type { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
 import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
-import type { ScopeKey } from '@deepseek-ai/dsh-scope'
 import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
-import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
 import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
 import type {
   SessionAddress,
@@ -19,34 +15,21 @@ import type {
   SessionPageRequest,
   SessionProjectionsBlock,
   SessionProjectionValues,
-  SessionToolCallView,
-  SessionToolView,
   SessionWireEvent,
 } from './types.ts'
 
 const DEFAULT_MAX_MESSAGES = 50
 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
 
-interface ToolCallData {
-  readonly callId: string
-  readonly name: string
-  readonly arguments: string
-}
-
 type SessionSource =
   | { readonly kind: 'attached'; readonly session: Session }
   | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
 
-interface BufferedEvent {
-  readonly session: Session
-  readonly event: SessionEvent
-}
-
 /** Implements cold-safe history operations delegated by the Session Controller. */
 export class SessionHistoryController {
   private readonly closeFollowers = new Set<() => void>()
 
-  /** @param ctx - Host context carrying Session, persistence, presenter, and projection services. */
+  /** @param ctx - Host context carrying Session, persistence, and projection services. */
   constructor(private readonly ctx: Context) {
     ctx.effect(() => () => {
       for (const close of this.closeFollowers) close()
@@ -57,7 +40,7 @@ export class SessionHistoryController {
   /**
    * Read one message-aligned history page without activating an Agent.
    * @param request - durable address and backwards-page cursor.
-   * @param signal - caller cancellation for persistence and preset reads.
+   * @param signal - caller cancellation for persistence reads.
    * @returns a contiguous event page and a projection baseline on tail reads.
    */
   async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
@@ -77,11 +60,8 @@ export class SessionHistoryController {
     if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
       reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
     }
-    const scope = await this.presenterScopeFor(addressId(request.address), source, events)
-    signal.throwIfAborted()
     const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
-    const argsFor = (callId: string) => backscanArgs(page.events, callId)
-    const entries = page.events.map(event => entryFor(this.ctx, event, argsFor, scope))
+    const entries = page.events.map(entryFor)
     const projections = request.beforeSeq === undefined
       ? this.projectionsFor(request.address, source, events)
       : undefined
@@ -102,12 +82,7 @@ export class SessionHistoryController {
     validateFollowRequest(request)
     const { address, afterSeq } = request
     const target = addressId(address)
-    const buffered: BufferedEvent[] = []
-    const openCalls = new Map<string, { readonly name: string; readonly args: unknown }>()
-    let fallbackEvents: readonly SessionEvent[] = []
-    const argsFor = (callId: string): { readonly name: string; readonly args: unknown } | undefined => (
-      openCalls.get(callId) ?? backscanArgs(fallbackEvents, callId)
-    )
+    const buffered: SessionEvent[] = []
     let wake: (() => void) | undefined
     const notify = (): void => {
       const resume = wake
@@ -122,7 +97,7 @@ export class SessionHistoryController {
     this.closeFollowers.add(close)
     const disposeEvent = this.ctx.on('session/event', (session, event) => {
       if (session.id !== target) return
-      buffered.push({ session, event })
+      buffered.push(event)
       notify()
     }, { global: true })
     const disposeCreated = this.ctx.on('session/created', (session) => {
@@ -130,7 +105,7 @@ export class SessionHistoryController {
       // Session construction appends session/end-seed before attachment, so the
       // marker has no session/event notification. Earlier session/created listeners
       // may publish later setup events first; this suffix must precede those notifications.
-      const suffix = session.events.slice(session.firstLiveSeq).map(event => ({ session, event }))
+      const suffix = session.events.slice(session.firstLiveSeq)
       buffered.unshift(...suffix)
       notify()
     }, { global: true })
@@ -139,8 +114,6 @@ export class SessionHistoryController {
     try {
       const source = await this.sourceFor(address, signal)
       const events = [...sourceEvents(source)]
-      fallbackEvents = events
-      const scope = await this.presenterScopeFor(target, source, events)
       signal.throwIfAborted()
       const cursor = events.at(-1)?.seq ?? -1
       if (afterSeq !== undefined && afterSeq > cursor) {
@@ -155,7 +128,7 @@ export class SessionHistoryController {
             reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
           }
           nextSeq++
-          yield { type: 'event', ...entryFor(this.ctx, event, argsFor, scope) }
+          yield { type: 'event', ...entryFor(event) }
         }
       }
       while (!follower.closed && !signal.aborted) {
@@ -164,26 +137,12 @@ export class SessionHistoryController {
           await new Promise<void>((resolve) => { wake = resolve })
           continue
         }
-        if (item.event.seq < nextSeq) continue
-        if (item.event.seq !== nextSeq) {
+        if (item.seq < nextSeq) continue
+        if (item.seq !== nextSeq) {
           reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
         }
         nextSeq++
-        if (item.event.type === 'tool/call') {
-          const data = item.event.data as ToolCallData
-          const call = parseToolCall(data)
-          /* v8 ignore next -- malformed durable tool arguments intentionally skip the live presentation cache. */
-          if (call !== undefined) openCalls.set(data.callId, call)
-        } else if (item.event.type === 'turn/end') {
-          openCalls.clear()
-        }
-        if (item.event.type === 'tool/result'
-          && !openCalls.has(item.event.data.message.source.callId)) {
-          fallbackEvents = item.session.events
-        }
-        const liveScope: Agent | undefined = this.ctx.get('agents')?.get(target)
-        const entry = entryFor(this.ctx, item.event, argsFor, liveScope ?? scope)
-        yield { type: 'event', ...entry }
+        yield { type: 'event', ...entryFor(item) }
       }
     } finally {
       this.closeFollowers.delete(close)
@@ -214,25 +173,6 @@ export class SessionHistoryController {
     return { kind: 'detached', header: inspected.meta, events: inspected.events }
   }
 
-  private async presenterScopeFor(
-    sessionId: SessionId,
-    source: SessionSource,
-    events: readonly SessionEvent[],
-  ): Promise<ScopeKey | undefined> {
-    const live = this.ctx.get('agents')?.get(sessionId)
-    if (live !== undefined) return live
-    const presets = this.ctx.get('agentPresets')
-    if (presets === undefined) return undefined
-    const session = source.kind === 'attached'
-      ? { header: source.session.header, events }
-      : { header: source.header, events }
-    try {
-      return await presets.standingKeyFor(resolveSessionPreset(session))
-    } catch {
-      return undefined
-    }
-  }
-
   private projectionsFor(
     address: SessionAddress,
     source: SessionSource,
@@ -368,76 +308,9 @@ function paginate(
   return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
 }
 
-function entryFor(
-  ctx: Context,
-  event: SessionEvent,
-  argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
-  scope?: ScopeKey,
-): SessionEventEntry {
-  const view = viewFor(ctx, event, argsFor, scope)
+function entryFor(event: SessionEvent): SessionEventEntry {
   return {
     // Session.append validates and freezes event data as JSON before publication.
     event: event as unknown as SessionWireEvent,
-    ...(view === undefined ? {} : { view }),
   }
 }
-
-function viewFor(
-  ctx: Context,
-  event: SessionEvent,
-  argsFor: (callId: string) => { readonly name: string; readonly args: unknown } | undefined,
-  scope?: ScopeKey,
-): SessionToolView | undefined {
-  if (event.type !== 'tool/call' && event.type !== 'tool/result') return undefined
-  const tools = ctx.get('tools')
-  /* v8 ignore next -- deployments without the optional Tools service omit presentation metadata. */
-  if (tools === undefined) return undefined
-  try {
-    if (event.type === 'tool/call') {
-      const data = event.data as ToolCallData
-      const view: ToolCallView | undefined = tools.get(data.name, scope)?.presentCall?.(JSON.parse(data.arguments))
-      return view === undefined ? undefined : { for: 'call', view: jsonView(view) }
-    }
-    const [result] = event.data.message.content
-    const call = argsFor(event.data.message.source.callId)
-    if (call === undefined) return undefined
-    const view: ToolResultView | undefined = tools.get(call.name, scope)?.presentResult?.(call.args, {
-      content: result.content,
-      isError: result.isError === true,
-      ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
-    })
-    return view === undefined ? undefined : { for: 'result', view: jsonView(view) }
-  } catch (error) {
-    ctx.logger.warn(`session: presenter failed for ${event.type}: ${String(error)}`)
-  }
-  return undefined
-}
-
-function backscanArgs(
-  events: readonly SessionEvent[],
-  callId: string,
-): { readonly name: string; readonly args: unknown } | undefined {
-  for (let index = events.length - 1; index >= 0; index--) {
-    const event = events[index] as SessionEvent
-    if (event.type !== 'tool/call') continue
-    const data = event.data as ToolCallData
-    if (data.callId !== callId) continue
-    return parseToolCall(data)
-  }
-  return undefined
-}
-
-function parseToolCall(data: ToolCallData): { readonly name: string; readonly args: unknown } | undefined {
-  try {
-    return { name: data.name, args: JSON.parse(data.arguments) }
-  } catch {
-    return undefined
-  }
-}
-
-function jsonView(view: ToolCallView): SessionToolCallView
-function jsonView(view: ToolResultView): ToolResultView
-function jsonView(view: ToolCallView | ToolResultView): SessionToolCallView | ToolResultView {
-  const encoded = JSON.stringify(view)
-  return JSON.parse(encoded) as SessionToolCallView | ToolResultView
-}

+ 1 - 2
packages/api/session-controller/src/index.ts

@@ -69,7 +69,6 @@ export class SessionController extends TypertRemoteService {
     'llm',
     'sessions',
     'sessionQuery',
-    'tools',
     'typert',
     'workspaceRegistry',
   ]
@@ -260,7 +259,7 @@ export class SessionController extends TypertRemoteService {
   /**
    * Read one cold-safe, message-aligned Session history page.
    * @param request - durable address, backward cursor, and page budget.
-   * @param signal - cancellation for persistence and presentation reads.
+   * @param signal - cancellation for persistence reads.
    * @returns one chronological page and optional latest projections.
    */
   @Remote('page')

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

@@ -10,12 +10,6 @@ import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/t
 import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
 import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
-import type {
-  DiffCallView,
-  GenericCallView,
-  TerminalCallView,
-  ToolResultView,
-} from '@deepseek-ai/dsh-tools/presentation'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
@@ -333,21 +327,9 @@ export type SessionAddress =
     readonly mode: 'one-shot' | 'continuable'
   }
 
-/** JSON-safe call render intent crossing the Session Remote boundary. */
-export type SessionToolCallView =
-  | (Omit<GenericCallView, 'rawInput'> & { readonly rawInput?: JsonValue })
-  | TerminalCallView
-  | DiffCallView
-
-/** Host-computed render intent accompanying one tool event. */
-export type SessionToolView =
-  | { readonly for: 'call'; readonly view: SessionToolCallView }
-  | { readonly for: 'result'; readonly view: ToolResultView }
-
-/** One raw Session event plus its optional transient render intent. */
+/** One raw Session event in the Remote journal. */
 export interface SessionEventEntry {
   readonly event: SessionWireEvent
-  readonly view?: SessionToolView
 }
 
 /** Session event wire form; durable readers own recognition of merge-extensible event names. */

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

@@ -5,6 +5,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import { describe, expect, it, vi } from 'vitest'
+import SessionController from '../src/index.ts'
 import { createSessionTestController } from './test-remote.ts'
 
 const defaults = {
@@ -13,6 +14,10 @@ const defaults = {
 }
 
 describe('SessionController facade', () => {
+  it('does not require the Tools service', () => {
+    expect(SessionController.inject).not.toContain('tools')
+  })
+
   it('owns Host service methods and publishes Agent lifecycle projections', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)

+ 1 - 1
packages/api/session-controller/tests/event-script.client.ts

@@ -142,7 +142,7 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s
   ]
 }
 
-/** Wrap raw events as view-less history entries (the wire shape history returns). */
+/** Wrap raw events in the journal envelope returned by history. */
 export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] {
   return events.map(event => ({ event }))
 }

+ 62 - 170
packages/api/session-controller/tests/session-history-view.host.spec.ts → packages/api/session-controller/tests/session-history-journal.host.spec.ts

@@ -1,38 +1,15 @@
-/**
- * Tool-card view computation over Session Controller history and follow: three standard card types
- * arrive on the frame, a presenterless tool ships no view field, a call-only
- * presenter keeps raw result content out of the view payload, and a throwing
- * presenter soft-falls to no view (the event still ships). Result pairing
- * works for both paged and live entries.
- */
+/** Raw Session journal transport and message-aligned pagination coverage. */
 
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { Agent } from '@deepseek-ai/dsh-agent'
 import SessionStore from '@deepseek-ai/dsh-session'
-import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
 import { CallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
-import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
 import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
 import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
 import { createSessionTestRemote } from './test-remote.ts'
 
-const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
-
-function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
-  return defineContentToolFixture({
-    name,
-    description: `tool ${name}`,
-    parameters: {},
-    execute: () => reply(`ran:${name}`),
-    ...presenters,
-  })
-}
-
 /** Append a production-shaped human prompt to the session surface. */
 function appendUserText(session: Session, text: string): SessionEvent {
   return session.append('user/message', createUserMessage({
@@ -65,27 +42,7 @@ function appendExtension(session: Session, type: string, data: unknown): Session
 async function harness(): Promise<{ ctx: Context }> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
-  await ctx.plugin(SystemPrompt, { persona: '' })
-  await ctx.plugin(ToolRuntime)
   await ctx.plugin(AgentRegistry)
-  ctx.tools.register(tool('gen', {
-    presentCall: () => ({ card: 'generic', title: 'gen call' }),
-    presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
-  }))
-  ctx.tools.register(tool('term', {
-    presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
-    presentResult: () => ({ card: 'terminal', output: 'done' }),
-  }))
-  ctx.tools.register(tool('diffy', {
-    presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
-  }))
-  ctx.tools.register(tool('call-only', {
-    presentCall: () => ({ card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' }),
-  }))
-  ctx.tools.register(tool('plain', {}))
-  ctx.tools.register(tool('boom', {
-    presentCall: () => { throw new Error('presenter exploded') },
-  }))
   return { ctx }
 }
 
@@ -119,73 +76,37 @@ async function openFollow(
   return { [Symbol.asyncIterator]: () => iterator }
 }
 
-describe('Session history view computation', () => {
-  it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
+describe('Session history raw journal', () => {
+  it('follows raw tool events and preserves result metadata without a Tools service', async () => {
     const { ctx } = await harness()
     const session = ctx.sessions.create()
     const history = new SessionHistoryController(ctx)
     const abort = new AbortController()
     const stream = await openFollow(history, session.id, abort.signal)
-    const collected = collect(stream, 9, abort)
-    const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
-
-    session.append('turn/start', { turn: 1 })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-call-only'), name: 'call-only', arguments: '{}' })
-    session.append('tool/result', {
-      turn: 1, step: 1,
-      message: createToolResultMessage({
-        callId: CallId('c-call-only'),
-        content: [{ type: 'text', text: rawResult }],
-        isError: false,
-      }),
-    }, { surfaceOp: 'append' })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
-    session.append('tool/result', {
+    const collected = collect(stream, 2, abort)
+    const call = session.append('tool/call', {
+      turn: 1, step: 1, callId: CallId('raw-call'), name: 'custom', arguments: '{malformed',
+    })
+    const result = session.append('tool/result', {
       turn: 1, step: 1,
       message: createToolResultMessage({
-        callId: CallId('c-gen'),
-        content: [{ type: 'text', text: 'ok' }],
+        callId: CallId('raw-call'),
+        content: [{ type: 'text', text: 'raw output' }],
         isError: false,
       }),
+      meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] },
     }, { surfaceOp: 'append' })
 
     const frames = await collected
-    const events = frames.filter(f => f.type === 'event')
-    const byCall = new Map(events
-      .filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
-      .map(f => [
-        `${f.event.type}:${f.event.type === 'tool/call'
-          ? (f.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
-          : (f.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
-        f,
-      ]))
-
-    expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
-    expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
-    expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
-    expect(byCall.get('tool/call:c-call-only')?.view).toEqual({
-      for: 'call',
-      view: { card: 'generic', title: 'program', kind: 'execute', rawInput: 'return value' },
-    })
-    const callOnlyResult = byCall.get('tool/result:c-call-only')
-    expect('view' in (callOnlyResult ?? {})).toBe(false)
-    const serializedResult = JSON.stringify(callOnlyResult)
-    expect(serializedResult.indexOf(rawResult)).toBeGreaterThanOrEqual(0)
-    expect(serializedResult.indexOf(rawResult)).toBe(serializedResult.lastIndexOf(rawResult))
-    // No presenter → the frame carries no view property at all.
-    expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
-    // Throwing presenter → soft-fall: event ships, no view.
-    expect(byCall.get('tool/call:c-boom')).toBeDefined()
-    expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
-    // Result pairing through the live table: presentResult saw the call's args.
-    expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
+    expect(frames).toEqual([
+      { type: 'event', event: call },
+      { type: 'event', event: result },
+    ])
+    expect((frames[1] as Extract<SessionFollowFrame, { type: 'event' }>).event.data)
+      .toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } })
   })
 
-  it('pairs live results from the open-call table without rescanning Session history', async () => {
+  it('follows live results without rescanning Session history', async () => {
     const { ctx } = await harness()
     const session = ctx.sessions.create()
     const history = new SessionHistoryController(ctx)
@@ -197,7 +118,7 @@ describe('Session history view computation', () => {
       turn: 1, step: 1, callId: CallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}',
     })
     await expect(iterator.next()).resolves.toMatchObject({
-      value: { type: 'event', view: { for: 'call', view: { card: 'terminal', title: 'pwd' } } },
+      value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } },
     })
 
     const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
@@ -213,7 +134,7 @@ describe('Session history view computation', () => {
         }),
       }, { surfaceOp: 'append' })
       await expect(iterator.next()).resolves.toMatchObject({
-        value: { type: 'event', view: { for: 'result', view: { card: 'terminal', output: 'done' } } },
+        value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } },
       })
     } finally {
       events.mockRestore()
@@ -223,53 +144,22 @@ describe('Session history view computation', () => {
     }
   })
 
-  it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
+  it('serves raw call and result entries without parsing tool arguments', async () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
     const session = ctx.sessions.create()
-    // history resolves the agent first; a live structural stub is enough (only
-    // .session is read on this path).
-    ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
-    session.append('turn/start', { turn: 1 })
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
-    // meta rides through to presentResult's ToolResult (the spread arm).
-    session.append('tool/result', {
-      turn: 1, step: 1,
-      message: createToolResultMessage({
-        callId: CallId('h-term'),
-        content: [{ type: 'text', text: 'ok' }],
-        isError: false,
-      }),
-      meta: { n: 1 },
-    }, { surfaceOp: 'append' })
-    // Unpaired result: no tool/call with this id anywhere in the page.
-    session.append('tool/result', {
-      turn: 1, step: 1,
-      message: createToolResultMessage({
-        callId: CallId('h-orphan'),
-        content: [{ type: 'text', text: 'x' }],
-        isError: false,
-      }),
-    }, { surfaceOp: 'append' })
-    // Paired, but the call's stored arguments do not parse: backscan soft-falls.
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
-    session.append('tool/result', {
-      turn: 1, step: 1,
-      message: createToolResultMessage({
-        callId: CallId('h-bad'),
-        content: [{ type: 'text', text: 'y' }],
-        isError: false,
-      }),
-    }, { surfaceOp: 'append' })
-    // Presenterless tool: pairing succeeds but presentResult is absent.
-    session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
-    session.append('tool/result', {
+    const start = session.append('turn/start', { turn: 1 })
+    const call = session.append('tool/call', {
+      turn: 1, step: 1, callId: CallId('history-call'), name: 'custom', arguments: '{broken',
+    })
+    const result = session.append('tool/result', {
       turn: 1, step: 1,
       message: createToolResultMessage({
-        callId: CallId('h-plain'),
-        content: [{ type: 'text', text: 'z' }],
-        isError: false,
+        callId: CallId('history-call'),
+        content: [{ type: 'text', text: 'failed raw output' }],
+        isError: true,
       }),
+      meta: { persisted: true, count: 3 },
     }, { surfaceOp: 'append' })
 
     const response = await remote.page({
@@ -278,27 +168,17 @@ describe('Session history view computation', () => {
     })
     expect(response.ok).toBe(true)
     if (!response.ok) throw new Error('unreachable')
-    const entries = response.value.events
-    const byKey = new Map(entries
-      .filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
-      .map(entry => [
-        `${entry.event.type}:${entry.event.type === 'tool/call'
-          ? (entry.event.data as unknown as SessionEvent<'tool/call'>['data']).callId
-          : (entry.event.data as unknown as SessionEvent<'tool/result'>['data']).message.source.callId}`,
-        entry,
-      ]))
-    expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
-    expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
-    expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
-    expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
-    expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
+    expect(response.value.events).toEqual([
+      { event: start },
+      { event: call },
+      { event: result },
+    ])
   })
 
   it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
     const session = ctx.sessions.create()
-    ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
     session.append('turn/start', { turn: 1 })
     const first = appendUserText(session, 'first prompt')
     appendAssistantText(session, 'first reply', 1)
@@ -348,7 +228,6 @@ describe('Session history view computation', () => {
     const { ctx } = await harness()
     const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
     const session = ctx.sessions.create()
-    ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
     session.append('turn/start', { turn: 1 })
     const sources = Array.from({ length: 128 }, (_unused, index) => session.append('assistant/chunk', {
       turn: 1,
@@ -384,28 +263,41 @@ describe('Session history view computation', () => {
     }
   })
 
-  it('pairs a followed result after turn/end from the addressed Session log', async () => {
+  it('follows a result after turn/end without reading the addressed Session log', async () => {
     const { ctx } = await harness()
     const session = ctx.sessions.create()
     const history = new SessionHistoryController(ctx)
     const abort = new AbortController()
     const stream = await openFollow(history, session.id, abort.signal)
-    const collected = collect(stream, 4, abort)
+    const iterator = stream[Symbol.asyncIterator]()
 
     session.append('turn/start', { turn: 1 })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/start' } } })
     session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'tool/call' } } })
     session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    session.append('tool/result', {
-      turn: 1, step: 1,
-      message: createToolResultMessage({
-        callId: CallId('c-late'),
-        content: [{ type: 'text', text: 'ok' }],
-        isError: false,
-      }),
-    }, { surfaceOp: 'append' })
-
-    const frames = await collected
-    const result = frames.find(f => f.type === 'event' && f.event.type === 'tool/result')
-    expect(result?.type === 'event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { event: { type: 'turn/end' } } })
+    const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => {
+      throw new Error('live result rescanned Session history')
+    })
+    try {
+      const result = session.append('tool/result', {
+        turn: 1, step: 1,
+        message: createToolResultMessage({
+          callId: CallId('c-late'),
+          content: [{ type: 'text', text: 'ok' }],
+          isError: false,
+        }),
+      }, { surfaceOp: 'append' })
+      await expect(iterator.next()).resolves.toEqual({
+        done: false,
+        value: { type: 'event', event: result },
+      })
+    } finally {
+      events.mockRestore()
+      abort.abort()
+      await iterator.next()
+      await ctx.fiber.dispose()
+    }
   })
 })

+ 15 - 27
packages/api/session-controller/tests/session.client.spec.ts

@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
-import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
 import { Session, type SessionOptions } from '../src/client/sessions/session.ts'
 import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts'
 import { entries, ev, plainTurn } from './event-script.client.ts'
@@ -26,12 +25,10 @@ function makeSession(
 function follow(
   api: FakeApiClient,
   event: SessionEvent,
-  view?: SessionToolView,
 ): Promise<void> {
   return api.pushFollow(SID, {
     type: 'event',
     event: event as never,
-    ...(view === undefined ? {} : { view }),
   })
 }
 
@@ -44,7 +41,7 @@ function eventSeqs(session: Session): number[] {
 }
 
 function histResponse(events: SessionEvent[], hasMore = false) {
-  // history returns HistoryEntry[] ({event, view?}); these tests are view-less.
+  // History returns raw journal envelopes around each event.
   return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
 }
 
@@ -601,39 +598,30 @@ describe('remaining branches', () => {
     await expect(session.dispose()).resolves.toBeUndefined()
   })
 
-  it('carries history-entry and follow-frame views through the event feed', async () => {
+  it('carries raw history and follow events through the event feed', async () => {
     const { api, session } = makeSession()
-    const callView = { for: 'call', view: { card: 'generic', title: '历史卡' } }
+    const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}')
+    const historyResult = ev.toolResult(7, 1, 'h1', 'done')
     api.onHistory = () => Promise.resolve(ok({
       events: [
         ...entries(plainTurn(0, 0, 'a', 'b')),
-        { event: ev.toolCall(6, 1, 'h1', 'bash', '{}'), view: callView },
-        { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } },
+        { event: historyCall },
+        { event: historyResult },
       ] as never[],
       hasMore: false,
       modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
     }))
     await session.open()
-    expect(windowEntries(session).slice(-2).map(item => item.view)).toEqual([
-      callView,
-      { for: 'result', view: { card: 'generic', title: '历史果' } },
+    expect(windowEntries(session).slice(-2)).toEqual([
+      { event: historyCall },
+      { event: historyResult },
     ])
-    await follow(
-      api,
-      ev.toolCall(8, 2, 'l1', 'write', '{}'),
-      { for: 'call', view: { card: 'generic', title: '直播卡' } },
-    )
-    expect(windowEntries(session).at(-1)?.view).toEqual({
-      for: 'call', view: { card: 'generic', title: '直播卡' },
-    })
-    await follow(
-      api,
-      ev.toolResult(9, 2, 'l1', 'ok'),
-      { for: 'result', view: { card: 'generic', title: '直播果' } },
-    )
-    expect(windowEntries(session).at(-1)?.view).toEqual({
-      for: 'result', view: { card: 'generic', title: '直播果' },
-    })
+    const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}')
+    await follow(api, liveCall)
+    expect(windowEntries(session).at(-1)).toEqual({ event: liveCall })
+    const liveResult = ev.toolResult(9, 2, 'l1', 'ok')
+    await follow(api, liveResult)
+    expect(windowEntries(session).at(-1)).toEqual({ event: liveResult })
   })
 })
 

+ 0 - 127
packages/api/session-controller/tests/transport.host.spec.ts

@@ -482,60 +482,6 @@ describe('SessionHistoryController', () => {
     expect(warn).toHaveBeenCalledWith(expect.stringContaining('child projection failed'))
   })
 
-  it('resolves presenter scope from a live Agent or the durable preset and tolerates lookup failure', async () => {
-    const live = await setup()
-    const liveSession = live.ctx.sessions.create(SessionId('live-scope'), { meta: { cwd: '/workspace' } })
-    const liveAgent = { id: liveSession.id }
-    const preset = vi.fn(() => Promise.resolve('preset-scope'))
-    live.ctx.provide('agents', { get: () => liveAgent } as never)
-    live.ctx.provide('agentPresets', { standingKeyFor: preset } as never)
-    await live.transport.page({
-      address: { kind: 'session', sessionId: liveSession.id }, throughSeq: -1,
-    }, signal())
-    expect(preset).not.toHaveBeenCalled()
-
-    const attached = await setup()
-    const attachedSession = attached.ctx.sessions.create(SessionId('preset-scope'), {
-      meta: { cwd: '/workspace', agentPreset: 'minimal' },
-    })
-    const standingKeyFor = vi.fn(() => Promise.resolve('standing-scope'))
-    attached.ctx.provide('agentPresets', { standingKeyFor } as never)
-    await attached.transport.page({
-      address: { kind: 'session', sessionId: attachedSession.id },
-      throughSeq: -1,
-    }, signal())
-    expect(standingKeyFor).toHaveBeenCalledWith('minimal')
-
-    const detached = await setup()
-    const detachedId = SessionId('detached-scope')
-    const header = {
-      version: 0, id: detachedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
-    }
-    cold(detached.ctx, header, [])
-    const rejected = vi.fn(() => Promise.reject(new Error('preset unavailable')))
-    detached.ctx.provide('agentPresets', { standingKeyFor: rejected } as never)
-    await expect(detached.transport.page({
-      address: { kind: 'session', sessionId: detachedId },
-      throughSeq: -1,
-    }, signal())).resolves.toMatchObject({ events: [] })
-    expect(rejected).toHaveBeenCalledWith('standard')
-
-    const switched = await setup()
-    const switchedId = SessionId('switched-scope')
-    const switchedHeader = {
-      version: 0, id: switchedId, createdAt: 1, cwd: '/workspace', agentPreset: 'standard',
-    }
-    cold(switched.ctx, switchedHeader, [
-      event('agent-preset/selected', 0, { agentPreset: 'minimal' }),
-    ])
-    const switchedKey = vi.fn(() => Promise.resolve('switched-scope'))
-    switched.ctx.provide('agentPresets', { standingKeyFor: switchedKey } as never)
-    await switched.transport.page({
-      address: { kind: 'session', sessionId: switchedId }, throughSeq: 0,
-    }, signal())
-    expect(switchedKey).toHaveBeenCalledWith('minimal')
-  })
-
   it('keeps message-aligned pagination contiguous across replacement provenance', async () => {
     const { ctx, transport } = await setup()
     const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } })
@@ -576,77 +522,4 @@ describe('SessionHistoryController', () => {
     expect(page.hasMore).toBe(false)
   })
 
-  it('projects tool call and result views and contains malformed presenters', async () => {
-    const { ctx, transport } = await setup()
-    const sessionId = SessionId('presenters')
-    const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' }
-    const events = [
-      event('fixture/start', 0),
-      event('tool/call', 1, { callId: 'c1', name: 'present', arguments: '{"path":"a.ts"}' }),
-      event('tool/result', 2, {
-        message: {
-          source: { callId: 'c1' },
-          content: [{ content: [{ type: 'text', text: 'ok' }], isError: true }],
-        },
-        meta: { persisted: true },
-      }),
-      event('tool/result', 3, {
-        message: {
-          source: { callId: 'missing' },
-          content: [{ content: [{ type: 'text', text: 'missing' }] }],
-        },
-      }),
-      event('tool/call', 4, { callId: 'c2', name: 'present', arguments: '{' }),
-      event('tool/result', 5, {
-        message: {
-          source: { callId: 'c2' },
-          content: [{ content: [{ type: 'text', text: 'bad args' }] }],
-        },
-      }),
-      event('tool/call', 6, { callId: 'c3', name: 'empty', arguments: '{}' }),
-      event('tool/result', 7, {
-        message: {
-          source: { callId: 'c3' },
-          content: [{ content: [{ type: 'text', text: 'no presenter' }], isError: false }],
-        },
-      }),
-      event('tool/call', 8, { callId: 'c4', name: 'throw-call', arguments: '{}' }),
-      event('tool/call', 9, { callId: 'c5', name: 'throw-result', arguments: '{}' }),
-      event('tool/result', 10, {
-        message: {
-          source: { callId: 'c5' },
-          content: [{ content: [{ type: 'text', text: 'throw' }], isError: false }],
-        },
-      }),
-    ]
-    cold(ctx, header, events)
-    ctx.provide('tools', {
-      get: (name: string) => {
-        if (name === 'present') {
-          return {
-            presentCall: (args: unknown) => ({ card: 'generic', title: 'Call', rawInput: args }),
-            presentResult: (_args: unknown, result: unknown) => ({ card: 'generic', title: 'Result', result }),
-          }
-        }
-        if (name === 'empty') return {}
-        if (name === 'throw-call') return { presentCall: () => { throw new Error('call presenter failed') } }
-        if (name === 'throw-result') return { presentResult: () => { throw new Error('result presenter failed') } }
-        return undefined
-      },
-    } as never)
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
-
-    const page = await transport.page({
-      address: { kind: 'session', sessionId }, throughSeq: 10,
-    }, signal())
-    expect(page.events[1]?.view).toEqual({
-      for: 'call', view: { card: 'generic', title: 'Call', rawInput: { path: 'a.ts' } },
-    })
-    expect(page.events[2]?.view).toMatchObject({ for: 'result', view: { card: 'generic', title: 'Result' } })
-    for (const index of [0, 3, 4, 5, 6, 7, 8, 9, 10]) {
-      expect(page.events[index]).not.toHaveProperty('view')
-    }
-    expect(warn).toHaveBeenCalledWith(expect.stringContaining('call presenter failed'))
-    expect(warn).toHaveBeenCalledWith(expect.stringContaining('result presenter failed'))
-  })
 })

+ 0 - 1
packages/api/session-controller/tsconfig.client.json

@@ -21,7 +21,6 @@
     { "path": "../../llm/llm" },
     { "path": "../../session/session-projection" },
     { "path": "../../session/session-title" },
-    { "path": "../../core/tools" },
     { "path": "../../util/brand" },
     { "path": "../../util/crypto" },
     { "path": "../../util/workspace-path" },

+ 0 - 1
packages/api/session-controller/tsconfig.host.json

@@ -24,7 +24,6 @@
     { "path": "../../core/agent-default-model" },
     { "path": "../../core/scope" },
     { "path": "../../core/session" },
-    { "path": "../../core/tools" },
     { "path": "../../attachment/attachment" },
     { "path": "../../interaction/permission-presets" },
     { "path": "../../jobs/jobs" },

+ 7 - 10
packages/bundle/web-app/cordis.patch.yml

@@ -435,16 +435,13 @@
 - id: tool-web
   disabled: true
 
-# The preset roster. `config/agent-presets/` ships with the deployment and is
-# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is
-# where a person — or an agent — authors their own, and carries the same trust
-# as shell access because a preset IS a composition.
-#
-# Only the SHIPPED root is an assembly fact: it sits beside the installed app's
-# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the
-# same treatment `distIndex` gets on the webserver row. The writable root is
-# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that
-# never reaches that patch still finds a person's presets.
+# The preset roster. The shipped presets are bundled inside
+# `dsh-agent-presets` itself and prepended as a read-only `system` root
+# (`includeShippedRoot`); `$DSH_HOME/.agent-presets` is where a person — or an
+# agent — authors their own, appended by the same package (`includeUserRoot`),
+# and carries the same trust as shell access because a preset IS a
+# composition. This row only names the default and any deployment-added
+# `roots`; no launcher patching is involved.
 - insert:
     - id: agent-presets
       name: '@deepseek-ai/dsh-agent-presets'

+ 1 - 1
packages/client/AGENTS.md

@@ -52,7 +52,7 @@ Non-negotiables across the layers:
 - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer.
 - **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest<P>`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)).
 - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`.
-- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule).
+- **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule).
 
 ## Dependency declaration
 

+ 2 - 4
packages/client/connection/package.json

@@ -55,8 +55,7 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-tool-todo": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tool-todo": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
@@ -67,7 +66,6 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-tool-todo": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tool-todo": "workspace:^"
   }
 }

+ 0 - 1
packages/client/connection/src/client/api.ts

@@ -17,7 +17,6 @@ export type {
   CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
   SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
 } from '@deepseek-ai/dsh-host-apiproxy/api'
-export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
 export type {
   RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
   ClientRequest, ServerResponse, RpcMessage,

+ 154 - 250
packages/client/connection/src/client/fixture.ts

@@ -17,6 +17,7 @@ import type {
 } from '@deepseek-ai/dsh-llm'
 import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {
+  JsonValue,
   SessionEvent,
   SessionId,
 } from '@deepseek-ai/dsh-session/types'
@@ -29,7 +30,6 @@ import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surfac
 import type {
   ApiProxy, ClientRequest,
   ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse,
-  ToolCallView, ToolResultView,
 } from './api.ts'
 import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
 import { AbstractApiClient, RpcId } from './api.ts'
@@ -71,13 +71,8 @@ interface FixtureProjectionsBlock {
   readonly values: Readonly<Record<string, unknown>>
 }
 
-type FixtureToolView =
-  | { readonly for: 'call'; readonly view: ToolCallView }
-  | { readonly for: 'result'; readonly view: ToolResultView }
-
 interface FixtureHistoryEntry {
   readonly event: SessionEvent
-  readonly view?: FixtureToolView
 }
 
 type FixtureSessionAddress =
@@ -362,11 +357,9 @@ function sgr(code: number, body: string): string {
  * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
  * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
  * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
- * height cap collapses the middle. The exit status is authored separately in
- * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
- * presenter CONSUMES its `[exit code: N]` marker out of the body, because a
- * terminal card shows the exit as its own pill and leaving the marker in would
- * render it twice (packages/shell/tool-bash/src/render.ts).
+ * height cap collapses the middle. This constant is the visible body; the call
+ * site appends the shell result's `[exit code: N]` marker so Client derivation
+ * can consume it into the terminal status pill.
  */
 const TERMINAL_OUTPUT_FIXTURE = [
   sgr(1, 'Running 4 checks'),
@@ -393,20 +386,10 @@ const TERMINAL_OUTPUT_FIXTURE = [
 ].join('\n')
 
 /**
- * Exit status for each terminal sample, keyed by its output text. Authored
- * alongside the sample rather than parsed back out of its trailing marker,
- * which is the bash tool's own job and not something to reimplement here.
- */
-const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
-  [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
-}
-
-/**
- * Structured grep result for the search sample (turn 67): matches grouped by
- * file, authored inline because the client-side fixture cannot import the tool
- * that produces the canonical value. `truncated` with a larger `total` than the
- * retained match count exercises the search card's capped indicator; the file
- * with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap.
+ * Structured grep metadata for the search sample (turn 67). `truncated` with a
+ * larger `total` than the retained match count exercises the search card's
+ * capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows
+ * exercises its head/tail height cap.
  */
 const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [
   {
@@ -475,18 +458,23 @@ const READ_SAMPLE_SOURCE = [
 const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
 const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
 const READ_SAMPLE_TOTAL = 180
-const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
+const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1
+const READ_SAMPLE_TEXT = [
+  `<path>${READ_SAMPLE_PATH}</path>`,
+  '<type>file</type>',
+  '<content>',
+  ...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`),
+  '',
+  `(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`,
+  '</content>',
+].join('\n')
 
 /**
- * The structured `web_search` result view for the web-search turn, authored inline
- * because this client-side fixture cannot import the web tool that projects it.
- * The sources exercise the citation list's features: a titled source with a
- * snippet and a date, a source with no title (its hostname labels the link) and
- * a snippet but no date, and a source with a title and a date but no snippet.
- * `truncated` marks the capped indicator. The shape is the contract's own
- * search view minus its wire discriminants.
+ * The `web_search` result metadata for the web-search turn. The sources cover a
+ * titled source with a snippet and date, a hostname-label fallback, and a
+ * titled source without a snippet; `truncated` exercises the capped indicator.
  */
-const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
+const WEB_SEARCH_META = {
   answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
   sources: [
     {
@@ -506,14 +494,14 @@ const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'sear
     },
   ],
   truncated: true,
-}
+} satisfies JsonValue
 
-/** The `web_fetch` result view for the web-fetch turn, authored inline for the same reason. */
-const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
+/** The `web_fetch` result metadata for the web-fetch turn. */
+const WEB_FETCH_META = {
   url: 'https://www.deepseek.com/blog/harness-architecture',
   statusCode: 200,
   truncated: false,
-}
+} satisfies JsonValue
 
 const DEEPSEEK_REASONING = {
   efforts: [
@@ -649,10 +637,16 @@ function buildAlphaLog(): SessionEvent[] {
     }
     push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   }
-  // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in
-  // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above
-  // stays presenter-less as the unknown fallback.
-  const toolTurn = (turn: number, name: string, args: string, resultText: string): void => {
+  // The structured samples use real first-party names and result metadata so
+  // the fixture follows the same event-to-card path as a persisted Session.
+  // `echo` above remains the unknown-tool fallback.
+  const toolTurn = (
+    turn: number,
+    name: string,
+    args: string,
+    resultText: string,
+    resultMeta?: JsonValue,
+  ): void => {
     const callId = `fx-call-${turn}`
     push({ type: 'turn/start', data: { turn } })
     push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) })
@@ -662,23 +656,66 @@ function buildAlphaLog(): SessionEvent[] {
       data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) },
     })
     push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } })
-    push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } })
+    push({
+      type: 'tool/result',
+      surfaceOp: 'append',
+      data: {
+        turn,
+        step: 0,
+        message: toolResultMessage(callId, text(resultText), false),
+        ...resultMeta === undefined ? {} : { meta: resultMeta },
+      },
+    })
     push({ type: 'step/end', data: { turn, step: 0 } })
     push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   }
   // A two-line command, so the fixture covers the terminal card's one-row-per-
   // command-line prompt (and that the card still marks the call exactly once).
-  toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
-  toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
-  toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
-  toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
+  toolTurn(
+    60,
+    'bash',
+    '{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}',
+    'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt',
+  )
+  toolTurn(
+    61,
+    'write',
+    '{"file_path":"notes/demo.txt","content":"hello fixture\\n"}',
+    'wrote notes/demo.txt',
+    { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] },
+  )
+  toolTurn(
+    62,
+    'edit',
+    '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}',
+    '已编辑',
+    { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] },
+  )
+  toolTurn(
+    63,
+    'write',
+    '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}',
+    '已写入',
+    { diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] },
+  )
   // Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
   // `edit` so it lands on the keyed FileMutationRow (the resident diff card the
-  // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
-  // the presenter reads to emit the two-hunk sample: the card draws one path
-  // header, the first hunk, a `⋯` gap, then the second (the same-file
+  // single-hunk turn 62 also uses). Its result metadata carries two scattered
+  // hunks under one path header, so the card draws the first hunk, a `⋯` gap,
+  // then the second (the same-file
   // second-hunk arm turns 62/63 cannot reach).
-  toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
+  toolTurn(
+    64,
+    'edit',
+    '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}',
+    '已编辑',
+    {
+      diffs: [
+        { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
+        { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
+      ],
+    },
+  )
   // Turn 65: one run_code turn with three logged sub-dispatches — the Code
   // Mode acceptance surface (parent code row + nested native-identical rows,
   // including an isError sub-call and a bash sub-call that must hit the same
@@ -734,52 +771,75 @@ function buildAlphaLog(): SessionEvent[] {
   ]
   // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
   // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
-  // whose prompt label is its last segment, and a non-zero exit authored beside
-  // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
-  // `[exit code: N]` marker, since the real presenter consumes that one out of
-  // the body. Named `bash`, so it also covers
-  // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
-  // row) — the two chat-row shapes the terminal card renders in.
+  // whose prompt label is its last segment, and a non-zero exit. The raw result
+  // includes an `[exit code: N]` marker below; Client
+  // derivation consumes it into the status pill before rendering the body.
   //
   // Ordered BEFORE the todo turn deliberately: the standing plan retires at the
   // next `turn/start`, so a turn appended after it would leave the dock's plan
   // strip empty and take the todo surfaces' own coverage with it.
-  toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
-
-  // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
-  // `shape: 'matches'` result view (grouped-by-file matches, truncated with a
-  // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
-  // truncated). Both ride the keyed SearchRow registration under their own
-  // names; the render-site fallback row is covered by the model derivation
-  // tests, since every fixture search tool has a keyed row. Ordered before the
-  // todo turn for the same standing-plan reason the bash turn is.
-  toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
-  toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
+  toolTurn(
+    66,
+    'bash',
+    '{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}',
+    `${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`,
+  )
+
+  // Turns 67-68 carry the search card's two metadata variants: grouped matches
+  // and a flat path list, both truncated with a larger pre-cap total. Both use
+  // the keyed SearchRow registration. They stay before the todo turn for the
+  // same standing-plan reason as the bash turn.
+  toolTurn(
+    67,
+    'grep',
+    '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}',
+    SEARCH_MATCHES_TEXT,
+    { shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 },
+  )
+  toolTurn(
+    68,
+    'glob',
+    '{"pattern":"**/SearchBlock*","path":"packages/client"}',
+    SEARCH_PATHS_TEXT,
+    { shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 },
+  )
 
   // Turn 69: the read sample — a WINDOW past an offset so the card draws file
   // line numbers starting above 1 and a "showing N of M" note (the window is
   // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
   // highlights. Named `read`, so it exercises the keyed ReadRow registration.
-  // The render-site fallback ROW SHAPE (a read call on the generic flattened
-  // path) is covered by the turn 65 run_code read sub-dispatches, which
-  // session.ts folds with resultView: null; the fallback-row + read-CARD
-  // combination is pinned by the web_fetch case in read-card.spec.tsx, not by
-  // this fixture. The read render intent is result-side only, so its pending
-  // call stays a generic `kind: 'read'` card; presentResult carries the
-  // structured window.
-  toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
-
-  // Turns 70-71: the web render intent — a web_search whose result view carries
-  // structured sources plus an answer (the citation list, one source lacking a
-  // title so its hostname labels the link, the capped indicator on), and a
-  // web_fetch whose result view carries the fetched URL and its HTTP status.
-  // Both keep a generic pending call view and add the `web` card only at
-  // result time, which is the contract's result-only web shape. Named after
-  // the real tools so they hit the keyed WebRow registration. Ordered BEFORE
-  // the todo turn for the same reason turn 66 is: the standing plan retires at
-  // the next turn/start, so a turn after it would empty the dock's plan strip.
-  toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.')
-  toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
+  // The run_code sub-dispatches above cover nested read calls without result
+  // metadata; this top-level result carries the structured window.
+  toolTurn(
+    69,
+    'read',
+    `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`,
+    READ_SAMPLE_TEXT,
+    {
+      path: READ_SAMPLE_PATH,
+      offset: READ_SAMPLE_FIRST_LINE,
+      lines: READ_SAMPLE_LINES,
+      totalLines: READ_SAMPLE_TOTAL,
+      lang: 'ts',
+    },
+  )
+
+  // Turns 70-71 carry the web tools' result metadata. They stay before the todo
+  // turn because a later turn/start retires the standing plan projection.
+  toolTurn(
+    70,
+    'web_search',
+    '{"queries":["deepseek harness architecture"]}',
+    'Search results for deepseek harness architecture.',
+    WEB_SEARCH_META,
+  )
+  toolTurn(
+    71,
+    'web_fetch',
+    '{"url":"https://www.deepseek.com/blog/harness-architecture"}',
+    '# Harness architecture\n\nEverything is a plugin.',
+    WEB_FETCH_META,
+  )
 
   // Turn 72: max-tokens sample — the provider ends the turn at its output cap
   // mid-sentence, so the chat flow must render the turn-max-tokens notice
@@ -832,151 +892,6 @@ function buildAlphaLog(): SessionEvent[] {
   return events as unknown as SessionEvent[]
 }
 
-/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */
-/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */
-const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback
-
-/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */
-function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
-  let args: Record<string, unknown>
-  try {
-    args = JSON.parse(argsRaw) as Record<string, unknown>
-  } catch {
-    /* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */
-    return undefined
-  }
-  switch (name) {
-    // Both names present the same terminal card: `fx-bash` lands on the
-    // render-site fallback row, `bash` on the keyed BashRow registration.
-    case 'fx-bash':
-    case 'bash':
-      return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
-    case 'fx-write':
-      return {
-        card: 'diff', title: `Write ${str(args.path)}`,
-        diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
-      }
-    // A read pending call is a GENERIC card (kind: 'read', a follow-along
-    // location): the read render intent is result-side only, because a call
-    // carries no file content until execute returns. The rich read card arrives
-    // in presentResult.
-    case 'read':
-      return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
-    case 'edit':
-      // The multi-hunk sample (turn 64) is keyed on its file_path, so the two
-      // scattered hunks share one path header and the card draws the `⋯` gap.
-      if (str(args.file_path) === 'src/config.ts') {
-        return {
-          card: 'diff', title: `Edit ${str(args.file_path)}`,
-          diffs: [
-            { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
-            { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
-          ],
-        }
-      }
-      return {
-        card: 'diff', title: `Edit ${str(args.file_path)}`,
-        diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
-      }
-    case 'write':
-      return {
-        card: 'diff', title: `Write ${str(args.file_path)}`,
-        diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
-      }
-    // A search call stays a generic card (kind: 'search'): the structured
-    // matches/paths exist only after execute, so the search card is result-time
-    // only (presentResult builds it). This mirrors the real grep/glob presenters.
-    case 'grep':
-      return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args }
-    case 'glob':
-      return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args }
-    // The web tools keep a GENERIC pending card and add the `web` result card
-    // only at result time (the contract's result-only web shape); their pending
-    // kind matches the result kind so a call and its result read as one category.
-    case 'web_search': {
-      const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : []
-      const title = queries.join(', ')
-      return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args }
-    }
-    case 'web_fetch':
-      return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
-    default:
-      return undefined // echo et al: the documented no-view fallback path
-  }
-}
-
-function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
-  const call = presentCall(name, argsRaw)
-  if (call === undefined) return undefined
-  // Search is result-time only: the call stays a generic search card, and the
-  // result view carries the structured shape the card renders. The view holds no
-  // result text — a UI without a search card falls back to the raw tool/result
-  // content — so the truncation recovery footer rides that raw content (the
-  // `toolTurn` message text), not the view. `total` exceeds the retained count so
-  // the card shows its capped indicator.
-  if (name === 'grep') {
-    return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }
-  }
-  if (name === 'glob') {
-    return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }
-  }
-  // The read result is the structured window the tool projects through
-  // `presentationMeta`; the fixture authors it inline (it cannot import the
-  // tool). Keyed on the name because the read pending call is a generic card,
-  // so `call.card` alone does not distinguish it from edit/write.
-  if (name === 'read') {
-    return {
-      card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
-      totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
-    }
-  }
-  // The web tools keep a generic pending card, so their result card is chosen
-  // by tool name rather than by the pending card tag: the structured `web` card
-  // the frontend consumes. The view carries no `content` copy (per the contract
-  // and the web-result-card note); a capability-less UI falls back to the raw
-  // `tool/result` content, which this fixture emits from `resultText`.
-  if (name === 'web_search') {
-    return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
-  }
-  if (name === 'web_fetch') {
-    return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
-  }
-  switch (call.card) {
-    case 'terminal':
-      // The sample's own exit status, authored beside it: re-parsing the
-      // trailing marker here would duplicate the bash tool's `parseExitStatus`,
-      // which this client-side fixture cannot import.
-      return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
-    case 'diff':
-      return { card: 'diff', diffs: call.diffs }
-    case 'generic':
-      return { card: 'generic', content: text(resultText) }
-  }
-}
-
-/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */
-function viewFor(event: SessionEvent, log: readonly SessionEvent[]): FixtureToolView | undefined {
-  if (event.type === 'tool/call') {
-    const view = presentCall(event.data.name, event.data.arguments)
-    return view === undefined ? undefined : { for: 'call', view }
-  }
-  if (event.type === 'tool/result') {
-    const callId = String(event.data.message.source.callId)
-    for (let i = log.length - 1; i >= 0; i--) {
-      const candidate = log[i]
-      /* v8 ignore next -- dense-array guard: i stays within [0, log.length),
-      so the undefined arm needs a sparse log no code path builds. */
-      if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) {
-        const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('')
-        const view = presentResult(candidate.data.name, candidate.data.arguments, resultText)
-        return view === undefined ? undefined : { for: 'result', view }
-      }
-    }
-    return undefined // cross-page unpaired: documented default
-  }
-  return undefined
-}
-
 /**
  * Fixture parallel of the plan unit's lifecycle fold. The paired
  * `command/done` retains successful plan selections and drops failures;
@@ -1422,11 +1337,9 @@ function projectionFramesOf(
 }
 
 /**
- * Message-boundary paging (mirrors the host's paging contract): count
- * maxMessages messages
- *  backwards from end, cut at a turn/start boundary.
- Entries carry pagination-time views
- *  (the host analogue computes viewFor per entry at page time). */
+ * Message-boundary paging mirrors the Host contract: count `maxMessages`
+ * backwards from the end and cut at a turn/start boundary.
+ */
 function pageOf(
   log: readonly SessionEvent[],
   beforeSeq: number | undefined,
@@ -1445,10 +1358,7 @@ function pageOf(
       break
     }
   }
-  const events = log.slice(start, end).map((event): FixtureHistoryEntry => {
-    const view = viewFor(event, log)
-    return view === undefined ? { event } : { event, view }
-  })
+  const events = log.slice(start, end).map((event): FixtureHistoryEntry => ({ event }))
   return { events, hasMore: start > 0 }
 }
 
@@ -1974,12 +1884,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     const log = logOf(id)
     const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent
     log.push(event)
-    // Emission-time view derivation (mirrors the host's live path).
-    const view = viewFor(event, log)
-    /* v8 ignore next 2 -- the view-present arm needs a live tool/call emission,
-    but the fixture replay produces text-only turns; view vocabulary is
-    exercised through the history samples (turns 60-62). */
-    emitFollow(id, view === undefined ? { event } : { event, view })
+    emitFollow(id, { event })
     // Host eager-drive parallel: a unit-advancing event pushes its finished value.
     for (const frame of projectionFramesOf(id, log, event)) emitControl(frame)
     if (event.type === 'user/message' && event.data.source.kind === 'user') {
@@ -3007,8 +2912,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
             throw new Error(`fixture: session event replay skipped seq ${String(nextSeq)}`)
           }
           nextSeq++
-          const view = viewFor(event, snapshot)
-          yield view === undefined ? { type: 'event', event } : { type: 'event', event, view }
+          yield { type: 'event', event }
         }
       }
       for await (const frame of conn.drain(signal)) {

+ 0 - 1
packages/client/connection/src/client/index.ts

@@ -32,7 +32,6 @@ declare module '@deepseek-ai/cordis' {
 export type {
   ApiProxy, HostApi,
   DirectoryEntry, DirectoryListing,
-  ToolCallView, ToolResultView,
   SkillsApi, SkillEntry,
   ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
   MessageId, ModelReasoningEffort, ModelSelection,

+ 43 - 1
packages/client/connection/tests/fixture.client.spec.ts

@@ -37,7 +37,6 @@ interface FixtureSessionSummary {
 
 interface FixtureHistoryEntry {
   readonly event: SessionEvent
-  readonly view?: unknown
 }
 
 interface FixturePage {
@@ -648,6 +647,49 @@ describe('createFixtureApi', () => {
     })
   })
 
+  it('serves raw history entries with replayable tool-result metadata', async () => {
+    const api = createFixtureApi()
+    const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 }))
+    if (!response.result.ok) throw new Error('history failed')
+
+    const entries = response.result.value.events
+    expect(entries.every(entry => !Object.hasOwn(entry, 'view'))).toBe(true)
+    const results = entries
+      .map(entry => entry.event)
+      .filter(event => event.type === 'tool/result')
+
+    expect(results.find(event => event.data.turn === 64)).toMatchObject({
+      data: {
+        meta: {
+          diffs: [
+            { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' },
+            { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' },
+          ],
+        },
+      },
+    })
+    expect(results.find(event => event.data.turn === 67)).toMatchObject({
+      data: { meta: { shape: 'matches', truncated: true, total: 42 } },
+    })
+    expect(results.find(event => event.data.turn === 69)).toMatchObject({
+      data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } },
+    })
+    const webSearch = results.find(event => event.data.turn === 70)
+    expect(webSearch).toHaveProperty('data.meta.truncated', true)
+    expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([
+      expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }),
+    ]))
+    expect(results.find(event => event.data.turn === 71)).toMatchObject({
+      data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } },
+    })
+    const terminal = results.find(event => event.data.turn === 66)
+    expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text')
+    expect(terminal).toHaveProperty(
+      'data.message.content.0.content.0.text',
+      expect.stringContaining('\n[exit code: 1]'),
+    )
+  })
+
   it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
     const api = createFixtureApi()
     const sessionId = sid('fx-alpha')

+ 8 - 6
packages/client/modules/src/client/manifest.ts

@@ -42,11 +42,10 @@ declare module '@deepseek-ai/cordis' {
 /**
  * One composed client entry pushed by the host (a graph row). Wire
  * single source: the host node half (package root) produces this same shape.
- * `immediately` marks stage-one prefetch; `inject` is informational graph
- * metadata (the authoritative edges live in each package's `dsh.client`
- * declaration and reach fibers through entry creation). `external` carries
- * module-graph edges: unlike `inject`, they constrain code arrival because
- * `require` is synchronous (see {@link WebBootGraph.entries}).
+ * `immediately` marks stage-one prefetch. `inject` names package rows whose
+ * factories must arrive before this row materializes, while Cordis separately
+ * uses the same package edges to compose entries. `external` carries exact
+ * non-inject module requests (see {@link WebBootGraph.entries}).
  */
 export interface WebBootEntry {
   /** Entry name == package name. */
@@ -55,7 +54,7 @@ export interface WebBootEntry {
   url: string
   /** Bundle content hash (cache-busting consistency anchor). */
   rev: string
-  /** Package-name dependency edges, informational (preflight display / HMR diffing). */
+  /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
   /** Stage-one prefetch mark: load the script for factory registration during module-face boot. */
   immediately?: boolean
@@ -83,6 +82,8 @@ export interface BootModuleRow {
   url: string
   /** Bundle content hash. */
   rev: string
+  /** Injected package rows whose factories arrive before this row materializes. */
+  inject: string[]
   /** Module specifiers this row requests from the module table ([] when the wire omits them). */
   external: string[]
 }
@@ -176,6 +177,7 @@ export function parseBootManifest(wire: unknown): BootManifest {
       id: row.id,
       url: row.url,
       rev: row.rev,
+      inject: inject === undefined ? [] : [...inject],
       external: external === undefined ? [] : [...external],
     })
     plugins.push({

+ 13 - 3
packages/client/modules/src/client/system.ts

@@ -124,8 +124,12 @@ export class ClientModuleSystem implements ClientModuleLoader {
     return task
   }
 
-  /** Register each unresolved dynamic request before registering its consumer. */
-  private async arriveGraphRow(row: BootModuleRow, open: readonly string[] = []): Promise<void> {
+  /** Register each injected package and unresolved dynamic request before its consumer. */
+  private async arriveGraphRow(
+    row: BootModuleRow,
+    open: readonly string[] = [],
+    visited = new Set<string>(),
+  ): Promise<void> {
     const cycleStart = open.indexOf(row.id)
     if (cycleStart !== -1) {
       throw new Error(
@@ -133,12 +137,18 @@ export class ClientModuleSystem implements ClientModuleLoader {
         + '(the host must reject this graph before serving it)',
       )
     }
+    if (visited.has(row.id)) return
+    visited.add(row.id)
     const next = [...open, row.id]
     for (const request of row.external) {
       const id = stripClientSuffix(request)
       if (this.seed.has(request) || this.loadCache.has(id)) continue
       const dependency = this.graphRows.get(id)
-      if (dependency !== undefined) await this.arriveGraphRow(dependency, next)
+      if (dependency !== undefined) await this.arriveGraphRow(dependency, next, visited)
+    }
+    for (const packageName of row.inject) {
+      const dependency = this.graphRows.get(packageName)
+      if (dependency !== undefined) await this.arriveGraphRow(dependency, [], visited)
     }
     await this.arrive(row)
   }

+ 20 - 4
packages/client/modules/tests/loader.client.spec.ts

@@ -20,7 +20,7 @@ afterEach(() => {
 })
 
 const row = (id: string, fields: Partial<BootModuleRow> = {}): BootModuleRow =>
-  ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', external: [], ...fields })
+  ({ id, url: `/plugins/${id}/client.js?rev=0`, rev: '0', inject: [], external: [], ...fields })
 
 interface Bench {
   loader: ClientModuleLoader
@@ -147,6 +147,22 @@ describe('lazy CJS arrival', () => {
     expect(exports.react.marker).toBe('react')
   })
 
+  it('registers injected package factories before materializing a consumer', async () => {
+    const b = bench([
+      row('consumer', { inject: ['provider'] }),
+      row('provider', { inject: ['consumer'] }),
+    ], {
+      consumer: req => ({ provider: req('provider/client') }),
+      provider: () => ({ marker: 'provider' }),
+    })
+    const exports = await b.loader.import('consumer', '', {}) as { provider: { marker: string } }
+    expect(b.fetched).toEqual([
+      '/plugins/provider/client.js?rev=0',
+      '/plugins/consumer/client.js?rev=0',
+    ])
+    expect(exports.provider.marker).toBe('provider')
+  })
+
   it('concurrent callers share one in-flight arrival and materialize once', async () => {
     const ran: string[] = []
     const url = '/plugins/a/client.js?rev=0'
@@ -306,13 +322,13 @@ describe('boot manifest wire', () => {
     const manifest = parseBootManifest({
       rev: 'graph',
       entries: [
-        { id: 'a', url: '/plugins/a/client.js', rev: '1' },
+        { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'] },
         { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] },
       ],
     })
     expect(manifest.modules).toEqual([
-      { id: 'a', url: '/plugins/a/client.js', rev: '1', external: [] },
-      { id: 'b', url: '/plugins/b/client.js', rev: '2', external: ['react'] },
+      { id: 'a', url: '/plugins/a/client.js', rev: '1', inject: ['b'], external: [] },
+      { id: 'b', url: '/plugins/b/client.js', rev: '2', inject: [], external: ['react'] },
     ])
   })
 

+ 3 - 8
packages/client/ui-chat/src/client/conversation-nodes/tool.ts

@@ -45,7 +45,6 @@ function rootCall(match: ConversationMatch): RunningToolCall {
     turn: match.event.data.turn,
     step: match.event.data.step,
     time: match.event.time,
-    callView: match.view?.for === 'call' ? match.view.view : null,
     subCalls: [],
   }
 }
@@ -64,8 +63,6 @@ function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolR
     isError: result.isError === true,
     ...match.event.data.error === undefined ? {} : { error: match.event.data.error },
     meta: match.event.data.meta,
-    callView: previous?.callView ?? null,
-    resultView: match.view?.for === 'result' ? match.view.view : null,
     subCalls: [],
   }
 }
@@ -82,12 +79,12 @@ interface DispatchData {
 function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
   return {
     callId: data.subCallId,
+    parentCallId: data.parentCallId,
     name: data.name,
     argsRaw: jsonArguments(data.arguments),
     turn: locationTurn(match),
     step: locationStep(match),
     time: match.event.time,
-    callView: null,
     subCalls: [],
   }
 }
@@ -98,12 +95,11 @@ function childResult(match: ConversationMatch, data: DispatchData, previous?: To
     seq: match.event.seq,
     time: match.event.time,
     callId: data.subCallId,
+    parentCallId: data.parentCallId,
     call: { name: data.name, argsRaw: jsonArguments(data.arguments) },
     callTime: previous?.time ?? null,
     content: data.content ?? [],
     isError: data.isError === true,
-    callView: null,
-    resultView: null,
     subCalls: [],
   }
 }
@@ -197,13 +193,12 @@ function projectBlock(
       seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
       time: interruptedAt.time,
       callId: block.callId,
+      ...block.parentCallId === undefined ? {} : { parentCallId: block.parentCallId },
       call: { name: block.name, argsRaw: block.argsRaw },
       callTime: block.time,
       content: [],
       isError: true,
       error: { name: 'Interrupted', code: 'interrupted' },
-      callView: block.callView,
-      resultView: null,
       subCalls: children,
     }
   projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected })

+ 2 - 3
packages/client/ui-chat/src/client/details/DetailsPanel.tsx

@@ -47,8 +47,8 @@ function rawResultText(block: ToolCallBlock): string {
 
 export function DetailsPanel({ useChat, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
   const selection = useStore(s => s.selection)
-  // Session workspace root: an omitted or relative terminal cwd resolves
-  // against it, which the pure presenter cannot see.
+  // Session workspace root: a card model resolves omitted or relative
+  // tool paths against it without reading Session services.
   const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
   const callId = selection?.callId
   // materialFor builds a fresh wrapper; shallowEqual short-circuits on its
@@ -56,7 +56,6 @@ export function DetailsPanel({ useChat, useSessions, sessionId, useStore, render
   const material = useChat(
     s => (callId === undefined ? null : materialFor(s, callId)),
     (a, b) => shallowEqual(a, b))
-
   return (
     <div className={css.root}>
       <div className={css.header}>

+ 2 - 3
packages/client/ui-chat/src/client/model/tool-call-tree.ts

@@ -59,12 +59,12 @@ export class ToolCallTree {
       const data = event.data
       const running: RunningToolCall = {
         callId: data.subCallId,
+        parentCallId: data.parentCallId,
         name: data.name,
         argsRaw: JSON.stringify(data.arguments),
         turn: 0,
         step: 0,
         time: event.time,
-        callView: null,
         subCalls: [],
       }
       const siblings = this.childrenByParent.get(data.parentCallId) ?? []
@@ -84,12 +84,11 @@ export class ToolCallTree {
       seq: event.seq,
       time: event.time,
       callId: data.subCallId,
+      parentCallId: data.parentCallId,
       call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
       callTime: started?.time ?? null,
       content: data.content,
       isError: data.isError,
-      callView: null,
-      resultView: null,
       subCalls: [],
     }
     this.childrenByParent.set(

+ 2 - 2
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -78,7 +78,7 @@ describe('deriveStats', () => {
   it('ignores tool results with no call time', () => {
     const tool: ToolResultNode = {
       kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
-      isError: false, callView: null, resultView: null, subCalls: [],
+      isError: false, subCalls: [],
     }
     const stats = deriveStats([tool, assistant(1, 1)])
     expect(stats.steps).toBe(1)
@@ -96,7 +96,7 @@ describe('deriveStats', () => {
     }
     const tool: ToolResultNode = {
       kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
-      isError: false, callView: null, resultView: null, subCalls: [],
+      isError: false, subCalls: [],
     }
     const stats = deriveStats([timed, untimed, tool])
     expect(stats.llmMs).toBe(2_500)

+ 4 - 7
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -137,10 +137,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
   kind: 'tool-result', seq, time: seq * 1_000, callId,
   call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
   callTime: seq * 1_000 - 500,
-  content: [], isError: false, callView: null, resultView: null, subCalls: [],
+  content: [], isError: false, subCalls: [],
 })
 const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
-  callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
+  callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [],
 })
 const command = (over: Partial<CommandNode> = {}): CommandNode => ({
   kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
@@ -361,17 +361,14 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
 describe('Chat node rendering', () => {
 
   it('threads the injected file-mention vocabulary into the closing prose only', () => {
-    const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
+    const wrote = (seq: number, callId: string): ToolResultNode => ({
       ...toolResult(seq, callId, 'write'),
-      callView: {
-        card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
-      },
     })
     const h = makeHarness({
       nodes: [
         user(1, 'build it'),
         assistant(2, 'writing `report.html` now', 1),
-        wrote(3, 'w', 'site/report.html'),
+        wrote(3, 'w'),
         assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1),
       ],
       turnEnds: new Map([[1, 4]]),

+ 16 - 7
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -106,7 +106,7 @@ function assistantMessage(id: string, text: string) {
   }
 }
 
-function toolResult(callId: string, text: string) {
+function toolResult(callId: string, text: string, isError = false) {
   return {
     id: `result-${callId}`,
     role: 'user',
@@ -115,7 +115,7 @@ function toolResult(callId: string, text: string) {
       type: 'tool-result',
       toolCallId: callId,
       content: [{ type: 'text', text }],
-      isError: false,
+      isError,
     }],
   }
 }
@@ -310,7 +310,9 @@ describe('built-in conversation node Definitions', () => {
     value.append(at(4, 'tool/result', {
       turn: 1,
       step: 1,
-      message: toolResult('root', 'done'),
+      message: toolResult('root', 'done', true),
+      error: { name: 'ToolError', code: 'failed' },
+      meta: { presentation: 'raw' },
     }, { surfaceOp: 'append' }))
     value.flush()
 
@@ -318,7 +320,15 @@ describe('built-in conversation node Definitions', () => {
     const settled = node(settledSnapshot, 'tool-call')
     expect(settled?.key).toBe(running?.key)
     expect(settledSnapshot.order).toBe(order)
-    expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' })
+    expect((settled?.data as ToolChatData).root).toMatchObject({
+      kind: 'tool-result',
+      callId: 'root',
+      call: { name: 'code', argsRaw: '{}' },
+      content: [{ type: 'text', text: 'done' }],
+      isError: true,
+      error: { name: 'ToolError', code: 'failed' },
+      meta: { presentation: 'raw' },
+    })
 
     const history = assembler([
       at(14, 'tool/code-dispatch-start', {
@@ -345,7 +355,7 @@ describe('built-in conversation node Definitions', () => {
     ], true)
     const before = node(snapshot(history), 'tool-call')
     expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
-      { kind: 'tool-result', callId: 'child', call: { name: 'read' } },
+      { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
     ])
 
     history.prepend([
@@ -364,7 +374,7 @@ describe('built-in conversation node Definitions', () => {
     const after = node(snapshot(history), 'tool-call')
     expect(after?.key).toBe(before?.key)
     expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
-      { kind: 'tool-result', callId: 'child', call: { name: 'read' } },
+      { kind: 'tool-result', callId: 'child', parentCallId: 'history-root', call: { name: 'read' } },
     ])
 
     const firstChild = (after?.data as ToolChatData).root.subCalls[0]
@@ -1011,7 +1021,6 @@ describe('built-in conversation node Definitions', () => {
     // behavior of both required Definition members anyway.
     const match = (seq: number, type: string, data: unknown) => ({
       event: { seq, time: seq * 1_000, type, data },
-      view: undefined,
       role: 'start',
       location: undefined,
     }) as unknown as Parameters<typeof turnMaxTokensDefinition.start>[1]

+ 9 - 6
packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -168,22 +168,24 @@ describe('render branch tails', () => {
     expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
   })
 
-  it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
+  it('DetailsPanel passes the existing parentCallId through to the Tool details seat', () => {
     localStorage.clear()
     const session = sessionSnapshot()
     const longText = 'x'.repeat(1_000)
     const runningCalls: readonly RunningToolCall[] = [{
       callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
-      time: 7_000, callView: null, subCalls: [{
+      time: 7_000, subCalls: [{
         kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
+        parentCallId: 'p1',
         call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
         callTime: 8_000,
-        content: [], isError: false, callView: null, resultView: null,
+        content: [], isError: false,
         subCalls: [{
           kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
+          parentCallId: 'p1:code:1',
           call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
           callTime: 8_500,
-          content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
+          content: [{ type: 'text', text: longText }], isError: false,
           subCalls: [],
         }],
       }],
@@ -224,13 +226,14 @@ describe('render branch tails', () => {
         t={t}
       />,
     )
-    // Chat resolves the selected sub-call and hands its complete
-    // frozen block to the Tool-owned details seat.
+    // Chat resolves the selected sub-call and keeps its Code Dispatch parent
+    // identity on the block handed to the Tool-owned details seat.
     expect(view.getByText('read')).toBeTruthy()
     expect(view.getByTestId('tool-details-seat')).toBeTruthy()
     expect(owners).toHaveLength(1)
     expect(owners[0]?.block).toMatchObject({
       callId: 'p1:code:1:code:1',
+      parentCallId: 'p1:code:1',
       call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
       content: [{ type: 'text', text: longText }],
     })

+ 3 - 3
packages/client/ui-chat/tests/tool-call-tree.client.spec.ts

@@ -21,7 +21,7 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv
 
 const root = (callId: string): RunningToolCall => ({
   callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
-  time: 1_700_000_000_000, callView: null, subCalls: [],
+  time: 1_700_000_000_000, subCalls: [],
 })
 
 describe('ToolCallTree', () => {
@@ -42,8 +42,8 @@ describe('ToolCallTree', () => {
     expect(tree.projectRunningCalls([root('a')])).toMatchObject([{
       callId: 'a',
       subCalls: [{
-        callId: 'b',
-        subCalls: [{ callId: 'c', subCalls: [] }],
+        callId: 'b', parentCallId: 'a',
+        subCalls: [{ callId: 'c', parentCallId: 'b', subCalls: [] }],
       }],
     }])
   })

+ 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: 6f47cd87af46cc270d3160482ad047b249aa5053
-README.zh.md: 38e3c626073e9e90a16eebdb91af1e89d4da7c22
+README.md: 4c9665b680fe1922770403d88a04dffc755481ad
+README.zh.md: 8bf3db2cb1401427f29016f8dbddcd9d27ec9635

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

@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
 
 `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source.
 
-The adapter converts each `SessionEventEntry` to `ConversationEventInput` as `{ event, view? }`: the raw Session event is preserved and the envelope-level tool view is included only when present. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
+The adapter converts each `SessionEventEntry` to a `{ event }` `ConversationEventInput` and preserves the raw Session event, including tool-result metadata. Contiguous append and prepend revisions use incremental assembly; replacement windows and revision gaps rebuild from the complete loaded window. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
 
 Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry.
 

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

@@ -8,7 +8,7 @@
 
 `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开 event source。
 
-adapter 将每个 `SessionEventEntry` 转换成 `{ event, view? }` 形式的 `ConversationEventInput`:原始 Session event 保持不变,仅在 envelope-level tool view 存在时携带 `view`。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
+adapter 将每个 `SessionEventEntry` 转换成 `{ event }` 形式的 `ConversationEventInput`,并保留原始 Session event,包括工具结果 metadata。连续 revision 的 append 和 prepend 使用增量组装;replace window 或 revision 断档从完整已加载窗口重建。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
 
 target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。
 

+ 1 - 3
packages/client/ui-conversation/src/client/contract/conversation.ts

@@ -1,14 +1,12 @@
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
-import type { SessionToolView } from '@deepseek-ai/dsh-api-session-controller/types'
 
 /* oxlint-disable typescript/no-duplicate-type-constituents, typescript/no-redundant-type-constituents --
  * The unaugmented declaration-merge maps intentionally resolve to never in the Runtime program;
  * installed business packages supply their concrete keys in consuming Client programs. */
 
-/** One raw log event plus its optional envelope-level presentation view. */
+/** One raw Session log event consumed by Conversation assembly. */
 export interface ConversationEventInput {
   readonly event: SessionEvent
-  readonly view?: SessionToolView
 }
 
 /** Definition-local identity and lifecycle role extracted from one event. */

+ 4 - 9
packages/client/ui-conversation/src/client/contract/records.ts

@@ -8,9 +8,6 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
 import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
-import type {
-  ToolCallView, ToolResultView,
-} from '@deepseek-ai/dsh-api-remotes/client'
 import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
 export type { TodoItem }
 
@@ -161,6 +158,8 @@ export interface ToolResultNode {
   /** Unix epoch ms from the tool/result session event. */
   time: number
   callId: string
+  /** Parent Tool call for a Code Dispatch result; absent on a root Session result. */
+  parentCallId?: string
   /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
   call: { name: string; argsRaw: string } | null
   /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
@@ -169,10 +168,6 @@ export interface ToolResultNode {
   isError: boolean
   error?: { name: string; code: string }
   meta?: unknown
-  /** Host-computed render intent from the paired tool/call's wire view; null = generic JSON card (documented default). */
-  callView: ToolCallView | null
-  /** Host-computed render intent from this tool/result's wire view; null = same default. */
-  resultView: ToolResultView | null
   /** Child calls owned by this call, in dispatch order. */
   subCalls: readonly ToolCallBlock[]
 }
@@ -268,14 +263,14 @@ export type ConversationNode =
 /** In-flight tool card material: tool/call seen, tool/result not yet. */
 export interface RunningToolCall {
   callId: string
+  /** Parent Tool call for a Code Dispatch start; absent on a root Session call. */
+  parentCallId?: string
   name: string
   argsRaw: string
   turn: number
   step: number
   /** Unix epoch ms when the tool/call event was logged. */
   time: number
-  /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
-  callView: ToolCallView | null
   /** Child calls owned by this call, in dispatch order. */
   subCalls: readonly ToolCallBlock[]
 }

+ 1 - 1
packages/client/ui-conversation/src/client/conversation/assembler.ts

@@ -189,7 +189,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore
 
   /**
    * Add one contiguous live tail event without scanning existing Contexts.
-   * @param input - appended Event and optional wire view.
+   * @param input - appended Session event.
    * @returns highest requested publication cadence.
    */
   append(input: ConversationEventInput): ConversationPublication {

+ 1 - 4
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -128,10 +128,7 @@ class BoundConversation implements ConversationBinding {
 }
 
 function conversationInput(entry: SessionEventEntry): ConversationEventInput {
-  return {
-    event: entry.event as unknown as SessionEvent,
-    ...(entry.view === undefined ? {} : { view: entry.view }),
-  }
+  return { event: entry.event as unknown as SessionEvent }
 }
 
 interface BindingRecord {

+ 6 - 0
packages/client/ui-conversation/src/client/locales.ts

@@ -145,6 +145,8 @@ export const zh = {
   'terminal.collapseAria': '收起输出',
   'terminal.expandAria': '展开其余 {n} 行输出',
   'terminal.expandRest': '… 其余 {n} 行',
+  'terminal.sendInput': '(发送输入)',
+  'terminal.session': '终端 {sessionId}',
 } satisfies Record<string, string>
 
 /** The conversation namespace key union. */
@@ -288,4 +290,8 @@ export const en = {
   'terminal.collapseAria': 'Collapse output',
   'terminal.expandAria': 'Expand the remaining {n} output lines',
   'terminal.expandRest': '… {n} more lines',
+  // The Host terminal_send presenter has no locale seat; keep its fallbacks
+  // aligned with these English values.
+  'terminal.sendInput': '(send input)',
+  'terminal.session': 'Terminal {sessionId}',
 } satisfies Record<ConversationKey, string>

Some files were not shown because too many files changed in this diff