Browse Source

fix(web): harden sidebar terminal lifecycle and recovery

Yichen Jiang 1 month ago
parent
commit
0a525601a7
46 changed files with 377 additions and 224 deletions
  1. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  3. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  4. 1 0
      apps/web/tests/expected/sidebar-terminal/limit.expected.md
  5. 1 0
      apps/web/tests/fixtures/sidebar-terminal.patch.yml
  6. 31 4
      apps/web/tests/sidebar-terminal.e2e.ts
  7. 2 2
      packages/api/terminal-controller/README.i18n.yaml
  8. 4 4
      packages/api/terminal-controller/README.md
  9. 4 4
      packages/api/terminal-controller/README.zh.md
  10. 11 6
      packages/api/terminal-controller/src/client/index.ts
  11. 47 18
      packages/api/terminal-controller/src/client/model.ts
  12. 3 2
      packages/api/terminal-controller/src/index.ts
  13. 12 5
      packages/api/terminal-controller/src/terminal.ts
  14. 10 0
      packages/api/terminal-controller/src/types.ts
  15. 2 2
      packages/api/terminal-controller/tests/controller.spec.ts
  16. 66 8
      packages/api/terminal-controller/tests/model.client.spec.ts
  17. 67 3
      packages/api/terminal-controller/tests/recovery.client.spec.ts
  18. 5 5
      packages/api/terminal-controller/tests/terminal.spec.ts
  19. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  20. 1 1
      packages/client/ui-sidebar-right/README.md
  21. 1 1
      packages/client/ui-sidebar-right/README.zh.md
  22. 1 1
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  23. 2 2
      packages/client/ui-sidebar-right/src/client/index.ts
  24. 12 29
      packages/client/ui-sidebar-right/src/client/service.ts
  25. 2 2
      packages/client/ui-sidebar-right/src/client/tab-domain.ts
  26. 2 2
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  27. 7 7
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  28. 25 88
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  29. 1 1
      packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts
  30. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  31. 1 1
      packages/client/ui-sidebar-terminal/README.md
  32. 1 1
      packages/client/ui-sidebar-terminal/README.zh.md
  33. 1 1
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx
  34. 10 0
      packages/client/ui-sidebar-terminal/src/client/locales.ts
  35. 2 2
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  36. 14 2
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  37. 2 2
      packages/e2b/subprocess-e2b/README.i18n.yaml
  38. 1 1
      packages/e2b/subprocess-e2b/README.md
  39. 1 1
      packages/e2b/subprocess-e2b/README.zh.md
  40. 1 1
      packages/e2b/subprocess-e2b/src/index.ts
  41. 2 0
      packages/e2b/subprocess-e2b/tests/terminal.spec.ts
  42. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  43. 1 1
      packages/subprocess/subprocess-local/README.md
  44. 1 1
      packages/subprocess/subprocess-local/README.zh.md
  45. 1 1
      packages/subprocess/subprocess-local/src/index.ts
  46. 6 0
      packages/subprocess/subprocess-local/tests/local.spec.ts

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: de51d0bb849de0599dfdaf86d2293c17618059cf
-2026-09-09-web-sidebar-terminal.zh.md: e6aacebc424efc12457b4a31f0b2660a3ad6225b
+2026-09-09-web-sidebar-terminal.md: 4339e6aa0bbb28383a9f2b42336965d44b1a0e07
+2026-09-09-web-sidebar-terminal.zh.md: aec1286b0d6e594813735acfd06dbaf34f261972

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

