Bladeren bron

Merge remote-tracking branch 'origin/master' into worktree/steer-followup-images

creatixchu 1 maand geleden
bovenliggende
commit
b71d0a35ef
100 gewijzigde bestanden met toevoegingen van 2421 en 152 verwijderingen
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml
  2. 100 0
      .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md
  3. 100 0
      .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml
  5. 113 0
      .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md
  6. 113 0
      .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml
  8. 96 0
      .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md
  9. 96 0
      .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.i18n.yaml
  11. 30 0
      .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md
  12. 30 0
      .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md
  13. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.i18n.yaml
  14. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md
  15. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md
  16. 6 0
      .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml
  17. 16 0
      .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md
  18. 43 0
      .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md
  19. 2 2
      README.i18n.yaml
  20. 1 1
      README.md
  21. 1 1
      README.zh.md
  22. 8 4
      apps/web/tests/chat-continuous-conversation.e2e.ts
  23. 2 1
      apps/web/tests/chat-long-interactions.e2e.ts
  24. 12 3
      apps/web/tests/chat-scroll-contract.e2e.ts
  25. 10 4
      apps/web/tests/code-mode-round.e2e.ts
  26. 1 1
      apps/web/tests/composer-tab-geometry.e2e.ts
  27. 5 1
      apps/web/tests/cordis-tool-round.e2e.ts
  28. 56 0
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  29. 2 7
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  30. 5 1
      apps/web/tests/expected/settings-chrome/dialog-en.expected.md
  31. 5 1
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  32. 49 0
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  33. 2 7
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  34. 59 0
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  35. 2 11
      apps/web/tests/expected/steer-all/settled.expected.md
  36. 12 2
      apps/web/tests/feedback-command.e2e.ts
  37. 10 2
      apps/web/tests/feedback-release.e2e.ts
  38. 10 0
      apps/web/tests/github-ready-review.e2e.ts
  39. 17 6
      apps/web/tests/goal-multi-turn-actions.e2e.ts
  40. 12 2
      apps/web/tests/lifecycle-chrome.e2e.ts
  41. 19 3
      apps/web/tests/live-interactions.e2e.ts
  42. 6 0
      apps/web/tests/minimal-preset.snapshot.ts
  43. 3 1
      apps/web/tests/navigation-panes.e2e.ts
  44. 11 2
      apps/web/tests/plan-review.e2e.ts
  45. 13 5
      apps/web/tests/question-composer.e2e.ts
  46. 12 2
      apps/web/tests/queue-actions.e2e.ts
  47. 18 3
      apps/web/tests/replay-round-trip.e2e.ts
  48. 33 0
      apps/web/tests/scaffold.ts
  49. 25 3
      apps/web/tests/seeded-history.e2e.ts
  50. 30 0
      apps/web/tests/settings-chrome.e2e.ts
  51. 4 2
      apps/web/tests/skill-tool-row.e2e.ts
  52. 20 9
      apps/web/tests/skill-user-invoke.e2e.ts
  53. 21 3
      apps/web/tests/steering.e2e.ts
  54. 12 2
      apps/web/tests/subagent-conversation.e2e.ts
  55. 30 1
      apps/web/tests/support.ts
  56. 94 7
      apps/web/tests/turn-tail-actions.e2e.ts
  57. 6 2
      apps/web/tests/web-search-round.e2e.ts
  58. 3 1
      apps/web/tests/workflow-run.e2e.ts
  59. 2 2
      docs/capability-seams.i18n.yaml
  60. 4 0
      docs/capability-seams.md
  61. 4 0
      docs/capability-seams.zh.md
  62. 2 2
      docs/config-catalog.i18n.yaml
  63. 68 0
      docs/config-catalog.md
  64. 68 0
      docs/config-catalog.zh.md
  65. 2 2
      docs/event-producer-consumer.i18n.yaml
  66. 3 3
      docs/event-producer-consumer.md
  67. 3 3
      docs/event-producer-consumer.zh.md
  68. 2 2
      docs/module-graph.i18n.yaml
  69. 8 1
      docs/module-graph.md
  70. 9 2
      docs/module-graph.zh.md
  71. 2 2
      docs/subsystems/extensions.i18n.yaml
  72. 18 0
      docs/subsystems/extensions.md
  73. 18 0
      docs/subsystems/extensions.zh.md
  74. 9 0
      knip.json
  75. 1 0
      package.json
  76. 2 2
      packages/client/ui-chat/README.i18n.yaml
  77. 4 0
      packages/client/ui-chat/README.md
  78. 4 0
      packages/client/ui-chat/README.zh.md
  79. 8 0
      packages/client/ui-chat/package.json
  80. 29 0
      packages/client/ui-chat/src/chat-settings.ts
  81. 20 1
      packages/client/ui-chat/src/client/apply.ts
  82. 7 0
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css
  83. 27 2
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  84. 10 2
      packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
  85. 162 5
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  86. 14 1
      packages/client/ui-chat/src/client/chat/ChatView.module.css
  87. 16 7
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  88. 48 0
      packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css
  89. 60 0
      packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
  90. 4 1
      packages/client/ui-chat/src/client/chat/register-node-renderers.ts
  91. 31 0
      packages/client/ui-chat/src/client/chat/searchable-hidden.ts
  92. 15 0
      packages/client/ui-chat/src/client/contract/assistant-content.ts
  93. 13 0
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  94. 17 0
      packages/client/ui-chat/src/client/contract/slots.ts
  95. 9 0
      packages/client/ui-chat/src/client/contract/store.ts
  96. 100 0
      packages/client/ui-chat/src/client/contract/turn-process.ts
  97. 99 6
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  98. 1 0
      packages/client/ui-chat/src/client/conversation-nodes/common.ts
  99. 2 0
      packages/client/ui-chat/src/client/conversation-nodes/register.ts
  100. 22 3
      packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.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-cross-realm-cdp-inspector.md
+2026-08-23-cross-realm-cdp-inspector.md: e6e1d48da4f7b2dfab2772e24cb49711e3e50f5a
+2026-08-23-cross-realm-cdp-inspector.zh.md: 9d67868caf9b4e5e0eb06583facd608b71468b22

+ 100 - 0
.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md

@@ -0,0 +1,100 @@
+# Agent Note: Cross-realm CDP inspector
+
+Status: implemented
+
+English | [中文](2026-08-23-cross-realm-cdp-inspector.zh.md)
+
+## Problem
+
+Host diagnostics, browser Client observations, and JavaScript debugging originate in different JavaScript realms. A debugger transport implemented on the Host main thread cannot deliver `Debugger.resume` while that thread is paused, and a design that lets each producer emit CDP directly duplicates protocol state and couples application instrumentation to Chrome's presentation protocol.
+
+## Decision
+
+`@deepseek-ai/dsh-experimental-inspector` is one private Client/Host Cordis plugin package. Its Host face starts a Node Worker; its Client face connects directly to that Worker. Cordis owns composition, service publication, bootstrap injection, and disposal only. The source protocol, Worker state, CDP server, V8 bridge, and domain adapters do not inspect Cordis runtime data.
+
+The Worker is the sole CDP endpoint and the sole owner of CDP state. Host and Client producers send validated observations under a versioned internal protocol; Client Runtime, Console, Sources, and semantic queries use separate typed frame families on the same authenticated carrier. A realm registry gives every DevTools connection the same Runtime, Console, Sources, and Debugger capability slots, while explicit unsupported members preserve different Host and Client support levels.
+
+## Realm ownership
+
+The Host main thread owns application objects and `globalThis.fetch`. It sends observations over a dedicated `MessagePort` and never constructs CDP messages.
+
+The Client page owns browser observations, evaluated values, and Client object handles. It exchanges JSON frames directly with the Worker over an authenticated ingest WebSocket, so a paused Host does not stop Client delivery or Runtime execution.
+
+The Inspector Worker owns HTTP discovery, both WebSocket routes, source generations, retention, realm sessions, CDP sessions, and domain adapters. Each DevTools connection opens one backend session for the Host and every connected Client realm. V8 object ids remain inside the Node Runtime backend. Client object handles remain inside the typed Client protocol. One connection-local object table maps either backend handle to CDP object ids and projects the same RemoteObject, property, exception, Console, and paused-frame types.
+
+Chrome DevTools consumes one page-type target. Runtime methods route by execution context or object id. Debugger source methods route by script id; Host scripts retain native debugging, while Client scripts expose read-only content and reject active debugging. `Profiler` and `HeapProfiler` remain Host-only. `Network` and the minimal page-target scaffold run inside the Worker.
+
+## Source protocol
+
+Both MessagePort and WebSocket carriers use the same JSON value set and discriminated frames. A source identifies one logical producer and one connection generation, declares capabilities and topics, sends an initial replacement, then appends sequence-numbered batches. The Worker rejects malformed, oversized, stale-generation, and undeclared-topic frames before reading domain fields.
+
+Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Host MessagePort carrier permits one append batch in flight and sends the next after the Worker acknowledges consumption. The Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects.
+
+Runtime frames use closed command and result unions instead of method strings with untyped parameter records. Every request carries a source id, source generation, DevTools Runtime session id, request id, and command. Every result repeats those identities and the command discriminant. Console lifecycle/events, chunked source reads, and non-CDP semantic queries have separate correlated frame families. RemoteObject values, previews, property descriptors, call arguments, exceptions, Console events, debugger frames, scripts, and errors have dedicated exact decoders.
+
+## Client Runtime, Console, and Sources
+
+`Runtime.enable` publishes the Host's real execution context and one negative-id synthetic execution context for each connected Client source that declares the Runtime capability. An omitted context continues to mean Host. Client source replacement destroys the old context and creates a new context with a new generation and unique id.
+
+The Client Runtime subset covers `Runtime.evaluate`, `Runtime.getProperties`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, `Runtime.releaseObject`, `Runtime.releaseObjectGroup`, and `Runtime.globalLexicalScopeNames`. The Client executes commands in its page realm and retains live objects in a table isolated by DevTools Runtime session. It returns opaque handles and JSON-safe metadata; the Worker validates the result and assigns a connection-local CDP object id. An object argument may be used only by the same Client source generation and DevTools session. Closing the source, disabling Runtime, closing DevTools, releasing an object, or releasing an object group removes the corresponding handles.
+
+JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. A Worker deadline sends request-scoped cancellation to the Client. Handles allocated for a response remain provisional until the Worker acknowledges that response, so cancellation and late responses cannot leave unreachable objects. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state.
+
+The Client Console observer preserves the original page call and asynchronously emits one event per enabled DevTools session. Each session serializes arguments into its own `console` object group, so disconnect, Runtime disable, or `Runtime.discardConsoleEntries` can release one connection without invalidating another. Context and Fiber arguments use the same semantic reference and DOM reverse mapping as evaluation results.
+
+The Client discovers this package's `lib/client.js` URL from the assembled web boot graph. `Debugger.enable` reads metadata through a typed source operation, and `Debugger.getScriptSource` reassembles bounded base64 chunks; the source map remains available at the advertised URL. Client-script breakpoint, step, and call-frame operations remain explicitly unsupported because page JavaScript cannot pause its own realm and continue servicing control messages. Target-wide pause and resume continue to control the Host debugger.
+
+## Host debugging
+
+The Worker attaches each DevTools connection to the Host main isolate through its own Node inspector Session. Node Runtime, Console, Sources, and Debugger backends normalize native values and events into the same realm model used by Client backends. The common projector allocates connection-local object ids for evaluation results, Console arguments, paused scopes, and call-frame results. Breakpoint requests are translated back to native backend handles before reaching Node. The default context may receive the display name `Host` while retaining its real id and metadata.
+
+The Worker event loop, DevTools socket, Client ingest socket, and Node inspector Session remain runnable while Host JavaScript is paused. Host observations naturally stop until resume.
+
+## Fetch capture
+
+Fetch capture wraps `globalThis.fetch` and is enabled by default. Every later fetch records its complete URL, headers, request body, response headers, response body, timing, cancellation, and error. No field is redacted by default; using the inspector grants local DevTools access to those secrets.
+
+The wrapper passes a normalized Request to the original fetch, reads request and response clones on independent capture tasks, and returns the original Response as soon as fetch resolves. Capture failure never changes the caller's fetch result. Finite per-body and journal budgets prevent unbounded retention; exceeding a budget preserves the captured prefix and reports truncation.
+
+## Alternatives considered
+
+**Run the CDP server on the Host main thread.** Rejected because a breakpoint freezes the socket responsible for delivering `Debugger.resume`.
+
+**Relay Client observations through the Host web server.** Rejected because the relay also freezes at a Host breakpoint and makes the Client data path depend on Host responsiveness.
+
+**Let producers emit CDP messages.** Rejected because producer code would own Chrome-specific request ids, replay, enable state, and ordering instead of domain observations.
+
+**Share one Node inspector Session across DevTools clients.** Rejected because object ids, object groups, enable state, and debugger operations belong to one protocol session; sharing requires an error-prone virtual-session layer.
+
+**Send live Client objects or CDP object ids over WebSocket.** Rejected because JSON cannot preserve identity or behavior, and a CDP object id belongs to one DevTools session. Client-local handles plus a Worker-owned per-connection mapping preserve both ownership rules.
+
+**Use one untyped Runtime RPC method.** Rejected because method strings and arbitrary parameter objects cannot enforce command/result correlation, object-reference ownership, or exhaustive evolution as Runtime, Sources, and Debugger support grows.
+
+**Split protocol, Host, and Client into separate packages.** Rejected for the experimental phase. One package keeps the capability deployable as one Client/Host plugin while source directories and build entries preserve realm boundaries.
+
+**Use Undici diagnostics channels as the complete fetch source.** Rejected because they observe transport lifecycle but cannot provide complete request and response bodies without consuming application streams. They may later augment transport-level timing.
+
+## Verification
+
+- A real Worker accepts Host MessagePort and Client WebSocket sources and exposes both through one CDP target.
+- Malformed, oversized, stale-generation, and sequence-gap frames cannot corrupt another source or the Worker.
+- Console evaluates in the Host context and receives Host console events.
+- Console lists Host and Client contexts; Client evaluation, properties, function calls, promise awaiting, and release operations preserve RemoteObject identity without sharing objects across realms or DevTools connections.
+- Host and Client Console events use the same projector; Client arguments remain isolated by DevTools connection and Cordis arguments resolve to Elements nodes.
+- Sources receives Host scripts and the built Client bundle; Client source reads are chunked and active debugging fails explicitly, while a breakpoint can pause the Host, evaluate a call frame, and resume.
+- Host paused scopes and call-frame results use the same connection-local RemoteObject table as Runtime evaluation.
+- Network replays requests that predate `Network.enable` and streams later requests without loss or duplication.
+- Successful, failed, aborted, redirected, textual, binary, streaming, and truncated fetches preserve caller behavior and expose the configured captured data.
+- Disposal stops capture, closes admission, disconnects V8 sessions, closes sockets, and waits for Worker exit before completing.
+
+## Consequences
+
+The Worker-owned endpoint keeps DevTools control responsive while Host JavaScript is paused and gives Host and Client observations one CDP state owner. That ownership adds the following security, resource, and compatibility costs.
+
+Full fetch capture intentionally exposes credentials and payloads to any local process that can attach to the CDP endpoint. Loopback binding is mandatory but is not authentication.
+
+Cloning request and response streams adds CPU, memory, and I/O pressure. Finite limits bound retained bytes but cannot make full capture free.
+
+A page-type synthetic target depends on a small set of Chrome DevTools compatibility responses outside Node's native inspector domains. Each no-op must be named and covered because silently accepting every unknown method hides protocol drift.
+
+Client Runtime execution uses page JavaScript evaluation, so page Content Security Policy may reject it and native DevTools command-line or REPL semantics are not promised. Read-only Client Sources do not imply active Client debugging; adding that capability requires an execution agent that remains responsive while the inspected page realm is paused.

+ 100 - 0
.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md

@@ -0,0 +1,100 @@
+# Agent Note: 跨 realm CDP Inspector
+
+Status: implemented
+
+[English](2026-08-23-cross-realm-cdp-inspector.md) | 中文
+
+## Problem
+
+Host 诊断、浏览器 Client 观测和 JavaScript 调试来自不同 JavaScript realm。Host 主线程上的 debugger transport 无法在该线程暂停时投递 `Debugger.resume`;若每个 producer 直接生成 CDP,又会重复协议状态,并把应用观测逻辑绑到 Chrome 呈现协议。
+
+## Decision
+
+`@deepseek-ai/dsh-experimental-inspector` 是一个私有 Client/Host 双面 Cordis 插件包。Host 面启动 Node Worker,Client 面直接连接该 Worker。Cordis 只负责组合、服务发布、bootstrap 注入与 dispose;source 协议、Worker 状态、CDP server、V8 bridge 和 domain adapter 不检查 Cordis 运行时数据。
+
+Worker 是唯一 CDP endpoint,也是 CDP 状态的唯一 owner。Host 与 Client producer 通过有版本的内部协议发送验证后的观测记录;Client Runtime、Console、Sources 和语义查询在同一条鉴权 carrier 上使用相互独立的类型化帧。realm registry 为每条 DevTools 连接提供相同的 Runtime、Console、Sources 和 Debugger capability slot,并用明确的 unsupported 成员保留 Host 与 Client 的支持差异。
+
+## Realm 所有权
+
+Host 主线程拥有应用对象和 `globalThis.fetch`。它通过专用 `MessagePort` 发送观测记录,绝不构造 CDP 消息。
+
+Client 页面拥有浏览器观测、求值得到的值和 Client object handle。它通过带鉴权的 ingest WebSocket 直接与 Worker 交换 JSON 帧,因此 Host 暂停不会阻断 Client 投递或 Runtime 执行。
+
+Inspector Worker 拥有 HTTP discovery、两条 WebSocket route、source generation、保留历史、realm session、CDP session 和 domain adapter。每条 DevTools 连接为 Host 和每个已连接 Client realm 分别建立一套 backend session。V8 object id 只留在 Node Runtime backend 内。Client object handle 只留在类型化 Client 协议内。单个 connection-local object table 把两类 backend handle 映射成 CDP object id,并投影同一种 RemoteObject、property、exception、Console 与 paused-frame 类型。
+
+Chrome DevTools 消费一个 page 类型 target。Runtime 方法按 execution context 或 object id 路由;Debugger source 方法按 script id 路由。Host script 保留原生调试,Client script 只暴露只读内容,并拒绝 active debugging。`Profiler` 与 `HeapProfiler` 仍然只属于 Host;`Network` 与最小 page-target scaffold 在 Worker 内执行。
+
+## Source 协议
+
+MessagePort 与 WebSocket carrier 使用同一组 JSON 值和判别联合帧。source 标识一个逻辑 producer 和一个连接 generation,声明 capability 与 topic,发送初始 replace,再追加带 sequence 的 batch。Worker 在读取 domain 字段前拒绝畸形、超限、旧 generation 和未声明 topic 的帧。
+
+投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;Host MessagePort carrier 同时只允许一个 append batch 在途,并在 Worker 确认消费后发送下一批。无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。
+
+Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符串加无类型 parameter record。每个 request 携带 source id、source generation、DevTools Runtime session id、request id 和 command;每个 result 重复这些身份与 command 判别符。Console lifecycle/event、分块 source 读取和非 CDP 语义查询使用各自独立的关联帧。RemoteObject value、preview、property descriptor、call argument、exception、Console event、debugger frame、script 与 error 都有独立的精确 decoder。
+
+## Client Runtime、Console 与 Sources
+
+`Runtime.enable` 发布 Host 的真实 execution context,并为每个声明 Runtime 能力的已连接 Client source 发布一个负数 id synthetic execution context。不指定 context 仍然表示 Host。Client source replacement 会销毁旧 context,并以新的 generation 与 unique id 创建新 context。
+
+Client Runtime 子集包括 `Runtime.evaluate`、`Runtime.getProperties`、`Runtime.callFunctionOn`、`Runtime.awaitPromise`、`Runtime.releaseObject`、`Runtime.releaseObjectGroup` 和 `Runtime.globalLexicalScopeNames`。Client 在页面 realm 中执行命令,并在按 DevTools Runtime session 隔离的表中保留实时对象。Client 只返回不透明 handle 与 JSON-safe metadata;Worker 验证结果并分配连接私有的 CDP object id。对象参数只能由同一 Client source generation 与 DevTools session 使用。source 断开、Runtime disable、DevTools 关闭、释放对象或释放 object group 都会移除对应 handle。
+
+JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。Worker deadline 会向 Client 发送 request-scoped cancellation。response 分配的 handle 在 Worker 确认该 response 前保持 provisional,因此 cancellation 和 late response 不会留下无法访问的对象。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。
+
+Client Console observer 保持原始页面调用行为,并为每个已启用的 DevTools session 异步发出一份 event。每个 session 把 argument 序列化到自己的 `console` object group,因此断联、Runtime disable 或 `Runtime.discardConsoleEntries` 可以释放一条连接而不使其他连接失效。Context 与 Fiber argument 使用和求值结果相同的语义引用及 DOM 反向映射。
+
+Client 从组装后的 web boot graph 发现本包 `lib/client.js` 的 URL。`Debugger.enable` 通过类型化 source operation 读取 metadata,`Debugger.getScriptSource` 重组有界 base64 chunk;source map 保持在公布的 URL 上可用。Client script breakpoint、step 与 call-frame 操作明确不受支持,因为页面 JavaScript 无法暂停自身 realm 后继续处理控制消息。target-wide pause 与 resume 继续控制 Host debugger。
+
+## Host 调试
+
+Worker 为每条 DevTools 连接建立独立 Node inspector Session,并连接 Host 主 isolate。Node Runtime、Console、Sources 与 Debugger backend 把原生 value 和 event 归一化成 Client backend 使用的同一种 realm model。公共 projector 为求值结果、Console argument、paused scope 和 call-frame result 分配 connection-local object id。breakpoint request 到达 Node 前会反向转换成原生 backend handle。默认 context 可以改显示名为 `Host`,但保留真实 id 和 metadata。
+
+Host JavaScript 暂停时,Worker event loop、DevTools socket、Client ingest socket 与 Node inspector Session 仍可运行。Host 观测自然暂停到 resume。
+
+## Fetch 采集
+
+fetch 采集包装 `globalThis.fetch`,并默认开启。之后每次 fetch 都记录完整 URL、headers、请求体、响应 headers、响应体、时间、取消与错误。默认不脱敏任何字段;启用 Inspector 即把这些秘密交给本机 DevTools。
+
+wrapper 把标准化 Request 交给原 fetch,通过独立采集任务读取 request/response clone,并在 fetch resolve 后立即把原始 Response 交给调用方。采集失败不得改变调用方的 fetch 结果。有限的单体与 journal 预算阻止无界保留;超过预算时保留已采集前缀并报告截断。
+
+## Alternatives considered
+
+**在 Host 主线程运行 CDP server。** 拒绝,因为断点会冻结负责投递 `Debugger.resume` 的 socket。
+
+**经 Host web server 中转 Client 观测。** 拒绝,因为 Host 断点同样冻结中转,并使 Client 数据路径依赖 Host 响应。
+
+**让 producer 直接生成 CDP 消息。** 拒绝,因为 Chrome 专用 request id、回放、enable 状态和排序会落入 producer,而不是领域观测。
+
+**多个 DevTools client 共用一个 Node inspector Session。** 拒绝,因为 object id、object group、enable 状态与 debugger 操作属于单个协议 session;共享需要易错的虚拟 session 层。
+
+**通过 WebSocket 发送 Client 实时对象或 CDP object id。** 拒绝,因为 JSON 无法保留对象身份或行为,而 CDP object id 只属于一条 DevTools session。Client-local handle 加 Worker 所有的逐连接映射同时维护这两条所有权规则。
+
+**使用一个无类型 Runtime RPC method。** 拒绝,因为 method 字符串和任意 parameter object 无法保证 command/result 关联、对象引用所有权,也无法在 Runtime、Sources 与 Debugger 支持增长时做穷尽演进。
+
+**把 protocol、Host 与 Client 拆成多个包。** 实验阶段拒绝。一个包保持能力以一个 Client/Host 插件部署,同时由源码目录与构建入口维护 realm 边界。
+
+**用 Undici diagnostics channel 作为完整 fetch 数据源。** 拒绝,因为它能观察 transport lifecycle,却无法在不消费应用 stream 的前提下提供完整 request/response body。后续可以用它补充 transport 级 timing。
+
+## Verification
+
+- 真实 Worker 同时接收 Host MessagePort 与 Client WebSocket source,并通过一个 CDP target 暴露两者。
+- 畸形、超限、旧 generation 与 sequence gap 帧不会破坏其他 source 或 Worker。
+- Console 在 Host context 求值并接收 Host console event。
+- Console 列出 Host 与 Client context;Client 求值、属性、函数调用、Promise await 与释放操作维持 RemoteObject 身份,且不在 realm 或 DevTools 连接之间共享对象。
+- Host 与 Client Console event 使用相同 projector;Client argument 按 DevTools 连接隔离,Cordis argument 可以解析到 Elements node。
+- Sources 接收 Host script 与构建后的 Client bundle;Client source 读取采用分块传输,active debugging 明确失败,而 Host 仍可被断点暂停、求值 call frame 并 resume。
+- Host paused scope 与 call-frame result 使用和 Runtime 求值相同的 connection-local RemoteObject table。
+- Network 回放 `Network.enable` 前的请求,并无遗漏、无重复地推送后续请求。
+- 成功、失败、取消、重定向、文本、二进制、流式与截断 fetch 都保持调用方行为,并暴露配置允许的完整采集数据。
+- dispose 停止采集、关闭入口、断开 V8 session、关闭 socket,并等待 Worker exit 后完成。
+
+## Consequences
+
+Worker 所有的 endpoint 在 Host JavaScript 暂停时仍保持 DevTools 控制可响应,并让 Host 与 Client 观测共享唯一 CDP 状态 owner。这项所有权带来以下安全、资源与兼容性成本。
+
+完整 fetch 采集会有意把 credential 和 payload 暴露给任何能连接 CDP endpoint 的本机进程。loopback 监听是强制要求,但不是鉴权。
+
+clone request/response stream 会增加 CPU、内存与 I/O 压力。有限预算能约束保留字节,不能让完整采集没有成本。
+
+page 类型 synthetic target 依赖 Node 原生 inspector domain 之外的一组 Chrome DevTools 兼容响应。每个 no-op 都必须明确命名并有测试;统一吞掉未知方法会掩盖协议漂移。
+
+Client Runtime 执行使用页面 JavaScript 求值,因此页面 Content Security Policy 可能拒绝它,也不承诺原生 DevTools command-line 或 REPL 语义。只读 Client Sources 不代表 active Client debugging;增加该能力需要一个在被检查页面 realm 暂停时仍能响应的执行 agent。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.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-24-cordis-runtime-tree-inspection.md
+2026-08-24-cordis-runtime-tree-inspection.md: 9784018a050bdb27e791a44e1cd342a31cec42c0
+2026-08-24-cordis-runtime-tree-inspection.zh.md: 05b9b0d4387447b7c8df05b7c7e771e96c767bd9

