浏览代码

Merge remote-tracking branch 'origin/master' into worktree/code-diff-card

# Conflicts:
#	apps/web/package.json
creatixchu 1 周之前
父节点
当前提交
dce58eaf4e
共有 100 个文件被更改,包括 5324 次插入275 次删除
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.i18n.yaml
  5. 29 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md
  6. 29 0
      .agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  8. 45 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  9. 45 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  10. 3 0
      THIRD_PARTY_NOTICES.md
  11. 1 0
      apps/web/package.json
  12. 2 0
      apps/web/tests/assembled-remote.ts
  13. 5 3
      apps/web/tests/clickable-links-gallery.e2e.ts
  14. 3 0
      apps/web/tests/details-session-lifecycle.e2e.ts
  15. 1 0
      apps/web/tests/document-preview.e2e.ts
  16. 1 0
      apps/web/tests/expected/sidebar-terminal/limit.expected.md
  17. 1 0
      apps/web/tests/expected/sidebar-terminal/running.expected.md
  18. 3 0
      apps/web/tests/expected/sidebar-terminal/selection.expected.md
  19. 5 0
      apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md
  20. 8 0
      apps/web/tests/fixtures/sidebar-terminal.patch.yml
  21. 6 1
      apps/web/tests/lifecycle-chrome.e2e.ts
  22. 43 5
      apps/web/tests/markdown-wide-table.e2e.ts
  23. 22 3
      apps/web/tests/navigation-panes.e2e.ts
  24. 44 1
      apps/web/tests/ptc-round.e2e.ts
  25. 19 9
      apps/web/tests/sidebar-right.e2e.ts
  26. 227 0
      apps/web/tests/sidebar-terminal.e2e.ts
  27. 2 2
      apps/web/tests/turn-tail-actions.e2e.ts
  28. 1 0
      apps/web/tsconfig.json
  29. 2 2
      docs/capability-seams.i18n.yaml
  30. 4 0
      docs/capability-seams.md
  31. 4 0
      docs/capability-seams.zh.md
  32. 2 2
      docs/config-catalog.i18n.yaml
  33. 40 0
      docs/config-catalog.md
  34. 40 0
      docs/config-catalog.zh.md
  35. 2 2
      docs/event-producer-consumer.i18n.yaml
  36. 1 1
      docs/event-producer-consumer.md
  37. 1 1
      docs/event-producer-consumer.zh.md
  38. 2 2
      docs/module-graph.i18n.yaml
  39. 5 0
      docs/module-graph.md
  40. 5 0
      docs/module-graph.zh.md
  41. 2 2
      docs/subsystems/subprocess.i18n.yaml
  42. 10 1
      docs/subsystems/subprocess.md
  43. 10 1
      docs/subsystems/subprocess.zh.md
  44. 2 2
      docs/subsystems/workspace.i18n.yaml
  45. 91 0
      docs/subsystems/workspace.md
  46. 91 0
      docs/subsystems/workspace.zh.md
  47. 2 2
      packages/api/README.i18n.yaml
  48. 1 0
      packages/api/README.md
  49. 1 0
      packages/api/README.zh.md
  50. 2 1
      packages/api/remotes/package.json
  51. 4 1
      packages/api/remotes/src/client/index.ts
  52. 3 0
      packages/api/remotes/tsconfig.client.json
  53. 3 0
      packages/api/remotes/tsconfig.host.json
  54. 6 0
      packages/api/terminal-controller/README.i18n.yaml
  55. 93 0
      packages/api/terminal-controller/README.md
  56. 93 0
      packages/api/terminal-controller/README.zh.md
  57. 93 0
      packages/api/terminal-controller/package.json
  58. 73 0
      packages/api/terminal-controller/src/client/close-requests.ts
  59. 150 0
      packages/api/terminal-controller/src/client/index.ts
  60. 352 0
      packages/api/terminal-controller/src/client/model.ts
  61. 20 0
      packages/api/terminal-controller/src/client/shell-preference.ts
  62. 360 0
      packages/api/terminal-controller/src/index.ts
  63. 57 0
      packages/api/terminal-controller/src/shells.ts
  64. 75 0
      packages/api/terminal-controller/src/stream.ts
  65. 175 0
      packages/api/terminal-controller/src/terminal.ts
  66. 63 0
      packages/api/terminal-controller/src/types.ts
  67. 543 0
      packages/api/terminal-controller/tests/controller.spec.ts
  68. 567 0
      packages/api/terminal-controller/tests/model.client.spec.ts
  69. 515 0
      packages/api/terminal-controller/tests/recovery.client.spec.ts
  70. 80 0
      packages/api/terminal-controller/tests/stream.spec.ts
  71. 228 0
      packages/api/terminal-controller/tests/terminal.spec.ts
  72. 38 0
      packages/api/terminal-controller/tsconfig.client.json
  73. 53 0
      packages/api/terminal-controller/tsconfig.host.json
  74. 11 0
      packages/api/terminal-controller/tsconfig.json
  75. 7 0
      packages/api/terminal-controller/tsdown.config.ts
  76. 4 0
      packages/bundle/web-app/cordis.patch.yml
  77. 3 1
      packages/bundle/web-app/package.json
  78. 0 2
      packages/client/locale/src/locales/en.ts
  79. 0 2
      packages/client/locale/src/locales/zh.ts
  80. 2 1
      packages/client/tsdown.client.ts
  81. 2 2
      packages/client/ui-chat/README.i18n.yaml
  82. 2 0
      packages/client/ui-chat/README.md
  83. 2 0
      packages/client/ui-chat/README.zh.md
  84. 8 1
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  85. 10 3
      packages/client/ui-chat/src/client/chat/ReasoningRow.tsx
  86. 21 2
      packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
  87. 9 2
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  88. 29 2
      packages/client/ui-chat/tests/reasoning-row.client.spec.tsx
  89. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  90. 3 1
      packages/client/ui-primitives/README.md
  91. 3 1
      packages/client/ui-primitives/README.zh.md
  92. 141 18
      packages/client/ui-primitives/src/JsonTree.module.css
  93. 349 141
      packages/client/ui-primitives/src/JsonTree.tsx
  94. 8 0
      packages/client/ui-primitives/src/icons/index.tsx
  95. 7 3
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  96. 5 4
      packages/client/ui-primitives/src/markdown/MarkdownText.module.css
  97. 2 2
      packages/client/ui-primitives/tests/icons.client.spec.tsx
  98. 159 31
      packages/client/ui-primitives/tests/json-tree.client.spec.tsx
  99. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  100. 8 4
      packages/client/ui-sidebar-right/README.md

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.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 .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md
-2026-09-06-embedded-stream-record-readers.md: 972fee634833cef5fd7b0a54f69780b9370f0cc3
-2026-09-06-embedded-stream-record-readers.zh.md: faba6e179887a9943926f2c73aa8e42a10cee300
+2026-09-06-embedded-stream-record-readers.md: 76e109de577d070093bb7123aa50d9a4ae7fcbe5
+2026-09-06-embedded-stream-record-readers.zh.md: 33f4b31117197c47e7f2ff1637bc13fa2cbf1a8c

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.md

@@ -20,7 +20,7 @@ After v2 embedded streams settlement widened with the message content and Chat a
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Run readers: `runFirstTokenTime` and `runFirstVisibleTime` reconstruct the first qualifying member's time from `time0` and the `dt` gaps and stop scanning there; a name-bearing Tool-call run yields `time0` without reading a fragment.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 - Stream readers: `assistantStreamFirstTokenTime`, `assistantStreamHasVisibleContent`, `assistantStreamHasVisibleText`, `lastAssistantStreamChunk(stream, type)` (backward scan), `assistantStreamChunks(stream, type)`, `joinAssistantStreamText`, and `assembleAssistantStream`, which feeds a `BlockAssembler` one joined delta per run (assembly only concatenates, so blocks, usage, finish, and replay state equal the per-member result). `RawStreamChunkType` excludes the delta types, so a raw-chunk lookup can never silently skip packed members.
 
 
-Session Stats reads `assistantStreamFirstTokenTime`; the token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
+Session Stats, Chat, and Trajectory read `assistantStreamFirstTokenTime` from both `assistant/attempt` and `assistant/message`, retaining the Step's first token across retries. Chat and Trajectory settle content from the assembled message while reading timing independently, so reopening history retains TTFT and decoding metrics without expanding streams. The token meter reads `lastAssistantStreamChunk(stream, 'usage')` and assembles provider output through `assembleAssistantStream`; the subagent output fold appends `joinAssistantStreamText`; the Session Controller scans `assistantStreamChunks(stream, 'block-end')` for images.
 
 
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 `expandAssistantStream` keeps its strict validation and its remaining callers, which need every member or validate the stream at a durable boundary: Session restore validation, the v1-to-v2 migration validator and publication Worker replay, the reconnect baseline, and test support.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-06-embedded-stream-record-readers.zh.md

@@ -20,7 +20,7 @@ Session 格式 v2 将每次模型尝试的紧凑流(`AssistantStreamRecord[]`
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - Run 读取器:`runFirstTokenTime` 与 `runFirstVisibleTime` 从 `time0` 与 `dt` 间隔重建首个合格成员的时间并停止扫描;带名称的 Tool-call run 直接产出 `time0`,不读片段。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 - 流读取器:`assistantStreamFirstTokenTime`、`assistantStreamHasVisibleContent`、`assistantStreamHasVisibleText`、`lastAssistantStreamChunk(stream, type)`(逆向扫描)、`assistantStreamChunks(stream, type)`、`joinAssistantStreamText` 与 `assembleAssistantStream`(每个 run 向 `BlockAssembler` 喂入一个拼接后的 delta;组装只做拼接,因此 blocks、usage、finish 与 replay state 与逐成员结果一致)。`RawStreamChunkType` 排除 delta 类型,因此原始 chunk 查找不可能静默跳过打包成员。
 
 
-Session Stats 读取 `assistantStreamFirstTokenTime`;token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
+Session Stats、Chat 与 Trajectory 从 `assistant/attempt` 和 `assistant/message` 读取 `assistantStreamFirstTokenTime`,跨重试保留步骤的首个 token。Chat 与 Trajectory 从组装后的消息结算内容,并独立读取计时,因此重新打开历史时无需展开流便能保留 TTFT 与解码指标。token 计量读取 `lastAssistantStreamChunk(stream, 'usage')` 并通过 `assembleAssistantStream` 组装提供商输出;子代理输出折叠追加 `joinAssistantStreamText`;Session Controller 用 `assistantStreamChunks(stream, 'block-end')` 扫描镜像。
 
 
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 `expandAssistantStream` 保留其严格校验与其余调用方(需要每个成员或在持久边界校验流):Session 恢复校验、v1-to-v2 迁移校验器与发布 Worker 重放、重连基线、测试支撑。
 
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-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/feature/2026-09-09-ptc-trajectory-code-inspection.md
+2026-09-09-ptc-trajectory-code-inspection.md: 477d199f519b5d515e5d58430bd902d9d209e46b
+2026-09-09-ptc-trajectory-code-inspection.zh.md: f305569176214c63ac549b0ec5103289e3e57a88

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md

@@ -0,0 +1,29 @@
+# Agent Note: Inspect recorded PTC source in Trajectory
+
+Status: implemented
+
+English | [中文](2026-09-09-ptc-trajectory-code-inspection.zh.md)
+
+## Problem
+
+PTC programs arrive as JSON string arguments. Escaping makes long programs difficult to read in a generic argument tree, while historical calls may use a different runtime language from the current deployment. Recorded result text can contain both printed output and a returned value without retaining their separation.
+
+## Decision
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.md) identifies calls by their recorded tool name and derives a code inspector from validated `run_code` arguments. The validated program carries the original JSON text; copying preserves source and argument bytes and exposes the original JSON through a separate toggle. Syntax highlighting requires an unambiguous TypeScript or Python hint in that call's recorded parameter description; unknown or conflicting hints leave plain text. Unsupported arguments retain the generic inspector.
+
+The result view preserves recorded text and uses a tree only for complete JSON objects or arrays. Errors retain captured output. Code views sample the shared wrapping preference when opened and keep their own choice while mounted.
+
+The [PTC runtime decision](2026-06-15-ptc.md) still owns execution and settlement; the [client presentation decision](../architecture/2026-08-23-client-derived-tool-presentation.md) still owns deriving UI from recorded facts. This inspector adds no Session events or host presentation fields.
+
+## Alternatives considered
+
+**Keep source inside the argument tree.** JSON escaping obscures program structure and makes copying executable source cumbersome.
+
+**Use the active runtime language or infer it from source.** Either can mislabel a historical program. Recorded schema hints constrain highlighting without changing the recording.
+
+**Split printed output from return values.** Historical rendered text does not establish that distinction; a parser could invent a separation the producer never recorded.
+
+## Consequences
+
+Readers can inspect and copy recorded programs without changing replay data. Schemas with no recognizable language hint receive no syntax highlighting. Component tests cover recorded-name recognition, schema fallback, exact source and argument copying, output states, and independent wrapping. JSON-tree tests cover clipping geometry, missing `ResizeObserver`, clipboard settlement after hover changes or unmount, and value-read counts during hover. Thinking tests cover body arrival, manual disclosure, and switching records; the [PTC browser scenario](../../../../apps/web/tests/ptc-round.e2e.ts) pins the assembled inspector and verifies overflow and the original-JSON round trip.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 在 Trajectory 中检查已记录的 PTC 源码
+
+Status: implemented
+
+[English](2026-09-09-ptc-trajectory-code-inspection.md) | 中文
+
+## 问题
+
+PTC 程序以 JSON 字符串参数传入。转义使长程序在通用参数树中难以阅读,而历史调用使用的运行时语言可能不同于当前部署。记录的结果文本可以同时包含打印输出与返回值,却未保留二者的分界。
+
+## 决策
+
+[Trajectory](../../../../packages/client/ui-trajectory/README.zh.md) 按已记录的工具名识别调用,并从已验证的 `run_code` 参数派生代码检查器。验证后的程序携带原始 JSON 文本;复制时保留源码与参数字节,并通过独立切换按钮展示原始 JSON。语法高亮要求该调用记录的参数说明包含明确且无冲突的 TypeScript 或 Python 提示;未知或冲突的提示使用纯文本。不支持的参数保留通用检查器。
+
+结果视图保留记录的文本,只对完整 JSON 对象或数组使用树形展示。错误保留已捕获的输出。代码视图在打开时读取共享换行偏好,并在挂载期间保留自身的选择。
+
+[PTC 运行时决策](2026-06-15-ptc.zh.md) 仍负责执行与结算;[客户端展示决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md) 仍负责从已记录事实派生 UI。此检查器不增加 Session 事件或宿主展示字段。
+
+## 考虑过的替代方案
+
+**把源码保留在参数树中。** JSON 转义遮蔽程序结构,也使复制可执行源码变得繁琐。
+
+**使用当前运行时语言,或从源码推断语言。** 两种方式都可能错误标注历史程序。已记录 Schema 的提示约束高亮,无需修改记录。
+
+**拆分打印输出与返回值。** 历史渲染文本无法确定二者的分界;解析器可能凭空添加生产方从未记录的分隔。
+
+## 后果
+
+读者可以检查和复制已记录的程序,无需修改回放数据。Schema 没有可识别的语言提示时不提供语法高亮。组件测试覆盖记录工具名识别、Schema 回退、源码与参数原样复制、输出状态及独立换行。JSON 树测试覆盖裁剪几何、缺少 `ResizeObserver`、悬停切换或卸载后剪贴板写入落定,以及悬停期间读取值的次数。思考测试覆盖正文到达、手动展开折叠及记录切换;[PTC 浏览器场景](../../../../apps/web/tests/ptc-round.e2e.ts) 固定组装后的检查器展示,并验证溢出和原始 JSON 的往返切换。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml

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

+ 45 - 0
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md

@@ -0,0 +1,45 @@
+# Agent Note: Web sidebar terminals
+
+Status: implemented
+
+English | [中文](2026-09-09-web-sidebar-terminal.zh.md)
+
+## Problem
+
+Web users need an interactive shell beside a Session to inspect the workspace and run commands. The Agent's persistent terminal tools control prompts and wait for semantic results; a human terminal instead needs raw keyboard input, normal shell configuration and a full screen. Browser rendering and transport can disappear while a command is still running.
+
+## Decision
+
+`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. A new terminal offers installed shells and waits for Start. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider and Session sandbox policy. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
+
+Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
+
+Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry.
+
+The caller retains a terminal ID in memory before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments.
+
+The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full.
+
+## Alternatives considered
+
+**Persist complete shell profiles in the browser.** Only the selected path is a user preference. Arguments and availability belong to Host discovery; persisting them would allow stale profiles to bypass current execution configuration. The sidebar continues to own the terminal list and tab controls.
+
+**Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry.
+
+**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload.
+
+**Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead.
+
+**Add a dedicated terminal WebSocket.** The existing Remote stream transport already owns authentication, cancellation and reconnection. A second carrier would duplicate those responsibilities.
+
+**Replay only a bounded raw byte tail.** A tail can begin inside an escape sequence or omit an alternate-screen transition. A serialized terminal screen provides a consistent recovery point with bounded history.
+
+**Kill when a React body or tab signal is disposed.** Unmount, Session switching and plugin reload can end those lifetimes without an explicit close. Cleanup must follow the user's close operation.
+
+**Build a Web completion engine.** Native shell completion already handles commands, paths and configured plugins through ordinary PTY input. An independent completion UI adds shell-specific parsing and synchronization; it is outside this feature.
+
+## Consequences
+
+A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
+
+The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.

+ 45 - 0
.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: Web sidebar terminals
+
+Status: implemented
+
+[English](2026-09-09-web-sidebar-terminal.md) | 中文
+
+## 问题
+
+Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命令。Agent 的持久终端工具控制提示符并等待语义结果;人工终端需要原始键盘输入、正常 shell 配置和完整屏幕。命令仍在运行时,浏览器渲染和网络连接可能消失。
+
+## 决定
+
+`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。新终端提供已安装 shell 的选择并等待用户启动。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+
+关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
+
+侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。
+
+调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。
+
+最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。
+
+## 考虑过的替代方案
+
+**在浏览器保存完整 shell profile。** 用户偏好只包含选中的路径。参数和可用性由 Host 探测决定;持久化这些信息会让过期 profile 绕过当前执行配置。终端列表和标签页控件仍由侧栏负责。
+
+**进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
+
+**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。
+
+**共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。
+
+**增加专用终端 WebSocket。** 现有 Remote stream 已管理认证、取消和重连。第二条传输通道会重复这些职责。
+
+**只重放有界原始字节尾部。** 字节尾部可能从转义序列中间开始,或缺失备用屏幕切换。序列化终端屏幕能在保留有界历史的同时提供一致恢复点。
+
+**React 正文或 tab signal 卸载时 kill。** 组件卸载、Session 切换和插件重载都可能结束这些生命周期,而用户并未关闭终端。清理必须跟随显式关闭操作。
+
+**自建 Web 补全引擎。** shell 原生补全通过普通 PTY 输入处理命令、路径和已配置插件。独立补全界面需要针对 shell 解析和同步,不属于此功能。
+
+## 影响
+
+保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
+
+Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。

+ 3 - 0
THIRD_PARTY_NOTICES.md

@@ -58,7 +58,10 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
 | [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
+| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
+| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
+| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |

+ 1 - 0
apps/web/package.json

@@ -59,6 +59,7 @@
     "ws": "8.21.0",
     "ws": "8.21.0",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-workspace-changes": "workspace:^",
     "@deepseek-ai/dsh-workspace-changes": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
     "@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
     "@deepseek-ai/dsh-experimental-auto-review": "workspace:^"
   }
   }
 }
 }

+ 2 - 0
apps/web/tests/assembled-remote.ts

@@ -2,6 +2,7 @@
  * RemoteMock scenario for built-client tests that do not own a Host.
  * RemoteMock scenario for built-client tests that do not own a Host.
  * The adjacent JSON is maintained with this module when Remote responses or
  * The adjacent JSON is maintained with this module when Remote responses or
  * the current Session header version change.
  * the current Session header version change.
+ * Fixture Sessions have no retained Host terminals to restore.
  */
  */
 
 
 import { readFileSync } from 'node:fs'
 import { readFileSync } from 'node:fs'
@@ -139,6 +140,7 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
       'settings/openSettingsDocument': ok({ opened: true }),
       'settings/openSettingsDocument': ok({ opened: true }),
       'settings/openAgentPresetDirectory': ok({ opened: true }),
       'settings/openAgentPresetDirectory': ok({ opened: true }),
       'subagents/list': ok({ entries: [], parentAvailable: true }),
       'subagents/list': ok({ entries: [], parentAvailable: true }),
+      'terminal/list': ok([]),
       'skills/list': ok({ skills: [] }),
       'skills/list': ok({ skills: [] }),
       'session/canOpenWorkspacePath': ok(true),
       'session/canOpenWorkspacePath': ok(true),
       'session/openWorkspacePath': ok({ opened: true }),
       'session/openWorkspacePath': ok({ opened: true }),

+ 5 - 3
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -45,6 +45,8 @@ const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 const SEED_ID = 'clickable-links-gallery-web-e2e'
 const SEED_ID = 'clickable-links-gallery-web-e2e'
 const DONE = 'LINK_GALLERY_DONE'
 const DONE = 'LINK_GALLERY_DONE'
+// Seeded events and browser time share a day independently of host timezones.
+const GALLERY_TIME = Date.UTC(2026, 0, 15, 12)
 
 
 const GUIDE_URL = 'https://docs.example.test/guide'
 const GUIDE_URL = 'https://docs.example.test/guide'
 const API_URL = 'https://docs.example.test/api'
 const API_URL = 'https://docs.example.test/api'
@@ -192,7 +194,6 @@ const CALLS: GalleryCall[] = [
  */
  */
 function galleryFixture(imageUrl: string): string {
 function galleryFixture(imageUrl: string): string {
   const session = Session.create(SessionId('clickable-links-gallery-source'))
   const session = Session.create(SessionId('clickable-links-gallery-source'))
-  const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
   session.append('turn/start', { turn: 1 })
   session.append('turn/start', { turn: 1 })
   const user = session.append('user/message', createUserMessage({
   const user = session.append('user/message', createUserMessage({
     content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'),
     content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'),
@@ -289,7 +290,7 @@ function galleryFixture(imageUrl: string): string {
     }),
     }),
     ...session.snapshotEvents().map(event => JSON.stringify({
     ...session.snapshotEvents().map(event => JSON.stringify({
       ...event,
       ...event,
-      time: eventTimeOrigin + event.seq * 1_000,
+      time: GALLERY_TIME + event.seq * 1_000,
     })),
     })),
     '',
     '',
   ].join('\n')
   ].join('\n')
@@ -305,9 +306,10 @@ describe('web e2e: clickable links gallery', () => {
   beforeAll(async () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
     scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
     imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString()
     imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString()
-    await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID)
+    await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME })
     browser = await chromium.launch()
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
+    await page.clock.setFixedTime(GALLERY_TIME + 60_000)
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })

+ 3 - 0
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -243,6 +243,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
 
 
     await select(original, 'LIGHTHOUSE')
     await select(original, 'LIGHTHOUSE')
     await open()
     await open()
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
     // The content-box panel adds its one rendered border pixel outside the
     // The content-box panel adds its one rendered border pixel outside the
     // CSS width assigned by the grid solver.
     // CSS width assigned by the grid solver.
     await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
     await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 })
@@ -256,6 +257,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => split.isDisabled()).toBe(false)
     await expect.poll(() => split.isDisabled()).toBe(false)
     await split.click()
     await split.click()
     await expect.poll(() => panes.count()).toBe(2)
     await expect.poll(() => panes.count()).toBe(2)
+    await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click()
     await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
     await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click()
     await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
     await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1)
     const retainedA = await paneSnapshot(page)
     const retainedA = await paneSnapshot(page)
@@ -278,6 +280,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await expect.poll(() => detailsTrack(page)).toBe(0)
     await expect.poll(() => detailsTrack(page)).toBe(0)
     await open()
     await open()
     expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
     expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
     await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
     await column.locator('[data-files-state="tree"]').waitFor({ timeout: 15_000 })
     const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
     const workspaceDirectory = column.locator('[data-files-entry="directory"] > button').filter({ hasText: /^workspace$/ })
     await workspaceDirectory.waitFor({ timeout: 15_000 })
     await workspaceDirectory.waitFor({ timeout: 15_000 })

+ 1 - 0
apps/web/tests/document-preview.e2e.ts

@@ -134,6 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
 
 
     const column = page.locator('[data-rightbar-col]')
     const column = page.locator('[data-rightbar-col]')
     await page.locator('[data-sidebar-right-expand]').click()
     await page.locator('[data-sidebar-right-expand]').click()
+    await column.locator('[data-sidebar-right-guide-entry="files"]').click()
     await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
     await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
     await column.locator('[data-files-reload]').click()
     await column.locator('[data-files-reload]').click()
     const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
     const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })

+ 1 - 0
apps/web/tests/expected/sidebar-terminal/limit.expected.md

@@ -0,0 +1 @@
+- alert: "Terminal error: The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit."

+ 1 - 0
apps/web/tests/expected/sidebar-terminal/running.expected.md

@@ -0,0 +1 @@
+- textbox "Terminal"

+ 3 - 0
apps/web/tests/expected/sidebar-terminal/selection.expected.md

@@ -0,0 +1,3 @@
+- text: Shell
+- button "Shell": bash — /bin/bash
+- button "Start terminal"

+ 5 - 0
apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md

@@ -0,0 +1,5 @@
+- menu:
+  - menuitem "bash — /bin/bash":
+    - text: bash — /bin/bash
+    - img
+  - menuitem "sh — /bin/sh"

+ 8 - 0
apps/web/tests/fixtures/sidebar-terminal.patch.yml

@@ -0,0 +1,8 @@
+- id: terminal-controller
+  config:
+    maxTerminals: 2
+    shellCandidates: [/bin/bash, /bin/sh]
+    shell:
+      path: /bin/bash
+      name: bash
+      args: [--noprofile, --norc, -i]

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

@@ -290,7 +290,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       try {
       try {
         await input.press('Enter')
         await input.press('Enter')
         if (MODE !== 'record') {
         if (MODE !== 'record') {
-          const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
+          const thinking = page.locator('[data-variant="think"][data-state="running"]')
+          const disclosure = thinking.getByRole('button')
+          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
+          await disclosure.click()
+          await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
+          const liveTail = thinking.locator('[data-follow-end]')
           await expect.poll(async () => {
           await expect.poll(async () => {
             if (await liveTail.count() !== 1) return false
             if (await liveTail.count() !== 1) return false
             return await liveTail.evaluate((element) => {
             return await liveTail.evaluate((element) => {

+ 43 - 5
apps/web/tests/markdown-wide-table.e2e.ts

@@ -56,6 +56,7 @@ const TAIL_MARKER = 'MWT_TABLES_DONE'
 const FILL_MARKER = 'MWT_FILL_C1'
 const FILL_MARKER = 'MWT_FILL_C1'
 const WIDE_MARKER = 'MWT_WIDE_C01'
 const WIDE_MARKER = 'MWT_WIDE_C01'
 const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
 const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
+const SHORT_MARKER = 'MWT_SHORT_C1'
 const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
 const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
 /** Golden-facing names, in {@link MARKERS} order. */
 /** Golden-facing names, in {@link MARKERS} order. */
 const TABLE_NAMES = ['fill', 'wide', 'long-cell']
 const TABLE_NAMES = ['fill', 'wide', 'long-cell']
@@ -76,13 +77,20 @@ const SENTENCE = 'This cell carries one full sentence so the unwrapped table is
 const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
 const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
 const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
 const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
 
 
-/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */
+/** The assistant markdown includes fitting and overflowing wide tables. */
 function tablesMarkdown(): string {
 function tablesMarkdown(): string {
   const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
   const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
   const wideRow = (row: number): string[] =>
   const wideRow = (row: number): string[] =>
     Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
     Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
   return [
   return [
-    'Three markdown tables exercise the wide-table layout rules.',
+    'Markdown tables exercise the wide-table layout rules.',
+    '',
+    `| ${SHORT_MARKER} | C2 | C3 | C4 |`,
+    '| --- | --- | --- | --- |',
+    '| 1 | 2 | 3 | 4 |',
+    '| 5 | 6 | 7 | 8 |',
+    '',
+    'The paragraph after the short table stays in place.',
     '',
     '',
     `| ${FILL_MARKER} | Current approach | Proposed approach |`,
     `| ${FILL_MARKER} | Current approach | Proposed approach |`,
     '| --- | --- | --- |',
     '| --- | --- | --- |',
@@ -103,7 +111,7 @@ function tablesMarkdown(): string {
   ].join('\n')
   ].join('\n')
 }
 }
 
 
-/** Build one closed, invariant-checked session fixture carrying the three tables. */
+/** Build one closed, invariant-checked session fixture carrying the tables. */
 function wideTableFixture(): string {
 function wideTableFixture(): string {
   const session = Session.create(SessionId('markdown-wide-table-source'))
   const session = Session.create(SessionId('markdown-wide-table-source'))
   const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
   const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
@@ -257,7 +265,8 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
   beforeAll(async () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
     scaffold = await launchWebScaffold({})
     await seedSession(scaffold, wideTableFixture(), SEED_ID)
     await seedSession(scaffold, wideTableFixture(), SEED_ID)
-    browser = await chromium.launch()
+    // The geometry assertions include the space occupied by native scrollbars.
+    browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
     page = await newEnglishPage(browser)
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -386,13 +395,42 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro
     // Resting hidden overflow keeps the scroll position reachable and intact.
     // Resting hidden overflow keeps the scroll position reachable and intact.
     expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
     expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
     await wide.hover()
     await wide.hover()
-    await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px')
+    await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px')
     // Pointer leaves: the bar rests hidden again.
     // Pointer leaves: the bar rests hidden again.
     await page.mouse.move(4, 4)
     await page.mouse.move(4, 4)
     await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
     await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 120_000)
   }, 120_000)
 
 
+  it('keeps a fitting wide table and its following paragraph stationary during interaction', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-short-table-height'))
+    await settleAt(1680)
+    const short = page.locator('[class*="tableScroll"]', { hasText: SHORT_MARKER })
+    await short.evaluate((element) => { element.scrollIntoView({ block: 'center', behavior: 'instant' }) })
+    await page.mouse.move(4, 4)
+    await short.evaluate((element) => { element.blur() })
+    expect(await short.evaluate(element => element.classList.contains('md-table-wide'))).toBe(true)
+    expect(await short.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
+    const position = () => short.evaluate((element) => {
+      const following = element.nextElementSibling
+      if (following === null) throw new Error('short table has no following paragraph')
+      return {
+        height: element.getBoundingClientRect().height,
+        followingTop: following.getBoundingClientRect().top,
+      }
+    })
+    const resting = await position()
+    await short.hover()
+    await expect.poll(position).toEqual(resting)
+    await page.mouse.move(4, 4)
+    await short.focus()
+    expect(await short.evaluate(element => document.activeElement === element)).toBe(true)
+    await expect.poll(position).toEqual(resting)
+    await short.evaluate((element) => { element.blur() })
+    await expect.poll(position).toEqual(resting)
+    expect(tripwire.pageErrors).toEqual([])
+  }, 120_000)
+
   it('keeps the fill/scroll relations under page zoom', async () => {
   it('keeps the fill/scroll relations under page zoom', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
     await sweep()
     await sweep()

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

@@ -24,6 +24,7 @@ import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './supp
 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.v3.jsonl')
 const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
@@ -97,7 +98,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       const raw = await readFile(SEED, 'utf8')
       const raw = await readFile(SEED, 'utf8')
       expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
       expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
         .toEqual([PROMPT_TURN1, PROMPT_TURN2])
         .toEqual([PROMPT_TURN1, PROMPT_TURN2])
-      await seedSession(scaffold, raw, SEED_ID)
+      // The inspector's calendar date must not depend on the day the test runs.
+      await seedSession(scaffold, raw, SEED_ID, undefined, { createdAt: Date.UTC(2026, 0, 1) })
     }
     }
     browser = await chromium.launch()
     browser = await chromium.launch()
   }, 120_000)
   }, 120_000)
@@ -264,13 +266,30 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.getByRole('tab', { name: 'Result' }).click()
     await page.getByRole('tab', { name: 'Result' }).click()
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
-    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBe(0)
+    expect(await page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').count()).toBeGreaterThan(0)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
     await details.getByRole('button', { name: 'Close details' }).click()
     await details.getByRole('button', { name: 'Close details' }).click()
   }, 60_000)
   }, 60_000)
 
 
+  it.skipIf(MODE === 'record')('restores Assistant timing from recorded history', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timing'))
+    await ensureSeedOpen(page)
+    await page.getByRole('tab', { name: 'Trajectory' }).click()
+    await page.getByRole('button', { name: 'Request #1', exact: true }).click()
+    const details = page.getByRole('complementary', { name: 'Event details' })
+    await details.getByRole('tab', { name: 'Timing', exact: true }).click()
+    const panel = details.getByRole('tabpanel', { name: 'Timing' })
+    for (const metric of ['TTFT', 'Generation', 'Throughput']) {
+      const value = panel.getByText(metric, { exact: true }).locator('..').locator('dd')
+      await expect.poll(() => value.textContent()).toMatch(/^\d/)
+    }
+    expect(await panel.getByText('First token unavailable', { exact: true }).count()).toBe(0)
+    const snapshot = await captureStableAria(page, '#trajectory-detail-panel', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(TIMING_EXPECTED, snapshot, MODE)
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     await ensureSeedOpen(page)
     await ensureSeedOpen(page)
@@ -504,7 +523,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
   it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
       'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
-      'terminal-card.expected.md',
+      'terminal-card.expected.md', 'timing.expected.md',
     ])
     ])
   })
   })
 })
 })

+ 44 - 1
apps/web/tests/ptc-round.e2e.ts

@@ -8,13 +8,15 @@ 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, captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 } from './scaffold.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.v3.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url))
+const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
+const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 // Elicits the successful and failed sub-rows this scenario asserts.
 // Elicits the successful and failed sub-rows this scenario asserts.
@@ -151,6 +153,47 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
   })
 
 