@@ -12,13 +12,13 @@ Web users need an interactive shell beside a Session to inspect the workspace an
 
 `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.
+Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 
 Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. 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.
+The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full.
 
 ## Alternatives considered
 

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

@@ -12,13 +12,13 @@ Web 用户需要在 Session 旁使用交互式 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 插件卸载也会清理终端。
+关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 
 侧栏布局、打开标签页映射、选中项和进程 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。
+最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。
 
 ## 考虑过的替代方案
 

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

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

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

@@ -1,5 +1,6 @@
 - id: terminal-controller
   config:
+    maxTerminals: 2
     shell:
       path: /bin/bash
       name: bash

+ 31 - 4
apps/web/tests/sidebar-terminal.e2e.ts

@@ -12,12 +12,13 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 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> {
+async function openTerminal(page: Page, waitForShell = true): 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-')
+  const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
+  if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
+  await entry.click()
+  if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
 }
 
 async function command(page: Page, text: string): Promise<void> {
@@ -142,4 +143,30 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!).length).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
   })
+
+  it('explains that exited terminals count toward the quota and permits creation after closing one', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota'))
+    const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!)
+    for (let count = 1; count <= 2; count++) {
+      await openTerminal(page)
+      await command(page, 'exit')
+      await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count)
+    }
+    await openTerminal(page, false)
+    const alert = page.getByRole('alert')
+    await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.')
+    const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url))
+    await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode())
+    const failed = page.locator('[data-dockkit-tab][aria-selected="true"]')
+    await failed.hover()
+    await failed.locator('[data-dockkit-tab-close]').click()
+    const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first()
+    await exited.hover()
+    await exited.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(() => terminals().length).toBe(1)
+    await openTerminal(page)
+    await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
 })

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md
-README.md: 08def8e53f6e9362b2bd99f03dc552115dcf9539
-README.zh.md: 4cd9d42272e4ff6a588305056450d145dc7dbb12
+README.md: 63da44832aa6087f43b9d9ecdafc56010f17245e
+README.zh.md: abcaa83d41ff525d9e7a60ae49c27636a5c8b411

+ 4 - 4
packages/api/terminal-controller/README.md

@@ -47,11 +47,11 @@ New terminals use the execution environment's declared default shell. Only when
 
 The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it never launches a desktop terminal application. Streaming UTF-8 decoding preserves split characters and leading BOMs, and replaces incomplete trailing bytes at EOF. Unary control uses the Gateway, and `follow` uses its multiplexed Remote stream transport. Headless xterm and its serializer produce each opening screen after all preceding output writes, then monotone output sequences identify subsequent frames. Slow followers fail explicitly; a new attachment restores the current screen.
 
-The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode.
+The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed.
 
-Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses.
+Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
 
-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.
+Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
 
 </details>
 
@@ -75,7 +75,7 @@ None; terminal output travels only between the browser and Host.
 
 <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.
+- Processes and screens survive browser reloads, but not Host or Session owner disposal. There is no durable terminal restoration or automatic shell respawn. Exited terminals count toward `maxTerminals`; close unused tabs to release their screens and quota.
 - The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed.
 - Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize.
 

+ 4 - 4
packages/api/terminal-controller/README.zh.md

@@ -47,11 +47,11 @@ Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway
 
 Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终端,不启动桌面终端应用。流式 UTF-8 解码保留跨块字符和开头的 BOM,并在 EOF 将不完整的尾部字节替换为替代字符。控制请求走 Gateway,`follow` 使用其复用的 Remote stream。Headless xterm 和序列化 addon 在此前输出写入后生成初始屏幕,后续增量携带单调序号。过慢的订阅者明确失败;重新连接恢复当前屏幕。
 
-最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。
+最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。
 
-Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。
+Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
 
-关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功后删除;启动时重试已保存的请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
+关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
 
 </details>
 
@@ -75,7 +75,7 @@ Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。
+- 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。
 - 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。
 - 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。
 

+ 11 - 6
packages/api/terminal-controller/src/client/index.ts

@@ -1,5 +1,6 @@
 /** Client terminal model service; views are keyed independently from Host terminal identities. */
 import { Service, type Context } from '@deepseek-ai/cordis'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-api-gateway/client'
 import { TerminalView, type TerminalRemote } from './model.ts'
@@ -8,7 +9,7 @@ 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'
+export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -42,9 +43,9 @@ export class ClientTerminals extends Service {
     super(ctx, 'webTerminals')
     ctx.effect(() => async () => {
       this.disposed = true
-      for (const views of this.views.values()) for (const view of views.values()) view.dispose()
+      const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()))
       this.views.clear()
-      await Promise.all(this.closing.values())
+      await Promise.all([...detaching, ...this.closing.values()])
     }, 'terminal-controller.client.views')
     for (const request of this.requests.pending()) this.cleanup(request)
   }
@@ -117,16 +118,20 @@ export class ClientTerminals extends Service {
       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)
+        if (!result.ok) throw result.error
       }
       this.requests.remove(record.id)
     })().catch((error: unknown) => {
+      if (remoteErrorOf(error)?.code === 'session/not-found') {
+        this.requests.remove(record.id)
+        return
+      }
       if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
         id: record.id, title: record.title,
         message: error instanceof Error ? error.message : String(error),
       }])
-    }).finally(() => {
-      view?.dispose()
+    }).then(async () => {
+      await view?.dispose()
       this.closing.delete(record.id)
     })
     this.closing.set(record.id, pending)

+ 47 - 18
packages/api/terminal-controller/src/client/model.ts

@@ -2,7 +2,7 @@
 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 { RemoteError, remoteErrorOf, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-api-terminal-controller/remote'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
@@ -13,6 +13,20 @@ import type {
 /** The generated terminal namespace's browser-facing operations. */
 export type TerminalRemote = ClientRemote['terminal']
 
+/** Product error identifiers translated by the terminal UI. */
+export type TerminalViewIssue = 'missingTerminal' | 'inputFull' | 'attachmentEnded' | 'invalidOutput' | 'terminalLimit'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** Client terminal failure preserved through the Remote stream supervisor. */
+    'terminal/view': { readonly issue: TerminalViewIssue }
+  }
+}
+
+class TerminalViewError extends RemoteError<'terminal/view'> {
+  constructor(issue: TerminalViewIssue, message: string = issue) { super('terminal/view', message, { issue }) }
+}
+
 /** One screen write awaiting the DOM emulator's callback. */
 export interface TerminalRenderFrame {
   readonly revision: number
@@ -28,6 +42,7 @@ export interface TerminalViewState {
   readonly writable: boolean
   readonly render?: TerminalRenderFrame | undefined
   readonly error?: string | undefined
+  readonly issue?: TerminalViewIssue | undefined
 }
 
 /** A view survives DOM unmount; its process only ends on explicit close. */
@@ -45,6 +60,7 @@ export class TerminalView {
   private closing: Promise<void> | undefined
   private writes: Promise<void> = Promise.resolve()
   private queuedInput = 0
+  private readonly detaching = new Set<Promise<void>>()
 
   /**
    * @param sessionId - Session owning the terminal.
@@ -83,7 +99,7 @@ export class TerminalView {
   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.patch({ phase: 'loading', error: undefined, issue: undefined })
     this.loading = (async () => {
       const [environment, available] = await Promise.all([
         this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId),
@@ -93,13 +109,13 @@ export class TerminalView {
       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.')
+      else throw new TerminalViewError('missingTerminal')
     })().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.patch({ phase: 'creating', error: undefined, issue: 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),
@@ -126,11 +142,11 @@ export class TerminalView {
         this.attachmentId = attachmentId
         return this.remote.follow(this.sessionId, info.id, attachmentId, signal)
       },
-      ended: () => new Error('Terminal attachment ended; reconnect to recover its state'),
+      ended: () => new TerminalViewError('attachmentEnded'),
       carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
     })
     this.stream = stream
-    this.patch({ phase: 'connecting', writable: false, error: undefined, render: undefined })
+    this.patch({ phase: 'connecting', writable: false, error: undefined, issue: undefined, render: undefined })
     void this.consume(stream)
   }
 
@@ -153,11 +169,11 @@ export class TerminalView {
     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 }
+    if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) { this.fail(new TerminalViewError('inputFull')); return }
     this.queuedInput += bytes
     const id = state.info.id
     this.writes = this.writes.then(async () => {
-      if (this.attachmentId !== attachmentId) return
+      if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
       valueOf(await this.remote.write(this.sessionId, id, attachmentId, data))
     }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) }).finally(() => { this.queuedInput -= bytes })
   }
@@ -176,7 +192,7 @@ export class TerminalView {
     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
+      if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return
       valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows))
     }).catch((error: unknown) => { if (this.attachmentId === attachmentId) this.fail(error) })
   }
@@ -201,7 +217,7 @@ export class TerminalView {
    */
   close(): Promise<void> {
     if (this.closing !== undefined) return this.closing
-    this.patch({ phase: 'closing', writable: false, error: undefined })
+    this.patch({ phase: 'closing', writable: false, error: undefined, issue: undefined })
     this.detach()
     this.closing = (async () => {
       // Even a refused or lost creation response may leave an allocation to close.
@@ -213,11 +229,15 @@ export class TerminalView {
     return this.closing
   }
 
-  /** Stop Client work on plugin unload without closing Host terminals. */
-  dispose(): void {
+  /**
+   * Stop Client work on plugin unload without closing Host terminals.
+   * @returns after active and previously detached stream iterators have closed.
+   */
+  async dispose(): Promise<void> {
     this.mounted = false
     this.lifetime.abort()
     this.detach()
+    await Promise.all(this.detaching)
   }
 
   private detach(): void {
@@ -226,7 +246,10 @@ export class TerminalView {
     this.attachmentId = undefined
     this.pendingRender?.resolve()
     this.pendingRender = undefined
-    if (previous !== undefined) void previous.dispose()
+    if (previous !== undefined) {
+      const cleanup = previous.dispose().finally(() => { this.detaching.delete(cleanup) })
+      this.detaching.add(cleanup)
+    }
   }
 
   private async consume(stream: RemoteStream<TerminalFrame>): Promise<void> {
@@ -237,14 +260,14 @@ export class TerminalView {
         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')
+          if (frame.type !== 'snapshot') throw new TerminalViewError('invalidOutput', 'Terminal output generation is missing its screen snapshot')
           generation = item.generation
           sequence = frame.sequence
           item.accept()
         } else if (frame.type === 'output') {
-          if (frame.sequence !== sequence + 1) throw new Error('Terminal output sequence has a gap')
+          if (frame.sequence !== sequence + 1) throw new TerminalViewError('invalidOutput', 'Terminal output sequence has a gap')
           sequence = frame.sequence
-        } else if (frame.type === 'snapshot') throw new Error('Unexpected terminal screen snapshot')
+        } else if (frame.type === 'snapshot') throw new TerminalViewError('invalidOutput', 'Unexpected terminal screen snapshot')
         if (frame.type !== 'output') {
           this.patch({ info: frame.info, title: frame.info.title, phase: 'connected', writable: frame.info.state === 'running' && frame.info.controllerId === this.attachmentId })
         }
@@ -274,11 +297,17 @@ export class TerminalView {
   }
 
   private fail(error: unknown): void {
-    this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, error: error instanceof Error ? error.message : String(error) })
+    const failure = remoteErrorOf(error)
+    if (failure?.code === 'terminal/control-unavailable') {
+      this.patch({ writable: false, error: undefined, issue: undefined })
+      return
+    }
+    const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined
+    this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
   }
 }
 
 function valueOf<T>(result: RemoteResult<T>): T {
-  if (!result.ok) throw new Error(result.error.message)
+  if (!result.ok) throw result.error
   return result.value
 }

+ 3 - 2
packages/api/terminal-controller/src/index.ts

@@ -7,7 +7,7 @@ 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 { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { resolveShell } from './shells.ts'
 import { BrowserTerminal } from './terminal.ts'
 import type {
@@ -153,7 +153,7 @@ export class TerminalController extends TypertRemoteService {
       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')
+    if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals })
     const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]))
     owner.pending.set(request.id, allocation)
     try {
@@ -232,6 +232,7 @@ export class TerminalController extends TypertRemoteService {
   async close(agent: Agent, id: WebTerminalId): Promise<void> {
     const owner = this.owner(agent)
     owner.closedIds.add(id)
+    // create publishes the allocation before this wait settles; close owns it even if create then rejects.
     await owner.pending.get(id)?.catch(() => { /* Creation reports its failure; close still owns any allocated process. */ })
     const terminal = owner.terminals.get(id)
     if (terminal !== undefined) {

+ 12 - 5
packages/api/terminal-controller/src/terminal.ts

@@ -1,14 +1,21 @@
 /** One PTY, a bounded terminal emulator and its detachable browser followers. */
 import { createRequire } from 'node:module'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { Terminal as HeadlessTerminal } from '@xterm/headless'
 import type { SerializeAddon as Serializer } from '@xterm/addon-serialize'
 import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
 import { TerminalFollower } from './stream.ts'
 import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts'
 
-// Literal createRequire calls keep CommonJS xterm entries reachable in the Preview image.
-const { Terminal } = createRequire(import.meta.url)('@xterm/headless') as typeof import('@xterm/headless')
-const { SerializeAddon } = createRequire(import.meta.url)('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize')
+const { Terminal, SerializeAddon } = loadXterm()
+
+function loadXterm() {
+  // The Preview's CommonJS wrapper owns its outer require binding; these literal calls also retain the CJS entries.
+  const require = createRequire(import.meta.url)
+  const { Terminal } = require('@xterm/headless') as typeof import('@xterm/headless')
+  const { SerializeAddon } = require('@xterm/addon-serialize') as typeof import('@xterm/addon-serialize')
+  return { Terminal, SerializeAddon }
+}
 
 /** Process lifetime is independent of follower and component lifetimes. */
 export class BrowserTerminal {
@@ -125,8 +132,8 @@ export class BrowserTerminal {
   }
 
   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')
+    if (this.closing !== undefined || this.info.state !== 'running') throw new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' })
+    if (this.controller?.id !== id) throw new RemoteError('terminal/control-unavailable', 'Terminal input is controlled by another attachment', { reason: 'read-only' })
   }
 
   private broadcast(frame: TerminalFrame): void {

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

@@ -1,5 +1,15 @@
 /** Browser terminal identities, metadata and screen-stream frames. */
 import type { Branded } from '@deepseek-ai/dsh-brand'
+import type {} from '@deepseek-ai/dsh-typert-protocol'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** Input or resize was refused without invalidating the output attachment. */
+    'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' }
+    /** Retained screens and pending allocations consume the Session's terminal quota. */
+    'terminal/limit-reached': { readonly limit: number }
+  }
+}
 
 /** A terminal identity scoped to one Session and one Host lifetime. */
 export type WebTerminalId = Branded<'WebTerminalId'>

+ 2 - 2
packages/api/terminal-controller/tests/controller.spec.ts

@@ -210,7 +210,7 @@ describe('TerminalController', () => {
     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')
+      await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
     } finally { allocation.resolve(handle) }
     const first = await creating
     expect(await controller.create(agent, request, signal())).toBe(first)
@@ -225,7 +225,7 @@ describe('TerminalController', () => {
     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 expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
     await controller.close(agent, id)
     expect(controller.list(agent)).toEqual([])
   })

+ 66 - 8
packages/api/terminal-controller/tests/model.client.spec.ts

@@ -1,4 +1,5 @@
 /** Browser view ownership across slow RPCs, remounts and transport generations. */
+import { setImmediate } from 'node:timers/promises'
 import { afterEach, expect, it, vi } from 'vitest'
 import { RemoteStream, RemoteStreamCarrierError, type ClientRemote, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -53,7 +54,7 @@ function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
   }
   cleanups.push(async () => { await Promise.all(streams.map(stream => stream.dispose())) })
   const model = new TerminalView(sessionId, remote, gateway, info.id)
-  cleanups.push(() => { model.dispose() })
+  cleanups.push(() => model.dispose())
   return { model, remote, gateway, options, streams, generation }
 }
 
@@ -141,7 +142,7 @@ it('bounds queued input by UTF-8 bytes and releases the byte budget after settle
   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' })
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'inputFull' })
   input.resolve(success(undefined))
   model.connect()
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
@@ -227,7 +228,7 @@ it('reports an attachment ending while the shell is still running and permits ma
     yield { type: 'snapshot', sequence: 0, screen: '', info: { ...info, id, controllerId } }
   })
   await connected(model)
-  await expect.poll(() => model.state.getSnapshot().error).toContain('Terminal attachment ended')
+  await expect.poll(() => model.state.getSnapshot().issue).toBe('attachmentEnded')
   model.connect()
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
 })
@@ -407,7 +408,7 @@ it('keeps controls inert when process metadata or the attachment is unavailable'
   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(model.state.getSnapshot().issue).toBe('inputFull')
   expect(remote.write).not.toHaveBeenCalled()
 })
 
@@ -421,7 +422,7 @@ it('renames an existing terminal, skips unchanged names, and exposes rename fail
   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.dispose()
   await model.rename('Ignored')
   expect(remote.rename).toHaveBeenCalledTimes(2)
 })
@@ -446,7 +447,7 @@ it('retains state when disposal overtakes successful allocation or a failed disc
     else vi.mocked(remote.environment).mockReturnValueOnce(discovery.promise)
     const loading = model.refresh()
     if (operation === 'creation') await expect.poll(() => remote.create).toHaveBeenCalledOnce()
-    model.dispose()
+    await model.dispose()
     const before = model.state.getSnapshot()
     if (operation === 'creation') creation.resolve(success(info))
     else discovery.reject(new Error('late discovery failure'))
@@ -462,7 +463,7 @@ it('ignores a successful rename after the view is disposed', async () => {
   const rename = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(remote.rename).mockReturnValueOnce(rename.promise)
   const pending = model.rename('Late')
-  model.dispose()
+  await model.dispose()
   const before = model.state.getSnapshot()
   rename.resolve(success(undefined))
   await pending
@@ -492,7 +493,7 @@ it('does not publish a close result or reconnect after disposal', async () => {
   const closing = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(remote.close).mockReturnValueOnce(closing.promise)
   const pending = model.close()
-  model.dispose()
+  await model.dispose()
   const before = model.state.getSnapshot()
   closing.resolve(success(undefined))
   await pending
@@ -501,3 +502,60 @@ it('does not publish a close result or reconnect after disposal', async () => {
   expect(model.state.getSnapshot()).toBe(before)
   expect(remote.follow).toHaveBeenCalledOnce()
 })
+
+it.each(['write', 'resize'] as const)('keeps the output connection when %s loses control before its state frame arrives', async (operation) => {
+  const { model, remote } = fixture()
+  const transfer = Promise.withResolvers<undefined>()
+  const response = Promise.withResolvers<RemoteResult<void>>()
+  cleanups.push(() => { transfer.resolve(undefined); response.resolve(success(undefined)) })
+  vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
+    yield { type: 'snapshot', sequence: 0, screen: 'retained screen', info: { ...info, id, controllerId } }
+    await transfer.promise
+    yield { type: 'state', info: { ...info, id } }
+    await untilAborted(signal)
+  })
+  await connected(model)
+  vi.mocked(remote[operation]).mockReturnValueOnce(response.promise)
+  if (operation === 'write') model.write('before transfer')
+  else model.resize(100, 30)
+  model.write('queued before transfer')
+  await expect.poll(() => vi.mocked(remote[operation]).mock.calls.length).toBe(1)
+  response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Another window owns input', { reason: 'read-only' }) })
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(false)
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', error: undefined, issue: undefined, render: { frame: { screen: 'retained screen' } } })
+  transfer.resolve(undefined)
+  await expect.poll(() => model.state.getSnapshot().info?.controllerId).toBeUndefined()
+  expect(remote.follow).toHaveBeenCalledOnce()
+  expect(vi.mocked(remote.write).mock.calls.some(call => call[3] === 'queued before transfer')).toBe(false)
+})
+
+it('keeps an exited screen when a pending input is refused after the exit state arrives', async () => {
+  const { model, remote } = fixture()
+  const exit = Promise.withResolvers<undefined>()
+  const response = Promise.withResolvers<RemoteResult<void>>()
+  cleanups.push(() => { exit.resolve(undefined); response.resolve(success(undefined)) })
+  vi.mocked(remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
+    yield { type: 'snapshot', sequence: 0, screen: 'final screen', info: { ...info, id, controllerId } }
+    await exit.promise
+    yield { type: 'state', info: { ...info, id, state: 'exited', exitCode: 0 } }
+    await untilAborted(signal)
+  })
+  await connected(model)
+  vi.mocked(remote.write).mockReturnValueOnce(response.promise)
+  model.write('exit race')
+  await expect.poll(() => vi.mocked(remote.write).mock.calls.length).toBe(1)
+  exit.resolve(undefined)
+  await expect.poll(() => model.state.getSnapshot().info?.state).toBe('exited')
+  response.resolve({ ok: false, error: new RemoteError('terminal/control-unavailable', 'Terminal is not running', { reason: 'not-running' }) })
+  await setImmediate()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', writable: false, error: undefined, issue: undefined, render: { frame: { screen: 'final screen' } } })
+})
+
+it('exposes a localized quota error and clears it after a successful retry', async () => {
+  const { model, remote } = fixture()
+  vi.mocked(remote.create).mockResolvedValueOnce({ ok: false, error: new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: 8 }) })
+  await model.refresh()
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'terminalLimit' })
+  await connected(model)
+  expect(model.state.getSnapshot()).toMatchObject({ phase: 'connected', issue: undefined, error: undefined })
+})

+ 67 - 3
packages/api/terminal-controller/tests/recovery.client.spec.ts

@@ -1,4 +1,5 @@
 /** Reloaded tab identities and nonblocking close requests use independent lifetimes. */
+import { setImmediate } from 'node:timers/promises'
 import { afterEach, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -42,7 +43,7 @@ function fixture() {
   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() })
+    cleanups.push(() => model.dispose())
     return model
   }
   async function service() {
@@ -92,7 +93,7 @@ it('does not recreate a recovered terminal that disappeared after the recovery l
   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(model.state.getSnapshot().issue).toBe('missingTerminal')
   expect(h.remote.create).not.toHaveBeenCalled()
   model.mount()
   await model.refresh()
@@ -152,7 +153,7 @@ it('updates a restored inactive terminal title and ignores late results after di
   const rename = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.rename).mockReturnValueOnce(rename.promise)
   const pending = model.rename('Late')
-  model.dispose()
+  await model.dispose()
   rename.resolve(success(undefined))
   await pending
   expect(model.state.getSnapshot().title).toBe('Build')
@@ -367,3 +368,66 @@ it('keeps cleanup usable in memory when storage access itself is denied', () =>
   expect(requests.pending()).toEqual([])
   expect(error).toHaveBeenCalledTimes(3)
 })
+
+it.each(['saved', 'view'] as const)('clears a %s close request after the Host confirms that its Session does not exist', async (source) => {
+  const data = storage()
+  const h = fixture()
+  const requests = new TerminalCloseRequests()
+  if (source === 'saved') requests.save({ sessionId, id: info.id, title: 'Build' })
+  vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) })
+  const { service, dispose } = await h.service()
+  if (source === 'view') {
+    const view = service.view(sessionId, 'tab')
+    await view.refresh()
+    service.close(sessionId, 'tab')
+  }
+  await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
+  await dispose()
+  expect(data.size).toBe(0)
+  expect(service.closeFailures.getSnapshot()).toEqual([])
+  expect(new TerminalCloseRequests().pending()).toEqual([])
+  await h.service()
+  expect(h.remote.close).toHaveBeenCalledOnce()
+})
+
+it('waits for both active and detached stream finalizers during plugin disposal without closing Host processes', async () => {
+  const h = fixture()
+  const { service, dispose } = await h.service()
+  const started = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
+  const release = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
+  const finished = [Promise.withResolvers<undefined>(), Promise.withResolvers<undefined>()]
+  cleanups.push(() => { for (const barrier of release) barrier.resolve(undefined) })
+  let index = 0
+  vi.mocked(h.remote.follow).mockImplementation(async function* (_session, id, controllerId, signal) {
+    const current = index++
+    try {
+      yield { type: 'snapshot', sequence: 0, screen: 'screen', info: { ...info, id, controllerId } }
+      await new Promise<void>((resolve) => {
+        if (signal?.aborted) resolve()
+        else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    } finally {
+      started[current]!.resolve(undefined)
+      await release[current]!.promise
+      finished[current]!.resolve(undefined)
+    }
+  })
+  const model = service.view(sessionId, 'tab')
+  model.mount()
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  model.connect()
+  await started[0]!.promise
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  let disposed = false
+  const disposing = dispose().then(() => { disposed = true })
+  await started[1]!.promise
+  release[1]!.resolve(undefined)
+  await finished[1]!.promise
+  // Drain runnable disposal continuations; only the held old finalizer may keep teardown pending.
+  await setImmediate()
+  expect(disposed).toBe(false)
+  release[0]!.resolve(undefined)
+  await disposing
+  expect(disposed).toBe(true)
+  expect(h.remote.close).not.toHaveBeenCalled()
+})

+ 5 - 5
packages/api/terminal-controller/tests/terminal.spec.ts

@@ -63,7 +63,7 @@ describe('BrowserTerminal', () => {
     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 expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } })
     await terminal.write(attachment('second'), '\t')
     expect(handle.write).toHaveBeenCalledWith('\t')
     await terminal.resize(attachment('second'), 100, 30)
@@ -103,15 +103,15 @@ describe('BrowserTerminal', () => {
 
   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')
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'read-only' } })
     const first = await attach(terminal)
     output.end()
     outcome.resolve({ exitCode: 0, signal: null })
     expect(await readFrame(first.iterator)).toMatchObject({ type: 'state', info: { state: 'exited' } })
-    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running')
-    await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toThrow('not running')
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
+    await expect(terminal.resize(attachment('first'), 100, 30)).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
     const closing = terminal.close()
-    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toThrow('not running')
+    await expect(terminal.write(attachment('first'), 'ignored')).rejects.toMatchObject({ code: 'terminal/control-unavailable', details: { reason: 'not-running' } })
     await closing
     expect(handle.write).not.toHaveBeenCalled()
     expect(handle.resize).not.toHaveBeenCalled()

+ 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: 1a0a34119cc91f2e7c829e8b0c1dc56ad74d2631
-README.zh.md: 7d611eb4a4d4daa0c00baa8658b8b3e84288471e
+README.md: 9278274ed8c4ab6e52890acd58d006c10788c053
+README.zh.md: ced37253897b43d7e6f74c850c75f844e855bb3b

+ 1 - 1
packages/client/ui-sidebar-right/README.md

@@ -93,7 +93,7 @@ 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.
+Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close.
 
 <a id="the-guide"></a>
 ## The guide

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

@@ -93,7 +93,7 @@ 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 卸载,不代表显式关闭。
+标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
 
 <a id="the-guide"></a>
 ## 引导页

+ 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 | Promise<void>
+  close(): void
 }
 
 /** Live information shared by a tab's body, title, and guide replacement. */

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

@@ -163,8 +163,8 @@ export function apply(ctx: ClientContext): void {
         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) })
+            try { controller.closeIn(sessionId, tabId) }
+            catch (error) { 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),

+ 12 - 29
packages/client/ui-sidebar-right/src/client/service.ts

@@ -134,8 +134,8 @@ 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>
+/** Synchronous close/replacement hook; resource owners retain any background cleanup. */
+export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => void
 
 /** The outward right-Sidebar face (`ctx.sidebarRight`). */
 export interface ISidebarRight {
@@ -163,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 | Promise<void>
+  close(tabId: TabId): void
   /**
    * The active tab of the active pane.
    * @returns the record, or `undefined` when no seat is mounted.
@@ -207,12 +207,11 @@ export interface ISidebarRight {
 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.
+   * @param handler - saves any background cleanup before returning and allowing removal.
    * @returns an effect-scoped unregister callback.
    */
   registerCloseHandler(kind: string, handler: SidebarRightCloseHandler): () => void {
@@ -308,33 +307,19 @@ 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 | Promise<void> {
+  closeIn(sessionId: SessionId, tabId: TabId): void {
     const actions = this.actionsFor(sessionId)
     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) })
+    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
+  private removeAfterCleanup(sessionId: SessionId, tab: TabRecord, commit: () => void): void {
+    this.closeHandlers.get(tab.kind)?.(sessionId, tab)
+    commit()
   }
 
   /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */
@@ -385,18 +370,16 @@ export class SidebarRightController implements ISidebarRight {
     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) })
+    this.removeAfterCleanup(sessionId, replaced, commit)
   }
 
   /**
    * 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 | Promise<void> {
+  close(tabId: TabId): void {
     const { sessionId, actions } = this.require()
-    if (this.adopted.has(sessionId)) return this.closeIn(sessionId, tabId)
+    if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return }
     actions.closeTab(sessionId, tabId)
   }
 

+ 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 | Promise<void>
+  closeIn(sessionId: SessionId, tabId: TabId): 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 - 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)
-    void occurrence.tabActions.close()
+    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.
-    void tabActions.close()
+    tabActions.close()
     expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined()
     expect(ctx.get('sidebarRight')).toBeUndefined()
     expect(ctx.get('sidebarRightTabs')).toBeUndefined()

+ 7 - 7
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(() => { void h.controller.close(initial.id) })
+      act(() => { 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(() => { void h.controller.close(initial.id) })
+    act(() => { 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(() => { void h.controller.close(reseeded.id) })
+    act(() => { 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(() => { void h.controller.close(preview.id) })
+    act(() => { 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(() => { void info.tab.actions.close() })
+    act(() => { info.tab.actions.close() })
     expect(info.tab.signal.aborted).toBe(true)
     expect(otherInfo.tab.signal.aborted).toBe(false)
     await h.runtime.sessions.setCurrent(SESSION)
@@ -580,11 +580,11 @@ describe('intentsFor — the kit\'s gestures as one session\'s store actions', (
   })
 })
 
-it('keeps a resource tab and reports cleanup rejection from its close button', async () => {
+it('keeps a resource tab and reports a synchronous cleanup failure 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 release = h.controller.registerCloseHandler('text', () => { throw failure })
   const logged = vi.spyOn(console, 'error').mockImplementation(() => {})
   try {
     fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${tab.id}"]`))