+ 113 - 0
.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md

@@ -0,0 +1,113 @@
+# Agent Note: Cordis runtime tree inspection
+
+Status: implemented
+
+English | [中文](2026-08-24-cordis-runtime-tree-inspection.zh.md)
+
+## Problem
+
+The Inspector needs to present each Host and Client Cordis runtime as a tree in Chrome DevTools Elements. A Cordis Context or Fiber selected in Elements must also behave as a live Runtime object, while a Cordis object printed in Console must be revealable as the same semantic node. CDP identifiers cannot be the source model: `NodeId`, `BackendNodeId`, and `RemoteObjectId` have different owners and lifetimes, and a future model-facing runtime query must consume the same Cordis data without translating CDP.
+
+Host and Client run the same Cordis abstractions in different JavaScript realms. Tree discovery and classification must therefore be one browser-safe implementation, while object resolution remains realm-local and only opaque references cross MessagePort or WebSocket boundaries.
+
+## Decision
+
+The Inspector uses one serialized Cordis tree model and keeps CDP as one adapter over it. The package separates live-object discovery, immutable snapshots, Worker-owned storage, and consumers:
+
+The existing [cross-realm Inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) owns the Worker, source carriers, Runtime routing, and security model. This note owns only the Cordis semantic data and its consumers.
+
+```text
+Host Context/Fiber ─┐
+                    ├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter
+Client Context/Fiber┘                                                                                 └─ future model adapter
+```
+
+`CordisTreeCollector` and its identity registry are browser-safe modules compiled into both package faces. Host and Client instantiate that same code against their own `ctx.root`; neither side carries a second classification implementation.
+
+## Cordis tree model
+
+`CordisTreeSnapshot` is a CDP-independent, lossless-JSON value with a schema version, monotonically increasing revision, object-registry id, truncation flag, and one nested root Context. Context nodes contain an opaque object handle and ordered Context/Fiber children. Fiber nodes contain their Cordis `uid`, an opaque object handle, and exactly one Context child representing `fiber.ctx`. Host and Client publish this same realm-tree type. No generated Context id, plugin metadata, service data, arbitrary property value, or object preview enters the tree.
+
+The inspection tree starts at the root Context and omits the Cordis root Fiber. For every other plugin, its parent Context contains the Fiber and that Fiber contains its owned Context. A Context created by `extend()`, `isolate()`, or `intercept()` without a new Fiber remains a direct Context child. Nesting expresses parentage without generated node ids and preserves both object identities without introducing the `Fiber.ctx` / `Context.fiber` cycle.
+
+The collector starts from the root, every live registry Fiber, and every event hook's owning Context. It follows Context prototype links back to the inspected root, unwraps Cordis shadow contexts, deduplicates by object identity, and excludes disposed fibers. `internal/plugin` and `internal/status` events schedule one microtask-coalesced replacement snapshot. Node-count and encoded-byte limits remove complete trailing branches, so every retained node still has its parent and every retained Fiber still has its owned Context.
+
+## Identity and lifetime
+
+The identities are intentionally distinct:
+
+- Fiber `uid` comes from Cordis. Context currently has no Cordis-owned id and the Inspector does not expose a generated substitute.
+- `InspectorObjectReference` is an opaque realm-local handle resolving a tree node to its live Context or Fiber. Snapshots carry the handle for routing, never as a semantic id or DOM attribute.
+- `BackendNodeId` is assigned by the Worker to one retained `(source id, source generation, object reference)` and is shared by DevTools connections while that generation's snapshot is retained.
+- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes.
+- `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group.
+
+`sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated.
+
+Standard CDP does not place a `RemoteObjectId` field on `DOM.Node`. `DOM.Node` carries `nodeId` and `backendNodeId`; `DOM.resolveNode` returns the corresponding `Runtime.RemoteObject`, and `DOM.requestNode` performs the reverse mapping. The implementation keeps these three CDP identities correlated without adding non-standard DOM fields.
+
+## Realm object bridge
+
+Each collector registers a realm-local object table under a private global symbol. The table maps opaque handles to live objects and can identify a currently retained object by identity. Replacing a snapshot removes handles absent from the new tree; disposing the observer unregisters the table.
+
+For Host nodes, the Worker uses that DevTools connection's private `node:inspector.Session` to evaluate a lookup in the Host table, producing a native V8 `RemoteObjectId`. For Client nodes, the Worker routes the same lookup through the existing typed Client Runtime channel and maps the returned Client handle to a connection-local CDP object id. No live object or engine object id crosses a source transport.
+
+Client Runtime values carry an optional validated `InspectorObjectReference`, while Host Runtime values are probed through their native V8 object id. The common CDP adapter changes recognized evaluation results, properties, exceptions, Console arguments, and paused-frame objects to `subtype: "node"`, records the object-id-to-backend-node relation, and supplies the Cordis element description. This gives both directions: Elements can expose a live object, and a Context or Fiber returned or printed in Console can be revealed in Elements.
+
+## Worker repository and updates
+
+Sources publish the Cordis tree as retained state rather than an event history. Host MessagePort and Client WebSocket publishers keep the latest state record and include it in `source/replace` after admission, reconnection, or a resnapshot request. Live replacements still use the ordinary sequenced append path. The Worker validates every snapshot for exact fields, bounded node count and depth, unique object handles and Fiber uids, a Context root, and exactly one Context child per Fiber before atomically replacing the prior tree.
+
+`CordisTreeStore` owns validated realm snapshots and source lifecycle only. Its internal reader retains live object routes for Runtime and DOM, while its public reader projects a detached `{ host, clients }` tree without transport or CDP ids. Host and Client `ctx.inspector.cordis.getTree()` calls use the same correlated query protocol and Worker reader without creating a CDP session. `CordisDomBackend` adds Worker-global backend ids, while each `CordisDomSession` owns frontend node ids, searches, enabled state, and RemoteObject correlations. A model adapter can consume the public reader without depending on DOM serialization or debugger activation.
+
+Closing a source changes its stored tree from connected to disconnected instead of deleting the last snapshot. Object lookup excludes disconnected trees, so the snapshot remains inspectable as data without retaining or reviving a live Context, Fiber, or Runtime object. A replacement from the same source id and a new transport generation atomically restores the connected state. The configurable disconnected-tree limit evicts the oldest retained snapshots.
+
+Accepted source snapshots rebuild the connection-neutral document and are diffed by stable backend node identity. A revision-only replacement emits no DOM event. Child insertion and removal use `DOM.childNodeInserted` and `DOM.childNodeRemoved`; attribute changes use their corresponding DOM events; sibling reorder falls back to `DOM.setChildNodes` for that parent only. Reusing one backend identity for a different node kind is the sole `DOM.documentUpdated` fallback. A disconnect invalidates object routes without changing the retained DOM tree, preserving expansion and selection; retention eviction removes only the evicted `<client>` node.
+
+## CDP projection
+
+The synthetic document has a `<host>` container and a `<clients>` container. `<host>` contains the Host root Context. `<clients>` contains one `<client>` per Client source, and each `<client>` contains that realm's root Context. These structural elements have no Runtime object or attributes. Context elements have no attributes. Fiber elements expose only `uid`, copied without reinterpretation from Cordis. Connected Context and Fiber elements resolve to live RemoteObjects; disconnected snapshots retain their DOM nodes but object resolution fails.
+
+Standard CDP has no backend-controlled frozen, locked, or dimmed state for a node in the ordinary Elements tree. Chromium's detached-node presentation is frontend-local to the Memory panel's `DOM.getDetachedDomNodes` flow. No connection-state attribute or non-standard `DOM.Node` field is added until its presentation is decided.
+
+The read-only adapter implements document retrieval, child requests, node description, attributes, outer HTML, search, backend-id pushes, node resolution, and reverse object lookup. Mutating DOM methods fail explicitly. Layout, CSS, accessibility, and browser DOM geometry are outside this semantic tree and return empty or unsupported responses only where Chrome DevTools requires a compatibility response.
+
+## Alternatives considered
+
+**Build CDP DOM nodes directly in each realm.** Rejected because Host and Client would duplicate classification, frontend ids would leak into source protocols, and a model consumer would need to reverse a presentation protocol back into Cordis concepts.
+
+**Send live objects or V8 object ids to the Worker.** Rejected because structured clone and JSON do not preserve identity or behavior, and engine object ids belong to one inspector session.
+
+**Generate an Inspector Context id.** Rejected because Cordis Context has no intrinsic id and a presentation adapter must not make an implementation key look like framework identity. Nested children express parentage; opaque object handles remain routing data.
+
+**Use one id for Fiber uid, backend nodes, and frontend nodes.** Rejected because source reconnection, multiple DevTools connections, document refresh, and Runtime object release have independent lifetimes.
+
+**Expose only Contexts and treat each Fiber-owned Context as the Fiber.** Rejected because it loses one of the two live objects, makes Console identity ambiguous, and prevents later Fiber-specific properties from having a stable owner.
+
+**Put the model-facing API on the CDP adapter.** Rejected because model access would inherit Chrome-specific node serialization, per-connection ids, and enable state. The Worker repository is the shared source; CDP and model access are sibling adapters.
+
+**Remove a realm tree when its source disconnects.** Rejected because transport loss would discard the last useful topology and collapse the user's Elements inspection state. Keeping old object handles usable was also rejected: a new connection generation cannot prove that any prior live object still exists.
+
+## Verification
+
+- The same collector implementation produces Host and Client snapshots from equivalent Cordis runtimes.
+- Elements shows `<host>` and `<clients>/<client>` containers with each realm's root Context directly beneath its container.
+- Context elements have no attributes; Fiber elements expose only their Cordis `uid`; the root Fiber is absent.
+- Every connected Context and Fiber has a connection-local frontend node id, a Worker backend node id, and a resolvable connection-local Runtime object id without exposing them as attributes.
+- `DOM.resolveNode` and `DOM.requestNode` round-trip Context and Fiber identities without sharing object ids across DevTools connections or source generations.
+- A Context or Fiber returned by Runtime evaluation is node-branded and can be revealed in Elements.
+- Disconnect destroys the Client execution context and its RemoteObjects while retaining the last Elements tree unchanged; a new transport generation replaces it after a complete snapshot arrives.
+- Reconnect and resnapshot replay the latest tree state; unchanged snapshots emit no DOM mutation, while structural changes update only their affected parent or node. Malformed or oversized replacements do not replace the last valid snapshot.
+- The stored snapshot and query API contain no CDP types and can support a future model-facing adapter unchanged.
+
+## Consequences
+
+Cordis exposes no complete global Context registry. The collector can recover contexts reachable from live fibers and event hooks; a context that is created, never used, and retained only by application code is intentionally absent.
+
+Object recognition adds a Runtime round trip for each Host object that requires semantic identification. An annotation failure leaves an ordinary RemoteObject rather than breaking Runtime or Debugger delivery. Client Console observation preserves the original method result and schedules serialization afterward; each enabled DevTools session receives independently retained handles, so recognition never blocks the page call or shares objects between connections.
+
+Sources continue to publish complete snapshots, keeping one shared Host/Client collector and allowing recovery after dropped observations. The Worker pays the snapshot comparison cost, then emits incremental CDP DOM mutations so unchanged revisions do not reset the Elements document. Node and byte limits preserve a valid prefix and report truncation; a later source delta protocol can replace the transport without changing the snapshot model or CDP projection.
+
+The object table intentionally keeps every object in the current visible tree strongly reachable until the next replacement or observer disposal. This is bounded by the retained snapshot and must not become a general-purpose object registry.
+
+The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory, and eviction removes the corresponding retained Client subtree.

+ 113 - 0
.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md

@@ -0,0 +1,113 @@
+# Agent Note: Cordis 运行时树检查
+
+Status: implemented
+
+[English](2026-08-24-cordis-runtime-tree-inspection.md) | 中文
+
+## Problem
+
+Inspector 需要在 Chrome DevTools Elements 中把每个 Host 和 Client Cordis 运行时呈现为一棵树。在 Elements 中选中的 Cordis Context 或 Fiber 也必须表现为实时 Runtime 对象,而 Console 中打印出的 Cordis 对象必须能定位回同一个语义节点。CDP id 不能成为源模型:`NodeId`、`BackendNodeId` 与 `RemoteObjectId` 的 owner 和生命周期不同,并且未来面向模型的运行时查询必须使用同一份 Cordis 数据,而不是再反向解析 CDP。
+
+Host 与 Client 在不同 JavaScript realm 中运行相同的 Cordis 抽象。因此,树发现与分类必须只有一份浏览器安全实现;对象解析仍留在各自 realm 内,跨 MessagePort 或 WebSocket 只传递不透明引用。
+
+## Decision
+
+Inspector 使用一套序列化 Cordis 树模型,并把 CDP 作为它的一个适配器。包内分离实时对象发现、不可变快照、Worker 存储与消费方:
+
+现有的[跨 realm Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、source carrier、Runtime 路由与安全模型;本 Note 只负责 Cordis 语义数据及其消费方。
+
+```text
+Host Context/Fiber ─┐
+                    ├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter
+Client Context/Fiber┘                                                                                 └─ future model adapter
+```
+
+`CordisTreeCollector` 及其身份注册表是浏览器安全模块,同时编入包的两个运行面。Host 与 Client 针对各自的 `ctx.root` 实例化同一份代码;任何一侧都不维护第二套分类实现。
+
+## Cordis tree model
+
+`CordisTreeSnapshot` 是与 CDP 无关的无损 JSON 值,包含 schema 版本、单调递增 revision、对象注册表 id、截断标志和一棵以 Context 为根的嵌套树。Context 节点包含不透明 object handle 与有序的 Context/Fiber children。Fiber 节点包含 Cordis `uid`、不透明 object handle,以及唯一一个表示 `fiber.ctx` 的 Context child。Host 与 Client 发布同一种 realm-tree 类型。生成的 Context id、插件 metadata、服务数据、任意属性值与对象 preview 都不进入树。
+
+inspection tree 从 root Context 开始,不包含 Cordis root Fiber。对其他每个插件,其 parent Context 包含 Fiber,该 Fiber 再包含它拥有的 Context。通过 `extend()`、`isolate()` 或 `intercept()` 创建且未创建新 Fiber 的 Context 仍是直接 Context 子节点。嵌套结构无需生成 node id 即可表达 parent,并保留两类对象身份,同时避免把 `Fiber.ctx` / `Context.fiber` 环写入序列化树。
+
+collector 从 root、注册表中的每个 live Fiber,以及每个 event hook 的 owner Context 开始。它沿 Context prototype 链回溯到被检查的 root,解开 Cordis shadow Context,按对象身份去重,并排除已 dispose 的 Fiber。`internal/plugin` 与 `internal/status` 事件调度一次 microtask 合并后的 replacement snapshot。节点数和编码字节数限制会移除完整的尾部 branch,因此每个保留节点仍有 parent,每个保留 Fiber 仍有其 owned Context。
+
+## Identity and lifetime
+
+各类身份刻意保持独立:
+
+- Fiber `uid` 来自 Cordis。Context 当前没有 Cordis 自有 id,Inspector 不会暴露一个生成值来替代。
+- `InspectorObjectReference` 是 realm 本地的不透明 handle,用于把树节点解析成实时 Context 或 Fiber。snapshot 携带该 handle 只为完成路由,不把它当成语义 id 或 DOM attribute。
+- `BackendNodeId` 由 Worker 为一条保留的 `(source id, source generation, object reference)` 分配,并在该 generation 的 snapshot 被保留期间由所有 DevTools 连接共享。
+- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。
+- `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。
+
+`sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。
+
+标准 CDP 不会在 `DOM.Node` 上放置 `RemoteObjectId` 字段。`DOM.Node` 携带 `nodeId` 与 `backendNodeId`;`DOM.resolveNode` 返回对应的 `Runtime.RemoteObject`,`DOM.requestNode` 执行反向映射。实现会关联这三类 CDP 身份,而不添加非标准 DOM 字段。
+
+## Realm object bridge
+
+每个 collector 都在私有 global symbol 下注册一个 realm 本地对象表。该表把不透明 handle 映射到实时对象,并能按身份识别当前保留的对象。替换快照时会移除新树中不存在的 handle;dispose observer 时注销该表。
+
+对 Host 节点,Worker 使用该 DevTools 连接私有的 `node:inspector.Session` 在 Host 对象表中执行查询,从而生成原生 V8 `RemoteObjectId`。对 Client 节点,Worker 通过已有的类型化 Client Runtime channel 路由同一查询,再把返回的 Client handle 映射为连接本地 CDP object id。实时对象和引擎 object id 都不会穿过 source transport。
+
+Client Runtime value 携带一个可选、已验证的 `InspectorObjectReference`,Host Runtime value 则通过原生 V8 object id 探测。公共 CDP adapter 把已识别的 evaluation result、property、exception、Console argument 和 paused-frame object 改成 `subtype: "node"`,记录 object-id 到 backend-node 的关系,并提供 Cordis element description。这样两个方向都成立:Elements 可以暴露实时对象,Console 中返回或打印的 Context 与 Fiber 也能定位到 Elements。
+
+## Worker repository and updates
+
+source 把 Cordis 树作为保留状态发布,而不是事件历史。Host MessagePort 与 Client WebSocket publisher 保留最新状态记录,并在接纳、重连或收到 resnapshot 请求后把它放入 `source/replace`。实时 replacement 仍走普通的有序 append 路径。Worker 在原子替换旧树前验证 snapshot 的精确字段、节点数与深度限制、object handle 与 Fiber uid 唯一性、Context root,以及每个 Fiber 恰好拥有一个 Context child。
+
+`CordisTreeStore` 只拥有已验证 realm snapshot 和 source 生命周期。内部 reader 为 Runtime 与 DOM 保留 live object route;公共 reader 则投影一棵不含 transport 或 CDP id 的 detached `{ host, clients }` tree。Host 与 Client 的 `ctx.inspector.cordis.getTree()` 通过同一套关联查询协议读取同一个 Worker reader,不创建 CDP session。`CordisDomBackend` 增加 Worker 全局 backend id,每个 `CordisDomSession` 则拥有 frontend node id、搜索、enable 状态和 RemoteObject 关联。模型 adapter 可以消费公共 reader,而不依赖 DOM 序列化或 debugger activation。
+
+source 关闭时,存储的树从 connected 变为 disconnected,而不是删除最后一份 snapshot。对象查询会排除 disconnected 树,因此 snapshot 仍可作为数据检查,但不会保留或复活实时 Context、Fiber 或 Runtime object。同一 source id 的新 transport generation 提交 replacement 后,会原子恢复 connected 状态。可配置的 disconnected tree 数量上限会淘汰最早保留的 snapshot。
+
+每个被接受的 source snapshot 都会重建 connection-neutral document,并按稳定的 backend node identity 比较差异。只改变 revision 的 replacement 不发送 DOM event;子节点增删使用 `DOM.childNodeInserted` 与 `DOM.childNodeRemoved`,attribute 变化使用对应 DOM event,兄弟节点重排只对该 parent 使用 `DOM.setChildNodes`。只有同一 backend identity 被复用为不同 node kind 时才回退到 `DOM.documentUpdated`。断联只会使 object route 失效,不改变保留的 DOM tree,因此保留展开与选择;达到保留上限时只移除被淘汰的 `<client>` 节点。
+
+## CDP projection
+
+synthetic document 包含一个 `<host>` container 和一个 `<clients>` container。`<host>` 包含 Host root Context;`<clients>` 为每个 Client source 包含一个 `<client>`,每个 `<client>` 再包含该 realm 的 root Context。这些结构 element 没有 Runtime object 或 attribute。Context element 没有 attribute。Fiber element 只暴露从 Cordis 原样复制的 `uid`。connected Context 与 Fiber 可以解析为 live RemoteObject;disconnected snapshot 保留 DOM node,但对象解析失败。
+
+标准 CDP 没有可由 backend 控制、用于普通 Elements 树节点的 frozen、locked 或 dimmed 状态。Chromium 的 detached-node 展示只存在于 Memory 面板的 `DOM.getDetachedDomNodes` 流程,并由 frontend 本地设置。在展示方式明确前,不增加 connection-state attribute 或非标准 `DOM.Node` 字段。
+
+只读适配器实现 document 获取、子节点请求、节点描述、属性、outer HTML、搜索、backend-id push、节点解析和对象反向查询。修改型 DOM 方法明确失败。layout、CSS、accessibility 与浏览器 DOM geometry 不属于这棵语义树;仅在 Chrome DevTools 需要兼容响应时返回空结果或 unsupported。
+
+## Alternatives considered
+
+**在每个 realm 直接构建 CDP DOM node。** 拒绝,因为 Host 与 Client 会重复分类,frontend id 会泄漏进 source 协议,模型消费方还必须把展示协议反向解析成 Cordis 概念。
+
+**把实时对象或 V8 object id 发送给 Worker。** 拒绝,因为 structured clone 与 JSON 无法保留身份或行为,而且引擎 object id 只属于一个 inspector session。
+
+**由 Inspector 生成 Context id。** 拒绝,因为 Cordis Context 没有自身 id,展示适配器不能把实现 key 伪装成框架身份。嵌套 children 表达 parent,不透明 object handle 只作为路由数据。
+
+**Fiber uid、backend node 与 frontend node 共用一个 id。** 拒绝,因为 source 重连、多条 DevTools 连接、document refresh 与 Runtime object release 的生命周期彼此独立。
+
+**只暴露 Context,并把每个 Fiber 拥有的 Context 当成 Fiber。** 拒绝,因为这会丢失两类实时对象中的一类,使 Console 身份产生歧义,并让后续 Fiber 专属属性失去稳定 owner。
+
+**把模型访问 API 放在 CDP 适配器上。** 拒绝,因为模型访问会继承 Chrome 专用 node 序列化、逐连接 id 和 enable 状态。Worker repository 是共享数据源,CDP 与模型访问是并列适配器。
+
+**source 断联时移除 realm 树。** 拒绝,因为传输中断会丢失最后一份有用拓扑,并折叠用户在 Elements 中的检查状态。继续使用旧 object handle 同样不可接受:新的连接 generation 无法证明任何先前实时对象仍然存在。
+
+## Verification
+
+- 同一个 collector 实现能从等价 Cordis 运行时生成 Host 和 Client 快照。
+- Elements 显示 `<host>` 与 `<clients>/<client>` container,每个 realm 的 root Context 直接位于其 container 下。
+- Context element 不含 attribute;Fiber element 只暴露 Cordis `uid`;root Fiber 不出现。
+- 每个 connected Context 与 Fiber 都有一个连接本地 frontend node id、一个 Worker backend node id 和一个可解析的连接本地 Runtime object id,且它们都不作为 attribute 暴露。
+- `DOM.resolveNode` 与 `DOM.requestNode` 能往返映射 Context/Fiber 身份,且不会跨 DevTools 连接或 source generation 共享 object id。
+- Runtime evaluation 返回的 Context 或 Fiber 会被标记为 node,并能在 Elements 中定位。
+- 断联会销毁 Client execution context 与 RemoteObject,同时原样保留最后一棵 Elements 树;新的 transport generation 在完整 snapshot 到达后替换它。
+- 重连和 resnapshot 会重放最新树状态;无变化的 snapshot 不发送 DOM mutation,结构变化只更新受影响的 parent 或 node。畸形或超限 replacement 不会替换最后一个有效快照。
+- 存储的 snapshot 与查询 API 不包含 CDP 类型,可以不加修改地支持未来的模型适配器。
+
+## Consequences
+
+Cordis 不提供完整的全局 Context registry。collector 能恢复从 live fiber 与 event hook 可达的 Context;一个已创建、从未使用且只由应用代码保留的 Context 会有意缺席。
+
+需要语义识别的每个 Host object 都会增加一次 Runtime round trip。annotation 失败时保留普通 RemoteObject,不破坏 Runtime 或 Debugger 投递。Client Console observation 保留原始 method result,并在之后调度序列化;每个已启用 DevTools session 独立保留 handle,因此识别既不阻塞页面调用,也不在连接间共享对象。
+
+source 仍发布完整 snapshot,从而复用同一套 Host/Client collector,并能在 observation 丢失后恢复。Worker 承担 snapshot 比较成本,再发送增量 CDP DOM mutation,使无变化的 revision 不会重置 Elements document。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换 source delta 协议,而不修改 snapshot model 或 CDP projection。
+
+对象表会有意强引用当前可见树中的每个对象,直到下一次 replacement 或 observer dispose。该集合受保留快照限制,不能扩展成通用对象注册表。
+
+Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰时会移除对应的已保留 Client subtree。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.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-26-inspector-execution-realms-and-protocol-planes.md
+2026-08-26-inspector-execution-realms-and-protocol-planes.md: e8bff0661d2d0c86c216b0a18e2feb7a2c786709
+2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 2db6307d1bfc536ac5e8b0f5d6f03e4cfe334989

+ 96 - 0
.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md

@@ -0,0 +1,96 @@
+# Agent Note: Inspector execution realms and protocol planes
+
+Status: implemented
+
+English | [中文](2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md)
+
+## Problem
+
+The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Without execution-oriented directories, feature names alone do not establish where code runs or which identifiers it may own.
+
+This ambiguity is risky because Host and Client support intentionally differs while their architecture must remain comparable. Host Runtime and Debugger delegate to Node's inspector protocol; Client Runtime and Console simulate the same backend semantics over an internal bridge. If their files, interfaces, and unsupported operations diverge structurally, each new protocol method encourages a second routing model. Likewise, consumers that only need the Cordis runtime tree must not inherit debugger activation, Chrome connection state, or CDP identifiers.
+
+The [cross-realm CDP inspector decision](2026-08-23-cross-realm-cdp-inspector.md) owns Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](2026-08-24-cordis-runtime-tree-inspection.md) owns Cordis tree semantics, object routing, and DOM projection. This decision owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state.
+
+## Decision
+
+Top-level source directories identify execution ownership. `client/` contains only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client belongs under `worker/`, not `client/`.
+
+The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling.
+
+```text
+src/
+  shared/   environment-independent data and interfaces
+  client/   browser Client producer and adapters
+  host/     Host Node-main-thread producer and adapters
+  worker/   Worker transport, repositories, realm backends, and CDP endpoint
+```
+
+`client/` and `host/` have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support.
+
+Worker-side realm adapters use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers.
+
+## Execution ownership
+
+`client/` owns page-realm observation, Client object handles, browser evaluation, browser Console interception, Client source publication, and its direct authenticated bridge to the Worker. It may use browser APIs but not Node or Worker implementation modules.
+
+`host/` owns Cordis plugin composition on the Node main thread, Worker startup and disposal, Host object observation, fetch capture, Node inspector notification forwarding, and the Host side of the Worker bridge. It may use Node APIs but does not construct Chrome CDP responses.
+
+`worker/bridge/` owns source admission, transport endpoints, connection generations, frame dispatch, correlation, and routing between source producers and Worker consumers. `worker/inspection/` owns retained Cordis and network observations plus transport-independent queries. `worker/realms/` owns the normalized Host and Client runtime backends. `worker/cdp/` owns HTTP discovery, DevTools sessions, Chrome method dispatch, domain enable state, and every connection-local Chrome identifier.
+
+The Worker remains the sole Chrome CDP wire and state owner. Client code simulates shared backend operations, not the CDP wire. Host code delegates supported backend operations to Node inspector, but Node protocol identifiers are translated inside the Worker Host realm before common domain projection.
+
+## Data and identifier ownership
+
+`shared/cordis/` contains the CDP-independent semantic model, immutable snapshots, collection and observation, realm-local object registration, projections, and reader interfaces. `model.ts` contains no transport handles or CDP identifiers. `snapshot.ts` may carry a realm-local opaque object reference because a live object query needs that route, but consumers can project it away.
+
+`shared/network/` contains fetch and network observations, captured body representation, and header normalization. These records describe observed activity and do not contain CDP request ids or domain enable state.
+
+`shared/cdp/` contains normalized backend interfaces and values for realm capabilities, Runtime, Console, Debugger, Sources, Profiler, HeapProfiler, and typed unsupported results. Backend handles in these interfaces are opaque and realm-owned. They are not Chrome `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, or `CallFrameId` values.
+
+`shared/bridge/` contains the versioned internal carrier: source and generation identifiers, envelopes, codecs, validation, bounded publication, RPC correlation, dispatch interfaces, and domain-specific message unions. Its message modules may transport Cordis snapshots, network observations, Console events, Runtime operations, source reads, debugger operations, and semantic queries without turning those values into CDP messages.
+
+`worker/cdp/ids.ts` is the only owner of Chrome connection-local identifiers such as `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, `NodeId`, and `CallFrameId`. Worker domain sessions allocate and release them and map them to realm backend handles or inspection records. Source, generation, sequence, request, Cordis Fiber uid, realm object reference, backend handle, and Chrome id remain distinct types because their owners and lifetimes differ.
+
+## Dependency rules
+
+The domain modules `shared/cordis/`, `shared/network/`, and `shared/cdp/` do not import `shared/bridge/` or any execution-specific directory. `shared/bridge/` may import those domain types when defining internal messages. No module under `shared/` imports Node-only or browser-only APIs.
+
+Top-level `client/` and `host/` import `shared/` but never each other or `worker/`. Equivalent roles use equivalent shared interfaces. Environment-specific transport and engine behavior stays in the mirrored implementation file rather than entering a shared conditional implementation.
+
+`worker/realms/` and `worker/inspection/` import shared interfaces but do not import `worker/cdp/`; normalized backend results and stored observations cannot contain Chrome connection state. `worker/cdp/` may consume realm and inspection interfaces to project CDP. `worker/bridge/` routes shared messages and invokes Worker services without becoming an owner of Cordis, network, Runtime, or Chrome state.
+
+The package remains one `@deepseek-ai/dsh-experimental-inspector` package with explicit Client and Host compiler faces. Directory separation is an execution and dependency rule, not a package split.
+
+## Verification
+
+- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root.
+- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed.
+- Cordis and network readers are usable without importing debugger, source, transport, or CDP session modules.
+- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids.
+- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages.
+- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state.
+- Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior have focused coverage.
+- Compiler faces, import checks, and the structural layout test reject environment leaks and Client/Host mirror drift.
+
+## Alternatives considered
+
+**Organize every file by feature domain.** Rejected because a Runtime or Cordis feature spans three environments with different available APIs. Feature-only paths conceal execution constraints and make accidental browser-to-Node imports difficult to review.
+
+**Put Worker Client and Host adapters in top-level `client/` and `host/`.** Rejected because those adapters execute in the Worker and own different resources from page and Node-main-thread producers. A directory name must answer where code runs before it answers which remote realm it represents.
+
+**Allow Client and Host trees to contain only currently supported files.** Rejected because asymmetric layout obscures missing capability decisions and lets equivalent routing roles acquire unrelated interfaces. Explicit unsupported implementations keep evolution exhaustive without pretending support exists.
+
+**Keep one shared protocol directory.** Rejected because internal carrier identities, Cordis semantic data, normalized Runtime values, and Chrome wire identifiers have different consumers and lifetimes. A single directory encourages domain models to depend on transport and CDP presentation.
+
+**Split Client, Host, protocol, and Worker into separate packages.** Rejected for the experimental phase. The deployment unit remains one Client/Host Cordis plugin, and package boundaries would add build and release coordination without improving the required execution separation.
+
+## Consequences
+
+Exact mirroring adds small adapter files for unsupported capabilities. Those files are intentional compatibility points between implementations, but they must stay thin and must not manufacture fake behavior.
+
+Moving types without changing behavior can still expose hidden dependency cycles, especially where Runtime object annotation reaches Cordis repositories. The dependency rules require inversion through shared interfaces rather than a temporary import from a lower-level module.
+
+`shared/cdp/` can become a second copy of the Chrome protocol if normalized types are added indiscriminately. A shared type belongs there only when both realm implementations or a common Worker projector consume it; Chrome session bookkeeping and wire-only fields remain under `worker/cdp/`.
+
+Explicit Client and Host compiler faces and focused behavior tests add maintenance work, but they keep environment leaks and mirror drift visible.