+  it.skipIf(MODE === 'record')('inspects recorded PTC source, wrapping, and original JSON in the trajectory', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-inspector'))
+    const call = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'run_code')
+    if (call?.type !== 'tool/call') throw new Error('recorded PTC call missing')
+    const args = JSON.parse(call.data.arguments) as { code: string; description: string }
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    const row = page.locator('tr[data-kind="tool"]').filter({ hasText: 'run_code' }).first()
+    await row.click()
+    await page.getByRole('tab', { name: 'Code', exact: true }).waitFor()
+    expect(await page.getByRole('tabpanel').textContent()).toContain(args.description)
+    expect(await page.getByRole('tabpanel').locator('dl').first().locator('dt').allTextContents())
+      .toEqual(['Hierarchy', 'Status'])
+    const overview = await Promise.all([1, 2].map(index => captureStableAria(
+      page, `[role="tabpanel"] [class*="overviewSections"] > section:nth-child(${index})`, scaffold.workspaceCwd,
+    )))
+    await compareOrRefreshGolden(TRAJECTORY_EXPECTED, overview.join('\n'), MODE)
+
+    await page.getByRole('button', { name: 'Code', exact: true }).click()
+    const source = page.locator('[data-line-numbers] pre')
+    await source.waitFor()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    const wrap = page.getByRole('button', { name: 'Wrap lines', exact: true })
+    expect(await wrap.getAttribute('aria-pressed')).toBe('false')
+    const content = page.locator('[data-wrap]').filter({ has: source })
+    expect(await content.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
+    await wrap.click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+    await expect.poll(() => content.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1)
+    const code = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(CODE_EXPECTED, code, MODE)
+
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    const json = page.getByRole('tree', { name: 'parameters JSON' })
+    await json.waitFor()
+    expect(await json.textContent()).toContain(args.description)
+    await page.getByRole('button', { name: 'Original JSON' }).click()
+    expect(await source.textContent()).toBe(args.code.endsWith('\n') ? args.code.slice(0, -1) : args.code)
+    await page.getByRole('tab', { name: 'Summary', exact: true }).click()
+    expect(await wrap.getAttribute('aria-pressed')).toBe('true')
+  })
+
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
   it.skipIf(MODE === 'record')('stayed clean: no page errors, no reconnect churn', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 19 - 9
apps/web/tests/sidebar-right.e2e.ts

@@ -132,6 +132,7 @@ async function resetSidebar(page: Page): Promise<Locator> {
   const column = page.locator('[data-rightbar-col]')
   const column = page.locator('[data-rightbar-col]')
   await expandOf(page).waitFor({ timeout: 15_000 })
   await expandOf(page).waitFor({ timeout: 15_000 })
   await ensureExpanded(page, column)
   await ensureExpanded(page, column)
+  await column.locator('[data-sidebar-right-guide-entry="files"]').click()
   await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
   await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
   await width(column)
   await width(column)
   return column
   return column
@@ -391,6 +392,10 @@ describe('web e2e: shipped right Sidebar', () => {
         expect(await centreY(selector), selector).toBe(textLine)
         expect(await centreY(selector), selector).toBe(textLine)
       }
       }
 
 
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
+      await column.locator('[data-sidebar-right-guide-entry="files"]').click()
+
       // A manual guide is closable beside Files and suppresses another add
       // A manual guide is closable beside Files and suppresses another add
       // control in its pane until it is closed.
       // control in its pane until it is closed.
       const addTab = column.locator('[data-dockkit-add-tab]')
       const addTab = column.locator('[data-dockkit-add-tab]')
@@ -762,10 +767,13 @@ describe('web e2e: shipped right Sidebar', () => {
       )
       )
       await expect.poll(async () => await panes.count()).toBe(2)
       await expect.poll(async () => await panes.count()).toBe(2)
 
 
-      const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
-      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1)
-      await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
+      const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
+      expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1)
+      await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5))
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
+      const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
+      await movedGuide.hover()
+      await movedGuide.locator('[data-dockkit-tab-close]').click()
 
 
       // Neither pane holds a guide, so both offer an add control.
       // Neither pane holds a guide, so both offer an add control.
       const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
       const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
@@ -902,7 +910,7 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(await panes.count()).toBe(2)
       expect(await panes.count()).toBe(2)
       expect(await splitButtons.count()).toBe(0)
       expect(await splitButtons.count()).toBe(0)
 
 
-      // 5. A manual guide and the document float while Files stays docked.
+      // 5. The split's guide and the document float while Files stays docked.
       const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
       const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
       await floatByDrag(page, floatOne)
       await floatByDrag(page, floatOne)
       await expect.poll(async () => await floats.count()).toBe(1)
       await expect.poll(async () => await floats.count()).toBe(1)
@@ -911,8 +919,7 @@ describe('web e2e: shipped right Sidebar', () => {
       await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
       await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
       await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
       await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x)
 
 
-      await panes.last().locator('[data-dockkit-add-tab]').click()
-      await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Files', 'Start'])
+      await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start'])
       const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       await floatByDrag(page, second)
       await floatByDrag(page, second)
       await expect.poll(async () => await floats.count()).toBe(2)
       await expect.poll(async () => await floats.count()).toBe(2)
@@ -967,7 +974,7 @@ describe('web e2e: shipped right Sidebar', () => {
       // Any other tab standing alone closes together with the column. Open the
       // Any other tab standing alone closes together with the column. Open the
       // sample file, close the guide (an ordinary close with two tabs), then
       // sample file, close the guide (an ordinary close with two tabs), then
       // close the file: the column collapses in the same gesture, and the
       // close the file: the column collapses in the same gesture, and the
-      // settle rule reseeds the current default, so reopening shows Files.
+      // settle rule reseeds the current default, so reopening shows Start.
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await column.locator('[data-dockkit-tab]').first().hover()
       await column.locator('[data-dockkit-tab]').first().hover()
@@ -977,8 +984,8 @@ describe('web e2e: shipped right Sidebar', () => {
       await column.locator('[data-dockkit-tab-close]').first().click()
       await column.locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
       await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
       await expandOf(page).click()
       await expandOf(page).click()
-      await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
-      expect(await column.locator('[data-files-state="tree"]').count()).toBe(1)
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+      expect(await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
 
 
       expect(tripwire.pageErrors).toEqual([])
       expect(tripwire.pageErrors).toEqual([])
       expect(tripwire.warnings).toEqual([])
       expect(tripwire.warnings).toEqual([])
@@ -1004,6 +1011,7 @@ describe('web e2e: shipped right Sidebar', () => {
 
 
       await ensureExpanded(page, column)
       await ensureExpanded(page, column)
       await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
       await expect.poll(async () => await column.locator('[data-dockkit-tab]').count()).toBeGreaterThan(0)
+      await column.locator('[data-sidebar-right-guide-entry="files"]').click()
       await column.locator('[data-dockkit-add-tab]').click()
       await column.locator('[data-dockkit-add-tab]').click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       // No "more" control on the chip: the chip carries its close, and the menu
       // No "more" control on the chip: the chip carries its close, and the menu
@@ -1051,6 +1059,8 @@ describe('web e2e: shipped right Sidebar', () => {
         const column = zhPage.locator('[data-rightbar-col]')
         const column = zhPage.locator('[data-rightbar-col]')
         await expandOf(zhPage).waitFor({ timeout: 20_000 })
         await expandOf(zhPage).waitFor({ timeout: 20_000 })
         await expandOf(zhPage).click()
         await expandOf(zhPage).click()
+        await expect.poll(async () => await tabTitles(column)).toEqual(['开始'])
+        await column.locator('[data-sidebar-right-guide-entry="files"]').click()
         await expect.poll(async () => await tabTitles(column)).toEqual(['文件'])
         await expect.poll(async () => await tabTitles(column)).toEqual(['文件'])
         await column.locator('[data-dockkit-add-tab]').click()
         await column.locator('[data-dockkit-add-tab]').click()
 
 

+ 227 - 0
apps/web/tests/sidebar-terminal.e2e.ts

@@ -0,0 +1,227 @@
+/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */
+import { mkdir } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller'
+import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
+import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url))
+const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url))
+
+async function openTerminal(page: Page, waitForShell = true): Promise<void> {
+  const expand = page.locator('[data-sidebar-right-expand]')
+  if (await expand.isVisible()) await expand.click()
+  const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
+  if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
+  await entry.click()
+  await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+  if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
+}
+
+async function command(page: Page, text: string): Promise<void> {
+  await page.locator('.xterm-helper-textarea:visible').click()
+  await page.keyboard.insertText(text)
+  await page.keyboard.press('Enter')
+}
+
+describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  let handles: SubprocessTerminalHandle[]
+  const inspector = createProcessInspector()
+  const alive = (identity: ProcessIdentity) => inspector.isAlive(identity)
+  const processIdentity = (index: number): ProcessIdentity => {
+    const pid = handles[index]!.pid
+    const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid)
+    if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`)
+    return identity
+  }
+
+  beforeEach(async () => {
+    scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    const agent = scaffold.ctx.agents.list()[0]
+    if (agent === undefined) throw new Error('Workspace did not create a Session')
+    handles = []
+    const subprocess = agent.ctx.get('subprocess')
+    if (subprocess === undefined) throw new Error('Session subprocess provider is missing')
+    const spawn = subprocess.spawnTerminal.bind(subprocess)
+    vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => {
+      const handle = await spawn(spec)
+      handles.push(handle)
+      return handle
+    })
+    agent.session.append('turn/start', { turn: 1 })
+    agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
+    agent.session.append('step/start', { turn: 1, step: 1 })
+    agent.session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Ready for terminal input.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' })
+    agent.session.append('step/end', { turn: 1, step: 1 })
+    agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    await scaffold.ctx.sessions.flush(agent.session)
+    await page.getByText('Ready for terminal input.').waitFor()
+    await mkdir(shots, { recursive: true })
+  }, 180_000)
+
+  afterEach(async () => {
+    try { await browser?.close() } finally {
+      try { await scaffold?.close() } finally { vi.restoreAllMocks() }
+    }
+  })
+
+  it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
+    await openTerminal(page)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'")
+    const screen = page.locator('.xterm-rows:visible')
+    await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY')
+    const aria = await terminal.ariaSnapshot()
+    await compareOrRefreshGolden(expected, aria, webSnapshotMode())
+    await command(page, "printf 'DSH_PID:%s\\n' \"$$\"")
+    await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u)
+    const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1])
+    const firstProcess = processIdentity(0)
+    expect(alive(firstProcess)).toBe(true)
+    await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }')
+    await page.keyboard.press('Control+l')
+    await page.keyboard.insertText('dsh_terminal_completion_pro')
+    await page.keyboard.press('Tab')
+    await page.keyboard.press('Enter')
+    await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell')
+    await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"")
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick()
+    await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development')
+    await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter')
+    await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development')
+    await openTerminal(page)
+    await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"")
+    await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u)
+    const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1])
+    // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities.
+    const secondProcess = processIdentity(1)
+    expect(secondProcess.pid).not.toBe(firstProcess.pid)
+    await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click()
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    expect(alive(secondProcess)).toBe(true)
+    await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
+    expect(alive(firstProcess)).toBe(true)
+    await page.locator('[data-sidebar-right-expand]').click()
+    const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
+    const dockedCols = terminals()[0]!.cols
+    await page.getByRole('button', { name: 'Fullscreen', exact: true }).click()
+    await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols)
+    await command(page, "printf 'SIZE:'; stty size")
+    await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
+    await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
+    await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
+    expect(terminals()).toHaveLength(2)
+    expect(alive(firstProcess)).toBe(true)
+    expect(alive(secondProcess)).toBe(true)
+    await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
+    const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
+    await secondTab.hover()
+    await secondTab.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(async () => await secondTab.count()).toBe(0)
+    await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false)
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"")
+    await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`)
+    await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true })
+    const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid))
+    await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!")
+    await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u)
+    const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid)
+    expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true)
+    expect(descendants.every(alive)).toBe(true)
+    const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' })
+    await tab.hover()
+    await tab.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('offers installed shells, remembers the choice after reload, and restores without a picker', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
+    await page.locator('[data-sidebar-right-expand]').click()
+    const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
+    expect(await entry.innerText()).toBe('New terminal\nRun commands in the Session workspace')
+    await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
+    await entry.click()
+    const selector = page.getByRole('button', { name: 'Shell', exact: true })
+    await selector.waitFor()
+    expect(await selector.innerText()).toContain('bash — /bin/bash')
+    expect(handles).toHaveLength(0)
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/selection.expected.md', import.meta.url)),
+      await page.locator('[data-sidebar-terminal]').ariaSnapshot(), webSnapshotMode())
+    await selector.click()
+    await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
+      await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
+    await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
+    await page.getByRole('menuitem', { name: 'sh — /bin/sh', exact: true }).click()
+    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
+    await page.screenshot({ path: `${shots}/shell-choice.png`, fullPage: true })
+    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
+    const screen = page.locator('.xterm-rows:visible')
+    await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
+    expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
+    const retained = processIdentity(0)
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('.xterm-helper-textarea:visible').waitFor()
+    expect(await selector.count()).toBe(0)
+    expect(alive(retained)).toBe(true)
+    await page.locator('[data-dockkit-add-tab]').click()
+    await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
+    await selector.waitFor()
+    expect(await selector.innerText()).toContain('sh — /bin/sh')
+    expect(handles).toHaveLength(1)
+    await page.getByRole('button', { name: 'Start terminal', exact: true }).click()
+    await expect.poll(() => handles.length).toBe(2)
+    expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('explains that exited terminals count toward the quota and permits creation after closing one', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota'))
+    const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
+    for (let count = 1; count <= 2; count++) {
+      await openTerminal(page)
+      await command(page, 'exit')
+      await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count)
+    }
+    await openTerminal(page, false)
+    const alert = page.getByRole('alert')
+    await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.')
+    const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url))
+    await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode())
+    const failed = page.locator('[data-dockkit-tab][aria-selected="true"]')
+    await failed.hover()
+    await failed.locator('[data-dockkit-tab-close]').click()
+    const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first()
+    await exited.hover()
+    await exited.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(() => terminals().length).toBe(1)
+    await openTerminal(page)
+    await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+})

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

@@ -201,8 +201,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await timeTrigger.click()
     await timeTrigger.click()
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
     expect(await timeDialog.count()).toBe(1)
     expect(await timeDialog.count()).toBe(1)
-    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
-    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBeGreaterThan(0)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
     await page.keyboard.press('Escape')
     await page.keyboard.press('Escape')
     await trigger.click()
     await trigger.click()
 
 

+ 1 - 0
apps/web/tsconfig.json

@@ -101,6 +101,7 @@
     "tests/feedback-release.e2e.ts",
     "tests/feedback-release.e2e.ts",
     "tests/agent-team-panel.e2e.ts",
     "tests/agent-team-panel.e2e.ts",
     "tests/sidebar-right.e2e.ts",
     "tests/sidebar-right.e2e.ts",
+    "tests/sidebar-terminal.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/changed-files.e2e.ts",
     "tests/changed-files.e2e.ts",
     "tests/changed-files-turn.e2e.ts",
     "tests/changed-files-turn.e2e.ts",

+ 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: acc2947f49a915ba27428d869f2404832b41e6e6
-capability-seams.zh.md: 019061bf17f00af8d091104ac15936051f080a1c
+capability-seams.md: 2b5a06b77fed90e0d9ef518b99e745379b41d8c2
+capability-seams.zh.md: 7b7630592f94807a56239fc9c8c198fe22c3db39

+ 4 - 0
docs/capability-seams.md

@@ -53,6 +53,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   pkg_api_workspace_files["api-workspace-files"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
+  pkg_api_terminal_controller["api-terminal-controller"]
+  svc_terminalController["ctx.terminalController<br/>Session interactive terminal Remote controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
@@ -242,6 +244,7 @@ flowchart LR
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_settingsController
   pkg_api_settings_controller --> svc_settingsController
+  pkg_api_terminal_controller --> svc_terminalController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
   pkg_api_workspace_files --> svc_workspaceFiles
@@ -508,6 +511,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
+| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |

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

@@ -55,6 +55,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   pkg_api_workspace_files["api-workspace-files"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
   svc_workspaceFiles["ctx.workspaceFiles<br/>Host workspace file Remote service"]
+  pkg_api_terminal_controller["api-terminal-controller"]
+  svc_terminalController["ctx.terminalController<br/>Session interactive terminal Remote controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   pkg_api_workspace_controller["api-workspace-controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
@@ -244,6 +246,7 @@ flowchart LR
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_settingsController
   pkg_api_settings_controller --> svc_settingsController
+  pkg_api_terminal_controller --> svc_terminalController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
   pkg_api_workspace_files --> svc_workspaceFiles
@@ -510,6 +513,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
+| `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |
 | `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |

+ 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: c07e7e424b996256fa99c268d07c58d9fbf5afed
-config-catalog.zh.md: baf131582578adcf06e012791c4c281251f1d278
+config-catalog.md: fba7074ec4e97b765a9ca627ced216d27ab1db85
+config-catalog.zh.md: 329d38717ec1b4c650a5a03c521eaead7b2f42d6

+ 40 - 0
docs/config-catalog.md

@@ -229,6 +229,45 @@ export interface Config {
 
 
 Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 Source: [`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 
 
+<a id="deepseek-aidsh-api-terminal-controller"></a>
+
+## `@deepseek-ai/dsh-api-terminal-controller`
+
+Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert`
+
+```ts config-catalog
+/** Deployment limits and an optional shell profile. */
+export interface Config {
+  /** Explicit shell profile; omission uses the execution environment's default shell. */
+  readonly shell?: {
+    /** Executable path or PATH name, verified by the subprocess provider. */
+    path: string
+    /** User-visible profile name. */
+    name: string
+    /** Arguments passed to the interactive shell. */
+    args: string[]
+  } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
+  /** Maximum retained terminals and pending allocations per Session. */
+  readonly maxTerminals: number
+  /** Maximum terminal width in columns. */
+  readonly maxCols: number
+  /** Maximum terminal height in rows. */
+  readonly maxRows: number
+  /** Screen history rows retained for reconnecting clients. */
+  readonly scrollback: number
+  /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
+  readonly maxBufferedBytes: number
+  /** Maximum UTF-8 bytes in one input request. */
+  readonly maxInputBytes: number
+  /** Provider process-termination grace period in milliseconds. */
+  readonly disposeGraceMs: number
+}
+```
+
+Source: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts)
+
 <a id="deepseek-aidsh-api-workspace-files"></a>
 <a id="deepseek-aidsh-api-workspace-files"></a>
 
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 ## `@deepseek-ai/dsh-api-workspace-files`
@@ -3543,6 +3582,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` ([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))

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

@@ -231,6 +231,45 @@ export interface Config {
 
 
 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 来源:[`packages/api/settings-controller/src/index.ts:36`](../packages/api/settings-controller/src/index.ts)
 
 
+<a id="deepseek-aidsh-api-terminal-controller"></a>
+
+## `@deepseek-ai/dsh-api-terminal-controller`
+
+Requires: `subprocess` · `sandboxPolicy` · `sessionProjections` · `typert`
+
+```ts config-catalog
+/** Deployment limits and an optional shell profile. */
+export interface Config {
+  /** Explicit shell profile; omission uses the execution environment's default shell. */
+  readonly shell?: {
+    /** Executable path or PATH name, verified by the subprocess provider. */
+    path: string
+    /** User-visible profile name. */
+    name: string
+    /** Arguments passed to the interactive shell. */
+    args: string[]
+  } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
+  /** Maximum retained terminals and pending allocations per Session. */
+  readonly maxTerminals: number
+  /** Maximum terminal width in columns. */
+  readonly maxCols: number
+  /** Maximum terminal height in rows. */
+  readonly maxRows: number
+  /** Screen history rows retained for reconnecting clients. */
+  readonly scrollback: number
+  /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
+  readonly maxBufferedBytes: number
+  /** Maximum UTF-8 bytes in one input request. */
+  readonly maxInputBytes: number
+  /** Provider process-termination grace period in milliseconds. */
+  readonly disposeGraceMs: number
+}
+```
+
+来源: [`packages/api/terminal-controller/src/index.ts:28`](../packages/api/terminal-controller/src/index.ts)
+
 <a id="deepseek-aidsh-api-workspace-files"></a>
 <a id="deepseek-aidsh-api-workspace-files"></a>
 
 
 ## `@deepseek-ai/dsh-api-workspace-files`
 ## `@deepseek-ai/dsh-api-workspace-files`
@@ -3545,6 +3584,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`([`packages/client/ui-sidebar-documentpreview/src/index.ts`](../packages/client/ui-sidebar-documentpreview/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts))

+ 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: 4d81c97dedfa5e3d8ebfe1bb8697f4be46a14e89
-event-producer-consumer.zh.md: 6c667f4c991f4d8245c9ebcf5a59b522014f9079
+event-producer-consumer.md: 3a7c8798771b00ebea90a1e8bfbc0265a9a13592
+event-producer-consumer.zh.md: 1041898b52bed1b20bd9a3cedb3d93d1515d614f

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

@@ -82,7 +82,7 @@ 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), `terminal-controller`, [`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` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |

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

@@ -84,7 +84,7 @@
 
 
 | 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), `terminal-controller`, [`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` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |

+ 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: b8f6ecef1389a8b4393e78d1429a5ab823db8d6d
-module-graph.zh.md: 471277027416588b9fd5794a71ddb40cd49f4799
+module-graph.md: 37fc86c39054b9e9f60098ac9b191a435afe8e2e
+module-graph.zh.md: cf01c57e30caa3d1a608e48a4a4dfad6f9ab2798

+ 5 - 0
docs/module-graph.md

@@ -114,6 +114,7 @@ flowchart TD
     pkg_api_remotes["api-remotes"]
     pkg_api_remotes["api-remotes"]
     pkg_api_session_controller["api-session-controller"]
     pkg_api_session_controller["api-session-controller"]
     pkg_api_settings_controller["api-settings-controller"]
     pkg_api_settings_controller["api-settings-controller"]
+    pkg_api_terminal_controller["api-terminal-controller"]
     pkg_api_workspace_controller["api-workspace-controller"]
     pkg_api_workspace_controller["api-workspace-controller"]
     pkg_api_workspace_files["api-workspace-files"]
     pkg_api_workspace_files["api-workspace-files"]
   end
   end
@@ -176,6 +177,7 @@ flowchart TD
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
+    pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_subagent["client-ui-subagent"]
     pkg_client_ui_subagent["client-ui-subagent"]
@@ -429,6 +431,7 @@ flowchart TD
   pkg_web_search_perplexity --> pkg_launch_environment
   pkg_web_search_perplexity --> pkg_launch_environment
   pkg_web_search_perplexity --> pkg_web
   pkg_web_search_perplexity --> pkg_web
   pkg_api_remotes --> pkg_scope
   pkg_api_remotes --> pkg_scope
+  pkg_api_terminal_controller --> pkg_subprocess
   pkg_attachment_local --> pkg_attachment
   pkg_attachment_local --> pkg_attachment
   pkg_attachment_local --> pkg_home_paths
   pkg_attachment_local --> pkg_home_paths
   pkg_client_file_upload --> pkg_scope
   pkg_client_file_upload --> pkg_scope
@@ -1319,6 +1322,7 @@ flowchart TD
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
+| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |
@@ -1374,6 +1378,7 @@ flowchart TD
 | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
+| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
 | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
 | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
 | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
 | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |

+ 5 - 0
docs/module-graph.zh.md

@@ -116,6 +116,7 @@ flowchart TD
     pkg_api_remotes["api-remotes"]
     pkg_api_remotes["api-remotes"]
     pkg_api_session_controller["api-session-controller"]
     pkg_api_session_controller["api-session-controller"]
     pkg_api_settings_controller["api-settings-controller"]
     pkg_api_settings_controller["api-settings-controller"]
+    pkg_api_terminal_controller["api-terminal-controller"]
     pkg_api_workspace_controller["api-workspace-controller"]
     pkg_api_workspace_controller["api-workspace-controller"]
     pkg_api_workspace_files["api-workspace-files"]
     pkg_api_workspace_files["api-workspace-files"]
   end
   end
@@ -178,6 +179,7 @@ flowchart TD
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
+    pkg_client_ui_sidebar_terminal["client-ui-sidebar-terminal"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_skill["client-ui-skill"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_slots["client-ui-slots"]
     pkg_client_ui_subagent["client-ui-subagent"]
     pkg_client_ui_subagent["client-ui-subagent"]
@@ -431,6 +433,7 @@ flowchart TD
   pkg_web_search_perplexity --> pkg_launch_environment
   pkg_web_search_perplexity --> pkg_launch_environment
   pkg_web_search_perplexity --> pkg_web
   pkg_web_search_perplexity --> pkg_web
   pkg_api_remotes --> pkg_scope
   pkg_api_remotes --> pkg_scope
+  pkg_api_terminal_controller --> pkg_subprocess
   pkg_attachment_local --> pkg_attachment
   pkg_attachment_local --> pkg_attachment
   pkg_attachment_local --> pkg_home_paths
   pkg_attachment_local --> pkg_home_paths
   pkg_client_file_upload --> pkg_scope
   pkg_client_file_upload --> pkg_scope
@@ -1321,6 +1324,7 @@ flowchart TD
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |
+| [`client-ui-sidebar-terminal`](../packages/client/ui-sidebar-terminal) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | — |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |
@@ -1376,6 +1380,7 @@ flowchart TD
 | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
 | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
+| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
 | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
 | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
 | [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
 | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
 | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |

+ 2 - 2
docs/subsystems/subprocess.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/subprocess.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md
-subprocess.md: d51291cdc42a50d4ecd408064bb6fb770e3e3f2e
-subprocess.zh.md: 054496701bc4be8f539ce0e1b5b573050d1bf961
+subprocess.md: e724903386b8c743da522a419401ac49ff1c1509
+subprocess.zh.md: b11a1cb0891123670be654d4c82105054979c911

+ 10 - 1
docs/subsystems/subprocess.md

@@ -244,7 +244,9 @@ interface SubprocessOutcome {
 
 
 `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics.
 `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics.
 
 
-The terminal spec fully specifies argv, cwd, environment overrides, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `inspectForeground`, `signalForeground`, and awaited `terminate`; the exact public shapes are generated into the [`ctx.subprocess` service catalog](#ctxsubprocess--subprocessruntime-abstract-seam).
+The terminal spec fully specifies argv, cwd, environment overrides, terminal type, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `resize`, `inspectForeground`, `signalForeground`, and awaited `terminate`; [`SubprocessTerminalSpawnSpec` and `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) define these fields and operations. `resize(cols, rows)` updates the live PTY dimensions and rejects after process exit.
+
+`terminalEnvironment(signal?)` returns `SubprocessTerminalEnvironment`: the execution environment platform (`posix` or `windows`) and optional `defaultShell`. These facts come from the provider rather than the Web server or browser. `resolveExecutable` verifies shell candidates; `SubprocessExecutableNotFoundError` identifies a missing executable, while provider and transport failures remain errors.
 
 
 ## Service behavior
 ## Service behavior
 
 
@@ -287,6 +289,13 @@ Implementations must honor these semantics:
  */
  */
 abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>
 abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>
 
 
+/**
+ * Inspect shell-selection facts in the provider's execution environment.
+ * @param signal - cancellation of remote environment inspection.
+ * @returns platform and preferred shell; executable lookup and allocation remain separate operations.
+ */
+abstract terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment>
+
 /**
 /**
  * Start one managed child process from a fully-specified spec; this seam
  * Start one managed child process from a fully-specified spec; this seam
  * applies no defaults.
  * applies no defaults.

+ 10 - 1
docs/subsystems/subprocess.zh.md

@@ -244,7 +244,9 @@ interface SubprocessOutcome {
 
 
 `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。
 `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。
 
 
-终端 spec 完全指定 argv、cwd、环境覆盖、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;确切的公共形状生成到 [`ctx.subprocess` 服务目录](#ctxsubprocess--subprocessruntime-abstract-seam)中。
+终端 spec 完全指定 argv、cwd、环境覆盖、终端类型、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`resize`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;[`SubprocessTerminalSpawnSpec` 与 `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) 定义这些字段和操作。`resize(cols, rows)` 更新正在运行的 PTY 尺寸,进程退出后拒绝调用。
+
+`terminalEnvironment(signal?)` 返回 `SubprocessTerminalEnvironment`:执行环境平台(`posix` 或 `windows`)与可选的 `defaultShell`。这些事实来自提供方,而非 Web 服务器或浏览器。`resolveExecutable` 验证候选 shell;`SubprocessExecutableNotFoundError` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。
 
 
 ## 服务行为
 ## 服务行为
 
 
@@ -287,6 +289,13 @@ Implementations must honor these semantics:
  */
  */
 abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>
 abstract resolveExecutable( command: string, env?: Readonly<Record<string, string>>, signal?: AbortSignal, ): Promise<string>
 
 
+/**
+ * Inspect shell-selection facts in the provider's execution environment.
+ * @param signal - cancellation of remote environment inspection.
+ * @returns platform and preferred shell; executable lookup and allocation remain separate operations.
+ */
+abstract terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment>
+
 /**
 /**
  * Start one managed child process from a fully-specified spec; this seam
  * Start one managed child process from a fully-specified spec; this seam
  * applies no defaults.
  * applies no defaults.

+ 2 - 2
docs/subsystems/workspace.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/workspace.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: 2c557c435c9eae9257e41ed2362fed832e437422
-workspace.zh.md: 76630506a2db6347e7155aedb12304a1c434959d
+workspace.md: 77e5dd39afb6d2840751d6d56a7a50f9fea2adaa
+workspace.zh.md: ad226e34b4fb508e9ce68e90349848d28632d559

+ 91 - 0
docs/subsystems/workspace.md

@@ -183,6 +183,97 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s
 
 
 Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts)
 Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts)
 
 
+<a id="ctxterminalcontroller--terminalcontroller"></a>
+
+### `ctx.terminalController` — `TerminalController`
+
+Typed Remote control of transient Session-owned terminal processes.
+
+```ts cordis-catalog
+/**
+ * Read the Session working directory and terminal limits without resolving a shell.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param signal - request cancellation.
+ * @returns the Session workspace directory and terminal limits.
+ */
+@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment
+
+/**
+ * Discover installed shells in the Session's execution environment.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param signal - request cancellation.
+ * @returns verified profiles, with the configured or system default first.
+ */
+@Remote shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]>
+
+/**
+ * List retained terminals without resolving or activating an Agent.
+ * @param sessionId - displayed Session identity, including offline history.
+ * @returns terminals retained for this Host lifetime.
+ */
+@Remote list(sessionId: SessionId): WebTerminalInfo[]
+
+/**
+ * Allocate an interactive shell once for a caller-generated identity.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param request - initial dimensions and idempotency identity.
+ * @param signal - allocation cancellation; committed terminals survive disconnection.
+ * @returns the existing or newly committed terminal.
+ */
+@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>
+
+/**
+ * Attach to a terminal without binding its process lifetime to the transport.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param attachmentId - new exclusive input attachment.
+ * @param signal - physical stream cancellation.
+ * @returns screen recovery followed by output and metadata changes.
+ */
+@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame>
+
+/**
+ * Deliver raw input, including Tab completion and control characters.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param attachmentId - current writable attachment.
+ * @param data - input bytes represented as UTF-8 text.
+ * @returns after provider input acceptance.
+ */
+@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise<void>
+
+/**
+ * Update the dimensions of the PTY and recovery screen.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param attachmentId - current writable attachment.
+ * @param cols - column count.
+ * @param rows - row count.
+ * @returns after the resize completes.
+ */
+@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise<void>
+
+/**
+ * Rename a terminal without changing its shell.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param title - nonempty display title, at most 120 characters.
+ */
+@Remote rename(agent: Agent, id: WebTerminalId, title: string): void
+
+/**
+ * Close an identity to future creation and kill its process range; repeated closes succeed.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @returns after provider cleanup succeeds. A failure retains the terminal for retry.
+ */
+@Remote async close(agent: Agent, id: WebTerminalId): Promise<void>
+```
+
+Types: [Agent](core.md) · [SessionId](core.md)
+
+Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts)
+
 <a id="ctxworkspacecontroller--workspacecontroller"></a>
 <a id="ctxworkspacecontroller--workspacecontroller"></a>
 
 
 ### `ctx.workspaceController` — `WorkspaceController`
 ### `ctx.workspaceController` — `WorkspaceController`

+ 91 - 0
docs/subsystems/workspace.zh.md

@@ -183,6 +183,97 @@ Host service backing the generated `ctx.remote.directoryPicker` namespace. The s
 
 
 Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts)
 Source: [`packages/api/workspace-controller/src/directory-picker.ts`](../../packages/api/workspace-controller/src/directory-picker.ts)
 
 
+<a id="ctxterminalcontroller--terminalcontroller"></a>
+
+### `ctx.terminalController` — `TerminalController`
+
+Typed Remote control of transient Session-owned terminal processes.
+
+```ts cordis-catalog
+/**
+ * Read the Session working directory and terminal limits without resolving a shell.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param signal - request cancellation.
+ * @returns the Session workspace directory and terminal limits.
+ */
+@Remote environment(agent: Agent, signal: AbortSignal): TerminalEnvironment
+
+/**
+ * Discover installed shells in the Session's execution environment.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param signal - request cancellation.
+ * @returns verified profiles, with the configured or system default first.
+ */
+@Remote shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]>
+
+/**
+ * List retained terminals without resolving or activating an Agent.
+ * @param sessionId - displayed Session identity, including offline history.
+ * @returns terminals retained for this Host lifetime.
+ */
+@Remote list(sessionId: SessionId): WebTerminalInfo[]
+
+/**
+ * Allocate an interactive shell once for a caller-generated identity.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param request - initial dimensions and idempotency identity.
+ * @param signal - allocation cancellation; committed terminals survive disconnection.
+ * @returns the existing or newly committed terminal.
+ */
+@Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>
+
+/**
+ * Attach to a terminal without binding its process lifetime to the transport.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param attachmentId - new exclusive input attachment.
+ * @param signal - physical stream cancellation.
+ * @returns screen recovery followed by output and metadata changes.
+ */
+@Remote({ mode: 'stream' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame>
+
+/**
+ * Deliver raw input, including Tab completion and control characters.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param attachmentId - current writable attachment.
+ * @param data - input bytes represented as UTF-8 text.
+ * @returns after provider input acceptance.
+ */
+@Remote async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise<void>
+
+/**
+ * Update the dimensions of the PTY and recovery screen.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param attachmentId - current writable attachment.
+ * @param cols - column count.
+ * @param rows - row count.
+ * @returns after the resize completes.
+ */
+@Remote async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise<void>
+
+/**
+ * Rename a terminal without changing its shell.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @param title - nonempty display title, at most 120 characters.
+ */
+@Remote rename(agent: Agent, id: WebTerminalId, title: string): void
+
+/**
+ * Close an identity to future creation and kill its process range; repeated closes succeed.
+ * @param agent - Session owner supplied by the Gateway.
+ * @param id - terminal identity.
+ * @returns after provider cleanup succeeds. A failure retains the terminal for retry.
+ */
+@Remote async close(agent: Agent, id: WebTerminalId): Promise<void>
+```
+
+Types: [Agent](core.zh.md) · [SessionId](core.zh.md)
+
+Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts)
+
 <a id="ctxworkspacecontroller--workspacecontroller"></a>
 <a id="ctxworkspacecontroller--workspacecontroller"></a>
 
 
 ### `ctx.workspaceController` — `WorkspaceController`
 ### `ctx.workspaceController` — `WorkspaceController`

+ 2 - 2
packages/api/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/api/README.md
 #   pnpm run verify-translation-pairing --write packages/api/README.md
-README.md: 5bc878fa53b9ad0c3795c13e9442274b98820190
-README.zh.md: 8aa364850e2378c7361807ae1065cfdd6eeaa9cc
+README.md: f1aefa7d4564539f94202e869bacb85857f939d5
+README.zh.md: 870bbd32552166fdbb06221baaf54b9a130c6069

+ 1 - 0
packages/api/README.md

@@ -31,6 +31,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust
 | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` |
 | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` |
 | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` |
 | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` |
 | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` |
 | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` |
+| [`terminal-controller/`](terminal-controller/README.md) | Session-owned interactive shells, screen recovery and browser terminal control. | `ctx.terminalController` / `ctx.remote.terminal` |
 | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 | [`workspace-files/`](workspace-files/README.md) | Owns bounded workspace file access — `stat`, paged `read`, `list`, and the instrumented-operation `changes` feed — and the Client `file` resource provider over it. | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 
 Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads.
 Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads.

+ 1 - 0
packages/api/README.zh.md

@@ -31,6 +31,7 @@ kind: "package-group"
 | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` |
 | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` |
 | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` |
 | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` |
 | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` |
 | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` |
+| [`terminal-controller/`](terminal-controller/README.zh.md) | Session 拥有的交互式 shell、屏幕恢复和浏览器终端控制。 | `ctx.terminalController` / `ctx.remote.terminal` |
 | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 | [`workspace-files/`](workspace-files/README.zh.md) | 拥有有界的工作区文件访问——`stat`、分页 `read`、`list` 与已埋点操作的 `changes` 流——以及其上的 Client `file` 资源提供方。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 
 Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。
 Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各控制器包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。

+ 2 - 1
packages/api/remotes/package.json

@@ -86,6 +86,7 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "zod": "^4.4.3",
     "zod": "^4.4.3",
-    "@deepseek-ai/dsh-command-feedback": "workspace:^"
+    "@deepseek-ai/dsh-command-feedback": "workspace:^",
+    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^"
   }
   }
 }
 }

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

@@ -16,6 +16,7 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
 import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
 import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
 import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
 import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
 import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
 import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
+import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote'
 import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote'
 import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
 
 
@@ -40,6 +41,8 @@ export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
 export type * from '@deepseek-ai/dsh-api-workspace-controller/types'
 export type * from '@deepseek-ai/dsh-api-workspace-controller/types'
 export type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
 export type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
 export type * from '@deepseek-ai/dsh-api-workspace-files/types'
 export type * from '@deepseek-ai/dsh-api-workspace-files/types'
+export type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
+export type * from '@deepseek-ai/dsh-api-terminal-controller/types'
 export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
 export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
 // The forwarded-event allowlist's selection seat: without it in the consumer's
 // The forwarded-event allowlist's selection seat: without it in the consumer's
 // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails.
 // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails.
@@ -156,7 +159,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
     for (const contribution of [
     for (const contribution of [
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
       agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
       pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
       pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
-      permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote,
+      permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
     ]) {
     ]) {
       disposers.push(await ctx.remote.$mount(contribution))
       disposers.push(await ctx.remote.$mount(contribution))
     }
     }

+ 3 - 0
packages/api/remotes/tsconfig.client.json

@@ -85,6 +85,9 @@
     },
     },
     {
     {
       "path": "../../feedback/command-feedback"
       "path": "../../feedback/command-feedback"
+    },
+    {
+      "path": "../terminal-controller/tsconfig.client.json"
     }
     }
   ]
   ]
 }
 }

+ 3 - 0
packages/api/remotes/tsconfig.host.json

@@ -70,6 +70,9 @@
     },
     },
     {
     {
       "path": "../../typert/protocol"
       "path": "../../typert/protocol"
+    },
+    {
+      "path": "../terminal-controller/tsconfig.host.json"
     }
     }
   ]
   ]
 }
 }

+ 6 - 0
packages/api/terminal-controller/README.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 packages/api/terminal-controller/README.md
+README.md: c302a2b7269bdb6a76d0c6056d5851a84a876e9a
+README.zh.md: dfc50e30f3a35b70c40bb76f9832cbd9548ba2cf

+ 93 - 0
packages/api/terminal-controller/README.md

@@ -0,0 +1,93 @@
+---
+description: "Interactive user terminals with execution-environment shell defaults, bounded screen recovery and typed Remote control."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-api-terminal-controller
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Open the execution environment's default shell in a Session workspace from the Web sidebar. Reconnect to existing processes and close their complete provider-owned process ranges. Terminal output stays outside the Agent transcript. Keeping a terminal open retains its process and a bounded screen buffer.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
+
+Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal.
+
+| Configuration | Default | Meaning |
+|---|---|---|
+| `shell` | omitted | Use the execution environment's default shell, or one explicit profile |
+| `shellCandidates` | `zsh`, `bash`, `fish`, `sh`, `ksh`, `tcsh`, `csh`, `pwsh`, `powershell`, `cmd`, `nu` | Additional executable names or paths offered when installed |
+| `maxTerminals` | `8` | Retained terminals and pending allocations per Session |
+| `maxCols`, `maxRows` | `500`, `200` | Maximum PTY dimensions |
+| `scrollback` | `1000` | Retained screen history rows |
+| `maxBufferedBytes` | `2097152` | Output queued for one follower |
+| `maxInputBytes` | `65536` | Maximum input request bytes |
+| `disposeGraceMs` | `1000` | Provider termination grace in milliseconds |
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen.
+
+The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed.
+
+Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
+
+New views wait for explicit shell selection and start. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. A saved path is preselected only while Host discovery still lists it; otherwise the current default is selected. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
+
+Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
+
+</details>
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Subprocess](../../subprocess/subprocess/README.md)
+- [Right Sidebar](../../client/ui-sidebar-right/README.md)
+- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md)
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as this package handles user terminal interaction without adding model input.
+
+#### KV Cache effect
+
+None; terminal output travels only between the browser and Host.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. Exited terminals count toward `maxTerminals`; close unused tabs to release their screens and quota.
+- The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed.
+- Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+No runtime invariant companion is published. One owner orders terminal metadata and screen updates; the provider exposes no independently observed dimensions to compare.
+
+</details>

+ 93 - 0
packages/api/terminal-controller/README.zh.md

@@ -0,0 +1,93 @@
+---
+description: "用户交互式终端:执行环境默认 shell、有界屏幕恢复和类型化 Remote 控制。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-api-terminal-controller
+
+[English](README.md) | 中文
+
+## 概述
+
+从 Web 侧栏在会话工作区打开执行环境的默认 shell。重新连接已有进程,并关闭 provider 管理的完整进程范围。终端输出不进入 Agent 对话记录。保留终端会占用进程和有界屏幕缓存。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [了解实现](#understand-the-implementation)
+- [延伸阅读](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用此包
+
+Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
+
+Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
+
+| 配置 | 默认值 | 含义 |
+|---|---|---|
+| `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile |
+| `shellCandidates` | `zsh`、`bash`、`fish`、`sh`、`ksh`、`tcsh`、`csh`、`pwsh`、`powershell`、`cmd`、`nu` | 已安装时供用户选择的额外可执行名称或路径 |
+| `maxTerminals` | `8` | 每个 Session 保留的终端和创建请求上限 |
+| `maxCols`、`maxRows` | `500`、`200` | PTY 最大尺寸 |
+| `scrollback` | `1000` | 屏幕历史行数 |
+| `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 |
+| `maxInputBytes` | `65536` | 单次输入请求的字节上限 |
+| `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 |
+
+<a id="understand-the-implementation"></a>
+## 了解实现
+
+<details>
+<summary>实现细节</summary>
+
+Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。
+
+最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。
+
+Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
+
+新视图等待用户选择 shell 并启动。上次选择的 shell 路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。仅当 Host 探测仍列出该路径时才预选它,否则选择当前默认项。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
+
+关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
+
+</details>
+
+<a id="further-exploration"></a>
+## 延伸阅读
+
+- [Subprocess](../../subprocess/subprocess/README.zh.md)
+- [Right Sidebar](../../client/ui-sidebar-right/README.zh.md)
+- [Web terminal decision](../../../.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md)
+
+<a id="model-experience"></a>
+## 模型体验
+
+无;此包只处理用户交互式终端,不向模型请求添加内容。
+
+#### KV 缓存影响
+
+无;终端输出只在浏览器与 Host 之间传输。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。
+- 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。
+- 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护说明</summary>
+
+不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。
+
+</details>

+ 93 - 0
packages/api/terminal-controller/package.json

@@ -0,0 +1,93 @@
+{
+  "name": "@deepseek-ai/dsh-api-terminal-controller",
+  "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
+  "version": "0.1.5-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/api/terminal-controller"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-gateway"
+      ],
+      "external": [
+        "@deepseek-ai/dsh-api-gateway/client"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/dsh-deque": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/schemastery": "workspace:^",
+    "@xterm/headless": "^6.0.0",
+    "@xterm/addon-serialize": "^0.14.0",
+    "zod": "^4.4.3"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-agent": "workspace:^",
+    "@deepseek-ai/dsh-api-gateway": "workspace:^",
+    "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-fs": "workspace:^",
+    "@deepseek-ai/dsh-sandbox": "workspace:^",
+    "@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-subprocess": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
+  ]
+}

+ 73 - 0
packages/api/terminal-controller/src/client/close-requests.ts

@@ -0,0 +1,73 @@
+/** Unfinished close requests survive reload independently of the removed sidebar tabs. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WebTerminalId } from '../types.ts'
+
+/** An explicit cleanup request; no process or open-tab metadata is mirrored here. */
+export interface TerminalCloseRequest {
+  readonly sessionId: SessionId
+  readonly id: WebTerminalId
+  readonly title: string
+}
+
+const PREFIX = 'dsh.terminal.close.v1.'
+
+/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */
+export class TerminalCloseRequests {
+  private readonly requests = new Map<WebTerminalId, TerminalCloseRequest>()
+
+  constructor() {
+    try {
+      if (typeof localStorage === 'undefined') return
+      for (let index = 0; index < localStorage.length; index++) {
+        const key = localStorage.key(index)
+        if (key?.startsWith(PREFIX)) this.load(key)
+      }
+    } catch (error) { console.error('Terminal cleanup recovery failed:', error) }
+  }
+
+  /**
+   * Read cleanup work still awaiting Host confirmation.
+   * @returns unfinished requests owned by this browser instance.
+   */
+  pending(): readonly TerminalCloseRequest[] { return [...this.requests.values()] }
+
+  /**
+   * Retain cleanup across reload before removing a tab.
+   * @param request - close intent to save before removing its tab.
+   */
+  save(request: TerminalCloseRequest): void {
+    this.requests.set(request.id, request)
+    try {
+      if (typeof localStorage !== 'undefined') localStorage.setItem(PREFIX + request.id, JSON.stringify(request))
+    } catch (error) { console.error('Terminal cleanup persistence failed:', error) }
+  }
+
+  /**
+   * Forget confirmed cleanup in memory and browser storage.
+   * @param id - terminal whose Host cleanup succeeded.
+   */
+  remove(id: WebTerminalId): void {
+    this.requests.delete(id)
+    try {
+      if (typeof localStorage !== 'undefined') localStorage.removeItem(PREFIX + id)
+    } catch (error) { console.error('Terminal cleanup persistence failed:', error) }
+  }
+
+  private load(key: string): void {
+    try {
+      const raw = localStorage.getItem(key)
+      if (raw === null) return
+      const parsed: unknown = JSON.parse(raw)
+      if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error('Invalid terminal cleanup request')
+      this.requests.set(parsed.id, parsed)
+    } catch (error) { console.error('Terminal cleanup recovery failed:', error) }
+  }
+}
+
+function isRequest(value: unknown): value is TerminalCloseRequest {
+  if (typeof value !== 'object' || value === null) return false
+  const request = value as Record<string, unknown>
+  return typeof request.sessionId === 'string' && request.sessionId.length > 0
+    && typeof request.id === 'string' && /^[\w-]{1,128}$/u.test(request.id)
+    && typeof request.title === 'string'
+}

+ 150 - 0
packages/api/terminal-controller/src/client/index.ts

@@ -0,0 +1,150 @@
+/** Client terminal model service; views are keyed independently from Host terminal identities. */
+import { Service, type Context } from '@deepseek-ai/cordis'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {} from '@deepseek-ai/dsh-api-gateway/client'
+import { TerminalView, type TerminalRemote } from './model.ts'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import type { WebTerminalId, WebTerminalInfo } from '../types.ts'
+import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
+
+export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** React-free browser terminal views and explicit process cleanup. */
+    webTerminals: ClientTerminals
+  }
+}
+
+/** A failed background close that can be retried without restoring its tab. */
+export interface TerminalCloseFailure {
+  readonly id: WebTerminalId
+  readonly title: string
+  readonly message: string
+}
+
+/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
+export class ClientTerminals extends Service {
+  /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
+  readonly closeFailures: SnapshotStore<readonly TerminalCloseFailure[]> = createSnapshotStore<readonly TerminalCloseFailure[]>([])
+  private readonly requests = new TerminalCloseRequests()
+  private readonly closing = new Map<WebTerminalId, Promise<void>>()
+  private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
+  private disposed = false
+  private readonly views = new Map<SessionId, Map<string, TerminalView>>()
+
+  /**
+   * @param ctx - Client root Context with Gateway and terminal Remote namespace.
+   * @param remote - generated terminal namespace.
+   */
+  constructor(ctx: Context, private readonly remote: TerminalRemote) {
+    super(ctx, 'webTerminals')
+    ctx.effect(() => async () => {
+      this.disposed = true
+      const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()))
+      this.views.clear()
+      await Promise.all([...detaching, ...this.closing.values()])
+    }, 'terminal-controller.client.views')
+    for (const request of this.requests.pending()) this.cleanup(request)
+  }
+
+  /**
+   * Return the stable model for one sidebar occurrence.
+   * @param sessionId - owning Session.
+   * @param key - sidebar occurrence key.
+   * @param terminalId - existing Host identity when restoring a listed terminal.
+   * @returns its observable state and terminal commands.
+   */
+  view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView {
+    let views = this.views.get(sessionId)
+    if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
+    let view = views.get(key)
+    if (view === undefined) {
+      const id = terminalId ?? randomUUID() as WebTerminalId
+      view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined)
+      views.set(key, view)
+      void view.refresh()
+    }
+    return view
+  }
+
+  /**
+   * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
+   * @param sessionId - owning Session.
+   * @param key - sidebar occurrence key, including an inactive restored tab.
+   * @param terminalId - restored identity if the tab has no model yet.
+   */
+  close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void {
+    const views = this.views.get(sessionId)
+    const view = views?.get(key)
+    const id = view?.id ?? terminalId
+    if (id === undefined) return
+    const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key }
+    this.closed.add(id)
+    this.requests.save(request)
+    views?.delete(key)
+    if (views?.size === 0) this.views.delete(sessionId)
+    this.cleanup(request, view)
+  }
+
+  /**
+   * Query Host terminals that have neither a tab in this page nor an unfinished close.
+   * @param sessionId - Session being displayed.
+   * @returns terminals available for opening as recovered tabs.
+   */
+  async recover(sessionId: SessionId): Promise<WebTerminalInfo[]> {
+    const result = await this.remote.list(sessionId)
+    if (!result.ok) throw new Error(result.error.message)
+    const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id))
+    const closing = new Set(this.requests.pending().map(request => request.id))
+    return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id))
+  }
+
+  /**
+   * Retry a saved close request without reopening its tab.
+   * @param id - failed terminal identity.
+   */
+  retryClose(id: WebTerminalId): void {
+    const record = this.requests.pending().find(item => item.id === id)
+    if (record !== undefined) this.cleanup(record)
+  }
+
+  private cleanup(record: TerminalCloseRequest, view?: TerminalView): void {
+    if (this.closing.has(record.id) || this.disposed) return
+    this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id))
+    const pending = (async () => {
+      if (view !== undefined) await view.close()
+      else {
+        const result = await this.remote.close(record.sessionId, record.id)
+        if (!result.ok) throw result.error
+      }
+      this.requests.remove(record.id)
+    })().catch((error: unknown) => {
+      if (remoteErrorOf(error)?.code === 'session/not-found') {
+        this.requests.remove(record.id)
+        return
+      }
+      if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
+        id: record.id, title: record.title,
+        message: error instanceof Error ? error.message : String(error),
+      }])
+    }).then(async () => {
+      await view?.dispose()
+      this.closing.delete(record.id)
+    })
+    this.closing.set(record.id, pending)
+  }
+}
+
+/** Required Client transport and terminal namespace. */
+export const inject = ['remote', 'remote.terminal']
+
+/**
+ * Install the Client terminal models.
+ * @param ctx - Client root Context.
+ */
+export function apply(ctx: Context): void {
+  new ClientTerminals(ctx, ctx.remote.terminal)
+}

