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

feat(web): add interactive sidebar terminals

Yichen Jiang 1 неделя назад
Родитель
Сommit
e15a9b1bec
100 измененных файлов с 5449 добавлено и 81 удалено
  1. 6 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  2. 45 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  3. 45 0
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  4. 3 0
      THIRD_PARTY_NOTICES.md
  5. 1 0
      apps/web/tests/expected/sidebar-terminal/running.expected.md
  6. 6 0
      apps/web/tests/fixtures/sidebar-terminal.patch.yml
  7. 19 9
      apps/web/tests/sidebar-right.e2e.ts
  8. 145 0
      apps/web/tests/sidebar-terminal.e2e.ts
  9. 1 0
      apps/web/tsconfig.json
  10. 2 2
      docs/capability-seams.i18n.yaml
  11. 4 0
      docs/capability-seams.md
  12. 4 0
      docs/capability-seams.zh.md
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 39 1
      docs/config-catalog.md
  15. 38 0
      docs/config-catalog.zh.md
  16. 2 2
      docs/event-producer-consumer.i18n.yaml
  17. 1 1
      docs/event-producer-consumer.md
  18. 1 1
      docs/event-producer-consumer.zh.md
  19. 2 2
      docs/module-graph.i18n.yaml
  20. 4 0
      docs/module-graph.md
  21. 4 0
      docs/module-graph.zh.md
  22. 2 2
      docs/subsystems/subprocess.i18n.yaml
  23. 10 1
      docs/subsystems/subprocess.md
  24. 10 1
      docs/subsystems/subprocess.zh.md
  25. 2 2
      docs/subsystems/workspace.i18n.yaml
  26. 83 0
      docs/subsystems/workspace.md
  27. 83 0
      docs/subsystems/workspace.zh.md
  28. 2 2
      packages/api/README.i18n.yaml
  29. 1 0
      packages/api/README.md
  30. 1 0
      packages/api/README.zh.md
  31. 2 1
      packages/api/remotes/package.json
  32. 4 1
      packages/api/remotes/src/client/index.ts
  33. 3 0
      packages/api/remotes/tsconfig.client.json
  34. 3 0
      packages/api/remotes/tsconfig.host.json
  35. 6 0
      packages/api/terminal-controller/README.i18n.yaml
  36. 90 0
      packages/api/terminal-controller/README.md
  37. 90 0
      packages/api/terminal-controller/README.zh.md
  38. 92 0
      packages/api/terminal-controller/package.json
  39. 73 0
      packages/api/terminal-controller/src/client/close-requests.ts
  40. 145 0
      packages/api/terminal-controller/src/client/index.ts
  41. 284 0
      packages/api/terminal-controller/src/client/model.ts
  42. 341 0
      packages/api/terminal-controller/src/index.ts
  43. 28 0
      packages/api/terminal-controller/src/shells.ts
  44. 75 0
      packages/api/terminal-controller/src/stream.ts
  45. 169 0
      packages/api/terminal-controller/src/terminal.ts
  46. 51 0
      packages/api/terminal-controller/src/types.ts
  47. 508 0
      packages/api/terminal-controller/tests/controller.spec.ts
  48. 503 0
      packages/api/terminal-controller/tests/model.client.spec.ts
  49. 369 0
      packages/api/terminal-controller/tests/recovery.client.spec.ts
  50. 80 0
      packages/api/terminal-controller/tests/stream.spec.ts
  51. 220 0
      packages/api/terminal-controller/tests/terminal.spec.ts
  52. 37 0
      packages/api/terminal-controller/tsconfig.client.json
  53. 53 0
      packages/api/terminal-controller/tsconfig.host.json
  54. 11 0
      packages/api/terminal-controller/tsconfig.json
  55. 7 0
      packages/api/terminal-controller/tsdown.config.ts
  56. 4 0
      packages/bundle/web-app/cordis.patch.yml
  57. 3 1
      packages/bundle/web-app/package.json
  58. 2 1
      packages/client/tsdown.client.ts
  59. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  60. 5 3
      packages/client/ui-sidebar-right/README.md
  61. 5 3
      packages/client/ui-sidebar-right/README.zh.md
  62. 1 1
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  63. 6 2
      packages/client/ui-sidebar-right/src/client/index.ts
  64. 57 8
      packages/client/ui-sidebar-right/src/client/service.ts
  65. 9 6
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx
  66. 2 2
      packages/client/ui-sidebar-right/src/client/tab-domain.ts
  67. 2 0
      packages/client/ui-sidebar-right/src/client/tab-registry.ts
  68. 4 1
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx
  69. 2 2
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  70. 7 1
      packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx
  71. 24 5
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  72. 154 5
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  73. 1 1
      packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts
  74. 6 0
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  75. 80 0
      packages/client/ui-sidebar-terminal/README.md
  76. 80 0
      packages/client/ui-sidebar-terminal/README.zh.md
  77. 76 0
      packages/client/ui-sidebar-terminal/package.json
  78. 9 0
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css
  79. 126 0
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx
  80. 3 0
      packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css
  81. 29 0
      packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx
  82. 9 0
      packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx
  83. 29 0
      packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx
  84. 60 0
      packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx
  85. 19 0
      packages/client/ui-sidebar-terminal/src/client/face.ts
  86. 81 0
      packages/client/ui-sidebar-terminal/src/client/index.ts
  87. 30 0
      packages/client/ui-sidebar-terminal/src/client/locales.ts
  88. 6 0
      packages/client/ui-sidebar-terminal/src/css-modules.d.ts
  89. 4 0
      packages/client/ui-sidebar-terminal/src/index.ts
  90. 175 0
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  91. 53 0
      packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx
  92. 83 0
      packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx
  93. 299 0
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  94. 51 0
      packages/client/ui-sidebar-terminal/tsconfig.json
  95. 3 0
      packages/client/ui-sidebar-terminal/tsdown.config.ts
  96. 2 2
      packages/e2b/subprocess-e2b/README.i18n.yaml
  97. 2 0
      packages/e2b/subprocess-e2b/README.md
  98. 2 0
      packages/e2b/subprocess-e2b/README.zh.md
  99. 21 4
      packages/e2b/subprocess-e2b/src/index.ts
  100. 9 1
      packages/e2b/subprocess-e2b/src/terminal.ts

+ 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: de51d0bb849de0599dfdaf86d2293c17618059cf
+2026-09-09-web-sidebar-terminal.zh.md: e6aacebc424efc12457b4a31f0b2660a3ad6225b

+ 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 starts the execution environment's default shell immediately. 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 only during 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.
+
+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. 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.
+
+## Alternatives considered
+
+**Add shell selection and terminal-specific creation controls.** Opening a terminal uses the execution environment's default shell, and the sidebar already owns tab creation and titles. Another picker, process list, header and add control would duplicate navigation around the terminal screen.
+
+**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。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider 和 Session sandbox policy。shell 只在创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
+
+关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。
+
+侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。`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。
+
+## 考虑过的替代方案
+
+**增加 shell 选择和终端专用创建控件。** 打开终端使用执行环境的默认 shell,侧栏已经负责标签页创建与标题。额外的选择器、进程列表、标题栏和添加控件会在终端屏幕周围重复导航功能。
+
+**进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
+
+**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 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

@@ -56,7 +56,10 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | 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/xterm`](https://github.com/xtermjs/xterm.js) | MIT |
 | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
 | [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |

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

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

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

@@ -0,0 +1,6 @@
+- id: terminal-controller
+  config:
+    shell:
+      path: /bin/bash
+      name: bash
+      args: [--noprofile, --norc, -i]

+ 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]')
   await expandOf(page).waitFor({ timeout: 15_000 })
   await ensureExpanded(page, column)
+  await column.locator('[data-sidebar-right-guide-entry="files"]').click()
   await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
   await width(column)
   return column
@@ -390,6 +391,10 @@ describe('web e2e: shipped right Sidebar', () => {
         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
       // control in its pane until it is closed.
       const addTab = column.locator('[data-dockkit-add-tab]')
@@ -752,10 +757,13 @@ describe('web e2e: shipped right Sidebar', () => {
       )
       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])
+      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.
       const filePane = panes.filter({ has: page.locator('[data-dockkit-tab-title]', { hasText: SAMPLE_NAME }) })
@@ -892,7 +900,7 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(await panes.count()).toBe(2)
       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 })
       await floatByDrag(page, floatOne)
       await expect.poll(async () => await floats.count()).toBe(1)
@@ -901,8 +909,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 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' })
       await floatByDrag(page, second)
       await expect.poll(async () => await floats.count()).toBe(2)
@@ -957,7 +964,7 @@ describe('web e2e: shipped right Sidebar', () => {
       // Any other tab standing alone closes together with the column. Open the
       // sample file, close the guide (an ordinary close with two tabs), then
       // 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 expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
       await column.locator('[data-dockkit-tab]').first().hover()
@@ -967,8 +974,8 @@ describe('web e2e: shipped right Sidebar', () => {
       await column.locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
       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.warnings).toEqual([])
@@ -994,6 +1001,7 @@ describe('web e2e: shipped right Sidebar', () => {
 
       await ensureExpanded(page, column)
       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 expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       // No "more" control on the chip: the chip carries its close, and the menu
@@ -1041,6 +1049,8 @@ describe('web e2e: shipped right Sidebar', () => {
         const column = zhPage.locator('[data-rightbar-col]')
         await expandOf(zhPage).waitFor({ timeout: 20_000 })
         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 column.locator('[data-dockkit-add-tab]').click()
 

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

@@ -0,0 +1,145 @@
+/** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */
+import { mkdir } from 'node:fs/promises'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller'
+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): Promise<void> {
+  const expand = page.locator('[data-sidebar-right-expand]')
+  if (await expand.isVisible()) await expand.click()
+  else await page.locator('[data-dockkit-add-tab]').click()
+  await page.locator('[data-sidebar-right-guide-entry="terminal"]').click()
+  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')
+}
+
+function alive(pid: number): boolean {
+  try {
+    process.kill(pid, 0)
+    // A container's init may retain a reparented zombie; it cannot run after terminal cleanup.
+    if (process.platform === 'linux') {
+      const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
+      const state = stat.slice(stat.lastIndexOf(')') + 2, stat.lastIndexOf(')') + 3)
+      return state !== 'Z' && state !== 'X'
+    }
+    return true
+  } catch (error) {
+    if (['ESRCH', 'ENOENT'].includes((error as NodeJS.ErrnoException).code ?? '')) return false
+    throw error
+  }
+}
+
+describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(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')
+    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)
+
+  afterAll(async () => { await browser?.close(); await scaffold?.close() })
+
+  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])
+    expect(alive(pid)).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])
+    expect(secondPid).not.toBe(pid)
+    await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click()
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    expect(alive(secondPid)).toBe(true)
+    await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
+    expect(alive(pid)).toBe(true)
+    await page.locator('[data-sidebar-right-expand]').click()
+    const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!)
+    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(['Start', 'Development', 'bash'])
+    expect(terminals()).toHaveLength(2)
+    expect(alive(pid)).toBe(true)
+    expect(alive(secondPid)).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(secondPid), { 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 })
+    await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!")
+    await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u)
+    const childPid = Number((await screen.innerText()).match(/CHILD_PID:(\d+)/u)?.[1])
+    expect(alive(childPid)).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(pid), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => alive(childPid), { timeout: 10_000 }).toBe(false)
+    await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -94,6 +94,7 @@
     "tests/feedback-release.e2e.ts",
     "tests/agent-team-panel.e2e.ts",
     "tests/sidebar-right.e2e.ts",
+    "tests/sidebar-terminal.e2e.ts",
     "tests/startup-auto-selection.e2e.ts",
     "tests/produced-files.e2e.ts",
     "tests/produced-file-mentions.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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: 9cc7a6fc50b81e9f82d58600c84d1ff6494634c9
-capability-seams.zh.md: e898bdf3c2c728a1bae427bbb737eb5269a1c500
+capability-seams.md: 826b8b546242b6581846b52a3a5919c333610d42
+capability-seams.zh.md: 106f4b75180322820604a2f254f34dc8aa57abdf

+ 4 - 0
docs/capability-seams.md

@@ -46,6 +46,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   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"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
@@ -234,6 +236,7 @@ flowchart LR
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_settingsController
+  pkg_api_terminal_controller --> svc_terminalController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
@@ -487,6 +490,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.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.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.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. |

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

@@ -48,6 +48,8 @@ flowchart LR
   svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
   pkg_api_workspace_files["api-workspace-files"]
   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"]
   svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
   svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
@@ -236,6 +238,7 @@ flowchart LR
   pkg_api_session_controller --> svc_sessionSkillCatalog
   pkg_api_settings_controller --> svc_credentialsController
   pkg_api_settings_controller --> svc_settingsController
+  pkg_api_terminal_controller --> svc_terminalController
   pkg_api_workspace_controller --> svc_directoryPickerController
   pkg_api_workspace_controller --> svc_workspaceController
   pkg_api_workspace_files --> svc_workspaceFiles
@@ -489,6 +492,7 @@ flowchart LR
 | `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.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.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,以及标明所属包的失败。 |

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

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

+ 39 - 1
docs/config-catalog.md

@@ -229,6 +229,43 @@ export interface Config {
 
 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
+  /** 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>
 
 ## `@deepseek-ai/dsh-api-workspace-files`
@@ -2575,7 +2612,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/e2b/subprocess-e2b/src/index.ts:26`](../packages/e2b/subprocess-e2b/src/index.ts)
+Source: [`packages/e2b/subprocess-e2b/src/index.ts:28`](../packages/e2b/subprocess-e2b/src/index.ts)
 
 <a id="deepseek-aidsh-system-prompt"></a>
 
@@ -3484,6 +3521,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-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-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-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))

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