+ 96 - 0
.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md

@@ -0,0 +1,96 @@
+# Agent Note: Inspector 执行环境与协议平面
+
+Status: implemented
+
+[English](2026-08-26-inspector-execution-realms-and-protocol-planes.md) | 中文
+
+## Problem
+
+Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。只按功能命名目录时,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。
+
+这种含糊会带来风险,因为 Host 与 Client 的支持能力有意不同,但架构必须保持可比较。Host Runtime 与 Debugger 委托 Node inspector protocol;Client Runtime 与 Console 通过内部 bridge 模拟同一套 backend 语义。如果两边的文件、接口与 unsupported operation 在结构上分叉,每增加一种协议方法都容易产生第二套路由模型。同样,只需要 Cordis 运行时树的消费方不应继承 debugger activation、Chrome 连接状态或 CDP 标识符。
+
+现有的[跨 realm CDP Inspector 决策](2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](2026-08-24-cordis-runtime-tree-inspection.zh.md)负责 Cordis 树语义、对象路由与 DOM projection。本决策负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。
+
+## Decision
+
+顶层源码目录标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。
+
+仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。
+
+```text
+src/
+  shared/   environment-independent data and interfaces
+  client/   browser Client producer and adapters
+  host/     Host Node-main-thread producer and adapters
+  worker/   Worker transport, repositories, realm backends, and CDP endpoint
+```
+
+`client/` 与 `host/` 拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。
+
+Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。
+
+## Execution ownership
+
+`client/` 负责 page realm observation、Client object handle、浏览器求值、浏览器 Console interception、Client source publication 以及到 Worker 的直接鉴权 bridge。它可以使用浏览器 API,但不能导入 Node 或 Worker 实现模块。
+
+`host/` 负责 Node 主线程上的 Cordis plugin composition、Worker 启动与 dispose、Host object observation、fetch capture、Node inspector notification forwarding,以及 Worker bridge 的 Host 一侧。它可以使用 Node API,但不构造 Chrome CDP response。
+
+`worker/bridge/` 负责 source admission、transport endpoint、connection generation、frame dispatch、correlation,以及 source producer 与 Worker consumer 之间的路由。`worker/inspection/` 负责保留的 Cordis 与 network observation,以及不依赖 transport 的 query。`worker/realms/` 负责规范化的 Host 与 Client runtime backend。`worker/cdp/` 负责 HTTP discovery、DevTools session、Chrome method dispatch、domain enable 状态与所有连接局部的 Chrome 标识符。
+
+Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模拟共享 backend operation,而不是模拟 CDP wire。Host 代码把支持的 backend operation 委托给 Node inspector,但 Node protocol 标识符在 Worker Host realm 内转换后才进入公共 domain projection。
+
+## Data and identifier ownership
+
+`shared/cordis/` 包含与 CDP 无关的语义模型、不可变 snapshot、collection 与 observation、realm-local object registration、projection 与 reader interface。`model.ts` 不包含 transport handle 或 CDP 标识符。`snapshot.ts` 可以携带 realm-local opaque object reference,因为实时对象查询需要该路由信息,但消费方可以在 projection 中移除它。
+
+`shared/network/` 包含 fetch 与 network observation、采集 body 表示及 header normalization。这些记录描述已观测活动,不包含 CDP request id 或 domain enable 状态。
+
+`shared/cdp/` 包含 realm capability、Runtime、Console、Debugger、Sources、Profiler、HeapProfiler 的规范化 backend 接口和值,以及类型化 unsupported 结果。这些接口中的 backend handle 是不透明且由 realm 持有的。它们不是 Chrome `RemoteObjectId`、`ExecutionContextId`、`ScriptId` 或 `CallFrameId`。
+
+`shared/bridge/` 包含带版本的内部 carrier:source 与 generation 标识符、envelope、codec、validation、有限 publication、RPC correlation、dispatch interface 及分领域的 message union。其 message 模块可以传输 Cordis snapshot、network observation、Console event、Runtime operation、source read、debugger operation 与语义 query,但不会把这些值转换成 CDP message。
+
+`worker/cdp/ids.ts` 是 Chrome 连接局部标识符的唯一 owner,包括 `RemoteObjectId`、`ExecutionContextId`、`ScriptId`、`NodeId` 与 `CallFrameId`。Worker domain session 分配并释放这些 id,把它们映射到 realm backend handle 或 inspection record。source、generation、sequence、request、Cordis Fiber uid、realm object reference、backend handle 与 Chrome id 必须保持为不同类型,因为它们的 owner 和生命周期不同。
+
+## Dependency rules
+
+领域模块 `shared/cordis/`、`shared/network/` 与 `shared/cdp/` 不导入 `shared/bridge/` 或任何执行环境专属目录。`shared/bridge/` 在定义内部 message 时可以导入这些领域类型。`shared/` 下的任何模块都不导入 Node-only 或 browser-only API。
+
+顶层 `client/` 与 `host/` 可以导入 `shared/`,但不能互相导入,也不能导入 `worker/`。等价角色使用等价的共享接口。环境专属 transport 与 engine 行为保留在对应镜像实现文件中,不进入带条件分支的共享实现。
+
+`worker/realms/` 与 `worker/inspection/` 可以导入共享接口,但不导入 `worker/cdp/`;规范化 backend result 和已存 observation 不能包含 Chrome connection state。`worker/cdp/` 可以消费 realm 与 inspection interface 来生成 CDP projection。`worker/bridge/` 路由共享 message 并调用 Worker service,但不成为 Cordis、network、Runtime 或 Chrome 状态的 owner。
+
+本能力继续保留在同一个 `@deepseek-ai/dsh-experimental-inspector` 包中,并使用显式 Client 与 Host compiler face。目录分隔是执行与依赖规则,不是拆包方案。
+
+## Verification
+
+- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。
+- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。
+- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。
+- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。
+- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。
+- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。
+- Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为均有聚焦测试覆盖。
+- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。
+
+## Alternatives considered
+
+**所有文件都按功能领域组织。** 拒绝,因为一个 Runtime 或 Cordis 功能会跨越三个可用 API 不同的环境。只有功能信息的路径会隐藏执行限制,也让 browser 到 Node 的意外导入难以审查。
+
+**把 Worker Client 与 Host adapter 放入顶层 `client/` 和 `host/`。** 拒绝,因为这些 adapter 实际运行在 Worker 中,持有的资源也不同于 page 与 Node 主线程 producer。目录名应先回答代码在哪里运行,再回答它代表哪个远端 realm。
+
+**Client 与 Host 目录只保留当前支持的文件。** 拒绝,因为不对称目录会隐藏缺失能力决策,并允许等价路由角色形成无关接口。显式 unsupported 实现既保证穷尽演进,也不虚构已支持行为。
+
+**保留一个共享 protocol 目录。** 拒绝,因为内部 carrier identity、Cordis 语义数据、规范化 Runtime value 与 Chrome wire identifier 的消费方和生命周期不同。单一目录会诱导领域模型依赖 transport 和 CDP presentation。
+
+**把 Client、Host、protocol 与 Worker 拆成多个包。** 实验阶段拒绝。部署单元仍是一个 Client/Host Cordis plugin;包边界会增加构建和发布协作,却不能改善所需的执行环境分隔。
+
+## Consequences
+
+严格镜像会为不支持的能力增加小型 adapter 文件。这些文件是两个实现之间有意保留的兼容点,但必须保持轻薄,也不能制造虚假行为。
+
+即使只移动类型而不改变行为,也可能暴露隐藏的依赖环,尤其是 Runtime object annotation 访问 Cordis repository 的位置。依赖规则要求通过共享接口反转依赖,不能临时从较低层模块反向导入。
+
+如果不加约束地添加规范化类型,`shared/cdp/` 可能变成第二份 Chrome protocol。只有两个 realm 实现或公共 Worker projector 会消费的类型才属于这里;Chrome session bookkeeping 与 wire-only field 保留在 `worker/cdp/`。
+
+显式 Client/Host compiler face 与聚焦行为测试增加了维护工作,但会持续暴露环境泄漏和镜像结构漂移。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.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-27-inspector-development-mount.md
+2026-08-27-inspector-development-mount.md: 0e5f0af52306ebb553e4fb911696cfc3fae087ee
+2026-08-27-inspector-development-mount.zh.md: 252e52692df2ad983e6da9ee9640c5ae6603f20f

+ 30 - 0
.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md

@@ -0,0 +1,30 @@
+# Agent Note: Inspector development mount
+
+Status: implemented
+
+English | [中文](2026-08-27-inspector-development-mount.zh.md)
+
+## Problem
+
+`@deepseek-ai/dsh-experimental-inspector` is a private package no published dsh installation carries, yet development launches need to mount it into the shipped Web composition on demand. A row in a shipped bundle patch cannot express this: `verify-cordis-config` requires every named row of a bundle patch to resolve from that bundle's own `dependencies` — disabled rows included — and a published manifest must not depend on an unpublished package.
+
+## Decision
+
+The inspector package owns a development overlay, `packages/experimental/inspector/cordis.patch.yml`, holding a single `insert` of the `experimental-inspector` row. A launch selects it through the generic overlay flag; `pnpm run demo:inspector` is the shorthand for `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml`.
+
+The overlay contributes only the row; the row's module resolves from the profile plane at entry import:
+
+- A source launch (`pnpm dsh`, tsx) resolves the workspace package through the tsconfig `paths` facade and needs no installation.
+- A built launch (`node apps/cli/lib/bin.js`) needs the package importable from the profile first: `dsh plugin --profile web add link:<absolute package path>`, once per profile. `link:` keeps dependency resolution inside the real package directory; `file:` re-installs the package's `workspace:^` dependencies in the profile and fails with `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND`.
+
+A launch whose profile cannot import the package fails loud at entry import (`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from <profile dir>`); nothing is skipped silently.
+
+## Consequences
+
+Published packages carry no trace of the inspector: no manifest entry, no composition row, no launcher flag. Mounting stays a per-launch choice — the same service without the overlay never loads the package — and every layer the launch composes is declared in a config file. The cost is launch-mode asymmetry: a built launch needs the one-time profile `link:` install, and the overlay must be named on every invocation, which `pnpm run demo:inspector` absorbs for the common case.
+
+## Alternatives considered
+
+- A `disabled: !!js` row in the shipped web-app patch: the dependency gate and npm publication both force the private package into the published manifest.
+- A `--inspector` launcher flag mounting the package as an extra bundle layer: the launcher owns neither app flags nor plugin package names.
+- An optional `peerDependencies` entry on `dsh-web-app` plus a dynamic `ctx.loader.create` from its glue plugin: it writes a never-published name into a published manifest and mounts a row no config layer declares.

+ 30 - 0
.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md

@@ -0,0 +1,30 @@
+# Agent Note:Inspector 开发挂载
+
+Status: implemented
+
+[English](2026-08-27-inspector-development-mount.md) | 中文
+
+## Problem
+
+`@deepseek-ai/dsh-experimental-inspector` 是任何已发布 dsh 安装都不携带的 private 包,但开发启动需要按需把它挂进随货 Web 组合。随货 bundle patch 里的一行表达不了这件事:`verify-cordis-config` 要求 bundle patch 中每个具名行都能从该 bundle 自己的 `dependencies` 解析——disabled 行也不豁免——而已发布的 manifest 不得依赖未发布的包。
+
+## Decision
+
+inspector 包自有一份开发 overlay,`packages/experimental/inspector/cordis.patch.yml`,只含一个 `insert` 的 `experimental-inspector` 行。启动通过通用 overlay flag 选它;`pnpm run demo:inspector` 是 `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml` 的简写。
+
+overlay 只贡献这一行;行的模块在 entry import 时从 profile 平面解析:
+
+- 源码启动(`pnpm dsh`,tsx)经 tsconfig `paths` 门面解析 workspace 包,无需任何安装。
+- built 启动(`node apps/cli/lib/bin.js`)需先让包可从 profile import:`dsh plugin --profile web add link:<包目录绝对路径>`,每个 profile 一次。`link:` 让依赖解析留在真实包目录内;`file:` 会在 profile 里重装该包的 `workspace:^` 依赖并以 `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` 失败。
+
+profile 无法 import 该包的启动会在 entry import 处响亮失败(`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from <profile dir>`);不存在静默跳过。
+
+## Consequences
+
+已发布的包不携带 inspector 的任何痕迹:没有 manifest 条目、没有组合行、没有 launcher flag。挂载保持按次启动选择——不带 overlay 的同一服务永远不会加载该包——且启动组合的每一层都由 config 文件声明。代价是启动方式不对称:built 启动需要一次性 profile `link:` 安装,且每次调用都要点名 overlay,常见场景由 `pnpm run demo:inspector` 吸收。
+
+## Alternatives considered
+
+- 随货 web-app patch 里放 `disabled: !!js` 行:依赖门禁与 npm 发布都会把 private 包逼进已发布 manifest。
+- `--inspector` launcher flag 把包挂成额外 bundle 层:launcher 既不拥有 app flag 也不拥有插件包名。
+- `dsh-web-app` 上加 optional `peerDependencies` 并由其 glue 插件动态 `ctx.loader.create`:向已发布 manifest 写入永不发布的名字,且挂载的行不在任何 config 层声明。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.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-26-stable-turn-process-order.md
+2026-08-26-stable-turn-process-order.md: 7122b901c8d24abc10c0bea51dcfc7a815b7aeeb
+2026-08-26-stable-turn-process-order.zh.md: 574855fd4c9f923dfc3a2a80b815ffed3b2d7422

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md

@@ -0,0 +1,29 @@
+# Agent Note: Stable Turn-process ordering
+
+Status: implemented
+
+English | [中文](2026-08-26-stable-turn-process-order.zh.md)
+
+## Problem
+
+Turn-process eligibility changes as Assistant output streams, becomes a final answer, or is invalidated by a Tool call, Retry, or later Step. Ordering existing Chat Nodes from that mutable range moved the initial System prompt and pre-User Context across the opening User, so one logical row appeared at different transcript positions during a Turn.
+
+## Decision
+
+Existing Chat Nodes keep one presentation order throughout a page lifetime. Their positions depend on durable anchors, node kinds, and the opening human input, never on the mutable process start or answer boundary. Dependency replay after loading older history retains an already projected System prompt's anchor. A newly projected process control may be inserted between existing rows, while completion, Retry, later Steps, pagination completion, and manual disclosure only change visibility or add new evidence.
+
+System prompt is independent of Turn Process: the initial prompt remains visible above the opening User and never receives process-member or process-hidden state. A later prompt first projected from a partial window retains that position when earlier request history loads. Context injection remains process content. When a Context or another potential process row has an event anchor before the opening User, Chat places it after that User from its first projection; once available, the process control occupies the stable position between the User and those rows. Without opening human input, the control stays before the earliest process candidate from its first appearance.
+
+The existing [Turn-process folding decision](../feature/2026-08-14-web-turn-process-folding.md) continues to own membership, completion, persistence, focus, and pagination behavior; this note supersedes only its earlier decision to fold System prompt and to defer pre-User process ordering until a mutable range included those rows.
+
+## Alternatives considered
+
+**Keep System prompt inside Process but preserve its original position.** Rejected because a disclosure below the opening User would control content above itself, and collapsing would remove the request-wide instruction that visually frames that User message.
+
+**Exempt only System prompt.** Rejected because pre-User Context could still move when answer qualification changed, preserving the same class of visual discontinuity.
+
+**Reparent process rows under the disclosure.** Rejected because moving keyed rows across React parents remounts stateful renderers.
+
+## Consequences
+
+The stable first-Turn presentation is `System prompt → User → Process → Context and other process rows → final Assistant`. Pre-User injected Context can therefore differ from raw event order, but it uses that semantic position from its first render. Tests cover the initial state, process appearance, completion collapse, manual expansion, and content-only answer-boundary changes.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 稳定的轮次过程排序
+
+Status: implemented
+
+[English](2026-08-26-stable-turn-process-order.md) | 中文
+
+## 问题
+
+Assistant 输出流式生成、成为最终正文,或因工具调用、Retry、后续步骤而失去正文资格时,轮次过程范围会变化。若既有 Chat Node 的排序依赖这段可变范围,首轮系统提示词和位于 User 之前的上下文会跨过开场 User,导致同一个逻辑行在轮次期间出现在不同位置。
+
+## 决策
+
+既有 Chat Node 在同一页面生命周期内保持同一展示顺序。其位置只依赖持久锚点、节点种类和开场人工输入,不依赖可变的过程起点或正文边界。加载更早历史触发依赖重放时,已经投影出的系统提示词保留原有锚点。新投影出的过程控件可以插入既有行之间;完成状态、Retry、后续步骤、分页完成与手动展开只改变可见性或增加新证据。
+
+系统提示词独立于轮次过程:初始提示词始终显示在开场 User 上方,并且不会获得过程成员或过程隐藏状态。后续提示词若首次从不完整历史窗口投影,加载更早的请求历史后仍保留该位置。上下文注入仍属于过程内容。若上下文或其它潜在过程行的事件锚点早于开场 User,Chat 从首次投影起就将其展示在该 User 之后;过程控件出现后占据 User 与这些过程行之间的稳定位置。没有开场人工输入时,控件从首次出现起就位于最早的过程候选之前。
+
+现有的[轮次过程折叠决策](../feature/2026-08-14-web-turn-process-folding.zh.md)继续负责成员关系、完成状态、持久化、焦点与分页行为;本记录仅取代其中“折叠系统提示词”以及“等可变范围纳入行后才调整 User 前过程顺序”的旧决定。
+
+## 曾考虑的替代方案
+
+**让系统提示词继续属于 Process,但保留原位置。** 不采用:位于开场 User 下方的 disclosure 会控制自身上方的内容,而且收起后会隐藏用于界定该 User 请求的整段指令。
+
+**只排除系统提示词。** 不采用:位于 User 之前的上下文仍会随正文资格变化而移动,保留了同类视觉跳动。
+
+**把过程行重新挂接到 disclosure 下。** 不采用:跨 React 父节点移动 keyed 行会重挂载有状态 renderer。
+
+## 后果
+
+稳定的首轮展示顺序为「系统提示词 → User → Process → 上下文及其它过程行 → 最终 Assistant」。因此,位于 User 之前的注入上下文展示顺序可能不同于原始事件顺序,但从首次渲染起保持不变。测试覆盖初始状态、过程出现、完成后默认收起、手动展开与仅正文边界变化的场景。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md
+2026-08-14-web-turn-process-folding.md: fd0015dc915a296725344cd3f4ae05124089adf6
+2026-08-14-web-turn-process-folding.zh.md: 47b1a36bd75695a2f04dfed654d45ec81d2b8bc9