+ 352 - 0
packages/api/terminal-controller/src/client/model.ts

@@ -0,0 +1,352 @@
+/** React-free browser terminal state and reconnecting Remote-stream ownership. */
+import { preferredShell, rememberShell } from './shell-preference.ts'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { RemoteStreamCarrierError, type ClientRemote, type RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
+import { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {
+  TerminalShell, TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
+  WebTerminalId, WebTerminalInfo,
+} from '../types.ts'
+
+/** The generated terminal namespace's browser-facing operations. */
+export type TerminalRemote = ClientRemote['terminal']
+
+/** Product error identifiers translated by the terminal UI. */
+export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** Client terminal failure preserved through the Remote stream supervisor. */
+    'terminal/view': { readonly issue: TerminalViewIssue }
+  }
+}
+
+class TerminalViewError extends RemoteError<'terminal/view'> {
+  constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) }
+}
+
+/** One screen write awaiting the DOM emulator's callback. */
+export interface TerminalRenderFrame {
+  readonly revision: number
+  readonly frame: Extract<TerminalFrame, { type: 'snapshot' | 'output' }>
+}
+
+/** Observable state of one sidebar occurrence. */
+export interface TerminalViewState {
+  readonly phase: 'idle' | 'loading' | 'selecting' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
+  readonly shells?: readonly TerminalShell[] | undefined
+  readonly selectedShell?: string | undefined
+  readonly environment?: TerminalEnvironment | undefined
+  readonly title?: string | undefined
+  readonly info?: WebTerminalInfo | undefined
+  readonly writable: boolean
+  readonly render?: TerminalRenderFrame | undefined
+  readonly error?: string | undefined
+  readonly issue?: TerminalViewIssue | undefined
+}
+
+/** A view survives DOM unmount; its process only ends on explicit close. */
+export class TerminalView {
+  /** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
+  readonly state: SnapshotStore<TerminalViewState> = createSnapshotStore<TerminalViewState>({ phase: 'idle', writable: false })
+  private readonly lifetime = new AbortController()
+  private stream: RemoteStream<TerminalFrame> | undefined
+  private mounted = false
+  private attachmentId: TerminalAttachmentId | undefined
+  private pendingRender: { revision: number; resolve: () => void } | undefined
+  private revision = 0
+  private creation: Promise<void> | undefined
+  private loading: Promise<void> | undefined
+  private closing: Promise<void> | undefined
+  private writes: Promise<void> = Promise.resolve()
+  private queuedInput = 0
+  private readonly detaching = new Set<Promise<void>>()
+
+  /**
+   * @param sessionId - Session owning the terminal.
+   * @param remote - typed terminal Remote operations.
+   * @param gateway - reconnecting stream factory.
+   * @param id - Host terminal identity, reused when recovering an item from its Session list.
+   * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
+   */
+  constructor(
+    private readonly sessionId: SessionId,
+    private readonly remote: TerminalRemote,
+    private readonly gateway: Pick<ClientRemote, '$stream'>,
+    readonly id: WebTerminalId,
+    private readonly createWhenMissing = true,
+  ) {}
+
+  /**
+   * Attach the DOM lifetime, loading shell choices or reconnecting the saved process.
+   * @returns a detach callback that leaves the terminal process alive.
+   */
+  mount(): () => void {
+    this.mounted = true
+    if (this.state.getSnapshot().info === undefined) void this.refresh()
+    else this.connect()
+    return () => {
+      this.mounted = false
+      this.detach()
+    }
+  }
+
+  /**
+   * Start or recover this tab, deduplicating mounts and retries during allocation.
+   * Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
+   * @returns after environment lookup and creation or recovery settle.
+   */
+  refresh(): Promise<void> {
+    if (this.creation !== undefined) return this.creation
+    if (this.loading !== undefined) return this.loading
+    if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
+    this.patch({ phase: 'loading', error: undefined, issue: undefined })
+    this.loading = (async () => {
+      const [environment, available] = await Promise.all([
+        this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
+      ])
+      if (this.stopped()) return
+      this.patch({ environment: valueOf(environment) })
+      const info = valueOf(available).find(item => item.id === this.id)
+      if (info !== undefined) this.adopt(info)
+      else if (this.createWhenMissing) {
+        const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal))
+        if (this.stopped()) return
+        const previous = this.state.getSnapshot().selectedShell ?? preferredShell()
+        const selectedShell = shells.find(shell => shell.path === previous)?.path ?? shells[0]?.path
+        this.patch({ phase: 'selecting', shells, selectedShell })
+      }
+      else throw new TerminalViewError('missingTerminal')
+    })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
+    return this.loading
+  }
+
+  /**
+   * Select and remember one verified shell before starting the terminal.
+   * @param path - executable path offered by Host discovery.
+   */
+  selectShell(path: string): void {
+    const state = this.state.getSnapshot()
+    if (state.phase === 'selecting' && state.shells?.some(shell => shell.path === path)) {
+      this.patch({ selectedShell: path })
+      rememberShell(path)
+    }
+  }
+
+  /**
+   * Start the selected shell once; failed attempts retain their selection for retry.
+   * @returns after allocation settles; failures are exposed in observable state.
+   */
+  start(): Promise<void> {
+    if (this.creation !== undefined) return this.creation
+    const state = this.state.getSnapshot()
+    if (state.phase !== 'selecting' || state.environment === undefined || state.selectedShell === undefined
+      || this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
+    rememberShell(state.selectedShell)
+    return this.create(state.environment, state.selectedShell)
+  }
+
+  private stopped(): boolean { return this.lifetime.signal.aborted || this.closing !== undefined }
+
+  private async create(environment: TerminalEnvironment, shellPath: string): Promise<void> {
+    this.patch({ phase: 'creating', error: undefined, issue: undefined })
+    this.creation = (async () => {
+      const info = valueOf(await this.remote.create(this.sessionId, {
+        id: this.id, shellPath, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
+      }, this.lifetime.signal))
+      if (!this.lifetime.signal.aborted) {
+        this.adopt(info)
+      }
+    })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.creation = undefined })
+    await this.creation
+  }
+
+  private adopt(info: WebTerminalInfo): void {
+    this.patch({ info, title: info.title })
+    if (this.mounted && this.closing === undefined) this.connect()
+  }
+
+  /** Reattach with a fresh screen and regain input control. */
+  connect(): void {
+    const info = this.state.getSnapshot().info
+    if (info === undefined || !this.mounted || this.closing !== undefined || this.lifetime.signal.aborted) return
+    this.detach()
+    const stream = this.gateway.$stream<TerminalFrame>({
+      name: 'Browser terminal output',
+      open: (signal) => {
+        const attachmentId = randomUUID() as TerminalAttachmentId
+        this.attachmentId = attachmentId
+        return this.remote.follow(this.sessionId, info.id, attachmentId, signal)
+      },
+      ended: () => new TerminalViewError('attachmentEnded'),
+      carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
+    })
+    this.stream = stream
+    this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined })
+    void this.consume(stream)
+  }
+
+  /**
+   * Release the next stream item after xterm has parsed this frame.
+   * @param revision - locally delivered render revision.
+   */
+  acknowledge(revision: number): void {
+    if (this.pendingRender?.revision !== revision) return
+    this.pendingRender.resolve()
+    this.pendingRender = undefined
+  }
+
+  /**
+   * Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
+   * @param data - input from the terminal emulator.
+   */
+  write(data: string): void {
+    const state = this.state.getSnapshot()
+    const attachmentId = this.attachmentId
+    if (!state.writable || state.info === undefined || attachmentId === undefined) return
+    const bytes = new TextEncoder().encode(data).byteLength
+    if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return }
+    this.queuedInput += bytes
+    const id = state.info.id
+    this.writes = this.writes.then(async () => {
+      if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
+      valueOf(await this.remote.write(this.sessionId, id, attachmentId, data))
+    }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes })
+  }
+
+  /**
+   * Resize only from the currently writable view.
+   * @param cols - measured column count.
+   * @param rows - measured row count.
+   */
+  resize(cols: number, rows: number): void {
+    const state = this.state.getSnapshot()
+    const attachmentId = this.attachmentId
+    if (!state.writable || state.info === undefined || attachmentId === undefined) return
+    if (state.info.cols === cols && state.info.rows === rows) return
+    const id = state.info.id
+    cols = Math.min(cols, state.environment?.maxCols ?? cols)
+    rows = Math.min(rows, state.environment?.maxRows ?? rows)
+    this.writes = this.writes.then(async () => {
+      if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
+      valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows))
+    }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) })
+  }
+
+  /**
+   * Update the Host terminal's display name.
+   * @param title - user-entered terminal title.
+   * @returns after the rename settles and its result is reflected in view state.
+   */
+  async rename(title: string): Promise<void> {
+    if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return
+    try {
+      valueOf(await this.remote.rename(this.sessionId, this.id, title))
+      const current = this.state.getSnapshot().info
+      this.patch({ ...(current === undefined ? {} : { info: { ...current, title: title.trim() } }), title: title.trim() })
+    } catch (error) { this.fail(error) }
+  }
+
+  /**
+   * Explicitly terminate this view's process independently of its DOM lifetime.
+   * @returns after Host process cleanup succeeds; failures remain retryable by the owner.
+   */
+  close(): Promise<void> {
+    if (this.closing !== undefined) return this.closing
+    this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined })
+    this.detach()
+    this.closing = (async () => {
+      // Even a refused or lost creation response may leave an allocation to close.
+      await this.creation
+      valueOf(await this.remote.close(this.sessionId, this.id))
+      this.detach()
+      this.patch({ phase: 'closed', writable: false })
+    })().catch((error: unknown) => { this.closing = undefined; this.fail(error); throw error })
+    return this.closing
+  }
+
+  /**
+   * Stop Client work on plugin unload without closing Host terminals.
+   * @returns after active and previously detached stream iterators have closed.
+   */
+  async dispose(): Promise<void> {
+    this.mounted = false
+    this.lifetime.abort()
+    this.detach()
+    await Promise.all(this.detaching)
+  }
+
+  private detach(): void {
+    const previous = this.stream
+    this.stream = undefined
+    this.attachmentId = undefined
+    this.pendingRender?.resolve()
+    this.pendingRender = undefined
+    if (previous !== undefined) {
+      const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) })
+      this.detaching.add(cleanup)
+    }
+  }
+
+  private async consume(stream: RemoteStream<TerminalFrame>): Promise<void> {
+    let generation = 0
+    let sequence = 0
+    try {
+      for await (const item of stream) {
+        if (this.stream !== stream) return
+        const frame = item.value
+        if (generation !== item.generation) {
+          if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot')
+          generation = item.generation
+          sequence = frame.sequence
+          item.accept()
+        } else if (frame.type === 'output') {
+          if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap')
+          sequence = frame.sequence
+        } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot')
+        if (frame.type !== 'output') {
+          this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId })
+        }
+        if (frame.type !== 'state') {
+          const revision = ++this.revision
+          await new Promise<void>((resolve) => {
+            this.pendingRender = { revision, resolve }
+            const aborted = (): void => { this.acknowledge(revision) }
+            item.signal.addEventListener('abort', aborted, { once: true })
+            this.pendingRender.resolve = () => { item.signal.removeEventListener('abort', aborted); resolve() }
+            this.patch({ render: { revision, frame } })
+            if (item.signal.aborted) aborted()
+          })
+        }
+      }
+    } catch (error) {
+      if (this.stream === stream) {
+        if (this.state.getSnapshot().info?.state === 'exited') this.patch({ phase: 'closed', writable: false })
+        else this.fail(error)
+      }
+    }
+  }
+
+  private patch(patch: Partial<TerminalViewState>): void {
+    if (this.lifetime.signal.aborted) return
+    this.state.set({ ...this.state.getSnapshot(), ...patch })
+  }
+
+  private fail(error: unknown): void {
+    const failure = remoteErrorOf(error)
+    if (failure?.code === 'terminal/control-unavailable') {
+      this.patch({ writable: false, error: undefined, issue: undefined })
+      return
+    }
+    const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined
+    this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
+  }
+}
+
+function valueOf<T>(result: RemoteResult<T>): T {
+  if (!result.ok) throw result.error
+  return result.value
+}

+ 20 - 0
packages/api/terminal-controller/src/client/shell-preference.ts

@@ -0,0 +1,20 @@
+/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */
+const KEY = 'dsh.terminal.shell'
+
+/**
+ * Read the browser preference.
+ * @returns the last selected shell path, or null when storage is unavailable.
+ */
+export function preferredShell(): string | null {
+  try { return typeof localStorage === 'undefined' ? null : localStorage.getItem(KEY) }
+  catch (_storageUnavailable) { return null }
+}
+
+/**
+ * Remember the selected shell without making storage a startup dependency.
+ * @param path - verified executable path offered by the Host.
+ */
+export function rememberShell(path: string): void {
+  try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, path) }
+  catch (_storageUnavailable) { /* Private browsing or quota failure leaves this launch usable. */ }
+}

+ 360 - 0
packages/api/terminal-controller/src/index.ts