@@ -231,6 +231,43 @@ export interface Config {
 
 来源:[`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
+  /** 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>
 
 ## `@deepseek-ai/dsh-api-workspace-files`
@@ -3486,6 +3523,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-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-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-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))

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4
-event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b
+event-producer-consumer.md: 3114d32853689ea267a923490a1a667a0a54b1f0
+event-producer-consumer.zh.md: 42915ab51d194d4827bf047135d39a9f3198a353

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

@@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 
 | 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` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent), `inspector` |

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

@@ -82,7 +82,7 @@
 
 | 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` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: cbaa8cb04a7ec2406b1f6e7f2c1dbf0a8dff0527
-module-graph.zh.md: 148b2730fccc1740d1c2f24d766f2aa7213093cf
+module-graph.md: 4e0db7406fa7b18bfef88e4a4828578aebcca327
+module-graph.zh.md: 590a3cb3de2fbd85a52bc8fabe5921905f148186

+ 4 - 0
docs/module-graph.md

@@ -115,6 +115,7 @@ flowchart TD
     pkg_api_remotes["api-remotes"]
     pkg_api_session_controller["api-session-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_files["api-workspace-files"]
   end
@@ -176,6 +177,7 @@ flowchart TD
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     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_slots["client-ui-slots"]
     pkg_client_ui_subagent["client-ui-subagent"]
@@ -1198,6 +1200,7 @@ flowchart TD
 | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
 | [`llm`](../packages/llm/llm) | `llm` | — |
 | [`api-gateway`](../packages/api/gateway) | `api` | — |
+| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — |
 | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
 | [`cmdline`](../packages/boot/cmdline) | `boot` | — |
 | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
@@ -1244,6 +1247,7 @@ flowchart TD
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `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-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |

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

@@ -117,6 +117,7 @@ flowchart TD
     pkg_api_remotes["api-remotes"]
     pkg_api_session_controller["api-session-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_files["api-workspace-files"]
   end
@@ -178,6 +179,7 @@ flowchart TD
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     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_slots["client-ui-slots"]
     pkg_client_ui_subagent["client-ui-subagent"]
@@ -1200,6 +1202,7 @@ flowchart TD
 | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
 | [`llm`](../packages/llm/llm) | `llm` | — |
 | [`api-gateway`](../packages/api/gateway) | `api` | — |
+| [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | — |
 | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
 | [`cmdline`](../packages/boot/cmdline) | `boot` | — |
 | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
@@ -1246,6 +1249,7 @@ flowchart TD
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `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-slots`](../packages/client/ui-slots) | `client` | — |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | — |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md
-subprocess.md: 782623760e70648a108ac51252b610e27bffbe45
-subprocess.zh.md: 7b8e590a843899b5f00a309a913e1dcb152c5c1b
+subprocess.md: 71040fb28b8cf919f72f8c727a7d0c7add30dada
+subprocess.zh.md: dda7356108305ac7e2f3182608b248ce6fa781de

+ 10 - 1
docs/subsystems/subprocess.md

@@ -240,7 +240,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.
 
-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
 
@@ -300,6 +302,13 @@ Implementations must honor these semantics:
  */
 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
  * applies no defaults.

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

@@ -240,7 +240,9 @@ interface SubprocessOutcome {
 
 `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` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。
 
 ## 服务行为
 
@@ -300,6 +302,13 @@ Implementations must honor these semantics:
  */
 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
  * 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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: a7c4b8843f0a0b2d2fb251157f7928a40df1e405
-workspace.zh.md: 3474cbf147848b04a4ec51147983a343bcc1e91d
+workspace.md: f37af82d7f8cec018d24a22534bc0b7342425d34
+workspace.zh.md: 02dd976abde63992b65f09ea4852cedf0d0b4b36

+ 83 - 0
docs/subsystems/workspace.md

@@ -183,6 +183,89 @@ 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)
 
+<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
+
+/**
+ * List running and exited terminals available for reattachment.
+ * @param agent - Session owner supplied by the Gateway.
+ * @returns terminals retained for this Host lifetime.
+ */
+@Remote list(agent: Agent): 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)
+
+Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts)
+
 <a id="ctxworkspacecontroller--workspacecontroller"></a>
 
 ### `ctx.workspaceController` — `WorkspaceController`

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

@@ -183,6 +183,89 @@ 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)
 
+<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
+
+/**
+ * List running and exited terminals available for reattachment.
+ * @param agent - Session owner supplied by the Gateway.
+ * @returns terminals retained for this Host lifetime.
+ */
+@Remote list(agent: Agent): 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)
+
+Source: [`packages/api/terminal-controller/src/index.ts`](../../packages/api/terminal-controller/src/index.ts)
+
 <a id="ctxworkspacecontroller--workspacecontroller"></a>
 
 ### `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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/README.md
-README.md: 20455e6b5622ffd6e826b1d4b427838f96f6610f
-README.zh.md: 0294b1823e6a09cae4591b4c6081a546d8d60494
+README.md: a60e47948ffcb54e07b33ff63a1ea92e3c05998e
+README.zh.md: 9e374c90c808f4314ba6565038bdba6bcca80cf9