File diff suppressed because it is too large
+ 16 - 0
.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md


+ 43 - 0
.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: Web 轮次过程折叠
+
+Status: implemented
+
+[English](2026-08-14-web-turn-process-folding.md) | 中文
+
+## 问题
+
+一个模型轮次可能会在最终正文之前展示系统提示词、上下文注入、推理、多条 Assistant 回复、工具调用与重试行。完整展示整条轨迹会淹没正文,而把独立 Chat Node 移入一个父级 disclosure 会重挂载有状态的工具 renderer,并扰乱按时间排列的证据。紧凑视图必须收起已完成的过程工作,同时在轮次仍处于推理、使用工具、重试或没有正文便结束时,保留当下唯一可用的证据。
+
+## 决策
+
+由 Host 支撑的 `ui-chat.transcriptView` 偏好提供 `normal` 与 `compact` 两种模式,默认值为 `compact`。Normal 保持所有过程行可见且不渲染轮次过程控件;Compact 应用下述 disclosure 规则。切换模式只改变 wrapper 可见性,不会重新挂接或卸载 Chat Node renderer;该偏好不写入 Session log。
+
+在 Compact 模式下,轮次打开期间始终完整展开。到 `turn/end` 时,只有当最近步骤包含面向用户的 Assistant 回复内容——非空文本、图片或未知可见块——并且不含工具调用块时,该步骤才成为最终正文边界。正文保持可见。边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行统一进入一条过程 disclosure。系统提示词、用户消息与 steering 消息保持独立,绝不加入过程组。已完成、已取消、已中断、失败和达到最大 token 的轮次使用同一终态投影;错误、最大 token 与 turn-tail 行留在过程组外。关闭时没有最终正文的轮次会保留全部过程证据。
+
+轮次作用域的 `turn-process` Definition 根据日志事件与步骤 Location data 推导首条模型或工具证据、最近步骤的已定稿正文边界、该正文之前带回复内容的持久 Assistant 消息数和工具调用计数。随产品交付的 subagent 委派名称(`subagent` 与 `subagent_*`)只增加 subagent 计数,不增加普通工具调用计数,因此两类不会重叠。上下文注入仍是过程证据但不增加摘要计数;系统提示词保持独立、持续可见,并始终位于开场 User 上方。它发布编码后的标量,使未变化的事实在流式期间保持值相等,并贡献一个稳定控制 Chat Node。Chat target 从首次投影起就把开场 User 或 steering 输入放在过程候选之前,再把合成控制行插入该输入与过程行之间。没有开场人工输入时,控制行从首次出现起就位于最早的过程候选之前。因此正文定稿、Retry、后续步骤、完成状态与手动展开只改变可见性,不改变既有节点的相对顺序,具体规则由[稳定的轮次过程排序](../bug-fix/2026-08-26-stable-turn-process-order.zh.md)说明。每个过程成员根据轮次规格判断自身归属。只有控制 Seat 与正文 Seat 订阅所属轮次在内容变化时更新的 Location key 列表,并在这份受限列表内推导外部过程证据和独立输入间距,因此纯数据更新会刷新布局,而不必重建全局顺序。轮次状态与已加载窗口是否完整随后共同决定能否折叠:打开中的轮次绝不折叠,历史不完整时也既不显示控件又不隐藏成员。控制 Node 从首条过程证据出现起一直存在,但其 Seat 会保持隐藏,直至关闭的轮次拥有最终正文且历史完整;显示后,它会省略每个值为 0 的分段,三项全为 0 时使用「已思考」(英文为 `Thought for a while`),并在摘要下方绘制通栏分隔线。
+
+Chat target 通过共享 settings scope 绑定持久化的 transcript 偏好,并把逐轮交互状态保存在会话作用域的 Chat store 中。`ChatView` 通过稳定的 keyed `ChatNodeSeat` 直接渲染每个业务 Node;加入过程控件不会重排既有 key,Compact 模式只改变 Seat wrapper 的 `hidden` 属性,不会重新挂接或卸载工具、Assistant、上下文或重试 renderer。Seat 通过 `ChatNodeOwnerProps` 传递同一份过程状态,因此最终 Assistant renderer 会隐藏自身步骤中的推理块,同时保留回复块;wrapper 可见性与行内推理共用一个 UI 状态真源。
+
+收起的过程成员使用 `hidden="until-found"`。在支持该能力的浏览器中,任一成员触发 `beforematch` 都会打开共享过程组。由于 hidden-until-found 成员会保留可搜索的零高度 box,Chat 列只在可见的相邻成员之间设置间距;控件分隔线横跨内容宽度,只有中间没有独立输入时,收起的过程控件才与正文相隔 8px,展开后恢复普通的 16px 行间距。在 Compact 模式下,不持久化的会话 store 只保存用户手动展开的「轮次 + 正文步骤」generation;没有记录即为收起,不同正文 generation 默认收起。因此,每个合格的已关闭轮次都使用相同默认状态,不区分实时完成、在「加载更早」后出现,或在读者离开尾部时结束。这可能在轮次关闭或历史变完整时让读者上方的内容重排。若自动收起会隐藏过程成员中的键盘焦点,则改为打开共享过程组并把焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。存在「加载更早」时,每个过程保持展开且控件隐藏;历史加载完整后,合格过程立即使用默认收起状态。页面重新加载会恢复持久化的 Normal 或 Compact 偏好;逐轮手动展开只在同一页面生命周期内的 view remount 之间保留。切换到 Normal 会显示所有过程行,切回 Compact 时会在默认收起状态上重新应用当前页面生命周期内的手动展开记录。
+
+这项展示与 [Conversation Node 组装](../architecture/2026-08-09-client-conversation-node-assembly.zh.md)共同成立:Definition 持有确定性的过程事实,Seat 持有共享交互状态,keyed renderer 保持独立。[按日志顺序投影的人工 transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md)保持完整,因为折叠不改变任何会话事件或模型输入。
+
+## 曾考虑的替代方案
+
+**只折叠较早的 Assistant 回复。** 不采用:常见的 `Think → Tool → 正文` 轮次只有一个回复步骤,不会出现紧凑控件,用户要求收起的过程内容仍会完整展开。
+
+**把上下文注入留在过程组外。** 不采用:注入的运行时上下文属于正文之前的轨迹,并不是一条新的人类指令;展开过程后,其自身的 disclosure 与标签仍完整保留。系统提示词则留在过程组外,因为移动或隐藏整次请求使用的指令会改变开场 User 周围的可见框架。
+
+**把整个轮次重新挂接到一条摘要行下。** 不采用:折叠资格会在轮次运行期间变化,而把既有 Chat Node 移过 React 父节点会重挂载有状态工具视图。稳定 Seat 可以提供一个 disclosure,同时不移动其子节点。
+
+**把手动展开状态存入轮次 Location data。** 不采用:Location data 是会话事件的确定性投影,不存在浏览器动作写入路径。UI 手势属于已声明且不持久化的 store。
+
+**复用 `DisclosureRow` 并卸载收起成员。** 不采用:浏览器查找无法发现其文本,重新打开也会重建有状态 renderer 子树。
+
+**在 `turn/end` 前折叠实时正文候选。** 不采用:流式文本后仍可能出现工具调用、重试或后续仅推理步骤。等待终态边界可以避免自动收起—展开—再次收起及其造成的布局跳动。
+
+**读者离开尾部时暂缓刚获得资格的收起。** 不采用:该方案需要瞬时完成检测与 deferred 状态,并使相同的已关闭轮次根据进入视口的路径获得不同初始状态。已关闭轮次统一使用确定性的默认值;滚动锚定负责保持位置,焦点保护负责保留正在进行的交互。
+
+## 后果
+
+Compact 模式会在轮次正文前只有注入上下文、推理或工具时仍突出最终正文,展开后每条过程行按原顺序恢复;Normal 模式则保留完整 transcript 且不展示轮次过程控件。隐藏的 wrapper 与 Markdown 子树保持挂载,用一定浏览器内存换取稳定工具状态、跨视图重挂载的手动展开状态与浏览器查找恢复。只有 Compact 模式下的普通跨消息选择会排除仍收起的成员;用户可以先展开过程组,或切换到 Normal。不支持 `hidden="until-found"` 与 `beforematch` 的浏览器仍可手动展开,却无法通过页内查找揭示收起的过程文本。单元覆盖固定已定稿正文边界、重试与中断、跨 kind 共享展开、最终步骤推理、模式切换、手动展开、历史补全后立即收起、离尾收起、焦点保留与最终分页的纯数据更新;组装后的浏览器快照固定运行中、已取消、已完成、分页历史与持久化设置轨迹。

+ 2 - 2
README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write README.md
 #   pnpm run verify-translation-pairing --write README.md
-README.md: 8fe2204c765dbccfd79a438a3a58900b7b21f52e
-README.zh.md: 3c7ad619303dad747cd5114375647b13a7629f8b
+README.md: 9f89db3d4502dea4a0d181799164f304d4976740
+README.zh.md: aa66ef1d24a5e2165859e9337273d807dff3d070

+ 1 - 1
README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 
 DeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).
 DeepSeek Harness (`dsh`) is an open-source agent harness developed by [DeepSeek AI](https://deepseek.com).
 
 
-It is built on an **everything-is-a-plugin** architecture and powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper).
+It is built on an **everything-is-a-plugin** architecture and powered by [Cordis](https://github.com/cordiverse/cordis), whose design is described in [_A Programming Paradigm for Spatiotemporal Composability_](https://arxiv.org/abs/2608.25512).
 
 
 Documentation: [https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/)
 Documentation: [https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/)
 
 

+ 1 - 1
README.zh.md

@@ -4,7 +4,7 @@
 
 
 DeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。
 DeepSeek Harness(`dsh`)是由 [DeepSeek AI](https://deepseek.com) 开发的开源 agent harness(智能体框架)。
 
 
-它构建于**一切皆插件**的架构之上,由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://github.com/cordiverse/paper)。
+它构建于**一切皆插件**的架构之上,由 [Cordis](https://github.com/cordiverse/cordis) 驱动,其设计参见论文 [_A Programming Paradigm for Spatiotemporal Composability_](https://arxiv.org/abs/2608.25512)。
 
 
 文档:[https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/)
 文档:[https://deepseek-harness.github.io/deepseek-harness/](https://deepseek-harness.github.io/deepseek-harness/)
 
 

+ 8 - 4
apps/web/tests/chat-continuous-conversation.e2e.ts

@@ -18,7 +18,9 @@ import {
   webSnapshotMode,
   webSnapshotMode,
   type WebScaffold,
   type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot,
+} from './support.ts'
 
 
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const TURN_COUNT = 12
 const TURN_COUNT = 12
@@ -315,6 +317,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
       const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
       const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
       await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
       await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
       expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
       expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
+      await expandOwningTurnProcess(page, toolRow)
       const disclosure = toolRow.locator('[data-sample="bash"]')
       const disclosure = toolRow.locator('[data-sample="bash"]')
       expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
       expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
       await disclosure.click()
       await disclosure.click()
@@ -332,9 +335,10 @@ describe('web e2e: continuous conversation grown through the composer', () => {
     ))).toHaveLength(TURN_COUNT)
     ))).toHaveLength(TURN_COUNT)
     expect(sessionEvents.flatMap(event =>
     expect(sessionEvents.flatMap(event =>
       event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
       event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
-    await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
-      timeout: 10_000,
-    }).toBe(1)
+    expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1)
+    expect(await page.locator(
+      '[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
+    ).count()).toBe(0)
     expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
     expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
     expect(sessionEvents.filter(event => (
     expect(sessionEvents.filter(event => (
       event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
       event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT

+ 2 - 1
apps/web/tests/chat-long-interactions.e2e.ts

@@ -19,7 +19,7 @@ import {
   webSnapshotMode,
   webSnapshotMode,
   type WebScaffold,
   type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
+import { conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const SESSION_ID = 'chat-long-interactions-e2e'
 const SESSION_ID = 'chat-long-interactions-e2e'
@@ -278,6 +278,7 @@ describe('web e2e: long Chat interaction contract', () => {
 
 
     const summary1 = call1.locator('[data-sample="bash"]')
     const summary1 = call1.locator('[data-sample="bash"]')
     const summary2 = call2.locator('[data-sample="bash"]')
     const summary2 = call2.locator('[data-sample="bash"]')
+    await expandOwningTurnProcess(page, call2)
     expect(await summary1.getAttribute('aria-expanded')).toBe('false')
     expect(await summary1.getAttribute('aria-expanded')).toBe('false')
     expect(await summary2.getAttribute('aria-expanded')).toBe('false')
     expect(await summary2.getAttribute('aria-expanded')).toBe('false')
     await summary2.focus()
     await summary2.focus()

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

@@ -20,7 +20,7 @@ import {
   webSnapshotMode,
   webSnapshotMode,
   type WebScaffold,
   type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
 const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
@@ -355,7 +355,7 @@ async function wheelUntilVisible(page: Page, selector: string, deltaY: number):
 
 
 function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
 function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
   return page.locator('[data-conversation-scroll]').evaluate((host) => {
   return page.locator('[data-conversation-scroll]').evaluate((host) => {
-    const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
+    const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')]
     const viewport = host.getBoundingClientRect()
     const viewport = host.getBoundingClientRect()
     const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
     const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
     const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
     const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
@@ -431,12 +431,19 @@ async function expectMarkerAboveComposer(page: Page, marker: string): Promise<vo
 async function loadEarlierWithAnchor(page: Page): Promise<void> {
 async function loadEarlierWithAnchor(page: Page): Promise<void> {
   await wheelToHistoryStart(page)
   await wheelToHistoryStart(page)
   const older = page.getByRole('button', { name: 'Load earlier', exact: true })
   const older = page.getByRole('button', { name: 'Load earlier', exact: true })
+  const loading = page.getByRole('button', { name: 'Loading…', exact: true })
   await older.waitFor({ timeout: 10_000 })
   await older.waitFor({ timeout: 10_000 })
   const anchor = await visibleFlowAnchor(page)
   const anchor = await visibleFlowAnchor(page)
   const before = await loadedFlowRows(page)
   const before = await loadedFlowRows(page)
   await older.click()
   await older.click()
-  await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before)
+  await expect.poll(async () => (
+    await loadedFlowRows(page) > before && await loading.count() === 0
+  ), { timeout: 30_000 }).toBe(true)
   await nextPaint(page)
   await nextPaint(page)
+  if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) {
+    expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0)
+    return
+  }
   await expectSameFlowTop(page, anchor)
   await expectSameFlowTop(page, anchor)
 }
 }
 
 
@@ -614,6 +621,7 @@ describe('web e2e: long Chat scroll contract', () => {
 
 
       const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
       const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
       const liveRow = world.page.locator(liveRowSelector)
       const liveRow = world.page.locator(liveRowSelector)
+      await expandOwningTurnProcess(world.page, liveRow)
       await wheelUntilVisible(world.page, liveRowSelector, -300)
       await wheelUntilVisible(world.page, liveRowSelector, -300)
       const toolAnchor = await liveRow.evaluate((row) => {
       const toolAnchor = await liveRow.evaluate((row) => {
         const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
         const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
@@ -762,6 +770,7 @@ describe('web e2e: long Chat scroll contract', () => {
       const lastToolRow = world.page.locator(
       const lastToolRow = world.page.locator(
         `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
         `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
       )
       )
+      await expandOwningTurnProcess(world.page, lastToolRow)
       await lastToolRow.focus()
       await lastToolRow.focus()
       await world.page.keyboard.press('End')
       await world.page.keyboard.press('End')
       await expectBottom(world.page)
       await expectBottom(world.page)

+ 10 - 4
apps/web/tests/code-mode-round.e2e.ts

@@ -8,10 +8,10 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/session.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/ui.expected.md', import.meta.url))
@@ -94,6 +94,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     // The parent run_code row wears the code variant with the model-authored
     // The parent run_code row wears the code variant with the model-authored
     // description as its summary (the presentCall contract).
     // description as its summary (the presentCall contract).
     const codeRow = page.locator('[data-variant="code"]').first()
     const codeRow = page.locator('[data-variant="code"]').first()
+    await expandOwningTurnProcess(page, codeRow)
     await codeRow.waitFor({ timeout: 10_000 })
     await codeRow.waitFor({ timeout: 10_000 })
     const nest = page.locator('[data-subcalls]').first()
     const nest = page.locator('[data-subcalls]').first()
     await nest.waitFor({ timeout: 10_000 })
     await nest.waitFor({ timeout: 10_000 })
@@ -106,13 +107,18 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     const nest = page.locator('[data-subcalls]').first()
     const nest = page.locator('[data-subcalls]').first()
     const frame = page.locator('[style*="grid-template-columns"]').first()
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+    await expandOwningTurnProcess(page, nest)
     await nest.locator('[data-sample="bash"]').first().click()
     await nest.locator('[data-sample="bash"]').first().click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
   })
   })
 
 
-  it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
+  it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria'))
-    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    const snapshot = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
   })
 
 

+ 1 - 1
apps/web/tests/composer-tab-geometry.e2e.ts

@@ -130,7 +130,7 @@ function measureTab(page: Page): Promise<TabMetrics> {
 async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
 async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
   await page.getByRole('tab', { name: tab, exact: true }).click()
   await page.getByRole('tab', { name: tab, exact: true }).click()
   if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
   if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
-  else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
+  else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]:visible').first().waitFor({ timeout: 30_000 })
   // Both measurements are taken after a paint, so a rectangle read mid-transition
   // Both measurements are taken after a paint, so a rectangle read mid-transition
   // cannot be reported as a shift the cascade did not cause.
   // cannot be reported as a shift the cascade did not cause.
   await page.evaluate(() => new Promise<void>((settle) => {
   await page.evaluate(() => new Promise<void>((settle) => {

+ 5 - 1
apps/web/tests/cordis-tool-round.e2e.ts

@@ -18,7 +18,7 @@ import {
   captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url))
@@ -162,6 +162,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
       .toBeGreaterThanOrEqual(1)
       .toBeGreaterThanOrEqual(1)
 
 
     const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first()
     const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first()
+    await expandOwningTurnProcess(page, inspectRow)
     await inspectRow.waitFor({ timeout: 10_000 })
     await inspectRow.waitFor({ timeout: 10_000 })
 
 
     // cordis_define does NOT go through the generic row: ui-cordis registers a
     // cordis_define does NOT go through the generic row: ui-cordis registers a
@@ -169,6 +170,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
     // title here is the CARD's ("Cordis Plugin"), and the expanded body is the
     // title here is the CARD's ("Cordis Plugin"), and the expanded body is the
     // card's own two code sections rather than a generic args dump.
     // card's own two code sections rather than a generic args dump.
     const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
     const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
+    await expandOwningTurnProcess(page, defineRow)
     await defineRow.waitFor({ timeout: 10_000 })
     await defineRow.waitFor({ timeout: 10_000 })
     // The whole summary row is the expand toggle (unified tool-row interaction).
     // The whole summary row is the expand toggle (unified tool-row interaction).
     await defineRow.locator('[aria-expanded]').first().click()
     await defineRow.locator('[aria-expanded]').first().click()
@@ -177,10 +179,12 @@ describe('web e2e: Cordis tools use their owned cards', () => {
     await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE)
     await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE)
 
 
     const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first()
     const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first()
+    await expandOwningTurnProcess(page, runRow)
     await runRow.waitFor({ timeout: 10_000 })
     await runRow.waitFor({ timeout: 10_000 })
     await expect.poll(() => runRow.textContent()).toContain('snap-')
     await expect.poll(() => runRow.textContent()).toContain('snap-')
 
 
     const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first()
     const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first()
+    await expandOwningTurnProcess(page, stopRow)
     await stopRow.waitFor({ timeout: 10_000 })
     await stopRow.waitFor({ timeout: 10_000 })
     await expect.poll(() => stopRow.textContent()).toContain('snap-')
     await expect.poll(() => stopRow.textContent()).toContain('snap-')
     await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')
     await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')

+ 56 - 0
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -0,0 +1,56 @@
+- tree "Sessions":
+  - treeitem "{{workspace}}" [expanded]:
+    - img
+    - text: {{workspace}}
+  - treeitem "Review deepseek-harness/deepseek-harness#314 Session actions for Review deepseek-harness/deepseek-harness#314" [selected]:
+    - text: Review deepseek-harness/deepseek-harness#314
+    - button "Session actions for Review deepseek-harness/deepseek-harness#314":
+      - img
+
+---
+
+- banner:
+  - navigation "Session hierarchy":
+    - button "Review deepseek-harness/deepseek-harness#314" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- button "Thought for a while" [expanded]:
+  - text: Thought for a while
+  - img
+- button "Context injection webhook github webhook handled by review-pr-when-ready":
+  - img
+  - img
+  - text: Context injection webhook github webhook handled by review-pr-when-ready
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- paragraph: "Review complete: no actionable findings."
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}}
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Read Only"': Read Only
+- button "Select model, current github-webhook-review-test/reply":
+  - text: github-webhook-review-test/reply
+  - img
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}}

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

@@ -24,14 +24,9 @@
   - img
   - img
   - img
   - img
   - text: System prompt
   - text: System prompt
-- button "Context injection webhook github webhook handled by review-pr-when-ready":
+- button "Thought for a while":
+  - text: Thought for a while
   - img
   - img
-  - img
-  - text: Context injection webhook github webhook handled by review-pr-when-ready
-- button "Context injection @deepseek-ai/dsh-system-prompt":
-  - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
 - paragraph: "Review complete: no actionable findings."
 - paragraph: "Review complete: no actionable findings."
 - button "Copy":
 - button "Copy":
   - img
   - img

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

@@ -44,7 +44,11 @@
     - img
     - img
   - button "Decrease font size":
   - button "Decrease font size":
     - img
     - img
-  - text: px Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior
+  - text: px Conversation display Controls process content in completed turns
+  - button "Compact":
+    - text: Compact
+    - img
+  - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior
   - button "Queue":
   - button "Queue":
     - text: Queue
     - text: Queue
     - img
     - img

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

@@ -44,7 +44,11 @@
     - img
     - img
   - button "减小字号":
   - button "减小字号":
     - img
     - img
-  - text: px 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
+  - text: px 对话显示 控制已完成轮次的过程内容
+  - button "Compact":
+    - text: Compact
+    - img
+  - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
   - button "排队发送":
   - button "排队发送":
     - text: 排队发送
     - text: 排队发送
     - img
     - img

+ 49 - 0
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -0,0 +1,49 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "/user-invoke-demo and confirm the fixtur" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
+- button "Copy":
+  - img
+- button "Thought for a while" [expanded]:
+  - text: Thought for a while
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Context injection user-invoke-demo":
+  - img
+  - img
+  - text: Context injection user-invoke-demo
+- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "0% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok

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

@@ -16,14 +16,9 @@
 - text: /user-invoke-demo and confirm the fixture wiring {{clock}}
 - text: /user-invoke-demo and confirm the fixture wiring {{clock}}
 - button "Copy":
 - button "Copy":
   - img
   - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
+- button "Thought for a while":
+  - text: Thought for a while
   - img
   - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Context injection user-invoke-demo":
-  - img
-  - img
-  - text: Context injection user-invoke-demo
 - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
 - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
 - button "Copy":
 - button "Copy":
   - img
   - img

+ 59 - 0
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -0,0 +1,59 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Use the ask_user_question tool to" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
+- button "Copy":
+  - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
+  - img
+  - img
+  - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
+- button "Ask question 1/1 answered":
+  - img
+  - img
+  - text: Ask question 1/1 answered
+- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
+- button "Copy":
+  - img
+- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
+- button "Copy":
+  - img
+- paragraph: "Got it: BANANA and ORANGE."
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "0% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok

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

@@ -16,18 +16,9 @@
 - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
 - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
 - button "Copy":
 - button "Copy":
   - img
   - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
+- button "1 tool call":
+  - text: 1 tool call
   - img
   - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
-  - img
-  - img
-  - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
-- button "Ask question 1/1 answered":
-  - img
-  - img
-  - text: Ask question 1/1 answered
 - text: "Interjection: include the word BANANA in your final reply. {{clock}}"
 - text: "Interjection: include the word BANANA in your final reply. {{clock}}"
 - button "Copy":
 - button "Copy":
   - img
   - img

+ 12 - 2
apps/web/tests/feedback-command.e2e.ts

@@ -15,7 +15,8 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -23,6 +24,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
+const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 // Discard port: loopback listener never binds, so FULL telemetry discloses
 // Discard port: loopback listener never binds, so FULL telemetry discloses
 // the shipped default policy without any record reaching a collector.
 // the shipped default policy without any record reaching a collector.
@@ -91,12 +93,20 @@ describe('web e2e: /feedback command acknowledgement', () => {
     expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
     expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE)
 
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
   }, 60_000)
 
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.jsonl', 'ack.expected.md', 'ack-expanded.expected.md',
+    ])
   })
   })
 })
 })

+ 10 - 2
apps/web/tests/feedback-release.e2e.ts

@@ -16,7 +16,8 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -27,6 +28,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-rele
 // manifest's `session.source`) instead of recording a duplicate.
 // manifest's `session.source`) instead of recording a duplicate.
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url))
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
+const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -115,6 +117,12 @@ describe('web e2e: feedback-gated release under the shipped default mode', () =>
 
 
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
   }, 60_000)
@@ -132,6 +140,6 @@ describe('web e2e: feedback-gated release under the shipped default mode', () =>
   }, 60_000)
   }, 60_000)
 
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md'])
   })
   })
 })
 })

