Browse Source

Merge pull request #4206 from deepseek-harness/worktree/sidebar-session-recovery

feat(web): restore sidebar layouts and reclaim unattended terminals
Yichen Jiang 4 weeks ago
parent
commit
358384f045
100 changed files with 2832 additions and 211 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.i18n.yaml
  5. 35 0
      .agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.md
  6. 35 0
      .agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  8. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  9. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
  11. 6 4
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
  12. 6 4
      .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
  13. 6 0
      .agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.i18n.yaml
  14. 56 0
      .agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.md
  15. 56 0
      .agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.zh.md
  16. 3 0
      apps/web/tests/expected/sidebar-terminal/disconnected.expected.md
  17. 2 0
      apps/web/tests/expected/sidebar-terminal/unavailable.expected.md
  18. 3 0
      apps/web/tests/fixtures/sidebar-terminal.patch.yml
  19. 47 2
      apps/web/tests/seeded-history.e2e.ts
  20. 35 11
      apps/web/tests/sidebar-right.e2e.ts
  21. 154 3
      apps/web/tests/sidebar-terminal.e2e.ts
  22. 1 0
      benchmarks/terminal-io/terminal-io.worker.ts
  23. 2 2
      docs/config-catalog.i18n.yaml
  24. 6 0
      docs/config-catalog.md
  25. 6 0
      docs/config-catalog.zh.md
  26. 2 2
      docs/subsystems/subprocess.i18n.yaml
  27. 3 1
      docs/subsystems/subprocess.md
  28. 3 1
      docs/subsystems/subprocess.zh.md
  29. 2 2
      docs/subsystems/workspace.i18n.yaml
  30. 9 0
      docs/subsystems/workspace.md
  31. 9 0
      docs/subsystems/workspace.zh.md
  32. 2 2
      packages/api/terminal-controller/README.i18n.yaml
  33. 11 3
      packages/api/terminal-controller/README.md
  34. 11 3
      packages/api/terminal-controller/README.zh.md
  35. 63 0
      packages/api/terminal-controller/src/client/bindings.ts
  36. 74 8
      packages/api/terminal-controller/src/client/index.ts
  37. 12 5
      packages/api/terminal-controller/src/client/model.ts
  38. 76 0
      packages/api/terminal-controller/src/client/retention.ts
  39. 56 19
      packages/api/terminal-controller/src/index.ts
  40. 132 0
      packages/api/terminal-controller/src/retention.ts
  41. 39 1
      packages/api/terminal-controller/src/terminal.ts
  42. 7 0
      packages/api/terminal-controller/src/types.ts
  43. 55 0
      packages/api/terminal-controller/tests/bindings.client.spec.ts
  44. 86 2
      packages/api/terminal-controller/tests/controller.spec.ts
  45. 7 0
      packages/api/terminal-controller/tests/model.client.spec.ts
  46. 254 27
      packages/api/terminal-controller/tests/recovery.client.spec.ts
  47. 58 0
      packages/api/terminal-controller/tests/retention.client.spec.ts
  48. 195 0
      packages/api/terminal-controller/tests/retention.spec.ts
  49. 8 0
      packages/api/terminal-controller/tests/terminal.spec.ts
  50. 2 0
      packages/api/terminal-controller/tsconfig.client.json
  51. 1 0
      packages/api/terminal-controller/tsconfig.host.json
  52. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  53. 5 3
      packages/client/ui-sidebar-right/README.md
  54. 5 3
      packages/client/ui-sidebar-right/README.zh.md
  55. 2 1
      packages/client/ui-sidebar-right/package.json
  56. 6 2
      packages/client/ui-sidebar-right/src/client/index.ts
  57. 100 0
      packages/client/ui-sidebar-right/src/client/persistence.ts
  58. 23 3
      packages/client/ui-sidebar-right/src/client/service.ts
  59. 14 2
      packages/client/ui-sidebar-right/src/client/stores.ts
  60. 59 0
      packages/client/ui-sidebar-right/src/client/tab-inventory.ts
  61. 4 0
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  62. 102 0
      packages/client/ui-sidebar-right/tests/persistence-validation.client.spec.ts
  63. 93 0
      packages/client/ui-sidebar-right/tests/persistence.client.spec.ts
  64. 2 2
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  65. 66 0
      packages/client/ui-sidebar-right/tests/tab-inventory.client.spec.ts
  66. 2 2
      packages/client/ui-sidebar-terminal/README.i18n.yaml
  67. 5 3
      packages/client/ui-sidebar-terminal/README.md
  68. 5 3
      packages/client/ui-sidebar-terminal/README.zh.md
  69. 4 3
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css
  70. 21 5
      packages/client/ui-sidebar-terminal/src/client/TerminalBody.tsx
  71. 12 2
      packages/client/ui-sidebar-terminal/src/client/index.ts
  72. 32 12
      packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
  73. 44 4
      packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx
  74. 16 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  75. 2 2
      packages/ssh/ssh/README.i18n.yaml
  76. 2 0
      packages/ssh/ssh/README.md
  77. 2 0
      packages/ssh/ssh/README.zh.md
  78. 9 2
      packages/ssh/ssh/src/helper-processes.ts
  79. 2 2
      packages/ssh/ssh/src/helper.ts
  80. 3 0
      packages/ssh/ssh/src/schemas.ts
  81. 21 1
      packages/ssh/ssh/tests/helper-processes.spec.ts
  82. 2 2
      packages/ssh/subprocess-ssh/README.i18n.yaml
  83. 2 0
      packages/ssh/subprocess-ssh/README.md
  84. 2 0
      packages/ssh/subprocess-ssh/README.zh.md
  85. 3 2
      packages/ssh/subprocess-ssh/src/index.ts
  86. 2 1
      packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts
  87. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  88. 5 0
      packages/subprocess/subprocess-local/README.md
  89. 5 0
      packages/subprocess/subprocess-local/README.zh.md
  90. 17 15
      packages/subprocess/subprocess-local/src/index.ts
  91. 9 0
      packages/subprocess/subprocess-local/src/linux-scope.ts
  92. 5 0
      packages/subprocess/subprocess-local/src/managed-owner.ts
  93. 26 12
      packages/subprocess/subprocess-local/src/process-inspector.ts
  94. 131 0
      packages/subprocess/subprocess-local/src/shell-activity.ts
  95. 54 2
      packages/subprocess/subprocess-local/src/terminal.ts
  96. 25 0
      packages/subprocess/subprocess-local/tests/linux-scope.spec.ts
  97. 3 0
      packages/subprocess/subprocess-local/tests/local.spec.ts
  98. 24 0
      packages/subprocess/subprocess-local/tests/process-inspector.spec.ts
  99. 81 0
      packages/subprocess/subprocess-local/tests/shell-activity-files.spec.ts
  100. 115 0
      packages/subprocess/subprocess-local/tests/shell-activity.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
-2026-09-07-sidebar-responsive-tab-info.md: ade20148cccd2233224417f762e65492828943bb
-2026-09-07-sidebar-responsive-tab-info.zh.md: 0903e4ff06ca187fcc1fd0e9d8de63f70d5cc427
+2026-09-07-sidebar-responsive-tab-info.md: 96f7f047d00a33d6cd48b026ce7762929720d9d6
+2026-09-07-sidebar-responsive-tab-info.zh.md: f020068e71777272fd15a967de547ce153884402

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md

@@ -12,7 +12,7 @@ Tab extensions need consistent live information about their containing pane and
 
 
 The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel`, and `tab` fields. It composes the framework-bound layout and navigation hooks; extensions neither subscribe themselves nor receive a service object. Body visibility requires an active tab in an expanded Sidebar; title visibility does not require an active tab. Hiding or switching Sessions leaves the tab lifetime intact. Closing the record aborts its signal. Tab actions stay bound to their owning Session. Store adoption is a private capability of the plugin assembly, not a public controller operation.
 The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel`, and `tab` fields. It composes the framework-bound layout and navigation hooks; extensions neither subscribe themselves nor receive a service object. Body visibility requires an active tab in an expanded Sidebar; title visibility does not require an active tab. Hiding or switching Sessions leaves the tab lifetime intact. Closing the record aborts its signal. Tab actions stay bound to their owning Session. Store adoption is a private capability of the plugin assembly, not a public controller operation.
 
 
-The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. Refresh restores defaults rather than persisting layout.
+The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. [Layout persistence and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) owns layout restoration after reload; frame width preferences still initialize from the viewport.
 
 
 Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled. On exit, the frame prepares the destination before the overlay retreats: no right track for close, a normal track for restore. Transition suppression survives clearing the fullscreen report and ends on the next geometry action.
 Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled. On exit, the frame prepares the destination before the overlay retreats: no right track for close, a normal track for restore. Transition suppression survives clearing the fullscreen report and ends on the next geometry action.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 
 Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 `tab` 字段。它组合框架绑定的布局与导航 hook;扩展既不自行订阅,也不接收服务对象。正文可见要求 Sidebar 展开且标签活跃;标题可见不要求标签活跃。隐藏或切换 Session 保留标签生命周期。关闭记录会中止其 signal。标签动作始终绑定到所属 Session。Store 收编是插件组装的私有能力,不是公共控制器操作。
 Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 `tab` 字段。它组合框架绑定的布局与导航 hook;扩展既不自行订阅,也不接收服务对象。正文可见要求 Sidebar 展开且标签活跃;标题可见不要求标签活跃。隐藏或切换 Session 保留标签生命周期。关闭记录会中止其 signal。标签动作始终绑定到所属 Session。Store 收编是插件组装的私有能力,不是公共控制器操作。
 
 
-框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。刷新恢复默认值,不持久化布局。
+框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。[布局持久化与 provider 恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)负责刷新后的布局还原;框架宽度偏好仍根据视口初始化。
 
 
 全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。 退场时,框架先准备目标布局,再让覆盖层退出:关闭不留右轨道,恢复保留普通轨道。清除全屏报告时仍保留过渡抑制,直到下一次几何操作才结束。
 全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。 退场时,框架先准备目标布局,再让覆盖层退出:关闭不留右轨道,恢复保留普通轨道。清除全屏报告时仍保留过渡抑制,直到下一次几何操作才结束。
 
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.i18n.yaml

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

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.md

@@ -0,0 +1,35 @@
+# Agent Note: Sidebar layout persistence and provider recovery
+
+Status: implemented
+
+English | [中文](2026-09-14-sidebar-layout-provider-recovery.zh.md)
+
+## Problem
+
+Reloading the browser loses open files, pane placement and selection. Recovering retained terminals by opening new tabs cannot reproduce that arrangement and can reopen a collapsed sidebar.
+
+## Decision
+
+The sidebar persists a validated current-layout snapshot and identity counter per Session, while the Client store keeps undo history in memory. Invalid saved types or layout references clear only that Session snapshot. Undo history resets on reload so persisted data does not grow with past interactions. It restores layout and tab identities before rendering bodies, including resource pins. Providers restore their own content; the sidebar has no terminal-specific state or reconnection logic. Transient navigation parameters and resource contents remain outside the layout snapshot.
+
+The terminal provider reads the adopted Session's tab records and restores terminal models before querying Host terminals that have no view. Its Client controller saves globally unique content-to-terminal identities as independent records before allocation, reuses them after reload, and removes them after recording explicit close intent. Layout-local tab ids identify separate live views but cannot identify persisted processes across windows; per-record writes and deletes avoid overwriting unrelated bindings. A restored identity never authorizes creating a replacement process. The Host owns process liveness, titles and screen contents; the saved association is only a recovery target. A saved association without a restored tab does not suppress Host discovery.
+
+OpenCode's `packages/app/src/context/layout.tsx` saves Session tabs, while `context/terminal.tsx` separately saves terminal identities. DSH uses its existing Session-scoped store and provider services for the same separation, retaining Session ownership for terminals.
+
+This partially supersedes the persistence exclusion in the [Web terminal decision](../feature/2026-09-09-web-sidebar-terminal.md). That note remains active because process ownership, cleanup, streaming and input-control decisions still apply.
+
+## Alternatives considered
+
+**Recover every retained terminal as a new tab.** Host discovery cannot recover tab placement, selection or a collapsed layout and duplicates restored tabs.
+
+**Put terminal recovery into the sidebar.** That would make the layout owner depend on a specific content provider. A generic read of committed tab records lets each provider restore its own state.
+
+**Persist process metadata and terminal output.** These values become stale independently of browser layout. Reattaching to the Host supplies current metadata and a consistent screen.
+
+## Consequences
+
+Layout survives reload within the same browser origin, with independent Session storage keys. Windows share the last saved layout for each Session; active windows retain their own layout until reload. Storage failure leaves memory state usable. Browser reload does not restart missing or exited processes; Host restart still cannot restore a shell. Provider state requires provider-owned recovery support, and transient file navigation parameters are not restored.
+
+Store tests cover layout round trips, bounded persisted state, invalid records, Session isolation and resource adoption. Terminal tests cover identity reuse, missing targets, inactive closes and discovery without a restored tab. Assembled-browser tests exercise file preview, split panes, selection, fullscreen and collapsed reload with real terminal process identities.
+
+The [two-hour unattended-terminal reclamation](../feature/2026-09-14-unattended-browser-terminal-reclamation.md) extends provider recovery with window-held terminal lifetimes while retaining this layout/content ownership split.

+ 35 - 0
.agents/notes/implemented/architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 侧栏布局持久化与 provider 恢复
+
+Status: implemented
+
+[English](2026-09-14-sidebar-layout-provider-recovery.md) | 中文
+
+## Problem
+
+刷新浏览器会丢失已打开文件、分栏位置和选中项。把保留的终端重新打开为新标签无法还原布局,还可能展开原本折叠的侧栏。
+
+## Decision
+
+侧栏按 Session 持久化经过校验的当前布局快照和身份计数,Client store 将撤销历史保留在内存中。保存的类型或布局引用无效时,只清理对应 Session 的快照。刷新后撤销历史清空,因此持久化数据不会随过去的操作持续增长。它在渲染正文前恢复布局和标签身份,并建立资源 pin。provider 恢复自身内容;侧栏不保存终端专属状态,也不负责重连。临时导航参数和资源内容不进入布局快照。
+
+terminal provider 读取已采用的 Session 标签记录,先恢复终端模型,再查询尚无视图的 Host 终端。其 Client controller 在分配前将全局唯一内容与终端身份的关联保存为独立记录,刷新后复用,并在记录显式关闭意图后删除。布局内的 tab id 标识独立活动视图,不能跨窗口标识持久化进程;逐条记录的写入和删除避免覆盖无关关联。恢复身份不允许创建替代进程。Host 负责进程存活状态、标题和屏幕内容;保存的关联只是恢复目标。没有对应恢复标签的关联不会阻止 Host 探测。
+
+OpenCode 的 `packages/app/src/context/layout.tsx` 保存 Session 标签,`context/terminal.tsx` 独立保存终端身份。DSH 使用现有 Session 作用域 store 和 provider 服务实现相同的职责划分,终端仍归 Session 所有。
+
+本决策部分替代 [Web 终端决策](../feature/2026-09-09-web-sidebar-terminal.zh.md)中不持久化的限定。原记录继续保留,因为进程归属、清理、流传输和输入控制决策仍适用。
+
+## Alternatives considered
+
+**把每个保留终端恢复为新标签。** Host 探测无法还原标签位置、选中项或折叠布局,还会重复创建已恢复的标签。
+
+**让侧栏负责终端恢复。** 布局所有者会因此依赖特定内容 provider。提供通用的已提交标签查询即可让各 provider 恢复自身状态。
+
+**持久化进程元数据和终端输出。** 这些值会独立于浏览器布局失效。重新连接 Host 能取得当前元数据和一致的屏幕。
+
+## Consequences
+
+同一浏览器站点内刷新会保留布局,各 Session 使用独立存储键。窗口共享每个 Session 最后保存的布局;活动窗口在刷新前保留各自布局。存储失败时内存状态仍可用。刷新不会重启缺失或已退出的进程;Host 重启仍无法恢复 shell。provider 状态需要自身支持恢复,临时文件导航参数不恢复。
+
+store 测试覆盖布局读写、有界持久化状态、无效记录、Session 隔离和资源采用。终端测试覆盖身份复用、目标缺失、未激活标签关闭,以及没有恢复标签时的探测。完整浏览器测试使用真实终端进程身份,验证文件预览、分栏、选中项、全屏和折叠状态下的刷新。
+
+[无人连接终端的两小时回收](../feature/2026-09-14-unattended-browser-terminal-reclamation.zh.md)为 provider 恢复增加窗口持有的终端生命周期,保留此处的布局/内容职责划分。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
-2026-09-04-right-sidebar-docking-infrastructure.md: 936f73c9210baf4297eace2deb270bb9594d7e8d
-2026-09-04-right-sidebar-docking-infrastructure.zh.md: d234dbcbb2f60ba1dd3b3d9fca2936a16521751d
+2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02
+2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md

@@ -39,7 +39,7 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes;
 
 
 [Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes.
 [Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes.
 
 
-`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
+`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and an expanded empty root pane receives the current default page. A collapsed surface may remain empty until its next expansion; no separate pane-closing gesture exists. [Layout persistence and provider recovery](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) owns Session-scoped browser storage and reload. Layout is presentation state and never enters the session log.
 
 
 ### Beyond the surface
 ### Beyond the surface
 
 
@@ -76,7 +76,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
 ## Consequences
 ## Consequences
 
 
 - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split.
 - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split.
-- Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls.
+- Layout is undoable, persisted and per Session. Undo is reachable only through `@internal` service methods; the product shows no history controls.
 - An expanded surface has no empty panes. Empty side panes merge away; an empty root receives the current default page only while expanded. New sessions and a collapsed surface whose last tab closed remain empty until expansion.
 - An expanded surface has no empty panes. Empty side panes merge away; an empty root receives the current default page only while expanded. New sessions and a collapsed surface whose last tab closed remain empty until expansion.
 - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide.
 - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide.
 - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement.
 - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement.

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md

@@ -39,7 +39,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 
 [默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。
 [默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。
 
 
-`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
+`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;展开且为空的根 pane 会填入当前默认页。折叠的布局可以保持为空,直到下次展开;没有单独的关闭 pane 手势。[布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责 Session 作用域的浏览器存储和刷新。布局是呈现状态,永不进入会话日志。
 
 
 ### 面之外
 ### 面之外
 
 
@@ -76,7 +76,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 ## Consequences
 ## Consequences
 
 
 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。
 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。
-- 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。
+- 布局可撤销、可持久化且按 Session 隔离。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。
 - 展开的布局不保留空 pane。空侧 pane 被合并,空根 pane 只在展开时填入当前默认页。新会话以及关闭最后一个 tab 后收起的布局保持为空,直到下次展开。
 - 展开的布局不保留空 pane。空侧 pane 被合并,空根 pane 只在展开时填入当前默认页。新会话以及关闭最后一个 tab 后收起的布局保持为空,直到下次展开。
 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。
 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。
 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。
 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。

+ 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;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: 9b0c1916872c04611f94bf00f7057cce99671c68
-2026-09-09-web-sidebar-terminal.zh.md: 5bc25f80cf6d33e07d2cec1b48e1f4ca7f7d750c
+2026-09-09-web-sidebar-terminal.md: 2649610e776029b10b11fc4ea87d75a24a58d32b
+2026-09-09-web-sidebar-terminal.zh.md: 9a3fd828ff52142cb945ff14d84d13c9e8522ecf

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

@@ -18,9 +18,9 @@ The application theme supplies terminal default colors. The body reads resolved
 
 
 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.
 Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
 
 
-Sidebar layout, open-tab mappings, selection and process PIDs are not persisted. When the Session header mounts, the Client queries `terminal.list` and opens retained Host terminals as new tabs. Listing takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Their `params.terminalId` association exists only in the current page. New and recovered views use different `createWhenMissing` values: only a new view may allocate a process; a recovered target that disappears reports an error. Host state supplies recovery identities and titles, so the browser does not maintain a second active-terminal registry.
+[Sidebar layout persistence and provider recovery](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) owns browser reload: layout and tab identities are restored before the terminal provider reconnects its views. Listing still takes the Session ID directly because history can outlive its Agent and terminal owner; an offline Session has no retained terminals to restore. Only a new view may allocate a process; a recovered target that disappears reports an error. The Host remains authoritative for process state, titles and screen contents.
 
 
-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 caller retains a terminal ID before creating it. Repeating create with the same Session and open ID does not allocate a second process. Closing an uncertain create uses that ID even when no creation response arrived. The Host records closed IDs before awaiting allocation, preventing a delayed create from reviving a closed terminal. Each attachment begins with a consistent, bounded serialized xterm screen; ordered output follows through the Gateway's existing multiplexed Remote stream. Output and screen snapshots share one operation queue. Followers retain final output on normal closure and fail explicitly on buffer overflow. Browser render acknowledgement prevents React batching from dropping increments.
 
 
 The latest attachment controls input and dimensions; other attachments remain read-only. Every physical stream opening gets a fresh input attachment identity, including automatic transport recovery. Input RPCs are serialized, and results from a superseded attachment cannot downgrade a replacement connection. Terminal output creates no model input, Agent tool result or Session event. The existing [persistent Agent terminal decision](2026-07-16-persistent-pty-sessions.md) continues to govern model-owned sessions; this feature extends the [portable subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.md) with terminal environment facts and resize. Control-transfer and process-exit refusals only disable input, preserving the healthy output view. Client-owned error identifiers are translated by the terminal UI, including guidance to close retained exited terminals when the quota is full.
 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.
 
 
@@ -30,7 +30,7 @@ The latest attachment controls input and dimensions; other attachments remain re
 
 
 **Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry.
 **Keep the tab visible until process cleanup finishes.** A slow or failed termination would delay the user's close action. Saving the cleanup intent allows immediate removal while preserving failure reporting and retry.
 
 
-**Persist sidebar layout or an active tab-to-process registry.** Layout persistence is outside this feature. An additional active registry duplicates Host state and can restore stale associations. Only an unfinished close is an independent user request that must survive page reload.
+**Persist an authoritative active-process registry.** Browser process state can become stale independently of Host state. The [layout persistence decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) supersedes the exclusion of saved tab associations while retaining Host authority for process liveness.
 
 
 **Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead.
 **Share the Agent terminal registry.** Its controlled prompts and semantic send/wait behavior would change human shell configuration and blur process ownership. User terminals share the subprocess capability instead.
 
 
@@ -44,6 +44,8 @@ The latest attachment controls input and dimensions; other attachments remain re
 
 
 ## Consequences
 ## Consequences
 
 
-A kept-open terminal retains a process and bounded screen memory. Reload restores Host-retained terminals rather than the previous sidebar layout; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
+A kept-open terminal retains a process and bounded screen memory. Reload restores the sidebar layout and reconnects Host-retained terminals; Host restart does not restore processes. An exited shell remains visible without automatic respawn. Background cleanup may outlive its tab, and unavailable browser storage limits retry recovery to the current page. Native PTY support and descendant cleanup guarantees remain provider-specific. One writable attachment avoids competing resize and input streams, while explicit takeover permits recovery from another page. Changing sandbox mode requires closing retained terminals first.
 
 
 The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.
 The implementation retains the Agent-terminal and portable-execution notes because their ownership and provider decisions remain independently useful; neither is superseded by browser terminals.
+
+The [two-hour unattended-terminal reclamation](2026-09-14-unattended-browser-terminal-reclamation.md) adds an idle grace period after all frontend holders disconnect, preserving busy or uncertain work.

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

@@ -18,9 +18,9 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
 
 
-侧栏布局、打开标签页映射、选中项和进程 PID 不持久化。Session header 挂载时,Client 查询 `terminal.list`,把 Host 保留的终端打开为新标签页。列表直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。`params.terminalId` 关联只在当前页面中保留。新视图与恢复视图使用不同的 `createWhenMissing`:只有新视图可以分配进程,恢复目标消失时显示错误。恢复标识和标题来自 Host 状态,浏览器不维护第二份活跃终端注册表。
+[侧栏布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责浏览器刷新:先恢复布局和标签身份,再由 terminal provider 重连视图。列表仍直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。只有新视图可以分配进程,恢复目标消失时显示错误。进程状态、标题和屏幕内容仍以 Host 为准。
 
 
-调用者在创建之前把终端 ID 保留在内存中。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。
+调用者在创建之前保留终端 ID。相同 Session 和未关闭 ID 的重复 create 不再分配进程。创建结果不确定时,关闭仍使用该 ID,即使没有收到创建响应。Host 在等待分配完成前记录已关闭 ID,防止迟到的 create 复活已关闭终端。每次连接先接收一致、有界的 xterm 序列化屏幕,后续有序输出使用 Gateway 已有的复用 Remote stream。输出和屏幕快照共享操作队列。订阅者正常关闭时保留末尾输出,缓存超限时明确失败。浏览器在完成渲染后确认帧,避免 React 批处理丢失增量。
 
 
 最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 RPC 按序发送,旧连接的结果不能把新连接降级为失败。终端输出不产生模型输入、Agent 工具结果或 Session 事件。[Agent 持久终端决策](2026-07-16-persistent-pty-sessions.zh.md)仍约束模型拥有的终端;此功能为[可移植 subprocess provider](../architecture/2026-07-28-portable-execution-world-consumers.zh.md)增加执行环境事实和 resize。 控制权转移和进程退出导致的拒绝只禁用输入,保留健康的输出视图。Client 自产错误标识由终端 UI 翻译,包括名额用满时关闭已保留的退出终端的提示。
 最新连接控制输入和尺寸,其他连接保持只读。每次物理流建立都创建新的输入连接标识,包括传输自动恢复。输入 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 翻译,包括名额用满时关闭已保留的退出终端的提示。
 
 
@@ -30,7 +30,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 
 **进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
 **进程清理完成前保留标签页。** 缓慢或失败的终止会拖延用户关闭操作。保存清理意图后,可以立即移除标签页,同时保留错误反馈与重试。
 
 
-**持久化侧栏布局或活跃标签页到进程的注册表。** 布局持久化不属于此功能。额外的活跃注册表重复 Host 状态,可能恢复过期关联。只有未完成的关闭操作是需要跨页面刷新保留的独立用户请求。
+**持久化作为权威的活跃进程注册表。** 浏览器进程状态可能独立于 Host 状态失效。[布局持久化决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)取代了不保存标签关联的限定,同时保留 Host 对进程存活状态的决定权。
 
 
 **共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。
 **共用 Agent 终端注册表。** 受控提示符和语义化 send/wait 会改变人工 shell 配置并混淆进程所有权。用户终端只共享 subprocess 能力。
 
 
@@ -44,6 +44,8 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
 
 
 ## 影响
 ## 影响
 
 
-保留终端会保留进程和有界屏幕内存。刷新恢复的是 Host 保留的终端,不是此前的侧栏布局;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
+保留终端会保留进程和有界屏幕内存。刷新恢复侧栏布局并重连 Host 保留的终端;Host 重启不恢复进程。shell 退出后保持可见,不自动重启。后台清理可能比标签页存活更久,浏览器存储不可用时只能在当前页面保留重试能力。原生 PTY 支持和后代进程清理保证仍由 provider 决定。单一可写连接避免竞争的输入和尺寸流,显式接管允许从另一页面恢复操作。改变 sandbox mode 前需要关闭保留的终端。
 
 
 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。
 Agent 终端和可移植执行环境两篇记录仍保留,其所有权与 provider 决策继续独立有效,不被浏览器终端取代。
+
+[无人连接终端的两小时回收](2026-09-14-unattended-browser-terminal-reclamation.zh.md)在全部前端持有者断开后设置空闲宽限期,保留忙碌或状态不确定的工作。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.i18n.yaml

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

+ 56 - 0
.agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.md

@@ -0,0 +1,56 @@
+# Agent Note: Two-hour reclamation of unattended browser terminals
+
+Status: implemented
+
+English | [中文](2026-09-14-unattended-browser-terminal-reclamation.zh.md)
+
+## Problem
+
+A browser can disappear without closing its terminal tabs. Keeping every abandoned terminal retains shells, descendant processes and screen buffers indefinitely. Output subscriptions do not identify abandonment: hidden tabs and inactive Sessions legitimately stop following the screen.
+
+## Decision
+
+A browser terminal is reclaimed only after no window holds it and the Host continuously confirms idle activity for two hours. Running work and unknown activity cancel the idle deadline. When work finishes, a fresh full grace period starts; command runtime and output silence never impose a deadline. Explicit tab close and Session or Host disposal retain their authority to terminate work.
+
+| Situation | Behavior |
+|---|---|
+| A connected window holds the tab, including a collapsed sidebar or inactive Session | Retain the terminal. |
+| One of several holding windows disappears | Retain while another window holds it. |
+| The final window disappears while a command is running, stopped, waiting for input, or running in the background | Preserve the work, regardless of runtime. |
+| No window holds a positively confirmed idle terminal | Start the full grace period and recheck before cleanup. |
+| A window returns before cleanup | Reattach the same process and cancel reclamation. |
+| The saved process is gone | Preserve the tab and show localized unavailability with a New terminal action. Only an explicit click replaces it in place with a fresh identity. |
+
+For example, a window disconnecting at 14:00 while a command runs until 20:00 cannot cause reclamation before 22:00. The deadline starts at the first subsequent confirmed idle observation.
+
+The [sidebar](../../../../packages/client/ui-sidebar-right/README.md#state) persists layout per Session and publishes `openTabs` metadata for saved and adopted Sessions. Startup discovery does not mount dormant content, pin files or activate Agents. Adopted stores are authoritative within their window; another window's storage writes cannot revoke those live holds. Permanent scope removal drops its metadata.
+
+The terminal provider intersects that inventory with its own saved terminal associations and unfinished close requests. Each window uses one `retain(sessionId, id, signal)` Remote stream per distinct terminal. Its acknowledgement grants a hold without screen output, Agent activation, input control or creation. Restored output waits for an acknowledged current hold. Transport generations cancel independently, and the existing Gateway reconnect and heartbeat mechanisms own transport liveness.
+
+The subprocess seam supplies `inspectActivity()` with a state and revision. Ordinary non-login Bash 4.4+ and Zsh launches support opt-in lifecycle records, combined with complete process-table observations and original process identities. Zsh distinguishes an empty top-level editor prompt from `vared`, selection and continuation input. Input invalidates prompt evidence; background and stopped descendants prevent idle. Native Linux also checks the systemd task count so escaped descendants still inside its owned scope remain protected. Custom traps, asynchronous Zsh descriptor handlers, unsupported launches and incomplete observations remain unknown. Lifecycle files are private and disappear after successful cleanup.
+
+The Host [terminal controller](../../../../packages/api/terminal-controller/README.md#use-this-package) owns configurable `unattendedTimeoutMs` (7200000), `activityPollIntervalMs` (30000) and `cleanupRetryMs` (60000). Zero disables automatic reclamation only. It uses a monotonic clock; stale observations after gaps longer than twice the polling interval reset the grace period. Input and hold changes invalidate in-flight observations. A successful final check marks the identity closed before asynchronous termination, preventing late creation and admission. Closed identities cannot be reused, and each cleanup remains attached to its original Session owner.
+
+Cleanup awaits provider quiescence and final screen output. Failure retains ownership, rejects new holders and schedules one retry without a new idle grace. Failed allocation cleanup follows the same retry policy. Owner disposal stops timers and streams and joins cleanup and observations before reporting failures, including when cleanup rejects before an observation settles. No Agent terminal tool, model input or Session event changes.
+
+Peer research on Codex thread unloading, OpenCode Location scopes and [VS Code PTY grace periods](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/ptyService.ts) informed separate references and delayed cleanup. The two-hour value is DSH product policy, not an industry default. The [browser-terminal decision](2026-09-09-web-sidebar-terminal.md) and [layout/provider recovery decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) remain active because their resource ownership and persistence separation still apply.
+
+## Alternatives considered
+
+**Count output followers.** Switching tabs or Sessions stops output subscriptions without abandoning the corresponding terminal. Window holds track open layout membership instead.
+
+**Kill every disconnected terminal after two hours.** A hard deadline would kill legitimate long-running commands. Busy and unknown work therefore has no automatic maximum runtime.
+
+**Infer idle from silence, low CPU or foreground identity.** Silent builds, sleeping jobs, shell builtins and commands waiting for input can satisfy these signals without completing. Positive lifecycle evidence and owned-job observations are both required.
+
+**Restore only the visible Session or retain every saved association.** The first loses dormant holds after refresh; the second lets obsolete associations keep abandoned processes alive. The layout/provider intersection represents the window's actual tabs.
+
+## Consequences
+
+Idle abandoned terminals have bounded retention while connected windows and long-running work survive refreshes and presentation changes. Busy, hung or permanently serving commands may remain indefinitely; so may unsupported shells and uncertain process observations. Explicit close remains available. PowerShell, fish, Windows, custom shell arguments and sandbox-wrapped launches currently report unknown activity.
+
+Root-shell exit does not authorize killing descendants. Failure to enumerate Linux processes is an unavailable observation, not an empty process range; activity stays unknown and cleanup retains ownership. Empty native Linux ranges and complete empty Linux sessions can permit cleanup of exited records. macOS cannot confirm an unobserved range after root exit and may retain its record until explicit close or owner disposal. Existing provider limits on escaped, unobserved descendants remain; the lifecycle record is not a security barrier against hostile same-user processes.
+
+Browser storage failure leaves current memory state usable but cannot guarantee recovery of dormant Sessions after reload. Host restart cannot restore PTYs. Screen history and per-Session terminal quotas retain their existing bounds; no separate unbounded expiry-reason cache is added.
+
+Fake-clock tests cover deadlines, reconnect races, observation invalidation and cleanup retries. Real PTY tests cover silent commands, builtins, background and stopped jobs, Zsh editing modes, startup files and traps. Browser tests cover persistent layouts, multiple windows, collapsed reload, transport loss, retained process identities, explicit replacement of unavailable tabs and reconnection to the original process after transport loss. The keyless recorded Session scenario verifies file-preview layout recovery.

+ 56 - 0
.agents/notes/implemented/feature/2026-09-14-unattended-browser-terminal-reclamation.zh.md

@@ -0,0 +1,56 @@
+# Agent Note: Two-hour reclamation of unattended browser terminals
+
+Status: implemented
+
+[English](2026-09-14-unattended-browser-terminal-reclamation.md) | 中文
+
+## 问题
+
+浏览器可能在没有关闭终端标签页的情况下消失。如果始终保留无人使用的终端,shell、后代进程和屏幕缓冲区会无限期占用资源。输出订阅不能代表是否遗弃:隐藏标签和非当前 Session 合理地停止跟随屏幕。
+
+## 决策
+
+只有没有任何窗口持有终端,并且 Host 持续确认空闲满两小时,才回收浏览器终端。运行中的工作和未知活动都会取消空闲截止时间。工作完成后重新给予完整宽限期;命令执行时长和无输出时间都不是截止条件。明确关闭标签,以及 Session 或 Host 卸载,仍有权终止工作。
+
+| 情况 | 行为 |
+|---|---|
+| 已连接窗口持有标签,包括侧栏折叠或非当前 Session | 保留终端。 |
+| 多个持有窗口中的一个消失 | 只要还有其他窗口持有,就继续保留。 |
+| 最后一个窗口消失时,命令仍在运行、停止、等待输入或后台执行 | 保留工作,不限制执行时长。 |
+| 无窗口持有,且明确确认终端空闲 | 开始完整宽限期,清理前再次检查。 |
+| 窗口在清理前返回 | 重连原进程,取消回收。 |
+| 保存的进程已经不存在 | 保留标签,显示本地化的不可用提示和“新建终端”。只有明确点击后,才在原位置用全新身份替换。 |
+
+例如,窗口在 14:00 断开,而命令持续到 20:00,则最早也不能在 22:00 前回收。期限从之后第一次确认空闲的观察开始计算。
+
+[侧栏](../../../../packages/client/ui-sidebar-right/README.zh.md#state) 按 Session 持久化布局,并通过 `openTabs` 发布已保存和已采用 Session 的标签元数据。启动发现不会挂载非当前内容、pin 文件或激活 Agent。窗口内以已采用的 store 为准;其他窗口对 storage 的写入不能撤销当前窗口的活动持有关系。永久删除 scope 时移除相应元数据。
+
+终端 provider 将该清单与自己保存的终端关联及未完成的关闭请求取交集。每个窗口为每个不同终端使用一条 `retain(sessionId, id, signal)` Remote 流。确认帧授予持有关系,不发送屏幕输出、不激活 Agent、不接管输入,也不创建进程。恢复输出前等待当前持有关系确认。传输代次独立取消,传输存活由现有 Gateway 重连和心跳机制负责。
+
+subprocess 能力接口提供带状态和 revision 的 `inspectActivity()`。普通非登录的 Bash 4.4+ 和 Zsh 支持显式启用生命周期记录,并结合完整进程表观察和原始进程身份。Zsh 区分空的顶层编辑提示符与 `vared`、选择及续行输入。输入使提示符证据失效;后台和停止的后代阻止空闲判断。原生 Linux 还检查 systemd task 数,保护脱离进程树但仍在自有 scope 中的后代。自定义 trap、Zsh 异步文件描述符 handler、不支持的启动方式和不完整观察保持 unknown。生命周期文件为私有文件,在清理成功后删除。
+
+Host [终端控制器](../../../../packages/api/terminal-controller/README.zh.md#use-this-package) 管理可配置的 `unattendedTimeoutMs`(7200000)、`activityPollIntervalMs`(30000)和 `cleanupRetryMs`(60000)。零只禁用自动回收。控制器使用单调时钟;观察间隔超过轮询周期两倍时,旧证据失效并重置宽限期。输入和持有关系变化会使正在进行的观察失效。最终检查通过后,在异步终止前将身份标记为关闭,阻止迟到的创建和连接。已关闭身份不能复用,每个清理任务始终属于原来的 Session owner。
+
+清理等待 provider 范围完全停稳和最终屏幕输出排空。失败时保留所有权、拒绝新持有关系,并安排一次重试,不重新给予空闲宽限期。分配失败后的清理采用相同重试策略。owner 卸载会停止计时器和流,在清理与观察都结束后才报告失败,包括清理早于观察结束而拒绝的情况。Agent 终端工具、模型输入和 Session 事件均不改变。
+
+Codex 线程卸载、OpenCode Location scope 和 [VS Code PTY 宽限期](https://github.com/microsoft/vscode/blob/main/src/vs/platform/terminal/node/ptyService.ts) 的调研支持了独立引用和延迟清理的设计。两小时是 DSH 的产品策略,不是行业默认值。[浏览器终端决策](2026-09-09-web-sidebar-terminal.zh.md) 与[布局/provider 恢复决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md) 继续有效,因为其资源所有权和持久化职责划分仍然适用。
+
+## 考虑过的替代方案
+
+**统计输出订阅者。** 切换标签或 Session 会停止输出订阅,但并未放弃对应终端。窗口持有关系改为追踪布局中打开的标签。
+
+**断开满两小时就杀掉所有终端。** 固定截止时间会杀掉合理的长命令。因此运行中和未知状态的工作没有自动最长执行时间。
+
+**根据静默、低 CPU 或前台身份推断空闲。** 静默构建、休眠任务、shell 内建命令及等待输入的命令都可能满足这些条件却尚未完成。必须同时具有正面的生命周期证据和自有任务观察。
+
+**只恢复可见 Session,或保留每个保存的关联。** 前者在刷新后丢失非当前 Session 的持有关系;后者让过时关联保活无人使用的进程。布局与 provider 的交集才代表该窗口实际打开的标签。
+
+## 结果与代价
+
+无人使用的空闲终端具有保留期限,而已连接窗口和长任务能跨刷新及呈现变化继续存在。运行中、挂起或长期提供服务的命令可能无限期保留;不支持的 shell 和不确定的进程观察也是如此。仍可明确关闭。PowerShell、fish、Windows、自定义 shell 参数以及经过 sandbox 包装的启动当前返回 unknown。
+
+根 shell 退出不授予终止后代的权限。Linux 进程枚举失败表示观察不可用,而不是进程范围为空;活动保持 unknown,清理保留所有权。明确为空的原生 Linux 范围和完整且为空的 Linux 会话允许清理已退出记录。macOS 无法在根进程退出后确认未观察到的范围,可能保留记录直到明确关闭或 owner 卸载。provider 对已逃逸且未观察到的后代的限制仍然存在;生命周期记录不是用于防御同一用户恶意进程的安全屏障。
+
+浏览器 storage 失败时,当前内存状态仍可使用,但不能保证刷新后恢复非当前 Session。Host 重启不能恢复 PTY。屏幕历史和每个 Session 的终端配额保持原有上限,不新增无限增长的到期原因缓存。
+
+虚拟时钟测试覆盖截止时间、重连竞争、观察失效和清理重试。真实 PTY 测试覆盖静默命令、内建命令、后台和停止任务、Zsh 编辑模式、启动文件及 trap。浏览器测试覆盖持久化布局、多窗口、折叠后刷新、传输中断、保留的进程身份、明确操作后替换失效标签,以及传输中断后重连原进程。无需密钥的已记录 Session 场景验证文件预览布局恢复。

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

@@ -0,0 +1,3 @@
+- status:
+  - text: Disconnected.
+  - button "Reconnect"

+ 2 - 0
apps/web/tests/expected/sidebar-terminal/unavailable.expected.md

@@ -0,0 +1,2 @@
+- alert: This terminal no longer exists. Open a new terminal.
+- button "New terminal"

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

@@ -1,6 +1,9 @@
 - id: terminal-controller
 - id: terminal-controller
   config:
   config:
     maxTerminals: 2
     maxTerminals: 2
+    unattendedTimeoutMs: 2000
+    activityPollIntervalMs: 100
+    cleanupRetryMs: 100
     shellCandidates: [/bin/bash, /bin/sh]
     shellCandidates: [/bin/bash, /bin/sh]
     shell:
     shell:
       path: /bin/bash
       path: /bin/bash

+ 47 - 2
apps/web/tests/seeded-history.e2e.ts

@@ -20,9 +20,10 @@ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
 import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
 import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
 import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
+import type {} from '@deepseek-ai/dsh-api-terminal-controller'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import {
 import {
-  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
   compareOrRefreshGolden, fixtureUserPrompts,
   compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
   launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
   webSnapshotMode, type WebScaffold,
   webSnapshotMode, type WebScaffold,
@@ -205,7 +206,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
   let seededThroughSeq = -1
   let seededThroughSeq = -1
 
 
   beforeAll(async () => {
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({})
+    scaffold = await launchWebScaffold(process.platform === 'win32' ? {} : {
+      extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
+    })
     // Composer recording uses a child workspace; seedSession owns the scaffold root.
     // Composer recording uses a child workspace; seedSession owns the scaffold root.
     const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
     const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
     await mkdir(sessionCwd, { recursive: true })
     await mkdir(sessionCwd, { recursive: true })
@@ -728,6 +731,48 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
     expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
   })
   })
 
 
+  it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-sidebar-reload'))
+    await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
+    const column = page.locator('[data-rightbar-col]')
+    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
+    const tabId = await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')
+    const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
+    expect(await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')).toBe(tabId)
+    const restoredPreview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
+    expect(restoredPreview).toBe(preview)
+  })
+
+  it.skipIf(MODE === 'record' || process.platform === 'win32')('offers explicit terminal replacement after restoring the recorded Session', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-terminal-unavailable'))
+    await page.locator('[data-dockkit-add-tab]').click()
+    await page.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button', { name: /^New terminal/u }).click()
+    const sessionId = SessionId(SEED_ID)
+    const terminals = () => scaffold.ctx.terminalController.list(sessionId)
+    await expect.poll(() => terminals().length).toBe(1)
+    const previous = terminals()[0]!.id
+    const agent = scaffold.ctx.agents.get(sessionId)
+    if (agent === undefined) throw new Error('Recorded Session did not attach an Agent for its terminal')
+    await scaffold.ctx.terminalController.close(agent, previous)
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    const terminal = page.locator('[data-sidebar-terminal]')
+    await expect.poll(() => terminal.getByRole('alert').innerText()).toContain('no longer exists')
+    expect(terminals()).toEqual([])
+    const expected = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
+    await compareOrRefreshGolden(expected, await terminal.ariaSnapshot(), MODE)
+    await terminal.getByRole('button', { name: 'New terminal', exact: true }).click()
+    await expect.poll(() => terminals().length).toBe(1)
+    expect(terminals()[0]!.id).not.toBe(previous)
+    await terminal.getByRole('status').waitFor({ state: 'hidden' })
+    await terminal.getByRole('textbox', { name: 'Terminal', exact: true }).waitFor()
+  })
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     // No replay fixture was installed and the llm seam is open — any stray
     // No replay fixture was installed and the llm seam is open — any stray
     // stream would have failed the turn loudly. Cleanliness pins the wire.
     // stream would have failed the turn loudly. Cleanliness pins the wire.

+ 35 - 11
apps/web/tests/sidebar-right.e2e.ts

@@ -24,7 +24,7 @@ import type { Browser, ConsoleMessage, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
 import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
-import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import { acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
 import {
 import {
   connectFreshWorkspace, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE,
   connectFreshWorkspace, newEnglishPage, saveFailureShot, ZH_BROWSER_LOCALE,
 } from './support.ts'
 } from './support.ts'
@@ -126,8 +126,13 @@ async function ensureExpanded(page: Page, column: Locator): Promise<void> {
   await column.locator('[data-sidebar-right-open]').waitFor({ timeout: 10_000 })
   await column.locator('[data-sidebar-right-open]').waitFor({ timeout: 10_000 })
 }
 }
 
 
-/** Reload the session's transient sidebar state before an independent gesture case. */
+/** Clear only this fixture's saved layouts before an independent gesture case. */
 async function resetSidebar(page: Page): Promise<Locator> {
 async function resetSidebar(page: Page): Promise<Locator> {
+  await page.evaluate(() => {
+    for (const key of Object.keys(localStorage)) {
+      if (key.startsWith('dsh.sidebar-right.v1.')) localStorage.removeItem(key)
+    }
+  })
   await page.reload({ waitUntil: 'load' })
   await page.reload({ waitUntil: 'load' })
   const column = page.locator('[data-rightbar-col]')
   const column = page.locator('[data-rightbar-col]')
   await expandOf(page).waitFor({ timeout: 15_000 })
   await expandOf(page).waitFor({ timeout: 15_000 })
@@ -800,7 +805,7 @@ describe('web e2e: shipped right Sidebar', () => {
     // panel by product decision, and copy has no service method yet:
     // panel by product decision, and copy has no service method yet:
     // `duplicateTab` is a store/kit intent only, which service.client.spec.ts covers.
     // `duplicateTab` is a store/kit intent only, which service.client.spec.ts covers.
 
 
-    it('keeps each session\'s surface to itself, and restores it on return', async () => {
+    it('keeps each session\'s surface to itself across switching and page reload', async () => {
       const fx = await newEnglishPage(browser)
       const fx = await newEnglishPage(browser)
       const fxTripwire = watchConsole(fx)
       const fxTripwire = watchConsole(fx)
       onTestFailed(() => saveFailureShot(fx, 'web-e2e-sidebar-right-sessions'))
       onTestFailed(() => saveFailureShot(fx, 'web-e2e-sidebar-right-sessions'))
@@ -843,6 +848,24 @@ describe('web e2e: shipped right Sidebar', () => {
         expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
         expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
         expect(await wrap.getAttribute('aria-pressed')).toBe('false')
         expect(await wrap.getAttribute('aria-pressed')).toBe('false')
         expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
         expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
+        let warningStart = fxTripwire.warnings.length
+        await fx.reload({ waitUntil: 'load' })
+        acknowledgeReloadConnectionLoss(fxTripwire, warningStart)
+        await expect.poll(records, { timeout: 15_000 }).toEqual(before)
+        await expect.poll(async () => await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
+        await column.locator('[data-sidebar-right-toggle]').click()
+        warningStart = fxTripwire.warnings.length
+        await fx.reload({ waitUntil: 'load' })
+        acknowledgeReloadConnectionLoss(fxTripwire, warningStart)
+        await fx.locator('[data-sidebar-right-expand]').waitFor()
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+        await fx.locator('[data-sidebar-right-expand]').click()
+        await expect.poll(records, { timeout: 15_000 }).toEqual(before)
+        expect(await width(column)).toBeGreaterThan(0)
+        await column.locator('[data-sidebar-right-panel]').evaluate(async (node) => {
+          await Promise.allSettled(node.getAnimations().map(animation => animation.finished))
+        })
+        await shot(fx, 'session-layout-restored')
         expect(fxTripwire.pageErrors).toEqual([])
         expect(fxTripwire.pageErrors).toEqual([])
         expect(fxTripwire.warnings).toEqual([])
         expect(fxTripwire.warnings).toEqual([])
       } finally {
       } finally {
@@ -990,18 +1013,19 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(tripwire.warnings).toEqual([])
       expect(tripwire.warnings).toEqual([])
     }, 90_000)
     }, 90_000)
 
 
-    it('§9.7 returns to the default surface after a reload', async () => {
+    it('preserves the open surface after a reload', async () => {
       onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
       onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-reload'))
-      await page.reload({ waitUntil: 'load' })
-      await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       const frame = page.locator('[class*="frame"]').first()
       const frame = page.locator('[class*="frame"]').first()
       const column = page.locator('[data-rightbar-col]')
       const column = page.locator('[data-rightbar-col]')
+      await ensureExpanded(page, column)
+      const titles = await tabTitles(column)
+      await page.reload({ waitUntil: 'load' })
+      await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
       await column.waitFor({ state: 'attached', timeout: 15_000 })
       await column.waitFor({ state: 'attached', timeout: 15_000 })
-      // The surface is view state, not durable session data: a reload zeroes it
-      // back to the collapsed default. Expected behaviour, not a defect.
-      await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
-      await expect.poll(async () => await expandOf(page).count()).toBe(1)
-      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      await expect.poll(async () => await tabTitles(column)).toEqual(titles)
+      await expect.poll(async () => await frame.getAttribute('data-rightbar-collapsed')).toBe(null)
+      expect(await expandOf(page).count()).toBe(0)
+      expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
     })
     })
 
 
     it('opens a context menu on right-click that the strip cannot clip', async () => {
     it('opens a context menu on right-click that the strip cannot clip', async () => {

+ 154 - 3
apps/web/tests/sidebar-terminal.e2e.ts

@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-api-terminal-controller'
 import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
 import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
 import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
 import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
 import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, saveFailureShot } from './support.ts'
 
 
 const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url))
 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))
 const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url))
@@ -28,6 +28,24 @@ async function command(page: Page, text: string): Promise<void> {
   await page.keyboard.press('Enter')
   await page.keyboard.press('Enter')
 }
 }
 
 
+async function controlTransport(page: Page) {
+  let blocked = false
+  let close: (() => Promise<void>) | undefined
+  await page.routeWebSocket('**/api/remote.mux', async (socket) => {
+    if (blocked) { await socket.close(); return }
+    const upstream = socket.connectToServer()
+    close = async () => { await upstream.close(); await socket.close() }
+  })
+  return {
+    async disconnect() {
+      blocked = true
+      if (close === undefined) throw new Error('Window did not establish its Remote mux')
+      await close()
+    },
+    reconnect() { blocked = false },
+  }
+}
+
 async function selectTerminalTheme(page: Page, name: string): Promise<void> {
 async function selectTerminalTheme(page: Page, name: string): Promise<void> {
   await page.getByRole('button', { name: 'Settings', exact: true }).click()
   await page.getByRole('button', { name: 'Settings', exact: true }).click()
   const dialog = page.getByRole('dialog', { name: 'Settings' })
   const dialog = page.getByRole('dialog', { name: 'Settings' })
@@ -59,7 +77,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
   beforeEach(async () => {
   beforeEach(async () => {
     scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
     scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
     browser = await chromium.launch()
     browser = await chromium.launch()
-    page = await newEnglishPage(browser)
+    const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
+    page = await context.newPage()
     tripwire = watchConsole(page)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -271,14 +290,26 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     await command(page, "printf 'SIZE:'; stty size")
     await command(page, "printf 'SIZE:'; stty size")
     await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
     await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
     await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
     await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
+    const tabIds = await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))
     await page.reload({ waitUntil: 'load' })
     await page.reload({ waitUntil: 'load' })
     await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
     await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
     await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
     await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
     expect(terminals()).toHaveLength(2)
     expect(terminals()).toHaveLength(2)
     expect(alive(firstProcess)).toBe(true)
     expect(alive(firstProcess)).toBe(true)
     expect(alive(secondProcess)).toBe(true)
     expect(alive(secondProcess)).toBe(true)
-    await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
+    expect(await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))).toEqual(tabIds)
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
+    await page.getByRole('button', { name: 'Exit fullscreen', exact: true }).waitFor()
+    await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('[data-sidebar-right-expand]').waitFor()
+    expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
+    expect(terminals()).toHaveLength(2)
+    await page.locator('[data-sidebar-right-expand]').click()
+    await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
     const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
     const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
+    await secondTab.click()
+    await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
     await secondTab.hover()
     await secondTab.hover()
     await secondTab.locator('[data-dockkit-tab-close]').click()
     await secondTab.locator('[data-dockkit-tab-close]').click()
     await expect.poll(async () => await secondTab.count()).toBe(0)
     await expect.poll(async () => await secondTab.count()).toBe(0)
@@ -369,6 +400,126 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   })
   })
 
 
+  it('keeps new terminals independent when two same-origin windows mint the same tab id', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shared-storage'))
+    const second = await page.context().newPage()
+    const secondErrors = watchConsole(second)
+    await second.goto(page.url(), { waitUntil: 'load' })
+    await second.getByText('Ready for terminal input.').waitFor()
+    await openTerminal(page)
+    const firstTab = await page.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
+    const firstProcess = processIdentity(0)
+    await command(page, "printf 'WINDOW_A\\n'")
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
+    await openTerminal(second)
+    const secondTab = await second.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
+    expect(secondTab).toBe(firstTab)
+    expect(handles).toHaveLength(2)
+    const secondProcess = processIdentity(1)
+    expect(secondProcess.pid).not.toBe(firstProcess.pid)
+    await command(second, "printf 'WINDOW_B\\n'")
+    await expect.poll(() => second.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_B')
+    expect(await second.locator('.xterm-rows:visible').innerText()).not.toContain('WINDOW_A')
+    expect(await page.locator('.xterm-rows:visible').innerText()).not.toContain('WINDOW_B')
+    const bindings = () => page.evaluate(() => Object.keys(localStorage)
+      .filter(key => key.startsWith('dsh.terminal.binding.v1.')).map(key => localStorage.getItem(key)))
+    const saved = await bindings()
+    expect(saved).toHaveLength(2)
+    const selected = second.locator('[data-dockkit-tab][aria-selected="true"]')
+    await selected.hover()
+    await selected.locator('[data-dockkit-tab-close]').click()
+    await expect.poll(() => alive(secondProcess)).toBe(false)
+    expect(alive(firstProcess)).toBe(true)
+    await expect.poll(() => bindings()).toHaveLength(1)
+    expect(saved).toContain((await bindings())[0])
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
+    await command(page, "printf 'WINDOW_A_RECONNECTED\\n'")
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A_RECONNECTED')
+    expect(handles).toHaveLength(2)
+    expect(alive(firstProcess)).toBe(true)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(secondErrors.pageErrors).toEqual([])
+  })
+
+  it('holds a collapsed layout across windows and reclaims only after the last transport disappears', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-window-holds'))
+    const retains = vi.spyOn(scaffold.ctx.terminalController, 'retain')
+    await openTerminal(page)
+    const original = processIdentity(0)
+    const sessionId = scaffold.ctx.agents.list()[0]!.id
+    // Shell activity is exercised with real PTYs in the provider tests; this scenario isolates window ownership.
+    vi.spyOn(handles[0]!, 'inspectActivity').mockResolvedValue({ state: 'idle', revision: 1 })
+    await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
+    const second = await page.context().newPage()
+    const transport = await controlTransport(second)
+    await second.goto(page.url(), { waitUntil: 'load' })
+    await second.locator('[data-sidebar-right-expand]').waitFor()
+    await expect.poll(() => retains.mock.calls.filter(call => !call[2].aborted).length).toBe(2)
+    await page.close()
+    page = second
+    tripwire = watchConsole(page)
+    await expect.poll(() => retains.mock.calls.filter(call => !call[2].aborted).length).toBe(1)
+    const disconnectedAt = performance.now()
+    await expect.poll(() => performance.now() - disconnectedAt, { timeout: 10_000 }).toBeGreaterThan(2500)
+    expect(alive(original)).toBe(true)
+    expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
+    await page.context().setOffline(true)
+    await transport.disconnect()
+    await expect.poll(() => retains.mock.calls.every(call => call[2].aborted), { timeout: 15_000 }).toBe(true)
+    await expect.poll(() => scaffold.ctx.terminalController.list(sessionId), { timeout: 15_000 }).toEqual([])
+    expect(alive(original)).toBe(false)
+    transport.reconnect()
+    await page.context().setOffline(false)
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('[data-sidebar-right-expand]').click()
+    await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists')
+    expect(handles).toHaveLength(1)
+    const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
+    const terminal = page.locator('[data-sidebar-terminal]')
+    await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode())
+    await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' })
+    const tabCount = await page.locator('[data-dockkit-tab]').count()
+    const create = terminal.getByRole('button', { name: 'New terminal', exact: true })
+    await create.focus()
+    await page.keyboard.press('Enter')
+    await expect.poll(() => handles.length).toBe(2)
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
+    expect(await page.locator('[data-dockkit-tab]').count()).toBe(tabCount)
+    expect(scaffold.ctx.terminalController.list(sessionId)).toHaveLength(1)
+    expect(alive(processIdentity(1))).toBe(true)
+    await command(page, "printf 'NEW_TERMINAL_READY\\n'")
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('NEW_TERMINAL_READY')
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
+  it('offers reconnection after transport loss and resumes the same process without a new terminal', async () => {
+    onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-reconnect'))
+    await openTerminal(page)
+    const original = processIdentity(0)
+    await command(page, "printf 'RECONNECT_READY\\n'")
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
+    const transport = await controlTransport(page)
+    await page.reload({ waitUntil: 'load' })
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
+    await transport.disconnect()
+    const reconnect = page.getByRole('button', { name: 'Reconnect', exact: true })
+    await reconnect.waitFor()
+    expect(await page.getByRole('alert').count()).toBe(0)
+    expect(alive(original)).toBe(true)
+    const disconnected = fileURLToPath(new URL('./expected/sidebar-terminal/disconnected.expected.md', import.meta.url))
+    await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode())
+    await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true })
+    await reconnect.click()
+    transport.reconnect()
+    await page.locator('[data-sidebar-terminal]').getByRole('status').waitFor({ state: 'hidden' })
+    await command(page, "printf 'RECONNECTED_INPUT\\n'")
+    await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECTED_INPUT')
+    expect(handles).toHaveLength(1)
+    expect(alive(original)).toBe(true)
+    expect(tripwire.pageErrors).toEqual([])
+  })
+
 })
 })
 
 
 function contrastRatio(first: string, second: string): number {
 function contrastRatio(first: string, second: string): number {

+ 1 - 0
benchmarks/terminal-io/terminal-io.worker.ts

@@ -43,6 +43,7 @@ async function measure(capacityBytes: number, mode: string): Promise<TerminalIoR
     done: ended.promise,
     done: ended.promise,
     async write() { writeReady.resolve() },
     async write() { writeReady.resolve() },
     async resize() {},
     async resize() {},
+    async inspectActivity() { return { state: 'unknown', revision: 0 } },
     async inspectForeground() { return { processGroupId: 1, inputWaiting: false } },
     async inspectForeground() { return { processGroupId: 1, inputWaiting: false } },
     async signalForeground() { return 1 },
     async signalForeground() { return 1 },
     async terminate() {
     async terminate() {

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

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

+ 6 - 0
docs/config-catalog.md

@@ -263,6 +263,12 @@ export interface Config {
   readonly maxInputBytes: number
   readonly maxInputBytes: number
   /** Provider process-termination grace period in milliseconds. */
   /** Provider process-termination grace period in milliseconds. */
   readonly disposeGraceMs: number
   readonly disposeGraceMs: number
+  /** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */
+  readonly unattendedTimeoutMs: number
+  /** Interval between unattended shell and process observations. */
+  readonly activityPollIntervalMs: number
+  /** Delay before retrying failed owned terminal cleanup. */
+  readonly cleanupRetryMs: number
 }
 }
 ```
 ```
 
 

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

@@ -265,6 +265,12 @@ export interface Config {
   readonly maxInputBytes: number
   readonly maxInputBytes: number
   /** Provider process-termination grace period in milliseconds. */
   /** Provider process-termination grace period in milliseconds. */
   readonly disposeGraceMs: number
   readonly disposeGraceMs: number
+  /** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */
+  readonly unattendedTimeoutMs: number
+  /** Interval between unattended shell and process observations. */
+  readonly activityPollIntervalMs: number
+  /** Delay before retrying failed owned terminal cleanup. */
+  readonly cleanupRetryMs: number
 }
 }
 ```
 ```
 
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/subprocess.md
-subprocess.md: e724903386b8c743da522a419401ac49ff1c1509
-subprocess.zh.md: b11a1cb0891123670be654d4c82105054979c911
+subprocess.md: eeae0dece1868688502eb733d693bd398fb76658
+subprocess.zh.md: 471ac5c45e69323edd92688f9b2f77f158b18eae

+ 3 - 1
docs/subsystems/subprocess.md

@@ -244,7 +244,9 @@ interface SubprocessOutcome {
 
 
 `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics.
 `spawnTerminal(spec)` is the non-pipe process primitive. The provider allocates the controlling terminal and owns UTF-8 text transport, foreground-process-group inspection and signalling, and one awaited TERM-to-KILL operation that reaches quiescence for every session member the provider can still observe; providers document substrate-specific observability limits. The PTY backend remains responsible for prompt detection, readiness inference, scrollback, sandbox policy, and persistent-session ownership; ordinary `spawn()` cannot reconstruct controlling-terminal semantics.
 
 
-The terminal spec fully specifies argv, cwd, environment overrides, terminal type, dimensions, cleanup grace, and optional allocation cancellation. Its handle exposes `pid`, ordered output, `done`, `write`, `resize`, `inspectForeground`, `signalForeground`, and awaited `terminate`; [`SubprocessTerminalSpawnSpec` and `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) define these fields and operations. `resize(cols, rows)` updates the live PTY dimensions and rejects after process exit.
+The terminal spec fully specifies argv, cwd, environment overrides, terminal type, dimensions, cleanup grace, and optional allocation cancellation and shell activity observation. Its handle exposes `pid`, ordered output, `done`, `write`, `resize`, `inspectForeground`, `inspectActivity`, `signalForeground`, and awaited `terminate`; [`SubprocessTerminalSpawnSpec` and `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) define these fields and operations. `resize(cols, rows)` updates the live PTY dimensions and rejects after process exit.
+
+`inspectActivity()` returns `SubprocessTerminalActivity`: `state` is `idle`, `busy` or `unknown`, and `revision` changes with provider-observed activity or input. A terminal request opts into supported shell lifecycle observation with `shellActivity`; provider-specific support and conservative unknown cases are documented by [subprocess-local](../../packages/subprocess/subprocess-local/README.md#running-terminal-sessions).
 
 
 `terminalEnvironment(signal?)` returns `SubprocessTerminalEnvironment`: the execution environment platform (`posix` or `windows`) and optional `defaultShell`. These facts come from the provider rather than the Web server or browser. `resolveExecutable` verifies shell candidates; `SubprocessExecutableNotFoundError` identifies a missing executable, while provider and transport failures remain errors.
 `terminalEnvironment(signal?)` returns `SubprocessTerminalEnvironment`: the execution environment platform (`posix` or `windows`) and optional `defaultShell`. These facts come from the provider rather than the Web server or browser. `resolveExecutable` verifies shell candidates; `SubprocessExecutableNotFoundError` identifies a missing executable, while provider and transport failures remain errors.
 
 

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

@@ -244,7 +244,9 @@ interface SubprocessOutcome {
 
 
 `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。
 `spawnTerminal(spec)` 是非管道进程原语。提供方分配控制终端,并负责 UTF-8 文本传输、前台进程组检查与信号发送,以及一项须等待的 TERM→KILL 操作;该操作会使提供方仍可观察到的每个会话成员完全停稳,提供方则会记录执行基底特有的可观察性限制。PTY 后端仍负责提示符检测、就绪推断、scrollback、沙箱策略和持久会话所有权;普通 `spawn()` 无法重建控制终端语义。
 
 
-终端 spec 完全指定 argv、cwd、环境覆盖、终端类型、尺寸、清理宽限期与可选的分配取消。其句柄公开 `pid`、有序输出、`done`、`write`、`resize`、`inspectForeground`、`signalForeground` 和须等待的 `terminate`;[`SubprocessTerminalSpawnSpec` 与 `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) 定义这些字段和操作。`resize(cols, rows)` 更新正在运行的 PTY 尺寸,进程退出后拒绝调用。
+终端 spec 完全指定 argv、cwd、环境覆盖、终端类型、尺寸、清理宽限期与可选的分配取消和 shell 活动观察。其句柄公开 `pid`、有序输出、`done`、`write`、`resize`、`inspectForeground`、`inspectActivity`、`signalForeground` 和须等待的 `terminate`;[`SubprocessTerminalSpawnSpec` 与 `SubprocessTerminalHandle`](../../packages/subprocess/subprocess/src/types.ts) 定义这些字段和操作。`resize(cols, rows)` 更新正在运行的 PTY 尺寸,进程退出后拒绝调用。
+
+`inspectActivity()` 返回 `SubprocessTerminalActivity`:`state` 为 `idle`、`busy` 或 `unknown`,`revision` 随 provider 观察到的活动或输入变化。终端请求通过 `shellActivity` 启用受支持的 shell 生命周期观察;各 provider 的支持范围和保守返回 unknown 的情况见 [subprocess-local](../../packages/subprocess/subprocess-local/README.zh.md#running-terminal-sessions)。
 
 
 `terminalEnvironment(signal?)` 返回 `SubprocessTerminalEnvironment`:执行环境平台(`posix` 或 `windows`)与可选的 `defaultShell`。这些事实来自提供方,而非 Web 服务器或浏览器。`resolveExecutable` 验证候选 shell;`SubprocessExecutableNotFoundError` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。
 `terminalEnvironment(signal?)` 返回 `SubprocessTerminalEnvironment`:执行环境平台(`posix` 或 `windows`)与可选的 `defaultShell`。这些事实来自提供方,而非 Web 服务器或浏览器。`resolveExecutable` 验证候选 shell;`SubprocessExecutableNotFoundError` 表示可执行文件不存在,提供方与传输故障仍作为错误报告。
 
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: 77e5dd39afb6d2840751d6d56a7a50f9fea2adaa
-workspace.zh.md: ad226e34b4fb508e9ce68e90349848d28632d559
+workspace.md: 93cce66801d123edd3a4b27e79f025c6935121a2
+workspace.zh.md: 9727c2efa1f0d1abc840f5ce9a377f57af01985a

+ 9 - 0
docs/subsystems/workspace.md

@@ -222,6 +222,15 @@ Typed Remote control of transient Session-owned terminal processes.
  */
  */
 @Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>
 @Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>
 
 
+/**
+ * Retain an existing terminal for a window without activating its Agent or taking input control.
+ * @param sessionId - owning Session identity, including an inactive saved layout.
+ * @param id - retained Host terminal identity.
+ * @param signal - physical Remote stream cancellation.
+ * @returns a hold acknowledgement followed by an open lifetime stream.
+ */
+@Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame>
+
 /**
 /**
  * Attach to a terminal without binding its process lifetime to the transport.
  * Attach to a terminal without binding its process lifetime to the transport.
  * @param agent - Session owner supplied by the Gateway.
  * @param agent - Session owner supplied by the Gateway.

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

@@ -222,6 +222,15 @@ Typed Remote control of transient Session-owned terminal processes.
  */
  */
 @Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>
 @Remote async create(agent: Agent, request: TerminalCreateRequest, signal: AbortSignal): Promise<WebTerminalInfo>
 
 
+/**
+ * Retain an existing terminal for a window without activating its Agent or taking input control.
+ * @param sessionId - owning Session identity, including an inactive saved layout.
+ * @param id - retained Host terminal identity.
+ * @param signal - physical Remote stream cancellation.
+ * @returns a hold acknowledgement followed by an open lifetime stream.
+ */
+@Remote({ mode: 'stream' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame>
+
 /**
 /**
  * Attach to a terminal without binding its process lifetime to the transport.
  * Attach to a terminal without binding its process lifetime to the transport.
  * @param agent - Session owner supplied by the Gateway.
  * @param agent - Session owner supplied by the Gateway.

+ 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;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md
 #   pnpm run verify-translation-pairing --write packages/api/terminal-controller/README.md
-README.md: 7bcd04ad2848b4f263aaff21d0bee5aea2333343
-README.zh.md: 3db761f32c0e7c99fa622dd7adb62f3ac301b766
+README.md: bc73007faa1b846e327742d2bfd5b44dcd23737d
+README.zh.md: 4dfd2181c518234df9517ae7afbeb57e4438b75c

+ 11 - 3
packages/api/terminal-controller/README.md

@@ -25,7 +25,7 @@ Open the execution environment's default shell in a Session workspace from the W
 <a id="use-this-package"></a>
 <a id="use-this-package"></a>
 ## Use this package
 ## Use this package
 
 
-The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
+The Web bundle mounts this package with the subprocess provider, sandbox policy and Typert Gateway. `remote.terminal` exposes `environment`, `shells`, `list`, `create`, `retain`, `follow`, `write`, `resize`, `rename` and `close`; each operation is scoped by Session identity. Listing reads retained Host terminals directly, so viewing an offline Session neither activates an Agent nor produces a recovery error.
 
 
 Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal.
 Shell discovery lists the execution environment's declared default shell first. Only when the provider omits that default does resolution use `/bin/sh` on POSIX or `cmd.exe` on Windows. An optional `shell` profile overrides that choice with executable `path`, display `name` and `args` (default `[]`). The selector also probes `shellCandidates` through the execution provider and omits only confirmed lookup misses. Creation accepts a discovered `shellPath` and verifies it again; resolution or transport failure is reported without launching a different shell. Environment lookup returns the working directory and limits without resolving a shell, so an unavailable default does not prevent reattaching to an existing process. Automatic POSIX profiles start interactively, and PowerShell uses `-NoLogo`, so completion and startup configuration remain shell-owned. The Session workspace supplies the initial directory; its sandbox policy also applies to the terminal.
 
 
@@ -39,6 +39,11 @@ Shell discovery lists the execution environment's declared default shell first.
 | `maxBufferedBytes` | `2097152` | Output queued for one follower |
 | `maxBufferedBytes` | `2097152` | Output queued for one follower |
 | `maxInputBytes` | `65536` | Maximum input request bytes |
 | `maxInputBytes` | `65536` | Maximum input request bytes |
 | `disposeGraceMs` | `1000` | Provider termination grace in milliseconds |
 | `disposeGraceMs` | `1000` | Provider termination grace in milliseconds |
+| `unattendedTimeoutMs` | `7200000` | Continuous confirmed idle time without window holds before cleanup; `0` disables automatic reclamation |
+| `activityPollIntervalMs` | `30000` | Interval between unattended activity observations |
+| `cleanupRetryMs` | `60000` | Delay before retrying failed cleanup |
+
+An open tab in any connected window retains its terminal, including hidden tabs and inactive Sessions. After the last hold disappears, only positively confirmed idle time counts toward reclamation. Running, stopped, input-waiting and background work remains protected; unknown activity clears the idle deadline. Completion starts a fresh full grace period. The Host uses a monotonic clock, rechecks current ownership and activity before cleanup, and resets evidence after an observation gap exceeding twice the polling interval. The timing fields use milliseconds and safe integers; polling and retry intervals must be positive.
 
 
 <a id="understand-the-implementation"></a>
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
 ## Understand the implementation
@@ -50,11 +55,13 @@ The Host uses `ctx.subprocess.spawnTerminal` with `TERM=xterm-256color`; it neve
 
 
 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.
 The latest attachment owns input and resize. Detachment releases input control without killing the process. Explicit close awaits process cleanup and final output; cleanup failure retains the resource for retry. The Session remembers closed identities and rejects their delayed or repeated creation, including creation already in progress when close arrives. A new terminal uses a new identity. Pending allocations remain owned even if cancellation and cleanup both fail. Session owner disposal and controller disposal also terminate owned processes. An open or pending terminal prevents changing that Session's sandbox mode. Input or resize refused after control transfer or process exit leaves the output attachment intact and disables input; rejected input is not replayed.
 
 
-Client views keep the association between sidebar tabs and terminal identities in memory. Recovery queries the Host for retained terminals; a new view may create a process, while a recovered view reports a missing target without creating a replacement. The Client model acknowledges screen writes after the browser emulator processes them, serializes input and ignores stale attachment responses. Client-owned errors carry locale keys. Plugin disposal awaits active and previously detached output streams without closing Host processes.
+The Client saves each Session/content-to-terminal association before allocation under its own `dsh.terminal.binding.v1.*` localStorage key. The content identity is globally unique; layout-local tab ids only identify live view occurrences. Independent record writes and deletes preserve other windows' bindings. Restored views reuse that identity; the sidebar terminal provider restores its views before querying unrepresented Host terminals. A new view may create a process, while a recovered view reports a missing target without creating a replacement. Explicit close removes the association after saving its cleanup request. The Host supplies current process metadata and screen contents; neither is saved in the browser. 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.
+
+The separate `retain(sessionId, id, signal)` Remote stream acknowledges a window hold without Agent activation, screen output, input transfer or process creation. The terminal provider supplies the sidebar's complete open-tab inventory, and the Client intersects it with its own saved identities. Duplicate occurrences share one hold per window; stale associations alone retain nothing. Restored output attachments wait for an acknowledged current hold. Transport cancellation releases exactly that physical generation; plugin disposal joins all hold streams. Failed cleanup retains ownership and retries without reopening admission or restarting the idle grace.
 
 
 New views start automatically, using an explicit guide selection or the remembered available shell. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. Default launches verify the saved path through Host discovery and fall back to the current default when it is absent. The guide records a selection before opening its tab; each new tab retains its own chosen path and allocation identity. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
 New views start automatically, using an explicit guide selection or the remembered available shell. The last selected shell path is stored under `dsh.terminal.shell` in origin-scoped localStorage. Default launches verify the saved path through Host discovery and fall back to the current default when it is absent. The guide records a selection before opening its tab; each new tab retains its own chosen path and allocation identity. Storage failures do not prevent startup. Restoring existing terminals neither reads this preference nor discovers shells.
 
 
-Closing saves an unfinished cleanup request before releasing the tab, then awaits Host cleanup in the background. A failure exposes a retry notification. Each request has its own terminal-ID localStorage key and is removed after successful cleanup or a definitive `session/not-found` response; startup retries saved requests. Transport failures retain the request. This stores cleanup intent, not sidebar layout, open-tab mappings, selected tabs or process PIDs. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
+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. Cleanup requests persist independently of tab associations and sidebar layout. If browser storage is unavailable, cleanup remains usable in memory but cannot be recovered after reload.
 
 
 </details>
 </details>
 
 
@@ -80,6 +87,7 @@ None; terminal output travels only between the browser and Host.
 
 
 - 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.
 - 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.
 - The subprocess provider determines native PTY availability and process-tree cleanup guarantees. Finding an executable does not prove PTY allocation will succeed.
+- Automatic reclamation depends on the provider's [supported shell activity observation](../../subprocess/subprocess-local/README.md#running-terminal-sessions). Unsupported shells, custom launch arguments and uncertain process observations may retain resources until explicit close or owner disposal. No maximum runtime kills busy commands.
 - Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize.
 - Screen recovery retains bounded history, not a complete transcript. Only one attachment at a time can write or resize.
 
 
 <a id="dev-note"></a>
 <a id="dev-note"></a>

+ 11 - 3
packages/api/terminal-controller/README.zh.md

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 <a id="use-this-package"></a>
 ## 使用此包
 ## 使用此包
 
 
-Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
+Web bundle 将此包与 subprocess provider、sandbox policy 和 Typert Gateway 一起挂载。`remote.terminal` 提供 `environment`、`shells`、`list`、`create`、`retain`、`follow`、`write`、`resize`、`rename` 和 `close`;每个操作均按 Session 标识限定范围。列表直接读取 Host 保留的终端,因此查看离线 Session 不会激活 Agent,也不会产生恢复错误。
 
 
 Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
 Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provider 未声明默认值时,才在 POSIX 使用 `/bin/sh`,在 Windows 使用 `cmd.exe`。可选的 `shell` profile 通过可执行路径 `path`、显示名称 `name` 和参数 `args`(默认 `[]`)覆盖这一选择。选择器还会通过执行 provider 探测 `shellCandidates`,仅省略确定未找到的候选。创建请求接受探测返回的 `shellPath` 并再次验证;解析或传输失败会直接报告,不启动其他 shell。环境查询只返回工作目录和限制,不解析 shell,因此默认 shell 不可用时仍可重新连接已有进程。POSIX 自动 profile 以交互模式启动,PowerShell 使用 `-NoLogo`,补全和启动配置仍由 shell 提供。初始目录来自 Session 工作区,终端遵循同一 sandbox policy。
 
 
@@ -39,6 +39,11 @@ Shell 探测结果首先列出执行环境声明的默认 shell。仅当 provide
 | `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 |
 | `maxBufferedBytes` | `2097152` | 单个订阅者的输出排队上限 |
 | `maxInputBytes` | `65536` | 单次输入请求的字节上限 |
 | `maxInputBytes` | `65536` | 单次输入请求的字节上限 |
 | `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 |
 | `disposeGraceMs` | `1000` | provider 终止宽限期,单位毫秒 |
+| `unattendedTimeoutMs` | `7200000` | 无窗口持有且持续确认空闲后开始清理的时长;`0` 禁用自动回收 |
+| `activityPollIntervalMs` | `30000` | 无窗口持有时的活动观察间隔 |
+| `cleanupRetryMs` | `60000` | 清理失败后的重试间隔 |
+
+任何已连接窗口中的打开标签页都会持有其终端,包括隐藏标签和非当前 Session。最后一个持有关系消失后,只有明确确认空闲的时间才计入回收期限。运行中、停止、等待输入和后台执行的任务都会受到保护;活动状态未知时清除空闲截止时间。任务完成后重新给予完整宽限期。Host 使用单调时钟,在清理前重新检查持有关系和当前活动;观察间隔超过轮询周期的两倍时丢弃旧证据。时间字段使用毫秒和安全整数,轮询及重试间隔必须为正数。
 
 
 <a id="understand-the-implementation"></a>
 <a id="understand-the-implementation"></a>
 ## 了解实现
 ## 了解实现
@@ -50,11 +55,13 @@ Host 通过 `ctx.subprocess.spawnTerminal` 创建 `TERM=xterm-256color` 的终
 
 
 最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。
 最新连接持有输入和尺寸控制权。断开连接只释放输入权,不结束进程。显式关闭等待进程清理和最后输出;清理失败时保留资源以便重试。Session 记住已关闭的标识并拒绝迟到或重复的创建请求,包括关闭到达时仍在进行的创建。新终端使用新标识。取消创建且清理失败时,已分配的进程仍有所有者。Session owner 和 controller 卸载也会终止所拥有的进程。存在终端或创建请求时不能改变该 Session 的 sandbox mode。 控制权转移或进程退出后被拒绝的输入和尺寸请求保留输出连接并禁用输入,不重发被拒绝的输入。
 
 
-Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作查询 Host 保留的终端;新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。 Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
+Client 在分配前将每条 Session/内容与终端身份的关联保存到独立的 localStorage key `dsh.terminal.binding.v1.*`。内容身份全局唯一;布局内的 tab id 只标识活动视图 occurrence。逐条记录的写入和删除会保留其他窗口的关联。恢复视图复用该身份;侧栏 terminal provider 先恢复自己的视图,再查询尚无视图的 Host 终端。新视图可以创建进程,恢复视图在目标缺失时显示错误,不创建替代进程。显式关闭先保存清理请求,再删除关联。当前进程元数据和屏幕内容由 Host 提供,不保存在浏览器中。Client 模型在浏览器完成屏幕解析后确认帧,按序发送输入,并忽略旧连接迟到的响应。Client 自产错误携带本地化键。插件卸载等待活跃及先前断开的输出流结束,不关闭 Host 进程。
+
+独立的 `retain(sessionId, id, signal)` Remote 流确认窗口持有关系,不激活 Agent、发送屏幕输出、转移输入权或创建进程。终端 provider 提供侧栏的完整打开标签清单,Client 将其与自己保存的身份取交集。同一窗口中的重复 occurrence 共用一个持有流;孤立的旧关联不会保活任何终端。恢复输出连接前必须等待当前持有关系确认。传输取消只释放对应的物理流代次;插件卸载等待全部持有流结束。清理失败时保留所有权并重试,不重新接受连接,也不重新计算空闲宽限期。
 
 
 新视图自动启动,使用开始页明确选中的 shell,或上次选择且仍可用的 shell。上次选择的路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。默认启动通过 Host 探测验证保存的路径,不可用时回到当前默认项。开始页在打开标签页前记录选择,每个新标签页保留自己的 shell 路径和分配身份。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
 新视图自动启动,使用开始页明确选中的 shell,或上次选择且仍可用的 shell。上次选择的路径保存在当前站点 localStorage 的 `dsh.terminal.shell` 中。默认启动通过 Host 探测验证保存的路径,不可用时回到当前默认项。开始页在打开标签页前记录选择,每个新标签页保留自己的 shell 路径和分配身份。存储失败不影响启动。恢复已有终端既不读取这一偏好,也不探测 shell。
 
 
-关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。保存的是清理意图,不是侧栏布局、打开标签页映射、选中标签页或进程 PID。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
+关闭时先保存未完成的清理请求并释放标签页,再在后台等待 Host 清理。失败时提供重试通知。每个请求使用独立的终端 ID localStorage key,清理成功或收到明确的 `session/not-found` 响应后删除;启动时重试已保存的请求。传输失败时保留请求。清理请求独立于标签关联和侧栏布局持久化。浏览器存储不可用时,内存中的清理仍可工作,但刷新后无法恢复该请求。
 
 
 </details>
 </details>
 
 
@@ -80,6 +87,7 @@ Client 视图只在内存中关联侧栏标签页与终端标识。恢复操作
 
 
 - 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。
 - 浏览器刷新保留进程和屏幕;Host 或 Session owner 卸载不保留。不提供持久终端恢复或自动重启 shell。 已退出的终端仍计入 `maxTerminals`;关闭不用的标签页可释放其屏幕和名额。
 - 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。
 - 原生 PTY 可用性和进程树清理保证由 subprocess provider 决定。找到可执行文件并不保证 PTY 分配成功。
+- 自动回收依赖 provider [支持的 shell 活动观察](../../subprocess/subprocess-local/README.zh.md#running-terminal-sessions)。不支持的 shell、自定义启动参数和不确定的进程观察可能让资源一直保留到明确关闭或 owner 卸载。运行中的命令没有强制最长执行时间。
 - 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。
 - 屏幕恢复只保留有界历史,不保留完整记录。同一时刻只有一个连接可输入或调整尺寸。
 
 
 <a id="dev-note"></a>
 <a id="dev-note"></a>

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

@@ -0,0 +1,63 @@
+/** Independent content-to-terminal records keep concurrent browser writes disjoint. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WebTerminalId } from '../types.ts'
+
+const PREFIX = 'dsh.terminal.binding.v1.'
+
+/** Saved recovery targets keyed by Session and globally unique terminal content identity. */
+export class TerminalBindings {
+  private readonly memory = new Map<string, WebTerminalId>()
+
+  /**
+   * Read a saved target, retaining this window's value when storage is unavailable.
+   * @param sessionId - owning Session.
+   * @param contentId - terminal content identity, shared only by deliberate copies.
+   * @returns the existing Host identity, if one has been saved.
+   */
+  get(sessionId: SessionId, contentId: string): WebTerminalId | undefined {
+    const key = this.key(sessionId, contentId)
+    const known = this.memory.get(key)
+    if (known !== undefined) return known
+    if (typeof localStorage === 'undefined') return undefined
+    try {
+      const raw = localStorage.getItem(key)
+      if (raw === null) return undefined
+      const value: unknown = JSON.parse(raw)
+      if (typeof value !== 'string' || !/^[\w-]{1,128}$/u.test(value)) return undefined
+      this.memory.set(key, value as WebTerminalId)
+      return value as WebTerminalId
+    } catch (_storageUnavailable) { return undefined }
+  }
+
+  /**
+   * Save an identity before its Host allocation begins.
+   * @param sessionId - owning Session.
+   * @param contentId - globally unique terminal content identity.
+   * @param id - existing or newly allocated Host identity.
+   */
+  set(sessionId: SessionId, contentId: string, id: WebTerminalId): void {
+    const key = this.key(sessionId, contentId)
+    this.memory.set(key, id)
+    if (typeof localStorage === 'undefined') return
+    try { localStorage.setItem(key, JSON.stringify(id)) }
+    catch (error) { console.error('Terminal binding persistence failed:', error) }
+  }
+
+  /**
+   * Remove this content's target after its close intent has been saved.
+   * @param sessionId - owning Session.
+   * @param contentId - closing terminal content identity.
+   */
+  delete(sessionId: SessionId, contentId: string): void {
+    const key = this.key(sessionId, contentId)
+    this.memory.delete(key)
+    if (typeof localStorage === 'undefined') return
+    try { localStorage.removeItem(key) }
+    catch (error) { console.error('Terminal binding cleanup failed:', error) }
+  }
+
+  /** Release cached values when the Client service is disposed. */
+  clear(): void { this.memory.clear() }
+
+  private key(sessionId: SessionId, contentId: string): string { return PREFIX + JSON.stringify([sessionId, contentId]) }
+}

+ 74 - 8
packages/api/terminal-controller/src/client/index.ts

@@ -9,6 +9,8 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import type { TerminalShell, WebTerminalId, WebTerminalInfo } from '../types.ts'
 import type { TerminalShell, WebTerminalId, WebTerminalInfo } from '../types.ts'
 import { preferredShell, rememberShell } from './shell-preference.ts'
 import { preferredShell, rememberShell } from './shell-preference.ts'
 import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
 import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
+import { TerminalWindowHold } from './retention.ts'
+import { TerminalBindings } from './bindings.ts'
 
 
 export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
 export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
 
 
@@ -41,6 +43,10 @@ export class ClientTerminals extends Service {
   private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
   private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
   private disposed = false
   private disposed = false
   private readonly views = new Map<SessionId, Map<string, TerminalView>>()
   private readonly views = new Map<SessionId, Map<string, TerminalView>>()
+  private readonly bindings = new TerminalBindings()
+  private readonly holds = new Map<SessionId, Map<WebTerminalId, TerminalWindowHold>>()
+  private readonly releasing = new Set<Promise<void>>()
+  private openTabs: readonly { sessionId: SessionId; tabId: string; contentId: string }[] = []
 
 
   /**
   /**
    * @param ctx - Client root Context with Gateway and terminal Remote namespace.
    * @param ctx - Client root Context with Gateway and terminal Remote namespace.
@@ -52,7 +58,10 @@ export class ClientTerminals extends Service {
       this.disposed = true
       this.disposed = true
       const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()))
       const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()))
       this.views.clear()
       this.views.clear()
-      await Promise.all([...detaching, ...this.closing.values()])
+      this.bindings.clear()
+      const holds = [...this.holds.values()].flatMap(holds => [...holds.values()].map(hold => hold.dispose()))
+      this.holds.clear()
+      await Promise.all([...detaching, ...holds, ...this.releasing, ...this.closing.values()])
     }, 'terminal-controller.client.views')
     }, 'terminal-controller.client.views')
     for (const request of this.requests.pending()) this.cleanup(request)
     for (const request of this.requests.pending()) this.cleanup(request)
   }
   }
@@ -61,18 +70,23 @@ export class ClientTerminals extends Service {
    * Return the stable model for one sidebar occurrence.
    * Return the stable model for one sidebar occurrence.
    * @param sessionId - owning Session.
    * @param sessionId - owning Session.
    * @param key - sidebar occurrence key.
    * @param key - sidebar occurrence key.
-   * @param terminalId - existing Host identity when restoring a listed terminal.
+   * @param contentId - globally unique content identity; layout-local tab ids are not persistence keys.
+   * @param terminalId - existing Host identity when restoring a listed terminal; otherwise reuse the saved content identity.
    * @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
    * @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
    * @returns its observable state and terminal commands.
    * @returns its observable state and terminal commands.
    */
    */
-  view(sessionId: SessionId, key: string, terminalId?: WebTerminalId, shellPath?: string): TerminalView {
+  view(sessionId: SessionId, key: string, contentId: string, terminalId?: WebTerminalId, shellPath?: string): TerminalView {
     let views = this.views.get(sessionId)
     let views = this.views.get(sessionId)
     if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
     if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
     let view = views.get(key)
     let view = views.get(key)
     if (view === undefined) {
     if (view === undefined) {
-      const id = terminalId ?? randomUUID() as WebTerminalId
-      view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined, shellPath)
+      const saved = terminalId ?? this.bindings.get(sessionId, contentId)
+      const id = saved ?? randomUUID() as WebTerminalId
+      this.bindings.set(sessionId, contentId, id)
+      view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, saved === undefined, shellPath,
+        signal => this.hold(sessionId, id).ready(signal))
       views.set(key, view)
       views.set(key, view)
+      this.reconcileHolds()
       void view.refresh()
       void view.refresh()
     }
     }
     return view
     return view
@@ -101,23 +115,75 @@ export class ClientTerminals extends Service {
    * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
    * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
    * @param sessionId - owning Session.
    * @param sessionId - owning Session.
    * @param key - sidebar occurrence key, including an inactive restored tab.
    * @param key - sidebar occurrence key, including an inactive restored tab.
+   * @param contentId - globally unique content identity whose binding is removed.
    * @param terminalId - restored identity if the tab has no model yet.
    * @param terminalId - restored identity if the tab has no model yet.
    */
    */
-  close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void {
+  close(sessionId: SessionId, key: string, contentId: string, terminalId?: WebTerminalId): void {
     const views = this.views.get(sessionId)
     const views = this.views.get(sessionId)
     const view = views?.get(key)
     const view = views?.get(key)
-    const id = view?.id ?? terminalId
+    const id = view?.id ?? terminalId ?? this.bindings.get(sessionId, contentId)
     if (id === undefined) return
     if (id === undefined) return
     const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key }
     const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key }
     this.closed.add(id)
     this.closed.add(id)
     this.requests.save(request)
     this.requests.save(request)
+    this.bindings.delete(sessionId, contentId)
     views?.delete(key)
     views?.delete(key)
     if (views?.size === 0) this.views.delete(sessionId)
     if (views?.size === 0) this.views.delete(sessionId)
     this.cleanup(request, view)
     this.cleanup(request, view)
+    this.reconcileHolds()
   }
   }
 
 
   /**
   /**
-   * Query Host terminals that have neither a tab in this page nor an unfinished close.
+   * Reconcile this window's open terminal occurrences, including dormant saved Sessions.
+   * @param tabs - terminal-kind membership supplied by the sidebar layout owner.
+   */
+  retainTabs(tabs: readonly { sessionId: SessionId; tabId: string; contentId: string }[]): void {
+    this.openTabs = tabs
+    this.reconcileHolds()
+  }
+
+  private hold(sessionId: SessionId, id: WebTerminalId): TerminalWindowHold {
+    let holds = this.holds.get(sessionId)
+    if (holds === undefined) { holds = new Map(); this.holds.set(sessionId, holds) }
+    let hold = holds.get(id)
+    if (hold === undefined || hold.failed) {
+      if (hold !== undefined) this.release(hold)
+      hold = new TerminalWindowHold(this.ctx.remote, this.remote, sessionId, id)
+      holds.set(id, hold)
+    }
+    return hold
+  }
+
+  private reconcileHolds(): void {
+    if (this.disposed) return
+    const wanted = new Map<SessionId, Set<WebTerminalId>>()
+    for (const tab of this.openTabs) {
+      const id = this.bindings.get(tab.sessionId, tab.contentId)
+      if (id === undefined || this.closed.has(id)) continue
+      let ids = wanted.get(tab.sessionId)
+      if (ids === undefined) { ids = new Set(); wanted.set(tab.sessionId, ids) }
+      ids.add(id)
+      const view = this.views.get(tab.sessionId)?.get(tab.tabId)
+      if (view === undefined || view.state.getSnapshot().info !== undefined) {
+        if (!this.holds.get(tab.sessionId)?.has(id)) this.hold(tab.sessionId, id)
+      }
+    }
+    for (const [sessionId, holds] of this.holds) {
+      for (const [id, hold] of holds) {
+        if (!wanted.get(sessionId)?.has(id)) { holds.delete(id); this.release(hold) }
+      }
+      if (holds.size === 0) this.holds.delete(sessionId)
+    }
+  }
+
+  private release(hold: TerminalWindowHold): void {
+    const releasing = hold.dispose().finally(() => { this.releasing.delete(releasing) })
+    this.releasing.add(releasing)
+    void releasing.catch((error: unknown) => { this.ctx.logger.warn('Terminal hold release failed', error) })
+  }
+
+  /**
+   * Query Host terminals without a live view or unfinished close.
    * @param sessionId - Session being displayed.
    * @param sessionId - Session being displayed.
    * @returns terminals available for opening as recovered tabs.
    * @returns terminals available for opening as recovered tabs.
    */
    */

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

@@ -70,6 +70,7 @@ export class TerminalView {
    * @param id - Host terminal identity, reused when recovering an item from its Session list.
    * @param id - Host terminal identity, reused when recovering an item from its Session list.
    * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
    * @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
    * @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
    * @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
+   * @param retain - window hold acknowledgement required before output attachment.
    */
    */
   constructor(
   constructor(
     private readonly sessionId: SessionId,
     private readonly sessionId: SessionId,
@@ -78,6 +79,7 @@ export class TerminalView {
     readonly id: WebTerminalId,
     readonly id: WebTerminalId,
     private readonly createWhenMissing = true,
     private readonly createWhenMissing = true,
     private readonly shellPath?: string,
     private readonly shellPath?: string,
+    private readonly retain?: (signal: AbortSignal) => Promise<void>,
   ) {}
   ) {}
 
 
   /**
   /**
@@ -146,7 +148,10 @@ export class TerminalView {
 
 
   private adopt(info: WebTerminalInfo): void {
   private adopt(info: WebTerminalInfo): void {
     this.patch({ info, title: info.title })
     this.patch({ info, title: info.title })
-    if (this.mounted && this.closing === undefined) this.connect()
+    if (this.retain === undefined) { if (this.mounted && this.closing === undefined) this.connect(); return }
+    void this.retain(this.lifetime.signal).then(() => {
+      if (this.mounted && this.closing === undefined) this.connect()
+    }).catch((error: unknown) => { if (!this.stopped()) this.fail(error) })
   }
   }
 
 
   /** Reattach with a fresh screen and regain input control. */
   /** Reattach with a fresh screen and regain input control. */
@@ -156,11 +161,13 @@ export class TerminalView {
     this.detach()
     this.detach()
     const stream = this.gateway.$stream<TerminalFrame>({
     const stream = this.gateway.$stream<TerminalFrame>({
       name: 'Browser terminal output',
       name: 'Browser terminal output',
-      open: (signal) => {
+      open: async function* (this: TerminalView, signal: AbortSignal) {
+        await this.retain?.(signal)
+        signal.throwIfAborted()
         const attachmentId = randomUUID() as TerminalAttachmentId
         const attachmentId = randomUUID() as TerminalAttachmentId
         this.attachmentId = attachmentId
         this.attachmentId = attachmentId
-        return this.remote.follow(this.sessionId, info.id, attachmentId, signal)
-      },
+        yield* this.remote.follow(this.sessionId, info.id, attachmentId, signal)
+      }.bind(this),
       ended: () => new TerminalViewError('attachmentEnded'),
       ended: () => new TerminalViewError('attachmentEnded'),
       carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
       carrierFailed: () => { if (this.stream === stream) this.patch({ phase: 'disconnected', writable: false }) },
     })
     })
@@ -321,7 +328,7 @@ export class TerminalView {
       this.patch({ writable: false, error: undefined, issue: undefined })
       this.patch({ writable: false, error: undefined, issue: undefined })
       return
       return
     }
     }
-    const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : undefined
+    const issue = failure?.code === 'terminal/view' ? failure.details.issue : failure?.code === 'terminal/limit-reached' ? 'terminalLimit' : failure?.code === 'terminal/unavailable' ? 'missingTerminal' : undefined
     this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
     this.patch({ phase: error instanceof RemoteStreamCarrierError ? 'disconnected' : 'failed', writable: false, issue, error: error instanceof Error ? error.message : String(error) })
   }
   }
 }
 }

+ 76 - 0
packages/api/terminal-controller/src/client/retention.ts

@@ -0,0 +1,76 @@
+/** One reconnecting window hold, shared by all occurrences of a terminal. */
+import type { ClientRemote, RemoteStream } from '@deepseek-ai/dsh-api-gateway/client'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TerminalRetentionFrame, WebTerminalId } from '../types.ts'
+import type { TerminalRemote } from './model.ts'
+
+/** A stream acknowledgement gates output attachment for each physical connection. */
+export class TerminalWindowHold {
+  private readonly stream: RemoteStream<TerminalRetentionFrame>
+  private readonly waiters = new Set<{ resolve: () => void; reject: (error: unknown) => void }>()
+  private generation: AbortSignal | undefined
+  private failure: Error | undefined
+
+  /**
+   * @param gateway - reconnecting stream owner.
+   * @param remote - typed terminal namespace.
+   * @param sessionId - saved layout's Session, without Agent activation.
+   * @param id - existing Host terminal.
+   */
+  constructor(gateway: Pick<ClientRemote, '$stream'>, remote: TerminalRemote, sessionId: SessionId, id: WebTerminalId) {
+    this.stream = gateway.$stream<TerminalRetentionFrame>({
+      name: 'Browser terminal window hold',
+      open: signal => remote.retain(sessionId, id, signal),
+      ended: () => new RemoteError('terminal/unavailable', 'Terminal hold ended', {}),
+    })
+    void this.consume()
+  }
+
+  /** Whether a terminal-domain failure ended this hold, allowing an explicit retry. */
+  get failed(): boolean { return this.failure !== undefined }
+
+  /**
+   * Wait for an acknowledged current physical hold before following its screen.
+   * @param signal - output request or view lifetime.
+   * @returns after acknowledgement, or rejects on cancellation/unavailability.
+   */
+  async ready(signal: AbortSignal): Promise<void> {
+    signal.throwIfAborted()
+    if (this.failure !== undefined) throw this.failure
+    if (this.generation !== undefined && !this.generation.aborted) return
+    const waiting = Promise.withResolvers<void>()
+    const abort = (): void => { waiting.reject(signal.reason) }
+    this.waiters.add(waiting)
+    signal.addEventListener('abort', abort, { once: true })
+    try { await waiting.promise }
+    finally { this.waiters.delete(waiting); signal.removeEventListener('abort', abort) }
+  }
+
+  /**
+   * Release this window's stream and all acknowledgement waiters.
+   * @returns after the stream consumer closes.
+   */
+  dispose(): Promise<void> {
+    this.reject(new Error('Terminal window hold released'))
+    return this.stream.dispose()
+  }
+
+  private async consume(): Promise<void> {
+    try {
+      for await (const item of this.stream) {
+        item.accept()
+        this.generation = item.signal
+        for (const waiter of this.waiters) waiter.resolve()
+        this.waiters.clear()
+      }
+    } catch (error) { this.reject(error) }
+  }
+
+  private reject(error: unknown): void {
+    this.failure = error instanceof Error ? error : new Error('Terminal window hold failed', { cause: error })
+    this.generation = undefined
+    for (const waiter of this.waiters) waiter.reject(error)
+    this.waiters.clear()
+  }
+}

+ 56 - 19
packages/api/terminal-controller/src/index.ts

@@ -3,15 +3,15 @@ import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import z from '@deepseek-ai/schemastery'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
-import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
 import type {} from '@deepseek-ai/dsh-sandbox-policy'
 import type {} from '@deepseek-ai/dsh-sandbox-policy'
 import type {} from '@deepseek-ai/dsh-sandbox'
 import type {} from '@deepseek-ai/dsh-sandbox'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
 import { discoverShells, resolveShell } from './shells.ts'
 import { discoverShells, resolveShell } from './shells.ts'
 import { BrowserTerminal } from './terminal.ts'
 import { BrowserTerminal } from './terminal.ts'
+import { TerminalRetention } from './retention.ts'
 import type {
 import type {
-  TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame,
+  TerminalShell, TerminalAttachmentId, TerminalCreateRequest, TerminalEnvironment, TerminalFrame, TerminalRetentionFrame,
   WebTerminalId, WebTerminalInfo,
   WebTerminalId, WebTerminalInfo,
 } from './types.ts'
 } from './types.ts'
 
 
@@ -51,6 +51,17 @@ export interface Config {
   readonly maxInputBytes: number
   readonly maxInputBytes: number
   /** Provider process-termination grace period in milliseconds. */
   /** Provider process-termination grace period in milliseconds. */
   readonly disposeGraceMs: number
   readonly disposeGraceMs: number
+  /** Continuous confirmed idle time without window holds before reclamation; zero disables reclamation. */
+  readonly unattendedTimeoutMs: number
+  /** Interval between unattended shell and process observations. */
+  readonly activityPollIntervalMs: number
+  /** Delay before retrying failed owned terminal cleanup. */
+  readonly cleanupRetryMs: number
+}
+
+interface TerminalAllocation {
+  info: WebTerminalInfo
+  readonly cleanup: TerminalRetention
 }
 }
 
 
 interface OwnedSession {
 interface OwnedSession {
@@ -59,7 +70,7 @@ interface OwnedSession {
   cleanup?: Promise<void>
   cleanup?: Promise<void>
   readonly terminals: Map<WebTerminalId, BrowserTerminal>
   readonly terminals: Map<WebTerminalId, BrowserTerminal>
   readonly pending: Map<WebTerminalId, Promise<BrowserTerminal>>
   readonly pending: Map<WebTerminalId, Promise<BrowserTerminal>>
-  readonly allocations: Map<WebTerminalId, { handle: SubprocessTerminalHandle; info: WebTerminalInfo }>
+  readonly allocations: Map<WebTerminalId, TerminalAllocation>
 }
 }
 
 
 /** Typed Remote control of transient Session-owned terminal processes. */
 /** Typed Remote control of transient Session-owned terminal processes. */
@@ -77,6 +88,9 @@ export class TerminalController extends TypertRemoteService {
     maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
     maxBufferedBytes: z.number().step(1).min(1024).default(2 * 1024 * 1024),
     maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
     maxInputBytes: z.number().step(1).min(1).default(64 * 1024),
     disposeGraceMs: z.number().step(1).min(1).default(1000),
     disposeGraceMs: z.number().step(1).min(1).default(1000),
+    unattendedTimeoutMs: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(7_200_000),
+    activityPollIntervalMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(30_000),
+    cleanupRetryMs: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(60_000),
   })
   })
 
 
   private readonly owners = new Map<SessionId, OwnedSession>()
   private readonly owners = new Map<SessionId, OwnedSession>()
@@ -167,7 +181,6 @@ export class TerminalController extends TypertRemoteService {
       this.requireOpen(owner, request.id)
       this.requireOpen(owner, request.id)
       return terminal.info
       return terminal.info
     }
     }
-    if (owner.allocations.has(request.id)) throw new Error('Close the failed terminal allocation before creating it again')
     if (new Set([...owner.terminals.keys(), ...owner.pending.keys(), ...owner.allocations.keys()]).size >= this.config.maxTerminals) throw new RemoteError('terminal/limit-reached', 'Session terminal limit reached', { limit: this.config.maxTerminals })
     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]))
     const allocation = this.spawn(agent, owner, request, AbortSignal.any([signal, this.lifetime.signal, owner.lifetime.signal]))
     owner.pending.set(request.id, allocation)
     owner.pending.set(request.id, allocation)
@@ -175,6 +188,9 @@ export class TerminalController extends TypertRemoteService {
       const terminal = await allocation
       const terminal = await allocation
       owner.terminals.set(request.id, terminal)
       owner.terminals.set(request.id, terminal)
       owner.allocations.delete(request.id)
       owner.allocations.delete(request.id)
+      terminal.monitor(this.config, () => { owner.closedIds.add(request.id) }, () => {
+        owner.terminals.delete(request.id)
+      }, (error) => { this.ctx.logger.error('Browser terminal cleanup failed', error) })
       this.requireOpen(owner, request.id)
       this.requireOpen(owner, request.id)
       return terminal.info
       return terminal.info
     } finally {
     } finally {
@@ -182,6 +198,23 @@ export class TerminalController extends TypertRemoteService {
     }
     }
   }
   }
 
 
+  /**
+   * Retain an existing terminal for a window without activating its Agent or taking input control.
+   * @param sessionId - owning Session identity, including an inactive saved layout.
+   * @param id - retained Host terminal identity.
+   * @param signal - physical Remote stream cancellation.
+   * @returns a hold acknowledgement followed by an open lifetime stream.
+   */
+  @Remote({ mode: 'stream' })
+  retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
+    const owner = this.owners.get(sessionId)
+    const terminal = owner?.terminals.get(id)
+    if (terminal === undefined || owner?.closedIds.has(id) === true || owner?.lifetime.signal.aborted === true) {
+      throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {})
+    }
+    return terminal.retain(signal)
+  }
+
   /**
   /**
    * Attach to a terminal without binding its process lifetime to the transport.
    * Attach to a terminal without binding its process lifetime to the transport.
    * @param agent - Session owner supplied by the Gateway.
    * @param agent - Session owner supplied by the Gateway.
@@ -256,7 +289,7 @@ export class TerminalController extends TypertRemoteService {
     } else {
     } else {
       const allocation = owner.allocations.get(id)
       const allocation = owner.allocations.get(id)
       if (allocation === undefined) return
       if (allocation === undefined) return
-      await allocation.handle.terminate()
+      await allocation.cleanup.close()
       owner.allocations.delete(id)
       owner.allocations.delete(id)
     }
     }
   }
   }
@@ -278,8 +311,8 @@ export class TerminalController extends TypertRemoteService {
     owner.cleanup = (async () => {
     owner.cleanup = (async () => {
       await Promise.allSettled(owner.pending.values())
       await Promise.allSettled(owner.pending.values())
       const results = await Promise.allSettled([
       const results = await Promise.allSettled([
-        ...[...owner.terminals.values()].map(terminal => terminal.close()),
-        ...[...owner.allocations.values()].map(allocation => allocation.handle.terminate()),
+        ...[...owner.terminals.values()].map(terminal => terminal.dispose()),
+        ...[...owner.allocations.values()].map(allocation => allocation.cleanup.dispose()),
       ])
       ])
       const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
       const errors = results.filter(result => result.status === 'rejected').map(result => result.reason as unknown)
       if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed')
       if (errors.length > 0) throw new AggregateError(errors, 'Session terminal cleanup failed')
@@ -292,12 +325,13 @@ export class TerminalController extends TypertRemoteService {
 
 
   private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal {
   private terminal(agent: Agent, id: WebTerminalId): BrowserTerminal {
     const terminal = this.owners.get(agent.id)?.terminals.get(id)
     const terminal = this.owners.get(agent.id)?.terminals.get(id)
-    if (terminal === undefined) throw new Error('Terminal no longer exists in this Session')
+    if (terminal === undefined) throw new RemoteError('terminal/unavailable', 'Terminal no longer exists in this Session', {})
+    this.requireOpen(this.owners.get(agent.id) as OwnedSession, id)
     return terminal
     return terminal
   }
   }
 
 
   private requireOpen(owner: OwnedSession, id: WebTerminalId): void {
   private requireOpen(owner: OwnedSession, id: WebTerminalId): void {
-    if (owner.closedIds.has(id)) throw new Error('Terminal was closed in this Session')
+    if (owner.closedIds.has(id)) throw new RemoteError('terminal/unavailable', 'Terminal was closed in this Session', {})
   }
   }
 
 
   private dimensions(cols: number, rows: number): void {
   private dimensions(cols: number, rows: number): void {
@@ -330,24 +364,27 @@ export class TerminalController extends TypertRemoteService {
     const handle = await subprocess.spawnTerminal({
     const handle = await subprocess.spawnTerminal({
       argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
       argv, cwd: environment.cwd, cols: request.cols, rows: request.rows,
       terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
       terminalType: 'xterm-256color', env: { DSH_SESSION_ID: agent.id },
+      shellActivity: true,
       graceMs: this.config.disposeGraceMs, signal,
       graceMs: this.config.disposeGraceMs, signal,
     })
     })
-    const allocation = {
-      handle,
-      info: {
-        id: request.id, shell, title: shell.name, cwd: environment.cwd,
-        cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
-      } as WebTerminalInfo,
+    const info: WebTerminalInfo = {
+      id: request.id, shell, title: shell.name, cwd: environment.cwd,
+      cols: request.cols, rows: request.rows, state: 'running', exitCode: null,
     }
     }
-    owner.allocations.set(request.id, allocation)
     try {
     try {
       signal.throwIfAborted()
       signal.throwIfAborted()
-      return new BrowserTerminal(handle, allocation.info, this.config.scrollback, this.config.maxBufferedBytes)
+      return new BrowserTerminal(handle, info, this.config.scrollback, this.config.maxBufferedBytes)
     } catch (error) {
     } catch (error) {
-      allocation.info = { ...allocation.info, state: 'failed', error: error instanceof Error ? error.message : String(error) }
-      try {
+      const cleanup = new TerminalRetention(this.config, handle.inspectActivity.bind(handle), async () => {
+        owner.closedIds.add(request.id)
         await handle.terminate()
         await handle.terminate()
         owner.allocations.delete(request.id)
         owner.allocations.delete(request.id)
+      }, (cleanupError) => { this.ctx.logger.error('Browser terminal allocation cleanup failed', cleanupError) })
+      owner.allocations.set(request.id, {
+        info: { ...info, state: 'failed', error: error instanceof Error ? error.message : String(error) }, cleanup,
+      })
+      try {
+        await cleanup.close()
       } catch (cleanupError) {
       } catch (cleanupError) {
         throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed')
         throw new AggregateError([error, cleanupError], 'Terminal allocation cleanup failed')
       }
       }

+ 132 - 0
packages/api/terminal-controller/src/retention.ts

@@ -0,0 +1,132 @@
+/** Window holds and conservative idle reclamation for one terminal owner. */
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import type { SubprocessTerminalActivity } from '@deepseek-ai/dsh-subprocess'
+import type { TerminalRetentionFrame } from './types.ts'
+
+/** Validated Host timing policy for unattended terminal cleanup. */
+export interface TerminalRetentionPolicy {
+  readonly unattendedTimeoutMs: number
+  readonly activityPollIntervalMs: number
+  readonly cleanupRetryMs: number
+}
+
+/** Exactly one owner orders holds, observation, and retryable process cleanup. */
+export class TerminalRetention {
+  private readonly lifetime = new AbortController()
+  private readonly holders = new Set<object>()
+  private epoch = 0
+  private timer: ReturnType<typeof setTimeout> | undefined
+  private observation: Promise<void> | undefined
+  private idle: { since: number; revision: number; observedAt: number } | undefined
+  private closing = false
+  private disposed = false
+  private cleanup: Promise<void> | undefined
+
+  /**
+   * @param policy - deployment timing choices.
+   * @param inspect - fresh shell and owned-job observation.
+   * @param terminate - mark the identity closed, await process quiescence, and remove its owner record.
+   * @param failed - diagnostic sink for failed automatic cleanup.
+   */
+  constructor(
+    private readonly policy: TerminalRetentionPolicy,
+    private readonly inspect: () => Promise<SubprocessTerminalActivity>,
+    private readonly terminate: () => Promise<void>,
+    private readonly failed: (error: unknown) => void,
+  ) { this.schedule(0) }
+
+  /**
+   * Hold one terminal for one physical Remote stream, independently of screen subscriptions.
+   * @param signal - transport generation lifetime.
+   * @returns acknowledgement followed by an open stream until cancellation or terminal closure.
+   */
+  async *retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
+    signal.throwIfAborted()
+    if (this.closing || this.disposed) throw new RemoteError('terminal/unavailable', 'Terminal is closing or unavailable', {})
+    const holder = {}
+    const ended = Promise.withResolvers<void>()
+    const combined = AbortSignal.any([signal, this.lifetime.signal])
+    const release = (): void => {
+      if (!this.holders.delete(holder)) return
+      combined.removeEventListener('abort', release)
+      this.invalidate()
+      ended.resolve()
+      this.schedule(0)
+    }
+    this.holders.add(holder)
+    this.invalidate()
+    this.cancelTimer()
+    combined.addEventListener('abort', release, { once: true })
+    try { yield { type: 'retained' }; await ended.promise }
+    finally { release() }
+  }
+
+  /** Invalidate outstanding idle observations before accepting input. */
+  invalidate(): void { this.epoch++; this.idle = undefined }
+
+  /**
+   * Start or join cleanup; failure keeps the identity closed and schedules one retry.
+   * @returns after owned process cleanup succeeds, or rejects with its failure.
+   */
+  close(): Promise<void> {
+    if (this.cleanup !== undefined) return this.cleanup
+    this.closing = true
+    this.invalidate()
+    this.lifetime.abort(new Error('Terminal closed'))
+    this.cancelTimer()
+    this.cleanup = this.terminate().catch((error: unknown) => {
+      this.cleanup = undefined
+      this.schedule(this.policy.cleanupRetryMs)
+      throw error
+    })
+    return this.cleanup
+  }
+
+  /**
+   * Stop timers and streams and await both observation and final cleanup.
+   * @returns after process quiescence; cleanup failure is reported to the disposing owner.
+   */
+  async dispose(): Promise<void> {
+    this.disposed = true
+    this.cancelTimer()
+    const observation = this.observation
+    try { await this.close() }
+    finally { await observation }
+  }
+
+  private cancelTimer(): void { clearTimeout(this.timer); this.timer = undefined }
+
+  private schedule(delay: number): void {
+    if (this.disposed || this.timer !== undefined) return
+    if (!this.closing && (this.holders.size > 0 || this.policy.unattendedTimeoutMs === 0)) return
+    const due = performance.now() + delay
+    this.timer = setTimeout(() => {
+      this.timer = undefined
+      const remaining = due - performance.now()
+      if (remaining > 0) { this.schedule(remaining); return }
+      if (this.closing) { void this.close().catch(this.failed); return }
+      this.observe()
+    }, Math.min(delay, 2_147_483_647))
+    this.timer.unref()
+  }
+
+  private observe(): void {
+    if (this.observation !== undefined) return
+    const epoch = this.epoch
+    this.observation = (async () => {
+      let activity: SubprocessTerminalActivity
+      try { activity = await this.inspect() }
+      catch (_activityUnavailable) { activity = { state: 'unknown', revision: 0 } }
+      if (this.disposed || this.closing || this.holders.size > 0 || epoch !== this.epoch) return
+      const now = performance.now()
+      if (activity.state !== 'idle') { this.idle = undefined; return }
+      if (this.idle?.revision !== activity.revision || now - this.idle.observedAt > this.policy.activityPollIntervalMs * 2) {
+        this.idle = { since: now, observedAt: now, revision: activity.revision }
+      } else this.idle.observedAt = now
+      if (now - this.idle.since >= this.policy.unattendedTimeoutMs) await this.close()
+    })().catch(this.failed).finally(() => {
+      this.observation = undefined
+      if (!this.closing) this.schedule(this.policy.activityPollIntervalMs)
+    })
+  }
+}

+ 39 - 1
packages/api/terminal-controller/src/terminal.ts

@@ -5,7 +5,8 @@ import type { SerializeAddon as Serializer } from '@xterm/addon-serialize'
 import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
 import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'
 import { TerminalFollower } from './stream.ts'
 import { TerminalFollower } from './stream.ts'
-import type { TerminalAttachmentId, TerminalFrame, WebTerminalInfo } from './types.ts'
+import { TerminalRetention, type TerminalRetentionPolicy } from './retention.ts'
+import type { TerminalAttachmentId, TerminalFrame, TerminalRetentionFrame, WebTerminalInfo } from './types.ts'
 
 
 const requireHeadless = createLazyRequire<typeof import('@xterm/headless')>('@xterm/headless', import.meta.url)
 const requireHeadless = createLazyRequire<typeof import('@xterm/headless')>('@xterm/headless', import.meta.url)
 const requireSerialize = createLazyRequire<typeof import('@xterm/addon-serialize')>('@xterm/addon-serialize', import.meta.url)
 const requireSerialize = createLazyRequire<typeof import('@xterm/addon-serialize')>('@xterm/addon-serialize', import.meta.url)
@@ -19,6 +20,7 @@ export class BrowserTerminal {
   private operations: Promise<unknown> = Promise.resolve()
   private operations: Promise<unknown> = Promise.resolve()
   private readonly drained: Promise<void>
   private readonly drained: Promise<void>
   private closing: Promise<void> | undefined
   private closing: Promise<void> | undefined
+  private retention: TerminalRetention | undefined
   private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined
   private controller: { id: TerminalAttachmentId; follower: TerminalFollower } | undefined
 
 
   /**
   /**
@@ -41,6 +43,31 @@ export class BrowserTerminal {
     this.drained = this.consume()
     this.drained = this.consume()
   }
   }
 
 
+  /**
+   * Start monitoring after this allocation is committed to its Session owner.
+   * @param policy - validated Host timing policy.
+   * @param closing - closes the id before any asynchronous termination.
+   * @param closed - removes the exact successfully terminated owner record.
+   * @param failed - diagnostic sink for background cleanup failure.
+   */
+  monitor(policy: TerminalRetentionPolicy, closing: () => void, closed: () => void, failed: (error: unknown) => void): void {
+    this.retention = new TerminalRetention(policy, () => this.handle.inspectActivity(), async () => {
+      closing()
+      await this.closeProcess()
+      closed()
+    }, failed)
+  }
+
+  /**
+   * Retain this committed process independently of output attachment.
+   * @param signal - physical window stream lifetime.
+   * @returns its hold acknowledgement and lifetime.
+   */
+  retain(signal: AbortSignal): AsyncIterable<TerminalRetentionFrame> {
+    if (this.retention === undefined) throw new Error('Terminal has not been committed')
+    return this.retention.retain(signal)
+  }
+
   /**
   /**
    * Attach with exclusive input control; an older attachment becomes read-only.
    * Attach with exclusive input control; an older attachment becomes read-only.
    * @param id - browser attachment identity.
    * @param id - browser attachment identity.
@@ -81,6 +108,7 @@ export class BrowserTerminal {
    * @returns when the provider accepts the input.
    * @returns when the provider accepts the input.
    */
    */
   write(id: TerminalAttachmentId, data: string): Promise<void> {
   write(id: TerminalAttachmentId, data: string): Promise<void> {
+    this.retention?.invalidate()
     return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) })
     return this.enqueue(async () => { this.requireController(id); await this.handle.write(data) })
   }
   }
 
 
@@ -115,6 +143,16 @@ export class BrowserTerminal {
    * @returns after process cleanup and final output drainage; failures remain retryable.
    * @returns after process cleanup and final output drainage; failures remain retryable.
    */
    */
   close(): Promise<void> {
   close(): Promise<void> {
+    return this.retention?.close() ?? this.closeProcess()
+  }
+
+  /**
+   * Stop unattended cleanup scheduling and await final process cleanup.
+   * @returns after terminal and monitor quiescence.
+   */
+  dispose(): Promise<void> { return this.retention?.dispose() ?? this.closeProcess() }
+
+  private closeProcess(): Promise<void> {
     if (this.closing !== undefined) return this.closing
     if (this.closing !== undefined) return this.closing
     this.closing = (async () => {
     this.closing = (async () => {
       await this.handle.terminate()
       await this.handle.terminate()

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

@@ -4,6 +4,8 @@ import type {} from '@deepseek-ai/dsh-typert-protocol'
 
 
 declare module '@deepseek-ai/dsh-typert-protocol' {
 declare module '@deepseek-ai/dsh-typert-protocol' {
   interface RemoteErrorDetailsMap {
   interface RemoteErrorDetailsMap {
+    /** The terminal identity is missing or has begun process cleanup. */
+    'terminal/unavailable': Record<string, never>
     /** Input or resize was refused without invalidating the output attachment. */
     /** Input or resize was refused without invalidating the output attachment. */
     'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' }
     'terminal/control-unavailable': { readonly reason: 'read-only' | 'not-running' }
     /** Retained screens and pending allocations consume the Session's terminal quota. */
     /** Retained screens and pending allocations consume the Session's terminal quota. */
@@ -16,6 +18,11 @@ export type WebTerminalId = Branded<'WebTerminalId'>
 /** An attachment allowed to write and resize one terminal. */
 /** An attachment allowed to write and resize one terminal. */
 export type TerminalAttachmentId = Branded<'TerminalAttachmentId'>
 export type TerminalAttachmentId = Branded<'TerminalAttachmentId'>
 
 
+/** Acknowledges one physical window hold without taking screen or input control. */
+export interface TerminalRetentionFrame {
+  readonly type: 'retained'
+}
+
 /** An executable shell verified in the subprocess provider's execution environment. */
 /** An executable shell verified in the subprocess provider's execution environment. */
 export interface TerminalShell {
 export interface TerminalShell {
   readonly path: string
   readonly path: string

+ 55 - 0
packages/api/terminal-controller/tests/bindings.client.spec.ts

@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+/** Content identity isolates terminal bindings across browser windows and Session scopes. */
+import { afterEach, expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WebTerminalId } from '../src/types.ts'
+import { TerminalBindings } from '../src/client/bindings.ts'
+
+const session = 'session' as SessionId
+const first = 'first' as WebTerminalId
+const second = 'second' as WebTerminalId
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); localStorage.clear() })
+
+it('preserves other windows records through interleaved writes, closes and reloads', () => {
+  const a = new TerminalBindings()
+  const b = new TerminalBindings()
+  expect(b.get(session, 'b')).toBeUndefined()
+  a.set(session, 'a', first)
+  b.set(session, 'b', second)
+  a.delete(session, 'a')
+  const reloaded = new TerminalBindings()
+  expect(reloaded.get(session, 'a')).toBeUndefined()
+  expect(reloaded.get(session, 'b')).toBe(second)
+  expect(localStorage.length).toBe(1)
+  expect(b.get(session, 'b')).toBe(second)
+  a.set('session.with.dot' as SessionId, 'b', first)
+  expect(new TerminalBindings().get(session, 'b')).toBe(second)
+  expect(new TerminalBindings().get('session.with.dot' as SessionId, 'b')).toBe(first)
+})
+
+it.each(['{broken', 'null', '42', '"not/a/terminal"'])('rejects malformed saved identity %s', (value) => {
+  const bindings = new TerminalBindings()
+  bindings.set(session, 'content', first)
+  const key = localStorage.key(0)!
+  localStorage.setItem(key, value)
+  expect(new TerminalBindings().get(session, 'content')).toBeUndefined()
+})
+
+it('keeps current-window values usable when storage is absent or inaccessible', () => {
+  const a = new TerminalBindings()
+  vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage blocked') })
+  expect(a.get(session, 'missing')).toBeUndefined()
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
+  vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw new Error('Storage blocked') })
+  a.set(session, 'a', first)
+  expect(a.get(session, 'a')).toBe(first)
+  a.delete(session, 'a')
+  expect(a.get(session, 'a')).toBeUndefined()
+  vi.stubGlobal('localStorage', undefined)
+  a.set(session, 'b', second)
+  expect(a.get(session, 'b')).toBe(second)
+  a.clear()
+  expect(a.get(session, 'b')).toBeUndefined()
+  a.delete(session, 'b')
+})

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

@@ -16,7 +16,7 @@ import type { TerminalAttachmentId, WebTerminalId } from '../src/types.ts'
 
 
 const roots: Context[] = []
 const roots: Context[] = []
 afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) })
 afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) })
-const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100 }
+const config: Config = { shellCandidates: ['zsh', 'bash', 'sh'], shell: { path: '/bin/bash', name: 'bash', args: ['--noprofile', '--norc', '-i'] }, maxTerminals: 2, maxCols: 200, maxRows: 100, scrollback: 100, maxBufferedBytes: 100_000, maxInputBytes: 1000, disposeGraceMs: 100, unattendedTimeoutMs: 7_200_000, activityPollIntervalMs: 30_000, cleanupRetryMs: 60_000 }
 const id = 'test-terminal' as WebTerminalId
 const id = 'test-terminal' as WebTerminalId
 const request = { id, cols: 80, rows: 24 }
 const request = { id, cols: 80, rows: 24 }
 const signal = (): AbortSignal => new AbortController().signal
 const signal = (): AbortSignal => new AbortController().signal
@@ -37,6 +37,7 @@ function fixture(overrides: Partial<Config> = {}) {
   const done = Promise.withResolvers<{ exitCode: number; signal: null }>()
   const done = Promise.withResolvers<{ exitCode: number; signal: null }>()
   const handle = {
   const handle = {
     pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
     pid: 123, output, done: done.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
+    inspectActivity: vi.fn<SubprocessTerminalHandle['inspectActivity']>(async () => ({ state: 'unknown', revision: 0 })),
     inspectForeground: async () => undefined, signalForeground: async () => 123,
     inspectForeground: async () => undefined, signalForeground: async () => 123,
     terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) }
     terminate: vi.fn(async () => { output.end(); done.resolve({ exitCode: 0, signal: null }) }) }
   const checked: SubprocessTerminalHandle = handle
   const checked: SubprocessTerminalHandle = handle
@@ -224,7 +225,7 @@ describe('TerminalController', () => {
     handle.terminate.mockRejectedValueOnce(new Error('still alive'))
     handle.terminate.mockRejectedValueOnce(new Error('still alive'))
     await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
     await expect(controller.create(agent, request, abort.signal)).rejects.toThrow('cleanup failed')
     expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }])
     expect(controller.list(agent.id)).toMatchObject([{ id, state: 'failed', error: 'lost request' }])
-    await expect(controller.create(agent, request, signal())).rejects.toThrow('Close the failed terminal allocation')
+    await expect(controller.create(agent, request, signal())).rejects.toThrow('closed in this Session')
     await expect(controller.create(agent, { ...request, id: 'another' as WebTerminalId }, signal())).rejects.toMatchObject({ code: 'terminal/limit-reached', details: { limit: 1 } })
     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)
     await controller.close(agent, id)
     expect(controller.list(agent.id)).toEqual([])
     expect(controller.list(agent.id)).toEqual([])
@@ -410,6 +411,14 @@ describe('shell resolution', () => {
     // Loader input is unvalidated; the schema's declared type describes its normalized output.
     // Loader input is unvalidated; the schema's declared type describes its normalized output.
     const parse = (input: unknown): Config => TerminalController.Config(input as Config)
     const parse = (input: unknown): Config => TerminalController.Config(input as Config)
     expect(parse({}).shell).toBeUndefined()
     expect(parse({}).shell).toBeUndefined()
+    expect(parse({})).toMatchObject({ unattendedTimeoutMs: 7_200_000, activityPollIntervalMs: 30_000, cleanupRetryMs: 60_000 })
+    expect(parse({ unattendedTimeoutMs: 0 }).unattendedTimeoutMs).toBe(0)
+    for (const key of ['unattendedTimeoutMs', 'activityPollIntervalMs', 'cleanupRetryMs']) {
+      for (const value of [-1, 0.5, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1]) {
+        expect(() => parse({ [key]: value })).toThrow()
+      }
+    }
+    for (const key of ['activityPollIntervalMs', 'cleanupRetryMs']) expect(() => parse({ [key]: 0 })).toThrow()
     expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] })
     expect(parse({ shell: { path: 'custom', name: 'Project shell' } }).shell).toEqual({ path: 'custom', name: 'Project shell', args: [] })
     expect(() => parse({ shell: { name: 'Project shell' } })).toThrow()
     expect(() => parse({ shell: { name: 'Project shell' } })).toThrow()
   })
   })
@@ -541,3 +550,78 @@ it('deduplicates Windows executable paths regardless of letter case', async () =
   const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] })
   const h = fixture({ shell: { path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }, shellCandidates: ['c:\\windows\\cmd.exe'] })
   expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
   expect(await h.controller.shells(h.agent, signal())).toEqual([{ path: 'C:\\Windows\\cmd.exe', name: 'Command Prompt', args: [] }])
 })
 })
+
+it('retains a known terminal without Agent resolution and fences stream admission after explicit close', async () => {
+  const h = fixture()
+  await h.controller.create(h.agent, request, signal())
+  const abort = new AbortController()
+  const held = h.controller.retain(h.agent.id, id, abort.signal)[Symbol.asyncIterator]()
+  expect(await held.next()).toMatchObject({ value: { type: 'retained' } })
+  expect(h.subprocess.spawnTerminal).toHaveBeenCalledOnce()
+  expect(() => h.controller.retain('missing-session' as SessionId, id, signal())).toThrow('unavailable')
+  const ended = held.next()
+  await h.controller.close(h.agent, id)
+  expect(await ended).toMatchObject({ done: true })
+  expect(() => h.controller.retain(h.agent.id, id, signal())).toThrow('unavailable')
+  await expect(h.controller.create(h.agent, request, signal())).rejects.toThrow('closed in this Session')
+})
+
+it('reclaims confirmed idle processes and preserves closed identity exclusion after removing their screens', async () => {
+  vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] })
+  const h = fixture({ unattendedTimeoutMs: 100, activityPollIntervalMs: 10 })
+  try {
+    h.handle.inspectActivity.mockResolvedValue({ state: 'busy', revision: 0 })
+    await h.controller.create(h.agent, request, signal())
+    await vi.advanceTimersByTimeAsync(500)
+    expect(h.handle.terminate).not.toHaveBeenCalled()
+    h.handle.inspectActivity.mockResolvedValue({ state: 'idle', revision: 1 })
+    await vi.advanceTimersByTimeAsync(110)
+    expect(h.handle.terminate).toHaveBeenCalledOnce()
+    expect(h.controller.list(h.agent.id)).toEqual([])
+    await expect(h.controller.create(h.agent, request, signal())).rejects.toThrow('closed in this Session')
+    expect(h.subprocess.spawnTerminal).toHaveBeenCalledOnce()
+  } finally {
+    await h.ctx.fiber.dispose()
+    vi.useRealTimers()
+  }
+})
+
+it.each(['allocation', 'committed'] as const)('retains failed %s cleanup, reports scheduled failures, and releases resources after retry', async (phase) => {
+  vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] })
+  const h = fixture({ unattendedTimeoutMs: 100, activityPollIntervalMs: 10, cleanupRetryMs: 20 })
+  h.handle.terminate.mockRejectedValueOnce(new Error('still alive')).mockRejectedValueOnce(new Error('still alive again'))
+  try {
+    if (phase === 'allocation') {
+      const abort = new AbortController()
+      h.subprocess.spawnTerminal.mockImplementationOnce(async () => { abort.abort(new Error('disconnected during spawn')); return h.handle })
+      await expect(h.controller.create(h.agent, request, abort.signal)).rejects.toThrow('cleanup failed')
+    } else {
+      h.handle.inspectActivity.mockResolvedValue({ state: 'idle', revision: 1 })
+      await h.controller.create(h.agent, request, signal())
+      await vi.advanceTimersByTimeAsync(100)
+    }
+    expect(h.controller.list(h.agent.id)).toHaveLength(1)
+    expect(() => h.controller.retain(h.agent.id, id, signal())).toThrow('unavailable')
+    await vi.advanceTimersByTimeAsync(20)
+    expect(h.handle.terminate).toHaveBeenCalledTimes(2)
+    expect(h.controller.list(h.agent.id)).toHaveLength(1)
+    await vi.advanceTimersByTimeAsync(20)
+    expect(h.handle.terminate).toHaveBeenCalledTimes(3)
+    expect(h.controller.list(h.agent.id)).toEqual([])
+  } finally {
+    await h.ctx.fiber.dispose()
+    vi.useRealTimers()
+  }
+})
+
+it('classifies missing and closing terminal identities for localized recovery actions', async () => {
+  const h = fixture()
+  expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal()))
+    .toThrow(expect.objectContaining({ code: 'terminal/unavailable' }))
+  await h.controller.create(h.agent, request, signal())
+  h.handle.terminate.mockRejectedValueOnce(new Error('cleanup pending'))
+  await expect(h.controller.close(h.agent, id)).rejects.toThrow('cleanup pending')
+  expect(() => h.controller.follow(h.agent, id, 'view' as TerminalAttachmentId, signal()))
+    .toThrow(expect.objectContaining({ code: 'terminal/unavailable' }))
+  await expect(h.controller.create(h.agent, request, signal())).rejects.toMatchObject({ code: 'terminal/unavailable' })
+})

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

@@ -31,6 +31,13 @@ function failure(message: string): RemoteResult<never> {
 
 
 function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
 function fixture(prepareStream?: <Item>(stream: RemoteStream<Item>) => void) {
   const remote: TerminalRemote = {
   const remote: TerminalRemote = {
+    retain: vi.fn<TerminalRemote['retain']>(async function* (_session, _id, signal) {
+      yield { type: 'retained' }
+      await new Promise<void>((resolve) => {
+        if (signal?.aborted) resolve()
+        else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    }),
     shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
     shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
     environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
     environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
     create: vi.fn<TerminalRemote['create']>(async (_sessionId, request) => success({ ...info, id: request.id })),
     create: vi.fn<TerminalRemote['create']>(async (_sessionId, request) => success({ ...info, id: request.id })),

+ 254 - 27
packages/api/terminal-controller/tests/recovery.client.spec.ts

@@ -10,6 +10,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts'
 import type { WebTerminalId, WebTerminalInfo, TerminalEnvironment } from '../src/types.ts'
 import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
 import { TerminalView, type TerminalRemote } from '../src/client/model.ts'
 import { TerminalCloseRequests } from '../src/client/close-requests.ts'
 import { TerminalCloseRequests } from '../src/client/close-requests.ts'
+import { TerminalBindings } from '../src/client/bindings.ts'
 import * as TerminalClient from '../src/client/index.ts'
 import * as TerminalClient from '../src/client/index.ts'
 
 
 const sessionId = 'session' as SessionId
 const sessionId = 'session' as SessionId
@@ -28,6 +29,13 @@ function storage() {
 
 
 function fixture() {
 function fixture() {
   const remote: TerminalRemote = {
   const remote: TerminalRemote = {
+    retain: vi.fn<TerminalRemote['retain']>(async function* (_session, _id, signal) {
+      yield { type: 'retained' }
+      await new Promise<void>((resolve) => {
+        if (signal?.aborted) resolve()
+        else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+      })
+    }),
     shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
     shells: vi.fn<TerminalRemote['shells']>(async () => success([info.shell])),
     environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
     environment: vi.fn<TerminalRemote['environment']>(async () => success(environment)), list: vi.fn<TerminalRemote['list']>(async () => success([])),
     create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
     create: vi.fn<TerminalRemote['create']>(async (_session, request) => success({ ...info, id: request.id })),
@@ -93,7 +101,7 @@ it('does not recreate a recovered terminal that disappeared after the recovery l
   const { service } = await h.service()
   const { service } = await h.service()
   vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
   vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
   expect(await service.recover(sessionId)).toEqual([info])
   expect(await service.recover(sessionId)).toEqual([info])
-  const model = service.view(sessionId, 'recovered', info.id)
+  const model = service.view(sessionId, 'recovered', 'recovered', info.id)
   await model.refresh()
   await model.refresh()
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false })
   expect(model.state.getSnapshot()).toMatchObject({ phase: 'failed', writable: false })
   expect(model.state.getSnapshot().issue).toBe('missingTerminal')
   expect(model.state.getSnapshot().issue).toBe('missingTerminal')
@@ -168,13 +176,13 @@ it('removes a view synchronously, keeps cleanup retryable, and never deletes a r
   storage()
   storage()
   const h = fixture()
   const h = fixture()
   const { service } = await h.service()
   const { service } = await h.service()
-  const first = service.view(sessionId, 'tab')
+  const first = service.view(sessionId, 'tab', 'tab')
   first.mount()
   first.mount()
   await first.refresh()
   await first.refresh()
   const closing = Promise.withResolvers<RemoteResult<void>>()
   const closing = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise)
   vi.mocked(h.remote.close).mockReturnValueOnce(closing.promise)
-  service.close(sessionId, 'tab')
-  const replacement = service.view(sessionId, 'tab')
+  service.close(sessionId, 'tab', 'tab')
+  const replacement = service.view(sessionId, 'tab', 'tab')
   expect(replacement).not.toBe(first)
   expect(replacement).not.toBe(first)
   expect(service.closeFailures.getSnapshot()).toEqual([])
   expect(service.closeFailures.getSnapshot()).toEqual([])
   closing.resolve(failure('termination refused'))
   closing.resolve(failure('termination refused'))
@@ -183,7 +191,7 @@ it('removes a view synchronously, keeps cleanup retryable, and never deletes a r
   service.retryClose(failed.id)
   service.retryClose(failed.id)
   service.retryClose(failed.id)
   service.retryClose(failed.id)
   await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([])
   await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([])
-  expect(service.view(sessionId, 'tab')).toBe(replacement)
+  expect(service.view(sessionId, 'tab', 'tab')).toBe(replacement)
   await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
   await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
 })
 })
 
 
@@ -191,7 +199,7 @@ it('closes an inactive restored tab by tab identity and retries saved close requ
   storage()
   storage()
   const h = fixture()
   const h = fixture()
   const first = await h.service()
   const first = await h.service()
-  first.service.close(sessionId, 'inactive', 'inactive' as WebTerminalId)
+  first.service.close(sessionId, 'inactive', 'inactive', 'inactive' as WebTerminalId)
   await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
   await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
   expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive')
   expect(h.remote.close).toHaveBeenCalledWith(sessionId, 'inactive')
   await first.dispose()
   await first.dispose()
@@ -218,17 +226,17 @@ it('recovers Host terminals by Session while excluding held, pending-close, and
   storage()
   storage()
   const h = fixture()
   const h = fixture()
   const { service } = await h.service()
   const { service } = await h.service()
-  const held = service.view(sessionId, 'held')
+  const held = service.view(sessionId, 'held', 'held')
   await held.refresh()
   await held.refresh()
-  const closing = service.view(sessionId, 'closing')
+  const closing = service.view(sessionId, 'closing', 'closing')
   await closing.refresh()
   await closing.refresh()
-  const done = service.view(sessionId, 'closed')
+  const done = service.view(sessionId, 'closed', 'closed')
   await done.refresh()
   await done.refresh()
   const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId }
   const unheld: WebTerminalInfo = { ...info, id: 'unheld' as WebTerminalId }
   const pending = Promise.withResolvers<RemoteResult<void>>()
   const pending = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
-  service.close(sessionId, 'closing')
-  service.close(sessionId, 'closed')
+  service.close(sessionId, 'closing', 'closing')
+  service.close(sessionId, 'closed', 'closed')
   await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1)
   await expect.poll(() => new TerminalCloseRequests().pending().length).toBe(1)
   vi.mocked(h.remote.list).mockResolvedValue(success([
   vi.mocked(h.remote.list).mockResolvedValue(success([
     { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld,
     { ...info, id: held.id }, { ...info, id: closing.id }, { ...info, id: done.id }, unheld,
@@ -250,11 +258,11 @@ it('queries current held views after a slow recovery response and reports discov
   vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
   vi.mocked(h.remote.list).mockReturnValueOnce(listed.promise)
   const recovering = service.recover(sessionId)
   const recovering = service.recover(sessionId)
   vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
   vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
-  const model = service.view(sessionId, 'recovered', info.id)
+  const model = service.view(sessionId, 'recovered', 'recovered', info.id)
   await model.refresh()
   await model.refresh()
   listed.resolve(success([info]))
   listed.resolve(success([info]))
   expect(await recovering).toEqual([])
   expect(await recovering).toEqual([])
-  expect(service.view(sessionId, 'recovered', info.id)).toBe(model)
+  expect(service.view(sessionId, 'recovered', 'recovered', info.id)).toBe(model)
   expect(model.id).toBe(info.id)
   expect(model.id).toBe(info.id)
   vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable'))
   vi.mocked(h.remote.list).mockResolvedValueOnce(failure('Session unavailable'))
   await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable')
   await expect(service.recover(sessionId)).rejects.toThrow('Session unavailable')
@@ -264,7 +272,7 @@ it('ignores an unknown tab and retries only saved close requests', async () => {
   storage()
   storage()
   const h = fixture()
   const h = fixture()
   const { service } = await h.service()
   const { service } = await h.service()
-  service.close(sessionId, 'unknown')
+  service.close(sessionId, 'unknown', 'unknown')
   service.retryClose('unknown' as WebTerminalId)
   service.retryClose('unknown' as WebTerminalId)
   expect(h.remote.close).not.toHaveBeenCalled()
   expect(h.remote.close).not.toHaveBeenCalled()
   expect(new TerminalCloseRequests().pending()).toEqual([])
   expect(new TerminalCloseRequests().pending()).toEqual([])
@@ -275,7 +283,7 @@ it('retains an inactive close failure with its tab title until retry succeeds',
   const h = fixture()
   const h = fixture()
   const { service } = await h.service()
   const { service } = await h.service()
   vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup'))
   vi.mocked(h.remote.close).mockResolvedValueOnce(failure('Host refused cleanup'))
-  service.close(sessionId, 'Build', info.id)
+  service.close(sessionId, 'Build', 'Build', info.id)
   expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true)
   expect(data.has(`dsh.terminal.close.v1.${info.id}`)).toBe(true)
   await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }])
   await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'Host refused cleanup' }])
   vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
   vi.mocked(h.remote.list).mockResolvedValueOnce(success([info]))
@@ -295,7 +303,7 @@ it('preserves a close failure from a non-Error rejection', async () => {
   const h = fixture()
   const h = fixture()
   const { service } = await h.service()
   const { service } = await h.service()
   vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed')
   vi.mocked(h.remote.close).mockRejectedValueOnce('carrier closed')
-  service.close(sessionId, 'Build', info.id)
+  service.close(sessionId, 'Build', 'Build', info.id)
   await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }])
   await expect.poll(() => service.closeFailures.getSnapshot()).toEqual([{ id: info.id, title: 'Build', message: 'carrier closed' }])
 })
 })
 
 
@@ -305,7 +313,7 @@ it('waits for pending cleanup during service disposal without publishing a late
   const { service, dispose } = await h.service()
   const { service, dispose } = await h.service()
   const pending = Promise.withResolvers<RemoteResult<void>>()
   const pending = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
-  service.close(sessionId, 'Build', info.id)
+  service.close(sessionId, 'Build', 'Build', info.id)
   let finished = false
   let finished = false
   const disposing = dispose().then(() => { finished = true })
   const disposing = dispose().then(() => { finished = true })
   expect(finished).toBe(false)
   expect(finished).toBe(false)
@@ -317,23 +325,100 @@ it('waits for pending cleanup during service disposal without publishing a late
   expect(h.remote.close).toHaveBeenCalledOnce()
   expect(h.remote.close).toHaveBeenCalledOnce()
 })
 })
 
 
-it('saves close intents without persisting any active terminal or sidebar state', async () => {
+it('persists occurrence identities before allocation and removes them on close without saving process output', async () => {
   const data = storage()
   const data = storage()
   const h = fixture()
   const h = fixture()
   const { service } = await h.service()
   const { service } = await h.service()
-  const model = service.view(sessionId, 'new-tab')
+  const model = service.view(sessionId, 'new-tab', 'new-tab')
+  const key = 'dsh.terminal.binding.v1.' + JSON.stringify([sessionId, 'new-tab'])
+  expect(JSON.parse(data.get(key)!)).toBe(model.id)
   await model.refresh()
   await model.refresh()
   expect(model.id).toMatch(/^[0-9a-f-]{36}$/)
   expect(model.id).toMatch(/^[0-9a-f-]{36}$/)
-  expect([...data.entries()]).toEqual([['dsh.terminal.shell', info.shell.path]])
+  expect([...data.keys()]).toEqual([key, 'dsh.terminal.shell'])
   const pending = Promise.withResolvers<RemoteResult<void>>()
   const pending = Promise.withResolvers<RemoteResult<void>>()
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
   vi.mocked(h.remote.close).mockReturnValueOnce(pending.promise)
-  service.close(sessionId, 'new-tab')
+  service.close(sessionId, 'new-tab', 'new-tab')
   const request = { sessionId, id: model.id, title: info.title }
   const request = { sessionId, id: model.id, title: info.title }
-  expect([...data.entries()]).toEqual([['dsh.terminal.shell', info.shell.path], [`dsh.terminal.close.v1.${model.id}`, JSON.stringify(request)]])
+  expect(data.has(key)).toBe(false)
+  expect(data.get(`dsh.terminal.close.v1.${model.id}`)).toBe(JSON.stringify(request))
   pending.resolve(success(undefined))
   pending.resolve(success(undefined))
   await expect.poll(() => [...data.keys()]).toEqual(['dsh.terminal.shell'])
   await expect.poll(() => [...data.keys()]).toEqual(['dsh.terminal.shell'])
 })
 })
 
 
+it('restores the same terminal in the same occurrence after reload without opening a recovery duplicate', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  const original = first.service.view(sessionId, 'tab', 'tab')
+  await original.refresh()
+  await first.dispose()
+  vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, id: original.id }]))
+  const second = await h.service()
+  const restored = second.service.view(sessionId, 'tab', 'tab')
+  expect(await second.service.recover(sessionId)).toEqual([])
+  restored.mount()
+  await restored.refresh()
+  expect(restored.id).toBe(original.id)
+  expect(h.remote.create).toHaveBeenCalledOnce()
+  await expect.poll(() => restored.state.getSnapshot().render?.frame).toMatchObject({ type: 'snapshot', screen: 'retained' })
+})
+
+it('offers retained processes when their saved occurrence is absent from the restored layout', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  const original = first.service.view(sessionId, 'lost-tab', 'lost-tab')
+  await original.refresh()
+  await first.dispose()
+  const retained = { ...info, id: original.id }
+  vi.mocked(h.remote.list).mockResolvedValue(success([retained]))
+  const second = await h.service()
+  expect(await second.service.recover(sessionId)).toEqual([retained])
+})
+
+it('keeps identical occurrence keys in different Sessions independent across reload', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  const a = first.service.view(sessionId, 'tab', 'tab')
+  const other = 'other' as SessionId
+  const b = first.service.view(other, 'tab', 'tab')
+  await Promise.all([a.refresh(), b.refresh()])
+  expect(a.id).not.toBe(b.id)
+  await first.dispose()
+  const second = await h.service()
+  expect(second.service.view(sessionId, 'tab', 'tab').id).toBe(a.id)
+  expect(second.service.view(other, 'tab', 'tab').id).toBe(b.id)
+})
+
+it('closes a saved inactive occurrence without mounting a view after reload', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  const original = first.service.view(sessionId, 'tab', 'tab')
+  await original.refresh()
+  await first.dispose()
+  const second = await h.service()
+  second.service.close(sessionId, 'tab', 'tab')
+  await expect.poll(() => h.remote.close).toHaveBeenCalledWith(sessionId, original.id)
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
+it('reports a missing saved terminal after reload and never starts a replacement shell', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  const original = first.service.view(sessionId, 'tab', 'tab')
+  await original.refresh()
+  await first.dispose()
+  const second = await h.service()
+  const restored = second.service.view(sessionId, 'tab', 'tab')
+  await restored.refresh()
+  expect(restored.state.getSnapshot()).toMatchObject({ phase: 'failed', issue: 'missingTerminal' })
+  await restored.refresh()
+  expect(h.remote.create).toHaveBeenCalledOnce()
+})
+
 it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => {
 it.each(['{broken', 'null', '{}', '[{}]', '{"sessionId":"s","id":"bad/id","title":"x"}', '{"sessionId":"s","id":"different","title":"x"}'])('discards malformed saved cleanup: %s', (raw) => {
   const data = storage()
   const data = storage()
   data.set('dsh.terminal.close.v1.terminal', raw)
   data.set('dsh.terminal.close.v1.terminal', raw)
@@ -382,13 +467,14 @@ it.each(['saved', 'view'] as const)('clears a %s close request after the Host co
   vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) })
   vi.mocked(h.remote.close).mockResolvedValue({ ok: false, error: new RemoteError('session/not-found', 'Deleted Session', { sessionId }) })
   const { service, dispose } = await h.service()
   const { service, dispose } = await h.service()
   if (source === 'view') {
   if (source === 'view') {
-    const view = service.view(sessionId, 'tab')
+    const view = service.view(sessionId, 'tab', 'tab')
     await view.refresh()
     await view.refresh()
-    service.close(sessionId, 'tab')
+    service.close(sessionId, 'tab', 'tab')
   }
   }
   await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
   await expect.poll(() => vi.mocked(h.remote.close).mock.calls.length).toBe(1)
   await dispose()
   await dispose()
-  expect([...data.entries()]).toEqual(source === 'view' ? [['dsh.terminal.shell', info.shell.path]] : [])
+  expect([...data.entries()]).toEqual(source === 'view'
+    ? [['dsh.terminal.shell', info.shell.path]] : [])
   expect(service.closeFailures.getSnapshot()).toEqual([])
   expect(service.closeFailures.getSnapshot()).toEqual([])
   expect(new TerminalCloseRequests().pending()).toEqual([])
   expect(new TerminalCloseRequests().pending()).toEqual([])
   await h.service()
   await h.service()
@@ -417,7 +503,7 @@ it('waits for both active and detached stream finalizers during plugin disposal
       finished[current]!.resolve(undefined)
       finished[current]!.resolve(undefined)
     }
     }
   })
   })
-  const model = service.view(sessionId, 'tab')
+  const model = service.view(sessionId, 'tab', 'tab')
   model.mount()
   model.mount()
   await model.refresh()
   await model.refresh()
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
   await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
@@ -451,7 +537,7 @@ it('discovers menu choices without creating a process and remembers a choice bef
   expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
   expect(data.get('dsh.terminal.shell')).toBe(alternate.path)
   expect(h.remote.create).not.toHaveBeenCalled()
   expect(h.remote.create).not.toHaveBeenCalled()
   expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBe(alternate.path)
   expect((await service.launchShells(sessionId, new AbortController().signal)).selectedShell).toBe(alternate.path)
-  const model = service.view(sessionId, 'chosen', undefined, alternate.path)
+  const model = service.view(sessionId, 'chosen', 'chosen', undefined, alternate.path)
   await model.refresh()
   await model.refresh()
   expect(h.remote.create).toHaveBeenLastCalledWith(
   expect(h.remote.create).toHaveBeenLastCalledWith(
     sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
     sessionId, expect.objectContaining({ shellPath: alternate.path }), expect.any(AbortSignal),
@@ -507,3 +593,144 @@ it('keeps launch usable when browser storage is denied and stops late shell disc
   expect(delayed.state.getSnapshot().phase).toBe('closed')
   expect(delayed.state.getSnapshot().phase).toBe('closed')
   expect(h.remote.create).toHaveBeenCalledOnce()
   expect(h.remote.create).toHaveBeenCalledOnce()
 })
 })
+
+it('retains only open saved occurrences across inactive Sessions and deduplicates their Host identities', async () => {
+  storage()
+  const otherSession = 'dormant-session' as SessionId
+  const otherId = 'other-terminal' as WebTerminalId
+  const bindings = new TerminalBindings()
+  bindings.set(sessionId, 'a', info.id)
+  bindings.set(sessionId, 'duplicate', info.id)
+  bindings.set(sessionId, 'stale', 'orphan' as WebTerminalId)
+  bindings.set(otherSession, 'b', otherId)
+  const h = fixture()
+  const { service } = await h.service()
+  service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }, { sessionId, tabId: 'duplicate', contentId: 'duplicate' }, { sessionId: otherSession, tabId: 'b', contentId: 'b' }])
+  await expect.poll(() => h.remote.retain).toHaveBeenCalledTimes(2)
+  expect(h.remote.environment).not.toHaveBeenCalled()
+  expect(h.remote.create).not.toHaveBeenCalled()
+  expect(h.remote.list).not.toHaveBeenCalled()
+  expect(h.remote.follow).not.toHaveBeenCalled()
+  const calls = vi.mocked(h.remote.retain).mock.calls
+  service.retainTabs([{ sessionId: otherSession, tabId: 'b', contentId: 'b' }])
+  await expect.poll(() => calls.find(call => call[0] === sessionId)?.[2]?.aborted).toBe(true)
+  expect(calls.find(call => call[0] === otherSession)?.[2]?.aborted).toBe(false)
+  service.retainTabs([])
+  await expect.poll(() => calls.every(call => call[2]?.aborted)).toBe(true)
+})
+
+it('waits for the window hold acknowledgement before restoring an output attachment', async () => {
+  storage()
+  new TerminalBindings().set(sessionId, 'restored', info.id)
+  const h = fixture()
+  vi.mocked(h.remote.list).mockResolvedValue(success([info]))
+  const acknowledge = Promise.withResolvers<undefined>()
+  vi.mocked(h.remote.retain).mockImplementation(async function* (_session, _id, signal) {
+    await acknowledge.promise
+    yield { type: 'retained' }
+    await new Promise<void>((resolve) => {
+      if (signal?.aborted) resolve()
+      else signal?.addEventListener('abort', () => { resolve() }, { once: true })
+    })
+  })
+  const { service } = await h.service()
+  service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
+  const model = service.view(sessionId, 'restored', 'restored')
+  model.mount()
+  await model.refresh()
+  expect(h.remote.follow).not.toHaveBeenCalled()
+  acknowledge.resolve(undefined)
+  await expect.poll(() => model.state.getSnapshot().writable).toBe(true)
+  expect(h.remote.retain).toHaveBeenCalledOnce()
+  expect(h.remote.create).not.toHaveBeenCalled()
+})
+
+it('shows a missing terminal when retention loses the race to Host cleanup, without allocating a replacement', async () => {
+  storage()
+  new TerminalBindings().set(sessionId, 'restored', info.id)
+  const h = fixture()
+  vi.mocked(h.remote.list).mockResolvedValue(success([info]))
+  vi.mocked(h.remote.retain).mockImplementation(() => { throw new RemoteError('terminal/unavailable', 'gone', {}) })
+  const { service } = await h.service()
+  service.retainTabs([{ sessionId, tabId: 'restored', contentId: 'restored' }])
+  const model = service.view(sessionId, 'restored', 'restored')
+  model.mount()
+  await expect.poll(() => model.state.getSnapshot().issue).toBe('missingTerminal')
+  expect(h.remote.follow).not.toHaveBeenCalled()
+  expect(h.remote.create).not.toHaveBeenCalled()
+})
+
+it('ignores late inventory after disposal and excludes missing bindings and saved close requests', async () => {
+  storage()
+  new TerminalBindings().set(sessionId, 'closing', info.id)
+  new TerminalCloseRequests().save({ sessionId, id: info.id, title: 'Closing' })
+  const h = fixture()
+  const { service, dispose } = await h.service()
+  service.retainTabs([{ sessionId, tabId: 'missing', contentId: 'missing' }, { sessionId, tabId: 'closing', contentId: 'closing' }])
+  expect(h.remote.retain).not.toHaveBeenCalled()
+  await dispose()
+  service.retainTabs([{ sessionId, tabId: 'closing', contentId: 'closing' }])
+  expect(h.remote.retain).not.toHaveBeenCalled()
+})
+
+it('keeps other holds usable when releasing one transport fails', async () => {
+  storage()
+  new TerminalBindings().set(sessionId, 'a', info.id)
+  const h = fixture()
+  const { service } = await h.service()
+  service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }])
+  await expect.poll(() => h.remote.retain).toHaveBeenCalledOnce()
+  const { TerminalWindowHold } = await import('../src/client/retention.ts')
+  // oxlint-disable-next-line typescript/unbound-method -- Preserve the real disposer while injecting one failure after it settles.
+  const original = TerminalWindowHold.prototype.dispose
+  const failure = vi.spyOn(TerminalWindowHold.prototype, 'dispose')
+  failure.mockImplementationOnce(async function (this: InstanceType<typeof TerminalWindowHold>) {
+    await original.call(this)
+    throw new Error('transport close failed')
+  })
+  service.retainTabs([])
+  await expect.poll(() => failure).toHaveBeenCalledOnce()
+  await setImmediate()
+  failure.mockRestore()
+  service.retainTabs([{ sessionId, tabId: 'a', contentId: 'a' }])
+  await expect.poll(() => h.remote.retain).toHaveBeenCalledTimes(2)
+})
+
+it('ignores a delayed retention failure after the restored view was disposed', async () => {
+  const h = fixture()
+  vi.mocked(h.remote.list).mockResolvedValue(success([info]))
+  const retained = Promise.withResolvers<undefined>()
+  const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
+  const model = new TerminalView(sessionId, h.remote, gateway, info.id, false, undefined, () => retained.promise)
+  cleanups.push(() => model.dispose())
+  await model.refresh()
+  await model.dispose()
+  const snapshot = model.state.getSnapshot()
+  retained.reject(new Error('late hold rejection'))
+  await setImmediate()
+  expect(model.state.getSnapshot()).toBe(snapshot)
+})
+
+it('keeps new terminals with colliding layout-local tab ids independent across shared-storage windows', async () => {
+  storage()
+  const h = fixture()
+  const first = await h.service()
+  const second = await h.service()
+  const a = first.service.view(sessionId, 'tab2', 'sidebar://terminal/a')
+  await a.refresh()
+  const b = second.service.view(sessionId, 'tab2', 'sidebar://terminal/b')
+  await b.refresh()
+  expect(a.id).not.toBe(b.id)
+  expect(h.remote.create).toHaveBeenCalledTimes(2)
+  first.service.close(sessionId, 'tab2', 'sidebar://terminal/a')
+  await expect.poll(() => new TerminalCloseRequests().pending()).toEqual([])
+  expect(h.remote.close).toHaveBeenCalledWith(sessionId, a.id)
+  expect(h.remote.close).not.toHaveBeenCalledWith(sessionId, b.id)
+  await second.dispose()
+  vi.mocked(h.remote.list).mockResolvedValue(success([{ ...info, id: b.id }]))
+  const reloaded = await h.service()
+  const restored = reloaded.service.view(sessionId, 'tab2', 'sidebar://terminal/b')
+  await restored.refresh()
+  expect(restored.id).toBe(b.id)
+  expect(h.remote.create).toHaveBeenCalledTimes(2)
+})

+ 58 - 0
packages/api/terminal-controller/tests/retention.client.spec.ts

@@ -0,0 +1,58 @@
+/** A window hold accepts each physical acknowledgement and releases pending consumers. */
+import { afterEach, expect, it, vi } from 'vitest'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { RemoteStream, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TerminalRemote } from '../src/client/model.ts'
+import { TerminalWindowHold } from '../src/client/retention.ts'
+import type { TerminalRetentionFrame, WebTerminalId } from '../src/types.ts'
+
+const holds: TerminalWindowHold[] = []
+afterEach(async () => { await Promise.all(holds.splice(0).map(hold => hold.dispose())) })
+function hold(retain: TerminalRemote['retain']) {
+  const gateway: Pick<ClientRemote, '$stream'> = { $stream: options => new RemoteStream({ generation: createSnapshotStore(undefined) }, options) }
+  const held = new TerminalWindowHold(gateway, { retain } as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId)
+  holds.push(held)
+  return held
+}
+
+it('fails pending and later attachments when the Host ends an acknowledged hold', async () => {
+  const finish = Promise.withResolvers<undefined>()
+  const held = hold(async function* () { yield { type: 'retained' }; await finish.promise })
+  await held.ready(new AbortController().signal)
+  finish.resolve(undefined)
+  await expect.poll(() => held.failed).toBe(true)
+  await expect(held.ready(new AbortController().signal)).rejects.toThrow('hold ended')
+})
+
+it('cancels an acknowledgement waiter independently of another view of the same terminal', async () => {
+  const ack = Promise.withResolvers<undefined>()
+  const held = hold(async function* (_session, _id, signal) {
+    await ack.promise
+    yield { type: 'retained' }
+    await new Promise<void>((resolve) => { signal?.addEventListener('abort', () => { resolve() }, { once: true }) })
+  })
+  const cancel = new AbortController()
+  const first = held.ready(cancel.signal)
+  const rejected = expect(first).rejects.toThrow('view removed')
+  const second = held.ready(new AbortController().signal)
+  cancel.abort(new Error('view removed'))
+  await rejected
+  ack.resolve(undefined)
+  await second
+  expect(held.failed).toBe(false)
+})
+
+it('preserves a non-Error transport failure as the cause of the failed hold', async () => {
+  const gateway = { $stream: () => ({
+    async *[Symbol.asyncIterator](): AsyncGenerator<never> {
+      // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Exercise an untyped carrier failure.
+      await Promise.reject('non-error transport failure')
+    },
+    dispose: async () => {},
+  } as unknown as RemoteStream<TerminalRetentionFrame>) } as Pick<ClientRemote, '$stream'>
+  const held = new TerminalWindowHold(gateway, { retain: vi.fn() } as unknown as TerminalRemote, 'session' as SessionId, 'terminal' as WebTerminalId)
+  holds.push(held)
+  await expect.poll(() => held.failed).toBe(true)
+  await expect(held.ready(new AbortController().signal)).rejects.toMatchObject({ cause: 'non-error transport failure' })
+})

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

@@ -0,0 +1,195 @@
+/** Monotonic idle grace, independent window holds, and retryable terminal ownership. */
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { SubprocessTerminalActivity } from '@deepseek-ai/dsh-subprocess'
+import { TerminalRetention, type TerminalRetentionPolicy } from '../src/retention.ts'
+
+const owners: TerminalRetention[] = []
+beforeEach(() => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }) })
+afterEach(async () => { await Promise.allSettled(owners.splice(0).map(owner => owner.dispose())); vi.useRealTimers() })
+
+function fixture(policy: Partial<TerminalRetentionPolicy> = {}) {
+  const inspect = vi.fn(async (): Promise<SubprocessTerminalActivity> => ({ state: 'idle', revision: 1 }))
+  const terminate = vi.fn(async () => {})
+  const failed = vi.fn()
+  const owner = new TerminalRetention(
+    { unattendedTimeoutMs: 100, activityPollIntervalMs: 10, cleanupRetryMs: 20, ...policy }, inspect, terminate, failed,
+  )
+  owners.push(owner)
+  return { owner, inspect, terminate, failed }
+}
+
+it('starts the whole idle grace at the first positive observation and clears only at its deadline', async () => {
+  const h = fixture()
+  await vi.advanceTimersByTimeAsync(99)
+  expect(h.terminate).not.toHaveBeenCalled()
+  await vi.advanceTimersByTimeAsync(1)
+  expect(h.terminate).toHaveBeenCalledOnce()
+  await vi.advanceTimersByTimeAsync(1000)
+  expect(h.terminate).toHaveBeenCalledOnce()
+})
+
+it('retains two independent windows and starts a fresh grace after the last disconnect', async () => {
+  const h = fixture()
+  const a = new AbortController()
+  const b = new AbortController()
+  const first = h.owner.retain(a.signal)[Symbol.asyncIterator]()
+  const second = h.owner.retain(b.signal)[Symbol.asyncIterator]()
+  expect(await first.next()).toMatchObject({ value: { type: 'retained' } })
+  await second.next()
+  a.abort()
+  await vi.advanceTimersByTimeAsync(500)
+  expect(h.inspect).not.toHaveBeenCalled()
+  b.abort()
+  await vi.advanceTimersByTimeAsync(99)
+  expect(h.terminate).not.toHaveBeenCalled()
+  await vi.advanceTimersByTimeAsync(1)
+  expect(h.terminate).toHaveBeenCalledOnce()
+  await first.return?.()
+  await second.return?.()
+})
+
+it.each(['busy', 'unknown'] as const)('protects %s work for arbitrarily many deadlines, then gives it the full idle grace', async (state) => {
+  const h = fixture()
+  h.inspect.mockResolvedValue({ state, revision: 2 })
+  await vi.advanceTimersByTimeAsync(1000)
+  expect(h.terminate).not.toHaveBeenCalled()
+  h.inspect.mockResolvedValue({ state: 'idle', revision: 3 })
+  await vi.advanceTimersByTimeAsync(109)
+  expect(h.terminate).not.toHaveBeenCalled()
+  await vi.advanceTimersByTimeAsync(1)
+  expect(h.terminate).toHaveBeenCalledOnce()
+})
+
+it('invalidates elapsed grace on failed inspection, input, and intervening shell revisions', async () => {
+  const h = fixture()
+  await vi.advanceTimersByTimeAsync(90)
+  h.inspect.mockRejectedValueOnce(new Error('SSH unavailable'))
+  await vi.advanceTimersByTimeAsync(90)
+  h.owner.invalidate()
+  await vi.advanceTimersByTimeAsync(90)
+  h.inspect.mockResolvedValue({ state: 'idle', revision: 2 })
+  await vi.advanceTimersByTimeAsync(100)
+  expect(h.terminate).not.toHaveBeenCalled()
+  await vi.advanceTimersByTimeAsync(10)
+  expect(h.terminate).toHaveBeenCalledOnce()
+})
+
+it('discards an asynchronous observation invalidated by a reconnect and its subsequent disconnect', async () => {
+  const h = fixture()
+  await vi.advanceTimersByTimeAsync(90)
+  const pending = Promise.withResolvers<SubprocessTerminalActivity>()
+  h.inspect.mockReturnValueOnce(pending.promise)
+  await vi.advanceTimersByTimeAsync(10)
+  const abort = new AbortController()
+  const stream = h.owner.retain(abort.signal)[Symbol.asyncIterator]()
+  await stream.next()
+  abort.abort()
+  pending.resolve({ state: 'idle', revision: 1 })
+  await vi.advanceTimersByTimeAsync(99)
+  expect(h.terminate).not.toHaveBeenCalled()
+  await vi.advanceTimersByTimeAsync(1)
+  expect(h.terminate).toHaveBeenCalledOnce()
+  await stream.return?.()
+})
+
+it('keeps failed cleanup closed to new holders and retries once without a new idle grace', async () => {
+  const h = fixture()
+  h.terminate.mockRejectedValueOnce(new Error('process still alive'))
+  await vi.advanceTimersByTimeAsync(100)
+  expect(h.failed).toHaveBeenCalledOnce()
+  await expect(h.owner.retain(new AbortController().signal)[Symbol.asyncIterator]().next()).rejects.toThrow('unavailable')
+  await vi.advanceTimersByTimeAsync(19)
+  expect(h.terminate).toHaveBeenCalledOnce()
+  await vi.advanceTimersByTimeAsync(1)
+  expect(h.terminate).toHaveBeenCalledTimes(2)
+})
+
+it('joins explicit cleanup, closes held streams, and cancels timers on disposal', async () => {
+  const h = fixture()
+  const stream = h.owner.retain(new AbortController().signal)[Symbol.asyncIterator]()
+  await stream.next()
+  const ended = stream.next()
+  const pending = Promise.withResolvers<undefined>()
+  h.terminate.mockReturnValueOnce(pending.promise)
+  const first = h.owner.close()
+  expect(h.owner.close()).toBe(first)
+  expect(await ended).toMatchObject({ done: true })
+  pending.resolve(undefined)
+  await h.owner.dispose()
+  expect(vi.getTimerCount()).toBe(0)
+  expect(h.terminate).toHaveBeenCalledOnce()
+})
+
+it('can disable automatic reclamation while retaining explicit cleanup and cancellation', async () => {
+  const h = fixture({ unattendedTimeoutMs: 0 })
+  await vi.advanceTimersByTimeAsync(1000)
+  expect(h.inspect).not.toHaveBeenCalled()
+  const signal = AbortSignal.abort(new Error('disconnected'))
+  await expect(h.owner.retain(signal)[Symbol.asyncIterator]().next()).rejects.toThrow('disconnected')
+  await h.owner.close()
+  expect(h.terminate).toHaveBeenCalledOnce()
+})
+
+it('bounds native timers without shortening a configured cleanup retry', async () => {
+  const h = fixture({ unattendedTimeoutMs: 0, cleanupRetryMs: 3_000_000_000 })
+  h.terminate.mockRejectedValueOnce(new Error('retry'))
+  await expect(h.owner.close()).rejects.toThrow('retry')
+  await vi.advanceTimersByTimeAsync(2_999_999_999)
+  expect(h.terminate).toHaveBeenCalledOnce()
+  await vi.advanceTimersByTimeAsync(1)
+  expect(h.terminate).toHaveBeenCalledTimes(2)
+})
+
+it('does not overlap activity queries when a disconnect schedules work during an outstanding observation', async () => {
+  const h = fixture()
+  const pending = Promise.withResolvers<SubprocessTerminalActivity>()
+  h.inspect.mockReturnValueOnce(pending.promise)
+  await vi.advanceTimersByTimeAsync(0)
+  const abort = new AbortController()
+  const stream = h.owner.retain(abort.signal)[Symbol.asyncIterator]()
+  await stream.next()
+  abort.abort()
+  await vi.advanceTimersByTimeAsync(0)
+  expect(h.inspect).toHaveBeenCalledOnce()
+  pending.resolve({ state: 'idle', revision: 1 })
+  await vi.advanceTimersByTimeAsync(10)
+  expect(h.inspect).toHaveBeenCalledTimes(2)
+  expect(h.terminate).not.toHaveBeenCalled()
+  await stream.return?.()
+})
+
+it('waits for the in-flight observation before reporting a disposal cleanup failure', async () => {
+  const h = fixture()
+  const pending = Promise.withResolvers<SubprocessTerminalActivity>()
+  h.inspect.mockReturnValueOnce(pending.promise)
+  await vi.advanceTimersByTimeAsync(0)
+  const error = new Error('cleanup failed')
+  h.terminate.mockRejectedValueOnce(error)
+  let settled = false
+  const disposed = h.owner.dispose().catch((reason: unknown) => { settled = true; return reason })
+  try {
+    await vi.advanceTimersByTimeAsync(0)
+    expect(settled).toBe(false)
+    pending.resolve({ state: 'unknown', revision: 1 })
+    expect(await disposed).toBe(error)
+  } finally { pending.resolve({ state: 'unknown', revision: 1 }); await disposed }
+})
+
+it('waits for pending cleanup when the observation settles first during disposal', async () => {
+  const h = fixture()
+  const observed = Promise.withResolvers<SubprocessTerminalActivity>()
+  const cleanup = Promise.withResolvers<undefined>()
+  h.inspect.mockReturnValueOnce(observed.promise)
+  h.terminate.mockReturnValueOnce(cleanup.promise)
+  await vi.advanceTimersByTimeAsync(0)
+  let settled = false
+  const disposed = h.owner.dispose().then(() => { settled = true })
+  try {
+    observed.resolve({ state: 'unknown', revision: 1 })
+    await vi.advanceTimersByTimeAsync(0)
+    expect(settled).toBe(false)
+    cleanup.resolve(undefined)
+    await disposed
+    expect(settled).toBe(true)
+  } finally { observed.resolve({ state: 'unknown', revision: 1 }); cleanup.resolve(undefined); await disposed }
+})

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

@@ -16,6 +16,7 @@ function fixture() {
   const outcome = Promise.withResolvers<SubprocessOutcome>()
   const outcome = Promise.withResolvers<SubprocessOutcome>()
   const handle = {
   const handle = {
     pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
     pid: 123, output, done: outcome.promise, write: vi.fn(async () => {}), resize: vi.fn(async () => {}),
+    inspectActivity: async () => ({ state: 'unknown' as const, revision: 0 }),
     inspectForeground: async () => undefined, signalForeground: async () => 123,
     inspectForeground: async () => undefined, signalForeground: async () => 123,
     terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }),
     terminate: vi.fn(async () => { output.end(); outcome.resolve({ exitCode: 0, signal: null }) }),
   }
   }
@@ -226,3 +227,10 @@ async function readFrame(iterator: AsyncIterator<TerminalFrame>): Promise<Termin
   if (result.done === true) throw new Error('Terminal stream ended before its expected frame')
   if (result.done === true) throw new Error('Terminal stream ended before its expected frame')
   return result.value
   return result.value
 }
 }
+
+it('refuses retention before allocation commit and disposes an uncommitted screen directly', async () => {
+  const { terminal, handle } = fixture()
+  expect(() => terminal.retain(new AbortController().signal)).toThrow('not been committed')
+  await terminal.dispose()
+  expect(handle.terminate).toHaveBeenCalledOnce()
+})

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

@@ -7,7 +7,9 @@
   },
   },
   "files": [
   "files": [
     "src/client/index.ts",
     "src/client/index.ts",
+    "src/client/bindings.ts",
     "src/client/model.ts",
     "src/client/model.ts",
+    "src/client/retention.ts",
     "src/client/shell-preference.ts",
     "src/client/shell-preference.ts",
     "src/types.ts",
     "src/types.ts",
     "src/client/close-requests.ts"
     "src/client/close-requests.ts"

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

@@ -9,6 +9,7 @@
     "src/index.ts",
     "src/index.ts",
     "src/types.ts",
     "src/types.ts",
     "src/terminal.ts",
     "src/terminal.ts",
+    "src/retention.ts",
     "src/stream.ts",
     "src/stream.ts",
     "src/shells.ts"
     "src/shells.ts"
   ],
   ],

+ 2 - 2
packages/client/ui-sidebar-right/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: e5d6e1bef68296e77c6986137bba83ad3f888f8d
-README.zh.md: 75cd092b5cc8602975340b1e434ade2abf7fb442
+README.md: a2ad8e17e58e04f10efb97236951cd172c654f95
+README.zh.md: 5ce03a0f712aa4ca8ef29404ff1a75467d2ec317

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

@@ -67,7 +67,9 @@ After every action the kit's settle planner keeps the expanded surface populated
 
 
 The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely.
 The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the layout stays empty until the next expansion seeds its current default page. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely.
 
 
-Layout, open tabs, navigation parameters and selection are memory-only. A reload constructs the collapsed default; content plugins can then reopen their own live content, as [terminal recovery](../ui-sidebar-terminal/README.md#use-this-package) does. Switching sessions keeps each surface where it was.
+The store saves each Session's layout, tab identities, selection, split ratios, floating rectangles, presentation and identity counter as JSON under `dsh.sidebar-right.v1.<sessionId>` in localStorage. Reload restores that layout before tab bodies render; switching Sessions keeps their layouts independent. Providers restore their own content from the retained tab identity and resource address, including [terminal reconnection](../ui-sidebar-terminal/README.md#use-this-package). Navigation parameters, resource contents and live connections are not layout state. Undo history stays in memory and resets on reload. Saved field types, node membership, selection and identity counters are validated before adoption or startup discovery; invalid data clears only its Session key. Storage failures leave the current layout usable in memory. Windows at the same origin share the last saved layout per Session; each active window keeps its own current layout until reload.
+
+`sidebarRight.openTabs` publishes stable metadata for every open tab across saved and adopted Sessions. Startup discovery reads the layout keys without mounting dormant content, pinning files or activating Agents. Adopted stores replace their own metadata on membership commits, and permanently cleared scopes remove it. Other windows' storage writes do not overwrite this window's live membership. Providers use this inventory to restore their own resource lifetimes.
 
 
 <a id="extension-seats"></a>
 <a id="extension-seats"></a>
 ## Extension seats
 ## Extension seats
@@ -91,7 +93,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
 <a id="the-tab-domain"></a>
 <a id="the-tab-domain"></a>
 ## The Tab domain
 ## The Tab domain
 
 
-The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
+The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store, reconciles restored records immediately, and follows its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
 
 
 Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close.
 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.
 
 
@@ -124,7 +126,7 @@ None; this package neither assembles nor sends a provider request.
 
 
 <a id="known-limitations-and-deferred-work"></a>
 <a id="known-limitations-and-deferred-work"></a>
 
 
-- **Memory-only layout.** Sidebar state is not persisted; content-specific recovery cannot reproduce the previous layout or selection.
+- **Browser-local layout.** Layout does not synchronize between devices; provider-specific state requires its provider's recovery support.
 - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right.
 - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right.
 - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet.
 - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet.
 - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent.
 - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent.

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

@@ -67,7 +67,9 @@ kind: "package-reference"
 
 
 停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。
 停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史,布局保持为空,直到下次展开时创建当时的默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。
 
 
-布局、打开的标签页、导航参数和选中项只在内存中。刷新会构造折叠的默认态;内容插件随后可重新打开自己仍存活的内容,例如[终端恢复](../ui-sidebar-terminal/README.zh.md#use-this-package)。切换会话则让每个停靠面留在原处。
+store 将每个 Session 的布局、标签身份、选中项、分栏比例、浮窗矩形、呈现方式和身份计数以 JSON 保存到 localStorage 的 `dsh.sidebar-right.v1.<sessionId>`。刷新时在渲染标签正文前恢复布局;不同 Session 的布局独立。provider 根据保留的标签身份和资源地址恢复自身内容,包括[终端重连](../ui-sidebar-terminal/README.zh.md#use-this-package)。导航参数、资源内容和活动连接不属于布局状态。撤销历史只保留在内存中,刷新后清空。采用布局或启动探测前会校验保存字段类型、节点成员关系、选中项和身份计数;无效数据只清除对应 Session 的 key。存储失败时,当前布局仍可在内存中使用。同源窗口共享每个 Session 最后保存的布局;活动窗口在刷新前保留各自的当前布局。
+
+`sidebarRight.openTabs` 发布所有已保存和已采用 Session 的打开标签元数据,并保持快照引用稳定。启动时读取布局 key,不挂载非当前内容、pin 文件或激活 Agent。已采用的 store 在标签成员变化时更新自己的元数据,永久清除的 scope 删除对应记录。其他窗口写入 storage 不会覆盖当前窗口的活动成员关系。provider 使用该清单恢复自己的资源生命周期。
 
 
 <a id="extension-seats"></a>
 <a id="extension-seats"></a>
 ## 扩展席位
 ## 扩展席位
@@ -91,7 +93,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
 <a id="the-tab-domain"></a>
 <a id="the-tab-domain"></a>
 ## Tab 域
 ## Tab 域
 
 
-Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
+Tab 域按(Session,Tab id)保留导航、中止信号与绑定动作。私有装配回调采用各 Session 的 store,立即对齐恢复的记录,再跟随其提交。记录消失或插件卸载才中止 signal,收起和切会话不销毁记录;undo 恢复的是新 occurrence。`useTabInfo()` 组合框架绑定的 store 与导航 hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
 
 
 标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
 标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
 
 
@@ -124,7 +126,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
 
 
 <a id="known-limitations-and-deferred-work"></a>
 <a id="known-limitations-and-deferred-work"></a>
 
 
-- **布局只在内存中。** 侧栏状态不持久化;内容自身的恢复无法还原此前的布局或选中项。
+- **布局保存在当前浏览器。** 布局不跨设备同步;provider 专属状态需要由该 provider 支持恢复。
 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。
 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。
 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。
 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。
 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。
 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。

+ 2 - 1
packages/client/ui-sidebar-right/package.json

@@ -68,7 +68,8 @@
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",
     "picomatch": "^4.0.4",
     "picomatch": "^4.0.4",
     "clsx": "^2.0.0",
     "clsx": "^2.0.0",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^"
+    "@deepseek-ai/dsh-util-crypto": "workspace:^",
+    "zod": "^4.4.3"
   },
   },
   "files": [
   "files": [
     "lib/index.js",
     "lib/index.js",

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

@@ -68,6 +68,7 @@ export type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-clien
 export type { PinResource, SidebarRightNavigator, TabOccurrence } from './tab-domain.ts'
 export type { PinResource, SidebarRightNavigator, TabOccurrence } from './tab-domain.ts'
 export type { SidebarRightKey } from './locales.ts'
 export type { SidebarRightKey } from './locales.ts'
 export type { OpenContentIntent } from './stores.ts'
 export type { OpenContentIntent } from './stores.ts'
+export type { SidebarRightOpenTab } from './tab-inventory.ts'
 
 
 /** This package's copy namespace. */
 /** This package's copy namespace. */
 const NS = 'sidebarRight'
 const NS = 'sidebarRight'
@@ -101,7 +102,7 @@ export function apply(ctx: ClientContext): void {
   // its own apply top level for the same reason.
   // its own apply top level for the same reason.
   const t = ctx.locale.bind(NS)
   const t = ctx.locale.bind(NS)
   const tabs = new SidebarRightTabRegistry(ctx)
   const tabs = new SidebarRightTabRegistry(ctx)
-  const { controller, adopt } = createSidebarRightController(
+  const { controller, adopt, forget } = createSidebarRightController(
     tabs,
     tabs,
     (address, signal) => { ctx.resources.pin(address, signal) },
     (address, signal) => { ctx.resources.pin(address, signal) },
   )
   )
@@ -131,7 +132,10 @@ export function apply(ctx: ClientContext): void {
       create: (scopeKey) => {
       create: (scopeKey) => {
         const instance = handle.create(scopeKey)
         const instance = handle.create(scopeKey)
         if (scopeKey !== undefined) adoptions.push(adopt(scopeKey as SessionId, instance))
         if (scopeKey !== undefined) adoptions.push(adopt(scopeKey as SessionId, instance))
-        return instance
+        return { ...instance, clearPersisted() {
+          instance.clearPersisted()
+          if (scopeKey !== undefined) forget(scopeKey as SessionId)
+        } }
       },
       },
     }
     }
     const layout: ILayout = ctx.layout
     const layout: ILayout = ctx.layout

+ 100 - 0
packages/client/ui-sidebar-right/src/client/persistence.ts

@@ -0,0 +1,100 @@
+/** Validated current-layout snapshots; undo history belongs to the live window. */
+import { z } from 'zod'
+import { EMPTY_HISTORY, type LayoutState } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SurfaceState } from './stores.ts'
+
+/** Persistence namespace shared by scoped stores and startup discovery. */
+export const sidebarPersistence = 'dsh.sidebar-right.v1'
+
+const paneId = z.string().regex(/^(?:pane|float)[1-9][0-9]*$/u)
+const splitId = z.string().regex(/^split[1-9][0-9]*$/u)
+const tabId = z.string().regex(/^tab[1-9][0-9]*$/u)
+const nodeId = z.union([paneId, splitId])
+const rectangle = z.object({ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive() })
+const node = z.discriminatedUnion('kind', [
+  z.object({ kind: z.literal('pane'), id: paneId, host: z.enum(['dock', 'float']), tabs: z.array(tabId), activeTabId: tabId.optional(), rect: rectangle.optional() }),
+  z.object({ kind: z.literal('split'), id: splitId, axis: z.enum(['row', 'column']), children: z.array(nodeId).min(2), sizes: z.array(z.number().positive()).min(2) }),
+])
+const layout = z.object({
+  nodes: z.record(nodeId, node),
+  tabs: z.record(tabId, z.object({ id: tabId, kind: z.string().min(1), contentId: z.string().min(1), title: z.string() })),
+  rootId: nodeId, floats: z.array(paneId), activePaneId: paneId,
+  expanded: z.boolean(), mode: z.enum(['push', 'fullscreen']),
+})
+const surface = z.object({ layout, minted: z.int().nonnegative() })
+const envelope = z.object({ bySession: z.record(z.string(), z.unknown()) })
+
+function validateReferences(value: z.infer<typeof surface>): void {
+  const { layout, minted } = value
+  const reject: () => never = () => { throw new Error('Invalid saved sidebar layout references') }
+  const visited = new Set<string>()
+  const usedTabs = new Set<string>()
+  const pending = [{ id: layout.rootId, host: 'dock' }, ...layout.floats.map(id => ({ id, host: 'float' }))]
+  for (const [id, entry] of [...Object.entries(layout.nodes), ...Object.entries(layout.tabs)]) {
+    if (entry.id !== id || Number(id.replace(/^[a-z]+/u, '')) > minted) reject()
+  }
+  for (const { id, host } of pending) {
+    const entry = layout.nodes[id]
+    if (visited.has(id) || entry === undefined) reject()
+    visited.add(id)
+    if (entry.kind === 'split') {
+      if (host !== 'dock' || entry.children.length !== entry.sizes.length
+        || Math.abs(entry.sizes.reduce((sum, size) => sum + size, 0) - 1) > 1e-9) reject()
+      pending.push(...entry.children.map(id => ({ id, host })))
+    } else {
+      if (entry.host !== host || (host === 'float' ? entry.rect === undefined || entry.tabs.length !== 1 : entry.rect !== undefined)) reject()
+      if (entry.tabs.length === 0 ? entry.activeTabId !== undefined : !entry.tabs.includes(entry.activeTabId ?? '')) reject()
+      for (const tab of entry.tabs) {
+        if (usedTabs.has(tab) || layout.tabs[tab] === undefined) reject()
+        usedTabs.add(tab)
+      }
+    }
+  }
+  if (visited.size !== Object.keys(layout.nodes).length || usedTabs.size !== Object.keys(layout.tabs).length
+    || layout.nodes[layout.activePaneId]?.kind !== 'pane') reject()
+}
+
+/**
+ * Restore one validated Session layout with a fresh in-window undo history.
+ * @param sessionId - storage scope.
+ * @returns the saved surface, or undefined when absent, inaccessible or invalid.
+ */
+export function readSidebarLayout(sessionId: string): SurfaceState | undefined {
+  if (typeof localStorage === 'undefined') return undefined
+  let raw: string | null
+  try { raw = localStorage.getItem(`${sidebarPersistence}.${sessionId}`) }
+  catch (_storageUnavailable) { return undefined }
+  if (raw === null) return undefined
+  try {
+    const saved = envelope.parse(JSON.parse(raw)).bySession[sessionId]
+    if (saved === undefined) return undefined
+    const parsed = surface.parse(saved)
+    validateReferences(parsed)
+    return { layout: parsed.layout as unknown as LayoutState, minted: parsed.minted, history: EMPTY_HISTORY }
+  } catch (_invalidLayout) {
+    clearSidebarLayout(sessionId)
+    return undefined
+  }
+}
+
+/**
+ * Persist current layout and identity allocation without retaining undo entries.
+ * @param sessionId - storage scope.
+ * @param surface - current in-memory surface.
+ */
+export function writeSidebarLayout(sessionId: string, surface: SurfaceState): void {
+  if (typeof localStorage === 'undefined') return
+  const saved = { bySession: { [sessionId]: { layout: surface.layout, minted: surface.minted } } }
+  try { localStorage.setItem(`${sidebarPersistence}.${sessionId}`, JSON.stringify(saved)) }
+  catch (error) { console.error('Sidebar layout persistence failed:', error) }
+}
+
+/**
+ * Remove only one Session's persisted layout.
+ * @param sessionId - storage scope to discard.
+ */
+export function clearSidebarLayout(sessionId: string): void {
+  if (typeof localStorage === 'undefined') return
+  try { localStorage.removeItem(`${sidebarPersistence}.${sessionId}`) }
+  catch (_storageUnavailable) { /* The invalid layout is still excluded from this window. */ }
+}

+ 23 - 3
packages/client/ui-sidebar-right/src/client/service.ts

@@ -38,6 +38,7 @@ import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-regist
 import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts'
 import { canCloseTab, type SidebarRightState, type SurfaceState } from './stores.ts'
 import type { createSidebarRightStore } from './stores.ts'
 import type { createSidebarRightStore } from './stores.ts'
 import { TabDomain, type PinResource } from './tab-domain.ts'
 import { TabDomain, type PinResource } from './tab-domain.ts'
+import { SidebarTabInventory } from './tab-inventory.ts'
 
 
 /** The seat's bound action set. */
 /** The seat's bound action set. */
 export type SurfaceActions = BoundActions<ReturnType<typeof createSidebarRightStore>>
 export type SurfaceActions = BoundActions<ReturnType<typeof createSidebarRightStore>>
@@ -57,27 +58,32 @@ interface Adoption {
 
 
 /**
 /**
  * Create the public controller and the plugin-private store adoption callback.
  * Create the public controller and the plugin-private store adoption callback.
- * Adoption subscribes without reconciling; the first store commit creates occurrences.
+ * Adoption reconciles restored records before any seat renders, then follows commits.
  * @param tabs - registered tab types.
  * @param tabs - registered tab types.
  * @param pin - resource retention for an occurrence's lifetime.
  * @param pin - resource retention for an occurrence's lifetime.
- * @returns the controller and a callback releasing exactly its own adoption.
+ * @returns the controller and plugin-owned adoption and scope-removal callbacks.
  */
  */
 export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): {
 export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): {
   controller: SidebarRightController
   controller: SidebarRightController
   adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void
   adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void
+  forget: (sessionId: SessionId) => void
 } {
 } {
   const adopted = new Map<SessionId, Adoption>()
   const adopted = new Map<SessionId, Adoption>()
-  const controller = new SidebarRightController(tabs, pin, adopted)
+  const inventory = new SidebarTabInventory()
+  const controller = new SidebarRightController(tabs, pin, adopted, inventory.source)
   return {
   return {
     controller,
     controller,
+    forget: (sessionId) => { inventory.remove(sessionId) },
     adopt(sessionId, store) {
     adopt(sessionId, store) {
       adopted.get(sessionId)?.unsubscribe()
       adopted.get(sessionId)?.unsubscribe()
       const sync = (): void => {
       const sync = (): void => {
         const surface = store.getSnapshot().bySession[sessionId]
         const surface = store.getSnapshot().bySession[sessionId]
+        inventory.update(sessionId, Object.values(surface?.layout.tabs ?? {}))
         if (surface !== undefined) controller.tabDomain.sync(sessionId, surface.layout)
         if (surface !== undefined) controller.tabDomain.sync(sessionId, surface.layout)
       }
       }
       const adoption: Adoption = { store, unsubscribe: store.subscribe(sync) }
       const adoption: Adoption = { store, unsubscribe: store.subscribe(sync) }
       adopted.set(sessionId, adoption)
       adopted.set(sessionId, adoption)
+      sync()
       return () => {
       return () => {
         adoption.unsubscribe()
         adoption.unsubscribe()
         if (adopted.get(sessionId) === adoption) adopted.delete(sessionId)
         if (adopted.get(sessionId) === adoption) adopted.delete(sessionId)
@@ -206,6 +212,8 @@ export interface ISidebarRight {
 
 
 /** Cross-plugin right-Sidebar face (ctx.sidebarRight). */
 /** Cross-plugin right-Sidebar face (ctx.sidebarRight). */
 export class SidebarRightController implements ISidebarRight {
 export class SidebarRightController implements ISidebarRight {
+  /** Open tab metadata across saved and adopted Sessions, independent of visible seats. */
+  readonly openTabs: SidebarTabInventory['source']
   private binding: SidebarRightBinding | undefined
   private binding: SidebarRightBinding | undefined
   private readonly closeHandlers = new Map<string, SidebarRightCloseHandler>()
   private readonly closeHandlers = new Map<string, SidebarRightCloseHandler>()
 
 
@@ -231,15 +239,27 @@ export class SidebarRightController implements ISidebarRight {
    * @param tabs - the tab-type registry consulted to claim an address.
    * @param tabs - the tab-type registry consulted to claim an address.
    * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with.
    * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with.
    * @param adopted - plugin-owned session stores used by occurrence actions.
    * @param adopted - plugin-owned session stores used by occurrence actions.
+   * @param openTabs - plugin-owned metadata source across saved and adopted layouts.
    */
    */
   constructor(
   constructor(
     private readonly tabs: SidebarRightTabRegistry,
     private readonly tabs: SidebarRightTabRegistry,
     pin: PinResource,
     pin: PinResource,
     private readonly adopted = new Map<SessionId, Adoption>(),
     private readonly adopted = new Map<SessionId, Adoption>(),
+    openTabs: SidebarTabInventory['source'] = new SidebarTabInventory().source,
   ) {
   ) {
+    this.openTabs = openTabs
     this.tabDomain = new TabDomain(this, pin)
     this.tabDomain = new TabDomain(this, pin)
   }
   }
 
 
+  /**
+   * Read the committed tabs of a Session so providers can restore their content.
+   * @param sessionId - Session whose layout has been adopted.
+   * @returns its open records, or an empty list before adoption.
+   */
+  tabsIn(sessionId: SessionId): readonly TabRecord[] {
+    return Object.values(this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout.tabs ?? {})
+  }
+
   /**
   /**
    * Adopt the mounted seat's binding, replacing any previous one.
    * Adopt the mounted seat's binding, replacing any previous one.
    *
    *

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

@@ -27,6 +27,7 @@
  * The kit plans none of this; it is decided here before its planners run.
  * The kit plans none of this; it is decided here before its planners run.
  */
  */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import { clearSidebarLayout, readSidebarLayout, writeSidebarLayout } from './persistence.ts'
 import type {
 import type {
   DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord,
   DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord,
 } from '@deepseek-ai/dsh-client-ui-dockkit'
 } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -253,7 +254,7 @@ function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState {
 }
 }
 
 
 /**
 /**
- * Create the Sidebar store handle.
+ * Create the Sidebar store handle with per-Session JSON persistence in localStorage.
  *
  *
  * The default page arrives as a thunk: a pane is seeded when a split or an
  * The default page arrives as a thunk: a pane is seeded when a split or an
  * expansion of an empty column needs one, which can be long after the store was
  * expansion of an empty column needs one, which can be long after the store was
@@ -264,7 +265,7 @@ function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState {
 export function createSidebarRightStore(
 export function createSidebarRightStore(
   seed: () => SidebarRightSeed,
   seed: () => SidebarRightSeed,
 ): EngineStoreHandle<SidebarRightState, SidebarRightActions> {
 ): EngineStoreHandle<SidebarRightState, SidebarRightActions> {
-  return defineStore({
+  const handle = defineStore<SidebarRightState, SidebarRightActions>({
     init: (): SidebarRightState => ({ bySession: {} }),
     init: (): SidebarRightState => ({ bySession: {} }),
     actions: {
     actions: {
       // Materialize a session's surface without changing it, so the first read
       // Materialize a session's surface without changing it, so the first read
@@ -409,4 +410,15 @@ export function createSidebarRightStore(
       },
       },
     },
     },
   })
   })
+  return { ...handle, create(scopeKey) {
+    const instance = handle.create(scopeKey)
+    if (scopeKey === undefined) return instance
+    const saved = readSidebarLayout(scopeKey)
+    if (saved !== undefined) instance.store.set({ bySession: { [scopeKey]: saved } })
+    instance.subscribe(() => {
+      const surface = instance.getSnapshot().bySession[scopeKey]
+      if (surface !== undefined) writeSidebarLayout(scopeKey, surface)
+    })
+    return { ...instance, clearPersisted: () => { clearSidebarLayout(scopeKey) } }
+  } }
 }
 }

+ 59 - 0
packages/client/ui-sidebar-right/src/client/tab-inventory.ts

@@ -0,0 +1,59 @@
+/** Metadata inventory of saved and adopted layouts without mounting their content. */
+import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { readSidebarLayout, sidebarPersistence } from './persistence.ts'
+
+/** One open occurrence; resource recovery belongs to its kind's provider. */
+export interface SidebarRightOpenTab {
+  readonly sessionId: SessionId
+  readonly tabId: TabId
+  readonly kind: string
+  readonly contentId: string
+}
+
+/** Derived membership only; layout stores remain the persisted authority. */
+export class SidebarTabInventory {
+  private readonly sessions = new Map<SessionId, readonly SidebarRightOpenTab[]>()
+  private readonly snapshot = createSnapshotStore<readonly SidebarRightOpenTab[]>([])
+  /** Read-only metadata observable shared with content providers. */
+  readonly source: ObservableSnapshot<readonly SidebarRightOpenTab[]> = this.snapshot
+
+  /** Read saved layouts once before the root service is published. */
+  constructor() {
+    try {
+      if (typeof localStorage === 'undefined') return
+      const keys = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index))
+      for (const key of keys) {
+        if (key === null || !key.startsWith(`${sidebarPersistence}.`)) continue
+        const sessionId = key.slice(sidebarPersistence.length + 1) as SessionId
+        const saved = readSidebarLayout(sessionId)
+        if (saved !== undefined) this.sessions.set(sessionId, Object.values(saved.layout.tabs).map(tab => ({
+          sessionId, tabId: tab.id, kind: tab.kind, contentId: tab.contentId,
+        })))
+      }
+      this.publish()
+    } catch (_storageUnavailable) { /* Adopted in-memory layouts still publish their open tabs. */ }
+  }
+
+  /**
+   * Replace membership from the authoritative in-window store.
+   * @param sessionId - adopted Session.
+   * @param tabs - current committed records.
+   */
+  update(sessionId: SessionId, tabs: readonly TabRecord[]): void {
+    this.sessions.set(sessionId, tabs.map(tab => ({ sessionId, tabId: tab.id, kind: tab.kind, contentId: tab.contentId })))
+    this.publish()
+  }
+
+  /**
+   * Forget a permanently cleared scope.
+   * @param sessionId - removed Session scope.
+   */
+  remove(sessionId: SessionId): void { this.sessions.delete(sessionId); this.publish() }
+
+  private publish(): void {
+    const next = [...this.sessions.values()].flat()
+    if (JSON.stringify(next) !== JSON.stringify(this.snapshot.getSnapshot())) this.snapshot.set(next)
+  }
+}

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

@@ -136,6 +136,7 @@ describe('ui-sidebar-right apply', () => {
     // store instance is minted here from the handle the registration declared.
     // store instance is minted here from the handle the registration declared.
     const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
     const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
     const instance = handle.create()
     const instance = handle.create()
+    instance.clearPersisted()
     const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
     const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
     injected.openTab('guide', { revealIfOpened: false })
     injected.openTab('guide', { revealIfOpened: false })
     const surface = instance.getSnapshot().bySession[SESSION]
     const surface = instance.getSnapshot().bySession[SESSION]
@@ -167,6 +168,9 @@ describe('ui-sidebar-right apply', () => {
     occurrence.tabActions.close()
     occurrence.tabActions.close()
     expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined()
     expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined()
     expect(occurrence.signal.aborted).toBe(true)
     expect(occurrence.signal.aborted).toBe(true)
+    expect(ctx.sidebarRight.openTabs.getSnapshot().length).toBeGreaterThan(0)
+    instance.clearPersisted()
+    expect(ctx.sidebarRight.openTabs.getSnapshot()).toEqual([])
   })
   })
 
 
   it('hands the guide body the registry\'s entry boxes, observable', async () => {
   it('hands the guide body the registry\'s entry boxes, observable', async () => {

+ 102 - 0
packages/client/ui-sidebar-right/tests/persistence-validation.client.spec.ts

@@ -0,0 +1,102 @@
+// @vitest-environment jsdom
+/** Saved layouts reject invalid structures and references before either rendering or inventory publication. */
+import { afterEach, expect, it, vi } from 'vitest'
+import { createSurface, createSidebarRightStore } from '../src/client/stores.ts'
+import { readSidebarLayout, writeSidebarLayout, clearSidebarLayout, sidebarPersistence } from '../src/client/persistence.ts'
+import { SidebarTabInventory } from '../src/client/tab-inventory.ts'
+
+const session = 'session'
+const key = `${sidebarPersistence}.${session}`
+const empty = createSurface()
+const tab = { id: 'tab2', kind: 'terminal', contentId: 'sidebar://terminal/unique', title: 'Terminal' }
+const pane = { id: 'pane1', kind: 'pane', host: 'dock', tabs: ['tab2'], activeTabId: 'tab2' }
+const layout = { ...empty.layout, nodes: { pane1: pane }, tabs: { tab2: tab } }
+const valid = { layout, minted: 2 }
+const split = { id: 'split3', kind: 'split', axis: 'row', children: ['pane1', 'pane4'], sizes: [0.5, 0.5] }
+const otherPane = { ...pane, id: 'pane4', tabs: [], activeTabId: undefined }
+const splitLayout = { ...layout, rootId: 'split3', nodes: { pane1: pane, split3: split, pane4: otherPane } }
+
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); localStorage.clear() })
+
+it.each([
+  ['missing layout', { minted: 2 }],
+  ['wrong tab fields', { ...valid, layout: { ...layout, tabs: { tab2: { ...tab, kind: null } } } }],
+  ['mismatched tab identity', { ...valid, layout: { ...layout, tabs: { tab2: { ...tab, id: 'tab3' } } } }],
+  ['reused future identity', { ...valid, minted: 1 }],
+  ['missing root', { ...valid, layout: { ...layout, rootId: 'pane3' } }],
+  ['unreachable node', { ...valid, minted: 4, layout: { ...layout, nodes: { pane1: pane, pane4: otherPane } } }],
+  ['missing tab reference', { ...valid, minted: 3, layout: { ...layout, nodes: { pane1: { ...pane, tabs: ['tab3'], activeTabId: 'tab3' } } } }],
+  ['duplicate tab reference', { ...valid, layout: { ...layout, nodes: { pane1: { ...pane, tabs: ['tab2', 'tab2'] } } } }],
+  ['unreachable tab', { ...valid, layout: { ...layout, nodes: { pane1: { ...pane, tabs: [], activeTabId: undefined } } } }],
+  ['missing selection', { ...valid, layout: { ...layout, nodes: { pane1: { ...pane, activeTabId: undefined } } } }],
+  ['invalid selection', { ...valid, minted: 3, layout: { ...layout, nodes: { pane1: { ...pane, activeTabId: 'tab3' } } } }],
+  ['selection in empty pane', { ...valid, layout: { ...layout, nodes: { pane1: { ...pane, tabs: [] } } } }],
+  ['floating host in dock tree', { ...valid, layout: { ...layout, nodes: { pane1: { ...pane, host: 'float' } } } }],
+  ['rectangle on dock pane', { ...valid, layout: { ...layout, nodes: { pane1: { ...pane, rect: { x: 0, y: 0, width: 100, height: 100 } } } } }],
+  ['duplicate split child', { minted: 4, layout: { ...splitLayout, nodes: { ...splitLayout.nodes, split3: { ...split, children: ['pane1', 'pane1'] } } } }],
+  ['split cycle', { minted: 4, layout: { ...splitLayout, nodes: { ...splitLayout.nodes, split3: { ...split, children: ['split3', 'pane1'] } } } }],
+  ['invalid split sum', { minted: 4, layout: { ...splitLayout, nodes: { ...splitLayout.nodes, split3: { ...split, sizes: [0.4, 0.4] } } } }],
+  ['mismatched split sizes', { minted: 4, layout: { ...splitLayout, nodes: { ...splitLayout.nodes, split3: { ...split, sizes: [0.2, 0.3, 0.5] } } } }],
+  ['split as active pane', { minted: 4, layout: { ...splitLayout, activePaneId: 'split3' } }],
+  ['floating split', { minted: 4, layout: { ...splitLayout, floats: ['split3'] } }],
+  ['float without rectangle', { minted: 3, layout: { ...empty.layout, tabs: { tab2: tab }, floats: ['float3'], nodes: { ...empty.layout.nodes, float3: { ...pane, id: 'float3', host: 'float' } } } }],
+  ['empty floating pane', { minted: 3, layout: { ...empty.layout, floats: ['float3'], nodes: { ...empty.layout.nodes, float3: { ...otherPane, id: 'float3', host: 'float', rect: { x: 0, y: 0, width: 100, height: 100 } } } } }],
+] as const)('discards %s without affecting another Session', (_name, invalid) => {
+  localStorage.setItem(key, JSON.stringify({ bySession: { [session]: invalid } }))
+  writeSidebarLayout('other', empty)
+  expect(createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create(session).getSnapshot()).toEqual({ bySession: {} })
+  expect(localStorage.getItem(key)).toBeNull()
+  expect(readSidebarLayout('other')).toEqual(empty)
+  expect(new SidebarTabInventory().source.getSnapshot()).toEqual([])
+})
+
+it('discards structurally invalid JSON envelopes and keeps an empty saved Session usable', () => {
+  for (const raw of ['null', '{"bySession":null}', '{"bySession":[]}']) {
+    localStorage.setItem(key, raw)
+    expect(readSidebarLayout(session)).toBeUndefined()
+    expect(localStorage.getItem(key)).toBeNull()
+  }
+  localStorage.setItem(key, '{"bySession":{}}')
+  expect(readSidebarLayout(session)).toBeUndefined()
+})
+
+it('keeps persisted bytes independent of focus history and preserves undo only in the live store', () => {
+  const seed = () => ({ kind: 'guide', title: 'Start' })
+  const current = createSidebarRightStore(seed).create(session)
+  let a = tab.id as Parameters<typeof current.actions.focusTab>[1]
+  let b = a
+  current.actions.openContent(session, { kind: 'text', contentId: 'file:a', title: 'A' }, (id) => { a = id })
+  current.actions.openContent(session, { kind: 'text', contentId: 'file:b', title: 'B' }, (id) => { b = id })
+  const before = localStorage.getItem(key)
+  for (let i = 0; i < 1000; i++) current.actions.focusTab(session, i % 2 === 0 ? a : b)
+  expect(localStorage.getItem(key)).toBe(before)
+  current.actions.setMode(session, 'fullscreen')
+  expect(current.getSnapshot().bySession[session]!.layout.mode).toBe('fullscreen')
+  current.actions.undo(session)
+  expect(current.getSnapshot().bySession[session]!.layout.mode).toBe('push')
+  const restored = createSidebarRightStore(seed).create(session)
+  const saved = restored.getSnapshot()
+  expect(saved.bySession[session]!.history).toEqual({ entries: [], cursor: 0 })
+  restored.actions.undo(session)
+  expect(restored.getSnapshot()).toBe(saved)
+})
+
+it('contains inaccessible storage and failed removal while keeping memory state available', () => {
+  vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('Storage blocked') })
+  expect(readSidebarLayout(session)).toBeUndefined()
+  vi.restoreAllMocks()
+  localStorage.setItem(key, 'null')
+  vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { throw new Error('Storage blocked') })
+  expect(readSidebarLayout(session)).toBeUndefined()
+  vi.stubGlobal('localStorage', undefined)
+  expect(readSidebarLayout(session)).toBeUndefined()
+  expect(() => { writeSidebarLayout(session, empty); clearSidebarLayout(session) }).not.toThrow()
+})
+
+it('does not persist a scope until the live store has a surface for it', () => {
+  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create(session)
+  instance.store.set({ bySession: {} })
+  expect(localStorage.getItem(key)).toBeNull()
+  instance.actions.setExpanded(session, true)
+  expect(readSidebarLayout(session)?.layout.expanded).toBe(true)
+})

+ 93 - 0
packages/client/ui-sidebar-right/tests/persistence.client.spec.ts

@@ -0,0 +1,93 @@
+/** Per-Session layout recovery precedes resource-body rendering. */
+import { afterEach, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { createSidebarRightStore } from '../src/client/stores.ts'
+import { createSidebarRightController } from '../src/client/service.ts'
+import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
+
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
+
+function storage() {
+  const values = new Map<string, string>()
+  vi.stubGlobal('localStorage', {
+    getItem: (key: string) => values.get(key) ?? null,
+    setItem: (key: string, value: string) => { values.set(key, value) },
+    removeItem: (key: string) => { values.delete(key) },
+  })
+  return values
+}
+
+const sessionId = 'first' as SessionId
+const seed = () => ({ kind: 'guide', title: 'Start' })
+
+it('restores current layout independently by Session with a fresh undo history', () => {
+  storage()
+  const handle = createSidebarRightStore(seed)
+  const first = handle.create(sessionId)
+  let file!: TabId
+  first.actions.openContent(sessionId, { kind: 'text', contentId: 'file:a', title: 'a' }, (id) => { file = id })
+  first.actions.splitPane(sessionId)
+  let floating!: TabId
+  first.actions.openContent(sessionId, { kind: 'text', contentId: 'file:b', title: 'b' }, (id) => { floating = id })
+  first.actions.floatTab(sessionId, floating, { x: 20, y: 30, width: 480, height: 320 })
+  const layout = first.getSnapshot().bySession[sessionId]!.layout
+  const split = Object.values(layout.nodes).find(node => node.kind === 'split')!
+  first.actions.resizeSplit(sessionId, split.id, [0.3, 0.7])
+  first.actions.focusTab(sessionId, file)
+  first.actions.setMode(sessionId, 'fullscreen')
+  const second = handle.create('second')
+  second.actions.open('second')
+  const saved = first.getSnapshot()
+  const restored = createSidebarRightStore(seed).create(sessionId)
+  const recovered = { bySession: { [sessionId]: { ...saved.bySession[sessionId], history: { entries: [], cursor: 0 } } } }
+  expect(restored.getSnapshot()).toEqual(recovered)
+  expect(createSidebarRightStore(seed).create('second').getSnapshot()).toEqual(second.getSnapshot())
+  expect(createSidebarRightStore(seed).create('new').getSnapshot()).toEqual({ bySession: {} })
+  restored.actions.undo(sessionId)
+  restored.actions.redo(sessionId)
+  expect(restored.getSnapshot()).toEqual(recovered)
+  restored.actions.setExpanded(sessionId, false)
+  expect(createSidebarRightStore(seed).create(sessionId).getSnapshot().bySession[sessionId]!.layout.expanded).toBe(false)
+  restored.clearPersisted()
+  expect(createSidebarRightStore(seed).create(sessionId).getSnapshot()).toEqual({ bySession: {} })
+  expect(createSidebarRightStore(seed).create('second').getSnapshot()).toEqual(second.getSnapshot())
+})
+
+it('pins restored records during adoption before the first render or store mutation', async () => {
+  storage()
+  const first = createSidebarRightStore(seed).create(sessionId)
+  let file!: TabId
+  const address = 'dsh-resource://file/session/first/a.txt'
+  first.actions.openContent(sessionId, { kind: 'text', contentId: address, title: 'a' }, (id) => { file = id })
+  const ctx = new Context()
+  const pin = vi.fn()
+  const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin)
+  expect(controller.tabsIn(sessionId)).toEqual([])
+  const restored = createSidebarRightStore(seed).create(sessionId)
+  const release = adopt(sessionId, restored)
+  try {
+    const occurrence = controller.tabDomain.occurrence(sessionId, { id: file })
+    expect(controller.tabsIn(sessionId)).toEqual([{ id: file, kind: 'text', contentId: address, title: 'a' }])
+    expect(pin).toHaveBeenCalledWith(address, occurrence.signal)
+    expect(occurrence.navigation.getSnapshot().address).toBe(address)
+    restored.actions.closeTab(sessionId, file)
+    expect(occurrence.signal.aborted).toBe(true)
+  } finally {
+    release()
+    controller.tabDomain.dispose()
+    await ctx.fiber.dispose()
+  }
+})
+
+it('starts from an empty layout when stored JSON is corrupt and keeps working when storage rejects writes', () => {
+  const values = storage()
+  values.set(`dsh.sidebar-right.v1.${sessionId}`, '{broken')
+  vi.spyOn(console, 'error').mockImplementation(() => {})
+  const instance = createSidebarRightStore(seed).create(sessionId)
+  expect(instance.getSnapshot()).toEqual({ bySession: {} })
+  vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
+  instance.actions.setExpanded(sessionId, true)
+  expect(instance.getSnapshot().bySession[sessionId]!.layout.expanded).toBe(true)
+})

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

@@ -524,8 +524,8 @@ describe('SidebarRightController — a tab\'s own actions', () => {
   it('adoption syncs the Tab domain on each commit of that store: the seeded guide is pinned, a closed tab aborted', () => {
   it('adoption syncs the Tab domain on each commit of that store: the seeded guide is pinned, a closed tab aborted', () => {
     const { controller, adopt, instance, pin } = harness()
     const { controller, adopt, instance, pin } = harness()
     const first = adopt(SESSION, instance)
     const first = adopt(SESSION, instance)
-    // Nothing is synced at adoption, and a commit that materializes another
-    // session leaves this session's occurrences alone.
+    // An empty adopted surface has no resources to pin; another Session's
+    // commits do not populate it.
     instance.actions.open(OTHER)
     instance.actions.open(OTHER)
     expect(pin).not.toHaveBeenCalled()
     expect(pin).not.toHaveBeenCalled()
     instance.actions.setExpanded(SESSION, true)
     instance.actions.setExpanded(SESSION, true)

+ 66 - 0
packages/client/ui-sidebar-right/tests/tab-inventory.client.spec.ts

@@ -0,0 +1,66 @@
+// @vitest-environment jsdom
+/** Dormant saved layouts expose only provider metadata, with in-window stores authoritative. */
+import { afterEach, expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { SidebarTabInventory } from '../src/client/tab-inventory.ts'
+import { sidebarPersistence } from '../src/client/persistence.ts'
+
+afterEach(() => { localStorage.clear(); vi.restoreAllMocks() })
+const session = 'inactive-session' as SessionId
+const tab = { id: 'tab2', kind: 'terminal', contentId: 'terminal-page', title: 'Terminal' } as TabRecord
+function save(id: SessionId, tabs: Record<string, unknown>): void {
+  localStorage.setItem(`${sidebarPersistence}.${id}`, JSON.stringify({ bySession: { [id]: { minted: 3, layout: { tabs, expanded: false, mode: 'push', rootId: 'pane1', activePaneId: 'pane1', floats: [], nodes: { pane1: { id: 'pane1', kind: 'pane', host: 'dock', tabs: Object.keys(tabs), activeTabId: Object.keys(tabs)[0] } } } } } }))
+}
+
+it('inventories collapsed and inactive saved Sessions without creating occurrences or resources', () => {
+  save(session, { [tab.id]: tab })
+  const other = 'other-session' as SessionId
+  save(other, { tab3: { ...tab, id: 'tab3', kind: 'documentPreview', contentId: '/a.ts' } })
+  localStorage.setItem('unrelated', 'broken')
+  const inventory = new SidebarTabInventory()
+  expect(inventory.source.getSnapshot()).toEqual([
+    { sessionId: session, tabId: tab.id, kind: tab.kind, contentId: tab.contentId },
+    { sessionId: other, tabId: 'tab3', kind: 'documentPreview', contentId: '/a.ts' },
+  ])
+})
+
+it('rejects an invalid layout as a whole without skipping later valid Sessions', () => {
+  save('invalid' as SessionId, { [tab.id]: tab, wrongId: tab })
+  save(session, { [tab.id]: tab })
+  localStorage.setItem(`${sidebarPersistence}.broken-json`, '{')
+  localStorage.setItem(`${sidebarPersistence}.missing-tabs`, '{"bySession":null}')
+  const inventory = new SidebarTabInventory()
+  expect(inventory.source.getSnapshot()).toHaveLength(1)
+  inventory.remove(session)
+  expect(inventory.source.getSnapshot()).toEqual([])
+})
+
+it('keeps stable membership across title and layout changes and ignores another window storage writes', () => {
+  save(session, { [tab.id]: tab })
+  const inventory = new SidebarTabInventory()
+  const snapshot = inventory.source.getSnapshot()
+  inventory.update(session, [{ ...tab, title: 'Renamed' }])
+  expect(inventory.source.getSnapshot()).toBe(snapshot)
+  save(session, {})
+  window.dispatchEvent(new StorageEvent('storage', { key: `${sidebarPersistence}.${session}` }))
+  expect(inventory.source.getSnapshot()).toBe(snapshot)
+  inventory.update(session, [])
+  expect(inventory.source.getSnapshot()).toEqual([])
+})
+
+it('publishes in-memory membership even when browser storage is inaccessible', () => {
+  vi.spyOn(Storage.prototype, 'length', 'get').mockImplementation(() => { throw new Error('storage blocked') })
+  const inventory = new SidebarTabInventory()
+  inventory.update(session, [tab])
+  expect(inventory.source.getSnapshot()).toMatchObject([{ sessionId: session, tabId: tab.id }])
+})
+
+it('allows startup without browser globals or with a key removed during enumeration', () => {
+  vi.stubGlobal('localStorage', undefined)
+  try { expect(new SidebarTabInventory().source.getSnapshot()).toEqual([]) }
+  finally { vi.unstubAllGlobals() }
+  save(session, { [tab.id]: tab })
+  vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(null)
+  expect(new SidebarTabInventory().source.getSnapshot()).toEqual([])
+})

+ 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;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
-README.md: 8efe326007a816e6cde00942ee8c411c86e82ff6
-README.zh.md: 9f48103b1ebbcbb8507f92214b1499abb42f362a
+README.md: 04224d2ef8ff41df7c56bfb2ece130f03a790873
+README.zh.md: 80ea0373e22e888194a2d36ddebe330d1222183b

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

@@ -27,11 +27,11 @@ Choose an installed shell from the right sidebar's Start page to run commands in
 
 
 Open the right sidebar and click **New terminal** to open the remembered available shell immediately. Its adjacent arrow opens the installed-shell menu; selecting an item remembers it and opens that shell directly. Discovery runs when the menu opens and does not allocate a terminal. A failed lookup offers Retry in the menu. Use **New tab** to return to the guide and open another terminal.
 Open the right sidebar and click **New terminal** to open the remembered available shell immediately. Its adjacent arrow opens the installed-shell menu; selecting an item remembers it and opens that shell directly. Discovery runs when the menu opens and does not allocate a terminal. A failed lookup offers Retry in the menu. Use **New tab** to return to the guide and open another terminal.
 
 
-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.
+Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A temporary disconnect preserves the screen and offers **Reconnect**, without exposing transport diagnostics. An exited shell remains visible with its exit code and offers **New terminal**; it 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.
 Closing or replacing a terminal tab removes it immediately and ends its process in the background. A cleanup failure shows a small notification with **Retry**; retrying does not reopen the tab. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process.
 
 
-Displaying a Session after a page reload reopens its retained Host terminals as new tabs. A recovery failure offers **Retry terminal recovery**. A recovered process that disappears reports an error instead of starting another shell. The [sidebar layout remains memory-only](../../client/ui-sidebar-right/README.md#state).
+After reload, the [sidebar restores its layout](../../client/ui-sidebar-right/README.md#state) and each terminal reconnects to its saved Host identity in the original tab. Collapsed and inactive tabs do not create recovery duplicates or change selection. Host terminals without a saved tab association reopen as recovery tabs; recovery failure offers **Retry terminal recovery**. A missing saved process shows a localized unavailable panel with **New terminal**. Clicking it replaces the unavailable tab in place with a fresh terminal; recovery never creates that replacement automatically.
 
 
 The terminal background, default text, cursor, and selection follow the DSH theme, including system preference and theme-token overrides. Theme changes preserve the running shell, output, and application OSC color overrides. Reset commands restore colors to the current DSH defaults. xterm adjusts text toward 4.5:1 contrast; the cursor keeps at least 3:1 contrast against its cell background, including Vim colorschemes.
 The terminal background, default text, cursor, and selection follow the DSH theme, including system preference and theme-token overrides. Theme changes preserve the running shell, output, and application OSC color overrides. Reset commands restore colors to the current DSH defaults. xterm adjusts text toward 4.5:1 contrast; the cursor keeps at least 3:1 contrast against its cell background, including Vim colorschemes.
 
 
@@ -43,7 +43,9 @@ The terminal background, default text, cursor, and selection follow the DSH them
 
 
 This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
 This plugin registers the `terminal` type and body/title seats with the right sidebar. The guide uses a compact dark terminal card, while tab titles retain the line glyph. The React-free terminal model belongs to `api-terminal-controller`; keyed framework hooks expose its state. `ui-primitives` Menu and Button provide the shell picker and startup controls, including keyboard navigation and the selected-item marker. xterm.js and FitAddon render the screen and measure the viewport. The body reserves an 8px gap below the tab strip within the pane height. Input, including Tab and control characters, travels unchanged to the PTY.
 
 
-A Session header contribution queries Host terminals and opens recovery tabs. Their navigation parameters carry `terminalId` only within the current page; a recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work.
+A Session header contribution queries Host terminals and opens only those without an existing tab association. The terminal controller saves each globally unique content identity's Host association independently and owns content recovery; the sidebar owns layout persistence. A recovered view cannot allocate a replacement process. The sidebar's close handler schedules cleanup through the [terminal controller](../../api/terminal-controller/README.md#understand-the-implementation) and returns synchronously. Browser component cleanup and the tab's abort signal only detach browser work.
+
+At plugin startup, terminal-kind entries in the sidebar's complete open-tab inventory retain matching saved Host identities, including dormant Sessions. This window hold remains independent of React mounts and screen subscriptions. Removing the last matching occurrence releases it; collapsing or switching views does not. The [terminal controller](../../api/terminal-controller/README.md#use-this-package) owns unattended idle reclamation and long-command protection.
 
 
 </details>
 </details>
 
 

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

@@ -27,11 +27,11 @@ kind: "package-reference"
 
 
 展开右侧栏,点击**新建终端**即可直接打开上次选择且仍可用的 shell。旁边的箭头展开已安装 shell 菜单;选择后立即记住该项并打开终端。菜单展开时才查询 shell,不分配终端进程;查询失败可在菜单内重试。通过**新标签页**回到开始页并打开更多终端。
 展开右侧栏,点击**新建终端**即可直接打开上次选择且仍可用的 shell。旁边的箭头展开已安装 shell 菜单;选择后立即记住该项并打开终端。菜单展开时才查询 shell,不分配终端进程;查询失败可在菜单内重试。通过**新标签页**回到开始页并打开更多终端。
 
 
-双击终端标签页标题即可重命名。另一页面持有输入权时,**接管输入**让当前连接可写。连接失败时可以**重新连接**。shell 退出后仍显示屏幕和退出码,不自动重启。 已退出的终端计入 Session 数量上限;达到上限时请关闭不用的标签页。
+双击终端标签标题可重命名。其他页面持有输入权时,点击 **接管输入** 使当前连接可写。暂时断线时保留屏幕并提供 **重新连接**,不展示传输内部错误。已退出的 shell 仍显示退出码并提供 **新建终端**,不会自动重启。已退出的终端仍计入 Session 配额;达到上限时请关闭不用的标签。
 
 
 关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。
 关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。
 
 
-刷新页面后显示某个 Session,会把 Host 保留的终端重新打开为新标签页。恢复失败时可点击**重试恢复终端**。恢复目标进程消失时显示错误,不启动另一个 shell。[侧栏布局仅保存在内存中](../../client/ui-sidebar-right/README.zh.md#state)。
+刷新后,[侧栏恢复布局](../../client/ui-sidebar-right/README.zh.md#state),各终端在原标签页中重连保存的 Host 身份。折叠和非当前标签不会产生重复的恢复标签,也不改变选中项。没有保存标签关联的 Host 终端作为恢复标签打开;恢复失败时提供 **重试恢复终端**。保存的进程已消失时,显示本地化的不可用提示和 **新建终端**。点击后在原位置用全新终端替换失效标签;恢复过程不会自动创建替代进程。
 
 
 终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题令牌覆盖。切换主题会保留运行中的 shell、输出和应用通过 OSC 设置的颜色。颜色重置命令恢复到当前 DSH 默认值。xterm 将文字对比度调整到 4.5:1;光标与所在单元格背景保持至少 3:1 的对比度,包括 Vim 配色方案。
 终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题令牌覆盖。切换主题会保留运行中的 shell、输出和应用通过 OSC 设置的颜色。颜色重置命令恢复到当前 DSH 默认值。xterm 将文字对比度调整到 4.5:1;光标与所在单元格背景保持至少 3:1 的对比度,包括 Vim 配色方案。
 
 
@@ -43,7 +43,9 @@ kind: "package-reference"
 
 
 插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
 插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。xterm.js 与 FitAddon 负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
 
 
-Session header contribution 查询 Host 终端并打开恢复标签页。导航参数中的 `terminalId` 只在当前页面内保留;恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
+Session header contribution 查询 Host 终端,只打开没有现有标签关联的终端。终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
+
+插件启动时,侧栏完整打开标签清单中的 terminal 条目会持有相匹配的已保存 Host 身份,包括非当前 Session。窗口持有关系独立于 React 挂载和屏幕订阅。删除最后一个匹配的 occurrence 会释放持有关系;折叠或切换视图不会释放。[终端控制器](../../api/terminal-controller/README.zh.md#use-this-package) 负责无人持有时的空闲回收和长命令保护。
 
 
 </details>
 </details>
 
 

+ 4 - 3
packages/client/ui-sidebar-terminal/src/client/TerminalBody.module.css

@@ -4,10 +4,11 @@
 .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); }
 .screen { flex: 1; min-height: 0; min-width: 0; overflow: hidden; padding: 8px; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); }
 .screen :global(.xterm) { height: 100%; }
 .screen :global(.xterm) { height: 100%; }
 .screen :global(.xterm-viewport) { background: var(--dsw-alias-bg-base); }
 .screen :global(.xterm-viewport) { background: var(--dsw-alias-bg-base); }
-.status { display: flex; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); }
-.status button { padding: 6px 10px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 6px; color: inherit; background: transparent; cursor: pointer; }
-.status button:hover { background: var(--dsw-alias-bg-l2); }
+.status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 5px 12px; background: var(--dsw-alias-bg-l2); }
 .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }
 .error { margin: 0; padding: 10px 12px; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-l2); }
+.empty { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 16px; min-height: 0; padding: 24px; overflow: auto; text-align: center; }
+.emptyMessage { max-width: 28ch; margin: 0; line-height: 1.6; overflow-wrap: anywhere; }
+.actionIcon { display: flex; }
 
 
 /* Cursor colors depend on its cell, so they must not update xterm's shared ANSI theme. */
 /* Cursor colors depend on its cell, so they must not update xterm's shared ANSI theme. */
 .screen :global(.xterm-cursor) { opacity: 1; }
 .screen :global(.xterm-cursor) { opacity: 1; }

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

@@ -2,11 +2,13 @@
 import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
 import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
 import { Terminal } from '@xterm/xterm'
 import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import { FitAddon } from '@xterm/addon-fit'
+import { Button, IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
 import type { TerminalBodyInjected } from './face.ts'
 import type { TerminalBodyInjected } from './face.ts'
 import type {} from './locales.ts'
 import type {} from './locales.ts'
+import { TerminalGuideIcon } from './TerminalIcon.tsx'
 import '@xterm/xterm/css/xterm.css'
 import '@xterm/xterm/css/xterm.css'
 import css from './TerminalBody.module.css'
 import css from './TerminalBody.module.css'
 import { TerminalTheme } from './terminal-theme.ts'
 import { TerminalTheme } from './terminal-theme.ts'
@@ -27,23 +29,37 @@ export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: Ter
   const state = useTerminal(tab.id)
   const state = useTerminal(tab.id)
   useEffect(() => model.mount(), [model])
   useEffect(() => model.mount(), [model])
   if (state === undefined) return null
   if (state === undefined) return null
-  const error = state.issue === undefined ? state.error ?? state.info?.error : t(state.issue)
+  const newTerminal = <Button variant="primary" size={state.issue === 'missingTerminal' ? 'md' : 'sm'}
+    icon={<span className={css.actionIcon} aria-hidden="true"><IconPlusOutline16 /></span>}
+    onClick={() => { tab.actions.openTab('terminal', { replaceTab: true }) }}>
+    {t('new')}
+  </Button>
+  if (state.issue === 'missingTerminal') return <section className={css.root} data-sidebar-terminal>
+    <div className={css.empty}>
+      <TerminalGuideIcon size={36} />
+      <p className={css.emptyMessage} role="alert">{t('missingTerminal')}</p>
+      {newTerminal}
+    </div>
+  </section>
+  const error = state.phase === 'disconnected' ? undefined : state.issue === undefined ? state.error ?? state.info?.error : t(state.issue)
   let status: string | undefined
   let status: string | undefined
   if (state.phase === 'idle' || state.phase === 'loading') status = t('loading')
   if (state.phase === 'idle' || state.phase === 'loading') status = t('loading')
   else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase)
   else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase)
   else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') })
   else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') })
   else if (state.info?.state === 'failed') status = t('unavailable')
   else if (state.info?.state === 'failed') status = t('unavailable')
   else if (state.phase === 'closed') status = t('closed')
   else if (state.phase === 'closed') status = t('closed')
-  const retry = state.phase === 'failed' || state.phase === 'disconnected'
+  const ended = state.info?.state === 'exited' || state.phase === 'closed'
+  const retry = !ended && (state.phase === 'failed' || state.phase === 'disconnected')
   const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
   const readOnly = state.phase === 'connected' && state.info?.state === 'running' && !state.writable
   return (
   return (
     <section className={css.root} data-sidebar-terminal>
     <section className={css.root} data-sidebar-terminal>
       {(status !== undefined || retry || readOnly) && <div className={css.status} role="status">
       {(status !== undefined || retry || readOnly) && <div className={css.status} role="status">
         {status}
         {status}
-        {readOnly && <>{t('readonly')} <button type="button" onClick={() => { model.connect() }}>{t('control')}</button></>}
+        {readOnly && <>{t('readonly')} <Button variant="outline" size="sm" onClick={() => { model.connect() }}>{t('control')}</Button></>}
         {retry && (state.info === undefined
         {retry && (state.info === undefined
-          ? <button type="button" onClick={() => { void model.refresh() }}>{t('retry')}</button>
-          : <button type="button" onClick={() => { model.connect() }}>{t('reconnect')}</button>)}
+          ? <Button variant="outline" size="sm" onClick={() => { void model.refresh() }}>{t(state.phase === 'disconnected' ? 'reconnect' : 'retry')}</Button>
+          : <Button variant="outline" size="sm" onClick={() => { model.connect() }}>{t('reconnect')}</Button>)}
+        {ended && newTerminal}
       </div>}
       </div>}
       {state.info !== undefined && <TerminalScreen state={state} model={model} visible={tab.visible} label={t('title')} theme={theme} />}
       {state.info !== undefined && <TerminalScreen state={state} model={model} visible={tab.visible} label={t('title')} theme={theme} />}
       {error !== undefined && <p className={css.error} role="alert">{t('failed', { message: error })}</p>}
       {error !== undefined && <p className={css.error} role="alert">{t('failed', { message: error })}</p>}

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

@@ -29,6 +29,12 @@ export function apply(ctx: Context): void {
   let disposed = false
   let disposed = false
   const recovered = new Map<SessionId, Promise<void>>()
   const recovered = new Map<SessionId, Promise<void>>()
   ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime')
   ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime')
+  ctx.effect(() => {
+    const sync = (): void => { ctx.webTerminals.retainTabs(ctx.sidebarRight.openTabs.getSnapshot().filter(tab => tab.kind === 'terminal')) }
+    const unsubscribe = ctx.sidebarRight.openTabs.subscribe(sync)
+    sync()
+    return () => { unsubscribe(); ctx.webTerminals.retainTabs([]) }
+  }, 'ui-sidebar-terminal.window-holds')
   const target = (sessionId: SessionId, key: string): SidebarRightTabParamsMap['terminal'] | undefined =>
   const target = (sessionId: SessionId, key: string): SidebarRightTabParamsMap['terminal'] | undefined =>
     ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params
     ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().params
   const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => {
   const terminalId = (sessionId: SessionId, key: string): WebTerminalId | undefined => {
@@ -37,7 +43,8 @@ export function apply(ctx: Context): void {
   }
   }
   const view = (sessionId: SessionId, key: string) => {
   const view = (sessionId: SessionId, key: string) => {
     const params = target(sessionId, key)
     const params = target(sessionId, key)
-    return ctx.webTerminals.view(sessionId, key, terminalId(sessionId, key),
+    const contentId = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation.getSnapshot().address
+    return ctx.webTerminals.view(sessionId, key, contentId, terminalId(sessionId, key),
       params !== undefined && 'shellPath' in params ? params.shellPath : undefined)
       params !== undefined && 'shellPath' in params ? params.shellPath : undefined)
   }
   }
   const namespace = 'sidebarTerminal'
   const namespace = 'sidebarTerminal'
@@ -49,7 +56,7 @@ export function apply(ctx: Context): void {
     guide: [{ id: 'new', order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }],
     guide: [{ id: 'new', order: 20, title: () => t('new'), description: () => t('description'), icon: TerminalGuideIcon }],
   }), 'ui-sidebar-terminal.type')
   }), 'ui-sidebar-terminal.type')
   ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => {
   ctx.effect(() => ctx.sidebarRight.registerCloseHandler('terminal', (sessionId, tab) => {
-    ctx.webTerminals.close(sessionId, tab.id, terminalId(sessionId, tab.id))
+    ctx.webTerminals.close(sessionId, tab.id, tab.contentId, terminalId(sessionId, tab.id))
   }), 'ui-sidebar-terminal.close')
   }), 'ui-sidebar-terminal.close')
   const inject = (sessionId: SessionId): TerminalInjected => ({
   const inject = (sessionId: SessionId): TerminalInjected => ({
     view: key => view(sessionId, key),
     view: key => view(sessionId, key),
@@ -80,6 +87,9 @@ export function apply(ctx: Context): void {
       restore: () => {
       restore: () => {
         let pending = recovered.get(sessionId)
         let pending = recovered.get(sessionId)
         if (pending === undefined) {
         if (pending === undefined) {
+          for (const tab of ctx.sidebarRight.tabsIn(sessionId)) {
+            if (tab.kind === 'terminal') view(sessionId, tab.id)
+          }
           pending = ctx.webTerminals.recover(sessionId).then((terminals) => {
           pending = ctx.webTerminals.recover(sessionId).then((terminals) => {
             if (disposed) return
             if (disposed) return
             for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', {
             for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', {

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

@@ -1,6 +1,7 @@
 // @vitest-environment jsdom
 // @vitest-environment jsdom
 /** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */
 /** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */
 import { createElement } from 'react'
 import { createElement } from 'react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { renderToStaticMarkup } from 'react-dom/server'
 import { renderToStaticMarkup } from 'react-dom/server'
 import { Context } from '@deepseek-ai/cordis'
 import { Context } from '@deepseek-ai/cordis'
 import { expect, it, vi } from 'vitest'
 import { expect, it, vi } from 'vitest'
@@ -8,6 +9,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types'
 import type { WebTerminalId, WebTerminalInfo } from '@deepseek-ai/dsh-api-terminal-controller/types'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
 import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
 import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type { SidebarRightCloseHandler } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { SidebarRightOpenTab } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import { apply, inject } from '../src/client/index.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx'
 import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx'
@@ -38,16 +40,18 @@ async function mountPlugin() {
   let closeHandler: SidebarRightCloseHandler | undefined
   let closeHandler: SidebarRightCloseHandler | undefined
   const model = { state: {} }
   const model = { state: {} }
   const terminals = {
   const terminals = {
-    view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(),
+    retainTabs: vi.fn(), view: vi.fn(() => model), close: vi.fn(), closeFailures: {}, retryClose: vi.fn(),
     launchShells: vi.fn(async () => ({ shells: [], selectedShell: undefined })), selectShell: vi.fn(),
     launchShells: vi.fn(async () => ({ shells: [], selectedShell: undefined })), selectShell: vi.fn(),
     recover: vi.fn(async (_sessionId: SessionId): Promise<WebTerminalInfo[]> => []),
     recover: vi.fn(async (_sessionId: SessionId): Promise<WebTerminalInfo[]> => []),
   }
   }
   let params: { terminalId: WebTerminalId } | { shellPath: string } | undefined
   let params: { terminalId: WebTerminalId } | { shellPath: string } | undefined
-  const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params }) } }))
+  const occurrence = vi.fn(() => ({ navigation: { getSnapshot: () => ({ params, address: 'sidebar://terminal/content' }) } }))
   const openTabIn = vi.fn()
   const openTabIn = vi.fn()
+  const tabsIn = vi.fn(() => [] as { id: string; kind: string }[])
+  const openTabs = createSnapshotStore<readonly SidebarRightOpenTab[]>([])
   ctx.provide('webTerminals', terminals as never)
   ctx.provide('webTerminals', terminals as never)
   ctx.provide('sidebarRight', {
   ctx.provide('sidebarRight', {
-    tabDomain: { occurrence }, openTabIn,
+    tabDomain: { occurrence }, openTabIn, tabsIn, openTabs,
     registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } },
     registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } },
   } as never)
   } as never)
   ctx.provide('slots', {
   ctx.provide('slots', {
@@ -62,7 +66,7 @@ async function mountPlugin() {
   ctx.provide('theme', { getTheme: () => theme } as never)
   ctx.provide('theme', { getTheme: () => theme } as never)
   const fiber = await ctx.plugin({ inject, apply })
   const fiber = await ctx.plugin({ inject, apply })
   return {
   return {
-    tabs, entries, dictionaries, terminals, model, occurrence, openTabIn, theme,
+    tabs, entries, dictionaries, terminals, model, occurrence, openTabIn, tabsIn, openTabs, theme,
     emitTheme() { ctx.emit('theme/change', theme) },
     emitTheme() { ctx.emit('theme/change', theme) },
     get closeHandler() { return closeHandler },
     get closeHandler() { return closeHandler },
     setParams(next: typeof params) { params = next },
     setParams(next: typeof params) { params = next },
@@ -107,22 +111,22 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
     h.emitTheme()
     h.emitTheme()
     expect(changed).toHaveBeenCalledOnce()
     expect(changed).toHaveBeenCalledOnce()
     expect(face.view('tab')).toBe(h.model)
     expect(face.view('tab')).toBe(h.model)
-    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined, undefined)
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', 'sidebar://terminal/content', undefined, undefined)
     const terminalId = 'retained' as WebTerminalId
     const terminalId = 'retained' as WebTerminalId
     h.setParams({ terminalId })
     h.setParams({ terminalId })
     h.setParams({ shellPath: '/bin/bash' })
     h.setParams({ shellPath: '/bin/bash' })
     face.view('tab')
     face.view('tab')
-    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', undefined, '/bin/bash')
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', 'sidebar://terminal/content', undefined, '/bin/bash')
     h.setParams({ terminalId })
     h.setParams({ terminalId })
     expect(face.keyedHooks.terminal('tab')).toBe(h.model.state)
     expect(face.keyedHooks.terminal('tab')).toBe(h.model.state)
-    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId, undefined)
+    expect(h.terminals.view).toHaveBeenLastCalledWith(sessionId, 'tab', 'sidebar://terminal/content', terminalId, undefined)
     expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' })
     expect(h.occurrence).toHaveBeenLastCalledWith(sessionId, { id: 'tab' })
     if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered')
     if (h.closeHandler === undefined) throw new Error('Terminal close handler was not registered')
-    h.closeHandler(sessionId, { id: 'tab' } as Parameters<SidebarRightCloseHandler>[1])
-    expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', terminalId)
+    h.closeHandler(sessionId, { id: 'tab', contentId: 'sidebar://terminal/content' } as Parameters<SidebarRightCloseHandler>[1])
+    expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'tab', 'sidebar://terminal/content', terminalId)
     h.setParams(undefined)
     h.setParams(undefined)
-    h.closeHandler(sessionId, { id: 'new-tab' } as Parameters<SidebarRightCloseHandler>[1])
-    expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', undefined)
+    h.closeHandler(sessionId, { id: 'new-tab', contentId: 'sidebar://terminal/new' } as Parameters<SidebarRightCloseHandler>[1])
+    expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', 'sidebar://terminal/new', undefined)
     const cleanupFace = h.entries[4]!.inject(sessionId) as TerminalCleanupInjected
     const cleanupFace = h.entries[4]!.inject(sessionId) as TerminalCleanupInjected
     expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures)
     expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures)
     cleanupFace.retryClose('terminal' as Parameters<TerminalCleanupInjected['retryClose']>[0])
     cleanupFace.retryClose('terminal' as Parameters<TerminalCleanupInjected['retryClose']>[0])
@@ -136,14 +140,17 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
   expect(h.dictionaries.size).toBe(0)
   expect(h.dictionaries.size).toBe(0)
 })
 })
 
 
-it('shares pending and completed recovery across Session headers and opens each returned terminal once', async () => {
+it('restores terminal occurrences before listing unrepresented Host terminals and shares recovery across headers', async () => {
   const h = await mountPlugin()
   const h = await mountPlugin()
+  h.tabsIn.mockReturnValue([{ id: 'collapsed-terminal', kind: 'terminal' }, { id: 'file', kind: 'text' }])
   const pending = Promise.withResolvers<WebTerminalInfo[]>()
   const pending = Promise.withResolvers<WebTerminalInfo[]>()
   h.terminals.recover.mockImplementationOnce(() => pending.promise)
   h.terminals.recover.mockImplementationOnce(() => pending.promise)
   const sessionId = 'session' as SessionId
   const sessionId = 'session' as SessionId
   const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
   const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
   const remounted = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
   const remounted = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
   const completion = recovery.restore()
   const completion = recovery.restore()
+  expect(h.terminals.view).toHaveBeenCalledWith(sessionId, 'collapsed-terminal', 'sidebar://terminal/content', undefined, undefined)
+  expect(h.terminals.view).toHaveBeenCalledOnce()
   try {
   try {
     expect(remounted.restore()).toBe(completion)
     expect(remounted.restore()).toBe(completion)
     expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId)
     expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId)
@@ -204,3 +211,16 @@ it('does not open retained terminals when their lookup completes after plugin un
     await h.dispose()
     await h.dispose()
   }
   }
 })
 })
+
+it('retains terminal metadata from dormant layouts and releases its inventory subscription on unload', async () => {
+  const h = await mountPlugin()
+  const terminal = { sessionId: 'inactive' as SessionId, tabId: 't', kind: 'terminal', contentId: 'terminal' } as SidebarRightOpenTab
+  h.openTabs.set([terminal, { ...terminal, kind: 'documentPreview', contentId: 'file' }])
+  await expect.poll(() => h.terminals.retainTabs).toHaveBeenLastCalledWith([terminal])
+  await h.dispose()
+  expect(h.terminals.retainTabs).toHaveBeenLastCalledWith([])
+  const calls = h.terminals.retainTabs.mock.calls.length
+  h.openTabs.set([terminal])
+  await Promise.resolve()
+  expect(h.terminals.retainTabs).toHaveBeenCalledTimes(calls)
+})

+ 44 - 4
packages/client/ui-sidebar-terminal/tests/terminal-body.client.spec.tsx

@@ -108,7 +108,7 @@ it('mounts automatic startup and offers retry only when startup fails', () => {
   fireEvent.click(h.view.getByRole('button', { name: en.retry }))
   fireEvent.click(h.view.getByRole('button', { name: en.retry }))
   expect(h.model.refresh).toHaveBeenCalledOnce()
   expect(h.model.refresh).toHaveBeenCalledOnce()
   h.update({ ...idle, phase: 'disconnected' })
   h.update({ ...idle, phase: 'disconnected' })
-  fireEvent.click(h.view.getByRole('button', { name: en.retry }))
+  fireEvent.click(h.view.getByRole('button', { name: en.reconnect }))
   expect(h.model.refresh).toHaveBeenCalledTimes(2)
   expect(h.model.refresh).toHaveBeenCalledTimes(2)
   h.update({ ...idle, phase: 'creating' })
   h.update({ ...idle, phase: 'creating' })
   expect(h.view.getByRole('status').textContent).toBe(en.creating)
   expect(h.view.getByRole('status').textContent).toBe(en.creating)
@@ -173,11 +173,11 @@ it('displays disconnect, close and exit states and offers explicit reconnection
   h.update({ ...idle, info, phase: 'closing' })
   h.update({ ...idle, info, phase: 'closing' })
   expect(h.view.queryByRole('status')).toBeNull()
   expect(h.view.queryByRole('status')).toBeNull()
   h.update({ ...idle, info, phase: 'closed' })
   h.update({ ...idle, info, phase: 'closed' })
-  expect(h.view.getByRole('status').textContent).toBe(en.closed)
+  expect(h.view.getByRole('status').textContent).toContain(en.closed)
   h.update({ ...idle, info: { ...info, state: 'exited', exitCode: 5 }, phase: 'connected' })
   h.update({ ...idle, info: { ...info, state: 'exited', exitCode: 5 }, phase: 'connected' })
-  expect(h.view.getByRole('status').textContent).toBe('Process exited (5)')
+  expect(h.view.getByRole('status').textContent).toContain('Process exited (5)')
   h.update({ ...idle, info: { ...info, state: 'exited', error: 'provider stopped' }, phase: 'connected' })
   h.update({ ...idle, info: { ...info, state: 'exited', error: 'provider stopped' }, phase: 'connected' })
-  expect(h.view.getByRole('status').textContent).toBe('Process exited (—)')
+  expect(h.view.getByRole('status').textContent).toContain('Process exited (—)')
   expect(h.view.getByRole('alert').textContent).toContain('provider stopped')
   expect(h.view.getByRole('alert').textContent).toContain('provider stopped')
   h.update({ ...idle, info: { ...info, state: 'failed', error: 'provider unreachable' }, phase: 'connected' })
   h.update({ ...idle, info: { ...info, state: 'failed', error: 'provider unreachable' }, phase: 'connected' })
   expect(h.view.getByRole('status').textContent).toBe(en.unavailable)
   expect(h.view.getByRole('status').textContent).toBe(en.unavailable)
@@ -376,3 +376,43 @@ it.each([en, zh])('translates known terminal failures while retaining unknown Ho
   h.update({ ...idle, phase: 'failed', error: 'Host permission denied' })
   h.update({ ...idle, phase: 'failed', error: 'Host permission denied' })
   expect(h.view.getByRole('alert').textContent).toContain('Host permission denied')
   expect(h.view.getByRole('alert').textContent).toContain('Host permission denied')
 })
 })
+
+it.each([en, zh])('offers an explicit new terminal for missing instances without retrying the lost process', (dictionary) => {
+  const h = mount({ ...idle, phase: 'failed', issue: 'missingTerminal', error: 'raw Host diagnostic' }, dictionary)
+  expect(h.view.getByRole('alert').textContent).toBe(dictionary.missingTerminal)
+  expect(h.view.queryByRole('button', { name: dictionary.reconnect })).toBeNull()
+  expect(h.view.queryByRole('button', { name: dictionary.retry })).toBeNull()
+  expect(h.view.queryByRole('textbox')).toBeNull()
+  fireEvent.click(h.view.getByRole('button', { name: dictionary.new }))
+  expect(h.openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true })
+  expect(h.model.refresh).not.toHaveBeenCalled()
+  expect(h.model.connect).not.toHaveBeenCalled()
+})
+
+it('removes a stale emulator when the Host confirms the terminal is missing', () => {
+  const h = mount({ ...idle, info, phase: 'connected', writable: true })
+  const terminal = fake.terminals[0]!
+  h.update({ ...idle, info, phase: 'failed', issue: 'missingTerminal' })
+  expect(terminal.dispose).toHaveBeenCalledOnce()
+  expect(h.view.queryByRole('textbox')).toBeNull()
+  expect(h.view.getByRole('button', { name: en.new })).toBeDefined()
+})
+
+it('keeps the screen on a temporary disconnect and presents reconnection without transport diagnostics', () => {
+  const h = mount({ ...idle, info, phase: 'disconnected', error: 'Remote mux WebSocket closed: 1006' })
+  expect(h.view.queryByRole('alert')).toBeNull()
+  expect(h.view.getByRole('status').textContent).toContain(en.disconnected)
+  expect(h.view.getByRole('textbox')).toBeDefined()
+  expect(h.view.queryByRole('button', { name: en.new })).toBeNull()
+  fireEvent.click(h.view.getByRole('button', { name: en.reconnect }))
+  expect(h.model.connect).toHaveBeenCalledOnce()
+  expect(h.openTab).not.toHaveBeenCalled()
+})
+
+it('offers a new terminal after process exit while preserving its final output for reading', () => {
+  const h = mount({ ...idle, info: { ...info, state: 'exited', exitCode: 0 }, phase: 'failed' })
+  expect(h.view.getByRole('textbox')).toBeDefined()
+  expect(h.view.queryByRole('button', { name: en.reconnect })).toBeNull()
+  fireEvent.click(h.view.getByRole('button', { name: en.new }))
+  expect(h.openTab).toHaveBeenCalledExactlyOnceWith('terminal', { replaceTab: true })
+})

+ 16 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -2558,6 +2558,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'request', description: 'initial dimensions and idempotency identity.' }, { name: 'signal', description: 'allocation cancellation; committed terminals survive disconnection.' }],
         parameters: [{ name: 'agent', description: 'Session owner supplied by the Gateway.' }, { name: 'request', description: 'initial dimensions and idempotency identity.' }, { name: 'signal', description: 'allocation cancellation; committed terminals survive disconnection.' }],
         returns: 'the existing or newly committed terminal.',
         returns: 'the existing or newly committed terminal.',
       },
       },
+      {
+        signature: '@Remote({ mode: \'stream\' }) retain(sessionId: SessionId, id: WebTerminalId, signal: AbortSignal): AsyncIterable<TerminalRetentionFrame>',
+        description: 'Retain an existing terminal for a window without activating its Agent or taking input control.',
+        parameters: [{ name: 'sessionId', description: 'owning Session identity, including an inactive saved layout.' }, { name: 'id', description: 'retained Host terminal identity.' }, { name: 'signal', description: 'physical Remote stream cancellation.' }],
+        returns: 'a hold acknowledgement followed by an open lifetime stream.',
+      },
       {
       {
         signature: '@Remote({ mode: \'stream\' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame>',
         signature: '@Remote({ mode: \'stream\' }) follow(agent: Agent, id: WebTerminalId, attachmentId: TerminalAttachmentId, signal: AbortSignal): AsyncIterable<TerminalFrame>',
         description: 'Attach to a terminal without binding its process lifetime to the transport.',
         description: 'Attach to a terminal without binding its process lifetime to the transport.',
@@ -6018,6 +6024,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SubprocessStdio',
     name: 'SubprocessStdio',
     declaration: 'export interface SubprocessStdio {\n    stdin: SubprocessStdinMode;\n    stdout: SubprocessOutputMode;\n    stderr: SubprocessOutputMode;\n    control?: \'pipe\';\n}',
     declaration: 'export interface SubprocessStdio {\n    stdin: SubprocessStdinMode;\n    stdout: SubprocessOutputMode;\n    stderr: SubprocessOutputMode;\n    control?: \'pipe\';\n}',
   },
   },
+  {
+    name: 'SubprocessTerminalActivity',
+    declaration: 'export interface SubprocessTerminalActivity {\n    state: \'idle\' | \'busy\' | \'unknown\';\n    revision: number;\n}',
+  },
   {
   {
     name: 'SubprocessTerminalEnvironment',
     name: 'SubprocessTerminalEnvironment',
     declaration: 'export interface SubprocessTerminalEnvironment {\n    platform: \'posix\' | \'windows\';\n    defaultShell?: string;\n}',
     declaration: 'export interface SubprocessTerminalEnvironment {\n    platform: \'posix\' | \'windows\';\n    defaultShell?: string;\n}',
@@ -6028,7 +6038,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'SubprocessTerminalHandle',
     name: 'SubprocessTerminalHandle',
-    declaration: 'export interface SubprocessTerminalHandle {\n    readonly pid: number;\n    readonly output: Readable;\n    readonly done: Promise<SubprocessOutcome>;\n    write(data: string): Promise<void>;\n    resize(cols: number, rows: number): Promise<void>;\n    inspectForeground(): Promise<SubprocessTerminalForeground | undefined>;\n    signalForeground(signal: SubprocessTerminalSignal): Promise<number>;\n    terminate(): Promise<void>;\n}',
+    declaration: 'export interface SubprocessTerminalHandle {\n    readonly pid: number;\n    readonly output: Readable;\n    readonly done: Promise<SubprocessOutcome>;\n    write(data: string): Promise<void>;\n    resize(cols: number, rows: number): Promise<void>;\n    inspectForeground(): Promise<SubprocessTerminalForeground | undefined>;\n    inspectActivity(): Promise<SubprocessTerminalActivity>;\n    signalForeground(signal: SubprocessTerminalSignal): Promise<number>;\n    terminate(): Promise<void>;\n}',
   },
   },
   {
   {
     name: 'SubprocessTerminalSignal',
     name: 'SubprocessTerminalSignal',
@@ -6036,7 +6046,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'SubprocessTerminalSpawnSpec',
     name: 'SubprocessTerminalSpawnSpec',
-    declaration: 'export interface SubprocessTerminalSpawnSpec {\n    argv: readonly string[];\n    cwd: string;\n    env?: Record<string, string> | undefined;\n    rows: number;\n    cols: number;\n    terminalType: string;\n    graceMs: number;\n    signal?: AbortSignal | undefined;\n}',
+    declaration: 'export interface SubprocessTerminalSpawnSpec {\n    argv: readonly string[];\n    cwd: string;\n    env?: Record<string, string> | undefined;\n    rows: number;\n    cols: number;\n    terminalType: string;\n    shellActivity?: boolean | undefined;\n    graceMs: number;\n    signal?: AbortSignal | undefined;\n}',
   },
   },
   {
   {
     name: 'SurfaceEvent',
     name: 'SurfaceEvent',
@@ -6162,6 +6172,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'TerminalResultView',
     name: 'TerminalResultView',
     declaration: 'export interface TerminalResultView {\n    card: \'terminal\';\n    title?: string;\n    output?: string;\n    exitCode?: number;\n    signal?: string;\n}',
     declaration: 'export interface TerminalResultView {\n    card: \'terminal\';\n    title?: string;\n    output?: string;\n    exitCode?: number;\n    signal?: string;\n}',
   },
   },
+  {
+    name: 'TerminalRetentionFrame',
+    declaration: 'export interface TerminalRetentionFrame {\n    readonly type: \'retained\';\n}',
+  },
   {
   {
     name: 'TerminalSendOperation',
     name: 'TerminalSendOperation',
     declaration: 'export interface TerminalSendOperation {\n    done: Promise<TerminalSendResult>;\n    readOutput(): TerminalSendRead;\n    cancel(): boolean;\n}',
     declaration: 'export interface TerminalSendOperation {\n    done: Promise<TerminalSendResult>;\n    readOutput(): TerminalSendRead;\n    cancel(): boolean;\n}',

+ 2 - 2
packages/ssh/ssh/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ssh/ssh/README.md
 #   pnpm run verify-translation-pairing --write packages/ssh/ssh/README.md
-README.md: 727ab6eefae6bf8361f41b2f1a4a7f85e7225226
-README.zh.md: 5548886d1c95e551330ecae9aaf24b4015cb3197
+README.md: c978538491c3ce8045663b9e675d2141e541cacc
+README.zh.md: 20c91b199aebdcf308d3e3f6c03f9188315e994a

+ 2 - 0
packages/ssh/ssh/README.md

@@ -62,6 +62,8 @@ Connection disposal joins forwarding and cancellation subprocesses and partially
 
 
 Failed startup and process results release their reservations after native quiescence; the bounded completion cache preserves the original rejection for later result reads. Helper shutdown also joins endpoint and directory cleanup already in progress.
 Failed startup and process results release their reservations after native quiescence; the bounded completion cache preserves the original rejection for later result reads. Helper shutdown also joins endpoint and directory cleanup already in progress.
 
 
+For terminals opting into shell activity observation, root exit retains the reservation and its remaining work. Activity RPC continues to reach the provider; explicit termination awaits quiescence before releasing endpoints and recording the completed result. Helper connection disposal and lease expiry retain their existing termination authority.
+
 The helper starts with `--disable-sigusr1`, so a same-user process signal cannot open its Node debugger.
 The helper starts with `--disable-sigusr1`, so a same-user process signal cannot open its Node debugger.
 
 
 </details>
 </details>

+ 2 - 0
packages/ssh/ssh/README.zh.md

@@ -62,6 +62,8 @@ OpenSSH 主连接承载私有管理 RPC。每条程序流使用独立转发的 U
 
 
 启动或进程结果失败后,预留项在原生进程范围清空后释放;有界完成缓存保留原始拒绝结果,供后续读取。辅助程序关闭也会等待已在进行的端点与目录清理。
 启动或进程结果失败后,预留项在原生进程范围清空后释放;有界完成缓存保留原始拒绝结果,供后续读取。辅助程序关闭也会等待已在进行的端点与目录清理。
 
 
+终端启用 shell 活动观察时,根进程退出会保留 reservation 及其剩余工作。活动 RPC 继续访问 provider;明确终止时先等待进程范围完全停稳,再释放端点并记录完成结果。helper 连接卸载和租约到期仍保留原有的终止权限。
+
 辅助程序以 `--disable-sigusr1` 启动,因此同用户进程发送的信号无法开启其 Node 调试器。
 辅助程序以 `--disable-sigusr1` 启动,因此同用户进程发送的信号无法开启其 Node 调试器。
 
 
 </details>
 </details>

+ 9 - 2
packages/ssh/ssh/src/helper-processes.ts

@@ -189,6 +189,7 @@ export class RemoteProcesses {
       const done = terminal.done.then(outcome => ({ outcome, spills: {}, collected: {} }))
       const done = terminal.done.then(outcome => ({ outcome, spills: {}, collected: {} }))
       record.done = done
       record.done = done
       void done.then(async () => {
       void done.then(async () => {
+        if (request.terminal?.shellActivity === true) { await output; return }
         await terminal.terminate()
         await terminal.terminate()
         await output
         await output
         await this.rememberCompleted(id, record, done)
         await this.rememberCompleted(id, record, done)
@@ -312,7 +313,12 @@ export class RemoteProcesses {
     record.controller.abort(new Error('SSH process termination requested'))
     record.controller.abort(new Error('SSH process termination requested'))
     record.ordinary?.terminate()
     record.ordinary?.terminate()
     if (record.ordinary !== undefined) await record.ordinary.waitForExit()
     if (record.ordinary !== undefined) await record.ordinary.waitForExit()
-    if (record.terminal !== undefined) await record.terminal.terminate()
+    if (record.terminal !== undefined) {
+      await record.terminal.terminate()
+      if (record.request.terminal?.shellActivity === true && record.done !== undefined) {
+        await this.rememberCompleted(id, record, record.done)
+      }
+    }
     if (record.ordinary === undefined && record.terminal === undefined) await this.release(id)
     if (record.ordinary === undefined && record.terminal === undefined) await this.release(id)
   }
   }
 
 
@@ -323,11 +329,12 @@ export class RemoteProcesses {
    * @param value - input bytes as text or the signal name.
    * @param value - input bytes as text or the signal name.
    * @returns the operation's wire result.
    * @returns the operation's wire result.
    */
    */
-  async terminal(id: SshProcessId, operation: 'write' | 'inspect' | 'signal', value?: string): Promise<unknown> {
+  async terminal(id: SshProcessId, operation: 'write' | 'inspect' | 'activity' | 'signal', value?: string): Promise<unknown> {
     const terminal = this.record(id).terminal
     const terminal = this.record(id).terminal
     if (terminal === undefined) throw new Error('SSH handle does not own a terminal')
     if (terminal === undefined) throw new Error('SSH handle does not own a terminal')
     if (operation === 'write') { await terminal.write(z.string().parse(value)); return null }
     if (operation === 'write') { await terminal.write(z.string().parse(value)); return null }
     if (operation === 'inspect') return await terminal.inspectForeground() ?? null
     if (operation === 'inspect') return await terminal.inspectForeground() ?? null
+    if (operation === 'activity') return terminal.inspectActivity()
     return terminal.signalForeground(z.enum(['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP']).parse(value))
     return terminal.signalForeground(z.enum(['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP']).parse(value))
   }
   }
 
 

+ 2 - 2
packages/ssh/ssh/src/helper.ts

@@ -123,9 +123,9 @@ export async function runSshHelper(transport: HelperTransport): Promise<void> {
       await processes.resizeTerminal(input.id, input.cols, input.rows)
       await processes.resizeTerminal(input.id, input.cols, input.rows)
       return null
       return null
     }
     }
-    if (method === 'terminal.write' || method === 'terminal.inspect' || method === 'terminal.signal') {
+    if (method === 'terminal.write' || method === 'terminal.inspect' || method === 'terminal.activity' || method === 'terminal.signal') {
       const input = z.object({ id: processIdSchema, value: z.string().optional() }).strict().parse(raw)
       const input = z.object({ id: processIdSchema, value: z.string().optional() }).strict().parse(raw)
-      return processes.terminal(input.id, method === 'terminal.write' ? 'write' : method === 'terminal.inspect' ? 'inspect' : 'signal', input.value)
+      return processes.terminal(input.id, method === 'terminal.write' ? 'write' : method === 'terminal.inspect' ? 'inspect' : method === 'terminal.activity' ? 'activity' : 'signal', input.value)
     }
     }
     if (method === 'executable') {
     if (method === 'executable') {
       const input = z.object({ command: z.string(), env: environmentSchema.optional() }).strict().parse(raw)
       const input = z.object({ command: z.string(), env: environmentSchema.optional() }).strict().parse(raw)

+ 3 - 0
packages/ssh/ssh/src/schemas.ts

@@ -50,6 +50,7 @@ export const spawnSchema = z.object({
   }).strict().optional(),
   }).strict().optional(),
   terminal: z.object({
   terminal: z.object({
     terminalType: z.string().min(1), rows: z.number().int().positive(), cols: z.number().int().positive(),
     terminalType: z.string().min(1), rows: z.number().int().positive(), cols: z.number().int().positive(),
+    shellActivity: z.boolean().optional(),
   }).strict().optional(),
   }).strict().optional(),
 }).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution')
 }).strict().refine(value => (value.stdio === undefined) !== (value.terminal === undefined), 'select ordinary or terminal execution')
 /** Connection handshake binds sockets and workspace to one helper process. */
 /** Connection handshake binds sockets and workspace to one helper process. */
@@ -83,3 +84,5 @@ export const doneSchema = z.object({
 }).strict()
 }).strict()
 /** Remote terminal foreground observation. */
 /** Remote terminal foreground observation. */
 export const foregroundSchema = z.object({ processGroupId: z.number().int().positive(), inputWaiting: z.boolean() }).strict().nullable()
 export const foregroundSchema = z.object({ processGroupId: z.number().int().positive(), inputWaiting: z.boolean() }).strict().nullable()
+/** Conservative shell lifecycle and process observation returned by the execution provider. */
+export const terminalActivitySchema = z.object({ state: z.enum(['idle', 'busy', 'unknown']), revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) }).strict()

+ 21 - 1
packages/ssh/ssh/tests/helper-processes.spec.ts

@@ -78,7 +78,7 @@ function terminal() {
   const terminate = vi.fn(async () => { output.end(); completion.resolve(outcome) })
   const terminate = vi.fn(async () => { output.end(); completion.resolve(outcome) })
   const resize = vi.fn(async (_cols: number, _rows: number) => {})
   const resize = vi.fn(async (_cols: number, _rows: number) => {})
   const handle: SubprocessTerminalHandle = {
   const handle: SubprocessTerminalHandle = {
-    resize,
+    resize, inspectActivity: async () => ({ state: 'unknown', revision: 0 }),
     pid: 42, output, done: completion.promise, write, inspectForeground, signalForeground, terminate,
     pid: 42, output, done: completion.promise, write, inspectForeground, signalForeground, terminate,
   }
   }
   return { handle, completion, output, write, resize, inspectForeground, signalForeground, terminate }
   return { handle, completion, output, write, resize, inspectForeground, signalForeground, terminate }
@@ -390,3 +390,23 @@ describe.skipIf(process.platform === 'win32')('SSH helper process settlement', (
     } finally { await test.close() }
     } finally { await test.close() }
   })
   })
 })
 })
+
+it.skipIf(process.platform === 'win32')('keeps opted-in terminal reservations after root exit until owned cleanup succeeds', async () => {
+  const test = await harness()
+  const child = terminal()
+  test.spawnTerminal.mockResolvedValueOnce(child.handle)
+  try {
+    const run = await test.prepare({ ...terminalRequest, terminal: { ...terminalRequest.terminal, shellActivity: true } })
+    run.channels.terminal!.end()
+    run.channels.terminal!.resume()
+    await test.owner.start(run.id)
+    child.output.end()
+    child.completion.resolve(outcome)
+    await expect(test.owner.done(run.id)).resolves.toEqual({ outcome, spills: {}, collected: {} })
+    expect(child.terminate).not.toHaveBeenCalled()
+    expect(await test.owner.terminal(run.id, 'activity')).toEqual({ state: 'unknown', revision: 0 })
+    await test.owner.terminate(run.id)
+    expect(child.terminate).toHaveBeenCalledOnce()
+    await expect.poll(async () => await readdir(test.root)).toEqual([])
+  } finally { await test.close() }
+})

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md
 #   pnpm run verify-translation-pairing --write packages/ssh/subprocess-ssh/README.md
-README.md: 15afa74f041e9944036c451dacaaedb53ecd9ff9
-README.zh.md: 3417f184fbcb702edeadf3b510663b1a9a58192f
+README.md: ad5ddd52bd92574393a49067843fd95702c1363c
+README.zh.md: c7a2e4d15e790dadcfbd1a76cf128d2770fb26b6

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

@@ -29,6 +29,8 @@ Mount this provider with [`dsh-ssh`](../ssh/README.md) and its filesystem provid
 
 
 Ordinary spawn returns a handle while remote allocation proceeds. Piped stdin and the optional duplex control endpoint accept writes during allocation. Terminal allocation, writes, foreground inspection, signals and termination retain their asynchronous interfaces.
 Ordinary spawn returns a handle while remote allocation proceeds. Piped stdin and the optional duplex control endpoint accept writes during allocation. Terminal allocation, writes, foreground inspection, signals and termination retain their asynchronous interfaces.
 
 
+Terminal requests forward `shellActivity` to the execution provider, and `inspectActivity()` returns its validated state and revision. Opted-in terminals remain addressable after root exit until their owner explicitly terminates them; the SSH helper lease still governs connection-level cleanup.
+
 A direct exit reported by `done` does not prove managed-range quiescence; `waitForExit()` observes that separately. Its optional signal bounds the entire observation, including pending allocation and termination, and cancellation returns `false` without stopping the process. An already confirmed empty range returns `true`; genuine observation failures reject. `terminate()` targets the same remote owner. Connection loss rejects unconfirmed work, and automatic replay never follows an ambiguous launch or mutation.
 A direct exit reported by `done` does not prove managed-range quiescence; `waitForExit()` observes that separately. Its optional signal bounds the entire observation, including pending allocation and termination, and cancellation returns `false` without stopping the process. An already confirmed empty range returns `true`; genuine observation failures reject. `terminate()` targets the same remote owner. Connection loss rejects unconfirmed work, and automatic replay never follows an ambiguous launch or mutation.
 
 
 -----
 -----

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

@@ -29,6 +29,8 @@ kind: "package-reference"
 
 
 普通 spawn 在远端分配过程中返回句柄。管道 stdin 和可选双工控制端点在分配过程中接受写入。终端分配、写入、前台检查、信号及终止均保留异步接口。
 普通 spawn 在远端分配过程中返回句柄。管道 stdin 和可选双工控制端点在分配过程中接受写入。终端分配、写入、前台检查、信号及终止均保留异步接口。
 
 
+终端请求将 `shellActivity` 转发给执行 provider,`inspectActivity()` 返回经校验的状态和 revision。启用后,根进程退出的终端仍可访问,直到 owner 明确终止;连接级清理仍由 SSH helper 租约控制。
+
 `done` 报告的直接进程退出并不证明托管进程范围已经静止;`waitForExit()` 单独观察该状态。其可选信号约束整个观察过程,包括等待分配和终止;取消返回 `false`,不会停止进程。已确认清空的范围返回 `true`,实际观察故障则拒绝。`terminate()` 针对同一个远端管理器。连接丢失会拒绝未确认的操作;启动或修改结果不明确时绝不自动重放。
 `done` 报告的直接进程退出并不证明托管进程范围已经静止;`waitForExit()` 单独观察该状态。其可选信号约束整个观察过程,包括等待分配和终止;取消返回 `false`,不会停止进程。已确认清空的范围返回 `true`,实际观察故障则拒绝。`terminate()` 针对同一个远端管理器。连接丢失会拒绝未确认的操作;启动或修改结果不明确时绝不自动重放。
 
 
 -----
 -----

+ 3 - 2
packages/ssh/subprocess-ssh/src/index.ts

@@ -6,7 +6,7 @@ import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-
 import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import type { SubprocessCollectedOutputs, SubprocessHandle, SubprocessOutcome, SubprocessOutputMode, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalEnvironment, SubprocessTerminalSignal, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
 import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
 import { OutputCollector } from '@deepseek-ai/dsh-subprocess-local/output'
 import type { SshConnection } from '@deepseek-ai/dsh-ssh'
 import type { SshConnection } from '@deepseek-ai/dsh-ssh'
-import { doneSchema, foregroundSchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
+import { doneSchema, foregroundSchema, terminalActivitySchema, outputSnapshotFrameLimit, outputSnapshotSchema, preparedSchema, remotePath, streamEndpointSchema } from '@deepseek-ai/dsh-ssh/schemas'
 import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas'
 import type { SshProcessId } from '@deepseek-ai/dsh-ssh/schemas'
 import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
 import { SshRpcPeer, RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
 import { z } from 'zod'
 import { z } from 'zod'
@@ -294,7 +294,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
     const ssh = this.ctx.ssh
     const ssh = this.ctx.ssh
     const prepared = await ssh.request('process.prepare', {
     const prepared = await ssh.request('process.prepare', {
       argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs,
       argv: spec.argv, cwd: spec.cwd, env: environment(spec.env), graceMs: spec.graceMs,
-      terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType },
+      terminal: { rows: spec.rows, cols: spec.cols, terminalType: spec.terminalType, shellActivity: spec.shellActivity },
     }, preparedSchema, signal)
     }, preparedSchema, signal)
     const id = prepared.id
     const id = prepared.id
     let socket: Socket | undefined
     let socket: Socket | undefined
@@ -316,6 +316,7 @@ export class SshSubprocessRuntime extends SubprocessRuntime {
         resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) },
         resize: async (cols, rows) => { await ssh.request('terminal.resize', { id, cols, rows }, z.null()) },
         write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) },
         write: async (data) => { await ssh.request('terminal.write', { id, value: data }, z.null()) },
         inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined,
         inspectForeground: async () => await ssh.request('terminal.inspect', { id }, foregroundSchema) ?? undefined,
+        inspectActivity: () => ssh.request('terminal.activity', { id }, terminalActivitySchema),
         signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()),
         signalForeground: (signal: SubprocessTerminalSignal) => ssh.request('terminal.signal', { id, value: signal }, z.number().int().positive()),
         terminate: () => {
         terminate: () => {
           closing ??= ssh.request('process.terminate', { id }, z.null(), undefined, true).then(() => {
           closing ??= ssh.request('process.terminate', { id }, z.null(), undefined, true).then(() => {

+ 2 - 1
packages/ssh/subprocess-ssh/tests/shell-discovery.spec.ts

@@ -43,9 +43,10 @@ it.skipIf(process.platform === 'win32')('queries the real helper and resizes its
   try {
   try {
     expect(await runtime.terminalEnvironment()).toMatchObject({ platform: 'posix' })
     expect(await runtime.terminalEnvironment()).toMatchObject({ platform: 'posix' })
     await expect(runtime.resolveExecutable(`${helper.root}/absent`)).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError)
     await expect(runtime.resolveExecutable(`${helper.root}/absent`)).rejects.toBeInstanceOf(SubprocessExecutableNotFoundError)
-    const terminal = await runtime.spawnTerminal({ argv: ['/bin/sh', '-i'], cwd: helper.root, cols: 80, rows: 24, terminalType: 'xterm-256color', graceMs: 100 })
+    const terminal = await runtime.spawnTerminal({ argv: ['/bin/sh', '-i'], cwd: helper.root, cols: 80, rows: 24, terminalType: 'xterm-256color', graceMs: 100, shellActivity: true })
     let output = ''
     let output = ''
     terminal.output.on('data', (chunk: Buffer) => { output += chunk.toString() })
     terminal.output.on('data', (chunk: Buffer) => { output += chunk.toString() })
+    expect((await terminal.inspectActivity()).state).toBe('unknown')
     await terminal.resize(120, 40)
     await terminal.resize(120, 40)
     await terminal.write('printf "REMOTE_TERM:%s\\n" "$TERM"; stty size\n')
     await terminal.write('printf "REMOTE_TERM:%s\\n" "$TERM"; stty size\n')
     await expect.poll(() => output).toContain('REMOTE_TERM:xterm-256color')
     await expect.poll(() => output).toContain('REMOTE_TERM:xterm-256color')

+ 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;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: 5379aff31cdcf036614a6664ce59330d319bd459
-README.zh.md: f61596ac94b7f1270275cb93593c90a281c9ca90
+README.md: 224634dc5493dba89bccc864fb8e2d2cd1ea9fe1
+README.zh.md: 439aedeac3ccaa4331a1c7e1a2c4b7092bce8384

+ 5 - 0
packages/subprocess/subprocess-local/README.md

@@ -50,10 +50,15 @@ The `./output` export shares this collector and retained-spill storage with proc
 
 
 An ordinary spawn can request the [subprocess control pipe](../subprocess/README.md#using-a-control-pipe). A Node target receives fd 7 on every supported host; Windows descriptor numbering requires CRT initialization. POSIX runners preserve that descriptor across `execve`; Windows Job and ACL runners establish it in the child's CRT startup table before Node initializes and close their own carrier copies after spawning. Standard streams and the runner's private management channel remain independent.
 An ordinary spawn can request the [subprocess control pipe](../subprocess/README.md#using-a-control-pipe). A Node target receives fd 7 on every supported host; Windows descriptor numbering requires CRT initialization. POSIX runners preserve that descriptor across `execve`; Windows Job and ACL runners establish it in the child's CRT startup table before Node initializes and close their own carrier copies after spawning. Standard streams and the runner's private management channel remain independent.
 
 
+<a id="running-terminal-sessions"></a>
 ### Running terminal sessions
 ### Running terminal sessions
 
 
 `spawnTerminal` allocates a real PTY and bridges UTF-8 text; you can inspect and signal the current foreground process group and await one `terminate()` operation. On supported Linux hosts, the original terminal argv runs directly inside a user-systemd scope, preserving the node-pty PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while the scope owns reparented or `setsid` descendants. On fallback hosts, cleanup retains exact identities from the rooted tree and observable session but cannot recover every escaped descendant. An exact Linux input wait requires a foreground thread whose fd 0 identifies the shell's controlling terminal and whose current syscall waits on that fd; if the kernel denies the syscall probe, the higher PTY backend uses its idle inference instead. On Windows, SIGINT is delivered as a Ctrl-C input write, SIGTSTP and SIGHUP are unsupported, and teardown verifies the shell's termination through the process table because an externally killed shell may never fire the PTY exit notification.
 `spawnTerminal` allocates a real PTY and bridges UTF-8 text; you can inspect and signal the current foreground process group and await one `terminate()` operation. On supported Linux hosts, the original terminal argv runs directly inside a user-systemd scope, preserving the node-pty PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while the scope owns reparented or `setsid` descendants. On fallback hosts, cleanup retains exact identities from the rooted tree and observable session but cannot recover every escaped descendant. An exact Linux input wait requires a foreground thread whose fd 0 identifies the shell's controlling terminal and whose current syscall waits on that fd; if the kernel denies the syscall probe, the higher PTY backend uses its idle inference instead. On Windows, SIGINT is delivered as a Ctrl-C input write, SIGTSTP and SIGHUP are unsupported, and teardown verifies the shell's termination through the process table because an externally killed shell may never fire the PTY exit notification.
 
 
+With `shellActivity: true`, plain non-login `bash -i` and `zsh -i` launches install private lifecycle records while retaining user startup files and prompt configuration. Bash requires version 4.4 or later and writable prompt hooks; Zsh observes an empty top-level ZLE prompt, excluding `vared`, selection and continuation prompts. Input, shell transitions and changed process observations advance activity revisions. Foreground, background and stopped descendants block idle; native Linux also requires exactly one task in the systemd scope, including ownership beyond the process tree; incomplete process-table scans, custom traps and Zsh asynchronous descriptor handlers yield unknown. A failure to enumerate the process table rejects the observation; activity remains unknown and cleanup retains ownership until a readable table permits verification. Private files are removed after successful process cleanup. Other shells, Windows, custom arguments and sandbox-wrapped executables remain usable with unknown activity.
+
+Opted-in root exit does not terminate surviving descendants. A confirmed empty Linux managed range or complete empty Linux session can authorize reclamation of its retained record; macOS cannot confirm an unobserved process range after root exit and keeps that record unknown. The existing fallback visibility limits still apply: shell lifecycle records do not make escaped, unobserved descendants discoverable. Lifecycle records coordinate ordinary shell behavior, not hostile same-user processes.
+
 ### Shutdown behavior
 ### Shutdown behavior
 
 
 Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
 Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.

+ 5 - 0
packages/subprocess/subprocess-local/README.zh.md

@@ -50,10 +50,15 @@ kind: "package-reference"
 
 
 普通 spawn 可以请求 [subprocess 控制管道](../subprocess/README.zh.md#using-a-control-pipe)。Node 目标在所有受支持的宿主上均收到 fd 7;Windows 描述符编号依赖 CRT 初始化。POSIX runner 在 `execve` 时保留该描述符;Windows Job 和 ACL runner 在 Node 初始化前通过子进程的 CRT 启动表建立它,并在 spawn 后关闭自身的承载副本。标准流与 runner 的私有管理通道保持独立。
 普通 spawn 可以请求 [subprocess 控制管道](../subprocess/README.zh.md#using-a-control-pipe)。Node 目标在所有受支持的宿主上均收到 fd 7;Windows 描述符编号依赖 CRT 初始化。POSIX runner 在 `execve` 时保留该描述符;Windows Job 和 ACL runner 在 Node 初始化前通过子进程的 CRT 启动表建立它,并在 spawn 后关闭自身的承载副本。标准流与 runner 的私有管理通道保持独立。
 
 
+<a id="running-terminal-sessions"></a>
 ### 运行终端会话
 ### 运行终端会话
 
 
 `spawnTerminal` 分配真实 PTY 并桥接 UTF-8 文本;你可以检查当前前台进程组并向其发送信号,还可以等待一次 `terminate()` 操作。在受支持的 Linux 宿主上,原始终端 argv 直接在 user-systemd scope 内运行;node-pty PID、会话 leader、控制终端、前台 `inputWaiting` 与就绪状态保持不变,而 scope 会拥有已重新设定父进程或调用 `setsid` 的后代。在 fallback 宿主上,清理会保留根进程树和可观察会话中的精确身份,但无法重新发现每个已经逃逸的后代。Linux 的精确输入等待要求前台线程的 fd 0 标识 shell 的控制终端,且线程当前的 syscall 正在等待该 fd;如果内核拒绝 syscall 探测,上层 PTY 后端会改用空闲推断。在 Windows 上,SIGINT 以 Ctrl-C 输入写入投递,SIGTSTP 与 SIGHUP 不受支持,拆卸会通过进程表验证 shell 已终止,因为被外部终止的 shell 可能永远不会触发 PTY 退出通知。
 `spawnTerminal` 分配真实 PTY 并桥接 UTF-8 文本;你可以检查当前前台进程组并向其发送信号,还可以等待一次 `terminate()` 操作。在受支持的 Linux 宿主上,原始终端 argv 直接在 user-systemd scope 内运行;node-pty PID、会话 leader、控制终端、前台 `inputWaiting` 与就绪状态保持不变,而 scope 会拥有已重新设定父进程或调用 `setsid` 的后代。在 fallback 宿主上,清理会保留根进程树和可观察会话中的精确身份,但无法重新发现每个已经逃逸的后代。Linux 的精确输入等待要求前台线程的 fd 0 标识 shell 的控制终端,且线程当前的 syscall 正在等待该 fd;如果内核拒绝 syscall 探测,上层 PTY 后端会改用空闲推断。在 Windows 上,SIGINT 以 Ctrl-C 输入写入投递,SIGTSTP 与 SIGHUP 不受支持,拆卸会通过进程表验证 shell 已终止,因为被外部终止的 shell 可能永远不会触发 PTY 退出通知。
 
 
+使用 `shellActivity: true` 时,普通非登录的 `bash -i` 与 `zsh -i` 会安装私有生命周期记录,同时保留用户启动文件和提示符配置。Bash 需要 4.4 或更高版本,并允许写入提示符 hook;Zsh 观察空的顶层 ZLE 提示符,排除 `vared`、选择和续行提示符。输入、shell 状态迁移和进程观察变化都会推进活动 revision。前台、后台及停止的后代任务阻止空闲判断;原生 Linux 还要求 systemd scope 中恰好只有一个 task,覆盖进程树以外仍归其所有的工作;进程表扫描不完整、自定义 trap 和 Zsh 异步文件描述符 handler 会返回 unknown。进程表枚举失败会拒绝本次观察;活动保持 unknown,清理保留所有权,直到进程表可读并能完成验证。进程清理成功后删除私有文件。其他 shell、Windows、自定义参数以及经 sandbox 包装的可执行文件仍可使用,但活动为 unknown。
+
+启用后,根进程退出不会终止仍在运行的后代。明确为空的 Linux 受管范围或完整且为空的 Linux 会话可允许回收保留的记录;macOS 无法在根进程退出后确认未观察到的进程范围,会将该记录保持为 unknown。原有 fallback 可见性限制仍然适用:shell 生命周期记录不能让已逃逸且未观察到的后代变得可发现。这些记录协调普通 shell 行为,不用于防御同一用户的恶意进程。
+
 ### 关闭行为
 ### 关闭行为
 
 
 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。
 正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。

+ 17 - 15
packages/subprocess/subprocess-local/src/index.ts

@@ -45,6 +45,7 @@ import { targetEnvironment } from './runner-launch.ts'
 import { createProcessInspector } from './process-inspector.ts'
 import { createProcessInspector } from './process-inspector.ts'
 import type { ProcessInspector } from './process-inspector.ts'
 import type { ProcessInspector } from './process-inspector.ts'
 import { LocalTerminalHandle } from './terminal.ts'
 import { LocalTerminalHandle } from './terminal.ts'
+import { prepareShellActivity } from './shell-activity.ts'
 
 
 const requireNodePty = createLazyRequire<typeof NodePty>('node-pty', import.meta.url)
 const requireNodePty = createLazyRequire<typeof NodePty>('node-pty', import.meta.url)
 
 
@@ -262,36 +263,33 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
       throw new Error('subprocess-local: terminal argv must contain a program')
       throw new Error('subprocess-local: terminal argv must contain a program')
     }
     }
     spec.signal?.throwIfAborted()
     spec.signal?.throwIfAborted()
+    const inspector = this.terminalInspector ?? createProcessInspector()
+    const containmentMode = this.selectContainmentMode('terminal')
     const env = targetEnvironment(spec)
     const env = targetEnvironment(spec)
+    const activity = prepareShellActivity(spec, env, this.internals.platform ?? process.platform)
+    const launch = activity === undefined ? spec : { ...spec, argv: activity.argv, env: activity.env }
     const options: IPtyForkOptions = {
     const options: IPtyForkOptions = {
       name: spec.terminalType,
       name: spec.terminalType,
       rows: spec.rows,
       rows: spec.rows,
       cols: spec.cols,
       cols: spec.cols,
       cwd: spec.cwd,
       cwd: spec.cwd,
-      env: { ...env, TERM: spec.terminalType },
-    }
-    const inspector = this.terminalInspector ?? createProcessInspector()
-    const containmentMode = this.selectContainmentMode('terminal')
-    const scope = containmentMode === 'linux-scope'
-      ? prepareLinuxTerminalScope(spec, {
-        ...env,
-        PWD: spec.cwd,
-        TERM: spec.terminalType,
-      })
-      : undefined
-    if (scope !== undefined) {
-      options.cwd = scope.cwd
-      options.env = scope.env
+      env: { ...activity?.env ?? env, TERM: spec.terminalType },
     }
     }
+    let scope: ReturnType<typeof prepareLinuxTerminalScope> | undefined
     let terminal: NodePty.IPty
     let terminal: NodePty.IPty
     try {
     try {
+      scope = containmentMode === 'linux-scope'
+        ? prepareLinuxTerminalScope(launch, { ...activity?.env ?? env, PWD: spec.cwd, TERM: spec.terminalType })
+        : undefined
+      if (scope !== undefined) { options.cwd = scope.cwd; options.env = scope.env }
       terminal = requireNodePty().spawn(
       terminal = requireNodePty().spawn(
         scope?.command ?? file,
         scope?.command ?? file,
-        scope?.args ?? [...spec.argv.slice(1)],
+        scope?.args ?? [...launch.argv.slice(1)],
         options,
         options,
       )
       )
     } catch (error) {
     } catch (error) {
       scope?.cleanup()
       scope?.cleanup()
+      activity?.dispose()
       throw error
       throw error
     }
     }
     // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.
     // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.
@@ -310,11 +308,15 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
       this.internals.platform ?? process.platform,
       this.internals.platform ?? process.platform,
       owner,
       owner,
       scope?.resolveOutcome,
       scope?.resolveOutcome,
+      activity,
+      () => { this.terminals.delete(handle as LocalTerminalHandle) },
+      spec.shellActivity === true,
     )
     )
     this.terminals.add(handle)
     this.terminals.add(handle)
     const release = async (): Promise<void> => {
     const release = async (): Promise<void> => {
       // terminate() can wait on this direct-exit promise.
       // terminate() can wait on this direct-exit promise.
       directSettlement.resolve()
       directSettlement.resolve()
+      if (spec.shellActivity === true) return
       await handle.terminate()
       await handle.terminate()
       this.terminals.delete(handle)
       this.terminals.delete(handle)
     }
     }

+ 9 - 0
packages/subprocess/subprocess-local/src/linux-scope.ts

@@ -199,6 +199,15 @@ class SystemdScopeOwner implements BoundProcessOwner {
     private readonly sleep: (delayMs: number, signal?: AbortSignal) => Promise<void>,
     private readonly sleep: (delayMs: number, signal?: AbortSignal) => Promise<void>,
   ) {}
   ) {}
 
 
+  inspectTaskCount(): number | undefined {
+    const result = this.runSync(this.systemctl, [
+      '--user', 'show', '--property=LoadState', '--property=ActiveState', '--property=TasksCurrent', this.unit,
+    ], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
+    if (result.error !== undefined || result.status !== 0 || typeof result.stdout !== 'string') return undefined
+    const state = this.parseUnitState(result.stdout)
+    return state.loadState === 'loaded' && state.activeState === 'active' ? state.tasksCurrent : undefined
+  }
+
   signal(signal: 'SIGTERM' | 'SIGKILL'): void {
   signal(signal: 'SIGTERM' | 'SIGKILL'): void {
     if (this.stopped) return
     if (this.stopped) return
     this.terminationRequested = true
     this.terminationRequested = true

+ 5 - 0
packages/subprocess/subprocess-local/src/managed-owner.ts

@@ -9,6 +9,11 @@ export interface BoundProcessOwner {
   signal(signal: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void
   signal(signal: 'SIGTERM' | 'SIGKILL', cancellationReason?: unknown): void
   /** Wait for the same managed range to become empty; reject when it cannot be observed. */
   /** Wait for the same managed range to become empty; reject when it cannot be observed. */
   waitForExit(): Promise<void>
   waitForExit(): Promise<void>
+  /**
+   * Count tasks in the native range, including descendants outside the observable process tree.
+   * @returns the current count, or undefined when observation is unavailable.
+   */
+  inspectTaskCount?(): number | undefined
   /** Synchronously force final termination during JavaScript-observable host exit. */
   /** Synchronously force final termination during JavaScript-observable host exit. */
   terminateForHostExit(): void
   terminateForHostExit(): void
   /** Release provider-private protocol artifacts after outcome and range settlement. */
   /** Release provider-private protocol artifacts after outcome and range settlement. */

+ 26 - 12
packages/subprocess/subprocess-local/src/process-inspector.ts

@@ -32,6 +32,8 @@ interface FileStatus {
  * is the fence a signal takes, because it reads current state instead.
  * is the fence a signal takes, because it reads current state instead.
  */
  */
 export interface ProcessSnapshot {
 export interface ProcessSnapshot {
+  /** Whether the process-table scan omitted no unreadable rows; absent means unverified. */
+  readonly complete?: boolean
   /**
   /**
    * Return the root and its transitive descendants as observed, children first.
    * Return the root and its transitive descendants as observed, children first.
    * @param rootPid - tree root to descend from.
    * @param rootPid - tree root to descend from.
@@ -66,6 +68,7 @@ export interface ProcessInspector {
   /**
   /**
    * Read the process table once and answer tree, session, and liveness from it.
    * Read the process table once and answer tree, session, and liveness from it.
    * @returns A process-table observation whose reads are shared.
    * @returns A process-table observation whose reads are shared.
+   * @throws when the platform process table cannot be enumerated.
    */
    */
   snapshot(): ProcessSnapshot
   snapshot(): ProcessSnapshot
   /**
   /**
@@ -222,11 +225,11 @@ export function linuxProcessGroupHasLiveMembers(
   return matched ? false : undefined
   return matched ? false : undefined
 }
 }
 
 
-function numericEntries(internals: ProcessInspectorInternals, path: string): number[] {
+function numericEntries(internals: ProcessInspectorInternals, path: string): number[] | undefined {
   try {
   try {
     return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
     return internals.readDir(path).filter(entry => /^\d+$/.test(entry)).map(Number)
   } catch (_unreadableProcDirectory) {
   } catch (_unreadableProcDirectory) {
-    return []
+    return undefined
   }
   }
 }
 }
 
 
@@ -383,7 +386,7 @@ function quiescent(state: string | undefined): boolean {
 class PosixProcessSnapshot implements ProcessSnapshot {
 class PosixProcessSnapshot implements ProcessSnapshot {
   private readonly byPid: Map<number, ProcessRow>
   private readonly byPid: Map<number, ProcessRow>
 
 
-  constructor(private readonly rows: ProcessRow[]) {
+  constructor(private readonly rows: ProcessRow[], readonly complete: boolean) {
     this.byPid = new Map(rows.map(row => [row.pid, row]))
     this.byPid = new Map(rows.map(row => [row.pid, row]))
   }
   }
 
 
@@ -444,10 +447,10 @@ class LinuxProcessInspector extends PosixProcessInspector {
     if (shell === undefined) return false
     if (shell === undefined) return false
     const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice)
     const terminalDevice = readLinuxTerminalDevice(this.internals, shellPid, shell.ttyDevice)
     if (terminalDevice === undefined) return false
     if (terminalDevice === undefined) return false
-    for (const pid of numericEntries(this.internals, '/proc')) {
+    for (const pid of numericEntries(this.internals, '/proc') ?? []) {
       const process = readLinuxStat(this.internals, pid)
       const process = readLinuxStat(this.internals, pid)
       if (process?.pgrp !== pgid) continue
       if (process?.pgrp !== pgid) continue
-      for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) {
+      for (const tid of numericEntries(this.internals, `/proc/${pid}/task`) ?? []) {
         const syscall = readSyscall(this.internals, pid, tid)
         const syscall = readSyscall(this.internals, pid, tid)
         if (syscall !== undefined
         if (syscall !== undefined
           && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables)
           && syscallWaitsOnStdin(this.internals, pid, tid, syscall, tables)
@@ -463,8 +466,12 @@ class LinuxProcessInspector extends PosixProcessInspector {
   }
   }
 
 
   snapshot(): ProcessSnapshot {
   snapshot(): ProcessSnapshot {
-    return new PosixProcessSnapshot(numericEntries(this.internals, '/proc').flatMap((pid) => {
+    const pids = numericEntries(this.internals, '/proc')
+    if (pids === undefined) throw new Error('Cannot inspect processes: /proc directory is unreadable')
+    let complete = true
+    const rows = pids.flatMap((pid) => {
       const stat = readLinuxStat(this.internals, pid)
       const stat = readLinuxStat(this.internals, pid)
+      if (stat === undefined) complete = false
       return stat === undefined ? [] : [{
       return stat === undefined ? [] : [{
         pid,
         pid,
         parentPid: stat.parentPid,
         parentPid: stat.parentPid,
@@ -472,17 +479,22 @@ class LinuxProcessInspector extends PosixProcessInspector {
         session: stat.session,
         session: stat.session,
         state: stat.state,
         state: stat.state,
       }]
       }]
-    }))
+    })
+    return new PosixProcessSnapshot(rows, complete)
   }
   }
 
 
 }
 }
 
 
 // `ps` exposes neither the session id nor a state column in this format, so a
 // `ps` exposes neither the session id nor a state column in this format, so a
 // macOS row can answer presence and parentage but never session membership.
 // macOS row can answer presence and parentage but never session membership.
-function macProcessTable(internals: ProcessInspectorInternals): ProcessRow[] {
-  return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
+function macProcessTable(internals: ProcessInspectorInternals): { rows: ProcessRow[]; complete: boolean } {
+  let complete = true
+  const rows = internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => {
     const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
     const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line)
-    if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return []
+    if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) {
+      if (line.trim().length > 0) complete = false
+      return []
+    }
     return [{
     return [{
       pid: Number(match[1]),
       pid: Number(match[1]),
       parentPid: Number(match[2]),
       parentPid: Number(match[2]),
@@ -491,6 +503,7 @@ function macProcessTable(internals: ProcessInspectorInternals): ProcessRow[] {
       state: undefined,
       state: undefined,
     }]
     }]
   })
   })
+  return { rows, complete }
 }
 }
 
 
 class MacProcessInspector extends PosixProcessInspector {
 class MacProcessInspector extends PosixProcessInspector {
@@ -508,12 +521,13 @@ class MacProcessInspector extends PosixProcessInspector {
   }
   }
 
 
   isAlive(identity: ProcessIdentity): boolean {
   isAlive(identity: ProcessIdentity): boolean {
-    return macProcessTable(this.internals)
+    return macProcessTable(this.internals).rows
       .some(entry => entry.pid === identity.pid && entry.started === identity.started)
       .some(entry => entry.pid === identity.pid && entry.started === identity.started)
   }
   }
 
 
   snapshot(): ProcessSnapshot {
   snapshot(): ProcessSnapshot {
-    return new PosixProcessSnapshot(macProcessTable(this.internals))
+    const table = macProcessTable(this.internals)
+    return new PosixProcessSnapshot(table.rows, table.complete)
   }
   }
 
 
 }
 }

+ 131 - 0
packages/subprocess/subprocess-local/src/shell-activity.ts

@@ -0,0 +1,131 @@
+/** Private shell lifecycle files; ordinary output never authorizes terminal reclamation. */
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { basename, join } from 'node:path'
+import type { SubprocessTerminalActivity, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+
+function quote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` }
+
+/** Opt-in startup integration and revision tracking for one ordinary interactive shell. */
+export class ShellActivity {
+  private revision = 0
+  private observed = ''
+  private invalidated: string | undefined
+  private state: SubprocessTerminalActivity['state'] = 'unknown'
+
+  /**
+   * @param directory - private startup and status file directory.
+   * @param argv - shell launch preserving supported user startup files.
+   * @param env - environment with any temporary startup redirect.
+   */
+  constructor(private readonly directory: string, readonly argv: readonly string[], readonly env: Record<string, string>) {}
+
+  /** Invalidate prompt evidence before delivering input or a foreground signal. */
+  invalidate(): void {
+    this.invalidated = this.read()
+    this.revision++
+    this.state = 'unknown'
+  }
+
+  /**
+   * Read the latest top-level shell transition, fenced against input since that transition.
+   * @param pid - original shell process id.
+   * @returns lifecycle evidence; process ownership must be checked separately.
+   */
+  inspect(pid: number): SubprocessTerminalActivity {
+    const record = this.read()
+    if (record !== this.observed) { this.observed = record; this.revision++ }
+    const match = /^(\d+):(\d+):(idle|busy)\n?$/u.exec(record)
+    this.state = record === this.invalidated || match?.[1] !== String(pid) ? 'unknown'
+      : match[3] as 'idle' | 'busy'
+    return { state: this.state, revision: this.revision }
+  }
+
+  /** Remove private startup and status files after process quiescence. */
+  dispose(): void { rmSync(this.directory, { recursive: true, force: true }) }
+
+  private read(): string {
+    try { return readFileSync(join(this.directory, 'state'), 'utf8') }
+    catch (_unavailableShellObservation) { return '' }
+  }
+}
+
+/**
+ * Prepare optional Bash or Zsh integration for a plain, non-login interactive launch.
+ * @param spec - terminal request; custom arguments and wrapped executables remain unmodified.
+ * @param env - scrubbed target environment.
+ * @param platform - execution platform.
+ * @returns private integration, or undefined for unsupported launches.
+ */
+export function prepareShellActivity(
+  spec: SubprocessTerminalSpawnSpec, env: Record<string, string>, platform: NodeJS.Platform,
+): ShellActivity | undefined {
+  if (spec.shellActivity !== true || platform === 'win32' || spec.argv.length !== 2 || spec.argv[1] !== '-i') return undefined
+  const shell = basename(spec.argv[0] as string)
+  if (shell !== 'bash' && shell !== 'zsh') return undefined
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-shell-'))
+  const state = quote(join(directory, 'state'))
+  const guards = quote(join(directory, 'guards'))
+  try {
+    if (shell === 'bash') {
+      const rc = join(directory, 'bashrc')
+      writeFileSync(rc, [
+        '[[ ! -r ~/.bashrc ]] || builtin source ~/.bashrc',
+        'if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )) && [[ ! $(declare -p PROMPT_COMMAND PS0 2>/dev/null) =~ declare\\ -[^[:space:]]*r ]]; then',
+        '  __dsh_shell_pid=$BASHPID; __dsh_shell_sequence=0',
+        '  __dsh_shell_idle() {',
+        '    local result=$?',
+        '    if [[ $BASHPID == "$__dsh_shell_pid" ]]; then',
+        '      (( ++__dsh_shell_sequence ))',
+        '      local activity=idle',
+        `      builtin trap -p >| ${guards}`,
+        `      [[ ! -s ${guards} ]] || activity=unknown`,
+        `      builtin printf '%s:%s:%s\\n' "$BASHPID" "$__dsh_shell_sequence" "$activity" >| ${state}`,
+        '    fi',
+        '    return "$result"',
+        '  }',
+        '  if [[ $(declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a "* ]]; then',
+        '    PROMPT_COMMAND+=(__dsh_shell_idle)',
+        '  else',
+        '    PROMPT_COMMAND="${PROMPT_COMMAND}"$\'\\n\'"__dsh_shell_idle"',
+        '  fi',
+        `  PS0+=${quote(`$(builtin printf '%s:%s:busy' "$__dsh_shell_pid" "$__dsh_shell_sequence" >| ${state})`)}`,
+        'fi',
+        '',
+      ].join('\n'), { mode: 0o600, flag: 'wx' })
+      return new ShellActivity(directory, [spec.argv[0] as string, '--rcfile', rc, '-i'], env)
+    }
+    writeFileSync(join(directory, '.zshenv'), [
+      env.ZDOTDIR === undefined ? 'unset ZDOTDIR' : `ZDOTDIR=${quote(env.ZDOTDIR)}`,
+      '[[ ! -r ${ZDOTDIR:-$HOME}/.zshenv ]] || builtin source "${ZDOTDIR:-$HOME}/.zshenv"',
+      'typeset -g __dsh_shell_pid=$$ __dsh_shell_sequence=0',
+      '__dsh_shell_activity() {',
+      '  (( ZSH_SUBSHELL == 0 && $$ == __dsh_shell_pid )) || return',
+      '  (( ++__dsh_shell_sequence ))',
+      `  builtin printf '%s:%s:%s\\n' "$$" "$__dsh_shell_sequence" "$1" >| ${state}`,
+      '  return 0',
+      '}',
+      '__dsh_shell_idle() {',
+      `  { builtin trap; zle -F; } >| ${guards}`,
+      '  if [[ $CONTEXT != start || -n $BUFFER ]]; then __dsh_shell_activity busy',
+      `  elif [[ -s ${guards}` + ' || -n ${(k)functions[(I)TRAP*]} ]]; then __dsh_shell_activity unknown',
+      '  else __dsh_shell_activity idle; fi',
+      '}',
+      '__dsh_shell_busy() { __dsh_shell_activity busy }',
+      '__dsh_shell_init() {',
+      '  autoload -Uz add-zle-hook-widget add-zsh-hook',
+      '  add-zle-hook-widget line-init __dsh_shell_idle',
+      '  add-zle-hook-widget line-finish __dsh_shell_busy',
+      '  add-zsh-hook preexec __dsh_shell_busy',
+      '  precmd_functions=(${precmd_functions:#__dsh_shell_init})',
+      '}',
+      'typeset -ga precmd_functions',
+      'precmd_functions+=(__dsh_shell_init)',
+      '',
+    ].join('\n'), { mode: 0o600, flag: 'wx' })
+    return new ShellActivity(directory, spec.argv, { ...env, ZDOTDIR: directory })
+  } catch (error) {
+    rmSync(directory, { recursive: true, force: true })
+    throw error
+  }
+}

+ 54 - 2
packages/subprocess/subprocess-local/src/terminal.ts

@@ -6,12 +6,14 @@ import { PassThrough } from 'node:stream'
 import type { IDisposable, IPty } from 'node-pty'
 import type { IDisposable, IPty } from 'node-pty'
 import type {
 import type {
   SubprocessOutcome,
   SubprocessOutcome,
+  SubprocessTerminalActivity,
   SubprocessTerminalForeground,
   SubprocessTerminalForeground,
   SubprocessTerminalHandle,
   SubprocessTerminalHandle,
   SubprocessTerminalSignal,
   SubprocessTerminalSignal,
 } from '@deepseek-ai/dsh-subprocess'
 } from '@deepseek-ai/dsh-subprocess'
 import type { BoundProcessOwner } from './managed-owner.ts'
 import type { BoundProcessOwner } from './managed-owner.ts'
 import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
 import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts'
+import type { ShellActivity } from './shell-activity.ts'
 
 
 function delay(ms: number, signal?: AbortSignal): Promise<void> {
 function delay(ms: number, signal?: AbortSignal): Promise<void> {
   return new Promise((resolve) => {
   return new Promise((resolve) => {
@@ -67,6 +69,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   private exited = false
   private exited = false
   private outputPaused = false
   private outputPaused = false
   private trackedDescendants: ProcessIdentity[] = []
   private trackedDescendants: ProcessIdentity[] = []
+  private activityRevision = 0
+  private activityKey = ''
+  private quiescent = false
+  private managedRangeEmpty = false
   /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
   /** The spawned shell's start identity; scans stop adopting members once the root pid no longer carries it. */
   private readonly rootIdentity: ProcessIdentity | undefined
   private readonly rootIdentity: ProcessIdentity | undefined
 
 
@@ -83,9 +89,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     private readonly platform: NodeJS.Platform = process.platform,
     private readonly platform: NodeJS.Platform = process.platform,
     private readonly managedOwner?: BoundProcessOwner,
     private readonly managedOwner?: BoundProcessOwner,
     private readonly resolveManagedOutcome?: (outcome: SubprocessOutcome) => SubprocessOutcome,
     private readonly resolveManagedOutcome?: (outcome: SubprocessOutcome) => SubprocessOutcome,
+    private readonly shellActivity?: Pick<ShellActivity, 'inspect' | 'invalidate' | 'dispose'>,
+    private readonly onQuiescence?: () => void,
+    private readonly observeShellExit = false,
   ) {
   ) {
     this.pid = terminal.pid
     this.pid = terminal.pid
-    this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid)
+    try { this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid) }
+    catch (_rootIdentityUnavailable) { this.rootIdentity = undefined }
     this.done = this.outcome.promise
     this.done = this.outcome.promise
     const resume = (): void => {
     const resume = (): void => {
       if (!this.outputPaused) return
       if (!this.outputPaused) return
@@ -103,6 +113,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
     this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => {
       if (this.exited) return
       if (this.exited) return
       this.exited = true
       this.exited = true
+      if (this.managedOwner !== undefined && this.observeShellExit) {
+        void this.managedOwner.waitForExit().then(() => { this.managedRangeEmpty = true }).catch(() => {
+          // Failed range observation cannot authorize idle reclamation.
+        })
+      }
       this.output.end()
       this.output.end()
       const outcome = {
       const outcome = {
         exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
         exitCode: exitSignal === undefined || exitSignal === 0 ? exitCode : null,
@@ -125,6 +140,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
   // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
   async write(data: string): Promise<void> {
   async write(data: string): Promise<void> {
     if (this.exited) throw new Error('terminal process has exited')
     if (this.exited) throw new Error('terminal process has exited')
+    this.shellActivity?.invalidate()
     this.terminal.write(data)
     this.terminal.write(data)
   }
   }
 
 
@@ -146,7 +162,39 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
     }
     }
   }
   }
 
 
+  // oxlint-disable-next-line typescript/require-await -- Local inspection is synchronous; SSH shares this promise interface.
+  async inspectActivity(): Promise<SubprocessTerminalActivity> {
+    let state: SubprocessTerminalActivity['state'] = this.quiescent ? 'idle' : 'unknown'
+    let revision = 0
+    if (!this.quiescent) {
+      try {
+        const shell = this.shellActivity?.inspect(this.pid) ?? { state: 'unknown' as const, revision: 0 }
+        revision = shell.revision
+        const observed = this.inspector.snapshot()
+        const descendants = this.descendants(observed)
+        const root = observed.tree(this.pid).find(member => member.pid === this.pid)
+        if (this.exited && this.managedRangeEmpty) state = 'idle'
+        else if (descendants.length > 0) state = 'busy'
+        else if (this.exited && this.managedOwner === undefined && this.platform === 'linux' && observed.complete === true && root === undefined) {
+          state = observed.session(this.pid).some(member => observed.alive(member)) ? 'busy' : 'idle'
+        }
+        else if (observed.complete === true && root?.started === this.rootIdentity?.started && root !== undefined) {
+          const foreground = this.inspector.foregroundPgid(this.pid)
+          state = foreground === undefined ? 'unknown' : foreground === this.pid ? shell.state : 'busy'
+          if (state === 'idle' && this.managedOwner !== undefined) {
+            const tasks = this.managedOwner.inspectTaskCount?.()
+            state = tasks === undefined || tasks < 1 ? 'unknown' : tasks === 1 ? 'idle' : 'busy'
+          }
+        }
+      } catch (_incompleteActivityObservation) { state = 'unknown' }
+    }
+    const key = `${revision}:${state}`
+    if (key !== this.activityKey) { this.activityKey = key; this.activityRevision++ }
+    return { state, revision: this.activityRevision }
+  }
+
   async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
   async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
+    this.shellActivity?.invalidate()
     const foreground = await this.inspectForeground()
     const foreground = await this.inspectForeground()
     if (foreground === undefined) {
     if (foreground === undefined) {
       throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
       throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
@@ -174,7 +222,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle {
   terminate(): Promise<void> {
   terminate(): Promise<void> {
     if (this.cleanup !== undefined) return this.cleanup
     if (this.cleanup !== undefined) return this.cleanup
     if (this.outputPaused) { this.outputPaused = false; this.terminal.resume() }
     if (this.outputPaused) { this.outputPaused = false; this.terminal.resume() }
-    const cleanup = this.closeOnce()
+    const cleanup = this.closeOnce().then(() => {
+      this.quiescent = true
+      this.shellActivity?.dispose()
+      this.onQuiescence?.()
+    })
     this.cleanup = cleanup
     this.cleanup = cleanup
     void cleanup.catch(() => { this.cleanup = undefined })
     void cleanup.catch(() => { this.cleanup = undefined })
     return cleanup
     return cleanup

+ 25 - 0
packages/subprocess/subprocess-local/tests/linux-scope.spec.ts

@@ -1058,3 +1058,28 @@ describe('Linux ordinary launch adapters', () => {
     expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
     expect(existsSync(linuxLaunchFilesFromLocator(requestPath).directory)).toBe(false)
   })
   })
 })
 })
+
+it('observes native task counts independently from process-tree membership', () => {
+  const terminalSpec = { argv: ['/bin/bash', '-i'], cwd: process.cwd(), rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100 }
+  const scope = prepareLinuxTerminalScope(terminalSpec, {}, { spawnSync: childProcessMocks.spawnSync })
+  const owner = scope.bindOwner({ running: () => true, signal: () => true, settled: Promise.resolve() })
+  try {
+    for (const tasks of [1, 2, 0]) {
+      childProcessMocks.spawnSync.mockReturnValue({ status: 0, stdout: `LoadState=loaded\nActiveState=active\nTasksCurrent=${tasks}\n` })
+      expect(owner.inspectTaskCount?.()).toBe(tasks)
+    }
+    for (const response of [
+      { status: 0, stdout: 'LoadState=loaded\nActiveState=active\nTasksCurrent=[not set]\n' },
+      { status: 0, stdout: 'LoadState=not-found\nActiveState=inactive\n' },
+      { status: 0, stdout: 'LoadState=loaded\nActiveState=inactive\n' },
+      { status: 1, stdout: '' },
+      { status: 0, stdout: null },
+      { status: null, error: new Error('manager unavailable') },
+    ]) {
+      childProcessMocks.spawnSync.mockReturnValue(response)
+      expect(owner.inspectTaskCount?.()).toBeUndefined()
+    }
+    childProcessMocks.spawnSync.mockReturnValue({ status: 0, stdout: 'LoadState=loaded\nActiveState=active\nTasksCurrent=invalid\n' })
+    expect(() => owner.inspectTaskCount?.()).toThrow('non-numeric')
+  } finally { scope.cleanup() }
+})

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

@@ -308,6 +308,7 @@ describe('LocalSubprocessRuntime', () => {
       done: Promise.resolve({ exitCode: 0, signal: null }),
       done: Promise.resolve({ exitCode: 0, signal: null }),
       write: async () => {},
       write: async () => {},
       resize: async () => {},
       resize: async () => {},
+      inspectActivity: async () => ({ state: 'unknown' as const, revision: 0 }),
       inspectForeground: async () => undefined,
       inspectForeground: async () => undefined,
       signalForeground: async () => 1,
       signalForeground: async () => 1,
       terminate,
       terminate,
@@ -336,6 +337,7 @@ describe('LocalSubprocessRuntime', () => {
       done: Promise.resolve({ exitCode: 0, signal: null }),
       done: Promise.resolve({ exitCode: 0, signal: null }),
       write: async () => {},
       write: async () => {},
       resize: async () => {},
       resize: async () => {},
+      inspectActivity: async () => ({ state: 'unknown' as const, revision: 0 }),
       inspectForeground: async () => undefined,
       inspectForeground: async () => undefined,
       signalForeground: async () => 1,
       signalForeground: async () => 1,
       terminate: vi.fn(async () => { throw firstFailure }),
       terminate: vi.fn(async () => { throw firstFailure }),
@@ -389,6 +391,7 @@ describe('LocalSubprocessRuntime', () => {
       done: Promise.resolve({ exitCode: 0, signal: null }),
       done: Promise.resolve({ exitCode: 0, signal: null }),
       write: async () => {},
       write: async () => {},
       resize: async () => {},
       resize: async () => {},
+      inspectActivity: async () => ({ state: 'unknown' as const, revision: 0 }),
       inspectForeground: async () => undefined,
       inspectForeground: async () => undefined,
       signalForeground: async () => 1,
       signalForeground: async () => 1,
       terminate: vi.fn(async () => { throw failure }),
       terminate: vi.fn(async () => { throw failure }),

+ 24 - 0
packages/subprocess/subprocess-local/tests/process-inspector.spec.ts

@@ -89,6 +89,30 @@ function fakeInternals() {
 }
 }
 
 
 describe('Linux process inspector', () => {
 describe('Linux process inspector', () => {
+  it.each(['EACCES', 'EMFILE'])('refuses to publish an empty snapshot when /proc enumeration fails with %s', (code) => {
+    const fake = fakeInternals()
+    const readDir = fake.internals.readDir.bind(fake.internals)
+    fake.internals.readDir = () => { throw Object.assign(new Error('Enumeration failed'), { code }) }
+    const inspector = createProcessInspector('linux', 'x64', fake.internals)
+    expect(() => inspector.snapshot()).toThrow('/proc directory is unreadable')
+    fake.internals.readDir = readDir
+    fake.dirs.set('/proc', ['10'])
+    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
+    expect(inspector.snapshot().complete).toBe(true)
+    expect(inspector.snapshot().tree(10)).toEqual([{ pid: 10, started: '500' }])
+  })
+
+  it('makes no stdin-wait claim when process or task directories cannot be enumerated', () => {
+    const fake = fakeInternals()
+    fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500'))
+    fake.links.set('/proc/10/fd/0', '/dev/pts/1')
+    fake.devices.set('/proc/10/fd/0', { character: true, rdev: 99 })
+    const inspector = createProcessInspector('linux', 'x64', fake.internals)
+    expect(inspector.isStdinWaiting(10, 10)).toBe(false)
+    fake.dirs.set('/proc', ['10'])
+    expect(inspector.isStdinWaiting(10, 10)).toBe(false)
+  })
+
   it('treats zombie-only process groups as quiescent and fails closed when unobservable', () => {
   it('treats zombie-only process groups as quiescent and fails closed when unobservable', () => {
     const fake = fakeInternals()
     const fake = fakeInternals()
     expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBeUndefined()
     expect(linuxProcessGroupHasLiveMembers(77, fake.internals)).toBeUndefined()

+ 81 - 0
packages/subprocess/subprocess-local/tests/shell-activity-files.spec.ts

@@ -0,0 +1,81 @@
+/** Private activity records reject stale input evidence and incomplete observations. */
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
+import { prepareShellActivity, ShellActivity } from '../src/shell-activity.ts'
+
+vi.mock('node:fs', async (original) => {
+  const fs = await original<typeof import('node:fs')>()
+  return { ...fs, writeFileSync: vi.fn(fs.writeFileSync) }
+})
+const cleanups: Array<() => void> = []
+afterEach(() => { for (const cleanup of cleanups.splice(0).reverse()) cleanup(); vi.mocked(writeFileSync).mockReset() })
+const spec: SubprocessTerminalSpawnSpec = { argv: ['/bin/zsh', '-i'], cwd: '/', rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 100, shellActivity: true }
+
+it('accepts only fresh complete records from the original shell and advances revisions on input', () => {
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-activity-record-'))
+  cleanups.push(() => { rmSync(directory, { recursive: true, force: true }) })
+  const activity = new ShellActivity(directory, [], {})
+  expect(activity.inspect(123).state).toBe('unknown')
+  const state = join(directory, 'state')
+  writeFileSync(state, '123:1:idle\n')
+  const idle = activity.inspect(123)
+  expect(idle.state).toBe('idle')
+  expect(activity.inspect(123)).toEqual(idle)
+  activity.invalidate()
+  expect(activity.inspect(123)).toEqual({ state: 'unknown', revision: idle.revision + 1 })
+  writeFileSync(state, '123:2:busy')
+  expect(activity.inspect(123).state).toBe('busy')
+  writeFileSync(state, '999:2:idle\n')
+  expect(activity.inspect(123).state).toBe('unknown')
+  writeFileSync(state, '123:3:id')
+  expect(activity.inspect(123).state).toBe('unknown')
+  activity.dispose()
+  expect(activity.inspect(123).state).toBe('unknown')
+})
+
+it('leaves unsupported executables, platforms, and custom launch arguments untouched', () => {
+  expect(prepareShellActivity({ ...spec, argv: ['/bin/fish', '-i'] }, {}, 'darwin')).toBeUndefined()
+  expect(prepareShellActivity({ ...spec, argv: ['/bin/zsh', '-l'] }, {}, 'darwin')).toBeUndefined()
+  expect(prepareShellActivity({ ...spec, argv: ['/bin/zsh', '-l', '-i'] }, {}, 'darwin')).toBeUndefined()
+  expect(prepareShellActivity({ ...spec, shellActivity: false }, {}, 'darwin')).toBeUndefined()
+  expect(prepareShellActivity(spec, {}, 'win32')).toBeUndefined()
+})
+
+it('supports an unset original ZDOTDIR and removes private files on disposal', () => {
+  const activity = prepareShellActivity(spec, {}, 'darwin')!
+  cleanups.push(() => { activity.dispose() })
+  expect(activity.env.ZDOTDIR).toBeDefined()
+  expect(existsSync(activity.env.ZDOTDIR!)).toBe(true)
+  activity.dispose()
+  expect(existsSync(activity.env.ZDOTDIR!)).toBe(false)
+})
+
+it('restores a quoted original ZDOTDIR before sourcing user startup files', () => {
+  const env = Object.freeze({ ZDOTDIR: "/shell config/user's $settings", OTHER: 'preserved' })
+  const activity = prepareShellActivity(spec, env, 'linux')!
+  cleanups.push(() => { activity.dispose() })
+  const directory = activity.env.ZDOTDIR!
+  expect(directory).not.toBe(env.ZDOTDIR)
+  expect(activity.env).toEqual({ ...env, ZDOTDIR: directory })
+  expect(activity.argv).toEqual(spec.argv)
+  expect(readFileSync(join(directory, '.zshenv'), 'utf8').split('\n').slice(0, 2)).toEqual([
+    "ZDOTDIR='/shell config/user'\\''s $settings'",
+    '[[ ! -r ${ZDOTDIR:-$HOME}/.zshenv ]] || builtin source "${ZDOTDIR:-$HOME}/.zshenv"',
+  ])
+  activity.dispose()
+  expect(existsSync(directory)).toBe(false)
+})
+
+it('removes the allocated private directory when writing its startup file fails', () => {
+  let directory: string | undefined
+  vi.mocked(writeFileSync).mockImplementationOnce((path) => {
+    directory = dirname(String(path))
+    throw new Error('disk unavailable')
+  })
+  expect(() => prepareShellActivity(spec, {}, 'darwin')).toThrow('disk unavailable')
+  expect(directory).toBeDefined()
+  expect(existsSync(directory!)).toBe(false)
+})

+ 115 - 0
packages/subprocess/subprocess-local/tests/shell-activity.spec.ts

@@ -0,0 +1,115 @@
+/** Real interactive shells preserve startup files and distinguish prompts from running jobs. */
+import { existsSync } from 'node:fs'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import LocalSubprocessRuntime from '../src/index.ts'
+
+const cleanups: Array<() => Promise<void>> = []
+afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
+
+async function shell(path: string, rc = '', envFile = '') {
+  const home = await mkdtemp(join(tmpdir(), 'dsh-shell-activity-test-'))
+  cleanups.push(() => rm(home, { recursive: true, force: true }))
+  await writeFile(join(home, '.zshenv'), envFile)
+  await writeFile(join(home, '.zshrc'), `PROMPT='READY> '\n${rc}\n`)
+  await writeFile(join(home, '.bashrc'), `PS1='READY> '\n${rc}\n`)
+  const ctx = new Context()
+  cleanups.push(async () => { await ctx.fiber.dispose() })
+  await ctx.plugin(LocalSubprocessRuntime)
+  const handle = await ctx.subprocess.spawnTerminal({ argv: [path, '-i'], cwd: home, env: { HOME: home, ZDOTDIR: home }, rows: 24, cols: 80, terminalType: 'xterm-256color', graceMs: 200, shellActivity: true })
+  cleanups.push(() => handle.terminate())
+  let output = ''
+  handle.output.on('data', (data: Buffer) => { output += data.toString('utf8') })
+  await expect.poll(() => output).toContain('READY>')
+  return { handle, home, output: () => output, activity: async () => (await handle.inspectActivity()).state }
+}
+
+describe.skipIf(process.platform === 'win32' || !existsSync('/bin/zsh'))('Zsh terminal activity', () => {
+  it('keeps silent foreground work, builtin loops, read, and background jobs busy until a new prompt is idle', async () => {
+    const h = await shell('/bin/zsh')
+    await expect.poll(h.activity).toBe('idle')
+    for (const command of ['sleep 600', 'while :; do :; done', 'read answer', 'sleep 600 &']) {
+      await h.handle.write(`${command}\r`)
+      await expect.poll(h.activity).toBe('busy')
+      await h.handle.write(command.endsWith('&') ? 'kill %1\r' : '\x03')
+      await expect.poll(h.activity).toBe('idle')
+    }
+  })
+
+  it('does not confuse vared or multiline editing with a top-level empty prompt', async () => {
+    const h = await shell('/bin/zsh')
+    await expect.poll(h.activity).toBe('idle')
+    await h.handle.write('value=abc; vared value\r')
+    await expect.poll(h.activity).toBe('busy')
+    await h.handle.write('\x03')
+    await expect.poll(h.activity).toBe('idle')
+    await h.handle.write('if true; then\r')
+    await expect.poll(h.activity).toBe('busy')
+    await h.handle.write('fi\r')
+    await expect.poll(h.activity).toBe('idle')
+    await h.handle.write('partial')
+    expect(await h.activity()).toBe('unknown')
+  })
+
+  it('preserves startup options and ZDOTDIR and supports noclobber status updates', async () => {
+    const h = await shell('/bin/zsh', 'setopt noclobber\nprint RC-LOADED', 'print ENV-LOADED')
+    expect(h.output()).toContain('ENV-LOADED')
+    expect(h.output()).toContain('RC-LOADED')
+    await expect.poll(h.activity).toBe('idle')
+    await h.handle.write('print -r -- "DIRECTORY:$ZDOTDIR"\r')
+    await expect.poll(() => h.output()).toContain(`DIRECTORY:${h.home}`)
+    await expect.poll(h.activity).toBe('idle')
+    expect(h.output()).not.toContain('file exists')
+  })
+
+  it('protects a stopped job even after the shell returns to the prompt', async () => {
+    const h = await shell('/bin/zsh')
+    await expect.poll(h.activity).toBe('idle')
+    await h.handle.write('sleep 600\r')
+    await expect.poll(h.activity).toBe('busy')
+    await h.handle.write('\x1a')
+    await expect.poll(() => h.output()).toContain('suspended')
+    expect(await h.activity()).toBe('busy')
+    await h.handle.write('kill -KILL %1\r')
+    await expect.poll(h.activity).toBe('idle')
+  })
+
+  it('refuses idle evidence when custom signal traps may run without terminal input', async () => {
+    const h = await shell('/bin/zsh', "trap ':' USR1")
+    expect(await h.activity()).toBe('unknown')
+    await h.handle.write('trap - USR1\r')
+    await expect.poll(h.activity).toBe('idle')
+  })
+
+  it('leaves disabled startup files disabled and restores ZDOTDIR immediately', async () => {
+    const h = await shell('/bin/zsh', 'print UNEXPECTED-RC', "unsetopt rcs\nPROMPT='READY> '")
+    expect(h.output()).not.toContain('UNEXPECTED-RC')
+    await h.handle.write('print -r -- "DIRECTORY:$ZDOTDIR"\r')
+    await expect.poll(() => h.output()).toContain(`DIRECTORY:${h.home}`)
+  })
+})
+
+describe.skipIf(process.platform === 'win32' || !existsSync('/bin/bash'))('Bash terminal activity', () => {
+  it('preserves scalar prompt hooks and uses unknown on shells without PS0', async () => {
+    const h = await shell('bash', "PROMPT_COMMAND='printf HOOK; # user comment'\nset -C")
+    expect(h.output()).toContain('HOOK')
+    await h.handle.write('printf "VERSION:%s\\n" "$BASH_VERSION"\r')
+    await expect.poll(() => h.output()).toContain('VERSION:')
+    await expect.poll(() => /VERSION:\d+\.\d+/u.test(h.output())).toBe(true)
+    const version = /VERSION:(\d+)\.(\d+)/u.exec(h.output())
+    const supported = Number(version?.[1]) > 4 || Number(version?.[1]) === 4 && Number(version?.[2]) >= 4
+    if (!supported) { expect(await h.activity()).toBe('unknown'); return }
+    await expect.poll(h.activity).toBe('idle')
+    for (const command of ['sleep 600', 'while :; do :; done', 'read answer']) {
+      await h.handle.write(`${command}\r`)
+      await expect.poll(h.activity, { message: command }).toBe('busy')
+      await h.handle.write('\x03')
+      await expect.poll(h.activity).toBe('idle')
+    }
+    expect(h.output()).not.toContain('syntax error')
+    expect(h.output()).not.toContain('file exists')
+  })
+})

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