@@ -0,0 +1,360 @@
+/** Session-scoped browser terminals over the composed subprocess and sandbox providers. */
+import type { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
+import type {} from '@deepseek-ai/dsh-sandbox'
+import type {} from '@deepseek-ai/dsh-session-projection'
+import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { discoverShells, resolveShell } from './shells.ts'
+import { BrowserTerminal } from './terminal.ts'
+import type {
+  TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame,
+  WebTerminalId, WebTerminalInfo,
+} from './types.ts'
+
+export type * from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Interactive user terminals, separate from the Agent terminal tool registry. */
+    terminalController: TerminalController
+  }
+}
+
+/** Deployment limits and an optional shell profile. */
+export interface Config {
+  /** Explicit shell profile; omission uses the execution environment's default shell. */
+  readonly shell?: {
+    /** Executable path or PATH name, verified by the subprocess provider. */
+    path: string
+    /** User-visible profile name. */
+    name: string
+    /** Arguments passed to the interactive shell. */
+    args: string[]
+  } | undefined
+  /** Executable names or paths checked for the new-terminal shell selector. */
+  readonly shellCandidates: string[]
+  /** Maximum retained terminals and pending allocations per Session. */
+  readonly maxTerminals: number
+  /** Maximum terminal width in columns. */
+  readonly maxCols: number
+  /** Maximum terminal height in rows. */
+  readonly maxRows: number
+  /** Screen history rows retained for reconnecting clients. */
+  readonly scrollback: number
+  /** Maximum queued UTF-8 frame bytes per output follower before disconnection. */
+  readonly maxBufferedBytes: number
+  /** Maximum UTF-8 bytes in one input request. */
+  readonly maxInputBytes: number
+  /** Provider process-termination grace period in milliseconds. */
+  readonly disposeGraceMs: number
+}
+
+interface OwnedSession {
+  readonly lifetime: AbortController
+  readonly closedIds: Set<WebTerminalId>
+  cleanup?: Promise<void>
+  readonly terminals: Map<WebTerminalId, BrowserTerminal>
+  readonly pending: Map<WebTerminalId, Promise<BrowserTerminal>>
+  readonly allocations: Map<WebTerminalId, { handle: SubprocessTerminalHandle; info: WebTerminalInfo }>
+}
+
+/** Typed Remote control of transient Session-owned terminal processes. */
+export class TerminalController extends TypertRemoteService {
+  static inject = ['subprocess', 'sandboxPolicy', 'sessionProjections', 'typert']
+  static Config: z<Config> = z.object({
+    shell: z.union([z.object({
+      path: z.string().required(), name: z.string().required(), args: z.array(z.string()).default([]),
+    }), z.const(undefined)]),
+    shellCandidates: z.array(z.string().min(1)).default(['zsh', 'bash', 'fish', 'sh', 'ksh', 'tcsh', 'csh', 'pwsh', 'powershell', 'cmd', 'nu']),
+    maxTerminals: z.number().step(1).min(1).default(8),
+    maxCols: z.number().step(1).min(2).default(500),
+    maxRows: z.number().step(1).min(1).default(200),
+    scrollback: z.number().step(1).min(0).default(1000),
+    maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
+    maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
+    disposeGraceMs: z.number().step(1).min(1).default(1000),
+  })
+
+  private readonly owners = new Map<SessionId, OwnedSession>()
+  private readonly lifetime = new AbortController()
+
+  /**
+   * @param ctx - Host context carrying typed Remote and execution providers.
+   * @param config - validated terminal limits and optional shell profile.
+   */
+  constructor(ctx: Context, private readonly config: Config) {
+    super(ctx, 'terminalController', { namespace: 'terminal' })
+    ctx.on('internal/dispatch', (_mode, eventName, args) => {
+      if (eventName !== 'session/event') return
+      const [session, event] = args as [Session, SessionEvent]
+      if (event.type !== 'sandbox/mode') return
+      const owner = this.owners.get(session.id)
+      if (owner === undefined || owner.terminals.size + owner.pending.size + owner.allocations.size === 0) return
+      const current = ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? ctx.sandboxPolicy.defaultMode
+      if (event.data.mode !== current) throw new Error('Close browser terminals before changing the Session sandbox mode')
+    }, { global: true })
+    ctx.effect(() => async () => {
+      this.lifetime.abort(new Error('Terminal controller disposed'))
+      const results = await Promise.allSettled([...this.owners].map(([id, owner]) => this.disposeOwner(id, owner)))
+      const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
+      if (errors.length > 0) throw new AggregateError(errors, 'Browser terminal cleanup failed')
+    }, 'terminal-controller.processes')
+  }
+
+  /**
+   * Read the Session working directory and terminal limits without resolving a shell.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param signal - request cancellation.
+   * @returns the Session workspace directory and terminal limits.
+   */
+  @Remote
+  environment(agent: Agent, signal: AbortSignal): TerminalEnvironment {
+    signal.throwIfAborted()
+    const { sandboxPolicy } = this.execution(agent)
+    return { cwd: sandboxPolicy.resolve({ session: agent.session }).workspaceRoot,
+      maxInputBytes: this.config.maxInputBytes, maxCols: this.config.maxCols,
+      maxRows: this.config.maxRows, scrollback: this.config.scrollback }
+  }
+
+  /**
+   * Discover installed shells in the Session's execution environment.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param signal - request cancellation.
+   * @returns verified profiles, with the configured or system default first.
+   */
+  @Remote
+  shells(agent: Agent, signal: AbortSignal): Promise<TerminalShell[]> {
+    signal.throwIfAborted()
+    return discoverShells(this.execution(agent).subprocess, this.config.shell, this.config.shellCandidates, signal)
+  }
+
+  /**
+   * List retained terminals without resolving or activating an Agent.
+   * @param sessionId - displayed Session identity, including offline history.
+   * @returns terminals retained for this Host lifetime.
+   */
+  @Remote
+  list(sessionId: SessionId): WebTerminalInfo[] {
+    const owner = this.owners.get(sessionId)
+    if (owner === undefined) return []
+    return [...owner.terminals.values(), ...owner.allocations.values()].map(terminal => terminal.info)
+  }
+
+  /**
+   * Allocate an interactive shell once for a caller-generated identity.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param request - initial dimensions and idempotency identity.
+   * @param signal - allocation cancellation; committed terminals survive disconnection.
+   * @returns the existing or newly committed terminal.
+   */
+  @Remote
+  async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo> {
+    this.lifetime.signal.throwIfAborted()
+    if (!/^[\w-]{1,128}$/u.test(request.id)) throw new Error('Invalid terminal identity')
+    this.dimensions(request.cols, request.rows)
+    const owner = this.owner(agent)
+    owner.lifetime.signal.throwIfAborted()
+    this.requireOpen(owner, request.id)
+    const existing = owner.terminals.get(request.id)
+    if (existing !== undefined) return existing.info
+    const pending = owner.pending.get(request.id)
+    if (pending !== undefined) {
+      const terminal = await pending
+      this.requireOpen(owner, request.id)
+      return terminal.info
+    }
+    if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again')
+    if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals })
+    const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]))
+    owner.pending.set(request.id, allocation)
+    try {
+      const terminal = await allocation
+      owner.terminals.set(request.id, terminal)
+      owner.allocations.delete(request.id)
+      this.requireOpen(owner, request.id)
+      return terminal.info
+    } finally {
+      owner.pending.delete(request.id)
+    }
+  }
+
+  /**
+   * Attach to a terminal without binding its process lifetime to the transport.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param id - terminal identity.
+   * @param attachmentId - new exclusive input attachment.
+   * @param signal - physical stream cancellation.
+   * @returns screen recovery followed by output and metadata changes.
+   */
+  @Remote({ mode: 'stream' })
+  follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
+    if (!/^[\w-]{1,128}$/u.test(attachmentId)) throw new Error('Invalid terminal attachment identity')
+    return this.terminal(agent, id).follow(attachmentId, signal)
+  }
+
+  /**
+   * Deliver raw input, including Tab completion and control characters.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param id - terminal identity.
+   * @param attachmentId - current writable attachment.
+   * @param data - input bytes represented as UTF-8 text.
+   * @returns after provider input acceptance.
+   */
+  @Remote
+  async write(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, data: string): Promise<void> {
+    if (Buffer.byteLength(data, 'utf8') > this.config.maxInputBytes) throw new Error('Terminal input exceeds the configured limit')
+    await this.terminal(agent, id).write(attachmentId, data)
+  }
+
+  /**
+   * Update the dimensions of the PTY and recovery screen.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param id - terminal identity.
+   * @param attachmentId - current writable attachment.
+   * @param cols - column count.
+   * @param rows - row count.
+   * @returns after the resize completes.
+   */
+  @Remote
+  async resize(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, cols: number, rows: number): Promise<void> {
+    this.dimensions(cols, rows)
+    await this.terminal(agent, id).resize(attachmentId, cols, rows)
+  }
+
+  /**
+   * Rename a terminal without changing its shell.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param id - terminal identity.
+   * @param title - nonempty display title, at most 120 characters.
+   */
+  @Remote
+  rename(agent: Agent, id: WebTerminalId, title: string): void {
+    if (title.trim().length === 0 || title.length > 120) throw new Error('Terminal title must contain 1–120 characters')
+    this.terminal(agent, id).rename(title.trim())
+  }
+
+  /**
+   * Close an identity to future creation and kill its process range; repeated closes succeed.
+   * @param agent - Session owner supplied by the Gateway.
+   * @param id - terminal identity.
+   * @returns after provider cleanup succeeds. A failure retains the terminal for retry.
+   */
+  @Remote
+  async close(agent: Agent, id: WebTerminalId): Promise<void> {
+    const owner = this.owner(agent)
+    owner.closedIds.add(id)
+    // create publishes the allocation before this wait settles; close owns it even if create then rejects.
+    await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ })
+    const terminal = owner.terminals.get(id)
+    if (terminal !== undefined) {
+      await terminal.close()
+      owner.terminals.delete(id)
+    } else {
+      const allocation = owner.allocations.get(id)
+      if (allocation === undefined) return
+      await allocation.handle.terminate()
+      owner.allocations.delete(id)
+    }
+  }
+
+  private owner(agent: Agent): OwnedSession {
+    let owner = this.owners.get(agent.id)
+    if (owner === undefined) {
+      owner = { terminals: new Map(), pending: new Map(), allocations: new Map(), closedIds: new Set(), lifetime: new AbortController() }
+      this.owners.set(agent.id, owner)
+      const owned = owner
+      agent.ctx.effect(() => async () => { await this.disposeOwner(agent.id, owned) }, 'terminal-controller.owner')
+    }
+    return owner
+  }
+
+  private disposeOwner(id: SessionId, owner: OwnedSession): Promise<void> {
+    if (owner.cleanup !== undefined) return owner.cleanup
+    owner.lifetime.abort(new Error('Terminal Session owner disposed'))
+    owner.cleanup = (async () => {
+      await Promise.allSettled(owner.pending.values())
+      const results = await Promise.allSettled([
+        ...[...owner.terminals.values()].map(terminal => terminal.close()),
+        ...[...owner.allocations.values()].map(allocation => allocation.handle.terminate()),
+      ])
+      const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
+      if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed')
+      owner.terminals.clear()
+      owner.allocations.clear()
+      this.owners.delete(id)
+    })().catch((error: unknown) => { delete owner.cleanup; throw error })
+    return owner.cleanup
+  }
+
+  private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal {
+    const terminal = this.owners.get(agent.id)?.terminals.get(id)
+    if (terminal === undefined) throw new Error('Terminal no longer exists in this Session')
+    return terminal
+  }
+
+  private requireOpen(owner: OwnedSession, id: WebTerminalId): void {
+    if (owner.closedIds.has(id)) throw new Error('Terminal was closed in this Session')
+  }
+
+  private dimensions(cols: number, rows: number): void {
+    if (!Number.isSafeInteger(cols) || cols < 2 || cols > this.config.maxCols
+      || !Number.isSafeInteger(rows) || rows < 1 || rows > this.config.maxRows) throw new Error('Terminal dimensions exceed the configured limits')
+  }
+
+  private execution(agent: Agent): { subprocess: Context['subprocess']; sandboxPolicy: Context['sandboxPolicy'] } {
+    // The Agent context selects execution providers but does not inject consumer services.
+    const subprocess = agent.ctx.get('subprocess')
+    const sandboxPolicy = agent.ctx.get('sandboxPolicy')
+    if (subprocess === undefined || sandboxPolicy === undefined) throw new Error('The Session execution environment requires subprocess and sandbox policy providers')
+    return { subprocess, sandboxPolicy }
+  }
+
+  private async spawn(agent: Agent, owner: OwnedSession, request: TerminalCreateRequest, signal: AbortSignal): Promise<BrowserTerminal> {
+    const environment = this.environment(agent, signal)
+    const { subprocess, sandboxPolicy } = this.execution(agent)
+    const shell = request.shellPath === undefined
+      ? await resolveShell(subprocess, this.config.shell, signal)
+      : (await this.shells(agent, signal)).find(candidate => candidate.path === request.shellPath)
+    if (shell === undefined) throw new Error('Selected shell is not available in this execution environment')
+    const policy = sandboxPolicy.resolve({ session: agent.session })
+    let argv = [shell.path, ...shell.args]
+    if (policy.mode !== 'danger-full-access') {
+      const sandbox = agent.ctx.get('sandbox')
+      if (sandbox === undefined) throw new Error('The Session sandbox mode requires an execution sandbox provider')
+      argv = (await sandbox.confine(argv, { ...policy, mode: policy.mode }, signal)).argv
+    }
+    const handle = await subprocess.spawnTerminal({
+      argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
+      terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
+      graceMs: this.config.disposeGraceMs, signal,
+    })
+    const allocation = {
+      handle,
+      info: {
+        id: request.id, shell, title: shell.name, cwd: environment.cwd,
+        cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
+      } as WebTerminalInfo,
+    }
+    owner.allocations.set(request.id, allocation)
+    try {
+      signal.throwIfAborted()
+      return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes)
+    } catch (error) {
+      allocation.info = { ...allocation.info, state: 'failed', error: error instanceof Error ? error.message : String(error) }
+      try {
+        await handle.terminate()
+        owner.allocations.delete(request.id)
+      } catch (cleanupError) {
+        throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed')
+      }
+      throw error
+    }
+  }
+}
+
+/** Browser terminal service plugin. */
+export default TerminalController

+ 57 - 0
packages/api/terminal-controller/src/shells.ts

@@ -0,0 +1,57 @@
+/** Shell selection and executable verification use the target execution provider. */
+import { SubprocessExecutableNotFoundError, type SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
+import type { TerminalShell } from './types.ts'
+
+/**
+ * Resolve the configured shell or the execution environment's default shell.
+ * @param subprocess - target execution provider.
+ * @param configured - optional profile overriding the environment's default shell.
+ * @param signal - resolution cancellation.
+ * @returns one verified shell; a declared default that cannot resolve rejects.
+ */
+export async function resolveShell(
+  subprocess: SubprocessRuntime, configured: TerminalShell | undefined, signal: AbortSignal,
+): Promise<TerminalShell> {
+  let shell = configured
+  if (shell === undefined) {
+    const environment = await subprocess.terminalEnvironment(signal)
+    shell = profile(environment.defaultShell ?? (environment.platform === 'windows' ? 'cmd.exe' : '/bin/sh'))
+  }
+  const path = await subprocess.resolveExecutable(shell.path, undefined, signal)
+  return { ...shell, path }
+}
+
+function profile(path: string): TerminalShell {
+  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
+  const kind = name.toLowerCase().replace(/\.exe$/u, '')
+  return { path, name, args: kind === 'cmd' ? [] : kind === 'pwsh' || kind === 'powershell' ? ['-NoLogo'] : ['-i'] }
+}
+
+/**
+ * List verified candidates after the configured or environment-default shell.
+ * @param subprocess - target execution provider.
+ * @param configured - optional default profile.
+ * @param candidates - executable names or paths permitted for shell selection.
+ * @param signal - discovery cancellation.
+ * @returns unique installed shells, with the default first; transport failures reject.
+ */
+export async function discoverShells(
+  subprocess: SubprocessRuntime, configured: TerminalShell | undefined,
+  candidates: readonly string[], signal: AbortSignal,
+): Promise<TerminalShell[]> {
+  const preferred = await resolveShell(subprocess, configured, signal)
+  const found = await Promise.all(candidates.map(async (candidate) => {
+    try { return await resolveShell(subprocess, profile(candidate), signal) }
+    catch (error) {
+      if (error instanceof SubprocessExecutableNotFoundError) return undefined
+      throw error
+    }
+  }))
+  const shells = new Map<string, TerminalShell>()
+  for (const shell of [preferred, ...found]) {
+    if (shell === undefined) continue
+    const key = shell.path.includes('\\') ? shell.path.toLowerCase() : shell.path
+    if (!shells.has(key)) shells.set(key, shell)
+  }
+  return [...shells.values()]
+}

+ 75 - 0
packages/api/terminal-controller/src/stream.ts

@@ -0,0 +1,75 @@
+/** A bounded output queue for one Remote stream generation. */
+import { Deque } from '@deepseek-ai/dsh-deque'
+import type { TerminalFrame } from './types.ts'
+
+/** Slow followers fail explicitly; a later attachment recovers from the screen. */
+export class TerminalFollower {
+  private readonly queue = new Deque<{ frame: TerminalFrame; bytes: number }>()
+  private bytes = 0
+  private wake: (() => void) | undefined
+  private closed = false
+  private finished = false
+  private failure: Error | undefined
+
+  /** @param maxBytes - maximum queued UTF-8 bytes for this follower. */
+  constructor(private readonly maxBytes: number) {}
+
+  /**
+   * Queue a frame or fail this follower when its byte limit is exceeded.
+   * @param frame - next ordered frame.
+   */
+  push(frame: TerminalFrame): void {
+    if (this.closed || this.finished) return
+    const bytes = Buffer.byteLength(JSON.stringify(frame), 'utf8')
+    if (this.bytes + bytes > this.maxBytes) {
+      this.failure = new Error('Terminal output consumer exceeded its buffer; reconnect to recover the current screen')
+      this.close()
+      return
+    }
+    this.queue.pushBack({ frame, bytes })
+    this.bytes += bytes
+    this.wake?.()
+  }
+
+  /** Finish after delivering every queued frame, including the final exit state. */
+  finish(): void {
+    this.finished = true
+    this.wake?.()
+  }
+
+  /** Stop this follower without stopping its terminal. */
+  close(): void {
+    this.closed = true
+    this.queue.clear()
+    this.bytes = 0
+    this.wake?.()
+  }
+
+  /**
+   * Drain until detached or failed.
+   * @param signal - Remote generation cancellation.
+   * @returns ordered terminal frames.
+   */
+  async *read(signal: AbortSignal): AsyncIterable<TerminalFrame> {
+    const abort = (): void => { this.close() }
+    signal.addEventListener('abort', abort, { once: true })
+    if (signal.aborted) abort()
+    try {
+      while (!this.closed) {
+        const next = this.queue.popFront()
+        if (next !== undefined) {
+          this.bytes -= next.bytes
+          yield next.frame
+        } else {
+          if (this.finished) break
+          await new Promise<void>((resolve) => { this.wake = resolve })
+          this.wake = undefined
+        }
+      }
+      if (this.failure !== undefined) throw this.failure
+    } finally {
+      signal.removeEventListener('abort', abort)
+      this.close()
+    }
+  }
+}

+ 175 - 0
packages/api/terminal-controller/src/terminal.ts

@@ -0,0 +1,175 @@
+/** One PTY, a bounded terminal emulator and its detachable browser followers. */
+import { createRequire } from 'node:module'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import type { Terminal as HeadlessTerminal } from '@xterm/headless'
+import type { SerializeAddon as Serializer } from '@xterm/addon-serialize'
+import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import { TerminalFollower } from './stream.ts'
+import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts'
+
+const { Terminal, SerializeAddon } = loadXterm()
+
+function loadXterm() {
+  // The Preview's CommonJS wrapper owns its outer require binding; these literal calls also retain the CJS entries.
+  const require = createRequire(import.meta.url)
+  const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless')
+  const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize')
+  return { Terminal, SerializeAddon }
+}
+
+/** Process lifetime is independent of follower and component lifetimes. */
+export class BrowserTerminal {
+  private readonly screen: HeadlessTerminal
+  private readonly serializer: Serializer
+  private readonly followers = new Set<TerminalFollower>()
+  private sequence = 0
+  private operations: Promise<unknown> = Promise.resolve()
+  private readonly drained: Promise<void>
+  private closing: Promise<void> | undefined
+  private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined
+
+  /**
+   * @param handle - allocated terminal process range.
+   * @param info - initial metadata.
+   * @param scrollback - maximum retained scrollback rows.
+   * @param maxBufferedBytes - per-follower queue cap.
+   */
+  constructor(
+    private readonly handle: SubprocessTerminalHandle,
+    public info: WebTerminalInfo,
+    scrollback: number,
+    private readonly maxBufferedBytes: number,
+  ) {
+    this.screen = new Terminal({ cols: info.cols, rows: info.rows, scrollback, allowProposedApi: true })
+    this.serializer = new SerializeAddon()
+    this.screen.loadAddon(this.serializer)
+    this.drained = this.consume()
+  }
+
+  /**
+   * Attach with exclusive input control; an older attachment becomes read-only.
+   * @param id - browser attachment identity.
+   * @param signal - attachment cancellation; never terminates the process.
+   * @returns a consistent screen followed by ordered output and state changes.
+   */
+  async *follow(id: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame> {
+    signal.throwIfAborted()
+    const follower = new TerminalFollower(this.maxBufferedBytes)
+    const baseline = await this.enqueue(() => {
+      signal.throwIfAborted()
+      this.controller = { id, follower }
+      this.info = { ...this.info, controllerId: id }
+      this.broadcast({ type: 'state', info: this.info })
+      const snapshot: TerminalFrame = { type: 'snapshot', sequence: this.sequence, screen: this.serializer.serialize(), info: this.info }
+      this.followers.add(follower)
+      return snapshot
+    })
+    try {
+      yield baseline
+      yield* follower.read(signal)
+    } finally {
+      this.followers.delete(follower)
+      follower.close()
+      if (this.controller?.follower === follower) {
+        this.controller = undefined
+        const { controllerId: _controllerId, ...info } = this.info
+        this.info = info
+        this.broadcast({ type: 'state', info })
+      }
+    }
+  }
+
+  /**
+   * Send raw terminal input without command interpretation.
+   * @param id - current writable attachment.
+   * @param data - UTF-8 input, including shell completion/control keys.
+   * @returns when the provider accepts the input.
+   */
+  write(id: TerminalAttachmentId, data: string): Promise<void> {
+    return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) })
+  }
+
+  /**
+   * Resize the PTY and recovery screen in the same operation order as output.
+   * @param id - current writable attachment.
+   * @param cols - validated column count.
+   * @param rows - validated row count.
+   * @returns when the provider and emulator use the new dimensions.
+   */
+  resize(id: TerminalAttachmentId, cols: number, rows: number): Promise<void> {
+    return this.enqueue(async () => {
+      this.requireController(id)
+      await this.handle.resize(cols, rows)
+      this.screen.resize(cols, rows)
+      this.info = { ...this.info, cols, rows }
+      this.broadcast({ type: 'state', info: this.info })
+    })
+  }
+
+  /**
+   * Publish a display name to every attached view.
+   * @param title - validated user title.
+   */
+  rename(title: string): void {
+    this.info = { ...this.info, title }
+    this.broadcast({ type: 'state', info: this.info })
+  }
+
+  /**
+   * Terminate the complete provider-owned process range before releasing its screen.
+   * @returns after process cleanup and final output drainage; failures remain retryable.
+   */
+  close(): Promise<void> {
+    if (this.closing !== undefined) return this.closing
+    this.closing = (async () => {
+      await this.handle.terminate()
+      await this.drained
+      for (const follower of this.followers) follower.finish()
+      this.followers.clear()
+      this.screen.dispose()
+    })().catch((error: unknown) => { this.closing = undefined; throw error })
+    return this.closing
+  }
+
+  private requireController(id: TerminalAttachmentId): void {
+    if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' })
+    if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' })
+  }
+
+  private broadcast(frame: TerminalFrame): void {
+    for (const follower of this.followers) follower.push(frame)
+  }
+
+  private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
+    const pending = this.operations.then(operation)
+    this.operations = pending.catch(() => { /* The caller owns this operation's failure; later cleanup must still run. */ })
+    return pending
+  }
+
+  private async consume(): Promise<void> {
+    const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
+    const outcome = this.handle.done.then(value => ({ value }), (error: unknown) => ({ error }))
+    try {
+      for await (const chunk of this.handle.output) {
+        // Node Readable's iterator is untyped; this provider explicitly emits Buffer chunks.
+        const data = decoder.decode(chunk as Buffer, { stream: true })
+        await this.output(data)
+      }
+      await this.output(decoder.decode())
+      const result = await outcome
+      if ('error' in result) throw result.error
+      this.info = { ...this.info, state: 'exited', exitCode: result.value.exitCode }
+    } catch (error) {
+      this.info = { ...this.info, state: 'failed', error: error instanceof Error ? error.message : String(error) }
+    }
+    this.broadcast({ type: 'state', info: this.info })
+  }
+
+  private async output(data: string): Promise<void> {
+    if (data.length === 0) return
+    await this.enqueue(async () => {
+      await new Promise<void>((resolve) => { this.screen.write(data, resolve) })
+      this.broadcast({ type: 'output', sequence: ++this.sequence, data })
+    })
+  }
+}

+ 63 - 0
packages/api/terminal-controller/src/types.ts

@@ -0,0 +1,63 @@
+/** Browser terminal identities, metadata and screen-stream frames. */
+import type { Branded } from '@deepseek-ai/dsh-brand'
+import type {} from '@deepseek-ai/dsh-typert-protocol'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** Input or resize was refused without invalidating the output attachment. */
+    'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' }
+    /** Retained screens and pending allocations consume the Session's terminal quota. */
+    'terminal/limit-reached': { readonly limit: number }
+  }
+}
+
+/** A terminal identity scoped to one Session and one Host lifetime. */
+export type WebTerminalId = Branded<'WebTerminalId'>
+/** An attachment allowed to write and resize one terminal. */
+export type TerminalAttachmentId = Branded<'TerminalAttachmentId'>
+
+/** An executable shell verified in the subprocess provider's execution environment. */
+export interface TerminalShell {
+  readonly path: string
+  readonly args: readonly string[]
+  readonly name: string
+}
+
+/** Working directory and limits shared by new and restored terminals. */
+export interface TerminalEnvironment {
+  readonly cwd: string
+  readonly maxInputBytes: number
+  readonly maxCols: number
+  readonly maxRows: number
+  readonly scrollback: number
+}
+
+/** Host-owned terminal state; process exit never creates a replacement shell. */
+export interface WebTerminalInfo {
+  readonly id: WebTerminalId
+  readonly title: string
+  readonly shell: TerminalShell
+  /** Initial working directory; shell directory changes do not update this field. */
+  readonly cwd: string
+  readonly cols: number
+  readonly rows: number
+  readonly state: 'running' | 'exited' | 'failed'
+  readonly exitCode: number | null
+  readonly error?: string
+  readonly controllerId?: TerminalAttachmentId
+}
+
+/** Create is idempotent for an open identity; closed identities cannot be recreated. */
+export interface TerminalCreateRequest {
+  /** A path returned by shell discovery; absent selects the execution default. */
+  readonly shellPath?: string
+  readonly id: WebTerminalId
+  readonly cols: number
+  readonly rows: number
+}
+
+/** Every attachment begins with a complete bounded screen, then ordered output. */
+export type TerminalFrame =
+  | { readonly type: 'snapshot'; readonly sequence: number; readonly screen: string; readonly info: WebTerminalInfo }
+  | { readonly type: 'output'; readonly sequence: number; readonly data: string }
+  | { readonly type: 'state'; readonly info: WebTerminalInfo }

+ 543 - 0
packages/api/terminal-controller/tests/controller.spec.ts