+ 10 - 0
apps/web/tests/github-ready-review.e2e.ts

@@ -11,6 +11,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-webhook'
 import type {} from '@deepseek-ai/dsh-webhook'
 import {
 import {
+  captureExpandedTurnProcessAria,
   captureStableAria,
   captureStableAria,
   compareOrRefreshGolden,
   compareOrRefreshGolden,
   launchWebScaffold,
   launchWebScaffold,
@@ -23,6 +24,9 @@ import { saveFailureShot } from './support.ts'
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/github-review/cordis.yml', import.meta.url))
 const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/github-review/cordis.yml', import.meta.url))
 const EXPECTED = fileURLToPath(new URL('./expected/github-ready-review/conversation.expected.md', import.meta.url))
 const EXPECTED = fileURLToPath(new URL('./expected/github-ready-review/conversation.expected.md', import.meta.url))
+const EXPANDED_EXPECTED = fileURLToPath(
+  new URL('./expected/github-ready-review/conversation-expanded.expected.md', import.meta.url),
+)
 const PROVIDER = 'github-webhook-review-test'
 const PROVIDER = 'github-webhook-review-test'
 const MODEL = 'reply'
 const MODEL = 'reply'
 const SECRET = 'github-webhook-review-secret'
 const SECRET = 'github-webhook-review-secret'
@@ -157,6 +161,12 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
     const tree = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
     const tree = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
     const conversation = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const conversation = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(EXPECTED, `${tree}\n\n---\n\n${conversation}`, MODE)
     await compareOrRefreshGolden(EXPECTED, `${tree}\n\n---\n\n${conversation}`, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(EXPANDED_EXPECTED, `${tree}\n\n---\n\n${expanded}`, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
   }, 60_000)

+ 17 - 6
apps/web/tests/goal-multi-turn-actions.e2e.ts

@@ -11,7 +11,7 @@ import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-goal'
 import type {} from '@deepseek-ai/dsh-goal'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -20,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-tu
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的'
 const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的'
@@ -29,7 +30,7 @@ const PACKAGE_FILES: Readonly<Record<string, string>> = {
   'packages/client/ui-conversation/README.md': '# UI conversation\n',
   'packages/client/ui-conversation/README.md': '# UI conversation\n',
   'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
   'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
   'packages/client/ui-conversation/src/client.ts': 'export {}\n',
   'packages/client/ui-conversation/src/client.ts': 'export {}\n',
-  'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n',
+  'packages/client/ui-chat/tests/chat-view.client.spec.tsx': 'export {}\n',
   'packages/context/session-reference/README.md': '# Session reference\n',
   'packages/context/session-reference/README.md': '# Session reference\n',
   'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
   'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
   'packages/context/session-reference/src/index.ts': 'export {}\n',
   'packages/context/session-reference/src/index.ts': 'export {}\n',
@@ -153,9 +154,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
     expect(goalRounds(sessionEvents)).toEqual([1, 2])
     expect(goalRounds(sessionEvents)).toEqual([1, 2])
     expect(sessionEvents.flatMap(event =>
     expect(sessionEvents.flatMap(event =>
       event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
       event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
-    await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
-      timeout: 15_000,
-    }).toBe(2)
+    await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2)
+    expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2)
+    expect(await page.locator(
+      '[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
+    ).count()).toBe(0)
     const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
     const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
     await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
     await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
     expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
     expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
@@ -163,11 +166,19 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
     await branchButtons.last().focus()
     await branchButtons.last().focus()
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold!.workspaceCwd,
+    )
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 140_000)
   }, 140_000)
 
 
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'replay.override.json', 'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+    ])
   })
   })
 })
 })

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

@@ -18,7 +18,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria,
+  captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
@@ -33,6 +34,7 @@ const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
 // Post-reload golden: the same settled conversation rebuilt purely from
 // Post-reload golden: the same settled conversation rebuilt purely from
 // persistence + history — byte-equal rendering is exactly the recovery claim.
 // persistence + history — byte-equal rendering is exactly the recovery claim.
 const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
 const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
+const RELOADED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -239,6 +241,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     // must render the same settled transcript the live turn produced.
     // must render the same settled transcript the live turn produced.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(RELOADED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
   }, 90_000)
 
 
@@ -277,7 +285,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+      'session.jsonl', 'replay.override.json', 'command-menu.expected.md',
+      'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md',
+      'reloaded.expected.md', 'reloaded-expanded.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 19 - 3
apps/web/tests/live-interactions.e2e.ts

@@ -23,7 +23,8 @@ import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay
 import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -34,10 +35,12 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // state, and the other four capture what remains after cancel, after a
 // state, and the other four capture what remains after cancel, after a
 // non-retryable failure, after retry recovery, and after retry exhaustion.
 // non-retryable failure, after retry recovery, and after retry exhaustion.
 const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
 const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
+const CANCEL_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'cancel-expanded.expected.md')
 const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
 const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
 const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
 const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
 const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
 const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
 const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
 const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
+const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md')
 const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
 const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
 const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
@@ -185,6 +188,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // partial ('partial' is the hang entry's replayed prefix) and no more.
     // partial ('partial' is the hang entry's replayed prefix) and no more.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold!.workspaceCwd,
+    )
+    await compareOrRefreshGolden(CANCEL_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
   }, 120_000)
@@ -268,6 +277,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // while the settled retry row remains as durable recovery context.
     // while the settled retry row remains as durable recovery context.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold!.workspaceCwd,
+    )
+    await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
   }, 120_000)
@@ -307,8 +322,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
 
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md',
-      'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md',
+      'session.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
+      'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
+      'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 6 - 0
apps/web/tests/minimal-preset.snapshot.ts

@@ -154,6 +154,12 @@ describe('minimal agent preset', () => {
     await sessionRow.click()
     await sessionRow.click()
     await page.getByText('MINIMAL_PRESET_REQUEST_OK', { exact: true }).waitFor({ timeout: 15_000 })
     await page.getByText('MINIMAL_PRESET_REQUEST_OK', { exact: true }).waitFor({ timeout: 15_000 })
 
 
+    const process = page.locator('[data-turn-process]')
+    await process.waitFor({ timeout: 15_000 })
+    await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false')
+    await process.click()
+    await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true')
+
     const row = page.locator('[data-sample="bash"]').first()
     const row = page.locator('[data-sample="bash"]').first()
     await row.waitFor({ timeout: 15_000 })
     await row.waitFor({ timeout: 15_000 })
     await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
     await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')

+ 3 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -19,7 +19,7 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'session.jsonl')
 const SEED = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -390,6 +390,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
     await ensureSeedOpen(page)
     await ensureSeedOpen(page)
     const bashRow = page.locator('[data-sample="bash"]').first()
     const bashRow = page.locator('[data-sample="bash"]').first()
+    await expandOwningTurnProcess(page, bashRow)
     await bashRow.waitFor({ timeout: 15_000 })
     await bashRow.waitFor({ timeout: 15_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
@@ -425,6 +426,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     // Expanded, the recorded command's own output sits in the message flow,
     // Expanded, the recorded command's own output sits in the message flow,
     // derived from the logged call/result presentations alone.
     // derived from the logged call/result presentations alone.
     const bashRow = page.locator('[data-sample="bash"]').first()
     const bashRow = page.locator('[data-sample="bash"]').first()
+    await expandOwningTurnProcess(page, bashRow)
     await bashRow.waitFor({ timeout: 15_000 })
     await bashRow.waitFor({ timeout: 15_000 })
     if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
     if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
     const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()
     const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()

+ 11 - 2
apps/web/tests/plan-review.e2e.ts

@@ -15,7 +15,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -27,6 +28,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
 const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
 const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
 const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
 const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
 const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
+const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 // One command line: /plan enters plan mode and submits the rest as the turn's
 // One command line: /plan enters plan mode and submits the rest as the turn's
@@ -111,13 +113,20 @@ describe('web e2e: plan review takeover round trip', () => {
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(APPROVED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
   }, 200_000)
 
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
+      'session.jsonl', 'review.expected.md', 'sidebar.expected.md',
+      'approved.expected.md', 'approved-expanded.expected.md',
     ])
     ])
   })
   })
 })
 })

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

@@ -19,7 +19,9 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot,
+} from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -30,6 +32,7 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
 // round trip and the final reply, the state the composer goldens cannot see.
 // round trip and the final reply, the state the composer goldens cannot see.
 const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
 const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
 const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md')
 const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md')
+const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e'
 const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e'
 
 
@@ -289,16 +292,20 @@ describe('web e2e: resident question composer round trip', () => {
     expect(await page.locator('[data-question-key]').count()).toBe(0)
     expect(await page.locator('[data-question-key]').count()).toBe(0)
     expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
     expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
-    // Golden of the answered transcript: the ask_user_question round trip
-    // rendered as history (expanded readable answers + DONE), composer takeover gone.
+    // The default golden pins Compact mode before process disclosure.
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
+    // Keep the ask_user_question card's readable answer in the expanded golden
+    // even though Compact mode hides the process by default.
+    await expandTurnProcesses(page)
     const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true })
     const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true })
     await answeredRow.click()
     await answeredRow.click()
     await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
     await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
     expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1)
     expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1)
     expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1)
     expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1)
     expect(await page.getByText(/"answers"/).count()).toBe(0)
     expect(await page.getByText(/"answers"/).count()).toBe(0)
-    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
+    const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
   }, 200_000)
@@ -410,6 +417,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', ()
       'composed.expected.md',
       'composed.expected.md',
       'answered.expected.md',
       'answered.expected.md',
       'cancelled.expected.md',
       'cancelled.expected.md',
+      'answered-expanded.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 12 - 2
apps/web/tests/queue-actions.e2e.ts

@@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
 import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
 import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -24,6 +24,7 @@ const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
 const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
 const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
 const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
 const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
+const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
@@ -168,6 +169,12 @@ describe('web e2e: queue row actions', () => {
 
 
     const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
     await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(PRESERVED_EXPANDED_EXPECTED, expanded, MODE)
 
 
     const settled = scaffold.whenTurnSettled()
     const settled = scaffold.whenTurnSettled()
     await input.fill(WAKE)
     await input.fill(WAKE)
@@ -272,7 +279,10 @@ describe('web e2e: queue row actions', () => {
   it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(
     await assertFixtureInventory(
       SNAPSHOT_DIR,
       SNAPSHOT_DIR,
-      ['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
+      [
+        'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
+        'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
+      ],
     )
     )
   })
   })
 })
 })

+ 18 - 3
apps/web/tests/replay-round-trip.e2e.ts

@@ -17,15 +17,21 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
+} from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url))
 const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url))
 const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url))
+const UI_EXPANDED_EXPECTED = fileURLToPath(
+  new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url),
+)
 const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url))
 const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
@@ -163,10 +169,17 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     }).waitFor({ timeout: 10_000 })
     }).waitFor({ timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
   })
   })
 
 
-  it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => {
+  it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
+    await expandTurnProcesses(page)
     const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
     const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
     const body = page.locator('[data-system-prompt-body]')
     const body = page.locator('[data-system-prompt-body]')
     await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
@@ -190,6 +203,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     // tier pins the same gesture against FixtureApiClient; this one runs on
     // tier pins the same gesture against FixtureApiClient; this one runs on
     // follow-stream-fed state). Runs after the golden capture so the committed
     // follow-stream-fed state). Runs after the golden capture so the committed
     // aria surface stays the untouched settled state.
     // aria surface stays the untouched settled state.
+    await expandTurnProcesses(page)
     const think = page.getByRole('button', { name: /^Think/ }).first()
     const think = page.getByRole('button', { name: /^Think/ }).first()
     expect(await think.getAttribute('aria-expanded')).toBe('false')
     expect(await think.getAttribute('aria-expanded')).toBe('false')
     await think.click()
     await think.click()
@@ -208,6 +222,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
       'tool-schemas.expected.json',
       'tool-schemas.expected.json',
       'web-context.expected.md',
       'web-context.expected.md',
       'ui.expected.md',
       'ui.expected.md',
+      'ui-expanded.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 33 - 0
apps/web/tests/scaffold.ts

@@ -1169,6 +1169,39 @@ export async function captureStableAria(
   return previous
   return previous
 }
 }
 
 
+/**
+ * Capture a stable aria snapshot with every eligible Turn process expanded,
+ * then restore the controls that were closed before the capture.
+ * @param page - the page under test.
+ * @param selector - the region locator selector.
+ * @param workspaceCwd - normalization input.
+ * @returns the stable normalized expanded snapshot.
+ */
+export async function captureExpandedTurnProcessAria(
+  page: Page,
+  selector: string,
+  workspaceCwd: string,
+): Promise<string> {
+  const controls = page.locator('[data-turn-process]')
+  const count = await controls.count()
+  expect(count).toBeGreaterThan(0)
+  const opened: number[] = []
+  for (let index = 0; index < count; index++) {
+    const control = controls.nth(index)
+    if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue
+    await control.click()
+    opened.push(index)
+  }
+  try {
+    return await captureStableAria(page, selector, workspaceCwd)
+  } finally {
+    for (const index of opened.reverse()) {
+      const control = controls.nth(index)
+      if (await control.getAttribute('aria-expanded') === 'true') await control.click()
+    }
+  }
+}
+
 /**
 /**
  * Compare a normalized golden, or rewrite it under refresh. Refresh is the
  * Compare a normalized golden, or rewrite it under refresh. Refresh is the
  * ONLY writer: a missing golden in replay mode fails with the healing command
  * ONLY writer: a missing golden in replay mode fails with the healing command

+ 25 - 3
apps/web/tests/seeded-history.e2e.ts

@@ -22,15 +22,19 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
 import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
   launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
   webSnapshotMode, type WebScaffold,
   webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url))
 const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
 const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url))
+const UI_EXPANDED_EXPECTED = fileURLToPath(
+  new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url),
+)
 // Command-row goldens over the same conversation after direct host commands.
 // Command-row goldens over the same conversation after direct host commands.
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
@@ -278,6 +282,14 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
     await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
       timeout: 10_000,
       timeout: 10_000,
     }).toBe(1)
     }).toBe(1)
+    const process = page.locator('[data-turn-process="1"]')
+    await process.waitFor({ state: 'visible', timeout: 10_000 })
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom)
+    const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
+      element.getBoundingClientRect().top)
+    // Collapsed control row keeps its own 8px margin plus the 8px flow gap.
+    expect(answerTop).toBe(processBottom + 16)
     expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
     expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
     // Tool cards render from logged tool/call + tool/result alone (views are
     // Tool cards render from logged tool/call + tool/result alone (views are
     // host-recomputed per page; the generic card is the documented default).
     // host-recomputed per page; the generic card is the documented default).
@@ -331,6 +343,12 @@ describe('web e2e: seeded history renders through cold resume', () => {
     const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
     const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = (await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )).split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
   })
   })
 
 
   it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
   it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
@@ -395,6 +413,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // file links (not expand-in-place / not details). Runs after the golden
     // file links (not expand-in-place / not details). Runs after the golden
     // capture; still zero model calls.
     // capture; still zero model calls.
     const fileLink = page.locator('[data-variant="read"] button').first()
     const fileLink = page.locator('[data-variant="read"] button').first()
+    await expandOwningTurnProcess(page, fileLink)
     await fileLink.waitFor({ timeout: 10_000 })
     await fileLink.waitFor({ timeout: 10_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
@@ -552,6 +571,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // stream would have failed the turn loudly. Cleanliness pins the wire.
     // stream would have failed the turn loudly. Cleanliness pins the wire.
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'session.jsonl', 'ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md',
+      'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+    ])
   })
   })
 })
 })

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

@@ -408,6 +408,36 @@ describe('web e2e: settings modal and General preferences', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
   }, 90_000)
 
 
+  it('persists the completed-Turn transcript mode across reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
+    await dialog.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+    await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-chat:\n\s+transcriptView: normal/)
+    await page.keyboard.press('Escape')
+
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const reloaded = page.getByRole('dialog', { name: '设置' })
+    await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+
+    await reloaded.getByRole('button', { name: 'Normal', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
+    await reloaded.getByRole('button', { name: 'Compact', exact: true }).waitFor({ timeout: 10_000 })
+    await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-chat:\n\s+transcriptView: compact/)
+    await page.keyboard.press('Escape')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 90_000)
+
   it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
   it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
     await page.getByRole('button', { name: '设置', exact: true }).click()
     await page.getByRole('button', { name: '设置', exact: true }).click()

+ 4 - 2
apps/web/tests/skill-tool-row.e2e.ts

@@ -10,7 +10,7 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.jsonl', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url))
@@ -42,7 +42,9 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
     const sessionRow = page.locator('[role="treeitem"]').nth(1)
     const sessionRow = page.locator('[role="treeitem"]').nth(1)
     await sessionRow.waitFor({ timeout: 10_000 })
     await sessionRow.waitFor({ timeout: 10_000 })
     await sessionRow.click()
     await sessionRow.click()
-    await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
+    const skillRow = page.locator('[data-tool="skill"]')
+    await expandOwningTurnProcess(page, skillRow)
+    await skillRow.waitFor({ timeout: 15_000 })
   }, 120_000)
   }, 120_000)
 
 
   afterAll(async () => {
   afterAll(async () => {

+ 20 - 9
apps/web/tests/skill-user-invoke.e2e.ts

@@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import {
 import {
   assertFixtureInventory,
   assertFixtureInventory,
+  captureExpandedTurnProcessAria,
   captureStableAria,
   captureStableAria,
   compareOrRefreshGolden,
   compareOrRefreshGolden,
   launchWebScaffold,
   launchWebScaffold,
@@ -21,10 +22,11 @@ import {
   webSnapshotMode,
   webSnapshotMode,
   type WebScaffold,
   type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 const SKILL_NAME = 'user-invoke-demo'
 const SKILL_NAME = 'user-invoke-demo'
@@ -120,10 +122,16 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
     expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
 
 
     // The rendered body arrives as a context-injection row named after the
     // The rendered body arrives as a context-injection row named after the
-    // skill; expanding it reveals the canonical <skill_content> block, and
-    // the user's text is NOT folded into it.
+    // skill. Context plus the final answer contributes no summary count, so
+    // the Turn uses the fallback title while the row's own disclosure remains usable.
+    const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME })
+    await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 })
+    await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
+    await settled
+    const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
+    await process.waitFor({ state: 'visible', timeout: 10_000 })
+    await expandOwningTurnProcess(page, injectionFlow)
     const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
     const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
-    await injectionRow.waitFor({ timeout: 15_000 })
     await injectionRow.click()
     await injectionRow.click()
     const injectionBody = page
     const injectionBody = page
       .locator('[data-context-injection-body]')
       .locator('[data-context-injection-body]')
@@ -133,18 +141,21 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(injected).toContain('Reply with the fixture acknowledgement line.')
     expect(injected).toContain('Reply with the fixture acknowledgement line.')
     expect(injected).not.toContain(ARGS_TEXT)
     expect(injected).not.toContain(ARGS_TEXT)
     await injectionRow.click()
     await injectionRow.click()
-
-    // The injection started a turn; the replay adapter answers it.
-    await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
-    await settled
+    await process.click()
 
 
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
   }, 60_000)
 
 
   it('keeps its snapshot inventory closed', async () => {
   it('keeps its snapshot inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
   })
   })
 })
 })

+ 21 - 3
apps/web/tests/steering.e2e.ts

@@ -11,7 +11,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -24,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // from user/message beside the reply that obeys it.
 // from user/message beside the reply that obeys it.
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
+const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 // The question composer replaces the textarea, so fill → Queue row → Steer
 // The question composer replaces the textarea, so fill → Queue row → Steer
 // starts only after request/context and must finish before the first replay
 // starts only after request/context and must finish before the first replay
@@ -43,6 +45,7 @@ const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl')
 const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json')
 const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json')
 const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md')
 const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md')
 const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md')
 const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md')
+const STEER_ALL_SETTLED_EXPANDED = join(STEER_ALL_DIR, 'settled-expanded.expected.md')
 const STEER_ONE = 'Interjection: include the word BANANA in your final reply.'
 const STEER_ONE = 'Interjection: include the word BANANA in your final reply.'
 const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.'
 const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.'
 
 
@@ -171,12 +174,20 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     // obeying reply, composer takeover gone.
     // obeying reply, composer takeover gone.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
   }, 200_000)
 
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md',
+    ])
   })
   })
 })
 })
 
 
@@ -392,13 +403,20 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     expect(await page.locator('[data-pending-steering]').count()).toBe(0)
     expect(await page.locator('[data-pending-steering]').count()).toBe(0)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE)
     await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(STEER_ALL_SETTLED_EXPANDED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
   }, 200_000)
 
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(STEER_ALL_DIR, [
     await assertFixtureInventory(STEER_ALL_DIR, [
-      'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md',
+      'replay.override.json', 'mid-steer.expected.md',
+      'settled.expected.md', 'settled-expanded.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 12 - 2
apps/web/tests/subagent-conversation.e2e.ts

@@ -11,7 +11,8 @@ import {
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import {
 import {
-  acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden,
   launchWebScaffold, watchConsole,
   launchWebScaffold, watchConsole,
   webSnapshotMode, type WebScaffold,
   webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
@@ -19,6 +20,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 
 const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url))
 const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
+const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath(
+  new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url),
+)
 const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url))
 const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url))
 const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url))
 const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url))
 const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url))
 const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url))
@@ -243,7 +247,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     const warningStart = tripwire.warnings.length
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
     await page.reload({ waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    const catalogButton = page.getByRole('button', { name: /subagents/ })
+    const catalogButton = page.getByRole('button', { name: '3 subagents', exact: true })
     await catalogButton.waitFor({ timeout: 15_000 })
     await catalogButton.waitFor({ timeout: 15_000 })
     await catalogButton.hover()
     await catalogButton.hover()
     const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
     const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
@@ -434,6 +438,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(AVAILABLE_CHILD_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })
   })

+ 30 - 1
apps/web/tests/support.ts

@@ -3,7 +3,7 @@ import { existsSync, mkdirSync } from 'node:fs'
 import { createServer } from 'node:net'
 import { createServer } from 'node:net'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
-import type { Browser, Page } from 'playwright'
+import type { Browser, Locator, Page } from 'playwright'
 
 
 /** The built page under test; `pnpm run test:web` rebuilds it before running. */
 /** The built page under test; `pnpm run test:web` rebuilds it before running. */
 export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
 export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
@@ -31,6 +31,35 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
   return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' })
   return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' })
 }
 }
 
 