+ 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` |
 | [`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` |
+| [`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 agent-write `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.

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

@@ -31,6 +31,7 @@ kind: "package-group"
 | [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 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` |
 | [`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` 与 agent 写入的 `changes` 流——以及其上的 Client `file` 资源提供者。 | `ctx.workspaceFiles` / `ctx.remote.workspaceFiles` |
 
 Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。

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

@@ -85,6 +85,7 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-files": "workspace:^",
     "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

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

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

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

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

@@ -67,6 +67,9 @@
     },
     {
       "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: f6755cab8def6bc1f5842ee387a20c5ed51e7e7f
+README.zh.md: fef6e6b45cef87e0a3f1c433dec13baad59ab75a

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

@@ -0,0 +1,90 @@
+---
+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`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity.
+
+New terminals use the execution environment's declared default shell. 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 provider verifies the executable during creation; resolution failure is reported without trying another 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 |
+| `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. 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.
+
+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.
+
+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; startup retries saved requests. 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.
+- 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>

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

@@ -0,0 +1,90 @@
+---
+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`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。
+
+新终端使用执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。provider 在创建时验证可执行文件;解析失败会直接报告,不尝试其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
+
+| 配置 | 默认值 | 含义 |
+|---|---|---|
+| `shell` | 省略 | 使用执行环境默认 shell,或指定一个 profile |
+| `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` 的终端,不启动桌面终端应用。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。
+
+最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。
+
+Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。
+
+关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功后删除;启动时重试已保存的请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 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。
+- 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。
+- 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护说明</summary>
+
+不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。
+
+</details>

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

@@ -0,0 +1,92 @@
+{
+  "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-alpha.1",
+  "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/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'
+}

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

@@ -0,0 +1,145 @@
+/** Client terminal model service; views are keyed independently from Host terminal identities. */
+import { Service, type Context } from '@deepseek-ai/cordis'
+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, 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
+      for (const views of this.views.values()) for (const view of views.values()) view.dispose()
+      this.views.clear()
+      await Promise.all(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 new Error(result.error.message)
+      }
+      this.requests.remove(record.id)
+    })().catch((error: unknown) => {
+      if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
+        id: record.id, title: record.title,
+        message: error instanceof Error ? error.message : String(error),
+      }])
+    }).finally(() => {
+      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)
+}

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

@@ -0,0 +1,284 @@
+/** React-free browser terminal state and reconnecting Remote-stream ownership. */
+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 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 {
+  TerminalAttachmentId, TerminalEnvironment, TerminalFrame,
+  WebTerminalId, WebTerminalInfo,
+} from '../types.ts'
+
+/** The generated terminal namespace's browser-facing operations. */
+export type TerminalRemote = ClientRemote['terminal']
+
+/** 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' | 'creating' | 'connecting' | 'connected' | 'disconnected' | 'closing' | 'closed' | 'failed'
+  readonly environment?: TerminalEnvironment | undefined
+  readonly title?: string | undefined
+  readonly info?: WebTerminalInfo | undefined
+  readonly writable: boolean
+  readonly render?: TerminalRenderFrame | undefined
+  readonly error?: string | 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
+
+  /**
+   * @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, starting the default shell 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.loading !== undefined) return this.loading
+    if (this.closing !== undefined || this.lifetime.signal.aborted) return Promise.resolve()
+    this.patch({ phase: 'loading', error: 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.lifetime.signal.aborted || this.closing !== undefined) 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) await this.create(valueOf(environment))
+      else throw new Error('This terminal no longer exists on the Host. Open a new terminal.')
+    })().catch((error: unknown) => { this.fail(error) }).finally(() => { this.loading = undefined })
+    return this.loading
+  }
+
+  private async create(environment: TerminalEnvironment): Promise<void> {
+    this.patch({ phase: 'creating', error: undefined })
+    this.creation = (async () => {
+      const info = valueOf(await this.remote.create(this.sessionId, {
+        id: this.id, cols: Math.min(80, environment.maxCols), rows: Math.min(24, environment.maxRows),
+      }, this.lifetime.signal))
+      if (!this.lifetime.signal.aborted) this.adopt(info)
+    })().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 Error('Terminal attachment ended; reconnect to recover its state'),
+      carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
+    })
+    this.stream = stream
+    this.patch({ phase: 'connecting', writable: false, error: 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 Error('Terminal input buffer is full')); return }
+    this.queuedInput += bytes
+    const id = state.info.id
+    this.writes = this.writes.then(async () => {
+      if (this.attachmentId !== attachmentId) 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) 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 })
+    this.detach()
+    this.closing = (async () => {
+      // Even a refused or lost creation response may leave an allocation to close.
+      await this.creation?.catch(() => {})
+      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. */
+  dispose(): void {
+    this.mounted = false
+    this.lifetime.abort()
+    this.detach()
+  }
+
+  private detach(): void {
+    const previous = this.stream
+    this.stream = undefined
+    this.attachmentId = undefined
+    this.pendingRender?.resolve()
+    this.pendingRender = undefined
+    if (previous !== undefined) void previous.dispose()
+  }
+
+  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 Error('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 Error('Terminal output sequence has a gap')
+          sequence = frame.sequence
+        } else if (frame.type === 'snapshot') throw new Error('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 {
+    this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) })
+  }
+}
+
+function valueOf<T>(result: RemoteResult<T>): T {
+  if (!result.ok) throw new Error(result.error.message)
+  return result.value
+}

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

@@ -0,0 +1,341 @@
+/** 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, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { resolveShell } from './shells.ts'
+import { BrowserTerminal } from './terminal.ts'
+import type {
+  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
+  /** 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)]),
+    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 }
+  }
+
+  /**
+   * List running and exited terminals available for reattachment.
+   * @param agent - Session owner supplied by the Gateway.
+   * @returns terminals retained for this Host lifetime.
+   */
+  @Remote
+  list(agent: Agent): WebTerminalInfo[] {
+    const owner = this.owners.get(agent.id)
+    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 Error('Session terminal limit reached')
+    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)
+    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 = await resolveShell(subprocess, this.config.shell, signal)
+    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 = sandbox.confine(argv, { ...policy, mode: policy.mode }).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

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

@@ -0,0 +1,28 @@
+/** Shell selection and executable verification use the target execution provider. */
+import 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'] }
+}

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

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

@@ -0,0 +1,169 @@
+/** One PTY, a bounded terminal emulator and its detachable browser followers. */
+import { createRequire } from 'node:module'
+import { StringDecoder } from 'node:string_decoder'
+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 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')
+
+/** 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 Error('Terminal is not running')
+    if (this.controller?.id !== id) throw new Error('Terminal input is controlled by another attachment')
+  }
+
+  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 StringDecoder('utf8')
+    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.write(chunk as Buffer)
+        await this.output(data)
+      }
+      await this.output(decoder.end())
+      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 })
+    })
+  }
+}

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

@@ -0,0 +1,51 @@
+/** Browser terminal identities, metadata and screen-stream frames. */
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** 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 {
+  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 }

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

@@ -0,0 +1,508 @@
+/** 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 = { 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)).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(owner(ctx, 'other'))).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)).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)).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)).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)).toMatchObject([{ id, state: 'failed' }])
+    await controller.close(agent, id)
+    expect(handle.terminate).toHaveBeenCalledTimes(2)
+    expect(controller.list(agent)).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)).toHaveLength(1)
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
+    await controller.close(agent, id)
+    expect(controller.list(agent)).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.toThrow('limit reached')
+    } 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)).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.toThrow('limit reached')
+    await controller.close(agent, id)
+    expect(controller.list(agent)).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)).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)).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)).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(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)).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)).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)).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)).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)).toHaveLength(1)
+    await controller.close(agent, id)
+    expect(controller.list(agent)).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)).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 })
+  }
+})

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

@@ -0,0 +1,503 @@
+/** Browser view ownership across slow RPCs, remounts and transport generations. */
+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 = {
+    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()
+  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', error: 'Terminal input buffer is full' })
+  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().error).toContain('Terminal attachment ended')
+  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().error).toBe('Terminal input buffer is full')
+  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' })
+  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()
+  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()
+    if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce()
+    model.dispose()
+    const before = model.state.getSnapshot()
+    if (operation === 'creation') creation.resolve(success(info))
+    else discovery.reject(new Error('late discovery failure'))
+    await loading
+    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')
+  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()
+  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()
+})

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

@@ -0,0 +1,369 @@
+/** Reloaded tab identities and nonblocking close requests use independent lifetimes. */
+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 = {
+    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('starts the default shell once across overlapping mounts and retries, with no shell choice in the request', 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 expect.poll(() => vi.mocked(h.remote.create).mock.calls.length).toBe(1)
+  expect(vi.mocked(h.remote.create).mock.calls[0]?.[1]).toEqual({ id: info.id, cols: 80, rows: 24 })
+  creation.resolve(success(info))
+  await loading
+  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().error).toBeDefined()
+  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()
+  expect(model.state.getSnapshot().error).toBe('response lost')
+  await model.refresh()
+  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 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 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')
+  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: info.title }
+  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)
+})

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

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

@@ -0,0 +1,220 @@
+/** 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))
+    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.toThrow('another attachment')
+    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.toThrow('another attachment')
+    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.toThrow('not running')
+    await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('not running')
+    const closing = terminal.close()
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('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('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
+}

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

@@ -0,0 +1,37 @@
+{
+  "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/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

@@ -107,6 +107,8 @@
 
     # Workspace file service: bounded read, directory listing, and the
     # agent-write change feed inside the session workspace root.
+    - id: terminal-controller
+      name: '@deepseek-ai/dsh-api-terminal-controller'
     - id: workspace-files
       name: '@deepseek-ai/dsh-api-workspace-files'
 
@@ -231,6 +233,8 @@
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 
     # 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
       name: '@deepseek-ai/dsh-client-ui-sidebar-files'
 

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

@@ -118,7 +118,9 @@
     "@deepseek-ai/dsh-workspace": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",
     "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": {
     "@deepseek-ai/cordis": "workspace:^",

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

@@ -10,7 +10,7 @@
  */
 import { readFile } from 'node:fs/promises'
 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 { fileURLToPath } from 'node:url'
 import type { UserConfig } from 'tsdown'
@@ -616,6 +616,7 @@ const SOURCEMAP_COMMENT = /\n\/\/# sourceMappingURL=.*\s*$/
 
 /** Resolve an emitted JS asset import against its source-tree counterpart. */
 function sourceAssetPath(source: string, importer: string): string {
+  if (!source.startsWith('.') && !isAbsolute(source)) return createRequire(importer).resolve(source)
   const emitted = resolvePath(dirname(importer), source)
   if (existsSync(emitted)) return emitted
   const boundary = emitted.indexOf(TYPES_MARKER)

+ 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;
 # 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
-README.md: 128f0e41a1b1a90f39f4db36f1a8ca8dfa3089f7
-README.zh.md: 66f2e8d6a07635d7e94ee50395c274951911e517
+README.md: 1a0a34119cc91f2e7c829e8b0c1dc56ad74d2631
+README.zh.md: 7d611eb4a4d4daa0c00baa8658b8b3e84288471e

+ 5 - 3
packages/client/ui-sidebar-right/README.md

@@ -67,7 +67,7 @@ After every action the kit's settle planner keeps the surface populated: a docke
 
 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 settle planner reseeds the current default page for the next expansion. 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>
 ## Extension seats
@@ -93,12 +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.
 
+Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler can save background cleanup and return synchronously, allowing immediate removal, or return a Promise that the sidebar awaits before explicit close or replacement. A thrown or rejected 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>
 ## 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.
 
-The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. 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 or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. 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 one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule replaces the guide with its page. An entry may set `revealIfOpened: false` to create another tab; omission keeps the normal reveal of an existing page. 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 or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. 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.
 
 <a id="copy"></a>
 ## Copy
@@ -118,7 +120,7 @@ None; this package neither assembles nor sends a provider request.
 
 <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.
 - **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.

+ 5 - 3
packages/client/ui-sidebar-right/README.zh.md

@@ -67,7 +67,7 @@ kind: "package-reference"
 
 停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。
 
-状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。
+布局、打开的标签页、导航参数和选中项只在内存中。刷新会构造折叠的默认态;内容插件随后可重新打开自己仍存活的内容,例如[终端恢复](../ui-sidebar-terminal/README.zh.md#use-this-package)。切换会话则让每个停靠面留在原处。
 
 <a id="extension-seats"></a>
 ## 扩展席位
@@ -93,12 +93,14 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
 
 Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
 
+标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 可以保存后台清理任务并同步返回,让标签页立即移除;也可以返回 Promise,让侧栏在显式关闭或替换前等待。handler 抛错或拒绝时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
+
 <a id="the-guide"></a>
 ## 引导页
 
 默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
 
-引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
+引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会用对应页面替换引导页。条目可设置 `revealIfOpened: false` 来新建另一个 tab;省略时保留聚焦已有页面的默认行为。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
 
 <a id="copy"></a>
 ## 文案
@@ -118,7 +120,7 @@ None; this package neither assembles nor sends a provider request.
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **只在内存中。** 不持久化任何东西;刷新让每个会话从折叠态开始
+- **布局只在内存中。** 侧栏状态不持久化;内容自身的恢复无法还原此前的布局或选中项
 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。
 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。
 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。

+ 1 - 1
packages/client/ui-sidebar-right/src/client/contract/slots.ts

@@ -126,7 +126,7 @@ export interface SidebarRightTabActions {
    */
   openTab<K extends string>(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor<K> }): void
   /** Close this tab. */
-  close(): void
+  close(): void | Promise<void>
 }
 
 /** Live information shared by a tab's body, title, and guide replacement. */

+ 6 - 2
packages/client/ui-sidebar-right/src/client/index.ts

@@ -49,7 +49,7 @@ export type { ExpandButtonProps } from './shell/ExpandButton.tsx'
 export type { SidebarRightState, SurfaceState } from './stores.ts'
 export type {
   ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions,
-  SidebarRightPlacement, SurfaceActions,
+  SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions,
 } from './service.ts'
 export type {
   SidebarRightGuideBox, SidebarRightGuideEntry, SidebarRightTabClaim, SidebarRightTabDefinition,
@@ -135,7 +135,7 @@ export function apply(ctx: ClientContext): void {
       },
     }
     const layout: ILayout = ctx.layout
-    const injected: Omit<SidebarRightInjected, 'keyedHooks' | 'occurrence'> = {
+    const injected: Omit<SidebarRightInjected, 'keyedHooks' | 'occurrence' | 'closeTab'> = {
       syncPresentation({ shown, track, fullscreen }) {
         if (shown) layout.openRightbar(track, fullscreen)
         else layout.closeRightbar()
@@ -162,6 +162,10 @@ export function apply(ctx: ClientContext): void {
         store,
         inject: (sessionId): SidebarRightInjected => ({
           ...injected,
+          closeTab: (tabId) => {
+            const pending = controller.closeIn(sessionId, tabId)
+            if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab close failed:', error) })
+          },
           keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation },
           occurrence: tab => controller.tabDomain.occurrence(sessionId, tab),
         }),

+ 57 - 8
packages/client/ui-sidebar-right/src/client/service.ts

@@ -28,13 +28,13 @@
  * callers use the service's navigation methods.
  */
 import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
-import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts'
 import { pageAddress } from './contract/seed.ts'
 import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts'
-import type { SidebarRightState, SurfaceState } from './stores.ts'
+import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts'
 import type { createSidebarRightStore } from './stores.ts'
 import { TabDomain, type PinResource } from './tab-domain.ts'
 
@@ -134,6 +134,9 @@ export interface SidebarRightOpenTabOptions<K extends string = string> extends S
 /** The scheme every resource address carries; anything else is not a resource this face opens. */
 const RESOURCE_SCHEME = 'dsh-resource://'
 
+/** Explicit close/replacement hook. Return void for immediate removal, or a promise to defer removal until cleanup succeeds. */
+export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void | Promise<void>
+
 /** The outward right-Sidebar face (`ctx.sidebarRight`). */
 export interface ISidebarRight {
   /**
@@ -160,7 +163,7 @@ export interface ISidebarRight {
    * Close one tab of the mounted session; the sole docked guide remains open.
    * @param tabId - the tab to close.
    */
-  close(tabId: TabId): void
+  close(tabId: TabId): void | Promise<void>
   /**
    * The active tab of the active pane.
    * @returns the record, or `undefined` when no seat is mounted.
@@ -203,6 +206,20 @@ export interface ISidebarRight {
 /** Cross-plugin right-Sidebar face (ctx.sidebarRight). */
 export class SidebarRightController implements ISidebarRight {
   private binding: SidebarRightBinding | undefined
+  private readonly closeHandlers = new Map<string, SidebarRightCloseHandler>()
+  private readonly closing = new Map<string, Promise<void>>()
+
+  /**
+   * Register resource cleanup before explicit removal. Failure preserves the tab.
+   * @param kind - tab kind owned by the registering plugin.
+   * @param handler - saves background cleanup synchronously, or returns cleanup to await before removal.
+   * @returns an effect-scoped unregister callback.
+   */
+  registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void {
+    if (this.closeHandlers.has(kind)) throw new Error(`sidebarRight: close handler already registered for ${kind}`)
+    this.closeHandlers.set(kind, handler)
+    return () => { if (this.closeHandlers.get(kind) === handler) this.closeHandlers.delete(kind) }
+  }
 
   /**
    * The Tab domain this controller navigates into; synced from each adopted
@@ -291,10 +308,33 @@ export class SidebarRightController implements ISidebarRight {
    * Not part of `ISidebarRight`: the Tab domain's path.
    * @param sessionId - the session the tab is in.
    * @param tabId - the tab to close.
+   * @returns resource cleanup completion when the tab type owns an asynchronous close handler.
    */
-  closeIn(sessionId: SessionId, tabId: TabId): void {
+  closeIn(sessionId: SessionId, tabId: TabId): void | Promise<void> {
     const actions = this.actionsFor(sessionId)
-    if (actions !== undefined) actions.closeTab(sessionId, tabId)
+    const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]
+    if (actions === undefined || surface === undefined) return
+    const tab = surface.layout.tabs[tabId]
+    if (tab === undefined || !canCloseTab(surface, tabId)) return
+    return this.removeAfterCleanup(sessionId, tab, () => { actions.closeTab(sessionId, tabId) })
+  }
+
+  private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void | Promise<void> {
+    const handler = this.closeHandlers.get(tab.kind)
+    if (handler === undefined) { commit(); return }
+    const key = `${sessionId}:${tab.id}`
+    const existing = this.closing.get(key)
+    if (existing !== undefined) return existing
+    const adoption = this.adopted.get(sessionId)
+    const occurrence = this.tabDomain.occurrence(sessionId, tab)
+    const completion = handler(sessionId, tab)
+    if (completion === undefined) { commit(); return }
+    const pending = completion.then(() => {
+      if (this.adopted.get(sessionId) !== adoption || occurrence.signal.aborted) return
+      commit()
+    }).finally(() => { this.closing.delete(key) })
+    this.closing.set(key, pending)
+    return pending
   }
 
   /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */
@@ -332,22 +372,31 @@ export class SidebarRightController implements ISidebarRight {
     placement: SidebarRightPlacement,
     params: SidebarRightNavigationParams,
   ): void {
-    actions.openContent(sessionId, {
+    const commit = (): void => { actions.openContent(sessionId, {
       kind: claim.kind,
       contentId: claim.contentId,
       title: claim.title,
       ...placement.paneId === undefined ? {} : { paneId: placement.paneId },
       ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab },
       ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened },
-    }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) })
+    }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) }
+    const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout
+    const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab]
+    const revealed = layout === undefined || placement.revealIfOpened === false
+      ? undefined : findContentTab(layout, claim.contentId, claim.kind)
+    if (replaced === undefined || replaced.id === revealed) { commit(); return }
+    const pending = this.removeAfterCleanup(sessionId, replaced, commit)
+    if (pending !== undefined) void pending.catch((error: unknown) => { console.error('Sidebar tab replacement failed:', error) })
   }
 
   /**
    * Close one tab of the mounted session; the sole docked guide remains open.
    * @param tabId - the tab to close.
+   * @returns resource cleanup completion when the tab type owns an asynchronous close handler.
    */
-  close(tabId: TabId): void {
+  close(tabId: TabId): void | Promise<void> {
     const { sessionId, actions } = this.require()
+    if (this.adopted.has(sessionId)) return this.closeIn(sessionId, tabId)
     actions.closeTab(sessionId, tabId)
   }
 

+ 9 - 6
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx

@@ -97,6 +97,8 @@ export interface SidebarRightInjected {
    * the guide opened by kind, through the same path as every other open.
    */
   readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void
+  /** Close through the resource owner's cleanup handler. */
+  readonly closeTab: (tabId: TabId) => void
   readonly hooks: {
     readonly tabTypes: HostObservable<readonly SidebarRightTabDefinition[]>
   }
@@ -123,6 +125,7 @@ interface PanelProps {
   readonly t: RightbarSeatProps['t']
   readonly renderSlot: Children['renderSlot']
   readonly openTab: SidebarRightInjected['openTab']
+  readonly closeTab: SidebarRightInjected['closeTab']
   readonly useTabTypes: RightbarSeatProps['useTabTypes']
   readonly useTabNavigation: RightbarSeatProps['useTabNavigation']
   readonly useStore: Store['useStore']
@@ -145,7 +148,7 @@ function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined {
  * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through.
  * @returns the intents the kit reports gestures to.
  */
-export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab']): DockIntents {
+export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab'], closeTab?: PanelProps['closeTab']): DockIntents {
   return {
     focusTab: (tabId) => { actions.focusTab(sessionId, tabId) },
     focusPane: (paneId) => { actions.focusPane(sessionId, paneId) },
@@ -155,7 +158,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open
     // guides in other panes; the store settles the open on a guide the pane
     // already holds, so the ask is idempotent all the same.
     addTab: (paneId) => { openTab(GUIDE_KIND, { paneId, revealIfOpened: false }) },
-    closeTab: (tabId) => { actions.closeTab(sessionId, tabId) },
+    closeTab: closeTab ?? ((tabId) => { actions.closeTab(sessionId, tabId) }),
     duplicateTab: (tabId) => { actions.duplicateTab(sessionId, tabId) },
     floatTab: (tabId, rect?: FloatRect) => { actions.floatTab(sessionId, tabId, rect) },
     unfloatPane: (paneId) => { actions.unfloatPane(sessionId, paneId) },
@@ -311,7 +314,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
           minPaneFraction={0.2}
           canAddTab={paneId => guideIn(surface.layout, paneId) === undefined}
           canCloseTab={tabId => canCloseTab(surface, tabId)}
-          intents={intentsFor(sessionId, actions, openTab)}
+          intents={intentsFor(sessionId, actions, openTab, panel.closeTab)}
           labels={dockLabels(t)}
           renderTab={bodiesFor(panel)}
           renderTabTitle={titlesFor(panel)}
@@ -334,7 +337,7 @@ function Floats(panel: PanelProps): ReactNode {
       <FloatLayer
         state={surface.layout}
         canCloseTab={tabId => canCloseTab(surface, tabId)}
-        intents={intentsFor(sessionId, actions, openTab)}
+        intents={intentsFor(sessionId, actions, openTab, panel.closeTab)}
         labels={dockLabels(t)}
         renderTab={bodiesFor(panel)}
         renderTabTitle={titlesFor(panel)}
@@ -351,7 +354,7 @@ function Floats(panel: PanelProps): ReactNode {
  * which session it is acting on, because this is the seat that knows both.
  */
 export function RightbarSeat({
-  sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab,
+  sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, closeTab,
   useTabTypes, useTabNavigation, occurrence,
 }: RightbarSeatProps): ReactNode {
   // One store instance per session, so this map holds this session's surface.
@@ -416,7 +419,7 @@ export function RightbarSeat({
 
   if (surface === undefined) return null
   const panel: PanelProps = {
-    sessionId, actions, t, renderSlot, surface, openTab, useTabTypes, useTabNavigation, useStore, occurrence,
+    sessionId, actions, t, renderSlot, surface, openTab, closeTab, useTabTypes, useTabNavigation, useStore, occurrence,
     fullscreen, autoFullscreen, reportRoom,
   }
   return (

+ 2 - 2
packages/client/ui-sidebar-right/src/client/tab-domain.ts

@@ -34,7 +34,7 @@ export interface SidebarRightNavigator {
   /** Open a page type in one session; see `ISidebarRight.openTab`. */
   openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void
   /** Close a tab of one session. */
-  closeIn(sessionId: SessionId, tabId: TabId): void
+  closeIn(sessionId: SessionId, tabId: TabId): void | Promise<void>
 }
 
 /** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */
@@ -175,7 +175,7 @@ export class TabDomain {
         openTab: (kind, options = {}) => {
           navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params })
         },
-        close: () => { navigator.closeIn(sessionId, tabId) },
+        close: () => navigator.closeIn(sessionId, tabId),
       },
     }
     this.session(sessionId).set(tabId, held)

+ 2 - 0
packages/client/ui-sidebar-right/src/client/tab-registry.ts

@@ -68,6 +68,8 @@ export interface SidebarRightGuideEntry {
   readonly title: () => string
   /** Optional glyph, drawn before the title. */
   readonly icon?: ComponentType<IconProps>
+  /** Defaults to true; false makes this entry create another tab instead of focusing an existing one. */
+  readonly revealIfOpened?: boolean
 }
 
 /** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */

+ 4 - 1
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx

@@ -67,7 +67,10 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid
   const options = {
     hookContext: useTabInfo,
     fallback: (
-      <ShippedGuide entries={entries} onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
+      <ShippedGuide entries={entries} onPick={(entry) => { tab.actions.openTab(entry.kind, {
+        replaceTab: true,
+        ...entry.revealIfOpened === undefined ? {} : { revealIfOpened: entry.revealIfOpened },
+      }) }} />
     ),
   } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> }
   return renderSlotChain('sidebar.right.tab.guide', {}, options)

+ 2 - 2
packages/client/ui-sidebar-right/tests/apply.client.spec.ts

@@ -163,7 +163,7 @@ describe('ui-sidebar-right apply', () => {
     // Held and pinned from the store's own commit: no seat synced anything.
     const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide)
     expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal)
-    occurrence.tabActions.close()
+    void occurrence.tabActions.close()
     expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined()
     expect(occurrence.signal.aborted).toBe(true)
   })
@@ -199,7 +199,7 @@ describe('ui-sidebar-right apply', () => {
     await fiber.dispose()
     expect(signal.aborted).toBe(true)
     // The adoption went with the plugin: a late action from the dead occurrence changes nothing.
-    tabActions.close()
+    void tabActions.close()
     expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined()
     expect(ctx.get('sidebarRight')).toBeUndefined()
     expect(ctx.get('sidebarRightTabs')).toBeUndefined()

+ 7 - 1
packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx

@@ -73,7 +73,7 @@ describe('GuideBody', () => {
     cleanup()
   })
 
-  it('picking a box opens that type in the guide\'s own place', () => {
+  it('picking a box without a reveal preference preserves the default opening behavior', () => {
     const { view, openTab } = mountGuide([box('files', 10)])
     const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]')
     if (entry === null) throw new Error('expected the files box')
@@ -82,6 +82,12 @@ describe('GuideBody', () => {
     cleanup()
   })
 
+  it.each([false, true])('passes an entry\'s explicit revealIfOpened=%s preference when replacing the guide', (revealIfOpened) => {
+    const { view, openTab } = mountGuide([{ ...box('terminal', 20), revealIfOpened }])
+    fireEvent.click(view.getByRole('button', { name: 'terminal title' }))
+    expect(openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true, revealIfOpened })
+  })
+
   it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => {
     const { view, guideEntries, boxes } = mountGuide([])
     expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull()

+ 24 - 5
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -138,7 +138,7 @@ describe('RightbarSeat presentation', () => {
     if (entryCount !== 1) {
       expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0)
       const before = h.layout()
-      act(() => { h.controller.close(initial.id) })
+      act(() => { void h.controller.close(initial.id) })
       expect(h.layout()).toBe(before)
       fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]'))
       expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull()
@@ -146,7 +146,7 @@ describe('RightbarSeat presentation', () => {
       return
     }
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull()
-    act(() => { h.controller.close(initial.id) })
+    act(() => { void h.controller.close(initial.id) })
     expect(h.layout().expanded).toBe(false)
     const reseeded = Object.values(h.layout().tabs)[0]!
     expect(reseeded.kind).toBe('text')
@@ -157,12 +157,12 @@ describe('RightbarSeat presentation', () => {
     expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull()
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull()
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull()
-    act(() => { h.controller.close(reseeded.id) })
+    act(() => { void h.controller.close(reseeded.id) })
     expect(h.layout().tabs[reseeded.id]).toBeUndefined()
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull()
     const preview = h.open('ordinary.txt')
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull()
-    act(() => { h.controller.close(preview.id) })
+    act(() => { void h.controller.close(preview.id) })
     expect(h.layout().tabs[preview.id]).toBeUndefined()
     expect(Object.keys(h.layout().tabs)).toEqual([guide.id])
     expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0)
@@ -437,7 +437,7 @@ describe('slot-owned useTabInfo', () => {
     expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt')
     expect(h.controller.active()?.contentId).toBe(otherTab.contentId)
     expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 })
-    act(() => { info.tab.actions.close() })
+    act(() => { void info.tab.actions.close() })
     expect(info.tab.signal.aborted).toBe(true)
     expect(otherInfo.tab.signal.aborted).toBe(false)
     await h.runtime.sessions.setCurrent(SESSION)
@@ -579,3 +579,22 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', (
     expect(openTab).toHaveBeenCalledWith('guide', { paneId: PANE_1, revealIfOpened: false })
   })
 })
+
+it('keeps a resource tab and reports cleanup rejection from its close button', async () => {
+  const h = await mountSeat()
+  const tab = h.open('terminal')
+  const failure = new Error('process still running')
+  const release = h.controller.registerCloseHandler('text', async () => { throw failure })
+  const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
+  try {
+    fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`))
+    await expect.poll(() => logged.mock.calls).toEqual([['Sidebar tab close failed:', failure]])
+    expect(h.layout().tabs[tab.id]).toBeDefined()
+    release()
+    fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`))
+    expect(h.layout().tabs[tab.id]).toBeUndefined()
+  } finally {
+    logged.mockRestore()
+    release()
+  }
+})

+ 154 - 5
packages/client/ui-sidebar-right/tests/service.client.spec.ts

@@ -82,7 +82,7 @@ describe('SidebarRightController — opening', () => {
     const { controller } = harness()
     expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted')
     expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted')
-    expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted')
+    expect(() => { void controller.close('tab1' as TabId) }).toThrow('no session surface is mounted')
     expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted')
     expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted')
     expect(() => { controller.split() }).toThrow('no session surface is mounted')
@@ -283,7 +283,7 @@ describe('SidebarRightController — opening', () => {
     publish()
     controller.openResource('dsh-resource://file/session/s-test/a.txt')
     publish()
-    controller.close(tabOf('a.txt'))
+    void controller.close(tabOf('a.txt'))
     expect(titles()).not.toContain('a.txt')
   })
 
@@ -459,7 +459,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
     // session's closes this session's, aborts its occurrence at once although
     // no seat draws the session, and the other keeps its own.
     expect(otherSurface.layout.tabs[guide.id]).toBeDefined()
-    guideOccurrence.tabActions.close()
+    void guideOccurrence.tabActions.close()
     expect(layout().tabs[guide.id]).toBeUndefined()
     expect(guideOccurrence.signal.aborted).toBe(true)
     expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface)
@@ -479,14 +479,14 @@ describe('SidebarRightController — a tab\'s own actions', () => {
     const before = instance.getSnapshot().bySession
     tabActions.openResource(B_TXT)
     tabActions.openTab('guide')
-    tabActions.close()
+    void tabActions.close()
     expect(instance.getSnapshot().bySession).toBe(before)
     // Adopted, they land; released, they stop again.
     const release = adopt(SESSION, instance)
     tabActions.openResource(B_TXT)
     expect(titles()).toContain('b.txt')
     release()
-    tabActions.close()
+    void tabActions.close()
     expect(titles()).toContain('a.txt')
   })
 
@@ -578,3 +578,152 @@ describe('SidebarRightController — binding lifetime', () => {
     expect(() => { controller.toggleExpanded() }).not.toThrow()
   })
 })
+
+describe('explicit tab resource cleanup', () => {
+  it.each(['close', 'replace'] as const)('immediately removes the tab after a synchronous cleanup handler during %s', (operation) => {
+    const h = harness()
+    const release = h.adopt(SESSION, h.instance)
+    const unbind = h.publish()
+    h.controller.openResource('dsh-resource://file/session/s-test/terminal')
+    const tabId = h.tabOf('terminal')
+    const original = h.layout().tabs[tabId]
+    const handler = vi.fn(() => { expect(h.layout().tabs[tabId]).toBe(original) })
+    const unregister = h.controller.registerCloseHandler('text', handler)
+    try {
+      const before = h.entries()
+      if (operation === 'close') expect(h.controller.close(tabId)).toBeUndefined()
+      else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false })
+      expect(handler).toHaveBeenCalledExactlyOnceWith(SESSION, original)
+      expect(h.layout().tabs[tabId]).toBeUndefined()
+      expect(h.entries()).toBe(before + 1)
+      expect(h.controller.closeIn(SESSION, tabId)).toBeUndefined()
+      expect(handler).toHaveBeenCalledOnce()
+    } finally {
+      unregister()
+      unbind()
+      release()
+    }
+  })
+
+  it('awaits cleanup once before closing and never closes on collapse', async () => {
+    const h = harness()
+    h.adopt(SESSION, h.instance)
+    h.publish()
+    h.controller.openResource('dsh-resource://file/session/s-test/terminal')
+    h.publish()
+    const tabId = h.tabOf('terminal')
+    const cleanup = Promise.withResolvers<undefined>()
+    const handler = vi.fn(() => cleanup.promise)
+    h.controller.registerCloseHandler('text', handler)
+    h.controller.toggleExpanded()
+    expect(handler).not.toHaveBeenCalled()
+    const first = h.controller.closeIn(SESSION, tabId)
+    const second = h.controller.closeIn(SESSION, tabId)
+    expect(first).toBe(second)
+    expect(handler).toHaveBeenCalledOnce()
+    expect(h.layout().tabs[tabId]).toBeDefined()
+    cleanup.resolve(undefined)
+    await first
+    expect(h.layout().tabs[tabId]).toBeUndefined()
+  })
+
+  it('keeps the tab after failed cleanup and permits retry', async () => {
+    const h = harness()
+    h.adopt(SESSION, h.instance)
+    h.publish()
+    h.controller.openResource('dsh-resource://file/session/s-test/terminal')
+    h.publish()
+    const tabId = h.tabOf('terminal')
+    const handler = vi.fn().mockRejectedValueOnce(new Error('still running')).mockResolvedValue(undefined)
+    h.controller.registerCloseHandler('text', handler)
+    await expect(h.controller.closeIn(SESSION, tabId)).rejects.toThrow('still running')
+    expect(h.layout().tabs[tabId]).toBeDefined()
+    await h.controller.closeIn(SESSION, tabId)
+    expect(h.layout().tabs[tabId]).toBeUndefined()
+  })
+
+  it('awaits cleanup before replacing in a single history entry, and skips cleanup when revealing the same tab', async () => {
+    const h = harness()
+    h.adopt(SESSION, h.instance)
+    h.publish()
+    const address = 'dsh-resource://file/session/s-test/terminal'
+    h.controller.openResource(address)
+    h.publish()
+    const tabId = h.tabOf('terminal')
+    const cleanup = Promise.withResolvers<undefined>()
+    const handler = vi.fn(() => cleanup.promise)
+    h.controller.registerCloseHandler('text', handler)
+    h.controller.openResource(address, { replaceTab: tabId })
+    expect(handler).not.toHaveBeenCalled()
+    const before = h.entries()
+    h.controller.openTab('guide', { replaceTab: tabId })
+    expect(h.entries()).toBe(before)
+    expect(h.layout().tabs[tabId]).toBeDefined()
+    cleanup.resolve(undefined)
+    await expect.poll(() => h.layout().tabs[tabId]).toBeUndefined()
+    expect(h.entries()).toBe(before + 1)
+  })
+})
+
+it('releases close handlers without a stale disposer removing a replacement', async () => {
+  const h = harness()
+  h.adopt(SESSION, h.instance)
+  h.publish()
+  const first = vi.fn(async () => {})
+  const second = vi.fn(async () => {})
+  const release = h.controller.registerCloseHandler('text', first)
+  expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered')
+  release()
+  const releaseSecond = h.controller.registerCloseHandler('text', second)
+  release()
+  h.controller.openResource('dsh-resource://file/session/s-test/one')
+  h.publish()
+  await h.controller.closeIn(SESSION, h.tabOf('one'))
+  expect(first).not.toHaveBeenCalled()
+  expect(second).toHaveBeenCalledOnce()
+  releaseSecond()
+  h.controller.openResource('dsh-resource://file/session/s-test/two')
+  h.publish()
+  const tabId = h.tabOf('two')
+  h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false })
+  expect(h.layout().tabs[tabId]).toBeUndefined()
+  expect(second).toHaveBeenCalledOnce()
+})
+
+it.each(['adoption', 'occurrence'] as const)('does not commit a pending close after its %s ends', async (lifetime) => {
+  const h = harness()
+  const release = h.adopt(SESSION, h.instance)
+  h.publish()
+  h.controller.openResource('dsh-resource://file/session/s-test/terminal')
+  h.publish()
+  const tabId = h.tabOf('terminal')
+  const cleanup = Promise.withResolvers<undefined>()
+  h.controller.registerCloseHandler('text', () => cleanup.promise)
+  const pending = h.controller.closeIn(SESSION, tabId)
+  if (lifetime === 'adoption') release()
+  else h.instance.actions.closeTab(SESSION, tabId)
+  const entries = h.entries()
+  cleanup.resolve(undefined)
+  await pending
+  expect(h.entries()).toBe(entries)
+  if (lifetime === 'adoption') expect(h.layout().tabs[tabId]).toBeDefined()
+})
+
+it('reports failed replacement cleanup and retains the original resource', async () => {
+  const h = harness()
+  h.adopt(SESSION, h.instance)
+  h.publish()
+  h.controller.openResource('dsh-resource://file/session/s-test/terminal')
+  h.publish()
+  const tabId = h.tabOf('terminal')
+  const error = new Error('process cleanup failed')
+  h.controller.registerCloseHandler('text', async () => { throw error })
+  const reported = vi.spyOn(console, 'error').mockImplementation(() => {})
+  try {
+    h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false })
+    await expect.poll(() => reported.mock.calls).toEqual([['Sidebar tab replacement failed:', error]])
+    expect(h.layout().tabs[tabId]).toBeDefined()
+  } finally {
+    reported.mockRestore()
+  }
+})

+ 1 - 1
packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts

@@ -191,7 +191,7 @@ describe('TabDomain — a tab\'s own actions', () => {
     const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId))
     tabActions.openTab('files', { replaceTab: true })
     expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId })
-    tabActions.close()
+    void tabActions.close()
     expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId)
   })
 

+ 6 - 0
packages/client/ui-sidebar-terminal/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/client/ui-sidebar-terminal/README.md
+README.md: cf915dc73c9009b4b91d084d9b7aee466e4f2c75
+README.zh.md: a7aa321d106d49061ae7b092f6e116cab153627c

+ 80 - 0
packages/client/ui-sidebar-terminal/README.md

@@ -0,0 +1,80 @@
+---
+description: "Open, recover and control interactive shell tabs in the Web right sidebar."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-terminal
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Open the execution environment's default shell beside a conversation without leaving the Web app. Rename terminals in their tabs and recover retained processes after reloading the page. Collapse the sidebar to keep commands running; close a terminal tab to request process termination. Tab completion follows the shell configuration.
+
+## 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
+
+Open the right sidebar and select **New terminal** on the Start page; first select **New tab** when another content tab is open. The default shell starts immediately. Use the sidebar's **New tab** control to open more terminals.
+
+Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A failed connection offers **Reconnect**. An exited shell remains visible with its exit code and never restarts automatically.
+
+Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process.
+
+Displaying a Session after a page reload reopens its retained Host terminals as new tabs. A recovery failure offers **Retry terminal recovery**. A recovered process that disappears reports an error instead of starting another shell. The [sidebar layout remains memory-only](../../client/ui-sidebar-right/README.md#state).
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+This plugin registers the `terminal` type and body/title seats with the right sidebar. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
+
+A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work.
+
+</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>
+
+- The default shell or native PTY startup can fail. The tab reports the failure without launching a different shell.
+- Completion menus and inline suggestions depend on shell configuration. The Web UI adds no independent completion engine.
+- Terminal history is bounded. The feature does not send terminal output to the Agent, provide split terminal panes inside a tab, or restore processes after Host restart.
+
+<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>

+ 80 - 0
packages/client/ui-sidebar-terminal/README.zh.md

@@ -0,0 +1,80 @@
+---
+description: "在 Web 右侧栏打开、恢复和控制交互式 shell 标签页。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-terminal
+
+[English](README.md) | 中文
+
+## 概述
+
+在对话旁打开执行环境的默认 shell,无需离开 Web 应用。在标签页上重命名终端,并在刷新页面后恢复保留的进程。折叠侧栏让命令继续运行,关闭终端标签页则请求结束进程。Tab 补全使用 shell 的配置。
+
+## 目录
+
+- [使用此包](#use-this-package)
+- [了解实现](#understand-the-implementation)
+- [延伸阅读](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用此包
+
+展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。
+
+双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。
+
+关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。
+
+刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。
+
+<a id="understand-the-implementation"></a>
+## 了解实现
+
+<details>
+<summary>实现细节</summary>
+
+插件向右侧栏注册 `terminal` 类型及正文和标题 seat。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
+
+Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
+
+</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>
+
+- 默认 shell 或原生 PTY 可能启动失败。标签页显示错误,不启动其他 shell。
+- 补全菜单和内联建议取决于 shell 配置,Web UI 不提供独立补全引擎。
+- 终端历史有上限。此功能不向 Agent 发送终端输出,不在单个标签页内拆分终端,也不在 Host 重启后恢复进程。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护说明</summary>
+
+不发布运行时 invariant companion。终端元数据与屏幕更新由同一对象按序写入,没有独立的进程尺寸观测可供比较。
+
+</details>

+ 76 - 0
packages/client/ui-sidebar-terminal/package.json

@@ -0,0 +1,76 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal",
+  "description": "Interactive shell tabs for the right Sidebar",
+  "version": "0.1.5-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-sidebar-terminal"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-terminal-controller",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
+        "@deepseek-ai/dsh-api-remotes"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
+    "react": "^18.2.0",
+    "clsx": "^2.0.0",
+    "react-dom": "^18.2.0",
+    "@deepseek-ai/dsh-api-terminal-controller": "workspace:^",
+    "@xterm/xterm": "^6.0.0",
+    "@xterm/addon-fit": "^0.11.0",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 9 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -0,0 +1,9 @@
+.root { display: flex; flex: 1; flex-direction: column; box-sizing: border-box; height: 100%; min-height: 0; padding-top: 8px; color: var(--dsw-alias-label-primary); font-size: 13px; }
+.title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.name { width: 120px; min-width: 48px; max-width: 100%; padding: 0 4px; color: inherit; font: inherit; background: var(--dsw-alias-bg-l1); border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; outline: none; }
+.screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; background: #17191d; }
+.screen :global(.xterm) { height: 100%; }
+.status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); }
+.root button { padding: 6px 10px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
+.root button:hover { background: var(--dsw-alias-bg-l2); }
+.error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }

+ 126 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx

@@ -0,0 +1,126 @@
+/** Sidebar terminal screen and connection recovery. */
+import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
+import { Terminal } from '@xterm/xterm'
+import { FitAddon } from '@xterm/addon-fit'
+import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { TerminalInjected } from './face.ts'
+import type {} from './locales.ts'
+import '@xterm/xterm/css/xterm.css'
+import css from './TerminalBody.module.css'
+
+/** Standard sidebar owner share plus terminal model and localized copy. */
+export type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalInjected>
+
+/**
+ * Mount an automatically started or restored terminal.
+ * @param props - sidebar occurrence, model lookup and translated copy.
+ * @returns the terminal screen and any pending or exceptional state.
+ */
+export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyProps): ReactNode {
+  const { tab } = useTabInfo()
+  const model = view(tab.id)
+  const state = useTerminal(tab.id)
+  useEffect(() => model.mount(), [model])
+  if (state === undefined) return null
+  const error = state.error ?? state.info?.error
+  let status: string | undefined
+  if (state.phase === 'idle' || state.phase === 'loading') status = t('loading')
+  else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase)
+  else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') })
+  else if (state.info?.state === 'failed') status = t('unavailable')
+  else if (state.phase === 'closed') status = t('closed')
+  const retry = state.phase === 'failed' || state.phase === 'disconnected'
+  const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
+  return (
+    <section className={css.root} data-sidebar-terminal>
+      {(status !== undefined || retry || readOnly) && <div className={css.status} role="status">
+        {status}
+        {readOnly && <>{t('readonly')} <button type="button" onClick={() => { model.connect() }}>{t('control')}</button></>}
+        {retry && (state.info === undefined
+          ? <button type="button" onClick={() => { void model.refresh() }}>{t('retry')}</button>
+          : <button type="button" onClick={() => { model.connect() }}>{t('reconnect')}</button>)}
+      </div>}
+      {state.info !== undefined && <TerminalScreen state={state} model={model} visible={tab.visible} label={t('title')} />}
+      {error !== undefined && <p className={css.error} role="alert">{t('failed', { message: error })}</p>}
+    </section>
+  )
+}
+
+/* oxlint-disable typescript/no-non-null-assertion -- React sets the DOM ref, then these effects initialize and use the emulator. */
+function TerminalScreen({ state, model, visible, label }: {
+  state: TerminalViewState
+  model: TerminalView
+  visible: boolean
+  label: string
+}): ReactNode {
+  const element = useRef<HTMLDivElement>(null)
+  const terminal = useRef<Terminal>()
+  const fit = useRef<FitAddon>()
+  const lastRevision = useRef(0)
+  const current = useRef({ state, visible })
+  current.current = { state, visible }
+
+  useLayoutEffect(() => {
+    const node = element.current!
+    const xterm = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollback: current.current.state.environment?.scrollback ?? 0, theme: { background: '#17191d', foreground: '#e7e9ee' } })
+    const addon = new FitAddon()
+    xterm.loadAddon(addon)
+    xterm.open(node)
+    xterm.textarea?.setAttribute('aria-label', label)
+    terminal.current = xterm
+    fit.current = addon
+    lastRevision.current = 0
+    const input = xterm.onData((data) => { model.write(data) })
+    const measure = (): void => {
+      if (!current.current.visible || !current.current.state.writable || node.clientWidth === 0 || node.clientHeight === 0) return
+      fitScreen(xterm, addon, current.current.state, model)
+    }
+    const observer = new ResizeObserver(measure)
+    observer.observe(node)
+    return () => {
+      observer.disconnect()
+      input.dispose()
+      xterm.dispose()
+      terminal.current = undefined
+      fit.current = undefined
+    }
+  }, [model])
+
+  useLayoutEffect(() => {
+    const xterm = terminal.current!
+    const render = state.render
+    if (render === undefined || render.revision <= lastRevision.current) return
+    lastRevision.current = render.revision
+    if (render.frame.type === 'snapshot') {
+      xterm.reset()
+      xterm.resize(render.frame.info.cols, render.frame.info.rows)
+    }
+    xterm.write(render.frame.type === 'snapshot' ? render.frame.screen : render.frame.data, () => { model.acknowledge(render.revision) })
+  }, [state.render, model])
+
+  useLayoutEffect(() => {
+    const xterm = terminal.current!
+    xterm.options.disableStdin = !state.writable
+    if (visible && state.writable && element.current?.clientWidth && element.current.clientHeight) {
+      fitScreen(xterm, fit.current!, state, model)
+    } else if (state.info !== undefined && !state.writable) xterm.resize(state.info.cols, state.info.rows)
+  }, [visible, state.writable, state.info?.cols, state.info?.rows, model])
+  useEffect(() => {
+    terminal.current!.textarea?.setAttribute('aria-label', label)
+  }, [label])
+  useEffect(() => { if (visible && state.writable) terminal.current!.focus() }, [visible, state.writable])
+  return <div className={css.screen} ref={element} />
+}
+/* oxlint-enable typescript/no-non-null-assertion */
+
+function fitScreen(xterm: Terminal, fit: FitAddon, state: TerminalViewState, model: TerminalView): void {
+  const dimensions = fit.proposeDimensions()
+  const environment = state.environment
+  if (dimensions === undefined || environment === undefined) return
+  const cols = Math.min(dimensions.cols, environment.maxCols)
+  const rows = Math.min(dimensions.rows, environment.maxRows)
+  if (cols < 2 || rows < 1) return
+  xterm.resize(cols, rows)
+  model.resize(cols, rows)
+}

+ 3 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.module.css

@@ -0,0 +1,3 @@
+.stack { position: fixed; right: 20px; bottom: 20px; display: grid; gap: 8px; max-width: min(440px, calc(100vw - 40px)); pointer-events: auto; }
+.notice { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l1); box-shadow: 0 4px 20px #0002; font-size: 13px; overflow-wrap: anywhere; }
+.notice button { flex-shrink: 0; padding: 4px 8px; border: 1px solid var(--dsw-alias-border-l3); border-radius: 4px; color: inherit; background: transparent; cursor: pointer; }

+ 29 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalCleanup.tsx

@@ -0,0 +1,29 @@
+/** Failed background cleanup remains actionable after the originating tab disappears. */
+import type { ReactNode } from 'react'
+import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from './locales.ts'
+import css from './TerminalCleanup.module.css'
+
+/** The model owns cleanup; this seat only shows failures and requests retries. */
+export interface TerminalCleanupInjected {
+  readonly hooks: { readonly closeFailures: HostObservable<readonly TerminalCloseFailure[]> }
+  /** @param id - failed cleanup identity. */
+  readonly retryClose: (id: WebTerminalId) => void
+}
+
+/**
+ * Render failed cleanup tasks without recreating or blocking any sidebar tab.
+ * @param props - root overlay hooks, retry command and localized copy.
+ * @returns a compact alert stack, empty when no close has failed.
+ */
+export function TerminalCleanup({ useCloseFailures, retryClose, t }: PropsRuntime<'shell.overlay'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalCleanupInjected>): ReactNode {
+  const failures = useCloseFailures(value => value)
+  if (failures.length === 0) return null
+  return <div className={css.stack}>{failures.map(failure => <div key={failure.id} className={css.notice} role="alert">
+    <span>{t('cleanupFailed', { title: failure.title, message: failure.message })}</span>
+    <button type="button" onClick={() => { retryClose(failure.id) }}>{t('retry')}</button>
+  </div>)}</div>
+}

+ 9 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx

@@ -0,0 +1,9 @@
+/** Terminal prompt glyph shared by the guide and tab title. */
+import type { ReactNode } from 'react'
+
+/** Render the terminal's decorative prompt icon. @returns a sixteen-pixel glyph. */
+export function TerminalIcon(): ReactNode {
+  return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+    <path d="m3 4 4 4-4 4M9 12h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+  </svg>
+}

+ 29 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalRecovery.tsx

@@ -0,0 +1,29 @@
+/** A Session header lifetime restores retained Host terminals without saving sidebar layout. */
+import { useEffect, useState, type ReactNode } from 'react'
+import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from './locales.ts'
+
+/** The plugin coordinates once-per-page recovery; the component only owns an error notice. */
+export interface TerminalRecoveryInjected {
+  /** @returns after retained terminals have been opened as sidebar tabs. */
+  readonly restore: () => Promise<void>
+}
+
+/**
+ * Restore terminals when a Session is displayed, with a retry action on lookup failure.
+ * @param props - Session header lifetime, restoration callback and localized copy.
+ * @returns nothing on success, or an unobtrusive retry control.
+ */
+export function TerminalRecovery({ restore, t }: PropsRuntime<'conversation.session.header.actions'> & PropsLocale<'sidebarTerminal'> & TerminalRecoveryInjected): ReactNode {
+  const [error, setError] = useState<string>()
+  const [attempt, setAttempt] = useState(0)
+  useEffect(() => {
+    let active = true
+    void restore().then(() => { if (active) setError(undefined) }, (reason: unknown) => {
+      if (active) setError(reason instanceof Error ? reason.message : String(reason))
+    })
+    return () => { active = false }
+  }, [restore, attempt])
+  return error === undefined ? null : <button type="button" title={t('recoveryFailed', { message: error })} onClick={() => { setError(undefined); setAttempt(value => value + 1) }}>{t('retryRecovery')}</button>
+}

+ 60 - 0
packages/client/ui-sidebar-terminal/src/client/TerminalTitle.tsx

@@ -0,0 +1,60 @@
+/** Live terminal names in docked and floating tab chrome. */
+import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { TerminalIcon } from './TerminalIcon.tsx'
+import type { TerminalInjected } from './face.ts'
+import type {} from './locales.ts'
+import css from './TerminalBody.module.css'
+
+/**
+ * Render the terminal name, editable in place on double-click.
+ * @param props - sidebar occurrence, terminal model and localized copy.
+ * @returns the terminal icon and current name or its editor.
+ */
+export function TerminalTitle({ useTabInfo, useTerminal, view, t }: PropsRuntime<'sidebar.right.pane.tab.title'> & PropsLocale<'sidebarTerminal'> & InjectFace<TerminalInjected>): ReactNode {
+  const { tab } = useTabInfo()
+  const title = useTerminal(tab.id, state => state?.info?.title ?? state?.title) ?? tab.title
+  const [editing, setEditing] = useState(false)
+  const input = useRef<HTMLInputElement>(null)
+  const label = useRef<HTMLSpanElement>(null)
+  const cancelled = useRef(false)
+  useLayoutEffect(() => {
+    if (editing) return
+    // Dockkit captures the pointer on its drag handle, which receives the double-click.
+    const chip = label.current?.closest('[data-dockkit-tab], [data-dockkit-float-grip]')
+    const rename = (event: Event): void => {
+      event.stopPropagation()
+      cancelled.current = false
+      setEditing(true)
+    }
+    chip?.addEventListener('dblclick', rename)
+    return () => { chip?.removeEventListener('dblclick', rename) }
+  }, [editing])
+  useLayoutEffect(() => {
+    if (!editing) return
+    input.current?.focus()
+    input.current?.select()
+  }, [editing])
+  return <>
+    <TerminalIcon />
+    {editing ? <input ref={input} className={css.name} defaultValue={title} maxLength={120} aria-label={t('rename')}
+      onPointerDown={(event) => { event.stopPropagation() }}
+      onClick={(event) => { event.stopPropagation() }}
+      onDoubleClick={(event) => { event.stopPropagation() }}
+      onBlur={(event) => {
+        setEditing(false)
+        const next = event.currentTarget.value.trim()
+        if (!cancelled.current && next !== '' && next !== title) void view(tab.id).rename(next)
+      }}
+      onKeyDown={(event) => {
+        event.stopPropagation()
+        // oxlint-disable-next-line typescript/no-deprecated -- Some IMEs report composition only through keyCode 229.
+        if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return
+        if (event.key === 'Escape') {
+          cancelled.current = true
+          event.currentTarget.blur()
+        } else if (event.key === 'Enter') event.currentTarget.blur()
+      }} />
+      : <span ref={label} className={css.title}>{title}</span>}
+  </>
+}

+ 19 - 0
packages/client/ui-sidebar-terminal/src/client/face.ts

@@ -0,0 +1,19 @@
+/** Injected terminal commands and keyed observable state. */
+import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import type { TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+
+/** The terminal's React-free model is resolved by sidebar occurrence. */
+export interface TerminalInjected {
+  /** @param key - sidebar occurrence key. @returns its terminal commands. */
+  readonly view: (key: string) => TerminalView
+  readonly keyedHooks: { readonly terminal: (key: string) => HostObservable<TerminalViewState> }
+}
+
+
+declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
+  interface SidebarRightTabParamsMap {
+    /** An existing Host terminal selected from the Session terminal list. */
+    terminal: { terminalId: WebTerminalId }
+  }
+}

+ 81 - 0
packages/client/ui-sidebar-terminal/src/client/index.ts

@@ -0,0 +1,81 @@
+/** Register interactive terminal tabs and explicit process cleanup with the sidebar. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import { TerminalIcon } from './TerminalIcon.tsx'
+import { TerminalBody } from './TerminalBody.tsx'
+import { TerminalTitle } from './TerminalTitle.tsx'
+import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
+import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx'
+import type { TerminalInjected } from './face.ts'
+import { en, zh } from './locales.ts'
+
+/** Services needed by the terminal's two sidebar seats. */
+export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals']
+
+/**
+ * Register the terminal type, observable views and background process cleanup.
+ * @param ctx - Client root Context with sidebar and terminal services.
+ */
+export function apply(ctx: Context): void {
+  let disposed = false
+  const recovered = new Map<SessionId, Promise<void>>()
+  ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime')
+  const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => {
+    const params = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params
+    return (params as { terminalId?: WebTerminalId } | undefined)?.terminalId
+  }
+  const view = (sessionId: SessionId, key: string) => ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key))
+  const namespace = 'sidebarTerminal'
+  const id = '@deepseek-ai/dsh-client-ui-sidebar-terminal'
+  const t = ctx.locale.bind(namespace)
+  ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy')
+  ctx.effect(() => ctx.sidebarRightTabs.register({
+    id, kind: 'terminal', priority: 'builtin', title: () => t('title'),
+    guide: [{ order: 20, title: () => t('new'), icon: TerminalIcon, revealIfOpened: false }],
+  }), 'ui-sidebar-terminal.type')
+  ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => {
+    ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id))
+  }), 'ui-sidebar-terminal.close')
+  const inject = (sessionId: SessionId): TerminalInjected => ({
+    view: key => view(sessionId, key),
+    keyedHooks: { terminal: key => view(sessionId, key).state },
+  })
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab', key: id, locale: namespace, inject }, TerminalBody,
+  )), 'ui-sidebar-terminal.body')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle,
+  )), 'ui-sidebar-terminal.title')
+  ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
+    name: 'conversation.session.header.actions', id, locale: namespace,
+    inject: (sessionId): TerminalRecoveryInjected => ({
+      restore: () => {
+        let pending = recovered.get(sessionId)
+        if (pending === undefined) {
+          pending = ctx.webTerminals.recover(sessionId).then((terminals) => {
+            if (disposed) return
+            for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', {
+              revealIfOpened: false, params: { terminalId: info.id },
+            })
+          }).catch((error: unknown) => { recovered.delete(sessionId); throw error })
+          recovered.set(sessionId, pending)
+        }
+        return pending
+      },
+    }),
+  }, TerminalRecovery)), 'ui-sidebar-terminal.recovery')
+  ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+    name: 'shell.overlay', id, locale: namespace,
+    inject: (): TerminalCleanupInjected => ({
+      hooks: { closeFailures: ctx.webTerminals.closeFailures },
+      retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) },
+    }),
+  }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup')
+}

+ 30 - 0
packages/client/ui-sidebar-terminal/src/client/locales.ts

@@ -0,0 +1,30 @@
+/** Copy owned by the sidebar terminal feature. */
+import type {} from '@deepseek-ai/dsh-client-ui-slots'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    sidebarTerminal: keyof typeof zh
+  }
+}
+
+/** Simplified Chinese terminal copy. */
+export const zh = {
+  recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端',
+  title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…',
+  connecting: '正在连接…', disconnected: '连接已断开。', reconnect: '重新连接',
+  readonly: '此页面当前只读。', control: '接管输入',
+  closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}',
+  rename: '终端名称', unavailable: '不可用', retry: '重试',
+  cleanupFailed: '终端「{title}」未能结束:{message}',
+} satisfies Record<string, string>
+
+/** English terminal copy. */
+export const en = {
+  recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery',
+  title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…',
+  connecting: 'Connecting…', disconnected: 'Disconnected.', reconnect: 'Reconnect',
+  readonly: 'This view is read-only.', control: 'Take control',
+  closed: 'Terminal closed.', exited: 'Process exited ({code})', failed: 'Terminal error: {message}',
+  rename: 'Terminal name', unavailable: 'Unavailable', retry: 'Retry',
+  cleanupFailed: 'Terminal “{title}” could not be ended: {message}',
+} satisfies Record<keyof typeof zh, string>

+ 6 - 0
packages/client/ui-sidebar-terminal/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-sidebar-terminal/src/index.ts

@@ -0,0 +1,4 @@
+/** Host companion for the interactive terminal Client plugin. */
+
+/** Mount the browser-only plugin through the Client loader. */
+export function apply(): void {}

+ 175 - 0
packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts

@@ -0,0 +1,175 @@
+// @vitest-environment jsdom
+/** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */
+import { Context } from '@deepseek-ai/cordis'
+import { expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { apply, inject } from '../src/client/index.ts'
+import { apply as hostApply } from '../src/index.ts'
+import { TerminalBody } from '../src/client/TerminalBody.tsx'
+import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
+import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx'
+import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx'
+import type { TerminalInjected } from '../src/client/face.ts'
+import { en, zh } from '../src/client/locales.ts'
+
+vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() }))
+
+const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null })
+
+async function mountPlugin() {
+  const ctx = new Context()
+  const tabs = new SidebarRightTabRegistry(ctx)
+  ctx.provide('sidebarRightTabs', tabs)
+  const entries: {
+    name: string
+    key?: string
+    id?: string
+    locale: string
+    component: unknown
+    inject: (id: SessionId) => unknown
+  }[] = []
+  const dictionaries = new Map<string, unknown>()
+  let closeHandler: SidebarRightCloseHandler | undefined
+  const model = { state: {} }
+  const terminals = {
+    view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(),
+    recover: vi.fn(async (_sessionId: SessionId): Promise<WebTerminalInfo[]> => []),
+  }
+  let params: { terminalId: WebTerminalId } | undefined
+  const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params }) } }))
+  const openTabIn = vi.fn()
+  ctx.provide('webTerminals', terminals as never)
+  ctx.provide('sidebarRight', {
+    tabDomain: { occurrence }, openTabIn,
+    registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } },
+  } as never)
+  ctx.provide('slots', {
+    inject: (_name: string, register: () => () => void) => register(),
+    register: (options: Omit<typeof entries[number], 'component'>, component: unknown) => { const entry = { ...options, component }; entries.push(entry); return () => { entries.splice(entries.indexOf(entry), 1) } },
+  } as never)
+  ctx.provide('locale', {
+    bind: () => (key: string) => key,
+    register: (name: string, values: unknown) => { dictionaries.set(name, values); return () => { dictionaries.delete(name) } },
+  } as never)
+  const fiber = await ctx.plugin({ inject, apply })
+  return {
+    tabs, entries, dictionaries, terminals, model, occurrence, openTabIn,
+    get closeHandler() { return closeHandler },
+    setParams(next: typeof params) { params = next },
+    async dispose() { await fiber.dispose(); await ctx.fiber.dispose() },
+  }
+}
+
+it('registers terminal views, recovery and cleanup, then releases every contribution on unload', async () => {
+  expect(hostApply).not.toThrow()
+  const h = await mountPlugin()
+  try {
+    const definition = h.tabs.get('terminal')!
+    expect(definition.title('sidebar://terminal')).toBe('title')
+    expect(definition.guide?.map(entry => [entry.order, entry.title(), entry.revealIfOpened])).toEqual([[20, 'new', false]])
+    expect(h.dictionaries.get('sidebarTerminal')).toEqual({ en, zh })
+    expect(h.entries.map(entry => [entry.name, entry.component, entry.locale])).toEqual([
+      ['sidebar.right.pane.tab', TerminalBody, 'sidebarTerminal'],
+      ['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'],
+      ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'],
+      ['shell.overlay', TerminalCleanup, 'sidebarTerminal'],
+    ])
+    const sessionId = 'session' as SessionId
+    const face = h.entries[0]!.inject(sessionId) as TerminalInjected
+    expect(face.view('tab')).toBe(h.model)
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined)
+    const terminalId = 'retained' as WebTerminalId
+    h.setParams({ terminalId })
+    expect(face.keyedHooks.terminal('tab')).toBe(h.model.state)
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId)
+    expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' })
+    if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered')
+    expect(h.closeHandler(sessionId, { id: 'tab' } as Parameters<SidebarRightCloseHandler>[1])).toBeUndefined()
+    expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId)
+    h.setParams(undefined)
+    expect(h.closeHandler(sessionId, { id: 'new-tab' } as Parameters<SidebarRightCloseHandler>[1])).toBeUndefined()
+    expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined)
+    const cleanupFace = h.entries[3]!.inject(sessionId) as TerminalCleanupInjected
+    expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures)
+    cleanupFace.retryClose('terminal' as Parameters<TerminalCleanupInjected['retryClose']>[0])
+    expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal')
+  } finally {
+    await h.dispose()
+  }
+  expect(h.closeHandler).toBeUndefined()
+  expect(h.tabs.get('terminal')).toBeUndefined()
+  expect(h.entries).toEqual([])
+  expect(h.dictionaries.size).toBe(0)
+})
+
+it('shares pending and completed recovery across Session headers and opens each returned terminal once', async () => {
+  const h = await mountPlugin()
+  const pending = Promise.withResolvers<WebTerminalInfo[]>()
+  h.terminals.recover.mockImplementationOnce(() => pending.promise)
+  const sessionId = 'session' as SessionId
+  const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected
+  const remounted = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected
+  const completion = recovery.restore()
+  try {
+    expect(remounted.restore()).toBe(completion)
+    expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId)
+    expect(h.openTabIn).not.toHaveBeenCalled()
+    pending.resolve([terminalInfo('build'), terminalInfo('tests')])
+    await completion
+    expect(h.openTabIn.mock.calls).toEqual([
+      [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'build' } }],
+      [sessionId, 'terminal', { revealIfOpened: false, params: { terminalId: 'tests' } }],
+    ])
+    await remounted.restore()
+    expect(h.terminals.recover).toHaveBeenCalledTimes(1)
+    expect(h.openTabIn).toHaveBeenCalledTimes(2)
+    const otherSession = 'other-session' as SessionId
+    await (h.entries[2]!.inject(otherSession) as TerminalRecoveryInjected).restore()
+    expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession)
+    expect(h.terminals.recover).toHaveBeenCalledTimes(2)
+    expect(h.openTabIn).toHaveBeenCalledTimes(2)
+  } finally {
+    pending.resolve([])
+    await completion
+    await h.dispose()
+  }
+})
+
+it('allows a failed Host lookup to be retried without marking the Session recovered', async () => {
+  const h = await mountPlugin()
+  const unavailable = new Error('Host unavailable')
+  h.terminals.recover.mockRejectedValueOnce(unavailable)
+  const sessionId = 'session' as SessionId
+  const recovery = h.entries[2]!.inject(sessionId) as TerminalRecoveryInjected
+  try {
+    await expect(recovery.restore()).rejects.toBe(unavailable)
+    expect(h.openTabIn).not.toHaveBeenCalled()
+    await recovery.restore()
+    await recovery.restore()
+    expect(h.terminals.recover).toHaveBeenCalledTimes(2)
+  } finally {
+    await h.dispose()
+  }
+})
+
+it('does not open retained terminals when their lookup completes after plugin unload', async () => {
+  const h = await mountPlugin()
+  const pending = Promise.withResolvers<WebTerminalInfo[]>()
+  h.terminals.recover.mockImplementationOnce(() => pending.promise)
+  const recovery = h.entries[2]!.inject('session' as SessionId) as TerminalRecoveryInjected
+  const completion = recovery.restore()
+  try {
+    expect(h.terminals.recover).toHaveBeenCalledOnce()
+    await h.dispose()
+    pending.resolve([terminalInfo('build')])
+    await completion
+    expect(h.openTabIn).not.toHaveBeenCalled()
+  } finally {
+    pending.resolve([])
+    await completion
+    await h.dispose()
+  }
+})

+ 53 - 0
packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx

@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+/** Cleanup errors remain reachable after the terminal tab has disappeared. */
+import { afterEach, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, within } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+type CleanupProps = Parameters<typeof TerminalCleanup>[0]
+const unusedHook = (): never => { throw new Error('Terminal cleanup does not consume global hooks') }
+const standard: Omit<CleanupProps, 'useCloseFailures' | 'retryClose' | 't'> = {
+  useSessions: unusedHook, useSessionPendingInteraction: unusedHook, usePanelInfo: unusedHook,
+  useWorkspaces: unusedHook, useResource: unusedHook,
+}
+
+it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => {
+  let failures: readonly TerminalCloseFailure[] = []
+  const retryClose = vi.fn<CleanupProps['retryClose']>()
+  const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures)
+  const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) }
+  const view = render(<TerminalCleanup {...props} />)
+  expect(view.container.childElementCount).toBe(0)
+  failures = [{ id: 'terminal' as WebTerminalId, title: 'Build', message: 'Host unavailable' }]
+  view.rerender(<TerminalCleanup {...props} />)
+  expect(view.getByRole('alert').textContent).toContain('Terminal “Build” could not be ended: Host unavailable')
+  fireEvent.click(view.getByRole('button', { name: en.retry }))
+  expect(retryClose).toHaveBeenCalledWith('terminal')
+  failures = []
+  view.rerender(<TerminalCleanup {...props} />)
+  expect(view.container.childElementCount).toBe(0)
+})
+
+it('keeps cleanup failures separate and retries the terminal selected by the user', () => {
+  const failures: readonly TerminalCloseFailure[] = [
+    { id: 'build' as WebTerminalId, title: 'Build', message: 'Host unavailable' },
+    { id: 'tests' as WebTerminalId, title: 'Tests', message: 'Process did not exit' },
+  ]
+  const retryClose = vi.fn<CleanupProps['retryClose']>()
+  const useCloseFailures: CleanupProps['useCloseFailures'] = selector => selector(failures)
+  const props: CleanupProps = { ...standard, useCloseFailures, retryClose, t: makeTranslate(en) }
+  const view = render(<TerminalCleanup {...props} />)
+  const notices = view.getAllByRole('alert')
+  expect(notices.map(notice => notice.textContent)).toEqual([
+    'Terminal “Build” could not be ended: Host unavailableRetry',
+    'Terminal “Tests” could not be ended: Process did not exitRetry',
+  ])
+  fireEvent.click(within(notices[1]!).getByRole('button', { name: en.retry }))
+  expect(retryClose).toHaveBeenCalledExactlyOnceWith('tests')
+})

+ 83 - 0
packages/client/ui-sidebar-terminal/tests/recovery.client.spec.tsx

@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+/** Session terminal recovery hides ordinary work and keeps failed lookup retryable. */
+import { afterEach, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { TerminalRecovery } from '../src/client/TerminalRecovery.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+
+function props(restore: () => Promise<void>): Parameters<typeof TerminalRecovery>[0] {
+  // The renderer supplies Session shares; recovery needs only the injected callback and copy.
+  return { restore, t: makeTranslate(en) } as Parameters<typeof TerminalRecovery>[0]
+}
+
+it('restores on mount without adding controls during a successful lookup', async () => {
+  const pending = Promise.withResolvers<undefined>()
+  const restore = vi.fn(() => pending.promise)
+  const view = render(<TerminalRecovery {...props(restore)} />)
+  try {
+    expect(restore).toHaveBeenCalledOnce()
+    expect(view.container.childElementCount).toBe(0)
+    await act(async () => { pending.resolve(undefined); await pending.promise })
+    view.rerender(<TerminalRecovery {...props(restore)} />)
+    expect(restore).toHaveBeenCalledOnce()
+    expect(view.container.childElementCount).toBe(0)
+  } finally {
+    await act(async () => { pending.resolve(undefined); await pending.promise })
+  }
+})
+
+it.each([new Error('Host unavailable'), 'Host unavailable'])('offers recovery retry for a failed lookup: %s', async (reason) => {
+  const pending = Promise.withResolvers<undefined>()
+  const restore = vi.fn<() => Promise<void>>().mockRejectedValueOnce(reason).mockImplementationOnce(() => pending.promise)
+  const view = render(<TerminalRecovery {...props(restore)} />)
+  try {
+    const retry = await view.findByRole('button', { name: en.retryRecovery })
+    expect(retry.getAttribute('title')).toBe('Terminal recovery failed: Host unavailable')
+    fireEvent.click(retry)
+    expect(restore).toHaveBeenCalledTimes(2)
+    expect(view.container.childElementCount).toBe(0)
+    await act(async () => { pending.resolve(undefined); await pending.promise })
+    expect(view.container.childElementCount).toBe(0)
+  } finally {
+    await act(async () => { pending.resolve(undefined); await pending.promise })
+  }
+})
+
+it('keeps the current Session failure when an earlier Session lookup later succeeds', async () => {
+  const pending = Promise.withResolvers<undefined>()
+  const previous = vi.fn(() => pending.promise)
+  const current = vi.fn(async () => { throw new Error('Current Session failed') })
+  const view = render(<TerminalRecovery {...props(previous)} />)
+  try {
+    view.rerender(<TerminalRecovery {...props(current)} />)
+    const retry = await view.findByRole('button', { name: en.retryRecovery })
+    expect(retry.getAttribute('title')).toContain('Current Session failed')
+    await act(async () => { pending.resolve(undefined); await pending.promise })
+    expect(view.getByRole('button', { name: en.retryRecovery }).getAttribute('title')).toContain('Current Session failed')
+  } finally {
+    await act(async () => { pending.resolve(undefined); await pending.promise })
+  }
+})
+
+it('ignores an earlier Session failure after a replacement Session recovers', async () => {
+  const pending = Promise.withResolvers<undefined>()
+  const previous = vi.fn(() => pending.promise)
+  const completed = Promise.resolve()
+  const current = vi.fn(() => completed)
+  const view = render(<TerminalRecovery {...props(previous)} />)
+  try {
+    view.rerender(<TerminalRecovery {...props(current)} />)
+    await act(async () => { await completed })
+    await act(async () => {
+      pending.reject(new Error('Previous Session failed'))
+      await expect(pending.promise).rejects.toThrow('Previous Session failed')
+    })
+    expect(view.container.childElementCount).toBe(0)
+  } finally {
+    pending.resolve(undefined)
+    await Promise.allSettled([pending.promise])
+  }
+})

+ 299 - 0
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -0,0 +1,299 @@
+// @vitest-environment jsdom
+/** Terminal startup, title editing and xterm's screen lifetime. */
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import type { TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
+import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
+import { TerminalBody, type TerminalBodyProps } from '../src/client/TerminalBody.tsx'
+import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
+import { en, zh } from '../src/client/locales.ts'
+
+const fake = vi.hoisted(() => ({
+  terminals: [] as FakeTerminal[],
+  dimensions: { cols: 120, rows: 40 } as { cols: number; rows: number } | undefined,
+}))
+class FakeTerminal {
+  options: { disableStdin?: boolean }
+  textarea: HTMLTextAreaElement | undefined = document.createElement('textarea')
+  input: ((data: string) => void) | undefined
+  readonly disposeInput = vi.fn()
+  readonly resize = vi.fn()
+  readonly reset = vi.fn()
+  readonly focus = vi.fn()
+  readonly dispose = vi.fn()
+  readonly write = vi.fn((_data: string, callback: () => void) => { callback() })
+  readonly loadAddon = vi.fn()
+  constructor(options: object) { this.options = options; fake.terminals.push(this) }
+  open(node: HTMLElement) { node.appendChild(this.textarea!) }
+  onData(input: (data: string) => void) { this.input = input; return { dispose: this.disposeInput } }
+}
+vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn(function (options: object) { return new FakeTerminal(options) }) }))
+vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { proposeDimensions() { return fake.dimensions } } }))
+
+let measure: (() => void) | undefined
+const disconnect = vi.fn()
+let boxWidth = 800
+let boxHeight = 600
+beforeEach(() => {
+  fake.terminals.length = 0
+  fake.dimensions = { cols: 120, rows: 40 }
+  boxWidth = 800
+  boxHeight = 600
+  vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => boxWidth)
+  vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => boxHeight)
+  vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { measure = callback } observe() {} disconnect = disconnect })
+})
+afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); disconnect.mockClear(); measure = undefined })
+
+const info = { id: 'terminal' as WebTerminalId, title: 'bash', shell: { path: '/bin/bash', name: 'bash', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running' as const, exitCode: null }
+const environment = { cwd: '/workspace', maxCols: 200, maxRows: 100, scrollback: 1000, maxInputBytes: 1000 }
+const idle: TerminalViewState = { phase: 'idle', writable: false, environment }
+const titleSurfaces = [
+  {
+    name: 'docked tab', selector: '[data-dockkit-tab]',
+    wrap: (title: ReactNode) => <div role="tab" tabIndex={0} aria-selected data-dockkit-tab="tab"><span>{title}</span></div>,
+  },
+  {
+    name: 'floating grip', selector: '[data-dockkit-float-grip]',
+    wrap: (title: ReactNode) => <header data-dockkit-float-grip="pane"><div data-dockkit-float-title><span>{title}</span></div></header>,
+  },
+]
+function mount(initial: TerminalViewState | undefined = idle) {
+  let state: TerminalViewState | undefined = initial
+  let visible = true
+  const detach = vi.fn()
+  const model = {
+    mount: vi.fn(() => detach), refresh: vi.fn(async () => {}),
+    rename: vi.fn(async () => {}), connect: vi.fn(), write: vi.fn(), resize: vi.fn(), acknowledge: vi.fn(),
+  }
+  const openTab = vi.fn()
+  const tab = () => ({ tab: { id: 'tab', title: 'Terminal', visible, actions: { openTab } } })
+  // The test supplies the owner and model hooks consumed here; the remaining slot props are framework-owned.
+  const props = {
+    view: () => model,
+    useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state),
+    useTabInfo: tab, t: makeTranslate(en),
+  } as unknown as TerminalBodyProps
+  const view = render(<TerminalBody {...props} />)
+  return {
+    view, props, model, detach, openTab,
+    update(next: TerminalViewState | undefined, shown = visible) {
+      state = next; visible = shown; view.rerender(<TerminalBody {...props} />)
+    },
+  }
+}
+
+it('mounts automatic startup and offers retry only when startup fails', () => {
+  const h = mount({ ...idle, phase: 'loading', environment: undefined })
+  expect(h.model.mount).toHaveBeenCalledOnce()
+  expect(h.view.getByRole('status').textContent).toBe(en.loading)
+  expect(h.view.queryByRole('combobox')).toBeNull()
+  expect(h.view.queryByRole('button')).toBeNull()
+  h.update({ ...idle, phase: 'failed', error: 'unavailable' })
+  expect(h.view.getByRole('alert').textContent).toContain('unavailable')
+  fireEvent.click(h.view.getByRole('button', { name: en.retry }))
+  expect(h.model.refresh).toHaveBeenCalledOnce()
+  h.update({ ...idle, phase: 'disconnected' })
+  fireEvent.click(h.view.getByRole('button', { name: en.retry }))
+  expect(h.model.refresh).toHaveBeenCalledTimes(2)
+  h.update({ ...idle, phase: 'creating' })
+  expect(h.view.getByRole('status').textContent).toBe(en.creating)
+  h.update({ ...idle, phase: 'closing' })
+  expect(h.view.queryByRole('status')).toBeNull()
+  expect(h.view.queryByRole('button')).toBeNull()
+  h.update(undefined)
+  expect(h.view.container.childElementCount).toBe(0)
+  expect(h.model.mount).toHaveBeenCalledOnce()
+})
+
+it('shows only the terminal screen while connected and detaches on unmount', () => {
+  const h = mount({ ...idle, info, phase: 'connected', writable: true })
+  expect(h.view.getByRole('textbox', { name: en.title })).toBeDefined()
+  expect(h.view.queryByRole('status')).toBeNull()
+  expect(h.view.queryByRole('button')).toBeNull()
+  expect(h.view.queryByRole('combobox')).toBeNull()
+  expect(h.view.queryByRole('heading')).toBeNull()
+  expect(h.view.queryByText('/workspace')).toBeNull()
+  expect(h.view.container.querySelector('header')).toBeNull()
+  h.view.unmount()
+  expect(h.detach).toHaveBeenCalledOnce()
+})
+
+it('keeps one emulator across rename and locale updates, applies snapshots and output once, and releases listeners', () => {
+  const snapshot: TerminalViewState = { ...idle, info, phase: 'connected', writable: true, render: { revision: 1, frame: { type: 'snapshot', sequence: 0, info, screen: 'restored' } } }
+  const h = mount(snapshot)
+  const terminal = fake.terminals[0]!
+  expect(terminal.reset).toHaveBeenCalledOnce()
+  expect(terminal.write).toHaveBeenCalledWith('restored', expect.any(Function))
+  expect(h.model.acknowledge).toHaveBeenCalledWith(1)
+  expect(terminal.focus).toHaveBeenCalledOnce()
+  terminal.input?.('help\t')
+  expect(h.model.write).toHaveBeenCalledWith('help\t')
+  h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'live' } } })
+  expect(terminal.write).toHaveBeenCalledWith('live', expect.any(Function))
+  h.update({ ...snapshot, render: { revision: 2, frame: { type: 'output', sequence: 1, data: 'duplicate' } } })
+  expect(terminal.write).toHaveBeenCalledTimes(2)
+  h.update({ ...snapshot, info: { ...info, title: 'Development' } })
+  h.view.rerender(<TerminalBody {...h.props} t={makeTranslate(zh)} />)
+  expect(fake.terminals).toHaveLength(1)
+  expect(terminal.textarea?.getAttribute('aria-label')).toBe(zh.title)
+  h.view.unmount()
+  expect(terminal.dispose).toHaveBeenCalledOnce()
+  expect(terminal.disposeInput).toHaveBeenCalledOnce()
+  expect(disconnect).toHaveBeenCalledOnce()
+})
+
+it('displays disconnect, close and exit states and offers explicit reconnection and takeover', () => {
+  const h = mount({ ...idle, info, phase: 'connecting' })
+  expect(h.view.getByRole('status').textContent).toBe(en.connecting)
+  h.update({ ...idle, info, phase: 'connected' })
+  expect(h.view.getByRole('status').textContent).toContain(en.readonly)
+  expect(fake.terminals[0]!.options.disableStdin).toBe(true)
+  fireEvent.click(h.view.getByRole('button', { name: en.control }))
+  expect(h.model.connect).toHaveBeenCalledOnce()
+  for (const phase of ['disconnected', 'failed'] as const) {
+    h.update({ ...idle, info, phase })
+    fireEvent.click(h.view.getByRole('button', { name: en.reconnect }))
+  }
+  expect(h.model.connect).toHaveBeenCalledTimes(3)
+  h.update({ ...idle, info, phase: 'closing' })
+  expect(h.view.queryByRole('status')).toBeNull()
+  h.update({ ...idle, info, phase: 'closed' })
+  expect(h.view.getByRole('status').textContent).toBe(en.closed)
+  h.update({ ...idle, info: { ...info, state: 'exited', exitCode: 5 }, phase: 'connected' })
+  expect(h.view.getByRole('status').textContent).toBe('Process exited (5)')
+  h.update({ ...idle, info: { ...info, state: 'exited', error: 'provider stopped' }, phase: 'connected' })
+  expect(h.view.getByRole('status').textContent).toBe('Process exited (—)')
+  expect(h.view.getByRole('alert').textContent).toContain('provider stopped')
+  h.update({ ...idle, info: { ...info, state: 'failed', error: 'provider unreachable' }, phase: 'connected' })
+  expect(h.view.getByRole('status').textContent).toBe(en.unavailable)
+})
+
+it('fits only visible writable terminals with measurable dimensions and clamps the provider limits', () => {
+  const state: TerminalViewState = { ...idle, info, phase: 'connected', writable: true }
+  const h = mount(state)
+  const terminal = fake.terminals[0]!
+  expect(h.model.resize).toHaveBeenCalledWith(120, 40)
+  h.model.resize.mockClear()
+  fake.dimensions = { cols: 500, rows: 300 }
+  measure?.()
+  expect(h.model.resize).toHaveBeenCalledWith(200, 100)
+  h.model.resize.mockClear()
+  h.update(state, false)
+  measure?.()
+  h.update({ ...state, writable: false }, true)
+  measure?.()
+  expect(h.model.resize).not.toHaveBeenCalled()
+  expect(terminal.resize).toHaveBeenLastCalledWith(80, 24)
+  boxWidth = 0
+  h.update(state)
+  measure?.()
+  boxWidth = 800; boxHeight = 0
+  measure?.()
+  h.update({ ...state, info: { ...info, cols: 81 } })
+  expect(h.model.resize).not.toHaveBeenCalled()
+  boxHeight = 600
+  fake.dimensions = undefined
+  measure?.()
+  h.update({ ...state, environment: undefined })
+  fake.dimensions = { cols: 40, rows: 20 }
+  measure?.()
+  h.update(state)
+  for (const dimensions of [{ cols: 1, rows: 20 }, { cols: 40, rows: 0 }]) { fake.dimensions = dimensions; measure?.() }
+  expect(h.model.resize).not.toHaveBeenCalled()
+})
+
+it.each(titleSurfaces)('shows saved and attached terminal names in the $name', (surface) => {
+  const h = mount(idle)
+  const chip = () => surface.wrap(<TerminalTitle {...h.props} />)
+  const title = render(chip())
+  expect(title.container.textContent).toBe('Terminal')
+  h.update({ ...idle, title: 'Saved terminal' })
+  title.rerender(chip())
+  expect(title.container.textContent).toBe('Saved terminal')
+  h.update({ ...idle, info })
+  title.rerender(chip())
+  expect(title.container.textContent).toBe('bash')
+  expect(title.container.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true')
+})
+
+it.each(titleSurfaces)('renames from the $name pointer target, preserving dragging and isolating editor shortcuts', (surface) => {
+  const h = mount({ ...idle, title: 'Saved terminal' })
+  const outer = vi.fn()
+  const title = render(<div onPointerDown={outer} onClick={outer} onDoubleClick={outer} onKeyDown={outer}>
+    {surface.wrap(<TerminalTitle {...h.props} />)}
+  </div>)
+  fireEvent.pointerDown(title.getByText('Saved terminal'))
+  expect(outer).toHaveBeenCalledOnce()
+  outer.mockClear()
+  fireEvent.doubleClick(title.container.querySelector(surface.selector)!)
+  const input = title.getByRole('textbox', { name: en.rename }) as HTMLInputElement
+  expect(document.activeElement).toBe(input)
+  expect(input.selectionStart).toBe(0)
+  expect(input.selectionEnd).toBe('Saved terminal'.length)
+  fireEvent.pointerDown(input)
+  fireEvent.click(input)
+  fireEvent.doubleClick(input)
+  fireEvent.keyDown(input, { key: 'ArrowLeft' })
+  fireEvent.change(input, { target: { value: ' Development ' } })
+  fireEvent.keyDown(input, { key: 'Enter' })
+  expect(h.model.rename).toHaveBeenCalledWith('Development')
+  expect(h.model.rename).toHaveBeenCalledOnce()
+  expect(title.queryByRole('textbox', { name: en.rename })).toBeNull()
+  expect(outer).not.toHaveBeenCalled()
+})
+
+it.each(titleSurfaces)('handles Escape, unchanged names and IME composition while editing the $name', (surface) => {
+  const h = mount({ ...idle, info })
+  const title = render(surface.wrap(<TerminalTitle {...h.props} />))
+  const edit = () => {
+    fireEvent.doubleClick(title.container.querySelector(surface.selector)!)
+    return title.getByRole('textbox', { name: en.rename })
+  }
+  let input = edit()
+  fireEvent.change(input, { target: { value: 'Cancelled' } })
+  fireEvent.keyDown(input, { key: 'Escape' })
+  expect(h.model.rename).not.toHaveBeenCalled()
+  input = edit()
+  fireEvent.blur(input)
+  expect(h.model.rename).not.toHaveBeenCalled()
+  input = edit()
+  fireEvent.change(input, { target: { value: '   ' } })
+  fireEvent.blur(input)
+  expect(h.model.rename).not.toHaveBeenCalled()
+  input = edit()
+  fireEvent.change(input, { target: { value: '开发' } })
+  fireEvent.keyDown(input, { key: 'Enter', isComposing: true })
+  fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 })
+  expect(title.getByRole('textbox', { name: en.rename })).toBe(input)
+  expect(h.model.rename).not.toHaveBeenCalled()
+  fireEvent.blur(input)
+  expect(h.model.rename).toHaveBeenCalledWith('开发')
+})
+
+it.each(titleSurfaces)('removes the native $name listener when its title unmounts', (surface) => {
+  const h = mount({ ...idle, info })
+  const outer = vi.fn()
+  const seat = (shown: boolean) => <div onDoubleClick={outer}>
+    {surface.wrap(shown && <TerminalTitle {...h.props} />)}
+  </div>
+  const title = render(seat(false))
+  const chip = title.container.querySelector(surface.selector)!
+  const add = vi.spyOn(chip, 'addEventListener')
+  const remove = vi.spyOn(chip, 'removeEventListener')
+  title.rerender(seat(true))
+  const listener = add.mock.calls.find(([type]) => type === 'dblclick')?.[1]
+  expect(listener).toBeTypeOf('function')
+  title.rerender(seat(false))
+  expect(remove).toHaveBeenCalledExactlyOnceWith('dblclick', listener)
+  fireEvent.doubleClick(chip)
+  expect(outer).toHaveBeenCalledOnce()
+  expect(h.model.rename).not.toHaveBeenCalled()
+})
+
+it('starts a recovered screen with no local history when environment discovery is unavailable', () => {
+  mount({ ...idle, info, environment: undefined })
+  expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0)
+})

+ 51 - 0
packages/client/ui-sidebar-terminal/tsconfig.json

@@ -0,0 +1,51 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-dockkit"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-session"
+    },
+    {
+      "path": "../ui-sidebar-right"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../api/terminal-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../ui-layout"
+    },
+    {
+      "path": "../ui-conversation"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-sidebar-terminal/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-terminal', ['lib/types/index.js'])

+ 2 - 2
packages/e2b/subprocess-e2b/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md
-README.md: 1c88e8b8650b93d326086f53311840922656244b
-README.zh.md: 74c43ae98724e652618026c9eb3191998fbf52a9
+README.md: 11cb60e3966b2dd7bd20021a9cb6e3806b41df3e
+README.zh.md: 14834748bbc82d87d3178ab75c7b470184b4e8c3

+ 2 - 0
packages/e2b/subprocess-e2b/README.md

@@ -125,6 +125,8 @@ Read these pages when the package-level contract is not enough. They move from t
 
 -----
 
+Terminal environment discovery reads the sandbox environment, and terminal resize uses the E2B PTY resize API. The caller-provided `terminalType` is applied to the remote child environment. Shell lookup misses are distinguished from transport errors.
+
 <a id="model-experience"></a>
 ## Model Experience
 

+ 2 - 0
packages/e2b/subprocess-e2b/README.zh.md

@@ -125,6 +125,8 @@ agent 可以在沙箱中打开交互式终端、发送输入、读取输出,
 
 -----
 
+终端环境探测读取 sandbox 环境,终端 resize 使用 E2B PTY resize API。调用者指定的 `terminalType` 应用于远端子进程环境。shell 查找缺失与传输错误分别报告。
+
 <a id="model-experience"></a>
 ## 模型体验
 

+ 21 - 4
packages/e2b/subprocess-e2b/src/index.ts

@@ -9,18 +9,20 @@ import { posix } from 'node:path'
 import { inspect } from 'node:util'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
+import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import type {
   SubprocessHandle,
   SubprocessSpawnSpec,
   SubprocessTerminalHandle,
+  SubprocessTerminalEnvironment,
   SubprocessTerminalSpawnSpec,
 } from '@deepseek-ai/dsh-subprocess'
-import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
+import { CommandExitError, e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
 import { E2BSubprocessHandle } from './process.ts'
 import { asError, signalOpts } from './remote.ts'
 import { spawnE2BTerminal } from './terminal.ts'
+import { readRemoteEnvironment, scrubRemoteEnvironment } from './environment.ts'
 
 /** Configuration for the E2B subprocess adapter. */
 export interface Config {
@@ -120,7 +122,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime {
       await sandbox.commands.run(
         `test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`,
         { envs: e2bControlEnvs(), ...signalOpts(signal) },
-      )
+      ).catch((error: unknown) => { throw executableLookupError(command, error) })
       signal?.throwIfAborted()
       return command
     }
@@ -134,7 +136,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime {
     const result = await sandbox.commands.run(
       `${prefix}command -v -- ${quoteE2BShellArg(command)}`,
       { cwd: this.ctx.e2b.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) },
-    )
+    ).catch((error: unknown) => { throw executableLookupError(command, error) })
     signal?.throwIfAborted()
     const executable = result.stdout.trim()
     if (executable.includes('\n') || (!posix.isAbsolute(executable) && !executable.includes('/'))) {
@@ -144,6 +146,14 @@ export class E2BSubprocessRuntime extends SubprocessRuntime {
     return posix.resolve(this.ctx.e2b.cwd, executable)
   }
 
+  /** @inheritdoc */
+  async terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment> {
+    const sandbox = await this.ctx.e2b.getSandbox()
+    const environment = scrubRemoteEnvironment(await readRemoteEnvironment(sandbox, signal))
+    const defaultShell = environment.get('SHELL')
+    return { platform: 'posix', ...defaultShell === undefined ? {} : { defaultShell } }
+  }
+
   /** @inheritdoc */
   spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
     if (this.disposing) throw new Error('subprocess-e2b: service is disposing')
@@ -229,3 +239,10 @@ export class E2BSubprocessRuntime extends SubprocessRuntime {
 }
 
 export default E2BSubprocessRuntime
+
+function executableLookupError(command: string, error: unknown): unknown {
+  if (error instanceof CommandExitError && (error.exitCode === 1 || error.exitCode === 127)) {
+    return new SubprocessExecutableNotFoundError(`subprocess-e2b: executable ${JSON.stringify(command)} was not found`, { cause: error })
+  }
+  return error
+}

+ 9 - 1
packages/e2b/subprocess-e2b/src/terminal.ts

@@ -310,6 +310,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
     })
   }
 
+  /** @inheritdoc */
+  resize(cols: number, rows: number): Promise<void> {
+    return this.trackOperation(async (signal) => {
+      if (this.topLevelExited) throw new Error('terminal process has exited')
+      await this.sandbox.pty.resize(this.pid, { cols, rows }, { signal })
+    })
+  }
+
   /** @inheritdoc */
   inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
     return this.trackOperation(signal => this.inspectForegroundOnce(signal))
@@ -479,7 +487,7 @@ export async function spawnE2BTerminal(
   try {
     const ambient = await readRemoteEnvironment(sandbox, spec.signal)
     controlEnvs = bootstrapEnvironment(ambient)
-    const environment = serializeRemoteEnvironment(ambient, spec.env)
+    const environment = serializeRemoteEnvironment(ambient, { ...spec.env, TERM: spec.terminalType })
     const argv = serializeValues(spec.argv, 'argv')
     stateDirectoryCreated = true
     await sandbox.files.makeDir(stateDir, signalOpts(spec.signal))

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