+ 25 - 88
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(() => { void controller.close('tab1' as TabId) }).toThrow('no session surface is mounted')
+    expect(() => { 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()
-    void controller.close(tabOf('a.txt'))
+    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()
-    void guideOccurrence.tabActions.close()
+    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')
-    void tabActions.close()
+    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()
-    void tabActions.close()
+    tabActions.close()
     expect(titles()).toContain('a.txt')
   })
 
@@ -591,12 +591,12 @@ describe('explicit tab resource cleanup', () => {
     const unregister = h.controller.registerCloseHandler('text', handler)
     try {
       const before = h.entries()
-      if (operation === 'close') expect(h.controller.close(tabId)).toBeUndefined()
+      if (operation === 'close') h.controller.close(tabId)
       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()
+      h.controller.closeIn(SESSION, tabId)
       expect(handler).toHaveBeenCalledOnce()
     } finally {
       unregister()
@@ -605,72 +605,47 @@ describe('explicit tab resource cleanup', () => {
     }
   })
 
-  it('awaits cleanup once before closing and never closes on collapse', async () => {
+  it('does not invoke cleanup on collapse or when revealing the same tab', () => {
     const h = harness()
     h.adopt(SESSION, h.instance)
     h.publish()
-    h.controller.openResource('dsh-resource://file/session/s-test/terminal')
+    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)
+    const handler = vi.fn()
     h.controller.registerCloseHandler('text', handler)
     h.controller.toggleExpanded()
+    h.controller.openResource(address, { replaceTab: tabId })
     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 () => {
+  it.each(['close', 'replace'] as const)('preserves a tab when its synchronous %s handler throws', (operation) => {
     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')
+    const release = h.controller.registerCloseHandler('text', () => { throw new Error('cannot retain cleanup') })
+    const perform = () => {
+      if (operation === 'close') h.controller.closeIn(SESSION, tabId)
+      else h.controller.openTab('guide', { replaceTab: tabId, revealIfOpened: false })
+    }
+    expect(perform).toThrow('cannot retain cleanup')
     expect(h.layout().tabs[tabId]).toBeDefined()
-    await h.controller.closeIn(SESSION, tabId)
+    release()
+    perform()
     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 () => {
+it('releases close handlers without a stale disposer removing a replacement', () => {
   const h = harness()
   h.adopt(SESSION, h.instance)
   h.publish()
-  const first = vi.fn(async () => {})
-  const second = vi.fn(async () => {})
+  const first = vi.fn(() => {})
+  const second = vi.fn(() => {})
   const release = h.controller.registerCloseHandler('text', first)
   expect(() => h.controller.registerCloseHandler('text', second)).toThrow('already registered')
   release()
@@ -678,7 +653,7 @@ it('releases close handlers without a stale disposer removing a replacement', as
   release()
   h.controller.openResource('dsh-resource://file/session/s-test/one')
   h.publish()
-  await h.controller.closeIn(SESSION, h.tabOf('one'))
+  h.controller.closeIn(SESSION, h.tabOf('one'))
   expect(first).not.toHaveBeenCalled()
   expect(second).toHaveBeenCalledOnce()
   releaseSecond()
@@ -689,41 +664,3 @@ it('releases close handlers without a stale disposer removing a replacement', as
   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 })
-    void tabActions.close()
+    tabActions.close()
     expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId)
   })
 

+ 2 - 2
packages/client/ui-sidebar-terminal/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-terminal/README.md
-README.md: cf915dc73c9009b4b91d084d9b7aee466e4f2c75
-README.zh.md: a7aa321d106d49061ae7b092f6e116cab153627c
+README.md: 2dba3ea839f89f505286fa6b977aef3fb0e45ab0
+README.zh.md: 63d483a37fcb9b913c20e47251aaaba3e0e21ffd

+ 1 - 1
packages/client/ui-sidebar-terminal/README.md

@@ -27,7 +27,7 @@ Open the execution environment's default shell beside a conversation without lea
 
 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.
+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. Exited terminals count toward the Session limit; close unused tabs when the limit is reached.
 
 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.
 

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

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 展开右侧栏,在开始页选择**新建终端**;已有其他标签页时,先选择**新标签页**。默认 shell 会立即启动。通过侧栏的**新标签页**控件打开更多终端。
 
-双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。
+双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。
 
 关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。
 

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

@@ -23,7 +23,7 @@ export function TerminalBody({ useTabInfo, useTerminal, view, t }: TerminalBodyP
   const state = useTerminal(tab.id)
   useEffect(() => model.mount(), [model])
   if (state === undefined) return null
-  const error = state.error ?? state.info?.error
+  const error = state.issue === undefined ? state.error ?? state.info?.error : t(state.issue)
   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)

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

@@ -16,6 +16,11 @@ export const zh = {
   closed: '终端已关闭。', exited: '进程已退出({code})', failed: '终端错误:{message}',
   rename: '终端名称', unavailable: '不可用', retry: '重试',
   cleanupFailed: '终端「{title}」未能结束:{message}',
+  missingTerminal: '此终端已不存在,请新建终端。',
+  inputFull: '输入缓冲区已满,请重新连接后重试。',
+  attachmentEnded: '终端连接已结束,请重新连接。',
+  invalidOutput: '终端画面传输异常,请重新连接。',
+  terminalLimit: '终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。',
 } satisfies Record<string, string>
 
 /** English terminal copy. */
@@ -27,4 +32,9 @@ export const en = {
   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}',
+  missingTerminal: 'This terminal no longer exists. Open a new terminal.',
+  inputFull: 'The input buffer is full. Reconnect and try again.',
+  attachmentEnded: 'The terminal connection ended. Reconnect to continue.',
+  invalidOutput: 'The terminal screen could not be received. Reconnect to recover it.',
+  terminalLimit: 'The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit.',
 } satisfies Record<keyof typeof zh, string>

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

@@ -87,10 +87,10 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     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()
+    h.closeHandler(sessionId, { id: 'tab' } as Parameters<SidebarRightCloseHandler>[1])
     expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId)
     h.setParams(undefined)
-    expect(h.closeHandler(sessionId, { id: 'new-tab' } as Parameters<SidebarRightCloseHandler>[1])).toBeUndefined()
+    h.closeHandler(sessionId, { id: 'new-tab' } as Parameters<SidebarRightCloseHandler>[1])
     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)

+ 14 - 2
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -60,7 +60,7 @@ const titleSurfaces = [
     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) {
+function mount(initial: TerminalViewState | undefined = idle, dictionary = en) {
   let state: TerminalViewState | undefined = initial
   let visible = true
   const detach = vi.fn()
@@ -74,7 +74,7 @@ function mount(initial: TerminalViewState | undefined = idle) {
   const props = {
     view: () => model,
     useTerminal: (_key: string, select?: (value: TerminalViewState | undefined) => unknown) => select === undefined ? state : select(state),
-    useTabInfo: tab, t: makeTranslate(en),
+    useTabInfo: tab, t: makeTranslate(dictionary),
   } as unknown as TerminalBodyProps
   const view = render(<TerminalBody {...props} />)
   return {
@@ -297,3 +297,15 @@ it('starts a recovered screen with no local history when environment discovery i
   mount({ ...idle, info, environment: undefined })
   expect(fake.terminals[0]!.options).toHaveProperty('scrollback', 0)
 })
+
+
+it.each([en, zh])('translates known terminal failures while retaining unknown Host diagnostics', (dictionary) => {
+  const h = mount(idle, dictionary)
+  for (const issue of ['missingTerminal', 'inputFull', 'attachmentEnded', 'invalidOutput', 'terminalLimit'] as const) {
+    h.update({ ...idle, phase: 'failed', issue, error: 'raw diagnostic' })
+    expect(h.view.getByRole('alert').textContent).toContain(dictionary[issue])
+    expect(h.view.getByRole('alert').textContent).not.toContain('raw diagnostic')
+  }
+  h.update({ ...idle, phase: 'failed', error: 'Host permission denied' })
+  expect(h.view.getByRole('alert').textContent).toContain('Host permission denied')
+})

+ 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: 11cb60e3966b2dd7bd20021a9cb6e3806b41df3e
-README.zh.md: 14834748bbc82d87d3178ab75c7b470184b4e8c3
+README.md: 344abc6b8c9c26306696d59d9a56415eb69fbd3d
+README.zh.md: 1a96f3a85da34349f02a78bcd7b51dbeb35e025c

+ 1 - 1
packages/e2b/subprocess-e2b/README.md

@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-Use this package when the agent's shell commands and terminals should run inside the remote sandbox rather than on your machine. It is the command half of the E2B family: commands, terminals, and files share one remote world.
+Use this package when the agent's shell commands and terminals should run inside the remote sandbox rather than on your machine. It is the command half of the E2B family: commands, terminals, and files share one remote world. `terminalEnvironment()` reports the remote sandbox’s nonempty `SHELL`; an empty or absent value leaves shell fallback to the consumer.
 
 ### When to choose it
 

+ 1 - 1
packages/e2b/subprocess-e2b/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-当 agent 的 shell 命令与终端应在远程沙箱内而非你的机器上运行时,使用本包。它是 E2B 家族的命令半边:命令、终端与文件共享同一个远程世界。
+当 agent 的 shell 命令与终端应在远程沙箱内而非你的机器上运行时,使用本包。它是 E2B 家族的命令半边:命令、终端与文件共享同一个远程世界。 `terminalEnvironment()` 返回远端沙箱中非空的 `SHELL`;空值或缺失值由消费者决定 shell 回退。
 
 ### 何时选择
 

+ 1 - 1
packages/e2b/subprocess-e2b/src/index.ts

@@ -150,7 +150,7 @@ export class E2BSubprocessRuntime extends SubprocessRuntime {
   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')
+    const defaultShell = environment.get('SHELL') || undefined
     return { platform: 'posix', ...defaultShell === undefined ? {} : { defaultShell } }
   }
 

+ 2 - 0
packages/e2b/subprocess-e2b/tests/terminal.spec.ts

@@ -859,6 +859,8 @@ describe('E2B subprocess terminal service', () => {
     try {
       fake.ambient = 'SHELL=/opt/fish\0'
       await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/opt/fish' })
+      fake.ambient = 'SHELL=\0'
+      await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' })
       fake.ambient = 'PATH=/usr/bin\0'
       await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' })
       fake.commandFailure = new Error('sandbox disconnected')

+ 2 - 2
packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md
-README.md: 11fd4cbcd4150cae75f15d5b5cc2d5294c2576ae
-README.zh.md: 4bc0bc284770b68ef9cd8c6d09f39ccc8866d0f2
+README.md: 27cbdd6973b0b829e7448b3db131ae1041038c68
+README.zh.md: 564c3cc1947e97b9cb4ae2d68c1c0c77bcc0d104

+ 1 - 1
packages/subprocess/subprocess-local/README.md

@@ -29,7 +29,7 @@ Mount the provider beside its consumers and start processes exactly as the subpr
 
 ### Mounting the provider
 
-Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration.
+Load the provider in the same composition as its consumers. It has no config fields: every choice arrives on the spawn request, so deployment-varying decisions stay with the caller's configuration. `terminalEnvironment()` reads a nonempty `SHELL` on POSIX, falling back to the account login shell, or a nonempty `ComSpec` on Windows. Empty values are omitted so the consumer can choose its platform fallback.
 
 ```yaml
 - name: '@deepseek-ai/dsh-subprocess-local'

+ 1 - 1
packages/subprocess/subprocess-local/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 挂载提供方
 
-在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。
+在与消费方相同的组合中加载本提供方。它没有任何配置字段:每项选择都随 spawn 请求到达,因此随部署变化的决策留在调用方的配置里。 `terminalEnvironment()` 在 POSIX 读取非空的 `SHELL`,缺失时使用账户登录 shell;在 Windows 读取非空的 `ComSpec`。空值会被省略,由消费者选择平台回退。
 
 ```yaml
 - name: '@deepseek-ai/dsh-subprocess-local'

+ 1 - 1
packages/subprocess/subprocess-local/src/index.ts

@@ -233,7 +233,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
   async terminalEnvironment(signal?: AbortSignal): Promise<SubprocessTerminalEnvironment> {
     signal?.throwIfAborted()
     const platform = process.platform === 'win32' ? 'windows' : 'posix'
-    const defaultShell = platform === 'windows' ? process.env.ComSpec : process.env.SHELL ?? userInfo().shell ?? undefined
+    const defaultShell = platform === 'windows' ? process.env.ComSpec || undefined : process.env.SHELL || userInfo().shell || undefined
     return { platform, ...defaultShell === undefined ? {} : { defaultShell } }
   }
 

+ 6 - 0
packages/subprocess/subprocess-local/tests/local.spec.ts

@@ -64,6 +64,10 @@ describe('LocalSubprocessRuntime', () => {
       await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({
         platform: 'posix', defaultShell: '/account/shell',
       })
+      vi.stubEnv('SHELL', '')
+      await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix', defaultShell: '/account/shell' })
+      loginShell = ''
+      await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' })
       loginShell = null
       await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'posix' })
       platform.mockReturnValue('win32')
@@ -73,6 +77,8 @@ describe('LocalSubprocessRuntime', () => {
       })
       vi.stubEnv('ComSpec', undefined)
       await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' })
+      vi.stubEnv('ComSpec', '')
+      await expect(ctx.subprocess.terminalEnvironment()).resolves.toEqual({ platform: 'windows' })
       platform.mockReturnValue('linux')
       userInfo.mockClear()
       const reason = new Error('terminal inspection cancelled')