+/**
+ * Expand every currently eligible Turn-process group so a Tool-focused
+ * scenario can exercise the original row contract beneath product-default
+ * compact Chat presentation.
+ * @param page - page containing the Chat view.
+ */
+export async function expandTurnProcesses(page: Page): Promise<void> {
+  const controls = page.locator('[data-turn-process]')
+  await controls.first().waitFor({ state: 'visible', timeout: 10_000 })
+  const count = await controls.count()
+  for (let index = 0; index < count; index++) {
+    const control = controls.nth(index)
+    if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
+  }
+}
+
+/**
+ * Expand the Turn-process group containing one possibly hidden descendant.
+ * @param page - page containing the Chat view.
+ * @param target - descendant whose owning Turn process should open.
+ */
+export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
+  const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
+  if (turn === undefined || await target.isVisible()) return
+  const control = page.locator(`[data-turn-process="${turn}"]`)
+  await control.waitFor({ state: 'visible', timeout: 10_000 })
+  if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
+}
+
 /** Fail loud on a stale checkout instead of testing yesterday's bundle. */
 /** Fail loud on a stale checkout instead of testing yesterday's bundle. */
 export function requireDist(): void {
 export function requireDist(): void {
   if (!existsSync(DIST_INDEX)) {
   if (!existsSync(DIST_INDEX)) {

+ 94 - 7
apps/web/tests/turn-tail-actions.e2e.ts

@@ -24,10 +24,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
-// Two goldens for the same message: parked mid-turn, then settled.
+// Three goldens for the same message: parked mid-turn, aborted, and completed.
 const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
 const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md')
 const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md')
+const COMPLETED_EXPECTED = join(SNAPSHOT_DIR, 'completed.expected.md')
+const FOCUSED_EXPECTED = join(SNAPSHOT_DIR, 'focused.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 // The recording must carry text in the SAME assistant message as the tool
 // The recording must carry text in the SAME assistant message as the tool
@@ -60,7 +62,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
   })
   })
 
 
   /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
   /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
-  async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise<void> {
+  async function launch(
+    buildOverride?: (sidecarHome: string) => ReplayOverrideDoc,
+    paceMs?: number,
+  ): Promise<void> {
     sessionEvents = []
     sessionEvents = []
     let overridePath: string | undefined
     let overridePath: string | undefined
     if (buildOverride !== undefined) {
     if (buildOverride !== undefined) {
@@ -75,6 +80,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
           replayFixture: FIXTURE,
           replayFixture: FIXTURE,
           ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
           ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
           compareReplaySession: overridePath === undefined,
           compareReplaySession: overridePath === undefined,
+          ...(paceMs === undefined ? {} : { paceMs }),
         },
         },
     )
     )
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
@@ -122,13 +128,13 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions'))
     // The barrier is armed before the park and awaited only after the stop
     // The barrier is armed before the park and awaited only after the stop
     // click, so its budget must cover the whole parked phase: marker poll,
     // click, so its budget must cover the whole parked phase: marker poll,
-    // three UI polls, and two captures with their stability windows. The
+    // live-state polls, and two captures with their stability windows. The
     // replay default (30s) leaves no headroom on a slow runner.
     // replay default (30s) leaves no headroom on a slow runner.
     const { settled } = await sendPrompt(120_000)
     const { settled } = await sendPrompt(120_000)
     // The marker IS the synchronization: the second call is provably parked,
     // The marker IS the synchronization: the second call is provably parked,
     // so the first step's message and tool result are already durable.
     // so the first step's message and tool result are already durable.
     await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
     await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
-    await expect.poll(() => page.getByText(NARRATION, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    expect(await page.locator('[data-turn-process]').count()).toBe(0)
     await expect.poll(
     await expect.poll(
       () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
       () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
       { timeout: 10_000 },
       { timeout: 10_000 },
@@ -148,6 +154,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await page.getByRole('button', { name: 'Stop generating' }).click()
     await page.getByRole('button', { name: 'Stop generating' }).click()
     await settled
     await settled
     expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted'])
     expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted'])
+    await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 })
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
     await copyButtons.last().focus()
     await copyButtons.last().focus()
@@ -182,9 +189,89 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
   }, 120_000)
 
 
+  it.skipIf(MODE === 'record')('folds the Turn process after the completed reply becomes the answer', async () => {
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-completed'))
+    const { settled } = await sendPrompt()
+    await settled
+    await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    const process = page.locator('[data-turn-process]')
+    await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    expect(await process.evaluate(element => getComputedStyle(element).borderBottomWidth)).toBe('1px')
+    const processBottom = await process.evaluate(element =>
+      element.closest<HTMLElement>('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom)
+    const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
+      element.closest<HTMLElement>('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top)
+    expect(answerTop).toBe((processBottom ?? 0) + 8)
+    await process.focus()
+    const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => {
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
+    const { settled } = await sendPrompt()
+    await settled
+    const process = page.locator('[data-turn-process]')
+    const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
+    await process.waitFor({ timeout: 10_000 })
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    expect(await tool.isVisible()).toBe(false)
+
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Settings' })
+    await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
+    await page.keyboard.press('Escape')
+
+    await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0)
+    await tool.waitFor({ state: 'visible', timeout: 10_000 })
+    await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-chat:\n\s+transcriptView: normal/)
+
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const restored = page.getByRole('dialog', { name: 'Settings' })
+    await restored.getByRole('button', { name: 'Normal', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
+    await page.keyboard.press('Escape')
+    await process.waitFor({ timeout: 10_000 })
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    expect(await tool.isVisible()).toBe(false)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('keeps a focused process member open when the completed reply arrives', async () => {
+    await launch(undefined, 200)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused'))
+    const { settled } = await sendPrompt()
+    const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
+    await tool.waitFor({ timeout: 30_000 })
+    await tool.focus()
+    expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
+    await settled
+    await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    const process = page.locator('[data-turn-process]')
+    await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
+    expect(await process.getAttribute('aria-expanded')).toBe('true')
+    expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
+    const focused = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(FOCUSED_EXPECTED, focused, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, [
-      'running.expected.md', 'session.jsonl', 'settled.expected.md', 'usage-expanded.expected.md',
-    ])
+    await assertFixtureInventory(
+      SNAPSHOT_DIR,
+      [
+        'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.jsonl',
+        'settled.expected.md', 'usage-expanded.expected.md',
+      ],
+    )
   })
   })
 })
 })

+ 6 - 2
apps/web/tests/web-search-round.e2e.ts

@@ -16,7 +16,7 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.jsonl', import.meta.url))
@@ -263,7 +263,9 @@ describe('web e2e: shipped default web search', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
     await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
     await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
       .toBeGreaterThanOrEqual(1)
       .toBeGreaterThanOrEqual(1)
-    await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
+    const searchTool = page.locator('[data-tool="web_search"]')
+    await expandOwningTurnProcess(page, searchTool)
+    await searchTool.waitFor({ timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
   })
@@ -271,6 +273,7 @@ describe('web e2e: shipped default web search', () => {
   it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => {
   it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll'))
     const row = page.locator('[data-tool="web_search"] [data-expandable]').first()
     const row = page.locator('[data-tool="web_search"] [data-expandable]').first()
+    await expandOwningTurnProcess(page, row)
     await row.click()
     await row.click()
     await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
     await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
 
 
@@ -299,6 +302,7 @@ describe('web e2e: shipped default web search', () => {
 
 
   it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => {
   it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room'))
+    await expandOwningTurnProcess(page, page.locator('[data-tool="web_search"]'))
     // `overflow-y: auto` clips inline-start overflow with no way to scroll it
     // `overflow-y: auto` clips inline-start overflow with no way to scroll it
     // back, and markers are right-aligned to the content edge, so a marker wider
     // back, and markers are right-aligned to the content edge, so a marker wider
     // than `padding-left` silently loses its leading digits. `searchMaxResults`
     // than `padding-left` silently loses its leading digits. `searchMaxResults`

+ 3 - 1
apps/web/tests/workflow-run.e2e.ts

@@ -15,7 +15,7 @@ import {
   type WebScaffold,
   type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import {
 import {
-  connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot,
+  connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
 } from './support.ts'
 } from './support.ts'
 
 
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
@@ -161,6 +161,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     const sessions = page.getByRole('tree', { name: 'Sessions' })
     const sessions = page.getByRole('tree', { name: 'Sessions' })
     await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
     await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
     await settled
     await settled
+    await expandTurnProcesses(page)
     await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
     await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
 
 
     expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
     expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
@@ -186,6 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
     await page.reload({ waitUntil: 'load' })
     await page.reload({ waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await expandTurnProcesses(page)
     const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
     const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
     await workflow.waitFor({ timeout: 15_000 })
     await workflow.waitFor({ timeout: 15_000 })
     expect(await workflow.getAttribute('aria-expanded')).toBe('false')
     expect(await workflow.getAttribute('aria-expanded')).toBe('false')

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 1e7e6e39d307a9e72b5d57420bde99f51063f64d
-capability-seams.zh.md: e33a1e6da7f71838c48b961f93389ba1a089f57f
+capability-seams.md: 3886ca582ea934c51fc20dfec01fd9f2af829597
+capability-seams.zh.md: a79b93bb8d6fff36e0828dbba8f7e20b885bcbfe

+ 4 - 0
docs/capability-seams.md

@@ -177,6 +177,8 @@ flowchart LR
   svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
   svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
   pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
   pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
   pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
   pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
+  pkg_inspector["inspector"]
+  svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
   pkg_jobs["jobs"]
   pkg_jobs["jobs"]
   svc_jobs["ctx.jobs<br/>Background job registry"]
   svc_jobs["ctx.jobs<br/>Background job registry"]
   pkg_jobs_local["jobs-local"]
   pkg_jobs_local["jobs-local"]
@@ -256,6 +258,7 @@ flowchart LR
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
   pkg_host_webserver --> svc_webServer
   pkg_host_webserver --> svc_webServer
+  pkg_inspector --> svc_inspector
   pkg_invariants --> svc_invariants
   pkg_invariants --> svc_invariants
   pkg_jobs --> svc_jobs
   pkg_jobs --> svc_jobs
   pkg_jobs_local --> svc_jobs
   pkg_jobs_local --> svc_jobs
@@ -513,6 +516,7 @@ flowchart LR
 | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
 | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
 | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
 | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
 | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. |
 | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. |
+| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
 | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
 | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
 | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
 | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
 | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
 | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |

+ 4 - 0
docs/capability-seams.zh.md

@@ -179,6 +179,8 @@ flowchart LR
   svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
   svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
   pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
   pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
   pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
   pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
+  pkg_inspector["inspector"]
+  svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
   pkg_jobs["jobs"]
   pkg_jobs["jobs"]
   svc_jobs["ctx.jobs<br/>Background job registry"]
   svc_jobs["ctx.jobs<br/>Background job registry"]
   pkg_jobs_local["jobs-local"]
   pkg_jobs_local["jobs-local"]
@@ -258,6 +260,7 @@ flowchart LR
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_browse --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
   pkg_host_directory_picker_native --> svc_directoryPicker
   pkg_host_webserver --> svc_webServer
   pkg_host_webserver --> svc_webServer
+  pkg_inspector --> svc_inspector
   pkg_invariants --> svc_invariants
   pkg_invariants --> svc_invariants
   pkg_jobs --> svc_jobs
   pkg_jobs --> svc_jobs
   pkg_jobs_local --> svc_jobs
   pkg_jobs_local --> svc_jobs
@@ -515,6 +518,7 @@ flowchart LR
 | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
 | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
 | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
 | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
 | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 |
 | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 |
+| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
 | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
 | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
 | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
 | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
 | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |
 | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289
-config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667
+config-catalog.md: 4331c0a5153f32f0e6af5b6ec6fd182ee9b335b4
+config-catalog.zh.md: 54f6ddde10053d422af2c5ebfd88a367597adc89

+ 68 - 0
docs/config-catalog.md

@@ -612,6 +612,74 @@ export interface Config {
 
 
 Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts)
 Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts)
 
 
+<a id="deepseek-aidsh-experimental-inspector"></a>
+
+## `@deepseek-ai/dsh-experimental-inspector`
+
+Requires: `webServer`
+
+```ts config-catalog
+/** Host plugin configuration. Fetch capture is enabled by default. */
+export interface Config extends Omit<InspectorOptions, 'clientOrigins'> {
+  /** Browser origins allowed to open the Client ingest WebSocket. */
+  clientOrigins?: string[]
+}
+
+/** User-facing Host options; every memory and lifecycle bound is configurable. */
+export interface InspectorOptions {
+  /** Loopback address used by the Worker HTTP and WebSocket endpoint. */
+  readonly host?: '127.0.0.1'
+  /** First port to bind; occupied ports advance until one is available. */
+  readonly port?: number
+  /** Additional exact browser origins admitted to the Client ingest socket. */
+  readonly clientOrigins?: readonly string[]
+  /** Whether to observe calls made through the current global fetch function. */
+  readonly captureFetch?: boolean
+  /** Maximum request-body prefix retained for one fetch. */
+  readonly maxRequestBodyBytes?: number
+  /** Maximum response-body prefix retained for one fetch. */
+  readonly maxResponseBodyBytes?: number
+  /** Maximum raw bytes encoded into one body observation. */
+  readonly maxBodyChunkBytes?: number
+  /** Maximum total request and response body bytes retained by the Worker. */
+  readonly maxJournalBytes?: number
+  /** Maximum active and completed fetch requests retained by the Worker. */
+  readonly maxRetainedRequests?: number
+  /** Maximum encoded bytes accepted in one source transport frame. */
+  readonly maxSourceFrameBytes?: number
+  /** Maximum observation records accepted in one source batch. */
+  readonly maxSourceRecordsPerFrame?: number
+  /** Maximum records waiting in one producer queue. */
+  readonly maxQueuedRecords?: number
+  /** Maximum encoded bytes waiting in one producer queue. */
+  readonly maxQueuedBytes?: number
+  /** Maximum time allowed for the Worker to become ready. */
+  readonly startupTimeoutMs?: number
+  /** Grace period before a stopping Worker is terminated. */
+  readonly stopTimeoutMs?: number
+  /** Initial upper bound for randomized Client reconnect delay. */
+  readonly clientReconnectBaseMs?: number
+  /** Maximum upper bound for randomized Client reconnect delay. */
+  readonly clientReconnectMaxMs?: number
+  /** Deadline for one Worker-to-Client Runtime or Sources request. */
+  readonly clientRuntimeTimeoutMs?: number
+  /** Deadline for one non-CDP semantic query. */
+  readonly queryTimeoutMs?: number
+  /** Maximum live object handles retained per Client Runtime session. */
+  readonly maxClientRuntimeObjects?: number
+  /** Maximum descriptors returned by one Client property request. */
+  readonly maxClientRuntimeProperties?: number
+  /** Maximum encoded bytes read for one Client script or source map. */
+  readonly maxClientSourceBytes?: number
+  /** Maximum Context and Fiber nodes retained in one realm snapshot. */
+  readonly maxCordisNodes?: number
+  /** Disconnected Cordis snapshots retained after their live realm closes. */
+  readonly maxDisconnectedCordisTrees?: number
+}
+```
+
+Source: [`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts)
+
 <a id="deepseek-aidsh-experimental-tool-agent-team"></a>
 <a id="deepseek-aidsh-experimental-tool-agent-team"></a>
 
 
 ## `@deepseek-ai/dsh-experimental-tool-agent-team`
 ## `@deepseek-ai/dsh-experimental-tool-agent-team`

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

@@ -614,6 +614,74 @@ export interface Config {
 
 
 来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts)
 来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts)
 
 
+<a id="deepseek-aidsh-experimental-inspector"></a>
+
+## `@deepseek-ai/dsh-experimental-inspector`
+
+需要:`webServer`
+
+```ts config-catalog
+/** Host plugin configuration. Fetch capture is enabled by default. */
+export interface Config extends Omit<InspectorOptions, 'clientOrigins'> {
+  /** Browser origins allowed to open the Client ingest WebSocket. */
+  clientOrigins?: string[]
+}
+
+/** User-facing Host options; every memory and lifecycle bound is configurable. */
+export interface InspectorOptions {
+  /** Loopback address used by the Worker HTTP and WebSocket endpoint. */
+  readonly host?: '127.0.0.1'
+  /** First port to bind; occupied ports advance until one is available. */
+  readonly port?: number
+  /** Additional exact browser origins admitted to the Client ingest socket. */
+  readonly clientOrigins?: readonly string[]
+  /** Whether to observe calls made through the current global fetch function. */
+  readonly captureFetch?: boolean
+  /** Maximum request-body prefix retained for one fetch. */
+  readonly maxRequestBodyBytes?: number
+  /** Maximum response-body prefix retained for one fetch. */
+  readonly maxResponseBodyBytes?: number
+  /** Maximum raw bytes encoded into one body observation. */
+  readonly maxBodyChunkBytes?: number
+  /** Maximum total request and response body bytes retained by the Worker. */
+  readonly maxJournalBytes?: number
+  /** Maximum active and completed fetch requests retained by the Worker. */
+  readonly maxRetainedRequests?: number
+  /** Maximum encoded bytes accepted in one source transport frame. */
+  readonly maxSourceFrameBytes?: number
+  /** Maximum observation records accepted in one source batch. */
+  readonly maxSourceRecordsPerFrame?: number
+  /** Maximum records waiting in one producer queue. */
+  readonly maxQueuedRecords?: number
+  /** Maximum encoded bytes waiting in one producer queue. */
+  readonly maxQueuedBytes?: number
+  /** Maximum time allowed for the Worker to become ready. */
+  readonly startupTimeoutMs?: number
+  /** Grace period before a stopping Worker is terminated. */
+  readonly stopTimeoutMs?: number
+  /** Initial upper bound for randomized Client reconnect delay. */
+  readonly clientReconnectBaseMs?: number
+  /** Maximum upper bound for randomized Client reconnect delay. */
+  readonly clientReconnectMaxMs?: number
+  /** Deadline for one Worker-to-Client Runtime or Sources request. */
+  readonly clientRuntimeTimeoutMs?: number
+  /** Deadline for one non-CDP semantic query. */
+  readonly queryTimeoutMs?: number
+  /** Maximum live object handles retained per Client Runtime session. */
+  readonly maxClientRuntimeObjects?: number
+  /** Maximum descriptors returned by one Client property request. */
+  readonly maxClientRuntimeProperties?: number
+  /** Maximum encoded bytes read for one Client script or source map. */
+  readonly maxClientSourceBytes?: number
+  /** Maximum Context and Fiber nodes retained in one realm snapshot. */
+  readonly maxCordisNodes?: number
+  /** Disconnected Cordis snapshots retained after their live realm closes. */
+  readonly maxDisconnectedCordisTrees?: number
+}
+```
+
+来源:[`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts)
+
 <a id="deepseek-aidsh-experimental-tool-agent-team"></a>
 <a id="deepseek-aidsh-experimental-tool-agent-team"></a>
 
 
 ## `@deepseek-ai/dsh-experimental-tool-agent-team`
 ## `@deepseek-ai/dsh-experimental-tool-agent-team`

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 6869ef2929d691165b77d04fba215f0ac7add621
-event-producer-consumer.zh.md: 45476a6f8497df97c9a09a7d7554c68b4525424f
+event-producer-consumer.md: 0c3641501afac2e65f2fc6c9ac9670eb082b0bbf
+event-producer-consumer.zh.md: 63a77b1b7ec7e983fe64a1c64baecca78d729ff8

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

@@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -78,8 +78,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event string | Dispatchers | Listeners |
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | --- | --- | --- |
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
-| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
+| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
-| `internal/status` | - | [`agent`](../packages/core/agent) |
+| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
 
 
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
 Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.

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

@@ -67,7 +67,7 @@
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -80,8 +80,8 @@
 | 事件字符串 | 派发方 | 监听方 |
 | 事件字符串 | 派发方 | 监听方 |
 | --- | --- | --- |
 | --- | --- | --- |
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) |
-| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
+| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
-| `internal/status` | - | [`agent`](../packages/core/agent) |
+| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
 
 
 维护模式:生成内容。Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析。
 维护模式:生成内容。Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析。

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 80aa13ec1818f2e46814f24c392b20550d9d6614
-module-graph.zh.md: cac842a13ec0efb9d1b60c18b9212851b892155b
+module-graph.md: c3b6348a15870842282f893f8e056e5928a69fd5
+module-graph.zh.md: 17e2f4b19a7cf059464388884b9b82f241f78ac1

+ 8 - 1
docs/module-graph.md

@@ -209,6 +209,7 @@ flowchart TD
     pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
     pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
     pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
     pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
     pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
     pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
+    pkg_experimental_inspector["experimental-inspector"]
     pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
     pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
     pkg_experimental_webworker_packer["experimental-webworker-packer"]
     pkg_experimental_webworker_packer["experimental-webworker-packer"]
     pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
     pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
@@ -437,6 +438,9 @@ flowchart TD
   pkg_credentials_local --> pkg_home_paths
   pkg_credentials_local --> pkg_home_paths
   pkg_credentials_local --> pkg_invariants
   pkg_credentials_local --> pkg_invariants
   pkg_credentials_local --> pkg_launch_environment
   pkg_credentials_local --> pkg_launch_environment
+  pkg_experimental_inspector --> pkg_client_modules
+  pkg_experimental_inspector --> pkg_host_webserver
+  pkg_experimental_inspector --> pkg_invariants
   pkg_session --> pkg_brand
   pkg_session --> pkg_brand
   pkg_session --> pkg_invariants
   pkg_session --> pkg_invariants
   pkg_session --> pkg_llm
   pkg_session --> pkg_llm
@@ -1526,6 +1530,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_client_ui_layout
   pkg_client_ui_chat --> pkg_client_ui_layout
   pkg_client_ui_chat --> pkg_client_ui_renderer
   pkg_client_ui_chat --> pkg_client_ui_renderer
   pkg_client_ui_chat --> pkg_client_ui_session
   pkg_client_ui_chat --> pkg_client_ui_session
+  pkg_client_ui_chat --> pkg_client_ui_settings
   pkg_client_ui_chat --> pkg_client_ui_workspace
   pkg_client_ui_chat --> pkg_client_ui_workspace
   pkg_client_ui_chat --> pkg_commands
   pkg_client_ui_chat --> pkg_commands
   pkg_client_ui_chat --> pkg_compaction
   pkg_client_ui_chat --> pkg_compaction
@@ -1534,6 +1539,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_llm_retry
   pkg_client_ui_chat --> pkg_llm_retry
   pkg_client_ui_chat --> pkg_session
   pkg_client_ui_chat --> pkg_session
   pkg_client_ui_chat --> pkg_session_stats
   pkg_client_ui_chat --> pkg_session_stats
+  pkg_client_ui_chat --> pkg_settings
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
   pkg_client_ui_chat --> pkg_tools
   pkg_client_ui_chat --> pkg_util_workspace_path
   pkg_client_ui_chat --> pkg_util_workspace_path
@@ -1752,6 +1758,7 @@ flowchart TD
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
+| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
@@ -1927,7 +1934,7 @@ flowchart TD
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
 | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |

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

@@ -211,6 +211,7 @@ flowchart TD
     pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
     pkg_experimental_agent_team_profile["experimental-agent-team-profile"]
     pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
     pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"]
     pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
     pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
+    pkg_experimental_inspector["experimental-inspector"]
     pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
     pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
     pkg_experimental_webworker_packer["experimental-webworker-packer"]
     pkg_experimental_webworker_packer["experimental-webworker-packer"]
     pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
     pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
@@ -439,6 +440,9 @@ flowchart TD
   pkg_credentials_local --> pkg_home_paths
   pkg_credentials_local --> pkg_home_paths
   pkg_credentials_local --> pkg_invariants
   pkg_credentials_local --> pkg_invariants
   pkg_credentials_local --> pkg_launch_environment
   pkg_credentials_local --> pkg_launch_environment
+  pkg_experimental_inspector --> pkg_client_modules
+  pkg_experimental_inspector --> pkg_host_webserver
+  pkg_experimental_inspector --> pkg_invariants
   pkg_session --> pkg_brand
   pkg_session --> pkg_brand
   pkg_session --> pkg_invariants
   pkg_session --> pkg_invariants
   pkg_session --> pkg_llm
   pkg_session --> pkg_llm
@@ -1528,6 +1532,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_client_ui_layout
   pkg_client_ui_chat --> pkg_client_ui_layout
   pkg_client_ui_chat --> pkg_client_ui_renderer
   pkg_client_ui_chat --> pkg_client_ui_renderer
   pkg_client_ui_chat --> pkg_client_ui_session
   pkg_client_ui_chat --> pkg_client_ui_session
+  pkg_client_ui_chat --> pkg_client_ui_settings
   pkg_client_ui_chat --> pkg_client_ui_workspace
   pkg_client_ui_chat --> pkg_client_ui_workspace
   pkg_client_ui_chat --> pkg_commands
   pkg_client_ui_chat --> pkg_commands
   pkg_client_ui_chat --> pkg_compaction
   pkg_client_ui_chat --> pkg_compaction
@@ -1536,6 +1541,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_llm_retry
   pkg_client_ui_chat --> pkg_llm_retry
   pkg_client_ui_chat --> pkg_session
   pkg_client_ui_chat --> pkg_session
   pkg_client_ui_chat --> pkg_session_stats
   pkg_client_ui_chat --> pkg_session_stats
+  pkg_client_ui_chat --> pkg_settings
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
   pkg_client_ui_chat --> pkg_tools
   pkg_client_ui_chat --> pkg_util_workspace_path
   pkg_client_ui_chat --> pkg_util_workspace_path
@@ -1697,7 +1703,7 @@ flowchart TD
   pkg_client_ui_cordis --> pkg_invariants
   pkg_client_ui_cordis --> pkg_invariants
 ```
 ```
 
 
-| Package | Group | Depends on |
+| 包 | 分组 | 依赖 |
 | --- | --- | --- |
 | --- | --- | --- |
 | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
 | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
 | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1754,6 +1760,7 @@ flowchart TD
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
+| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
@@ -1929,7 +1936,7 @@ flowchart TD
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
 | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/extensions.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/extensions.md
-extensions.md: 0418afc7f1b6b6cd892deb618a4f346f6cde720d
-extensions.zh.md: f2d86add9f1b62913fcc1b89abf1fc1a1d2a178f
+extensions.md: 540f3c477b4e128b0c1062192185e6276c9e9263
+extensions.zh.md: ebfe7827484cea2cf8c6d77ca26796f7751d203a

+ 18 - 0
docs/subsystems/extensions.md

@@ -256,6 +256,24 @@ Types: [Agent](core.md)
 
 
 Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts)
 Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts)
 
 
+<a id="ctxinspector--inspectorservice"></a>
+
+### `ctx.inspector` — `InspectorService`
+
+Shared Host/Client service façade over the realm's source publisher.
+
+```ts cordis-catalog
+/**
+ * Publish one JSON observation without waiting for Worker delivery.
+ * @param topic - Domain-owned topic name.
+ * @param payload - JSON value validated before it reaches the carrier.
+ * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
+ */
+publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
+```
+
+Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts)
+
 <a id="cordis-events"></a>
 <a id="cordis-events"></a>
 
 
 ### `cordis/*` events
 ### `cordis/*` events

+ 18 - 0
docs/subsystems/extensions.zh.md

@@ -256,6 +256,24 @@ Types: [Agent](core.zh.md)
 
 
 Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts)
 Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts)
 
 
+<a id="ctxinspector--inspectorservice"></a>
+
+### `ctx.inspector` — `InspectorService`
+
+Shared Host/Client service façade over the realm's source publisher.
+
+```ts cordis-catalog
+/**
+ * Publish one JSON observation without waiting for Worker delivery.
+ * @param topic - Domain-owned topic name.
+ * @param payload - JSON value validated before it reaches the carrier.
+ * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`.
+ */
+publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void
+```
+
+Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts)
+
 <a id="cordis-events"></a>
 <a id="cordis-events"></a>
 
 
 ### `cordis/*` events
 ### `cordis/*` events

+ 9 - 0
knip.json

@@ -45,6 +45,15 @@
         "@deepseek-ai/dsh-client-ui-directory-picker-native"
         "@deepseek-ai/dsh-client-ui-directory-picker-native"
       ]
       ]
     },
     },
+    "packages/experimental/inspector": {
+      "entry": [
+        "tests/**/*.e2e.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ]
+    },
     "packages/extensions/cordis-host-runner": {
     "packages/extensions/cordis-host-runner": {
       "entry": [
       "entry": [
         "tests/**/*.spec.ts"
         "tests/**/*.spec.ts"

+ 1 - 0
package.json

@@ -147,6 +147,7 @@
     "release:publish": "tsx scripts/release/publish.ts",
     "release:publish": "tsx scripts/release/publish.ts",
     "dsh": "node --import tsx/esm apps/cli/src/bin.ts",
     "dsh": "node --import tsx/esm apps/cli/src/bin.ts",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
     "demo:code-mode": "node scripts/demo-code-mode.mjs",
+    "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.patch.yml",
     "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
     "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts",
     "dev:web": "tsx scripts/dev-web.ts --poll",
     "dev:web": "tsx scripts/dev-web.ts --poll",
     "postinstall": "node scripts/install-lefthook.mjs"
     "postinstall": "node scripts/install-lefthook.mjs"

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 5e365593b054c6826de817e3e0d83734b1c8410d
-README.zh.md: 0f1569ab877acd465f41baedb9a626e42eb29983
+README.md: 4f5e969197453ee030e2deb8a09f36d3327b5ed0
+README.zh.md: 47ed92366f6c9e0c5b54c8d5095d30c8561bad47

File diff suppressed because it is too large
+ 4 - 0
packages/client/ui-chat/README.md


File diff suppressed because it is too large
+ 4 - 0
packages/client/ui-chat/README.zh.md


+ 8 - 0
packages/client/ui-chat/package.json

@@ -39,6 +39,7 @@
         "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-session",
         "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-client-ui-workspace"
         "@deepseek-ai/dsh-client-ui-workspace"
       ],
       ],
       "platform": "web"
       "platform": "web"
@@ -62,6 +63,7 @@
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
@@ -70,6 +72,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
@@ -89,6 +92,7 @@
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
@@ -98,12 +102,16 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react": "~18.3.1",
     "react": "^18.2.0"
     "react": "^18.2.0"
   },
   },
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^"
+  },
   "files": [
   "files": [
     "lib/index.js",
     "lib/index.js",
     "lib/invariant.js",
     "lib/invariant.js",

+ 29 - 0
packages/client/ui-chat/src/chat-settings.ts

@@ -0,0 +1,29 @@
+/** Chat transcript preferences stored in the Host user-settings document. */
+
+import z from '@deepseek-ai/schemastery'
+
+/** Settings namespace owned by the Chat target. */
+export const CHAT_SETTINGS_NAMESPACE = 'ui-chat'
+
+/** Field carrying the completed-Turn transcript presentation mode. */
+export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
+
+/** Transcript presentation modes accepted at settings boundaries. */
+export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const
+
+/** Completed-Turn transcript presentation. */
+export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
+
+/** Default preserves the compact process disclosure introduced by Chat. */
+export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
+
+/** Durable Chat section shared by the Host schema and browser scope. */
+export interface ChatSettings {
+  /** Presentation mode for completed Turn process content. */
+  transcriptView: TranscriptViewMode
+}
+
+/** Durable Chat schema; also the wire envelope the browser scope validates against. */
+export const ChatSettingsSchema: z<ChatSettings> = z.object({
+  [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
+})

+ 20 - 1
packages/client/ui-chat/src/client/apply.ts

@@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import type {
 import type {
   ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected,
   ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected,
@@ -25,7 +26,10 @@ import { StatsLine } from './chat/StatsLine.tsx'
 import { registerConversationNodes } from './conversation-nodes/register.ts'
 import { registerConversationNodes } from './conversation-nodes/register.ts'
 import { DetailsPanel } from './details/DetailsPanel.tsx'
 import { DetailsPanel } from './details/DetailsPanel.tsx'
 import { en, NS, zh } from './locale.ts'
 import { en, NS, zh } from './locale.ts'
+import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
 import { createChatStore } from './stores.ts'
 import { createChatStore } from './stores.ts'
+import { TranscriptViewPolicy } from './transcript-view.ts'
+import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
 
 
 const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
   hooks: {
   hooks: {
@@ -42,7 +46,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 
 
 /** Services required by the Chat target and its presentation registrations. */
 /** Services required by the Chat target and its presentation registrations. */
 export const inject = [
 export const inject = [
-  'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale',
+  'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', 'settingsScope',
 ]
 ]
 
 
 /**
 /**
@@ -74,6 +78,20 @@ export function apply(ctx: Context): void {
   const t = ctx.locale.bind(NS)
   const t = ctx.locale.bind(NS)
   const chatStore = createChatStore()
   const chatStore = createChatStore()
   const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
   const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
+  const transcriptView = new TranscriptViewPolicy(
+    ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE }),
+  )
+
+  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
+    name: 'settings.general.item',
+    id: 'transcript-view',
+    order: 12,
+    locale: NS,
+    inject: (): TranscriptViewRowInjected => ({
+      hooks: { transcriptView: transcriptView.mode },
+      setTranscriptView: (mode) => { transcriptView.setMode(mode) },
+    }),
+  }, TranscriptViewRow))
 
 
   ctx.slots.inject('conversation.view', () => {
   ctx.slots.inject('conversation.view', () => {
     const disposeView = ctx.slots.register({
     const disposeView = ctx.slots.register({
@@ -91,6 +109,7 @@ export function apply(ctx: Context): void {
         const session = ctx.sessions.binding(sessionId)?.session
         const session = ctx.sessions.binding(sessionId)?.session
         if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
         if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
         return {
         return {
+          hooks: { transcriptView: transcriptView.mode },
           openDetails: (target) => {
           openDetails: (target) => {
             actions.select(target)
             actions.select(target)
             ctx.layout.openDetails()
             ctx.layout.openDetails()

+ 7 - 0
packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css

@@ -42,6 +42,13 @@
   padding-left: var(--dsh-table-lead);
   padding-left: var(--dsh-table-lead);
 }
 }
 
 
+/* hidden="until-found" keeps a zero-height reasoning box in flex layout.
+   Cancel the one gap that box would otherwise leave before the visible reply;
+   visible Assistant blocks retain the ordinary 16px rhythm. */
+.body > [data-turn-process-inline][hidden] {
+  margin-bottom: -16px;
+}
+
 /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
 /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
    size like the small/code token variants — 11px is dense secondary text
    size like the small/code token variants — 11px is dense secondary text
    that would fall to an illegible 9px at the 12px floor. */
    that would fall to an illegible 9px at the 12px floor. */

+ 27 - 2
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -6,6 +6,7 @@ import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts
 import type { AssistantBlock } from '../contract/snapshot.ts'
 import type { AssistantBlock } from '../contract/snapshot.ts'
 import { markdownLabels } from '../markdown-labels.ts'
 import { markdownLabels } from '../markdown-labels.ts'
 import { ReasoningRow } from './ReasoningRow.tsx'
 import { ReasoningRow } from './ReasoningRow.tsx'
+import { useSearchableHidden } from './searchable-hidden.ts'
 import css from './AssistantMarkdown.module.css'
 import css from './AssistantMarkdown.module.css'
 
 
 export interface AssistantMarkdownProps {
 export interface AssistantMarkdownProps {
@@ -15,6 +16,10 @@ export interface AssistantMarkdownProps {
   interrupted?: boolean | undefined
   interrupted?: boolean | undefined
   /** Render consecutive image blocks through the attachment slot. */
   /** Render consecutive image blocks through the attachment slot. */
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
+  /** Hide reasoning that belongs to the Turn-level process disclosure. */
+  reasoningHidden?: boolean | undefined
+  /** Reveal the owning Turn-level process disclosure. */
+  revealProcess?: (() => void) | undefined
   /** Resolved prose file mentions for this Assistant's closing turn. */
   /** Resolved prose file mentions for this Assistant's closing turn. */
   mentions?: MarkdownFileMentions | undefined
   mentions?: MarkdownFileMentions | undefined
   /** The owning view's locale seat, passed down as a plain prop. */
   /** The owning view's locale seat, passed down as a plain prop. */
@@ -23,7 +28,8 @@ export interface AssistantMarkdownProps {
 
 
 /** Reasoning block as the Think variant summary row (figma 39:28304). */
 /** Reasoning block as the Think variant summary row (figma 39:28304). */
 export const AssistantMarkdown = memo(function AssistantMarkdown({
 export const AssistantMarkdown = memo(function AssistantMarkdown({
-  blocks, streaming, interrupted, renderMessageImages, mentions, t,
+  blocks, streaming, interrupted, renderMessageImages,
+  reasoningHidden = false, revealProcess, mentions, t,
 }: AssistantMarkdownProps) {
 }: AssistantMarkdownProps) {
   // Stable per locale revision (t identity changes on switch): a fresh object
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
   // per render would rebuild MarkdownText's component table every chunk.
@@ -53,7 +59,15 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
         )
         )
         break
         break
       case 'reasoning':
       case 'reasoning':
-        rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
+        rendered.push(
+          <ProcessReasoning
+            key={i}
+            hidden={reasoningHidden}
+            reveal={revealProcess}
+          >
+            <ReasoningRow text={block.text} running={streaming && i === last} t={t} />
+          </ProcessReasoning>,
+        )
         break
         break
       case 'image': {
       case 'image': {
         // Consecutive image blocks share one gallery so several images tile
         // Consecutive image blocks share one gallery so several images tile
@@ -102,3 +116,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
     </div>
     </div>
   )
   )
 })
 })
+
+function ProcessReasoning({ hidden, reveal, children }: {
+  hidden: boolean
+  reveal?: (() => void) | undefined
+  children: ReactNode
+}) {
+  const ref = useSearchableHidden(hidden, reveal ?? NOOP)
+  return <div ref={ref} data-turn-process-inline={hidden || undefined}>{children}</div>
+}
+
+const NOOP = (): void => {}

+ 10 - 2
packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx

@@ -1,10 +1,10 @@
-import { memo, useMemo } from 'react'
+import { memo, useCallback, useMemo } from 'react'
 import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
 import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
 import { AssistantMarkdown } from './AssistantMarkdown.tsx'
 import { AssistantMarkdown } from './AssistantMarkdown.tsx'
 
 
 /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
 /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
 export const AssistantNodeView = memo(function AssistantNodeView({
 export const AssistantNodeView = memo(function AssistantNodeView({
-  node, useTurnData, openFile, renderMessageImages, fileMentions, t,
+  node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
 }: ChatNodeViewProps<'assistant-step'>) {
 }: ChatNodeViewProps<'assistant-step'>) {
   const data = node.data
   const data = node.data
   const turn = node.location.kind === 'turn' || node.location.kind === 'step'
   const turn = node.location.kind === 'turn' || node.location.kind === 'step'
@@ -20,12 +20,20 @@ export const AssistantNodeView = memo(function AssistantNodeView({
     () => owner === undefined ? undefined : fileMentions(owner),
     () => owner === undefined ? undefined : fileMentions(owner),
     [fileMentions, owner],
     [fileMentions, owner],
   )
   )
+  const reasoningHidden = turnProcess !== undefined
+    && turnProcess.foldable
+    && turnProcess.spec.answerStep === data.step
+    && turnProcess.spec.inlineReasoning
+    && !turnProcess.open
+  const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
   return (
   return (
     <AssistantMarkdown
     <AssistantMarkdown
       blocks={data.blocks}
       blocks={data.blocks}
       streaming={data.status === 'running'}
       streaming={data.status === 'running'}
       interrupted={data.status === 'interrupted'}
       interrupted={data.status === 'interrupted'}
       renderMessageImages={renderMessageImages}
       renderMessageImages={renderMessageImages}
+      reasoningHidden={reasoningHidden}
+      revealProcess={revealProcess}
       mentions={mentions}
       mentions={mentions}
       t={t}
       t={t}
     />
     />

+ 162 - 5
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -1,12 +1,23 @@
-import { memo, useMemo } from 'react'
+import { memo, useCallback, useMemo } from 'react'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatNode } from '../contract/chat-nodes.ts'
 import type { ChatNode } from '../contract/chat-nodes.ts'
+import type { ChatNodeStore } from '../contract/snapshot.ts'
+import {
+  decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration,
+  type TurnProcessSpec,
+} from '../contract/turn-process.ts'
+import { storedTurnProcessEntry } from '../stores.ts'
+import { useSearchableHidden } from './searchable-hidden.ts'
 import css from './ChatView.module.css'
 import css from './ChatView.module.css'
 
 
 interface ChatNodeSeatProps extends ChatNodeOwnerProps {
 interface ChatNodeSeatProps extends ChatNodeOwnerProps {
   readonly nodeKey: string
   readonly nodeKey: string
+  readonly historyIncomplete: boolean
+  readonly compactTranscript: boolean
   readonly useChat: ChatViewSlotProps['useChat']
   readonly useChat: ChatViewSlotProps['useChat']
+  readonly useStore: ChatViewSlotProps['useStore']
+  readonly actions: ChatViewSlotProps['actions']
   readonly renderSlot: ChatViewSlotProps['renderSlot']
   readonly renderSlot: ChatViewSlotProps['renderSlot']
   readonly t: ChatViewSlotProps['t']
   readonly t: ChatViewSlotProps['t']
 }
 }
@@ -15,13 +26,153 @@ type RoutedChatNodeOwner = {
   [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
   [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
 }[ChatNode['kind']]
 }[ChatNode['kind']]
 
 
-/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
+const EMPTY_PROCESS_KEYS: readonly string[] = []
+
+interface TurnProcessLayout {
+  readonly hasExternalProcess: boolean
+  readonly compactAnswer: boolean
+}
+
+function turnProcessOpeningHumanAnchor(
+  keys: readonly string[],
+  nodes: ChatNodeStore,
+  spec: TurnProcessSpec,
+): number | undefined {
+  let anchor: number | undefined
+  for (const key of keys) {
+    const node = nodes.get(key) as ChatNode | undefined
+    if ((node?.kind === 'user' || node?.kind === 'steering')
+      && node.anchorSeq < spec.controlAnchorSeq) {
+      anchor = Math.min(anchor ?? node.anchorSeq, node.anchorSeq)
+    }
+  }
+  return anchor
+}
+
+/** Derive disclosure facts from one content-revisioned Turn index. */
+function turnProcessLayout(
+  keys: readonly string[],
+  nodes: ChatNodeStore,
+  spec: TurnProcessSpec,
+): TurnProcessLayout {
+  let hasExternalProcess = false
+  let compactAnswer = true
+  const openingHumanAnchor = turnProcessOpeningHumanAnchor(keys, nodes, spec)
+  for (const key of keys) {
+    const node = nodes.get(key) as ChatNode | undefined
+    if (node === undefined || node.kind === 'turn-process') continue
+    if ((node.kind === 'user' || node.kind === 'steering')
+      && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
+      && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
+      compactAnswer = false
+    }
+    if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
+      || node.anchorSeq < spec.processStartSeq
+      || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
+    if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {
+      hasExternalProcess = true
+    }
+  }
+  return { hasExternalProcess, compactAnswer }
+}
+
+/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
 export const ChatNodeSeat = memo(function ChatNodeSeat({
-  nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
-  renderMessageImages, fileMentions, useChat, renderSlot, t,
+  nodeKey, historyIncomplete, compactTranscript,
+  selectedCallId, cwd, openFile, inspectCall, forkAt,
+  renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
 }: ChatNodeSeatProps) {
   const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
   const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
+  const processSignature = useChat((snapshot) => {
+    const current = snapshot.nodes.get(nodeKey)
+    const location = current?.location
+    return location?.kind === 'turn' || location?.kind === 'step'
+      ? location.turn.data.get('turn-process')
+      : undefined
+  })
+  const processSpec = useMemo(
+    () => processSignature === undefined ? undefined : decodeTurnProcess(processSignature),
+    [processSignature],
+  )
+  const nodeStore = useChat(snapshot => snapshot.nodes)
+  const processLayoutKeys = useChat((snapshot) => {
+    if (!compactTranscript || historyIncomplete || processSpec === undefined) return EMPTY_PROCESS_KEYS
+    const current = snapshot.nodes.get(nodeKey) as ChatNode | undefined
+    const location = current?.location
+    if (current === undefined
+      || (location?.kind !== 'turn' && location?.kind !== 'step')
+      || location.turn.status !== 'closed'
+      || location.turn.turn !== processSpec.turn) return EMPTY_PROCESS_KEYS
+    const ownsLayout = current.kind === 'turn-process'
+      || (current.kind === 'assistant-step' && current.data.step === processSpec.answerStep)
+    return ownsLayout ? snapshot.locations.getTurn(processSpec.turn) : EMPTY_PROCESS_KEYS
+  })
+  const processLayout = useMemo(
+    () => processSpec === undefined || processLayoutKeys.length === 0
+      ? undefined
+      : turnProcessLayout(processLayoutKeys, nodeStore, processSpec),
+    [nodeStore, processLayoutKeys, processSpec],
+  )
+  const processGeneration = useMemo(
+    () => processSpec === undefined ? undefined : turnProcessGeneration(processSpec),
+    [processSpec],
+  )
+  const storedEntry = useStore(state => processSpec === undefined
+    ? undefined
+    : storedTurnProcessEntry(state, processSpec.turn))
+  const processEntry = storedEntry?.generation === processGeneration ? storedEntry : undefined
+  const processOpen = processEntry !== undefined
+  const setOpen = useCallback((open: boolean) => {
+    if (processGeneration !== undefined && processSpec !== undefined) {
+      actions.setTurnProcessOpen(processSpec.turn, processGeneration, open)
+    }
+  }, [actions, processGeneration, processSpec])
   const routedNode = node as ChatNode | undefined
   const routedNode = node as ChatNode | undefined
+  const sameTurn = routedNode !== undefined
+    && processSpec !== undefined
+    && (routedNode.location.kind === 'turn' || routedNode.location.kind === 'step')
+    && routedNode.location.turn.turn === processSpec.turn
+  const turnClosed = sameTurn
+    && routedNode.location.turn.status === 'closed'
+  const processWindowReady = processSpec !== undefined
+    && compactTranscript
+    && processSpec.answerAnchorSeq !== null
+    && turnClosed
+    && !historyIncomplete
+  const processMember = sameTurn
+    && processWindowReady
+    && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
+    && routedNode.anchorSeq >= processSpec.processStartSeq
+    && routedNode.anchorSeq < processSpec.answerAnchorSeq
+  const processAnswer = sameTurn
+    && processWindowReady
+    && routedNode.kind === 'assistant-step'
+    && routedNode.data.step === processSpec.answerStep
+  const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer
+  const foldable = processWindowReady
+    && (processMember || (ownsDisclosure
+      && ((processLayout?.hasExternalProcess ?? false) || processSpec.inlineReasoning)))
+  const turnProcess = useMemo(() => processGeneration === undefined || processSpec === undefined
+    ? undefined
+    : {
+      spec: processSpec,
+      foldable,
+      open: processOpen,
+      setOpen,
+    }, [
+    foldable, processGeneration, processOpen, processSpec, setOpen,
+  ])
+  const controllerInactive = routedNode?.kind === 'turn-process'
+    && !foldable
+  const compactAnswer = processAnswer
+    && foldable
+    && processLayout?.compactAnswer === true
+    && !processOpen
+  const processHidden = controllerInactive || (foldable && processMember && !processOpen)
+  const revealProcess = useCallback(() => {
+    if (processMember) setOpen(true)
+  }, [processMember, setOpen])
+  const wrapperRef = useSearchableHidden(processHidden, revealProcess)
   const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
   const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
     ? null
     ? null
     : {
     : {
@@ -32,8 +183,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
       forkAt,
       forkAt,
       renderMessageImages,
       renderMessageImages,
       fileMentions,
       fileMentions,
+      turnProcess,
     }, [
     }, [
-    node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions,
+    node, selectedCallId, cwd, openFile, inspectCall, forkAt,
+    renderMessageImages, fileMentions, turnProcess,
   ])
   ])
   if (routedNode === undefined || owner === null) return null
   if (routedNode === undefined || owner === null) return null
   const location = routedNode.location
   const location = routedNode.location
@@ -46,11 +199,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
   const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
   const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
   return (
   return (
     <div
     <div
+      ref={wrapperRef}
       className={css.flowItem}
       className={css.flowItem}
       data-chat-anchor-key={routedNode.key}
       data-chat-anchor-key={routedNode.key}
       data-chat-flow-key={routedNode.key}
       data-chat-flow-key={routedNode.key}
       data-chat-flow-kind={routedNode.kind}
       data-chat-flow-kind={routedNode.kind}
       data-chat-turn={turn}
       data-chat-turn={turn}
+      data-turn-process-member={processMember || undefined}
+      data-turn-process-hidden={processHidden || undefined}
+      data-turn-process-answer={compactAnswer || undefined}
     >
     >
       {renderSlot('conversation.chat.node', routedOwner, {
       {renderSlot('conversation.chat.node', routedOwner, {
         entryKey: routedNode.kind,
         entryKey: routedNode.kind,

+ 14 - 1
packages/client/ui-chat/src/client/chat/ChatView.module.css

@@ -41,7 +41,14 @@
   margin: 0 auto;
   margin: 0 auto;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  gap: 16px;
+}
+
+/* `hidden="until-found"` retains a zero-height box so browser find can reveal
+   its subtree. Direct Chat Node Seats and auxiliary rows share one flow;
+   hidden and empty Seats do not contribute spacing. */
+.column > :not([hidden]):not(.flowItem:empty)
+  ~ :not([hidden]):not(.flowItem:empty) {
+  margin-top: var(--dsh-chat-flow-gap, 16px);
 }
 }
 
 
 /* Settled-flow identity boundary. It is neutral until it becomes the natural
 /* Settled-flow identity boundary. It is neutral until it becomes the natural
@@ -50,6 +57,12 @@
   min-width: 0;
   min-width: 0;
 }
 }
 
 
+/* A closed process reads as one summary immediately followed by its answer.
+   Expanded process rows return to the ordinary 16px rhythm. */
+.flowItem[data-turn-process-answer] {
+  --dsh-chat-flow-gap: 8px;
+}
+
 /* A keyed renderer may intentionally decline its row after dispatch (the
 /* A keyed renderer may intentionally decline its row after dispatch (the
    completed-turn tail does this when it owns neither actions nor extensions).
    completed-turn tail does this when it owns neither actions nor extensions).
    An empty flex item must not consume the column gap. */
    An empty flex item must not consume the column gap. */

+ 16 - 7
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -30,7 +30,7 @@ interface PagingAnchor {
 
 
 /** Find an already-rendered row without interpolating a selector. */
 /** Find an already-rendered row without interpolating a selector. */
 function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
 function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
-  for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
+  for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
     if (row.dataset.chatAnchorKey === key) return row
     if (row.dataset.chatAnchorKey === key) return row
   }
   }
   return null
   return null
@@ -87,7 +87,9 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
       if (row !== null && list.contains(row)) return row
       if (row !== null && list.contains(row)) return row
     }
     }
   }
   }
-  const rows = list.querySelectorAll<HTMLElement>('[data-chat-flow] > [data-chat-flow-key]:not(:empty)')
+  const rows = list.querySelectorAll<HTMLElement>(
+    '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
+  )
   let low = 0
   let low = 0
   let high = rows.length
   let high = rows.length
   while (low < high) {
   while (low < high) {
@@ -154,7 +156,7 @@ function observedRpcIds(
 function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
 function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
   let latest: number | null = null
   let latest: number | null = null
   for (const turn of timeline.turns.values()) {
   for (const turn of timeline.turns.values()) {
-    if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time
+    if (turn.status === 'open') latest = turn.start?.time ?? null
   }
   }
   return latest
   return latest
 }
 }
@@ -200,8 +202,8 @@ function TurnStatus({ startTime, t }: {
  * ordered business Node crosses the keyed renderer seat.
  * ordered business Node crosses the keyed renderer seat.
  */
  */
 export function ChatView({
 export function ChatView({
-  useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
-  fileMentions, t,
+  useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
+  fileMentions, useTranscriptView, t,
 }: ChatViewSlotProps) {
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
   const order = useChat(s => s.order)
   const nodeStore = useChat(s => s.nodes)
   const nodeStore = useChat(s => s.nodes)
@@ -219,6 +221,7 @@ export function ChatView({
   const hasMore = useSession(s => s.hasMore)
   const hasMore = useSession(s => s.hasMore)
   const loadingOlder = useSession(s => s.loadingOlder)
   const loadingOlder = useSession(s => s.loadingOlder)
   const selectedCallId = useStore(s => s.selection?.callId)
   const selectedCallId = useStore(s => s.selection?.callId)
+  const compactTranscript = useTranscriptView(mode => mode === 'compact')
   const inspectCall = useCallback((callId: string) => {
   const inspectCall = useCallback((callId: string) => {
     openView('trajectory', callId)
     openView('trajectory', callId)
   }, [openView])
   }, [openView])
@@ -280,8 +283,10 @@ export function ChatView({
 
 
   const listRef = useRef<HTMLDivElement | null>(null)
   const listRef = useRef<HTMLDivElement | null>(null)
   const columnRef = useRef<HTMLDivElement | null>(null)
   const columnRef = useRef<HTMLDivElement | null>(null)
-  const atBottomRef = useRef(true)
-  const [atBottom, setAtBottom] = useState(true)
+  // A saved position starts disarmed; the first layout effect synchronously
+  // restores it and normalizes a floor-clamped position back to following.
+  const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
+  const atBottomRef = useRef(atBottom)
   const [activeTurn, setActiveTurn] = useState<number | null>(
   const [activeTurn, setActiveTurn] = useState<number | null>(
     () => turnNavigationItems.at(-1)?.turn ?? null,
     () => turnNavigationItems.at(-1)?.turn ?? null,
   )
   )
@@ -593,7 +598,11 @@ export function ChatView({
             <ChatNodeSeat
             <ChatNodeSeat
               key={nodeKey}
               key={nodeKey}
               nodeKey={nodeKey}
               nodeKey={nodeKey}
+              historyIncomplete={hasMore}
+              compactTranscript={compactTranscript}
               useChat={useChat}
               useChat={useChat}
+              useStore={useStore}
+              actions={actions}
               selectedCallId={selectedCallId}
               selectedCallId={selectedCallId}
               cwd={cwd}
               cwd={cwd}
               openFile={requestOpenFile}
               openFile={requestOpenFile}

+ 48 - 0
packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css

@@ -0,0 +1,48 @@
+.root {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  width: 100%;
+  min-width: 0;
+  height: 33px;
+  padding: 0 0 8px;
+  border: none;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+  background: none;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+  text-align: left;
+}
+
+.root:not([data-open]) {
+  margin-bottom: 8px;
+}
+
+.chevron {
+  flex: none;
+  width: 16px;
+  height: 16px;
+  margin-left: 6px;
+  color: var(--dsw-alias-label-tertiary);
+  transform: rotate(-90deg);
+  transition: transform 100ms ease;
+}
+
+.root[data-open] .chevron {
+  transform: rotate(0deg);
+}
+
+.label {
+  min-width: 0;
+  overflow: hidden;
+  font-size: 14px;
+  line-height: 24px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .chevron {
+    transition: none;
+  }
+}

+ 60 - 0
packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx

@@ -0,0 +1,60 @@
+import { memo } from 'react'
+import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ChatNodeViewProps } from '../contract/slots.ts'
+import css from './TurnProcessNodeView.module.css'
+
+/** Turn-level process disclosure controller. */
+export const TurnProcessNodeView = memo(function TurnProcessNodeView({
+  node, turnProcess, t,
+}: ChatNodeViewProps<'turn-process'>) {
+  if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state')
+  if (!turnProcess.foldable) return null
+  const open = turnProcess.open
+  const labels: string[] = []
+  if (node.data.toolCallCount > 0) {
+    labels.push(t(
+      node.data.toolCallCount === 1
+        ? 'message.turnProcess.toolCalls.one'
+        : 'message.turnProcess.toolCalls.other',
+      { count: node.data.toolCallCount },
+    ))
+  }
+  if (node.data.messageCount > 0) {
+    labels.push(t(
+      node.data.messageCount === 1
+        ? 'message.turnProcess.messages.one'
+        : 'message.turnProcess.messages.other',
+      { count: node.data.messageCount },
+    ))
+  }
+  if (node.data.subagentCount > 0) {
+    labels.push(t(
+      node.data.subagentCount === 1
+        ? 'message.turnProcess.subagents.one'
+        : 'message.turnProcess.subagents.other',
+      { count: node.data.subagentCount },
+    ))
+  }
+  const label = labels.length === 0
+    ? t('message.turnProcess.thoughtForAWhile')
+    : labels.join(t('message.turnProcess.separator'))
+  return (
+    <button
+      type="button"
+      className={css.root}
+      data-open={open || undefined}
+      data-turn-process={node.data.turn}
+      data-turn-process-messages={node.data.messageCount}
+      data-turn-process-tool-calls={node.data.toolCallCount}
+      data-turn-process-subagents={node.data.subagentCount}
+      aria-expanded={open}
+      onClick={(event) => {
+        event.currentTarget.focus()
+        turnProcess.setOpen(!open)
+      }}
+    >
+      <span className={css.label}>{label}</span>
+      <IconChevronDownOutline14 className={css.chevron} />
+    </button>
+  )
+})

+ 4 - 1
packages/client/ui-chat/src/client/chat/register-node-renderers.ts

@@ -6,8 +6,9 @@ import {
   CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
   CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
   TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
   TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
 } from './MessageItem.tsx'
 } from './MessageItem.tsx'
-import { TurnTailNodeView } from './TurnTailNodeView.tsx'
 import { SystemPromptNodeView } from './SystemPromptRow.tsx'
 import { SystemPromptNodeView } from './SystemPromptRow.tsx'
+import { TurnProcessNodeView } from './TurnProcessNodeView.tsx'
+import { TurnTailNodeView } from './TurnTailNodeView.tsx'
 
 
 /**
 /**
  * Register this package's business renderers behind the keyed Chat Node seat.
  * Register this package's business renderers behind the keyed Chat Node seat.
@@ -40,6 +41,8 @@ export function registerChatNodeRenderers(ctx: Context): void {
     { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
     { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
     { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
     { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
+  ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
+    { name: 'conversation.chat.node', key: 'turn-process', locale: NS }, TurnProcessNodeView))
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
     name: 'conversation.chat.node',
     name: 'conversation.chat.node',
     key: 'turn-tail',
     key: 'turn-tail',

+ 31 - 0
packages/client/ui-chat/src/client/chat/searchable-hidden.ts

@@ -0,0 +1,31 @@
+import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
+
+/**
+ * Apply searchable hidden state without unmounting a stable subtree.
+ * @param hidden - whether the subtree is currently hidden.
+ * @param reveal - callback for browser find's `beforematch` reveal.
+ * @returns ref for the stable subtree root.
+ */
+export function useSearchableHidden(
+  hidden: boolean,
+  reveal: () => void,
+): RefObject<HTMLDivElement> {
+  const ref = useRef<HTMLDivElement>(null)
+  useLayoutEffect(() => {
+    const element = ref.current
+    if (element === null) return
+    if (hidden && element.contains(element.ownerDocument.activeElement)) {
+      reveal()
+      return
+    }
+    if (hidden) element.setAttribute('hidden', 'until-found')
+    else element.removeAttribute('hidden')
+  }, [hidden, reveal])
+  useEffect(() => {
+    const element = ref.current
+    if (element === null) return
+    element.addEventListener('beforematch', reveal)
+    return () => { element.removeEventListener('beforematch', reveal) }
+  }, [reveal])
+  return ref
+}

+ 15 - 0
packages/client/ui-chat/src/client/contract/assistant-content.ts

@@ -0,0 +1,15 @@
+import type { AssistantBlock } from './snapshot.ts'
+
+/**
+ * Test whether Assistant blocks contain a user-facing reply rather than only
+ * reasoning or Tool-call protocol material.
+ * @param blocks - Assistant content blocks.
+ * @returns whether the blocks contain visible reply content.
+ */
+export function hasAssistantReplyContent(blocks: readonly AssistantBlock[]): boolean {
+  return blocks.some((block) => {
+    if (block.kind === 'reasoning' || block.kind === 'tool-call') return false
+    if (block.kind === 'text') return block.text.trim() !== ''
+    return true
+  })
+}

+ 13 - 0
packages/client/ui-chat/src/client/contract/chat-nodes.ts

@@ -97,6 +97,19 @@ export interface TurnTailChatData {
   readonly tokenUsage?: TurnTokenUsage
   readonly tokenUsage?: TurnTokenUsage
 }
 }
 
 
+/** Turn-level process disclosure projected before the finalized answer. */
+export interface TurnProcessChatData {
+  readonly turn: number
+  readonly controlAnchorSeq: number
+  readonly processStartSeq: number
+  readonly answerAnchorSeq: number | null
+  readonly answerStep: number | null
+  readonly inlineReasoning: boolean
+  readonly messageCount: number
+  readonly toolCallCount: number
+  readonly subagentCount: number
+}
+
 /**
 /**
  * Test whether a Tool root has settled.
  * Test whether a Tool root has settled.
  * @param block - Tool root lifecycle value.
  * @param block - Tool root lifecycle value.

+ 17 - 0
packages/client/ui-chat/src/client/contract/slots.ts

@@ -7,12 +7,15 @@ import type {
   InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
   InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
   SnapshotSelectorHook,
   SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-ui-slots'
 } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { createChatStore } from '../stores.ts'
 import type { createChatStore } from '../stores.ts'
 import type { ToolCallId, SelectionTarget } from './store.ts'
 import type { ToolCallId, SelectionTarget } from './store.ts'
 import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
 import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
 import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
 import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
+import type { TurnProcessSpec } from './turn-process.ts'
+import type { TranscriptViewMode } from '../../chat-settings.ts'
 
 
 /** Selector hook over the current Conversation binding's Chat target. */
 /** Selector hook over the current Conversation binding's Chat target. */
 export type UseChat = SnapshotSelectorHook<ChatSnapshot>
 export type UseChat = SnapshotSelectorHook<ChatSnapshot>
@@ -65,6 +68,16 @@ export interface ChatNodeOwnerProps {
   forkAt: (seq: number) => void
   forkAt: (seq: number) => void
   renderMessageImages: RenderMessageImages
   renderMessageImages: RenderMessageImages
   fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
   fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
+  /** Turn-process state when this Node belongs to a projected Turn. */
+  turnProcess?: TurnProcessOwnerProps | undefined
+}
+
+/** Shared presentation state for one Turn-process answer generation. */
+export interface TurnProcessOwnerProps {
+  readonly spec: TurnProcessSpec
+  readonly foldable: boolean
+  readonly open: boolean
+  setOpen(open: boolean): void
 }
 }
 
 
 /** Full props of one keyed Chat renderer. */
 /** Full props of one keyed Chat renderer. */
@@ -98,6 +111,10 @@ export interface ChatScrollPosition {
 
 
 /** Business callbacks injected into the Chat view. */
 /** Business callbacks injected into the Chat view. */
 export interface ChatViewInjected {
 export interface ChatViewInjected {
+  hooks: {
+    /** Persisted completed-Turn transcript presentation. */
+    transcriptView: SnapshotStore<TranscriptViewMode>
+  }
   openDetails: (target: SelectionTarget) => void
   openDetails: (target: SelectionTarget) => void
   openFile: (path: string) => Promise<void>
   openFile: (path: string) => Promise<void>
   loadOlder: () => void
   loadOlder: () => void

+ 9 - 0
packages/client/ui-chat/src/client/contract/store.ts

@@ -1,5 +1,7 @@
 /** Chat-owned selection state shared by the transcript and details panel. */
 /** Chat-owned selection state shared by the transcript and details panel. */
 
 
+import type { TurnProcessGeneration } from './turn-process.ts'
+
 /** Tool call identity as carried by Chat nodes. */
 /** Tool call identity as carried by Chat nodes. */
 export type ToolCallId = string
 export type ToolCallId = string
 
 
@@ -11,7 +13,14 @@ export interface SelectionTarget {
   toolName?: string
   toolName?: string
 }
 }
 
 
+/** One manually expanded Turn answer generation. */
+export interface TurnProcessViewEntry {
+  readonly turn: number
+  readonly generation: TurnProcessGeneration
+}
+
 /** Per-Session state shared only by the Chat view and details surface. */
 /** Per-Session state shared only by the Chat view and details surface. */
 export interface ChatStoreState {
 export interface ChatStoreState {
   selection: SelectionTarget | null
   selection: SelectionTarget | null
+  turnProcesses: TurnProcessViewEntry[]
 }
 }

+ 100 - 0
packages/client/ui-chat/src/client/contract/turn-process.ts

@@ -0,0 +1,100 @@
+import type { ChatNode } from './chat-nodes.ts'
+
+/** Turn-local process window encoded as a reference-stable Location-data scalar. */
+export type TurnProcessSignature = string
+
+/** Stable identity of one finalized answer generation, independent of its exact ordering anchor. */
+export type TurnProcessGeneration = string
+
+/** Current process range and finalized answer boundary derived from one Turn. */
+export interface TurnProcessSpec {
+  readonly turn: number
+  /** Stable control-node anchor source, including currently ineligible evidence. */
+  readonly controlAnchorSeq: number
+  readonly processStartSeq: number
+  readonly answerAnchorSeq: number | null
+  readonly answerStep: number | null
+  readonly inlineReasoning: boolean
+  /** Reply-bearing durable Assistant messages before the final answer. */
+  readonly messageCount: number
+  /** Durable non-subagent Tool calls recorded by this Turn. */
+  readonly toolCallCount: number
+  /** Tool calls whose configured name identifies a subagent delegation. */
+  readonly subagentCount: number
+}
+
+const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
+  'system-prompt',
+  'user',
+  'steering',
+  'turn-process',
+  'turn-error',
+  'turn-max-tokens',
+  'turn-tail',
+] as const satisfies readonly ChatNode['kind'][]
+
+/** Chat Node kinds that remain independent of a Turn's process disclosure. */
+export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet<string> = new Set(
+  TURN_PROCESS_INDEPENDENT_KIND_LIST,
+)
+
+/**
+ * Identify one finalized answer generation without using its ordering anchor.
+ * @param spec - current Turn process specification.
+ * @returns stable identity until the finalized answer Step is withdrawn or replaced.
+ */
+export function turnProcessGeneration(spec: TurnProcessSpec): TurnProcessGeneration {
+  return `${String(spec.turn)}|${spec.answerStep === null ? '' : String(spec.answerStep)}`
+}
+
+/**
+ * Encode one process specification as a primitive Location-data value.
+ * @param spec - current Turn process specification.
+ * @returns reference-stable scalar for equal specifications.
+ */
+export function encodeTurnProcess(spec: TurnProcessSpec): TurnProcessSignature {
+  return [
+    spec.turn,
+    spec.controlAnchorSeq,
+    spec.processStartSeq,
+    spec.answerAnchorSeq ?? '',
+    spec.answerStep ?? '',
+    spec.inlineReasoning ? 1 : 0,
+    spec.messageCount,
+    spec.toolCallCount,
+    spec.subagentCount,
+  ].join('|')
+}
+
+/**
+ * Decode a same-process signature produced by {@link encodeTurnProcess}.
+ * @param signature - encoded Turn process value.
+ * @returns decoded process specification.
+ */
+export function decodeTurnProcess(signature: TurnProcessSignature): TurnProcessSpec {
+  const [
+    turn, controlAnchorSeq, processStartSeq, answerAnchorSeq, answerStep, inlineReasoning,
+    messageCount, toolCallCount, subagentCount,
+  ] = signature.split('|')
+  return {
+    turn: Number(turn),
+    controlAnchorSeq: Number(controlAnchorSeq),
+    processStartSeq: Number(processStartSeq),
+    answerAnchorSeq: answerAnchorSeq === '' ? null : Number(answerAnchorSeq),
+    answerStep: answerStep === '' ? null : Number(answerStep),
+    inlineReasoning: inlineReasoning === '1',
+    messageCount: Number(messageCount),
+    toolCallCount: Number(toolCallCount),
+    subagentCount: Number(subagentCount),
+  }
+}
+
+/**
+ * Recognize the shipped subagent delegation name and its configured variants.
+ * Control tools use distinct names such as `send_message` and `list_agents`.
+ * @param name - durable Tool-call name.
+ * @returns whether the call creates or forks a subagent.
+ */
+export function isSubagentDelegationTool(name: string): boolean {
+  return name === 'subagent' || name.startsWith('subagent_')
+}

+ 99 - 6
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -9,6 +9,7 @@ import type {
   ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
   ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
   LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
   LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
 } from '../contract/snapshot.ts'
 } from '../contract/snapshot.ts'
+import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
 import { sessionRecallLabels } from './event-projection.ts'
 import { sessionRecallLabels } from './event-projection.ts'
 import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
 import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
 
 
@@ -192,10 +193,101 @@ function locationCoordinates(location: ConversationLocation): { turn?: number; s
   return {}
   return {}
 }
 }
 
 
-function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] {
-  return nodes
-    .filter(node => node.visibility === 'visible')
-    .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
+interface TurnProcessPresentation {
+  readonly control?: ChatNode<'turn-process'>
+  readonly openingHumanAnchor?: number
+  readonly earliestProcessAnchor?: number
+}
+
+function turnProcessPresentations(
+  nodes: readonly ChatConversationViewNode[],
+): ReadonlyMap<number, TurnProcessPresentation> {
+  const presentations = new Map<number, TurnProcessPresentation>()
+  for (const raw of nodes) {
+    const node = raw as ChatNode
+    if (node.kind === 'turn-process') {
+      presentations.set(node.data.turn, { ...presentations.get(node.data.turn), control: node })
+    }
+  }
+  for (const raw of nodes) {
+    const node = raw as ChatNode
+    const location = node.location
+    if (location.kind !== 'turn' && location.kind !== 'step') continue
+    const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
+    if ((node.kind === 'user' || node.kind === 'steering')
+      && node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) {
+      presentations.set(location.turn.turn, {
+        ...current,
+        openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq),
+      })
+      continue
+    }
+    if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue
+    presentations.set(location.turn.turn, {
+      ...current,
+      earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq),
+    })
+  }
+  return presentations
+}
+
+interface PresentationPosition {
+  readonly anchor: number
+  readonly rank: number
+  readonly originalAnchor: number
+}
+
+function presentationPosition(
+  raw: ChatConversationViewNode,
+  presentations: ReadonlyMap<number, TurnProcessPresentation>,
+): PresentationPosition {
+  const node = raw as ChatNode
+  const location = node.location
+  if (location.kind !== 'turn' && location.kind !== 'step') {
+    return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
+  }
+  const presentation = presentations.get(location.turn.turn)
+  if (presentation === undefined) {
+    return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
+  }
+  const openingHumanAnchor = presentation.openingHumanAnchor
+  if (openingHumanAnchor !== undefined
+    && node.anchorSeq < openingHumanAnchor
+    && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
+    return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq }
+  }
+  if (presentation.control !== undefined && node.key === presentation.control.key) {
+    return openingHumanAnchor === undefined
+      ? {
+        anchor: presentation.earliestProcessAnchor ?? node.anchorSeq,
+        rank: -1,
+        originalAnchor: node.anchorSeq,
+      }
+      : { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq }
+  }
+  return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
+}
+
+/**
+ * Order visible Chat Nodes without changing existing relative order as process
+ * eligibility changes. Opening human input precedes process candidates, while
+ * each synthetic process control sits between them.
+ * @param nodes - currently materialized Chat Nodes.
+ * @returns visible Nodes in presentation order.
+ */
+export function orderedVisibleChatNodes(
+  nodes: readonly ChatConversationViewNode[],
+): ChatConversationViewNode[] {
+  const visible = nodes.filter(node => node.visibility === 'visible')
+  const presentations = turnProcessPresentations(visible)
+  return visible.sort((left, right) => {
+    const leftPosition = presentationPosition(left, presentations)
+    const rightPosition = presentationPosition(right, presentations)
+    return leftPosition.anchor - rightPosition.anchor
+      || leftPosition.rank - rightPosition.rank
+      || leftPosition.originalAnchor - rightPosition.originalAnchor
+      || left.key.localeCompare(right.key)
+  })
 }
 }
 
 
 function referenceMessageSeq(node: ChatConversationViewNode): number | undefined {
 function referenceMessageSeq(node: ChatConversationViewNode): number | undefined {
@@ -556,7 +648,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
   }): ChatSnapshot {
   }): ChatSnapshot {
     const nodes = this.referenceLabels.replace(input.nodes)
     const nodes = this.referenceLabels.replace(input.nodes)
     this.store.replace(nodes)
     this.store.replace(nodes)
-    this.order = orderedVisible(nodes).map(node => node.key)
+    this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
     this.locations.rebuild(this.order, this.store)
     this.locations.rebuild(this.order, this.store)
     this.navigation.rebuild(input.timeline, this.locations, this.store)
     this.navigation.rebuild(input.timeline, this.locations, this.store)
     this.timeline = input.timeline
     this.timeline = input.timeline
@@ -573,6 +665,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     for (const node of upserts) {
     for (const node of upserts) {
       const previous = this.store.get(node.key)
       const previous = this.store.get(node.key)
       const nodeStructural = previous === undefined
       const nodeStructural = previous === undefined
+        || previous.kind !== node.kind
         || previous.anchorSeq !== node.anchorSeq
         || previous.anchorSeq !== node.anchorSeq
         || previous.visibility !== node.visibility
         || previous.visibility !== node.visibility
         || locationIdentity(previous.location) !== locationIdentity(node.location)
         || locationIdentity(previous.location) !== locationIdentity(node.location)
@@ -581,7 +674,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     }
     }
     this.store.upsert(upserts)
     this.store.upsert(upserts)
     if (structural) {
     if (structural) {
-      const next = orderedVisible(this.store.values()).map(node => node.key)
+      const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key)
       this.order = sameReferences(this.order, next) ? this.order : next
       this.order = sameReferences(this.order, next) ? this.order : next
       this.locations.rebuild(this.order, this.store)
       this.locations.rebuild(this.order, this.store)
     }
     }

+ 1 - 0
packages/client/ui-chat/src/client/conversation-nodes/common.ts

@@ -14,6 +14,7 @@ import type {
 export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
 export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
   interruptedAssistant: -0.9,
   interruptedAssistant: -0.9,
   interruptedFollowup: -0.8,
   interruptedFollowup: -0.8,
+  processControl: -0.1,
   maxTokensNotice: 0.05,
   maxTokensNotice: 0.05,
   finalizedFollowup: 0.1,
   finalizedFollowup: 0.1,
 } as const
 } as const

+ 2 - 0
packages/client/ui-chat/src/client/conversation-nodes/register.ts

@@ -11,6 +11,7 @@ import { registerRetryConversationNode } from './retry.ts'
 import { registerToolConversationNode } from './tool.ts'
 import { registerToolConversationNode } from './tool.ts'
 import { registerTurnErrorConversationNode } from './turn-error.ts'
 import { registerTurnErrorConversationNode } from './turn-error.ts'
 import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
 import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
+import { registerTurnProcess } from './turn-process.ts'
 import { registerTurnTailConversationNode } from './turn-tail.ts'
 import { registerTurnTailConversationNode } from './turn-tail.ts'
 
 
 /**
 /**
@@ -22,6 +23,7 @@ export function registerConversationNodes(ctx: Context): void {
   registerMessageConversationNode(ctx)
   registerMessageConversationNode(ctx)
   registerRequestPromptConversationNode(ctx)
   registerRequestPromptConversationNode(ctx)
   registerAssistantConversationNode(ctx)
   registerAssistantConversationNode(ctx)
+  registerTurnProcess(ctx)
   registerToolConversationNode(ctx)
   registerToolConversationNode(ctx)
   registerCommandConversationNode(ctx)
   registerCommandConversationNode(ctx)
   registerCompactionConversationNode(ctx)
   registerCompactionConversationNode(ctx)

+ 22 - 3
packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts

@@ -1,7 +1,8 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type {
 import type {
-  ConversationMatch, ConversationNodeDefinition, RequestPromptInspector,
+  ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ChatNode } from '../contract/chat-nodes.ts'
 import { chatNode } from './common.ts'
 import { chatNode } from './common.ts'
 
 
 declare module '../contract/chat-nodes.ts' {
 declare module '../contract/chat-nodes.ts' {
@@ -33,6 +34,19 @@ function requestPromptAnchor(
     : match.location.step.start?.seq ?? match.event.seq
     : match.location.step.start?.seq ?? match.event.seq
 }
 }
 
 
+/** Keep an already rendered prompt at its page-lifetime presentation anchor. */
+function stableRequestPromptAnchor(
+  context: ConversationNodeContext<RequestPromptState>,
+  match: ConversationMatch,
+  previous: Readonly<RequestPromptState> | undefined,
+  isInitial: boolean,
+): number {
+  const current = context.current.get('chat') as ChatNode | null | undefined
+  return current?.kind === 'system-prompt'
+    ? current.anchorSeq
+    : requestPromptAnchor(match, previous, isInitial)
+}
+
 /**
 /**
  * Request-header prompt Definition for the Chat target.
  * Request-header prompt Definition for the Chat target.
  * @param inspect - the shared prompt interpretation, supplied by the
  * @param inspect - the shared prompt interpretation, supplied by the
@@ -46,7 +60,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
     match: event => event.type === 'request/header'
     match: event => event.type === 'request/header'
       ? { id: String(event.seq), role: 'start' }
       ? { id: String(event.seq), role: 'start' }
       : null,
       : null,
-    start: (_context, match, reader) => {
+    start: (context, match, reader) => {
       if (match.event.type !== 'request/header') {
       if (match.event.type !== 'request/header') {
         throw new Error('request-prompt start requires request/header')
         throw new Error('request-prompt start requires request/header')
       }
       }
@@ -57,7 +71,12 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
       const inspection = inspect(previous?.prompt, match.event)
       const inspection = inspect(previous?.prompt, match.event)
       const change = inspection.change?.kind
       const change = inspection.change?.kind
       return {
       return {
-        anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'),
+        anchorSeq: stableRequestPromptAnchor(
+          context,
+          match,
+          previous,
+          match.event.data.reason === 'initial',
+        ),
         showsPrompt: previous === undefined
         showsPrompt: previous === undefined
           || match.event.data.reason !== 'change'
           || match.event.data.reason !== 'change'
           || match.event.data.startsSeries === true
           || match.event.data.startsSeries === true

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