@@ -0,0 +1,543 @@
+/** Session identity, allocation races, confinement and real PTY behavior. */
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { PassThrough } from 'node:stream'
+import { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
+import { SubprocessExecutableNotFoundError, type SubprocessRuntime, type SubprocessTerminalEnvironment, type SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { TerminalController, type Config } from '../src/index.ts'
+import { resolveShell } from '../src/shells.ts'
+import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts'
+
+const roots: Context[] = []
+afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) })
+const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 }
+const id = 'test-terminal' as WebTerminalId
+const request = { id, cols: 80, rows: 24 }
+const signal = (): AbortSignal => new AbortController().signal
+
+function owner(ctx: Context, id = 'session'): Agent {
+  return { id: id as SessionId, ctx, session: { id: id as SessionId } } as unknown as Agent
+}
+
+function fixture(overrides: Partial<Config> = {}) {
+  const ctx = new Context()
+  roots.push(ctx)
+  const effects = vi.spyOn(ctx.fiber, 'effect')
+  const sandboxPolicy = { defaultMode: 'danger-full-access', resolve: vi.fn((): SandboxExecutionPolicy => ({ mode: 'danger-full-access', workspaceRoot: '/workspace' })) }
+  const projections = { stateOf: vi.fn((): SandboxMode | null => null) }
+  ctx.provide('sandboxPolicy', sandboxPolicy as never)
+  ctx.provide('sessionProjections', projections as never)
+  const output = new PassThrough()
+  const done = Promise.withResolvers<{ exitCode: number; signal: null }>()
+  const handle = {
+    pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
+    inspectForeground: async () => undefined, signalForeground: async () => 123,
+    terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) }
+  const checked: SubprocessTerminalHandle = handle
+  const subprocess = { terminalEnvironment: vi.fn(async (): Promise<SubprocessTerminalEnvironment> => ({ platform: 'posix', defaultShell: '/bin/bash' })), resolveExecutable: vi.fn(async (path: string) => path), spawnTerminal: vi.fn(async () => checked) }
+  ctx.provide('subprocess', subprocess as never)
+  const controller = new TerminalController(ctx, { ...config, ...overrides })
+  const disposeEffect = (label: string): Promise<void> => {
+    const index = effects.mock.calls.findIndex(call => call[1] === label)
+    const result = effects.mock.results[index]
+    if (result?.type !== 'return' || typeof result.value !== 'function') throw new Error(`Missing effect: ${label}`)
+    return result.value()
+  }
+  return { ctx, agent: owner(ctx), controller, subprocess, handle, sandboxPolicy, projections, disposeEffect }
+}
+
+describe('TerminalController', () => {
+  it('resolves execution services from an Agent plugin context without consumer injections', async () => {
+    const { ctx, controller } = fixture()
+    let agent: Agent | undefined
+    await ctx.plugin((child) => { agent = owner(child) })
+    if (agent === undefined) throw new Error('Agent plugin did not load')
+    expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace' })
+    await controller.create(agent, request, signal())
+    await controller.close(agent, id)
+  })
+
+  it('creates the environment default shell and keeps an existing identity when that default changes', async () => {
+    const { controller, agent, subprocess } = fixture({ shell: undefined })
+    subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/usr/local/bin/zsh' })
+    expect(controller.environment(agent, signal())).toEqual({ cwd: '/workspace', maxCols: 200, maxRows: 100, maxInputBytes: 1000, scrollback: 100 })
+    expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
+    const created = await controller.create(agent, request, signal())
+    expect(created.shell.path).toBe('/usr/local/bin/zsh')
+    expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/usr/local/bin/zsh', '-i'] }))
+    subprocess.terminalEnvironment.mockResolvedValue({ platform: 'posix', defaultShell: '/bin/sh' })
+    expect(await controller.create(agent, request, signal())).toBe(created)
+    expect(subprocess.terminalEnvironment).toHaveBeenCalledOnce()
+    expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+  })
+
+  it('restores a running terminal after its default executable becomes unavailable', async () => {
+    const { controller, agent, subprocess } = fixture({ shell: undefined })
+    await controller.create(agent, request, signal())
+    subprocess.resolveExecutable.mockRejectedValue(new SubprocessExecutableNotFoundError('default shell was removed'))
+    expect(controller.environment(agent, signal())).toMatchObject({ cwd: '/workspace', maxInputBytes: 1000 })
+    expect(controller.list(agent.id)).toMatchObject([{ id, state: 'running' }])
+    const stream = controller.follow(agent, id, 'restored' as TerminalAttachmentId, signal())[Symbol.asyncIterator]()
+    try {
+      expect(await stream.next()).toMatchObject({ done: false, value: { type: 'snapshot', info: { id, state: 'running' } } })
+      expect(subprocess.resolveExecutable).toHaveBeenCalledOnce()
+      await expect(controller.create(agent, { ...request, id: 'new-terminal' as WebTerminalId }, signal())).rejects.toThrow('default shell was removed')
+      expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+    } finally { await stream.return?.() }
+  })
+
+  it('honors cancellation before reading environment limits', () => {
+    const { controller, agent, subprocess, sandboxPolicy } = fixture()
+    const abort = new AbortController()
+    abort.abort(new Error('request cancelled'))
+    expect(() => controller.environment(agent, abort.signal)).toThrow('request cancelled')
+    expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
+    expect(sandboxPolicy.resolve).not.toHaveBeenCalled()
+  })
+
+  it('deduplicates concurrent creates, scopes access by Session, and preserves terminals when a stream aborts', async () => {
+    const { controller, agent, ctx, subprocess, handle } = fixture()
+    const [first, second] = await Promise.all([controller.create(agent, request, signal()), controller.create(agent, request, signal())])
+    expect(first.id).toBe(second.id)
+    expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+    expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ terminalType: 'xterm-256color', cwd: '/workspace' }))
+    expect(controller.list('other' as SessionId)).toEqual([])
+    expect(() => controller.follow(owner(ctx, 'other'), id, 'writer' as TerminalAttachmentId, signal())).toThrow('no longer exists')
+    const abort = new AbortController()
+    const stream = controller.follow(agent, id, 'writer' as TerminalAttachmentId, abort.signal)[Symbol.asyncIterator]()
+    const firstFrame = await stream.next()
+    if (firstFrame.done === true) throw new Error('Terminal stream ended before its snapshot')
+    expect(firstFrame.value.type).toBe('snapshot')
+    abort.abort()
+    await stream.return?.()
+    expect(handle.terminate).not.toHaveBeenCalled()
+    await controller.close(agent, id)
+    expect(handle.terminate).toHaveBeenCalledOnce()
+    expect(controller.list(agent.id)).toEqual([])
+    await controller.close(agent, id)
+  })
+
+  it('waits for pending allocation when explicitly closing and rolls back cancellation before publication', async () => {
+    const { controller, agent, subprocess, handle } = fixture()
+    const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
+    subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise)
+    const creating = controller.create(agent, request, signal())
+    const rejected = expect(creating).rejects.toThrow('closed in this Session')
+    const closing = controller.close(agent, id)
+    allocation.resolve(handle)
+    await rejected
+    await closing
+    expect(controller.list(agent.id)).toEqual([])
+    expect(handle.terminate).toHaveBeenCalledOnce()
+  })
+
+  it('rejects a delayed create after close even when that identity has never been allocated', async () => {
+    const { controller, agent, subprocess } = fixture()
+    await controller.close(agent, id)
+    await controller.close(agent, id)
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
+    expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
+    const nextId = 'next-terminal' as WebTerminalId
+    await expect(controller.create(agent, { ...request, id: nextId }, signal())).resolves.toMatchObject({ id: nextId })
+    expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+  })
+
+  it('rejects original, duplicate and later creates when close arrives during allocation', async () => {
+    const { controller, agent, subprocess, handle } = fixture()
+    const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
+    const spawning = Promise.withResolvers<undefined>()
+    subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise })
+    const creating = controller.create(agent, request, signal())
+    const duplicate = controller.create(agent, request, signal())
+    const rejected = expect(creating).rejects.toThrow('closed in this Session')
+    const duplicateRejected = expect(duplicate).rejects.toThrow('closed in this Session')
+    try {
+      await spawning.promise
+      const closing = controller.close(agent, id)
+      await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
+      allocation.resolve(handle)
+      await Promise.all([rejected, duplicateRejected, closing])
+      await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
+      expect(handle.terminate).toHaveBeenCalledOnce()
+      expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+      expect(controller.list(agent.id)).toEqual([])
+    } finally { allocation.resolve(handle) }
+  })
+
+  it('retains failed allocation cleanup for a later explicit close', async () => {
+    const { controller, agent, subprocess, handle } = fixture()
+    const abort = new AbortController()
+    subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('request disconnected')); return handle })
+    vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('cleanup failed'))
+    await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
+    expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed' }])
+    await controller.close(agent, id)
+    expect(handle.terminate).toHaveBeenCalledTimes(2)
+    expect(controller.list(agent.id)).toEqual([])
+  })
+
+  it('retains a terminal when process cleanup fails so closing can be retried', async () => {
+    const { controller, agent, handle } = fixture()
+    await controller.create(agent, request, signal())
+    vi.mocked(handle.terminate).mockRejectedValueOnce(new Error('process still alive'))
+    await expect(controller.close(agent, id)).rejects.toThrow('still alive')
+    expect(controller.list(agent.id)).toHaveLength(1)
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
+    await controller.close(agent, id)
+    expect(controller.list(agent.id)).toHaveLength(0)
+  })
+
+  it('rejects invalid dimensions, unavailable shells and oversized input before executing them', async () => {
+    const { controller, agent, subprocess } = fixture()
+    await expect(controller.create(agent, { ...request, cols: 1 }, signal())).rejects.toThrow('dimensions')
+    subprocess.resolveExecutable.mockRejectedValueOnce(new SubprocessExecutableNotFoundError('shell unavailable'))
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('unavailable')
+    expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
+    await expect(controller.write(agent, id, 'writer' as TerminalAttachmentId, 'x'.repeat(1001))).rejects.toThrow('input')
+  })
+
+  it('keeps the committed identity on repeated creation and counts pending reservations toward the Session limit', async () => {
+    const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 })
+    const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
+    const spawning = Promise.withResolvers<undefined>()
+    subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise })
+    const creating = controller.create(agent, request, signal())
+    try {
+      await spawning.promise
+      await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
+    } finally { allocation.resolve(handle) }
+    const first = await creating
+    expect(await controller.create(agent, request, signal())).toBe(first)
+    expect(subprocess.spawnTerminal).toHaveBeenCalledOnce()
+  })
+
+  it('keeps failed allocations reserved until their cleanup succeeds', async () => {
+    const { controller, agent, subprocess, handle } = fixture({ maxTerminals: 1 })
+    const abort = new AbortController()
+    subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort('lost request'); return handle })
+    handle.terminate.mockRejectedValueOnce(new Error('still alive'))
+    await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
+    expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }])
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation')
+    await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
+    await controller.close(agent, id)
+    expect(controller.list(agent.id)).toEqual([])
+  })
+
+  it('lets close reclaim an allocation whose request failed while close was waiting', async () => {
+    const { controller, agent, subprocess, handle } = fixture()
+    const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
+    const abort = new AbortController()
+    subprocess.spawnTerminal.mockImplementationOnce(() => allocation.promise)
+    const creating = controller.create(agent, request, abort.signal)
+    const rejected = expect(creating).rejects.toThrow('cancelled')
+    const closing = controller.close(agent, id)
+    abort.abort(new Error('cancelled'))
+    allocation.resolve(handle)
+    await rejected
+    await closing
+    expect(handle.terminate).toHaveBeenCalledOnce()
+    expect(controller.list(agent.id)).toEqual([])
+  })
+
+  it('routes input, resize and trimmed names through the active attachment', async () => {
+    const { controller, agent, handle } = fixture({ maxInputBytes: 6 })
+    await controller.create(agent, request, signal())
+    const attachmentId = 'writer' as TerminalAttachmentId
+    const stream = controller.follow(agent, id, attachmentId, signal())[Symbol.asyncIterator]()
+    await stream.next()
+    try {
+      await controller.write(agent, id, attachmentId, '终端')
+      expect(handle.write).toHaveBeenCalledWith('终端')
+      await expect(controller.write(agent, id, attachmentId, '终端!')).rejects.toThrow('input exceeds')
+      await controller.resize(agent, id, attachmentId, 200, 100)
+      expect(handle.resize).toHaveBeenCalledWith(200, 100)
+      controller.rename(agent, id, '  server logs  ')
+      expect(controller.list(agent.id)).toMatchObject([{ title: 'server logs', cols: 200, rows: 100 }])
+    } finally { await stream.return?.() }
+  })
+
+  it.each([{ cols: 1.5, rows: 24 }, { cols: 201, rows: 24 }, { cols: 80, rows: 1.5 }, { cols: 80, rows: 0 }, { cols: 80, rows: 101 }])('rejects dimensions $cols × $rows before allocation or resize', async (dimensions) => {
+    const { controller, agent, subprocess, handle } = fixture()
+    await expect(controller.create(agent, { ...request, ...dimensions }, signal())).rejects.toThrow('dimensions')
+    await expect(controller.resize(agent, id, 'writer' as TerminalAttachmentId, dimensions.cols, dimensions.rows)).rejects.toThrow('dimensions')
+    expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
+    expect(handle.resize).not.toHaveBeenCalled()
+  })
+
+  it('rejects invalid wire identities and names without changing retained terminals', async () => {
+    const { controller, agent } = fixture()
+    await expect(controller.create(agent, { ...request, id: '../terminal' as WebTerminalId }, signal())).rejects.toThrow('Invalid terminal identity')
+    expect(() => controller.follow(agent, id, '' as TerminalAttachmentId, signal())).toThrow('attachment identity')
+    expect(() => { controller.rename(agent, id, '  ') }).toThrow('1–120')
+    expect(() => { controller.rename(agent, id, 'x'.repeat(121)) }).toThrow('1–120')
+    await controller.close(agent, id)
+    expect(controller.list(agent.id)).toEqual([])
+  })
+
+  it('uses the Session sandbox policy to confine its selected shell', async () => {
+    const { controller, agent, ctx, sandboxPolicy, subprocess } = fixture()
+    const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/workspace', sessionId: agent.id }
+    sandboxPolicy.resolve.mockReturnValue(policy)
+    const confine = vi.fn((argv: readonly string[]) => ({ argv: ['sandbox-runner', ...argv] }))
+    ctx.provide('sandbox', { confine } as never)
+    await controller.create(agent, request, signal())
+    expect(confine).toHaveBeenCalledWith(['/bin/bash', '--noprofile', '--norc', '-i'], policy, expect.any(AbortSignal))
+    expect(subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['sandbox-runner', '/bin/bash', '--noprofile', '--norc', '-i'], env: { DSH_SESSION_ID: agent.id }, graceMs: 100 }))
+  })
+
+  it('rejects a confined Session without a sandbox provider before spawning', async () => {
+    const { controller, agent, sandboxPolicy, subprocess } = fixture()
+    sandboxPolicy.resolve.mockReturnValue({ mode: 'read-only', workspaceRoot: '/workspace' })
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('requires an execution sandbox provider')
+    expect(subprocess.spawnTerminal).not.toHaveBeenCalled()
+  })
+
+  it.each(['subprocess', 'sandboxPolicy'] as const)('fails clearly when the Session lacks %s', (missing) => {
+    const { controller, subprocess, sandboxPolicy } = fixture()
+    const isolated = new Context()
+    roots.push(isolated)
+    if (missing !== 'subprocess') isolated.provide('subprocess', subprocess as never)
+    if (missing !== 'sandboxPolicy') isolated.provide('sandboxPolicy', sandboxPolicy as never)
+    expect(() => controller.environment(owner(isolated), signal())).toThrow('requires subprocess and sandbox policy providers')
+  })
+
+  it('blocks sandbox-mode changes only while that Session retains a terminal', async () => {
+    const { controller, agent, ctx, projections } = fixture()
+    const mode = (mode: SandboxMode): void => { ctx.emit('session/event', agent.session, { type: 'sandbox/mode', data: { mode } } as SessionEvent) }
+    ctx.emit('session/disposed', agent.session)
+    ctx.emit('session/event', agent.session, { type: 'turn/start', data: { turn: 1 } } as SessionEvent)
+    expect(() => { mode('workspace-write') }).not.toThrow()
+    await controller.create(agent, request, signal())
+    expect(() => { mode('danger-full-access') }).not.toThrow()
+    expect(() => { mode('workspace-write') }).toThrow('Close browser terminals')
+    projections.stateOf.mockReturnValue('read-only')
+    expect(() => { mode('read-only') }).not.toThrow()
+    expect(() => { mode('danger-full-access') }).toThrow('Close browser terminals')
+    await controller.close(agent, id)
+    expect(() => { mode('workspace-write') }).not.toThrow()
+  })
+
+  it('terminates committed processes when the Session effect ends', async () => {
+    const { controller, agent, handle, disposeEffect } = fixture()
+    await controller.create(agent, request, signal())
+    await disposeEffect('terminal-controller.owner')
+    expect(handle.terminate).toHaveBeenCalledOnce()
+    expect(controller.list(agent.id)).toEqual([])
+  })
+
+  it('cleans up a Session when its real Agent plugin fiber is disposed', async () => {
+    const { controller, ctx, handle } = fixture()
+    let agent: Agent | undefined
+    const fiber = await ctx.plugin((child) => { agent = owner(child) })
+    if (agent === undefined) throw new Error('Agent plugin did not load')
+    await controller.create(agent, request, signal())
+    await fiber.dispose()
+    expect(handle.terminate).toHaveBeenCalledOnce()
+    expect(controller.list(agent.id)).toEqual([])
+  })
+
+  it('waits for pending creation during Host disposal and rolls back its process before resolving', async () => {
+    const { controller, agent, subprocess, handle, disposeEffect } = fixture()
+    const allocation = Promise.withResolvers<SubprocessTerminalHandle>()
+    const spawning = Promise.withResolvers<undefined>()
+    subprocess.spawnTerminal.mockImplementationOnce(() => { spawning.resolve(undefined); return allocation.promise })
+    const creating = controller.create(agent, request, signal())
+    const rejected = expect(creating).rejects.toThrow('disposed')
+    await spawning.promise
+    const disposing = disposeEffect('terminal-controller.processes')
+    allocation.resolve(handle)
+    await rejected
+    await disposing
+    expect(handle.terminate).toHaveBeenCalledOnce()
+    expect(controller.list(agent.id)).toEqual([])
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('controller disposed')
+  })
+
+  it('shares cleanup between the Session and Host effects while awaiting process termination', async () => {
+    const { controller, agent, handle, disposeEffect } = fixture()
+    await controller.create(agent, request, signal())
+    const stopping = Promise.withResolvers<undefined>()
+    const stopped = Promise.withResolvers<undefined>()
+    const terminate = handle.terminate.getMockImplementation()
+    if (terminate === undefined) throw new Error('Missing process cleanup fixture')
+    handle.terminate.mockImplementationOnce(async () => { stopping.resolve(undefined); await stopped.promise; await terminate() })
+    const ownerDisposal = disposeEffect('terminal-controller.owner')
+    try {
+      await stopping.promise
+      const hostDisposal = disposeEffect('terminal-controller.processes')
+      stopped.resolve(undefined)
+      await Promise.all([ownerDisposal, hostDisposal])
+      expect(handle.terminate).toHaveBeenCalledOnce()
+      expect(controller.list(agent.id)).toEqual([])
+    } finally { stopped.resolve(undefined) }
+  })
+
+  it('retains cleanup failures from both Session and Host disposal for explicit retry', async () => {
+    const { controller, agent, handle, disposeEffect } = fixture()
+    await controller.create(agent, request, signal())
+    handle.terminate.mockRejectedValueOnce(new Error('still alive'))
+    await expect(disposeEffect('terminal-controller.owner')).rejects.toThrow('Session terminal cleanup failed')
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('Session owner disposed')
+    handle.terminate.mockRejectedValueOnce(new Error('still alive again'))
+    await expect(disposeEffect('terminal-controller.processes')).rejects.toThrow('Browser terminal cleanup failed')
+    expect(controller.list(agent.id)).toHaveLength(1)
+    await controller.close(agent, id)
+    expect(controller.list(agent.id)).toEqual([])
+    expect(handle.terminate).toHaveBeenCalledTimes(3)
+  })
+
+  it('reclaims retained failed allocations when the owning Session ends', async () => {
+    const { controller, agent, subprocess, handle, disposeEffect } = fixture()
+    const abort = new AbortController()
+    subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected')); return handle })
+    handle.terminate.mockRejectedValueOnce(new Error('first cleanup failed'))
+    await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
+    await disposeEffect('terminal-controller.owner')
+    expect(handle.terminate).toHaveBeenCalledTimes(2)
+    expect(controller.list(agent.id)).toEqual([])
+  })
+})
+
+describe('shell resolution', () => {
+  it('accepts an omitted shell profile and defaults configured arguments to an empty list', () => {
+    // Loader input is unvalidated; the schema's declared type describes its normalized output.
+    const parse = (input: unknown): Config => TerminalController.Config(input as Config)
+    expect(parse({}).shell).toBeUndefined()
+    expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] })
+    expect(() => parse({ shell: { name: 'Project shell' } })).toThrow()
+  })
+
+  it.each([
+    { platform: 'posix' as const, path: '/bin/sh', name: 'sh', args: ['-i'] },
+    { platform: 'windows' as const, path: 'cmd.exe', name: 'cmd.exe', args: [] },
+  ])('uses the conservative $platform fallback only when the provider omits its default shell', async ({ platform, path, name, args }) => {
+    const { subprocess } = fixture()
+    subprocess.terminalEnvironment.mockResolvedValue({ platform })
+    const runtime = subprocess as unknown as SubprocessRuntime
+    const requestSignal = signal()
+    expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path, name, args })
+    expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(path, undefined, requestSignal)
+  })
+
+  it.each([
+    { platform: 'posix' as const, defaultShell: '/usr/local/bin/fish', name: 'fish', args: ['-i'] },
+    { platform: 'windows' as const, defaultShell: 'C:\\Windows\\CMD.EXE', name: 'CMD.EXE', args: [] },
+    { platform: 'windows' as const, defaultShell: 'C:\\PowerShell\\powershell.exe', name: 'powershell.exe', args: ['-NoLogo'] },
+    { platform: 'posix' as const, defaultShell: '/usr/local/bin/pwsh', name: 'pwsh', args: ['-NoLogo'] },
+  ])('resolves only the declared default $defaultShell with its interactive arguments', async ({ platform, defaultShell, name, args }) => {
+    const { subprocess } = fixture()
+    subprocess.terminalEnvironment.mockResolvedValue({ platform, defaultShell })
+    subprocess.resolveExecutable.mockResolvedValue('/resolved/shell')
+    const runtime = subprocess as unknown as SubprocessRuntime
+    const requestSignal = signal()
+    expect(await resolveShell(runtime, undefined, requestSignal)).toEqual({ path: '/resolved/shell', name, args })
+    expect(subprocess.terminalEnvironment).toHaveBeenCalledExactlyOnceWith(requestSignal)
+    expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith(defaultShell, undefined, requestSignal)
+  })
+
+  it('uses an explicit profile without querying the environment and preserves its name and arguments', async () => {
+    const { subprocess } = fixture()
+    subprocess.resolveExecutable.mockResolvedValue('/resolved/custom')
+    const runtime = subprocess as unknown as SubprocessRuntime
+    const requestSignal = signal()
+    const configured = { path: 'custom', name: 'Project shell', args: ['--project'] }
+    expect(await resolveShell(runtime, configured, requestSignal)).toEqual({ ...configured, path: '/resolved/custom' })
+    expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
+    expect(subprocess.resolveExecutable).toHaveBeenCalledExactlyOnceWith('custom', undefined, requestSignal)
+  })
+
+  it.each([new SubprocessExecutableNotFoundError('missing default'), new Error('transport unavailable')])('reports default resolution failure without attempting fallback: %s', async (failure) => {
+    const { subprocess } = fixture()
+    subprocess.resolveExecutable.mockRejectedValue(failure)
+    const runtime = subprocess as unknown as SubprocessRuntime
+    await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(failure)
+    expect(subprocess.resolveExecutable).toHaveBeenCalledOnce()
+  })
+
+  it('reports an unavailable configured shell without consulting the provider default', async () => {
+    const { subprocess } = fixture()
+    const failure = new SubprocessExecutableNotFoundError('missing configured shell')
+    subprocess.resolveExecutable.mockRejectedValue(failure)
+    const runtime = subprocess as unknown as SubprocessRuntime
+    await expect(resolveShell(runtime, { path: '/missing', name: 'missing', args: [] }, signal())).rejects.toBe(failure)
+    expect(subprocess.resolveExecutable).toHaveBeenCalledOnce()
+    expect(subprocess.terminalEnvironment).not.toHaveBeenCalled()
+  })
+
+  it('propagates environment cancellation before verifying an executable', async () => {
+    const { subprocess } = fixture()
+    const cancelled = new Error('request disconnected')
+    subprocess.terminalEnvironment.mockRejectedValue(cancelled)
+    const runtime = subprocess as unknown as SubprocessRuntime
+    await expect(resolveShell(runtime, undefined, signal())).rejects.toBe(cancelled)
+    expect(subprocess.resolveExecutable).not.toHaveBeenCalled()
+  })
+})
+
+it.skipIf(process.platform === 'win32')('runs a real interactive shell with completion, TERM and live window dimensions', async () => {
+  const cwd = await mkdtemp(join(tmpdir(), 'dsh-web-terminal-'))
+  const ctx = new Context()
+  const runtime = await ctx.plugin(LocalSubprocessRuntime)
+  try {
+    const handle = await ctx.subprocess.spawnTerminal({ argv: ['/bin/bash', '--noprofile', '--norc', '-i'], cwd, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100, env: { PS1: 'READY> ', PS2: '' } })
+    let output = ''
+    handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') })
+    try {
+      await expect.poll(() => output).toContain('READY>')
+      await handle.resize(100, 30)
+      await handle.write("printf 'TERM:%s\\n' \"$TERM\"; stty size\r")
+      await expect.poll(() => output).toContain('TERM:xterm-256color')
+      await expect.poll(() => output).toContain('30 100')
+      // Bash's builtin completion expands this unambiguous command before Enter.
+      await handle.write('histor\t')
+      await expect.poll(() => output).toContain('history')
+    } finally { await handle.terminate() }
+    await expect(handle.done).resolves.toBeDefined()
+  } finally {
+    await runtime.dispose()
+    await ctx.fiber.dispose()
+    await rm(cwd, { recursive: true, force: true })
+  }
+})
+
+it('discovers installed shells once per path, preserves default arguments, and refuses unlisted paths', async () => {
+  const h = fixture({ shellCandidates: ['bash', 'zsh', 'missing'] })
+  h.subprocess.resolveExecutable.mockImplementation(async (path) => {
+    if (path === 'missing') throw new SubprocessExecutableNotFoundError('absent')
+    return path.startsWith('/') ? path : `/bin/${path}`
+  })
+  const shells = await h.controller.shells(h.agent, signal())
+  expect(shells).toEqual([config.shell, { path: '/bin/zsh', name: 'zsh', args: ['-i'] }])
+  expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled()
+  await expect(h.controller.create(h.agent, { ...request, shellPath: '/bin/unlisted' }, signal())).rejects.toThrow('Selected shell is not available')
+  expect(h.subprocess.spawnTerminal).not.toHaveBeenCalled()
+  const created = await h.controller.create(h.agent, { ...request, shellPath: '/bin/zsh' }, signal())
+  expect(created.shell.path).toBe('/bin/zsh')
+  expect(h.subprocess.spawnTerminal).toHaveBeenCalledWith(expect.objectContaining({ argv: ['/bin/zsh', '-i'] }))
+  h.subprocess.resolveExecutable.mockRejectedValue(new Error('SSH disconnected'))
+  await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('SSH disconnected')
+  expect(await h.controller.create(h.agent, { ...request, shellPath: '/bin/bash' }, signal())).toBe(created)
+})
+
+it('propagates optional-shell discovery transport errors and cancellation', async () => {
+  const h = fixture({ shellCandidates: ['fish'] })
+  h.subprocess.resolveExecutable.mockImplementation(async (path) => {
+    if (path === 'fish') throw new Error('lookup transport failed')
+    return path
+  })
+  await expect(h.controller.shells(h.agent, signal())).rejects.toThrow('lookup transport failed')
+  const cancelled = AbortSignal.abort(new Error('cancelled discovery'))
+  expect(() => h.controller.shells(h.agent, cancelled)).toThrow('cancelled discovery')
+})
+
+it('deduplicates Windows executable paths regardless of letter case', async () => {
+  const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] })
+  expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
+})

+ 567 - 0
packages/api/terminal-controller/tests/model.client.spec.ts

@@ -0,0 +1,567 @@
+/** Browser view ownership across slow RPCs, remounts and transport generations. */
+import { setImmediate } from 'node:timers/promises'
+import { afterEach, expect, it, vi } from 'vitest'
+import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
+import type { TerminalEnvironment, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts'
+
+const sessionId = 'session' as SessionId
+const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'bash', path: '/bin/bash', args: ['-i'] }, title: 'bash', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null }
+const environment: TerminalEnvironment = {
+  cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100,
+}
+const success = <T>(value: T): RemoteResult<T> => ({ ok: true, value })
+const cleanups: (() => void | Promise<void>)[] = []
+afterEach(async () => { for (const close of cleanups.splice(0).reverse()) await close() })
+
+function untilAborted(signal?: AbortSignal): Promise<void> {
+  return new Promise((resolve) => {
+    if (signal?.aborted) resolve()
+    else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+  })
+}
+
+function failure(message: string): RemoteResult<never> {
+  return { ok: false, error: new RemoteError('gateway/bad-request', message, {}) }
+}
+
+function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
+  const remote: TerminalRemote = {
+    shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
+    environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
+    create: vi.fn<TerminalRemote['create']>(async (_sessionId, request) => success({ ...info, id: request.id })),
+    close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
+    write: vi.fn<TerminalRemote['write']>(async () => success(undefined)), resize: vi.fn<TerminalRemote['resize']>(async () => success(undefined)),
+    follow: vi.fn<TerminalRemote['follow']>(async function* (_sessionId, id, attachmentId, signal) {
+      yield { type: 'snapshot', sequence: 0, screen: 'ready', info: { ...info, id, controllerId: attachmentId } }
+      await untilAborted(signal)
+    }),
+  }
+  const options: RemoteStreamOptions<unknown>[] = []
+  const streams: { dispose(): Promise<void>; restart(): void }[] = []
+  const generation = createSnapshotStore<ReturnType<ConstructorParameters<typeof RemoteStream>[0]['generation']['getSnapshot']>>(undefined)
+  const gateway: Pick<ClientRemote, '$stream'> = {
+    $stream: (option) => {
+      options.push(option)
+      const stream = new RemoteStream({ generation }, option)
+      streams.push(stream)
+      prepareStream?.(stream)
+      return stream
+    },
+  }
+  cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) })
+  const model = new TerminalView(sessionId, remote, gateway, info.id)
+  cleanups.push(() => model.dispose())
+  return { model, remote, gateway, options, streams, generation }
+}
+
+async function mount(model: TerminalView) {
+  const detach = model.mount()
+  await model.refresh()
+  await model.start()
+  return detach
+}
+
+function acknowledge(model: TerminalView): void {
+  const render = model.state.getSnapshot().render
+  if (render !== undefined) model.acknowledge(render.revision)
+}
+
+async function connected(model: TerminalView): Promise<void> {
+  await mount(model)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  acknowledge(model)
+}
+
+it('does not let a late input failure downgrade a newer connection', async () => {
+  const { model, remote } = fixture()
+  await mount(model)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  acknowledge(model)
+  const input = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.write).mockReturnValueOnce(input.promise)
+  model.write('x')
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1)
+  model.connect()
+  await expect.poll(() => vi.mocked(remote.follow).mock.calls.length).toBe(2)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  model.write('current input')
+  input.reject(new Error('old attachment was replaced'))
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'current input')).toBe(true)
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined })
+})
+
+it('waits for render acknowledgement before publishing subsequent output', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) {
+    const frames: TerminalFrame[] = [
+      { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } },
+      { type: 'output', sequence: 1, data: 'first' },
+      { type: 'output', sequence: 2, data: 'second' },
+    ]
+    for (const frame of frames) yield frame
+    await untilAborted(signal)
+  })
+  await mount(model)
+  await expect.poll(() => model.state.getSnapshot().render?.frame.type).toBe('snapshot')
+  acknowledge(model)
+  await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 1 })
+  acknowledge(model)
+  await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'output', sequence: 2 })
+})
+
+it('serializes input and resizes, clamps geometry, and suppresses unchanged sizes', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  const input = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.write).mockReturnValueOnce(input.promise)
+  model.resize(80, 24)
+  model.write('first')
+  model.write('second')
+  model.resize(500, 300)
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1)
+  expect(remote.resize).not.toHaveBeenCalled()
+  input.resolve(success(undefined))
+  await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1)
+  const attachmentId = vi.mocked(remote.follow).mock.calls[0]![2]
+  const id = model.state.getSnapshot().info!.id
+  expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['first', 'second'])
+  expect(remote.resize).toHaveBeenCalledWith(sessionId, id, attachmentId, 200, 100)
+  model.resize(90, 24)
+  await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(2)
+})
+
+it('bounds queued input by UTF-8 bytes and releases the byte budget after settlement', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.environment).mockResolvedValue(success({ ...environment, maxInputBytes: 6 }))
+  await connected(model)
+  const input = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.write).mockReturnValueOnce(input.promise)
+  model.write('界界')
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1)
+  model.write('a')
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' })
+  input.resolve(success(undefined))
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  model.write('界界')
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(2)
+  expect(vi.mocked(remote.write).mock.calls.map(call => call[3])).toEqual(['界界', '界界'])
+})
+
+it('drops queued commands from a detached attachment and ignores its late resize failure', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  const input = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.resize).mockReturnValueOnce(input.promise)
+  model.resize(100, 30)
+  await expect.poll(() => vi.mocked(remote.resize).mock.calls.length).toBe(1)
+  model.write('stale')
+  model.resize(110, 35)
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  model.write('current')
+  input.resolve(failure('old attachment'))
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1)
+  expect(vi.mocked(remote.write).mock.calls[0]?.[3]).toBe('current')
+  expect(remote.resize).toHaveBeenCalledOnce()
+  expect(model.state.getSnapshot().phase).toBe('connected')
+})
+
+it('publishes current input and resize failures and can reconnect for another attempt', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  vi.mocked(remote.write).mockResolvedValueOnce(failure('input refused'))
+  model.write('x')
+  await expect.poll(() => model.state.getSnapshot().error).toBe('input refused')
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  vi.mocked(remote.resize).mockResolvedValueOnce(failure('resize refused'))
+  model.resize(100, 30)
+  await expect.poll(() => model.state.getSnapshot().error).toBe('resize refused')
+})
+
+it('treats carrier loss as disconnected only for the active attachment', async () => {
+  const { model, options } = fixture()
+  await connected(model)
+  const first = options[0]!
+  first.carrierFailed?.(new RemoteStreamCarrierError('offline'))
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', writable: false })
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  first.carrierFailed?.(new RemoteStreamCarrierError('late old failure'))
+  expect(model.state.getSnapshot().phase).toBe('connected')
+})
+
+it.each([
+  ['output before snapshot', [{ type: 'output', sequence: 1, data: 'orphan' }], 'missing its screen snapshot'],
+  ['sequence gap', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'output', sequence: 6, data: 'gap' }], 'sequence has a gap'],
+  ['repeated snapshot', [{ type: 'snapshot', sequence: 4, screen: '', info }, { type: 'snapshot', sequence: 4, screen: '', info }], 'Unexpected terminal screen snapshot'],
+] satisfies readonly (readonly [string, readonly TerminalFrame[], string])[])('refuses a malformed output stream: %s', async (_name, frames, message) => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.follow).mockImplementation(async function* () { yield* frames })
+  await mount(model)
+  if (frames[0]?.type === 'snapshot') {
+    await expect.poll(() => model.state.getSnapshot().render).toBeDefined()
+    acknowledge(model)
+  }
+  await expect.poll(() => model.state.getSnapshot().error).toContain(message)
+  expect(model.state.getSnapshot().writable).toBe(false)
+})
+
+it('retains an exited screen and closes controls when its stream ends', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId) {
+    yield { type: 'snapshot', sequence: 0, screen: 'last screen', info: { ...info, id, controllerId } }
+    yield { type: 'state', info: { ...info, id, controllerId, state: 'exited', exitCode: 3 } }
+  })
+  await connected(model)
+  await expect.poll(() => model.state.getSnapshot().phase).toBe('closed')
+  expect(model.state.getSnapshot()).toMatchObject({ writable: false, info: { state: 'exited', exitCode: 3 }, render: { frame: { screen: 'last screen' } } })
+})
+
+it('reports an attachment ending while the shell is still running and permits manual reconnect', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId) {
+    yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }
+  })
+  await connected(model)
+  await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded')
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+})
+
+it('publishes remote control transfer without replacing the retained screen', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.follow).mockImplementation(async function* (_sessionId, id, controllerId, signal) {
+    yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } }
+    yield { type: 'state', info: { ...info, id } }
+    await untilAborted(signal)
+  })
+  await connected(model)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(false)
+  expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
+})
+
+/** Pause delivery after the real Gateway iterator settles, before the model observes it. */
+function deliveryBarrier() {
+  const ready = Promise.withResolvers<undefined>()
+  const release = Promise.withResolvers<undefined>()
+  let held = false
+  function prepare<Item>(stream: RemoteStream<Item>): void {
+    if (held) return
+    held = true
+    const iterator = stream[Symbol.asyncIterator]()
+    let first = true
+    vi.spyOn(stream, Symbol.asyncIterator).mockReturnValue({
+      async next() {
+        if (!first) return iterator.next()
+        first = false
+        try {
+          return await iterator.next()
+        } finally {
+          ready.resolve(undefined)
+          await release.promise
+        }
+      },
+      async return() { return iterator.return!() },
+    })
+  }
+  cleanups.push(() => { release.resolve(undefined) })
+  return { ready: ready.promise, release: () => { release.resolve(undefined) }, prepare }
+}
+
+it('ignores an already delivered screen when its attachment is replaced before the model receives it', async () => {
+  const barrier = deliveryBarrier()
+  const { model, remote } = fixture(barrier.prepare)
+  await mount(model)
+  await barrier.ready
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  const current = model.state.getSnapshot()
+  barrier.release()
+  await model.rename('settled')
+  expect(model.state.getSnapshot().render).toBe(current.render)
+  expect(remote.follow).toHaveBeenCalledTimes(2)
+})
+
+it('ignores an already settled stream error after a newer attachment has become writable', async () => {
+  const barrier = deliveryBarrier()
+  const { model, remote } = fixture(barrier.prepare)
+  vi.mocked(remote.follow).mockImplementationOnce(async function* () {
+    yield* []
+    throw new Error('retired attachment failed')
+  })
+  await mount(model)
+  await barrier.ready
+  model.connect()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  barrier.release()
+  await model.rename('settled')
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined })
+})
+
+it('releases a pending screen render on carrier generation cancellation and accepts the replacement screen', async () => {
+  const { model, remote, streams } = fixture()
+  await mount(model)
+  await expect.poll(() => model.state.getSnapshot().render).toBeDefined()
+  const revision = model.state.getSnapshot().render!.revision
+  streams[0]!.restart()
+  await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(revision + 1)
+  expect(remote.follow).toHaveBeenCalledTimes(2)
+  expect(model.state.getSnapshot().writable).toBe(true)
+})
+
+it('does not wait for the DOM callback of a screen whose generation was already cancelled at delivery', async () => {
+  const barrier = deliveryBarrier()
+  const { model, remote, streams } = fixture(barrier.prepare)
+  await mount(model)
+  await barrier.ready
+  streams[0]!.restart()
+  barrier.release()
+  await expect.poll(() => model.state.getSnapshot().render?.revision).toBe(2)
+  expect(remote.follow).toHaveBeenCalledTimes(2)
+  expect(model.state.getSnapshot().writable).toBe(true)
+})
+
+it.each(['write', 'resize'] as const)('keeps a fresh attachment writable when a pending %s fails after automatic transport recovery', async (operation) => {
+  const { model, remote, generation } = fixture()
+  generation.set({ id: 1, host: { home: '/home/fixture' } })
+  const disconnected = Promise.withResolvers<undefined>()
+  vi.mocked(remote.follow).mockImplementationOnce(async function* (_sessionId, id, controllerId, signal) {
+    yield { type: 'snapshot', sequence: 0, screen: 'before disconnect', info: { ...info, id, controllerId } }
+    await Promise.race([disconnected.promise, untilAborted(signal)])
+    if (signal?.aborted) return
+    throw new RemoteStreamCarrierError('connection lost')
+  })
+  await connected(model)
+  const pending = Promise.withResolvers<RemoteResult<void>>()
+  if (operation === 'write') {
+    vi.mocked(remote.write).mockReturnValueOnce(pending.promise)
+    model.write('old input')
+  } else {
+    vi.mocked(remote.resize).mockReturnValueOnce(pending.promise)
+    model.resize(100, 30)
+  }
+  await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1)
+  const oldAttachmentId = vi.mocked(remote.follow).mock.calls[0]![2]
+  generation.set(undefined)
+  disconnected.resolve(undefined)
+  await expect.poll(() => model.state.getSnapshot().phase).toBe('disconnected')
+  expect(model.state.getSnapshot().writable).toBe(false)
+  expect(remote.follow).toHaveBeenCalledOnce()
+
+  generation.set({ id: 2, host: { home: '/home/fixture' } })
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  expect(remote.follow).toHaveBeenCalledTimes(2)
+  const attachmentId = vi.mocked(remote.follow).mock.calls[1]![2]
+  expect(attachmentId).not.toBe(oldAttachmentId)
+  expect(model.state.getSnapshot().info?.controllerId).toBe(attachmentId)
+  expect(model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'ready' })
+
+  model.write('fresh input')
+  pending.reject(new Error('old attachment was replaced'))
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.some(call => call[3] === 'fresh input')).toBe(true)
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: true, error: undefined })
+  expect(vi.mocked(remote.write).mock.calls.at(-1)?.[2]).toBe(attachmentId)
+  expect(remote.create).toHaveBeenCalledOnce()
+  expect(remote.close).not.toHaveBeenCalled()
+})
+
+it('ignores controls before discovery and reconnects an existing process after remount', async () => {
+  const { model, remote } = fixture()
+  model.connect()
+  model.write('early')
+  model.resize(100, 30)
+  model.acknowledge(100)
+  expect(remote.follow).not.toHaveBeenCalled()
+  expect(remote.write).not.toHaveBeenCalled()
+  expect(remote.resize).not.toHaveBeenCalled()
+  const unmount = await mount(model)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  unmount()
+  model.connect()
+  model.write('detached')
+  model.resize(100, 30)
+  expect(remote.follow).toHaveBeenCalledOnce()
+  expect(remote.write).not.toHaveBeenCalled()
+  expect(remote.resize).not.toHaveBeenCalled()
+  model.mount()
+  await expect.poll(() => remote.follow).toHaveBeenCalledTimes(2)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  expect(remote.create).toHaveBeenCalledOnce()
+})
+
+it('keeps controls inert when process metadata or the attachment is unavailable', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  const state = model.state.getSnapshot()
+  model.state.set({ ...state, info: undefined })
+  model.write('missing metadata')
+  model.resize(100, 30)
+  expect(remote.write).not.toHaveBeenCalled()
+  expect(remote.resize).not.toHaveBeenCalled()
+  model.state.set({ ...state, environment: undefined })
+  model.resize(100, 30)
+  await expect.poll(() => remote.resize).toHaveBeenCalledOnce()
+  expect(vi.mocked(remote.resize).mock.calls[0]?.slice(-2)).toEqual([100, 30])
+  model.write('unknown budget')
+  expect(model.state.getSnapshot().issue).toBe('inputFull')
+  expect(remote.write).not.toHaveBeenCalled()
+})
+
+it('renames an existing terminal, skips unchanged names, and exposes rename failures', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  await model.rename(`  ${info.title}  `)
+  expect(remote.rename).not.toHaveBeenCalled()
+  await model.rename('  Build  ')
+  expect(model.state.getSnapshot()).toMatchObject({ title: 'Build', info: { title: 'Build' } })
+  vi.mocked(remote.rename).mockRejectedValueOnce('rename connection lost')
+  await model.rename('Other')
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'rename connection lost', title: 'Build' })
+  await model.dispose()
+  await model.rename('Ignored')
+  expect(remote.rename).toHaveBeenCalledTimes(2)
+})
+
+it('classifies a discovery carrier failure as disconnected and supports explicit retry', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.environment).mockRejectedValueOnce(new RemoteStreamCarrierError('offline'))
+  await model.refresh()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'disconnected', error: 'offline' })
+  expect(remote.create).not.toHaveBeenCalled()
+  await model.refresh()
+  await model.start()
+  expect(model.state.getSnapshot().info?.id).toBe(info.id)
+  expect(remote.create).toHaveBeenCalledOnce()
+})
+
+it('retains state when disposal overtakes successful allocation or a failed discovery', async () => {
+  for (const operation of ['creation', 'discovery'] as const) {
+    const { model, remote } = fixture()
+    const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
+    const discovery = Promise.withResolvers<RemoteResult<TerminalEnvironment>>()
+    if (operation === 'creation') vi.mocked(remote.create).mockReturnValueOnce(creation.promise)
+    else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise)
+    const loading = model.refresh()
+    const starting = operation === 'creation' ? loading.then(() => model.start()) : undefined
+    if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce()
+    await model.dispose()
+    const before = model.state.getSnapshot()
+    if (operation === 'creation') creation.resolve(success(info))
+    else discovery.reject(new Error('late discovery failure'))
+    await loading
+    await starting
+    expect(model.state.getSnapshot()).toBe(before)
+    expect(remote.follow).not.toHaveBeenCalled()
+  }
+})
+
+it('ignores a successful rename after the view is disposed', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  const rename = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.rename).mockReturnValueOnce(rename.promise)
+  const pending = model.rename('Late')
+  await model.dispose()
+  const before = model.state.getSnapshot()
+  rename.resolve(success(undefined))
+  await pending
+  expect(model.state.getSnapshot()).toBe(before)
+})
+
+it('allows retry after failed process cleanup and never reconnects while close is pending', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  const closing = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.close).mockReturnValueOnce(closing.promise)
+  const first = model.close()
+  expect(model.close()).toBe(first)
+  model.connect()
+  expect(remote.follow).toHaveBeenCalledOnce()
+  closing.resolve(failure('close refused'))
+  await expect(first).rejects.toThrow('close refused')
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', error: 'close refused' })
+  await model.close()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'closed', writable: false })
+  expect(remote.close).toHaveBeenCalledTimes(2)
+})
+
+it('does not publish a close result or reconnect after disposal', async () => {
+  const { model, remote } = fixture()
+  await connected(model)
+  const closing = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(remote.close).mockReturnValueOnce(closing.promise)
+  const pending = model.close()
+  await model.dispose()
+  const before = model.state.getSnapshot()
+  closing.resolve(success(undefined))
+  await pending
+  model.mount()
+  model.connect()
+  expect(model.state.getSnapshot()).toBe(before)
+  expect(remote.follow).toHaveBeenCalledOnce()
+})
+
+it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => {
+  const { model, remote } = fixture()
+  const transfer = Promise.withResolvers<undefined>()
+  const response = Promise.withResolvers<RemoteResult<void>>()
+  cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) })
+  vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
+    yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } }
+    await transfer.promise
+    yield { type: 'state', info: { ...info, id } }
+    await untilAborted(signal)
+  })
+  await connected(model)
+  vi.mocked(remote[operation]).mockReturnValueOnce(response.promise)
+  if (operation === 'write') model.write('before transfer')
+  else model.resize(100, 30)
+  model.write('queued before transfer')
+  await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1)
+  response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) })
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(false)
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } })
+  transfer.resolve(undefined)
+  await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined()
+  expect(remote.follow).toHaveBeenCalledOnce()
+  expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false)
+})
+
+it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => {
+  const { model, remote } = fixture()
+  const exit = Promise.withResolvers<undefined>()
+  const response = Promise.withResolvers<RemoteResult<void>>()
+  cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) })
+  vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
+    yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } }
+    await exit.promise
+    yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } }
+    await untilAborted(signal)
+  })
+  await connected(model)
+  vi.mocked(remote.write).mockReturnValueOnce(response.promise)
+  model.write('exit race')
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1)
+  exit.resolve(undefined)
+  await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited')
+  response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) })
+  await setImmediate()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } })
+})
+
+it('exposes a localized quota error and clears it after a successful retry', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) })
+  await model.refresh()
+  await model.start()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' })
+  await connected(model)
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined })
+})

+ 515 - 0
packages/api/terminal-controller/tests/recovery.client.spec.ts

@@ -0,0 +1,515 @@
+/** Reloaded tab identities and nonblocking close requests use independent lifetimes. */
+import { setImmediate } from 'node:timers/promises'
+import { afterEach, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts'
+import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
+import { TerminalCloseRequests } from '../src/client/close-requests.ts'
+import * as TerminalClient from '../src/client/index.ts'
+
+const sessionId = 'session' as SessionId
+const info: WebTerminalInfo = { id: 'terminal' as WebTerminalId, shell: { name: 'zsh', path: '/bin/zsh', args: ['-i'] }, title: 'zsh', cwd: '/workspace', rows: 24, cols: 80, state: 'running', exitCode: null }
+const environment: TerminalEnvironment = { cwd: info.cwd, maxInputBytes: 1000, maxCols: 200, maxRows: 100, scrollback: 100 }
+const success = <T>(value: T): RemoteResult<T> => ({ ok: true, value })
+const failure = (message: string): RemoteResult<never> => ({ ok: false, error: new RemoteError('gateway/bad-request', message, {}) })
+const cleanups: (() => void | Promise<void>)[] = []
+afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
+
+function storage() {
+  const data = new Map<string, string>()
+  vi.stubGlobal('localStorage', { get length() { return data.size }, key: (index: number) => [...data.keys()][index] ?? null, getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value) }, removeItem: (key: string) => { data.delete(key) } })
+  return data
+}
+
+function fixture() {
+  const remote: TerminalRemote = {
+    shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
+    environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
+    create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
+    close: vi.fn<TerminalRemote['close']>(async () => success(undefined)), rename: vi.fn<TerminalRemote['rename']>(async () => success(undefined)),
+    write: vi.fn<TerminalRemote['write']>(async () => success(undefined)), resize: vi.fn<TerminalRemote['resize']>(async () => success(undefined)),
+    follow: vi.fn<TerminalRemote['follow']>(async function* (_session, id, controllerId, signal) {
+      yield { type: 'snapshot', sequence: 0, screen: 'retained', info: { ...info, id, controllerId } }
+      await new Promise<void>((resolve) => {
+        if (signal?.aborted) resolve()
+        else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    }),
+  }
+  const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
+  function view() {
+    const model = new TerminalView(sessionId, remote, gateway, info.id)
+    cleanups.push(() => model.dispose())
+    return model
+  }
+  async function service() {
+    const ctx = new Context()
+    ctx.provide('remote', { ...gateway, terminal: remote } as never)
+    ctx.provide('remote.terminal', remote)
+    const fiber = ctx.plugin(TerminalClient)
+    cleanups.push(async () => { await ctx.fiber.dispose() })
+    await fiber
+    return { service: ctx.webTerminals, dispose: () => fiber.dispose() }
+  }
+  return { remote, view, service }
+}
+
+it('waits for shell selection and deduplicates overlapping starts', async () => {
+  const h = fixture()
+  const model = h.view()
+  const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
+  vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
+  model.mount()
+  const loading = model.refresh()
+  expect(model.refresh()).toBe(loading)
+  await loading
+  expect(h.remote.create).not.toHaveBeenCalled()
+  const starting = model.start()
+  const remounting = model.refresh()
+  void model.start()
+  expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, shellPath: info.shell.path, cols: 80, rows: 24 })
+  creation.resolve(success(info))
+  await starting
+  await remounting
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
+it('recovers a listed process without creating another, including its title and screen', async () => {
+  const h = fixture()
+  vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, title: 'Build' }]))
+  const model = h.view()
+  model.mount()
+  await model.refresh()
+  expect(h.remote.create).not.toHaveBeenCalled()
+  await expect.poll(() => model.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
+  expect(h.remote.follow).toHaveBeenCalledWith(sessionId, info.id, expect.any(String), expect.any(AbortSignal))
+})
+
+it('does not recreate a recovered terminal that disappeared after the recovery list was shown', async () => {
+  const h = fixture()
+  const { service } = await h.service()
+  vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
+  expect(await service.recover(sessionId)).toEqual([info])
+  const model = service.view(sessionId, 'recovered', info.id)
+  await model.refresh()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false })
+  expect(model.state.getSnapshot().issue).toBe('missingTerminal')
+  expect(h.remote.create).not.toHaveBeenCalled()
+  model.mount()
+  await model.refresh()
+  expect(h.remote.create).not.toHaveBeenCalled()
+})
+
+it('retries lost create acknowledgements with the saved id and closes after a refused allocation', async () => {
+  const h = fixture()
+  const model = h.view()
+  vi.mocked(h.remote.create).mockResolvedValueOnce(failure('response lost'))
+  await model.refresh()
+  await model.start()
+  expect(model.state.getSnapshot().error).toBe('response lost')
+  await model.refresh()
+  await model.start()
+  expect(vi.mocked(h.remote.create).mock.calls.map(call => call[1].id)).toEqual([info.id, info.id])
+  await model.close()
+  expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
+})
+
+it('waits for an in-flight creation while close detaches immediately and prevents a late connection', async () => {
+  const h = fixture()
+  const model = h.view()
+  const creation = Promise.withResolvers<RemoteResult<WebTerminalInfo>>()
+  vi.mocked(h.remote.create).mockReturnValueOnce(creation.promise)
+  const unmount = model.mount()
+  await model.refresh()
+  const starting = model.start()
+  await expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
+  const closing = model.close()
+  expect(model.close()).toBe(closing)
+  unmount()
+  expect(h.remote.close).not.toHaveBeenCalled()
+  creation.resolve(failure('cancelled allocation'))
+  await starting
+  await closing
+  expect(h.remote.close).toHaveBeenCalledWith(sessionId, info.id)
+  expect(h.remote.follow).not.toHaveBeenCalled()
+})
+
+it('does not allocate after close or disposal overtakes discovery', async () => {
+  for (const action of ['close', 'dispose'] as const) {
+    const h = fixture()
+    const model = h.view()
+    const discovery = Promise.withResolvers<RemoteResult<TerminalEnvironment>>()
+    vi.mocked(h.remote.environment).mockReturnValueOnce(discovery.promise)
+    const loading = model.refresh()
+    await model[action]()
+    discovery.resolve(success(environment))
+    await loading
+    await model.refresh()
+    expect(h.remote.create).not.toHaveBeenCalled()
+  }
+})
+
+it('updates a restored inactive terminal title and ignores late results after disposal', async () => {
+  const h = fixture()
+  const model = h.view()
+  await model.rename('  Build  ')
+  expect(h.remote.rename).toHaveBeenCalledWith(sessionId, info.id, '  Build  ')
+  expect(model.state.getSnapshot().title).toBe('Build')
+  const rename = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise)
+  const pending = model.rename('Late')
+  await model.dispose()
+  rename.resolve(success(undefined))
+  await pending
+  expect(model.state.getSnapshot().title).toBe('Build')
+})
+
+it('removes a view synchronously, keeps cleanup retryable, and never deletes a replacement view', async () => {
+  storage()
+  const h = fixture()
+  const { service } = await h.service()
+  const first = service.view(sessionId, 'tab')
+  first.mount()
+  await first.refresh()
+  const closing = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise)
+  service.close(sessionId, 'tab')
+  const replacement = service.view(sessionId, 'tab')
+  expect(replacement).not.toBe(first)
+  expect(service.closeFailures.getSnapshot()).toEqual([])
+  closing.resolve(failure('termination refused'))
+  await expect.poll(() => service.closeFailures.getSnapshot().length).toBe(1)
+  const failed = service.closeFailures.getSnapshot()[0]!
+  service.retryClose(failed.id)
+  service.retryClose(failed.id)
+  await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([])
+  expect(service.view(sessionId, 'tab')).toBe(replacement)
+  await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
+})
+
+it('closes an inactive restored tab by tab identity and retries saved close requests after reload', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  first.service.close(sessionId, 'inactive', 'inactive' as WebTerminalId)
+  await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
+  expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive')
+  await first.dispose()
+  const pending = new TerminalCloseRequests()
+  pending.save({ sessionId, id: 'second' as WebTerminalId, title: 'Second' })
+  await h.service()
+  await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(2)
+  expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'second')
+  await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
+})
+
+it('keeps concurrent windows close requests independent and removes only the settled request', () => {
+  storage()
+  const a = new TerminalCloseRequests()
+  const b = new TerminalCloseRequests()
+  a.save({ sessionId, id: 'a' as WebTerminalId, title: 'A' })
+  b.save({ sessionId, id: 'b' as WebTerminalId, title: 'B' })
+  expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['a', 'b'])
+  a.remove('a' as WebTerminalId)
+  expect(new TerminalCloseRequests().pending().map(request => request.id)).toEqual(['b'])
+})
+
+it('recovers Host terminals by Session while excluding held, pending-close, and already-closed identities', async () => {
+  storage()
+  const h = fixture()
+  const { service } = await h.service()
+  const held = service.view(sessionId, 'held')
+  await held.refresh()
+  const closing = service.view(sessionId, 'closing')
+  await closing.refresh()
+  const done = service.view(sessionId, 'closed')
+  await done.refresh()
+  const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId }
+  const pending = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
+  service.close(sessionId, 'closing')
+  service.close(sessionId, 'closed')
+  await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1)
+  vi.mocked(h.remote.list).mockResolvedValue(success([
+    { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld,
+  ]))
+  expect(await service.recover(sessionId)).toEqual([unheld])
+  expect(h.remote.list).toHaveBeenLastCalledWith(sessionId)
+  const otherSession = 'other-session' as SessionId
+  vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
+  expect(await service.recover(otherSession)).toEqual([info])
+  pending.resolve(success(undefined))
+  await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
+  expect(await service.recover(sessionId)).toEqual([unheld])
+})
+
+it('queries current held views after a slow recovery response and reports discovery errors', async () => {
+  const h = fixture()
+  const { service } = await h.service()
+  const listed = Promise.withResolvers<RemoteResult<WebTerminalInfo[]>>()
+  vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
+  const recovering = service.recover(sessionId)
+  vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
+  const model = service.view(sessionId, 'recovered', info.id)
+  await model.refresh()
+  listed.resolve(success([info]))
+  expect(await recovering).toEqual([])
+  expect(service.view(sessionId, 'recovered', info.id)).toBe(model)
+  expect(model.id).toBe(info.id)
+  vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable'))
+  await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable')
+})
+
+it('ignores an unknown tab and retries only saved close requests', async () => {
+  storage()
+  const h = fixture()
+  const { service } = await h.service()
+  service.close(sessionId, 'unknown')
+  service.retryClose('unknown' as WebTerminalId)
+  expect(h.remote.close).not.toHaveBeenCalled()
+  expect(new TerminalCloseRequests().pending()).toEqual([])
+})
+
+it('retains an inactive close failure with its tab title until retry succeeds', async () => {
+  const data = storage()
+  const h = fixture()
+  const { service } = await h.service()
+  vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup'))
+  service.close(sessionId, 'Build', info.id)
+  expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true)
+  await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }])
+  vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
+  expect(await service.recover(sessionId)).toEqual([])
+  const pending = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
+  service.retryClose(info.id)
+  service.retryClose(info.id)
+  expect(service.closeFailures.getSnapshot()).toEqual([])
+  expect(h.remote.close).toHaveBeenCalledTimes(2)
+  pending.resolve(success(undefined))
+  await expect.poll(() => data.size).toBe(0)
+})
+
+it('preserves a close failure from a non-Error rejection', async () => {
+  storage()
+  const h = fixture()
+  const { service } = await h.service()
+  vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed')
+  service.close(sessionId, 'Build', info.id)
+  await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }])
+})
+
+it('waits for pending cleanup during service disposal without publishing a late failure', async () => {
+  storage()
+  const h = fixture()
+  const { service, dispose } = await h.service()
+  const pending = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
+  service.close(sessionId, 'Build', info.id)
+  let finished = false
+  const disposing = dispose().then(() => { finished = true })
+  expect(finished).toBe(false)
+  pending.resolve(failure('transport is stopping'))
+  await disposing
+  expect(service.closeFailures.getSnapshot()).toEqual([])
+  expect(new TerminalCloseRequests().pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }])
+  service.retryClose(info.id)
+  expect(h.remote.close).toHaveBeenCalledOnce()
+})
+
+it('saves close intents without persisting any active terminal or sidebar state', async () => {
+  const data = storage()
+  const h = fixture()
+  const { service } = await h.service()
+  const model = service.view(sessionId, 'new-tab')
+  await model.refresh()
+  expect(model.id).toMatch(/^[0-9a-f-]{36}$/)
+  expect(data.size).toBe(0)
+  const pending = Promise.withResolvers<RemoteResult<void>>()
+  vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
+  service.close(sessionId, 'new-tab')
+  const request = { sessionId, id: model.id, title: 'new-tab' }
+  expect([...data.entries()]).toEqual([[`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]])
+  pending.resolve(success(undefined))
+  await expect.poll(() => data.size).toBe(0)
+})
+
+it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => {
+  const data = storage()
+  data.set('dsh.terminal.close.v1.terminal', raw)
+  const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+  expect(new TerminalCloseRequests().pending()).toEqual([])
+  expect(error).toHaveBeenCalledOnce()
+})
+
+it('skips unrelated storage and cleanup keys removed during enumeration', () => {
+  const getItem = vi.fn(() => null)
+  vi.stubGlobal('localStorage', {
+    length: 3,
+    key: (index: number) => ['unrelated', 'dsh.terminal.close.v1.gone', null][index],
+    getItem,
+  })
+  expect(new TerminalCloseRequests().pending()).toEqual([])
+  expect(getItem).toHaveBeenCalledExactlyOnceWith('dsh.terminal.close.v1.gone')
+})
+
+it('keeps close requests usable without browser storage', () => {
+  vi.stubGlobal('localStorage', undefined)
+  const requests = new TerminalCloseRequests()
+  requests.save({ sessionId, id: info.id, title: 'Build' })
+  expect(requests.pending()).toEqual([{ sessionId, id: info.id, title: 'Build' }])
+  requests.remove(info.id)
+  expect(requests.pending()).toEqual([])
+})
+
+it('keeps cleanup usable in memory when storage access itself is denied', () => {
+  const error = vi.spyOn(console, 'error').mockImplementation(() => {})
+  vi.stubGlobal('localStorage', undefined)
+  Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('storage denied') } })
+  const requests = new TerminalCloseRequests()
+  requests.save({ sessionId, id: info.id, title: 'Build' })
+  expect(requests.pending()).toHaveLength(1)
+  requests.remove(info.id)
+  expect(requests.pending()).toEqual([])
+  expect(error).toHaveBeenCalledTimes(3)
+})
+
+it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => {
+  const data = storage()
+  const h = fixture()
+  const requests = new TerminalCloseRequests()
+  if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' })
+  vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) })
+  const { service, dispose } = await h.service()
+  if (source === 'view') {
+    const view = service.view(sessionId, 'tab')
+    await view.refresh()
+    service.close(sessionId, 'tab')
+  }
+  await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
+  await dispose()
+  expect(data.size).toBe(0)
+  expect(service.closeFailures.getSnapshot()).toEqual([])
+  expect(new TerminalCloseRequests().pending()).toEqual([])
+  await h.service()
+  expect(h.remote.close).toHaveBeenCalledOnce()
+})
+
+it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => {
+  const h = fixture()
+  const { service, dispose } = await h.service()
+  const started = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
+  const release = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
+  const finished = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
+  cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) })
+  let index = 0
+  vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
+    const current = index++
+    try {
+      yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } }
+      await new Promise<void>((resolve) => {
+        if (signal?.aborted) resolve()
+        else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    } finally {
+      started[current]!.resolve(undefined)
+      await release[current]!.promise
+      finished[current]!.resolve(undefined)
+    }
+  })
+  const model = service.view(sessionId, 'tab')
+  model.mount()
+  await model.refresh()
+  await model.start()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  model.connect()
+  await started[0]!.promise
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  let disposed = false
+  const disposing = dispose().then(() => { disposed = true })
+  await started[1]!.promise
+  release[1]!.resolve(undefined)
+  await finished[1]!.promise
+  // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending.
+  await setImmediate()
+  expect(disposed).toBe(false)
+  release[0]!.resolve(undefined)
+  await disposing
+  expect(disposed).toBe(true)
+  expect(h.remote.close).not.toHaveBeenCalled()
+})
+
+it('remembers a shell choice immediately for new views and falls back when that shell is absent', async () => {
+  const data = storage()
+  const h = fixture()
+  const alternate = { name: 'bash', path: '/bin/bash', args: ['-i'] }
+  vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell, alternate]))
+  vi.mocked(h.remote.create).mockImplementation(async (_sessionId, request) => success({ ...info, shell: alternate, id: request.id }))
+  const first = h.view()
+  await first.start()
+  first.selectShell(alternate.path)
+  await first.refresh()
+  expect(first.state.getSnapshot()).toMatchObject({ phase: 'selecting', selectedShell: info.shell.path })
+  first.selectShell('/not-listed')
+  expect(first.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  first.selectShell(alternate.path)
+  expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
+  const beforeLaunch = h.view()
+  await beforeLaunch.refresh()
+  expect(beforeLaunch.state.getSnapshot().selectedShell).toBe(alternate.path)
+  expect(h.remote.create).not.toHaveBeenCalled()
+  await first.start()
+  expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
+  expect(h.remote.create).toHaveBeenCalledWith(sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal))
+  const second = h.view()
+  await second.refresh()
+  expect(second.state.getSnapshot().selectedShell).toBe(alternate.path)
+  await second.dispose()
+  await second.start()
+  vi.mocked(h.remote.shells).mockResolvedValue(success([info.shell]))
+  const third = h.view()
+  await third.refresh()
+  expect(third.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
+it('retains the last selection across a failed launch', async () => {
+  const data = storage()
+  const h = fixture()
+  vi.mocked(h.remote.create).mockResolvedValueOnce(failure('shell disappeared'))
+  const model = h.view()
+  await model.refresh()
+  await model.start()
+  expect(data.get('dsh.terminal.shell')).toBe(info.shell.path)
+  await model.refresh()
+  expect(model.state.getSnapshot().selectedShell).toBe(info.shell.path)
+  await model.close()
+  await model.start()
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
+it('keeps launch usable when browser storage is denied and stops late shell discovery after close', async () => {
+  vi.stubGlobal('localStorage', undefined)
+  Object.defineProperty(globalThis, 'localStorage', { configurable: true, get() { throw new Error('denied') } })
+  const h = fixture()
+  const model = h.view()
+  await model.refresh()
+  await model.start()
+  expect(model.state.getSnapshot().info).toBeDefined()
+  const delayed = h.view()
+  const shells = Promise.withResolvers<Awaited<ReturnType<TerminalRemote['shells']>>>()
+  vi.mocked(h.remote.shells).mockReturnValueOnce(shells.promise)
+  const loading = delayed.refresh()
+  await expect.poll(() => h.remote.shells).toHaveBeenCalledTimes(2)
+  await delayed.close()
+  shells.resolve(success([info.shell]))
+  await loading
+  expect(delayed.state.getSnapshot().phase).toBe('closed')
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})

+ 80 - 0
packages/api/terminal-controller/tests/stream.spec.ts

@@ -0,0 +1,80 @@
+/** Output followers drain on process exit and discard queued data on detach. */
+import { describe, expect, it } from 'vitest'
+import { TerminalFollower } from '../src/stream.ts'
+import type { TerminalFrame } from '../src/types.ts'
+
+const frame: TerminalFrame = { type: 'output', sequence: 1, data: '终端' }
+const signal = (): AbortSignal => new AbortController().signal
+
+describe('TerminalFollower', () => {
+  it('accepts the exact byte limit and restores capacity after each frame is consumed', async () => {
+    const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8'))
+    const stream = follower.read(signal())[Symbol.asyncIterator]()
+    try {
+      follower.push(frame)
+      expect(await stream.next()).toEqual({ done: false, value: frame })
+      follower.push(frame)
+      follower.finish()
+      follower.push({ ...frame, sequence: 2 })
+      expect(await stream.next()).toEqual({ done: false, value: frame })
+      expect(await stream.next()).toEqual({ done: true, value: undefined })
+    } finally { follower.close(); await stream.return?.() }
+  })
+
+  it('fails accumulated overflow using encoded byte size', async () => {
+    const follower = new TerminalFollower(Buffer.byteLength(JSON.stringify(frame), 'utf8'))
+    follower.push(frame)
+    follower.push(frame)
+    await expect(follower.read(signal())[Symbol.asyncIterator]().next()).rejects.toThrow('reconnect to recover')
+  })
+
+  it('discards queued data when detached and ignores later output', async () => {
+    const follower = new TerminalFollower(1000)
+    follower.push(frame)
+    follower.close()
+    follower.push(frame)
+    expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined })
+  })
+
+  it('does not deliver buffered output to an already-aborted request', async () => {
+    const follower = new TerminalFollower(1000)
+    follower.push(frame)
+    const abort = new AbortController()
+    abort.abort()
+    expect(await follower.read(abort.signal)[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined })
+  })
+
+  it('wakes a waiting reader for output and then for graceful completion', async () => {
+    const follower = new TerminalFollower(1000)
+    const stream = follower.read(signal())[Symbol.asyncIterator]()
+    try {
+      const reading = stream.next()
+      follower.push(frame)
+      expect(await reading).toEqual({ done: false, value: frame })
+      const finishing = stream.next()
+      follower.finish()
+      expect(await finishing).toEqual({ done: true, value: undefined })
+    } finally { follower.close(); await stream.return?.() }
+  })
+
+  it('wakes a waiting reader on cancellation', async () => {
+    const follower = new TerminalFollower(1000)
+    const abort = new AbortController()
+    const stream = follower.read(abort.signal)[Symbol.asyncIterator]()
+    try {
+      const reading = stream.next()
+      abort.abort()
+      expect(await reading).toEqual({ done: true, value: undefined })
+    } finally { follower.close(); await stream.return?.() }
+  })
+
+  it('closes the follower when its consumer returns before EOF', async () => {
+    const follower = new TerminalFollower(1000)
+    const stream = follower.read(signal())[Symbol.asyncIterator]()
+    follower.push(frame)
+    await stream.next()
+    await stream.return?.()
+    follower.push(frame)
+    expect(await follower.read(signal())[Symbol.asyncIterator]().next()).toEqual({ done: true, value: undefined })
+  })
+})

+ 228 - 0
packages/api/terminal-controller/tests/terminal.spec.ts

@@ -0,0 +1,228 @@
+/** Terminal screen continuity and process ownership under real output scheduling. */
+import { PassThrough } from 'node:stream'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
+import { BrowserTerminal } from '../src/terminal.ts'
+import { TerminalFollower } from '../src/stream.ts'
+import type { TerminalAttachmentId, TerminalFrame, WebTerminalId, WebTerminalInfo } from '../src/types.ts'
+
+const info: WebTerminalInfo = { id: 'terminal-test' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null }
+const attachment = (id: string): TerminalAttachmentId => id as TerminalAttachmentId
+const cleanups: (() => Promise<void>)[] = []
+afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())) })
+
+function fixture() {
+  const output = new PassThrough()
+  const outcome = Promise.withResolvers<SubprocessOutcome>()
+  const handle = {
+    pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
+    inspectForeground: async () => undefined, signalForeground: async () => 123,
+    terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }),
+  }
+  const checked: SubprocessTerminalHandle = handle
+  const terminal = new BrowserTerminal(checked, info, 100, 100_000)
+  cleanups.push(() => terminal.close())
+  return { terminal, output, outcome, handle }
+}
+
+async function attach(terminal: BrowserTerminal, id = 'first') {
+  const controller = new AbortController()
+  const iterator = terminal.follow(attachment(id), controller.signal)[Symbol.asyncIterator]()
+  const baseline = await readFrame(iterator)
+  cleanups.push(async () => { controller.abort(); await iterator.return?.() })
+  return { controller, iterator, baseline }
+}
+
+describe('BrowserTerminal', () => {
+  it('restores the screen after detach without re-executing the shell or replaying duplicate output', async () => {
+    const { terminal, output, handle } = fixture()
+    const first = await attach(terminal)
+    expect(first.baseline).toMatchObject({ type: 'snapshot', sequence: 0 })
+    output.write(Buffer.from('hello\r\n'))
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', sequence: 1, data: 'hello\r\n' })
+    first.controller.abort()
+    await first.iterator.return?.()
+    expect(handle.terminate).not.toHaveBeenCalled()
+    output.write(Buffer.from('world'))
+    await expect.poll(() => terminal.info.state).toBe('running')
+    const second = await attach(terminal, 'second')
+    // The baseline and its output queue share the same ordered screen write queue.
+    const frames = [second.baseline]
+    if (second.baseline.type === 'snapshot' && !second.baseline.screen.includes('world')) frames.push(await readFrame(second.iterator))
+    expect(JSON.stringify(frames)).toContain('world')
+    expect(JSON.stringify(frames)).toContain('hello')
+    expect(handle.terminate).not.toHaveBeenCalled()
+  })
+
+  it('preserves split UTF-8, gives the newest attachment input, and resizes both PTY and recovery screen', async () => {
+    const { terminal, output, handle } = fixture()
+    const first = await attach(terminal)
+    const encoded = Buffer.from('终端')
+    output.write(encoded.subarray(0, 2))
+    await expect.poll(() => output.readableLength).toBe(0)
+    output.write(encoded.subarray(2))
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '终端' })
+    const second = await attach(terminal, 'second')
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } })
+    await terminal.write(attachment('second'), '\t')
+    expect(handle.write).toHaveBeenCalledWith('\t')
+    await terminal.resize(attachment('second'), 100, 30)
+    expect(handle.resize).toHaveBeenCalledWith(100, 30)
+    expect(terminal.info).toMatchObject({ cols: 100, rows: 30 })
+    second.controller.abort()
+    await second.iterator.return?.()
+    expect(terminal.info.controllerId).toBeUndefined()
+  })
+
+  it('keeps exit facts and screen until explicit cleanup and never starts a replacement process', async () => {
+    const { terminal, output, outcome, handle } = fixture()
+    const first = await attach(terminal)
+    output.end('done')
+    outcome.resolve({ exitCode: 7, signal: null })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'done' })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited', exitCode: 7 } })
+    expect(handle.terminate).not.toHaveBeenCalled()
+    const second = await attach(terminal, 'second')
+    expect(second.baseline).toMatchObject({ type: 'snapshot', info: { state: 'exited', exitCode: 7 } })
+    await terminal.close()
+    expect(handle.terminate).toHaveBeenCalledOnce()
+  })
+
+  it('drains final output and exit state before closing followers', async () => {
+    const { terminal, handle, output, outcome } = fixture()
+    const first = await attach(terminal)
+    vi.mocked(handle.terminate).mockImplementationOnce(async () => {
+      output.end('FINAL OUTPUT\r\n')
+      outcome.resolve({ exitCode: 0, signal: null })
+    })
+    await terminal.close()
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'FINAL OUTPUT\r\n' })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
+    expect((await first.iterator.next()).done).toBe(true)
+  })
+
+  it('rejects input before attachment, during close and after process exit', async () => {
+    const { terminal, handle, output, outcome } = fixture()
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } })
+    const first = await attach(terminal)
+    output.end()
+    outcome.resolve({ exitCode: 0, signal: null })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
+    await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
+    const closing = terminal.close()
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
+    await closing
+    expect(handle.write).not.toHaveBeenCalled()
+    expect(handle.resize).not.toHaveBeenCalled()
+  })
+
+  it('preserves the input controller when an older follower detaches', async () => {
+    const { terminal, handle } = fixture()
+    const first = await attach(terminal)
+    const second = await attach(terminal, 'second')
+    first.controller.abort()
+    await first.iterator.return?.()
+    expect(terminal.info.controllerId).toBe(attachment('second'))
+    terminal.rename('build output')
+    expect(await readFrame(second.iterator)).toMatchObject({ type: 'state', info: { title: 'build output', controllerId: 'second' } })
+    await terminal.write(attachment('second'), 'pwd\r')
+    expect(handle.write).toHaveBeenCalledWith('pwd\r')
+  })
+
+  it('does not grant input to attachments cancelled before their snapshot is ready', async () => {
+    const { terminal, handle } = fixture()
+    const aborted = new AbortController()
+    aborted.abort(new Error('already detached'))
+    await expect(terminal.follow(attachment('cancelled'), aborted.signal)[Symbol.asyncIterator]().next()).rejects.toThrow('already detached')
+    await attach(terminal)
+    const writing = Promise.withResolvers<undefined>()
+    const written = Promise.withResolvers<undefined>()
+    handle.write.mockImplementationOnce(async () => { writing.resolve(undefined); await written.promise })
+    const pendingWrite = terminal.write(attachment('first'), 'pwd\r')
+    try {
+      await writing.promise
+      const abort = new AbortController()
+      const pendingAttachment = terminal.follow(attachment('late'), abort.signal)[Symbol.asyncIterator]().next()
+      const rejected = expect(pendingAttachment).rejects.toThrow('detached while waiting')
+      abort.abort(new Error('detached while waiting'))
+      written.resolve(undefined)
+      await pendingWrite
+      await rejected
+      expect(terminal.info.controllerId).toBe(attachment('first'))
+    } finally { written.resolve(undefined) }
+  })
+
+  it('continues accepting operations after a provider write or resize fails', async () => {
+    const { terminal, handle } = fixture()
+    await attach(terminal)
+    handle.write.mockRejectedValueOnce(new Error('input transport failed'))
+    handle.resize.mockRejectedValueOnce(new Error('resize transport failed'))
+    await expect(terminal.write(attachment('first'), 'failed')).rejects.toThrow('input transport failed')
+    await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('resize transport failed')
+    expect(terminal.info).toMatchObject({ cols: 80, rows: 24 })
+    await terminal.write(attachment('first'), 'accepted')
+    await terminal.resize(attachment('first'), 90, 25)
+    expect(handle.write).toHaveBeenLastCalledWith('accepted')
+    expect(terminal.info).toMatchObject({ cols: 90, rows: 25 })
+  })
+
+  it.each([new Error('process wait failed'), 'remote process wait failed'])('publishes a failed process outcome and retains its recovery screen: %s', async (failure) => {
+    const { terminal, output, outcome } = fixture()
+    const first = await attach(terminal)
+    output.end('last output')
+    outcome.reject(failure)
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: 'last output' })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: failure instanceof Error ? failure.message : failure } })
+    const second = await attach(terminal, 'second')
+    expect(second.baseline).toMatchObject({ type: 'snapshot', screen: 'last output', info: { state: 'failed' } })
+  })
+
+  it('publishes output-stream failure even when the process wait succeeds', async () => {
+    const { terminal, output, outcome } = fixture()
+    const first = await attach(terminal)
+    output.destroy(new Error('output transport failed'))
+    outcome.resolve({ exitCode: 0, signal: null })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'failed', error: 'output transport failed' } })
+  })
+
+  it('flushes incomplete UTF-8 at EOF before publishing the process exit', async () => {
+    const { terminal, output, outcome } = fixture()
+    const first = await attach(terminal)
+    output.end(Buffer.from([0xe7, 0xbb]))
+    outcome.resolve({ exitCode: 0, signal: null })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '�' })
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
+  })
+
+  it('preserves a leading UTF-8 BOM in the terminal output stream', async () => {
+    const { terminal, output } = fixture()
+    const first = await attach(terminal)
+    output.write(Buffer.from('\uFEFF终端'))
+    expect(await readFrame(first.iterator)).toMatchObject({ type: 'output', data: '\uFEFF终端' })
+  })
+
+  it('shares concurrent close attempts and permits retry after termination fails', async () => {
+    const { terminal, handle } = fixture()
+    await attach(terminal)
+    handle.terminate.mockRejectedValueOnce(new Error('process range remains alive'))
+    const first = terminal.close()
+    expect(terminal.close()).toBe(first)
+    await expect(first).rejects.toThrow('remains alive')
+    await terminal.write(attachment('first'), 'retry cleanup next')
+    await terminal.close()
+    expect(handle.terminate).toHaveBeenCalledTimes(2)
+  })
+
+  it('reports overflow rather than silently discarding output', async () => {
+    const follower = new TerminalFollower(64)
+    follower.push({ type: 'output', sequence: 1, data: 'x'.repeat(128) })
+    await expect(follower.read(new AbortController().signal)[Symbol.asyncIterator]().next()).rejects.toThrow('buffer')
+  })
+})
+
+async function readFrame(iterator: AsyncIterator<TerminalFrame>): Promise<TerminalFrame> {
+  const result = await iterator.next()
+  if (result.done === true) throw new Error('Terminal stream ended before its expected frame')
+  return result.value
+}

+ 38 - 0
packages/api/terminal-controller/tsconfig.client.json

@@ -0,0 +1,38 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+  },
+  "files": [
+    "src/client/index.ts",
+    "src/client/model.ts",
+    "src/client/shell-preference.ts",
+    "src/types.ts",
+    "src/client/close-requests.ts"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../gateway/tsconfig.client.json"
+    },
+    {
+      "path": "../../client/store"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../util/crypto"
+    }
+  ]
+}

+ 53 - 0
packages/api/terminal-controller/tsconfig.host.json

@@ -0,0 +1,53 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+  },
+  "files": [
+    "src/index.ts",
+    "src/types.ts",
+    "src/terminal.ts",
+    "src/stream.ts",
+    "src/shells.ts"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../fs/fs"
+    },
+    {
+      "path": "../../sandbox/sandbox"
+    },
+    {
+      "path": "../../sandbox/sandbox-policy"
+    },
+    {
+      "path": "../../session/session-projection"
+    },
+    {
+      "path": "../../subprocess/subprocess"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../util/brand"
+    },
+    {
+      "path": "../../util/deque"
+    }
+  ]
+}

+ 11 - 0
packages/api/terminal-controller/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.host.json"
+    },
+    {
+      "path": "./tsconfig.client.json"
+    }
+  ]
+}

+ 7 - 0
packages/api/terminal-controller/tsdown.config.ts

@@ -0,0 +1,7 @@
+import { clientBundle } from '../../client/tsdown.client.ts'
+
+export default clientBundle(
+  '@deepseek-ai/dsh-api-terminal-controller',
+  ['lib/types/index.js'],
+  { hostPhase: true },
+)

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

@@ -104,6 +104,8 @@
 
 
     # Workspace file service: bounded read, directory listing, and the
     # Workspace file service: bounded read, directory listing, and the
     # agent-write change feed inside the session workspace root.
     # agent-write change feed inside the session workspace root.
+    - id: terminal-controller
+      name: '@deepseek-ai/dsh-api-terminal-controller'
     - id: workspace-files
     - id: workspace-files
       name: '@deepseek-ai/dsh-api-workspace-files'
       name: '@deepseek-ai/dsh-api-workspace-files'
 
 
@@ -228,6 +230,8 @@
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 
 
     # The right Sidebar's workspace file tree tab type.
     # The right Sidebar's workspace file tree tab type.
+    - id: ui-sidebar-terminal
+      name: '@deepseek-ai/dsh-client-ui-sidebar-terminal'
     - id: ui-sidebar-files
     - id: ui-sidebar-files
       name: '@deepseek-ai/dsh-client-ui-sidebar-files'
       name: '@deepseek-ai/dsh-client-ui-sidebar-files'
 
 

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

@@ -119,7 +119,9 @@
     "@deepseek-ai/dsh-workspace": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "commander": "^15.0.0",
     "commander": "^15.0.0",
-    "open": "^11.0.0"
+    "open": "^11.0.0",
+    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",

+ 0 - 2
packages/client/locale/src/locales/en.ts

@@ -34,8 +34,6 @@ export const en = {
   'unknown': 'Unknown',
   'unknown': 'Unknown',
   'none': 'None',
   'none': 'None',
   'truncated': 'Truncated',
   'truncated': 'Truncated',
-  'json.collapseNode': 'Collapse JSON node',
-  'json.expandNode': 'Expand JSON node',
   'json.label': 'JSON',
   'json.label': 'JSON',
   'markdown.footnotes': 'Footnotes',
   'markdown.footnotes': 'Footnotes',
   'markdown.truncatedCharacters': '… truncated at {total} characters',
   'markdown.truncatedCharacters': '… truncated at {total} characters',

+ 0 - 2
packages/client/locale/src/locales/zh.ts

@@ -32,8 +32,6 @@ export const zh = {
   'unknown': '未知',
   'unknown': '未知',
   'none': '无',
   'none': '无',
   'truncated': '已截断',
   'truncated': '已截断',
-  'json.collapseNode': '收起 JSON 节点',
-  'json.expandNode': '展开 JSON 节点',
   'json.label': 'JSON',
   'json.label': 'JSON',
   'markdown.footnotes': '脚注',
   'markdown.footnotes': '脚注',
   'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
   'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',

+ 2 - 1
packages/client/tsdown.client.ts

@@ -12,7 +12,7 @@
  */
  */
 import { readFile } from 'node:fs/promises'
 import { readFile } from 'node:fs/promises'
 import { existsSync, globSync, readFileSync } from 'node:fs'
 import { existsSync, globSync, readFileSync } from 'node:fs'
-import { isBuiltin } from 'node:module'
+import { createRequire, isBuiltin } from 'node:module'
 import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
 import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { fileURLToPath } from 'node:url'
 import type { TsdownPlugin, UserConfig } from 'tsdown'
 import type { TsdownPlugin, UserConfig } from 'tsdown'
@@ -681,6 +681,7 @@ const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/
 
 
 /** Resolve an emitted JS asset import against its source-tree counterpart. */
 /** Resolve an emitted JS asset import against its source-tree counterpart. */
 function sourceAssetPath(source: string, importer: string): string {
 function sourceAssetPath(source: string, importer: string): string {
+  if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source)
   const emitted = resolvePath(dirname(importer), source)
   const emitted = resolvePath(dirname(importer), source)
   if (existsSync(emitted)) return emitted
   if (existsSync(emitted)) return emitted
   const boundary = emitted.indexOf(TYPES_MARKER)
   const boundary = emitted.indexOf(TYPES_MARKER)

+ 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: 2333739f2827eb673525b9f00547d12e8d343888
-README.zh.md: 0de859abfcfac1679d605cf4447a2f4ac2312f5d
+README.md: 254c511a023aa8217b73cc0fe3802f13f2a63d80
+README.zh.md: d79f8b5060bd6a452ddaf018074adaea88b85604

+ 2 - 0
packages/client/ui-chat/README.md

@@ -41,6 +41,8 @@ Each nonempty appended `system/message` owns a collapsed prompt row, including a
 
 
 A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
 A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
 
 
+Reply timing survives reopening history: recorded token timestamps supply first-token latency and generation duration, including the Step's first token from an earlier retry attempt. Missing Step starts or token timestamps leave the affected metrics unavailable.
+
 <a id="completed-turn-footer"></a>
 <a id="completed-turn-footer"></a>
 ## Completed-turn footer
 ## Completed-turn footer
 
 

+ 2 - 0
packages/client/ui-chat/README.zh.md

@@ -43,6 +43,8 @@ kind: "package-reference"
 
 
 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
 只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
 
 
+重新打开历史后仍保留回复计时:记录中的 token 时间戳提供首 token 延迟和生成耗时,包括步骤中更早重试尝试的首个 token。缺少步骤起点或 token 时间戳时,受影响的指标保持不可用。
+
 <a id="completed-turn-footer"></a>
 <a id="completed-turn-footer"></a>
 ## 已完成轮次的页脚
 ## 已完成轮次的页脚
 
 

+ 8 - 1
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -58,6 +58,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
     return { resolve: value => localPathMediaUrl(protocol, origin, value) }
     return { resolve: value => localPathMediaUrl(protocol, origin, value) }
   }, [])
   }, [])
   const last = blocks.length - 1
   const last = blocks.length - 1
+  const hasBodyOrToolCall = blocks.some(block => block.kind === 'tool-call'
+    || (block.kind === 'text' && block.text.trim() !== ''))
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // Tool-call heads render as tool rows in the chat view's grouping pass, so
   // a node that is only those heads (or empty) would paint an empty root
   // a node that is only those heads (or empty) would paint an empty root
   // between tool groups — skip the shell unless something visible remains.
   // between tool groups — skip the shell unless something visible remains.
@@ -89,7 +91,12 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
             hidden={reasoningHidden}
             hidden={reasoningHidden}
             reveal={revealProcess}
             reveal={revealProcess}
           >
           >
-            <ReasoningRow text={block.text} running={streaming && i === last} t={t} />
+            <ReasoningRow
+              text={block.text}
+              running={streaming && i === last}
+              defaultExpanded={!hasBodyOrToolCall && block.text.trim() !== ''}
+              t={t}
+            />
           </ProcessReasoning>,
           </ProcessReasoning>,
         )
         )
         break
         break

+ 10 - 3
packages/client/ui-chat/src/client/chat/ReasoningRow.tsx

@@ -22,11 +22,18 @@ function latestLine(text: string): string {
  * the complete text.
  * the complete text.
  * @param props.text - complete or streaming reasoning text.
  * @param props.text - complete or streaming reasoning text.
  * @param props.running - whether this block is the streaming tail.
  * @param props.running - whether this block is the streaming tail.
+ * @param props.defaultExpanded - expanded state until the reader toggles it.
  * @param props.t - conversation locale seat for the running status.
  * @param props.t - conversation locale seat for the running status.
  * @returns the reasoning disclosure.
  * @returns the reasoning disclosure.
  */
  */
-export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
-  const [expanded, setExpanded] = useState(false)
+export function ReasoningRow({ text, running, defaultExpanded, t }: {
+  text: string
+  running: boolean
+  defaultExpanded: boolean
+  t: ChatViewSlotProps['t']
+}) {
+  const [expandedOverride, setExpandedOverride] = useState<boolean>()
+  const expanded = expandedOverride ?? defaultExpanded
   const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
   const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
 
 
   return (
   return (
@@ -47,7 +54,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
         open={expanded}
         open={expanded}
         expandable
         expandable
         expandOnRowClick
         expandOnRowClick
-        onToggle={() => { setExpanded(value => !value) }}
+        onToggle={() => { setExpandedOverride(!expanded) }}
         collapsedContent={(
         collapsedContent={(
           <>
           <>
             <span className={css.separator} aria-hidden />
             <span className={css.separator} aria-hidden />

+ 21 - 2
packages/client/ui-chat/src/client/conversation-nodes/assistant.ts

@@ -4,6 +4,7 @@ import type {
   ConversationNodeContext, ConversationNodeDefinition,
   ConversationNodeContext, ConversationNodeDefinition,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { assistantStreamFirstTokenTime } from '@deepseek-ai/dsh-llm/assistant-stream'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type {} from '@deepseek-ai/dsh-llm-retry/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
 import type { AssistantChatData } from '../contract/chat-nodes.ts'
@@ -161,6 +162,17 @@ function updateChunk(
   }
   }
 }
 }
 
 
+/** The Step retains its first token across live chunks and settled retry attempts. */
+function settleTiming(
+  state: AssistantState,
+  event: SessionEvent<'assistant/message' | 'assistant/attempt'>,
+): AssistantState {
+  return {
+    ...state,
+    firstTokenTime: state.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream),
+  }
+}
+
 function settleMessage(
 function settleMessage(
   state: AssistantState,
   state: AssistantState,
   match: ConversationMatch,
   match: ConversationMatch,
@@ -168,7 +180,7 @@ function settleMessage(
 ): AssistantState {
 ): AssistantState {
   const blocks = toAssistantBlocks(event.data.message.content)
   const blocks = toAssistantBlocks(event.data.message.content)
   return {
   return {
-    ...state,
+    ...settleTiming(state, event),
     blocks,
     blocks,
     visibleBlocks: countVisibleBlocks(blocks),
     visibleBlocks: countVisibleBlocks(blocks),
     hidden: false,
     hidden: false,
@@ -236,6 +248,11 @@ function fallbackState(context: ConversationNodeContext<AssistantState>): Assist
       state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
       state = updateChunk(state, match.event.data.chunk, match.event.seq, match.event.time)
       continue
       continue
     }
     }
+    if (match.event.type === 'assistant/attempt') {
+      state ??= initialState(match.event.data.turn, match.event.data.step)
+      state = settleTiming(state, match.event)
+      continue
+    }
     if (match.event.type === 'assistant/message') {
     if (match.event.type === 'assistant/message') {
       state ??= initialState(match.event.data.turn, match.event.data.step)
       state ??= initialState(match.event.data.turn, match.event.data.step)
       state = settleMessage(state, match, match.event)
       state = settleMessage(state, match, match.event)
@@ -296,6 +313,7 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
   match: (event) => {
   match: (event) => {
     if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
     if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
     if (event.type === 'assistant/live-chunk'
     if (event.type === 'assistant/live-chunk'
+      || event.type === 'assistant/attempt'
       || (event.type === 'assistant/message' && event.surfaceOp === 'append')) {
       || (event.type === 'assistant/message' && event.surfaceOp === 'append')) {
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
       return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
     }
     }
@@ -313,13 +331,14 @@ export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
       return updateChunk(context.state, match.event.data.chunk, match.event.seq, match.event.time)
     }
     }
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
     if (match.event.type === 'assistant/message') return settleMessage(context.state, match, match.event)
+    if (match.event.type === 'assistant/attempt') return settleTiming(context.state, match.event)
     if (match.event.type === 'llm/retry') {
     if (match.event.type === 'llm/retry') {
       return resetForRetry(context.state)
       return resetForRetry(context.state)
     }
     }
     return context.state
     return context.state
   },
   },
   publication: (match) => {
   publication: (match) => {
-    if (match.event.type === 'step/start') return 'none'
+    if (match.event.type === 'step/start' || match.event.type === 'assistant/attempt') return 'none'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     if (match.event.type !== 'assistant/live-chunk') return 'immediate'
     const type = match.event.data.chunk.type
     const type = match.event.data.chunk.type
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
     return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'

+ 9 - 2
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -1056,9 +1056,16 @@ describe('built-in conversation node Definitions', () => {
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     const finalNode = (node(finalizedPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(finalNode).toMatchObject({
     expect(finalNode).toMatchObject({
       blocks: [{ kind: 'text', text: 'done' }],
       blocks: [{ kind: 'text', text: 'done' }],
-      timing: { firstTokenTime: null },
+      timing: { firstTokenTime: 1_999 },
     })
     })
 
 
+    const windowed = assembler(finalizedInputs.slice(2), true)
+    const timing = () => (node(snapshot(windowed), 'assistant-step')?.data as AssistantChatData).finalNode?.timing
+    expect(timing()).toMatchObject({ stepStartTime: null, firstTokenTime: 1_999 })
+    windowed.prepend(finalizedInputs.slice(0, 2), false)
+    windowed.flush()
+    expect(timing()).toEqual(finalNode?.timing)
+
     const namedToolHistory = [
     const namedToolHistory = [
       at(40, 'turn/start', { turn: 3 }),
       at(40, 'turn/start', { turn: 3 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
       at(41, 'step/start', { turn: 3, step: 1 }),
@@ -1083,7 +1090,7 @@ describe('built-in conversation node Definitions', () => {
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     const namedTool = (node(namedToolPacked, 'assistant-step')?.data as AssistantChatData).finalNode
     expect(namedTool).toMatchObject({
     expect(namedTool).toMatchObject({
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
       blocks: [{ kind: 'tool-call', callId: 'call-2', name: 'read', argsRaw: '' }],
-      timing: { firstTokenTime: null },
+      timing: { firstTokenTime: 4_000 },
     })
     })
   })
   })
 
 

+ 29 - 2
packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

@@ -14,6 +14,26 @@ const t = makeTranslate(zh, commonZh)
 const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
 const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
 
 
 describe('ReasoningRow', () => {
 describe('ReasoningRow', () => {
+  it('collapses when body text arrives and preserves a later manual expansion', () => {
+    const reasoning = { kind: 'reasoning' as const, text: 'Inspect the session\nCheck persistence' }
+    const view = render(
+      <AssistantMarkdown t={t} blocks={[reasoning]} streaming renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    view.rerender(
+      <AssistantMarkdown t={t} blocks={[reasoning, { kind: 'text', text: 'Answer' }]}
+        streaming renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(view.getByText('思考'))
+    view.rerender(
+      <AssistantMarkdown t={t} blocks={[reasoning, { kind: 'text', text: 'Complete answer' }]}
+        streaming={false} renderMessageImages={renderMessageImages} />,
+    )
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText(/Check persistence/)).toBeTruthy()
+  })
+
   it('follows the latest streaming line, then restores the settled first line', () => {
   it('follows the latest streaming line, then restores the settled first line', () => {
     const view = render(
     const view = render(
       <AssistantMarkdown
       <AssistantMarkdown
@@ -24,6 +44,8 @@ describe('ReasoningRow', () => {
       />,
       />,
     )
     )
     expect(view.getByText('运行中')).toBeTruthy()
     expect(view.getByText('运行中')).toBeTruthy()
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
+    fireEvent.click(view.getByText('思考'))
     expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
     expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-follow-end'))
       .toBe('true')
       .toBe('true')
 
 
@@ -62,6 +84,7 @@ describe('ReasoningRow', () => {
     )
     )
     const row = view.getByRole('button')
     const row = view.getByRole('button')
 
 
+    fireEvent.click(view.getByText('思考'))
     fireEvent.click(view.getByText('Inspect the session'))
     fireEvent.click(view.getByText('Inspect the session'))
     expect(row.getAttribute('aria-expanded')).toBe('true')
     expect(row.getAttribute('aria-expanded')).toBe('true')
     expect(view.getByText(/Check persistence/)).toBeTruthy()
     expect(view.getByText(/Check persistence/)).toBeTruthy()
@@ -91,6 +114,7 @@ describe('ReasoningRow', () => {
       />,
       />,
     )
     )
 
 
+    fireEvent.click(view.getByText('思考'))
     expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy()
     expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy()
     expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
     expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
 
 
@@ -98,7 +122,7 @@ describe('ReasoningRow', () => {
     expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
     expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
   })
   })
 
 
-  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
+  it('opens reasoning-only replies as plain prose and allows manual collapse', () => {
     const view = render(
     const view = render(
       <AssistantMarkdown
       <AssistantMarkdown
         t={t}
         t={t}
@@ -107,10 +131,13 @@ describe('ReasoningRow', () => {
         renderMessageImages={renderMessageImages}
         renderMessageImages={renderMessageImages}
       />,
       />,
     )
     )
-    fireEvent.click(view.getByText('思考'))
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('true')
     expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
     expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
     expect(view.queryByText('IN')).toBeNull()
     expect(view.queryByText('IN')).toBeNull()
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
     expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
     expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
     expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
+    fireEvent.click(view.getByText('思考'))
+    expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
+    expect(view.queryByText(/Check persistence/)).toBeNull()
   })
   })
 })
 })

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: d22fa2eed35457c0599fd41473102b1aebe4bd2e
-README.zh.md: b53cf76dc8b8a253da83d1e94b6f9f1b5abf19cc
+README.md: 07e61be1bb993716c85273aac8ee6e650d221365
+README.zh.md: 194d8e994d82ae167ce2c7af06d9b199ef066b27

+ 3 - 1
packages/client/ui-primitives/README.md

@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. |
+| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |
@@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
 
 
 
+`JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
+
 ### Localizing copy
 ### Localizing copy
 
 
 The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
 The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.

+ 3 - 1
packages/client/ui-primitives/README.zh.md

@@ -52,7 +52,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。 |
+| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |
@@ -74,6 +74,8 @@ kind: "package-library"
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 
 
 
+`JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
+
 ### 本地化文案
 ### 本地化文案
 
 
 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。

+ 141 - 18
packages/client/ui-primitives/src/JsonTree.module.css

@@ -29,17 +29,17 @@
 
 
 .container {
 .container {
   box-sizing: border-box;
   box-sizing: border-box;
-  width: max-content;
-  min-width: 100%;
+  width: 100%;
+  min-width: 0;
   margin: 0;
   margin: 0;
-  padding: 6px 8px 8px;
+  padding: 6px 8px 8px 8px;
   white-space: pre;
   white-space: pre;
 }
 }
 
 
 .expandedTopLevel {
 .expandedTopLevel {
   box-sizing: border-box;
   box-sizing: border-box;
-  width: max-content;
-  min-width: 100%;
+  width: 100%;
+  min-width: 0;
   padding: 6px 8px 8px 14px;
   padding: 6px 8px 8px 14px;
 }
 }
 
 
@@ -66,11 +66,13 @@
 .row {
 .row {
   position: relative;
   position: relative;
   box-sizing: border-box;
   box-sizing: border-box;
-  min-width: 100%;
+  min-width: 0;
   min-height: 16px;
   min-height: 16px;
   margin: 0;
   margin: 0;
   padding: 0 0 0 10px;
   padding: 0 0 0 10px;
   list-style: none;
   list-style: none;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
 }
 }
 
 
 .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
 .row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
@@ -81,13 +83,13 @@
   top: 0;
   top: 0;
   right: 0;
   right: 0;
   left: 0;
   left: 0;
-  height: 16px;
+  height: var(--json-tree-row-height, 16px);
   background: var(--json-tree-hover);
   background: var(--json-tree-hover);
   content: '';
   content: '';
   pointer-events: none;
   pointer-events: none;
 }
 }
 
 
-.row > span:not(.expander) {
+.row > span:not(.expander, .copySlot) {
   position: relative;
   position: relative;
   z-index: 1;
   z-index: 1;
 }
 }
@@ -106,6 +108,100 @@
   color: var(--json-tree-string);
   color: var(--json-tree-string);
 }
 }
 
 
+.stringField {
+  position: relative;
+  z-index: 1;
+  display: flow-root;
+}
+
+.stringText {
+  display: block;
+  max-height: calc(var(--json-tree-collapsed-lines) * 16px);
+  padding-left: 12px;
+  overflow: hidden;
+  text-indent: -12px;
+  word-break: break-all;
+}
+
+.stringRaw {
+  margin: 4px 0 0 12px;
+  overflow: auto;
+  color: var(--dsw-alias-label-primary);
+  font: inherit;
+  overflow-wrap: normal;
+  white-space: pre;
+  word-break: normal;
+}
+
+.stringActions {
+  position: absolute;
+  top: 1px;
+  right: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  width: fit-content;
+  height: 16px;
+}
+
+.stringRaw[data-wrap='true'] {
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.stringActions > .copySlot {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.stringToggleSlot {
+  display: flex;
+  float: right;
+  align-items: flex-end;
+  height: calc(var(--json-tree-collapsed-lines) * 16px);
+  margin-left: 2px;
+  shape-outside: inset(calc((var(--json-tree-collapsed-lines) - 1) * 16px) 0 0);
+  pointer-events: none;
+}
+
+.stringToggle {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 2px;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  font: inherit;
+  text-indent: 0;
+  white-space: nowrap;
+  cursor: pointer;
+  pointer-events: auto;
+}
+
+.stringToggle:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.stringToggle:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 1px;
+}
+
+.row:has(> .stringField) {
+  --json-tree-row-height: 100%;
+}
+
+.summary {
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .numberValue {
 .numberValue {
   color: var(--json-tree-number);
   color: var(--json-tree-number);
 }
 }
@@ -134,13 +230,38 @@
   color: var(--dsw-alias-label-tertiary);
   color: var(--dsw-alias-label-tertiary);
 }
 }
 
 
-.copyAnchor {
-  position: fixed;
-  z-index: 3;
+.copySlot {
   display: inline-flex;
   display: inline-flex;
+  width: 20px;
+  height: 16px;
+  opacity: 0;
+  pointer-events: none;
 }
 }
 
 
-.copyButton {
+.row > .copySlot {
+  position: absolute;
+  z-index: 2;
+  top: 0;
+  right: 0;
+}
+
+.row:has(> .stringField) > .copySlot {
+  top: auto;
+  bottom: 0;
+}
+
+.row:has(> .copySlot) > .stringField .stringToggleSlot {
+  padding-right: 24px;
+}
+
+.row:hover:not(:has(.row:hover)) > .copySlot,
+.row[data-json-copy-active] > .copySlot,
+.copySlot:focus-within {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.actionButton {
   box-sizing: border-box;
   box-sizing: border-box;
   display: inline-flex;
   display: inline-flex;
   align-items: center;
   align-items: center;
@@ -152,22 +273,24 @@
   border: 0;
   border: 0;
   border-radius: 3px;
   border-radius: 3px;
   color: var(--dsw-alias-label-secondary);
   color: var(--dsw-alias-label-secondary);
-  background: var(--dsw-alias-bg-layer-1);
-  box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
+  background: transparent;
   cursor: pointer;
   cursor: pointer;
 }
 }
 
 
-.copyButton:hover {
+.actionButton:hover {
   color: var(--dsw-alias-label-primary);
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-hover);
 }
 }
 
 
-.copyButton:focus-visible {
+.actionButton[aria-pressed='true'] {
+  color: var(--dsw-alias-state-business-primary);
+}
+
+.actionButton:focus-visible {
   outline: 1px solid var(--dsw-alias-state-business-primary);
   outline: 1px solid var(--dsw-alias-state-business-primary);
   outline-offset: -1px;
   outline-offset: -1px;
 }
 }
 
 
-.copyButton[data-state='failed'] {
+.actionButton[data-state='failed'] {
   color: var(--dsw-alias-state-error-primary);
   color: var(--dsw-alias-state-error-primary);
 }
 }
 
 

+ 349 - 141
packages/client/ui-primitives/src/JsonTree.tsx

@@ -1,12 +1,12 @@
 import clsx from 'clsx'
 import clsx from 'clsx'
-import { useEffect, useId, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
 import type {
 import type {
+  CSSProperties,
   KeyboardEvent as ReactKeyboardEvent,
   KeyboardEvent as ReactKeyboardEvent,
   MouseEvent as ReactMouseEvent,
   MouseEvent as ReactMouseEvent,
   ReactNode,
   ReactNode,
-  UIEvent as ReactUIEvent,
 } from 'react'
 } from 'react'
-import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
+import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx'
 import { Menu } from './Menu.tsx'
 import { Menu } from './Menu.tsx'
 import type { MenuEntry } from './Menu.tsx'
 import type { MenuEntry } from './Menu.tsx'
 import css from './JsonTree.module.css'
 import css from './JsonTree.module.css'
@@ -65,10 +65,103 @@ interface RowTarget {
   value: unknown
   value: unknown
 }
 }
 
 
-interface CopyTarget extends RowTarget {
-  left: number
-  side: 'bottom' | 'top'
-  top: number
+type CopyMode = 'json' | 'path' | 'prettyJson' | 'value'
+
+interface CopySnapshot {
+  id: string
+  target: RowTarget
+  state: 'idle' | 'copied' | 'failed'
+  menuOpen: boolean
+}
+
+/** Notify only the old and new row actions; JSON values do not subscribe to hover state. */
+function createCopyStore() {
+  let current: CopySnapshot | undefined
+  const listeners = new Map<string, Set<() => void>>()
+  return {
+    get: () => current,
+    set(next: CopySnapshot | undefined) {
+      const previous = current?.id
+      current = next
+      for (const id of new Set([previous, next?.id])) {
+        if (id === undefined) continue
+        for (const listener of listeners.get(id) ?? []) listener()
+      }
+    },
+    subscribe(id: string, listener: () => void) {
+      let row = listeners.get(id)
+      if (row === undefined) listeners.set(id, row = new Set())
+      row.add(listener)
+      return () => {
+        row.delete(listener)
+        if (row.size === 0) listeners.delete(id)
+      }
+    },
+  }
+}
+
+function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: {
+  store: ReturnType<typeof createCopyStore>
+  target: RowTarget
+  persistent: boolean
+  labels: JsonTreeLabels
+  onCopy: (target: RowTarget, mode: CopyMode) => Promise<void>
+  onClose: () => void
+}) {
+  const id = pathId(target.path)
+  const subscribe = useCallback((listener: () => void) => store.subscribe(id, listener), [id, store])
+  const getSnapshot = () => {
+    const current = store.get()
+    return current?.id === id ? current : undefined
+  }
+  const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+  const buttonRef = useRef<HTMLButtonElement>(null)
+  const state = snapshot?.state ?? 'idle'
+  const object = typeof target.value === 'object' && target.value !== null
+  const copyTitle = state === 'copied'
+    ? labels.copied
+    : state === 'failed'
+      ? labels.copyFailed
+      : object ? labels.copyPrettyJson : labels.copyValue
+  return (
+    <span className={css.copySlot}>
+      {(persistent || snapshot !== undefined) && (
+        <Menu
+          open={snapshot?.menuOpen === true}
+          compact
+          portal
+          align="end"
+          anchor={(
+            <button
+              ref={buttonRef}
+              type="button"
+              className={css.actionButton}
+              data-json-copy-button
+              data-state={state}
+              aria-label={copyTitle}
+              title={labels.copyButtonTitle(copyTitle)}
+              onClick={() => void onCopy(target, object ? 'prettyJson' : 'value')}
+              onContextMenu={(event) => {
+                event.preventDefault()
+                event.stopPropagation()
+                store.set({ id, target, state, menuOpen: true })
+              }}
+            >
+              {state === 'copied'
+                ? <IconCheckOutline16 size={12} />
+                : <IconCopyOutline16 size={12} />}
+            </button>
+          )}
+          items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
+          onSelect={(mode) => {
+            void onCopy(target, mode as CopyMode)
+          }}
+          onClose={onClose}
+          getAnchorRect={() => (buttonRef.current as HTMLButtonElement).getBoundingClientRect()}
+        />
+      )}
+    </span>
+  )
 }
 }
 
 
 function isExpandableValue(value: unknown): value is object | unknown[] {
 function isExpandableValue(value: unknown): value is object | unknown[] {
@@ -224,6 +317,8 @@ function NodeField({
 }
 }
 
 
 interface JsonTreeNodeProps {
 interface JsonTreeNodeProps {
+  collapsedStringLines: number
+  stringWrapping: JsonTreeProps['stringWrapping']
   field?: string
   field?: string
   initialExpanded: boolean
   initialExpanded: boolean
   labels: JsonTreeLabels
   labels: JsonTreeLabels
@@ -231,11 +326,180 @@ interface JsonTreeNodeProps {
   onClaimTabStop: (id: string) => void
   onClaimTabStop: (id: string) => void
   onRowHover: (row: HTMLElement, target: RowTarget) => void
   onRowHover: (row: HTMLElement, target: RowTarget) => void
   path: JsonPath
   path: JsonPath
+  renderCopy: ((target: RowTarget, persistent?: boolean) => ReactNode) | undefined
   tabStopId: string | null
   tabStopId: string | null
   value: unknown
   value: unknown
 }
 }
 
 
+function JsonString({
+  collapsedStringLines,
+  stringWrapping,
+  field,
+  labels,
+  lastElement,
+  renderCopy,
+  value,
+}: {
+  collapsedStringLines: number
+  stringWrapping: JsonTreeProps['stringWrapping']
+  field: string | undefined
+  labels: JsonTreeLabels
+  lastElement: boolean
+  renderCopy: ((persistent?: boolean) => ReactNode) | undefined
+  value: string
+}) {
+  const contentsId = useId()
+  const contentRef = useRef<HTMLSpanElement>(null)
+  const rawRef = useRef<HTMLPreElement>(null)
+  const [expanded, setExpanded] = useState(false)
+  const [wrapped, setWrapped] = useState(false)
+  const [truncated, setTruncated] = useState(false)
+
+  useLayoutEffect(() => {
+    if (expanded) return
+    const content = contentRef.current as HTMLSpanElement
+    const measure = () => {
+      const lineHeight = Number.parseFloat(getComputedStyle(content).lineHeight)
+      setTruncated(content.scrollHeight > lineHeight * collapsedStringLines)
+    }
+    measure()
+    if (typeof ResizeObserver === 'undefined') return
+    const observer = new ResizeObserver(measure)
+    observer.observe(content)
+    return () => { observer.disconnect() }
+  }, [collapsedStringLines, expanded, field, lastElement, value])
+
+  useLayoutEffect(() => {
+    if (!expanded) return
+    const raw = rawRef.current as HTMLPreElement
+    // Keep raw text within the window and clipping ancestors outside the tree.
+    // Capture scrolling because an ancestor can move the string without resizing it.
+    const clips: HTMLElement[] = []
+    const tree = raw.closest<HTMLElement>(`.${css.root}`) as HTMLElement
+    for (let parent = tree.parentElement; parent !== null; parent = parent.parentElement) {
+      if (/auto|scroll|hidden|clip/.test(getComputedStyle(parent).overflowY)) clips.push(parent)
+    }
+    const measure = () => {
+      let top = 0
+      let bottom = window.innerHeight
+      for (const clip of clips) {
+        const rect = clip.getBoundingClientRect()
+        const style = getComputedStyle(clip)
+        top = Math.max(top, rect.top + clip.clientTop)
+        bottom = Math.min(bottom, rect.top + clip.clientTop + clip.clientHeight
+          - Number.parseFloat(style.paddingBottom))
+      }
+      const available = bottom - Math.max(top, raw.getBoundingClientRect().top)
+      raw.style.maxHeight = `${Math.max(16, available - 4)}px`
+    }
+    measure()
+    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
+    observer?.observe(raw)
+    for (const clip of clips) observer?.observe(clip)
+    window.addEventListener('resize', measure)
+    window.addEventListener('scroll', measure, true)
+    return () => {
+      observer?.disconnect()
+      window.removeEventListener('resize', measure)
+      window.removeEventListener('scroll', measure, true)
+    }
+  }, [expanded, value])
+
+  if (expanded) {
+    const fieldId = `${contentsId}-field`
+    return (
+      <div className={css.stringField} data-expanded>
+        {field !== undefined && <span id={fieldId} className={css.label}>{fieldText(field)}:</span>}
+        <pre
+          ref={rawRef}
+          id={contentsId}
+          className={css.stringRaw}
+          data-wrap={wrapped}
+          tabIndex={0}
+          aria-labelledby={field === undefined ? undefined : fieldId}
+        >
+          {value}
+        </pre>
+        {!lastElement && <span className={css.punctuation}>,</span>}
+        <div className={css.stringActions}>
+          {stringWrapping !== undefined && (
+            <button
+              type="button"
+              className={css.actionButton}
+              aria-label={stringWrapping.label}
+              title={stringWrapping.label}
+              aria-pressed={wrapped}
+              aria-controls={contentsId}
+              onClick={() => {
+                const next = !wrapped
+                setWrapped(next)
+                stringWrapping.setDefault(next)
+              }}
+            >
+              <IconWrapLinesOutline16 size={12} />
+            </button>
+          )}
+          <button
+            type="button"
+            className={css.actionButton}
+            aria-label={labels.collapseNode}
+            title={labels.collapseNode}
+            aria-expanded
+            aria-controls={contentsId}
+            onClick={() => { setExpanded(false) }}
+          >
+            <svg
+              width="12"
+              height="12"
+              viewBox="0 0 16 16"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="1.5"
+              aria-hidden="true"
+            >
+              <path d="M9.5 1.5v5h5M1.5 9.5h5v5" />
+            </svg>
+          </button>
+          {renderCopy?.(true)}
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <>
+      {renderCopy?.()}
+      <span className={css.stringField} data-expanded={expanded}>
+        <span ref={contentRef} id={contentsId} className={css.stringText}>
+          {truncated && (
+            <span className={css.stringToggleSlot}>
+              <button
+                type="button"
+                className={css.stringToggle}
+                aria-label={labels.expandNode}
+                aria-expanded={false}
+                aria-controls={contentsId}
+                onClick={() => {
+                  setWrapped(stringWrapping?.getDefault() ?? false)
+                  setExpanded(true)
+                }}
+              >
+                <span aria-hidden="true">…</span>{labels.expandNode}
+              </button>
+            </span>
+          )}
+          {field !== undefined && <span className={css.label}>{fieldText(field)}:</span>}
+          {primitiveValue(value)}
+          {!lastElement && <span className={css.punctuation}>,</span>}
+        </span>
+      </span>
+    </>
+  )
+}
+
 function JsonTreeNode({
 function JsonTreeNode({
+  collapsedStringLines,
+  stringWrapping,
   field,
   field,
   initialExpanded,
   initialExpanded,
   labels,
   labels,
@@ -243,6 +507,7 @@ function JsonTreeNode({
   onClaimTabStop,
   onClaimTabStop,
   onRowHover,
   onRowHover,
   path,
   path,
+  renderCopy,
   tabStopId,
   tabStopId,
   value,
   value,
 }: JsonTreeNodeProps) {
 }: JsonTreeNodeProps) {
@@ -281,10 +546,25 @@ function JsonTreeNode({
         onRowHover(event.currentTarget, { path, value })
         onRowHover(event.currentTarget, { path, value })
       }}
       }}
     >
     >
+      {typeof value !== 'string' && renderCopy?.({ path, value })}
       {children}
       {children}
     </div>
     </div>
   )
   )
 
 
+  if (typeof value === 'string') {
+    return row(
+      <JsonString
+        collapsedStringLines={collapsedStringLines}
+        stringWrapping={stringWrapping}
+        field={field}
+        value={value}
+        labels={labels}
+        lastElement={lastElement}
+        renderCopy={renderCopy === undefined ? undefined : persistent => renderCopy({ path, value }, persistent)}
+      />,
+    )
+  }
+
   if (!container) {
   if (!container) {
     return row((
     return row((
       <>
       <>
@@ -322,14 +602,18 @@ function JsonTreeNode({
         onClick={toggle}
         onClick={toggle}
         onKeyDown={onExpanderKeyDown}
         onKeyDown={onExpanderKeyDown}
       />
       />
-      <NodeField field={field} expandable onToggle={toggle} />
-      <span className={css.preview}>{previewValue(value, 0)}</span>
-      {!lastElement && <span className={css.punctuation}>,</span>}
+      <span className={css.summary}>
+        <NodeField field={field} expandable onToggle={toggle} />
+        <span className={css.preview}>{previewValue(value, 0)}</span>
+        {!lastElement && <span className={css.punctuation}>,</span>}
+      </span>
       {expanded && (
       {expanded && (
         <ul id={contentsId} role="group" className={css.children}>
         <ul id={contentsId} role="group" className={css.children}>
           {entries.map(([key, item], index) => (
           {entries.map(([key, item], index) => (
             <JsonTreeNode
             <JsonTreeNode
               key={key}
               key={key}
+              collapsedStringLines={collapsedStringLines}
+              stringWrapping={stringWrapping}
               field={key}
               field={key}
               value={item}
               value={item}
               path={[...path, Array.isArray(value) ? index : key]}
               path={[...path, Array.isArray(value) ? index : key]}
@@ -339,6 +623,7 @@ function JsonTreeNode({
               tabStopId={tabStopId}
               tabStopId={tabStopId}
               onClaimTabStop={onClaimTabStop}
               onClaimTabStop={onClaimTabStop}
               onRowHover={onRowHover}
               onRowHover={onRowHover}
+              renderCopy={renderCopy}
             />
             />
           ))}
           ))}
         </ul>
         </ul>
@@ -356,7 +641,7 @@ function formattedPath(path: JsonPath): string {
   }, '$')
   }, '$')
 }
 }
 
 
-function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
+function copyText(target: RowTarget, mode: CopyMode): string {
   if (mode === 'path') return formattedPath(target.path)
   if (mode === 'path') return formattedPath(target.path)
   if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
   if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
   if (mode === 'json') return JSON.stringify(target.value)
   if (mode === 'json') return JSON.stringify(target.value)
@@ -376,6 +661,17 @@ export interface JsonTreeProps {
   label: string
   label: string
   /** Optional positioning class owned by the caller. */
   /** Optional positioning class owned by the caller. */
   className?: string | undefined
   className?: string | undefined
+  /** Maximum visible lines per collapsed string; defaults to 3. */
+  collapsedStringLines?: number
+  /** Optional wrap toggle; each expansion reads the shared default without changing other open strings. */
+  stringWrapping?: {
+    /** Localized label for the wrapping toggle. */
+    label: string
+    /** Read the wrapping preference when a string is expanded. @returns Whether to wrap long lines. */
+    getDefault: () => boolean
+    /** Remember a user toggle for future expansions. @param wrapped - Whether to wrap long lines. */
+    setDefault: (wrapped: boolean) => void
+  } | undefined
   /** Whether JSON rows expose copy actions. */
   /** Whether JSON rows expose copy actions. */
   copyable?: boolean
   copyable?: boolean
   /** Whether the top-level object or array is always expanded. */
   /** Whether the top-level object or array is always expanded. */
@@ -393,6 +689,8 @@ export function JsonTree({
   data,
   data,
   label,
   label,
   className,
   className,
+  collapsedStringLines = 3,
+  stringWrapping,
   copyable = true,
   copyable = true,
   expandTopLevel = true,
   expandTopLevel = true,
   labels,
   labels,
@@ -407,14 +705,10 @@ export function JsonTree({
       ? null
       ? null
       : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
       : pathId([Array.isArray(data) ? firstExpandableIndex : firstExpandableEntry[0]])
     : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
     : isExpandableValue(data) && rootEntries.length > 0 ? pathId([]) : null
-  const rootRef = useRef<HTMLDivElement>(null)
   const activeRowRef = useRef<HTMLElement>()
   const activeRowRef = useRef<HTMLElement>()
-  const copyButtonRef = useRef<HTMLButtonElement>(null)
-  const copyMenuOpenRef = useRef(false)
   const resetTimer = useRef<ReturnType<typeof setTimeout>>()
   const resetTimer = useRef<ReturnType<typeof setTimeout>>()
-  const [copyTarget, setCopyTarget] = useState<CopyTarget>()
-  const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
-  const [copyMenuOpen, setCopyMenuOpen] = useState(false)
+  const copySequence = useRef(0)
+  const [copyStore] = useState(createCopyStore)
   const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
   const [tabStopId, setTabStopId] = useState<string | null>(initialTabStopId)
 
 
   const setActiveRow = (row: HTMLElement | undefined) => {
   const setActiveRow = (row: HTMLElement | undefined) => {
@@ -424,121 +718,74 @@ export function JsonTree({
   }
   }
 
 
   const clearCopyTarget = () => {
   const clearCopyTarget = () => {
+    copySequence.current += 1
+    if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     setActiveRow(undefined)
     setActiveRow(undefined)
-    setCopyTarget(undefined)
-    setCopyState('idle')
-    copyMenuOpenRef.current = false
-    setCopyMenuOpen(false)
-  }
-
-  const copyPosition = (row: HTMLElement): Pick<CopyTarget, 'left' | 'side' | 'top'> => {
-    const root = rootRef.current
-    /* v8 ignore next -- row events and viewport listeners run only after the root ref mounts. */
-    if (root === null) throw new Error('JsonTree root is not mounted')
-    const rootRect = root.getBoundingClientRect()
-    const rowRect = row.getBoundingClientRect()
-    return {
-      left: rootRect.left + root.clientWidth - 26,
-      side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
-      top: rowRect.top,
-    }
-  }
-
-  const positionCopyButton = (row: HTMLElement, target: RowTarget) => {
-    const position = copyPosition(row)
-    setCopyTarget({ ...target, ...position })
-  }
-
-  const repositionCopyButton = (row: HTMLElement) => {
-    const position = copyPosition(row)
-    setCopyTarget((current) => {
-      /* v8 ignore next -- an active row and its copy target are installed together. */
-      if (current === undefined) return current
-      return { ...current, ...position }
-    })
+    copyStore.set(undefined)
   }
   }
 
 
   useEffect(() => () => {
   useEffect(() => () => {
+    copySequence.current += 1
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     activeRowRef.current?.removeAttribute('data-json-copy-active')
     activeRowRef.current?.removeAttribute('data-json-copy-active')
   }, [])
   }, [])
 
 
   useEffect(() => {
   useEffect(() => {
-    activeRowRef.current?.removeAttribute('data-json-copy-active')
-    activeRowRef.current = undefined
-    copyMenuOpenRef.current = false
-    setCopyTarget(undefined)
-    setCopyState('idle')
-    setCopyMenuOpen(false)
+    clearCopyTarget()
     setTabStopId(initialTabStopId)
     setTabStopId(initialTabStopId)
   }, [data, expandTopLevel, initialTabStopId])
   }, [data, expandTopLevel, initialTabStopId])
 
 
-  useEffect(() => {
-    const reposition = () => {
-      const row = activeRowRef.current
-      if (row !== undefined) repositionCopyButton(row)
-    }
-    window.addEventListener('scroll', reposition, true)
-    window.addEventListener('resize', reposition)
-    return () => {
-      window.removeEventListener('scroll', reposition, true)
-      window.removeEventListener('resize', reposition)
-    }
-  }, [])
-
   const handleRowHover = (row: HTMLElement, target: RowTarget) => {
   const handleRowHover = (row: HTMLElement, target: RowTarget) => {
-    if (!copyable || copyMenuOpenRef.current) return
+    if (!copyable || copyStore.get()?.menuOpen) return
     if (activeRowRef.current === row) return
     if (activeRowRef.current === row) return
     setActiveRow(row)
     setActiveRow(row)
-    setCopyState('idle')
-    copyMenuOpenRef.current = false
-    setCopyMenuOpen(false)
-    positionCopyButton(row, target)
+    copyStore.set({ id: pathId(target.path), target, state: 'idle', menuOpen: false })
   }
   }
 
 
   const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
   const handleRootMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
-    if (!copyable || copyMenuOpenRef.current) return
+    if (!copyable || copyStore.get()?.menuOpen) return
     /* v8 ignore next -- browser mouse events delivered through React target an Element. */
     /* v8 ignore next -- browser mouse events delivered through React target an Element. */
     if (!(event.target instanceof Element)) return
     if (!(event.target instanceof Element)) return
     if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
     if (event.target.closest('[data-json-copy-button]') === null) clearCopyTarget()
   }
   }
 
 
-  const handleScroll = (_event: ReactUIEvent<HTMLDivElement>) => {
-    const row = activeRowRef.current
-    if (row !== undefined) repositionCopyButton(row)
-  }
-
-  const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
-    /* v8 ignore next -- copy controls only render while their target exists. */
-    if (copyTarget === undefined) return
+  const copy = async (target: RowTarget, mode: CopyMode) => {
+    const sequence = ++copySequence.current
+    const snapshot: CopySnapshot = {
+      id: pathId(target.path), target, state: 'idle', menuOpen: false,
+    }
+    copyStore.set(snapshot)
+    let state: CopySnapshot['state']
     try {
     try {
-      await navigator.clipboard.writeText(copyText(copyTarget, mode))
-      setCopyState('copied')
+      await navigator.clipboard.writeText(copyText(target, mode))
+      state = 'copied'
     } catch {
     } catch {
-      setCopyState('failed')
+      state = 'failed'
     }
     }
+    const current = copyStore.get()
+    if (sequence !== copySequence.current || current?.target !== target) return
+    copyStore.set({ ...current, state })
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
     if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
-    resetTimer.current = setTimeout(() => { setCopyState('idle') }, 1_500)
+    resetTimer.current = setTimeout(() => {
+      const current = copyStore.get()
+      if (current?.target === target) copyStore.set({ ...current, state: 'idle' })
+    }, 1_500)
   }
   }
 
 
   const [rootOpen, rootClose] = bracketOf(data)
   const [rootOpen, rootClose] = bracketOf(data)
-  const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
-  const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
-  const copyTitle = copyState === 'copied'
-    ? labels.copied
-    : copyState === 'failed'
-      ? labels.copyFailed
-      : copyTargetIsObject ? labels.copyPrettyJson : labels.copyValue
+  const renderCopy = copyable ? (target: RowTarget, persistent = false) => (
+    <JsonCopyAction store={copyStore} target={target} persistent={persistent} labels={labels}
+      onCopy={copy} onClose={clearCopyTarget} />
+  ) : undefined
 
 
   return (
   return (
     <div
     <div
-      ref={rootRef}
       className={clsx(css.root, className)}
       className={clsx(css.root, className)}
+      style={{ '--json-tree-collapsed-lines': collapsedStringLines } as CSSProperties}
       onMouseOver={handleRootMouseOver}
       onMouseOver={handleRootMouseOver}
       onMouseLeave={() => {
       onMouseLeave={() => {
-        if (!copyMenuOpenRef.current) clearCopyTarget()
+        if (!copyStore.get()?.menuOpen) clearCopyTarget()
       }}
       }}
-      onScroll={handleScroll}
     >
     >
       {expandTopLevel
       {expandTopLevel
         ? (
         ? (
@@ -551,6 +798,7 @@ export function JsonTree({
                 handleRowHover(event.currentTarget, { path: [], value: data })
                 handleRowHover(event.currentTarget, { path: [], value: data })
               }}
               }}
             >
             >
+              {renderCopy?.({ path: [], value: data })}
               <span className={css.punctuation}>{rootOpen}</span>
               <span className={css.punctuation}>{rootOpen}</span>
             </div>
             </div>
             <div
             <div
@@ -561,6 +809,8 @@ export function JsonTree({
               {rootEntries.map(([key, value], index) => (
               {rootEntries.map(([key, value], index) => (
                 <JsonTreeNode
                 <JsonTreeNode
                   key={key}
                   key={key}
+                  collapsedStringLines={collapsedStringLines}
+                  stringWrapping={stringWrapping}
                   field={key}
                   field={key}
                   value={value}
                   value={value}
                   path={[Array.isArray(data) ? index : key]}
                   path={[Array.isArray(data) ? index : key]}
@@ -570,6 +820,7 @@ export function JsonTree({
                   tabStopId={tabStopId}
                   tabStopId={tabStopId}
                   onClaimTabStop={setTabStopId}
                   onClaimTabStop={setTabStopId}
                   onRowHover={handleRowHover}
                   onRowHover={handleRowHover}
+                  renderCopy={renderCopy}
                 />
                 />
               ))}
               ))}
             </div>
             </div>
@@ -581,6 +832,8 @@ export function JsonTree({
         : (
         : (
           <div aria-label={label} className={css.container} role="tree">
           <div aria-label={label} className={css.container} role="tree">
             <JsonTreeNode
             <JsonTreeNode
+              collapsedStringLines={collapsedStringLines}
+              stringWrapping={stringWrapping}
               value={data}
               value={data}
               path={[]}
               path={[]}
               labels={labels}
               labels={labels}
@@ -589,55 +842,10 @@ export function JsonTree({
               tabStopId={tabStopId}
               tabStopId={tabStopId}
               onClaimTabStop={setTabStopId}
               onClaimTabStop={setTabStopId}
               onRowHover={handleRowHover}
               onRowHover={handleRowHover}
+              renderCopy={renderCopy}
             />
             />
           </div>
           </div>
         )}
         )}
-      {copyTarget !== undefined && (
-        <span
-          className={css.copyAnchor}
-          style={{ left: copyTarget.left, top: copyTarget.top }}
-        >
-          <Menu
-            open={copyMenuOpen}
-            compact
-            portal
-            align="end"
-            side={copyTarget.side}
-            anchor={(
-              <button
-                ref={copyButtonRef}
-                type="button"
-                className={css.copyButton}
-                data-json-copy-button
-                data-state={copyState}
-                aria-label={copyTitle}
-                title={labels.copyButtonTitle(copyTitle)}
-                onClick={() => void copy(defaultCopyMode)}
-                onContextMenu={(event) => {
-                  event.preventDefault()
-                  event.stopPropagation()
-                  copyMenuOpenRef.current = true
-                  setCopyMenuOpen(true)
-                }}
-              >
-                {copyState === 'copied'
-                  ? <IconCheckOutline16 size={12} />
-                  : <IconCopyOutline16 size={12} />}
-              </button>
-            )}
-            items={copyTargetIsObject ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)}
-            onSelect={(id) => {
-              void copy(id as 'json' | 'path' | 'prettyJson' | 'value')
-              copyMenuOpenRef.current = false
-              setCopyMenuOpen(false)
-            }}
-            onClose={clearCopyTarget}
-            getAnchorRect={() => (
-              copyButtonRef.current as HTMLButtonElement
-            ).getBoundingClientRect()}
-          />
-        </span>
-      )}
     </div>
     </div>
   )
   )
 }
 }

+ 8 - 0
packages/client/ui-primitives/src/icons/index.tsx

@@ -938,6 +938,14 @@ export const IconArchiveOutline20 = ({ size = 20, className }: IconProps) => (
   </svg>
   </svg>
 )
 )
 
 
+/** Line-wrapping toggle glyph. */
+export const IconWrapLinesOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
+    stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden="true">
+    <path d="M1.5 3.5h13M1.5 7.5h10.25a2.5 2.5 0 0 1 0 5H8m2-2-2 2 2 2M1.5 11.5h3" />
+  </svg>
+)
+
 /**
 /**
  * Plan row glyph of the composer menu (#3567): a document with three ruled
  * Plan row glyph of the composer menu (#3567): a document with three ruled
  * lines and a pencil over its corner. Drawn on a 14 grid whose content spans
  * lines and a pencil over its corner. Drawn on a 14 grid whose content spans

+ 7 - 3
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -29,6 +29,8 @@ export interface CodeBlockProps {
   contentRef?: Ref<HTMLDivElement> | undefined
   contentRef?: Ref<HTMLDivElement> | undefined
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   /** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
   lineNumbers?: boolean | undefined
   lineNumbers?: boolean | undefined
+  /** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */
+  showHeader?: boolean | undefined
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
   /** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
   copyLabel: string
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
   /** Copy-button label during the post-copy confirmation window. */
@@ -61,7 +63,9 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
   )
 }
 }
 
 
-export function CodeBlock({ code, lang, streaming, className, contentRef, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({
+  code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
+}: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
   const rootRef = useRef<HTMLDivElement>(null)
@@ -180,7 +184,7 @@ export function CodeBlock({ code, lang, streaming, className, contentRef, lineNu
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
         '--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
       } as CSSProperties}>
       } as CSSProperties}>
       {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
       {/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
-      <div className={css.bannerWrap}>
+      {showHeader && <div className={css.bannerWrap}>
         <div className={css.banner} data-code-block-banner>
         <div className={css.banner} data-code-block-banner>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
           <div className={css.action}>
@@ -189,7 +193,7 @@ export function CodeBlock({ code, lang, streaming, className, contentRef, lineNu
             </button>
             </button>
           </div>
           </div>
         </div>
         </div>
-      </div>
+      </div>}
       <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
       <div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
     </div>
     </div>
   )
   )

+ 5 - 4
packages/client/ui-primitives/src/markdown/MarkdownText.module.css

@@ -198,10 +198,11 @@
    scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules
    scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules
    nor a :hover `scrollbar-color` change reaches the painted bar), so the
    nor a :hover `scrollbar-color` change reaches the painted bar), so the
    toggle is `overflow-x` itself — a layout change repaints reliably. The
    toggle is `overflow-x` itself — a layout change repaints reliably. The
-   resting padding matches the themed bar's height, so on an overflowing
-   table the appearing bar exactly replaces it and nothing below shifts.
+   resting padding matches the themed bar's height on the WebKit scrollbar
+   path. There, `scroll` reserves that height even when the table fits.
+   Other engines and overlay scrollbars may reserve a different height.
    Wheel and trackpad scrolling need the pointer over the table, which is
    Wheel and trackpad scrolling need the pointer over the table, which is
-   already the hover that re-enables `auto`. */
+   already the hover that re-enables scrolling. */
 .tableScroll:global(.md-table-wide) {
 .tableScroll:global(.md-table-wide) {
   overflow-x: hidden;
   overflow-x: hidden;
   padding-bottom: var(--dsh-scrollbar-width, 8px);
   padding-bottom: var(--dsh-scrollbar-width, 8px);
@@ -209,7 +210,7 @@
 
 
 .tableScroll:global(.md-table-wide):hover,
 .tableScroll:global(.md-table-wide):hover,
 .tableScroll:global(.md-table-wide):focus-visible {
 .tableScroll:global(.md-table-wide):focus-visible {
-  overflow-x: auto;
+  overflow-x: scroll;
   padding-bottom: 0;
   padding-bottom: 0;
 }
 }
 
 

+ 2 - 2
packages/client/ui-primitives/tests/icons.client.spec.tsx

@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
 const iconNames = Object.keys(icons)
 const iconNames = Object.keys(icons)
 
 
 describe('ic_ds_ icon set', () => {
 describe('ic_ds_ icon set', () => {
-  it('exports the full icon set (46 deepsuite + 21 figma extracts + eleven product glyphs outside those sets)', () => {
-    expect(iconNames.length).toBe(78)
+  it('exports the full icon set (46 deepsuite + 21 figma extracts + twelve product glyphs outside those sets)', () => {
+    expect(iconNames.length).toBe(79)
     // The composer menu's own glyphs, pinned by name.
     // The composer menu's own glyphs, pinned by name.
     expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16']))
     expect(iconNames).toEqual(expect.arrayContaining(['IconPlanOutline14', 'IconCompactOutline16', 'IconShieldOutline16']))
   })
   })

+ 159 - 31
packages/client/ui-primitives/tests/json-tree.client.spec.tsx

@@ -12,10 +12,12 @@ function JsonTree(props: Omit<ComponentProps<typeof LocalizedJsonTree>, 'label'
   return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
   return <LocalizedJsonTree label="JSON" {...props} labels={jsonTreeLabels} />
 }
 }
 
 
-let writeText: ReturnType<typeof vi.fn>
+let writeText: ReturnType<typeof vi.fn<Clipboard['writeText']>>
+let originalClipboard: PropertyDescriptor | undefined
 
 
 beforeEach(() => {
 beforeEach(() => {
-  writeText = vi.fn().mockResolvedValue(undefined)
+  originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
+  writeText = vi.fn<Clipboard['writeText']>().mockResolvedValue(undefined)
   Object.defineProperty(navigator, 'clipboard', {
   Object.defineProperty(navigator, 'clipboard', {
     configurable: true,
     configurable: true,
     value: { writeText },
     value: { writeText },
@@ -25,9 +27,162 @@ beforeEach(() => {
 afterEach(() => {
 afterEach(() => {
   cleanup()
   cleanup()
   vi.useRealTimers()
   vi.useRealTimers()
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+  if (originalClipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
+  else Object.defineProperty(navigator, 'clipboard', originalClipboard)
 })
 })
 
 
+function stubStringLayout(scrollHeight = 200): void {
+  vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(scrollHeight)
+  const computedStyle = window.getComputedStyle.bind(window)
+  vi.spyOn(window, 'getComputedStyle').mockImplementation((element) => {
+    const style = computedStyle(element)
+    style.lineHeight = '16px'
+    if (style.paddingBottom === '') style.paddingBottom = '0px'
+    return style
+  })
+}
+
 describe('JsonTree', () => {
 describe('JsonTree', () => {
+  it('ignores clipboard settlement after the row changes or the tree unmounts', async () => {
+    vi.useFakeTimers()
+    const pending: (() => void)[] = []
+    writeText.mockImplementation(() => new Promise<void>((resolve) => { pending.push(resolve) }))
+    const view = render(<JsonTree data={{ first: 1, second: 2 }} />)
+    const rows = screen.getAllByRole('treeitem')
+    fireEvent.mouseOver(rows[0] as HTMLElement)
+    fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
+    fireEvent.mouseOver(rows[1] as HTMLElement)
+    await act(async () => { pending[0]!() })
+    expect(screen.queryByRole('button', { name: 'Copied' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: 'Copy value' }))
+    view.unmount()
+    const timerCount = vi.getTimerCount()
+    await act(async () => { pending[1]!() })
+    expect(vi.getTimerCount()).toBe(timerCount)
+  })
+
+  it('updates copy actions without rereading JSON properties on hover', () => {
+    const readValue = vi.fn(() => 'payload '.repeat(100))
+    const data = Object.fromEntries(Array.from({ length: 500 }, (_, index) => [
+      `field${index}`,
+      { get value() { return readValue() } },
+    ]))
+    render(<JsonTree data={data} />)
+    const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    readValue.mockClear()
+
+    fireEvent.mouseOver(rows[0] as HTMLElement)
+    expect(within(rows[0] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
+    fireEvent.mouseOver(rows[1] as HTMLElement)
+    expect(within(rows[0] as HTMLElement).queryByRole('button', { name: 'Copy pretty JSON' })).toBeNull()
+    expect(within(rows[1] as HTMLElement).getByRole('button', { name: 'Copy pretty JSON' })).toBeTruthy()
+    expect(readValue).not.toHaveBeenCalled()
+  })
+
+  it('expands raw strings without ResizeObserver and keeps the visible viewport limit', () => {
+    stubStringLayout()
+    vi.stubGlobal('ResizeObserver', undefined)
+    let rawTop = 150
+    const view = render(
+      <div style={{ overflowY: 'auto', paddingBottom: '10px' }}>
+        <JsonTree data={{ first: 'raw\ntext', last: 'last\ntext' }} />
+      </div>,
+    )
+    const clip = view.container.firstElementChild as HTMLElement
+    vi.spyOn(clip, 'clientTop', 'get').mockReturnValue(2)
+    vi.spyOn(clip, 'clientHeight', 'get').mockReturnValue(140)
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      return new DOMRect(0, this === clip ? 100 : rawTop, 200, 140)
+    })
+    const rows = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
+    const raw = rows[0]?.querySelector('pre') as HTMLPreElement
+    expect(raw.textContent).toBe('raw\ntext')
+    expect(raw.style.maxHeight).toBe('78px')
+    expect(raw.nextElementSibling?.textContent).toBe(',')
+
+    rawTop = 80
+    fireEvent.scroll(clip)
+    expect(raw.style.maxHeight).toBe('126px')
+    rawTop = 300
+    fireEvent.resize(window)
+    expect(raw.style.maxHeight).toBe('16px')
+    fireEvent.click(within(rows[0] as HTMLElement).getByRole('button', { name: 'Collapse JSON node' }))
+    expect(raw.isConnected).toBe(false)
+    rawTop = 100
+    fireEvent.scroll(clip)
+    expect(raw.style.maxHeight).toBe('16px')
+
+    fireEvent.click(within(rows[1] as HTMLElement).getByRole('button', { name: 'Expand JSON node' }))
+    expect(rows[1]?.querySelector('pre')?.nextElementSibling?.textContent).not.toBe(',')
+  })
+
+  it('shows the string expander only beyond the configured collapsed line count', () => {
+    stubStringLayout(48)
+    let resize: (() => void) | undefined
+    const disconnect = vi.fn()
+    vi.stubGlobal('ResizeObserver', class {
+      constructor(callback: () => void) { resize = callback }
+      observe() {}
+      disconnect = disconnect
+    })
+    const data = { text: 'three lines of text' }
+    const view = render(<JsonTree data={data} />)
+    expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
+    view.rerender(<JsonTree data={data} collapsedStringLines={2} />)
+    expect(screen.getByRole('button', { name: 'Expand JSON node' })).toBeTruthy()
+    vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockReturnValue(16)
+    act(() => { resize?.() })
+    expect(screen.queryByRole('button', { name: 'Expand JSON node' })).toBeNull()
+    view.unmount()
+    expect(disconnect).toHaveBeenCalledTimes(2)
+  })
+
+  it('keeps raw strings intact and samples the wrapping preference on every expansion', async () => {
+    stubStringLayout()
+    vi.stubGlobal('ResizeObserver', class {
+      observe() {}
+      disconnect() {}
+    })
+    let wrapped = false
+    const stringWrapping = {
+      label: 'Wrap lines',
+      getDefault: () => wrapped,
+      setDefault: (value: boolean) => { wrapped = value },
+    }
+    const original = `  leading spaces\n\t"quoted" \\${'long'.repeat(100)}\nlast line\n`
+    render(<JsonTree data={{ first: original, second: original }} stringWrapping={stringWrapping} />)
+    const [first, second] = within(screen.getByRole('tree')).getAllByRole('treeitem')
+    const a = within(first as HTMLElement)
+    const b = within(second as HTMLElement)
+    fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' }))
+    fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' }))
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+
+    fireEvent.click(a.getByRole('button', { name: 'Wrap lines' }))
+    expect(wrapped).toBe(true)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('false')
+
+    fireEvent.click(b.getByRole('button', { name: 'Collapse JSON node' }))
+    fireEvent.click(b.getByRole('button', { name: 'Expand JSON node' }))
+    expect(b.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(b.getByRole('button', { name: 'Wrap lines' }))
+    expect(wrapped).toBe(false)
+    expect(a.getByRole('button', { name: 'Wrap lines' }).getAttribute('aria-pressed')).toBe('true')
+
+    fireEvent.click(a.getByRole('button', { name: 'Collapse JSON node' }))
+    fireEvent.click(a.getByRole('button', { name: 'Expand JSON node' }))
+    const toggle = a.getByRole('button', { name: 'Wrap lines' })
+    expect(toggle.getAttribute('aria-pressed')).toBe('false')
+    const contents = document.getElementById(toggle.getAttribute('aria-controls') as string)
+    expect(contents?.textContent).toBe(original)
+    fireEvent.click(a.getByRole('button', { name: 'Copy value' }))
+    await waitFor(() => { expect(writeText).toHaveBeenCalledWith(original) })
+  })
+
   it('keeps the top level open and renders expandable value previews', () => {
   it('keeps the top level open and renders expandable value previews', () => {
     render(
     render(
       <JsonTree
       <JsonTree
@@ -270,48 +425,21 @@ describe('JsonTree', () => {
     view.unmount()
     view.unmount()
   })
   })
 
 
-  it('keeps copy placement synchronized and clears stale targets', () => {
+  it('keeps the copy action on its hovered row and clears stale targets', () => {
     const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
     const view = render(<JsonTree data={{ first: { a: 1 }, second: 2 }} />)
     const root = view.container.firstElementChild as HTMLElement
     const root = view.container.firstElementChild as HTMLElement
     const tree = screen.getByRole('tree')
     const tree = screen.getByRole('tree')
     const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
     const firstRow = within(tree).getAllByRole('treeitem')[0] as HTMLElement
     const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
     const secondRow = within(tree).getAllByRole('treeitem')[1] as HTMLElement
 
 
-    Object.defineProperty(root, 'clientHeight', { configurable: true, value: 100 })
-    Object.defineProperty(root, 'clientWidth', { configurable: true, value: 300 })
-    vi.spyOn(root, 'getBoundingClientRect').mockReturnValue({
-      bottom: 100,
-      height: 100,
-      left: 10,
-      right: 310,
-      top: 0,
-      width: 300,
-      x: 10,
-      y: 0,
-      toJSON: () => ({}),
-    })
-    vi.spyOn(firstRow, 'getBoundingClientRect').mockReturnValue({
-      bottom: 91,
-      height: 16,
-      left: 10,
-      right: 200,
-      top: 75,
-      width: 190,
-      x: 10,
-      y: 75,
-      toJSON: () => ({}),
-    })
-
     fireEvent.mouseOver(firstRow)
     fireEvent.mouseOver(firstRow)
     const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
     const copyButton = screen.getByRole('button', { name: 'Copy pretty JSON' })
-    expect((copyButton.closest('span')?.parentElement as HTMLElement).style.left).toBe('284px')
+    expect(firstRow.contains(copyButton)).toBe(true)
     fireEvent.mouseOver(copyButton)
     fireEvent.mouseOver(copyButton)
     expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
     expect(screen.getByRole('button', { name: 'Copy pretty JSON' })).toBeDefined()
     fireEvent.mouseOver(firstRow)
     fireEvent.mouseOver(firstRow)
 
 
     fireEvent.scroll(root)
     fireEvent.scroll(root)
-    fireEvent.scroll(window)
-    fireEvent.resize(window)
 
 
     fireEvent.contextMenu(copyButton)
     fireEvent.contextMenu(copyButton)
     fireEvent.mouseOver(secondRow)
     fireEvent.mouseOver(secondRow)

+ 2 - 2
packages/client/ui-sidebar-right/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-sidebar-right/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: c947b5411f51aff54f480949af203d192662adbf
-README.zh.md: 641a8fab400bbfadaf9a1b4b96ab8369a8121edf
+README.md: 75c600edf014564894c9299422c840bce15eb8e8
+README.zh.md: 203bc30d0550234e9ed97c1ccac70bed433d0605

+ 8 - 4
packages/client/ui-sidebar-right/README.md

@@ -67,7 +67,7 @@ After every action the kit's settle planner keeps the expanded surface populated
 
 
 The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely.
 The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely.
 
 
-State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was.
+Layout, open tabs, navigation parameters and selection are memory-only. A reload constructs the collapsed default; content plugins can then reopen their own live content, as [terminal recovery](../ui-sidebar-terminal/README.md#use-this-package) does. Switching sessions keeps each surface where it was.
 
 
 <a id="extension-seats"></a>
 <a id="extension-seats"></a>
 ## Extension seats
 ## Extension seats
@@ -84,7 +84,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
 <a id="ctxsidebarright"></a>
 <a id="ctxsidebarright"></a>
 ## `ctx.sidebarRight`
 ## `ctx.sidebarRight`
 
 
-`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource://<type>/…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error.
+`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource://<type>/…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error.
 
 
 `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws.
 `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws.
 
 
@@ -93,10 +93,14 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
 
 
 The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
 The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
 
 
+Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close.
+
 <a id="the-guide"></a>
 <a id="the-guide"></a>
 ## The guide
 ## The guide
 
 
-Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
+A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness.
+
+Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
 
 
 The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
 The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
 
 
@@ -118,7 +122,7 @@ None; this package neither assembles nor sends a provider request.
 
 
 <a id="known-limitations-and-deferred-work"></a>
 <a id="known-limitations-and-deferred-work"></a>
 
 
-- **Memory-only.** Nothing is persisted; a reload starts every session collapsed.
+- **Memory-only layout.** Sidebar state is not persisted; content-specific recovery cannot reproduce the previous layout or selection.
 - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right.
 - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right.
 - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet.
 - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet.
 - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent.
 - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent.

部分文件因为文件数量过多而无法显示