diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
index 94b8f7c295..0a33e19e2c 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-client-session-references.md
-2026-09-15-client-session-references.md: 00f87cfb6f358218060a72199888690c3e46a091
-2026-09-15-client-session-references.zh.md: 7947a8a405d295eb6324ac33f06c0f2bc062c91b
+2026-09-15-client-session-references.md: 162ef08875e5b0591b3b4099c9bdcadb18e7d175
+2026-09-15-client-session-references.zh.md: 3af8932f7c002b3b63de21c689ae91e3017a164e
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
index 00f87cfb6f..162ef08875 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
@@ -73,7 +73,7 @@ The Controller owns source counts alongside each live generation's references. F
Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them.
-The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
+The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` contains Host summaries, projected child-catalog rows, and fallback rows for retained subagents with a known direct-parent address. Acquisition publishes a missing subagent row synchronously so Provider discovery does not depend on parent-catalog arrival; title projections and reference-source counts continue to update that row. Ordinary retained Sessions do not synthesize fallback rows. `ids` remains the Host-list membership and ordering. Consumers of an unlisted generation use its binding and `retainInfo` directly. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum.
@@ -141,7 +141,7 @@ Scoped business objects capture their binding before awaiting work. They do not
### Main-area navigation and presentation
-`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
+`uiWorkspace.openSession`, `openWorkspace`, and `startSession` are the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. Sidebar `forkSession` creates and renames the child without retaining it or changing selection. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup.
@@ -160,7 +160,7 @@ Source metadata does not change when DOM focus moves or when a global panel hide
Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler.
-Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
+Workspace connection preserves its navigation guards and panel-switch invalidation. Sidebar fork does not supersede an in-flight navigation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
### Presets and creation flows
@@ -221,6 +221,6 @@ Consumers must keep references until their real completion point; an unreleased
Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values.
-Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation.
+Every public `retain`, including the temporary reference used by ordinary Session rename, starts the generation's shared initial history opening. Fork-title assignment instead sends rename directly and applies its returned projection without a Client reference or history I/O. A metadata-only fork operation therefore cannot create a temporary list row or switch the main view.
The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator.
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
index 7947a8a405..3af8932f7c 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
@@ -73,7 +73,7 @@ Controller 将来源计数与每个活跃代的引用共同管理。每个来源
每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。
-即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
+即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 包含 Host 摘要、子会话目录投影行,以及已保留且具有明确直接父子地址的 subagent 兜底行。获取引用时同步发布缺失的 subagent 行,使 Provider 发现不依赖父目录到达;标题投影和引用来源计数继续更新该行。普通被保留 Session 不合成兜底行。`ids` 仍表示 Host 列表成员关系与顺序。未列出 generation 的使用方直接读取其 binding 与 `retainInfo`。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。
@@ -141,7 +141,7 @@ Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或
### 主区域导航与展示
-`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
+`uiWorkspace.openSession`、`openWorkspace` 和 `startSession` 是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。侧边栏 `forkSession` 创建并重命名子会话,不 retain 子会话,也不改变选择。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。
@@ -160,7 +160,7 @@ DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据
Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。
-Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
+Workspace 连接保持既有导航检查与面板切换失效规则。侧边栏 fork 不替代尚未完成的导航。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
### 预设与创建流程
@@ -221,6 +221,6 @@ Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Con
最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。
-每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。
+每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括普通 Session 重命名所用的临时引用。Fork 标题设置直接发送 rename 并应用返回的投影,不获取 Client 引用,也不读取历史。因此,仅修改元数据的 fork 操作不会创建列表临时行或切换主视图。
目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。
diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
index af7fc09ddb..cfbf30d80a 100644
--- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
-2026-09-09-web-sidebar-terminal.md: 5757aed3702dbcc7752e99912714bd1356a96206
-2026-09-09-web-sidebar-terminal.zh.md: 39870d1ba26cbc1d655b35a3d2dba61692d0fdb9
+2026-09-09-web-sidebar-terminal.md: 5ed920d744f847cc97b08645592f0be221f18b8d
+2026-09-09-web-sidebar-terminal.zh.md: c3ceb0214c2f4db42b2fefb8b65619e198d9dc7e
diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
index 5757aed370..5ed920d744 100644
--- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
+++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.md
@@ -16,7 +16,7 @@ The application theme supplies terminal default colors. The body reads resolved
`api-terminal-controller` owns user terminals by Session and exposes the `terminal` Remote namespace. `ui-sidebar-terminal` registers native right-sidebar tabs, xterm.js rendering and FitAddon sizing. The terminal guide card has a primary action for the remembered available shell and a separate installed-shell menu. Selecting a menu item records its path and opens a new terminal immediately; discovery alone allocates no process. Each tab owns its startup and close lifecycle. Host discovery verifies the configured candidates, with the execution default first; creation accepts only a currently discovered path. The browser remembers the last selected shell path in origin-scoped localStorage and falls back to the current default if that path is unavailable. The terminal type declares independent instances, so ordinary page deduplication cannot collapse separate processes when opening or docking tabs. The existing sidebar controls open additional tabs; double-clicking a tab title renames its terminal. Terminal processes use the composed subprocess provider; [user-terminal permissions](../architecture/2026-09-16-user-terminal-permissions.md) govern their execution permissions independently of the Agent. Shell resolution occurs during discovery and creation; reading limits and reconnecting an existing process do not depend on the default executable remaining available. Interactive shell configuration supplies Tab completion and optional inline suggestions.
-Close and replacement remove the tab synchronously and run process cleanup in the background. The Client first records the unfinished close request under a terminal-specific localStorage key; success removes it, and startup retries requests that remain. A cleanup failure produces a lightweight notification with a retry action without reopening the tab. Independent keys prevent another window from overwriting unrelated cleanup requests. Collapse, tab/Session switching, floating, fullscreen and browser disconnection preserve the process. Component cleanup and `TabDomain.signal` only detach browser work because the same lifetime can end during plugin reload. Failed process cleanup retains ownership, including failures after allocation but before create publication. Session owner disposal and Host plugin disposal also clean up terminals. A definitive missing-Session response retires its saved close request because the Session owns process cleanup; transport failures remain retryable. Client plugin disposal awaits every detached stream so a replacement plugin does not inherit unfinished Client cleanup.
+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. Cleanup failures have no notification or manual retry action. Host terminals missing from the saved layout are not reopened automatically and have no UI recovery entry. 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 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.
diff --git a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
index 39870d1ba2..c3ceb0214c 100644
--- a/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-09-web-sidebar-terminal.zh.md
@@ -16,7 +16,7 @@ Web 用户需要在 Session 旁使用交互式 shell 检查工作区和运行命
`api-terminal-controller` 按 Session 管理用户终端并提供 `terminal` Remote namespace。`ui-sidebar-terminal` 注册原生右侧栏标签页,使用 xterm.js 渲染和 FitAddon 测量尺寸。终端开始页卡片的主操作打开上次选择且仍可用的 shell,独立菜单提供已安装 shell。选择菜单项会记录路径并立即打开新终端;仅探测 shell 不分配进程。每个标签页拥有自己的启动和关闭生命周期。Host 探测会验证配置的候选,并把执行环境默认项放在首位;创建只接受当前探测返回的路径。浏览器在当前站点 localStorage 中记住上次选择的 shell 路径,该路径不可用时回到当前默认项。终端类型声明独立实例,因此打开或停靠标签页时,普通页面的去重规则不会合并不同进程。已有侧栏控件负责打开更多标签页,双击标签页标题可重命名终端。终端进程使用组合的 subprocess provider;[用户终端权限](../architecture/2026-09-16-user-terminal-permissions.zh.md)规定其独立于 Agent 的执行权限。shell 在探测和创建时解析;读取限制和重新连接已有进程不依赖默认可执行文件仍然可用。交互式 shell 配置提供 Tab 补全和可选的内联建议。
-关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败时显示带重试操作的轻量通知,不重新打开标签页。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
+关闭和替换会同步移除标签页,并在后台清理进程。Client 先以终端独立的 localStorage key 保存未完成的关闭请求;成功后删除,启动时重试剩余请求。清理失败没有通知或手动重试操作。保存布局之外的 Host 终端不会自动重新打开,也没有 UI 恢复入口。独立 key 避免其他窗口覆盖无关的清理请求。折叠、切换标签页或 Session、浮动、全屏和浏览器断线均保留进程。组件清理和 `TabDomain.signal` 只停止浏览器工作,因为插件重新加载也会结束这些生命周期。进程清理失败时保留所有权,包括分配完成但 create 尚未发布时的失败。Session owner 和 Host 插件卸载也会清理终端。 明确的 Session 不存在响应会清除已保存的关闭请求,因为进程清理由 Session 负责;传输失败仍可重试。Client 插件卸载等待所有断开的流结束,避免替换插件继承未完成的 Client 清理。
[侧栏布局持久化与 provider 恢复](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md)负责浏览器刷新:先恢复布局和标签身份,再由 terminal provider 重连视图。列表仍直接使用 Session ID,因为历史记录可以比 Agent 和终端 owner 存活更久;离线 Session 没有需要恢复的保留终端。只有新视图可以分配进程,恢复目标消失时显示错误。进程状态、标题和屏幕内容仍以 Host 为准。
diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml
index 2d995ebcf4..b793133d0f 100644
--- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
-2026-09-18-session-pin-and-sidebar-archive.md: eb0c82ba84b1ebc94b23bff4d613ec6de9565d71
-2026-09-18-session-pin-and-sidebar-archive.zh.md: d971c7b4ff2b6de6a0f49c41974b7654843b29ea
+2026-09-18-session-pin-and-sidebar-archive.md: 262f7b76d6ac7eee21a646eedc93b7d936c86675
+2026-09-18-session-pin-and-sidebar-archive.zh.md: 86ddf4b58781c5974b93562396c3205cfdd0f9dc
diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
index eb0c82ba84..262f7b76d6 100644
--- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
+++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md
@@ -18,7 +18,7 @@ The `dsh-workspace` registry stores registry-global pin and archive sets as Sess
Each browser-local account has one complete Session sequence, including pinned and archived members. Workspace membership bounds each account; registry-global pin and archive sets never add a Session to an unrelated Workspace. Grouped, Ungrouped, and flat views use the same rules.
-- Existing members retain their positions. Missing pinned members prepend in the pin array's order. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never interleaves existing positions or duplicates an id.
+- Existing members retain their positions. Missing pinned members prepend in the pin array's order. New ordinary forks enter immediately before their source, including nested forks, without inheriting its pin. Saved members keep their relative order, and visible pinned rows still lead ordinary forks. Other missing members append by descending Session `updatedAt`, with missing archived members last. Supplementation never duplicates an id.
- Supplementation is an in-memory projection, not an automatic storage migration. A Session-order write records the complete supplemented sequence, including hidden archived rows. Filtering alone never rewrites Session positions.
- A successful Pin action marks the Session pinned and moves its position to the front of its complete account sequence. Completion uses current saved positions and the latest Workspace membership, preserving reorders completed while the request was pending. Unpin removes the mark without restoring an earlier position. Both pinned and ordinary drags edit the same complete sequence; display indices never replace its hidden members.
- The current blank New Session precedes both partitions; other pinned Sessions lead unpinned Sessions. Manual preserves the complete sequence's relative order within both partitions. Last updated sorts both partitions strictly by descending `updatedAt`, with Session id as the tie-break. Pin records carry only Session identities, not timestamps.
diff --git a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md
index d971c7b4ff..86ddf4b587 100644
--- a/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md
@@ -18,7 +18,7 @@ Status: implemented
每份浏览器本地排序记录包含一条完整会话序列,包括置顶和归档成员。每份序列受 Workspace 成员关系约束,全局置顶与归档集合不会把会话补进无关的 Workspace。分组、Ungrouped 和平铺视图使用同一套规则。
-- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不穿插已有位置,也不重复加入同一个 id。
+- 已有成员保留位置。缺失的置顶成员按置顶数组中的顺序补到头部。新增的普通 fork 紧邻来源之前插入,包括连续 fork,不继承来源的置顶。已保存成员保持相对顺序,可见置顶行仍排在普通 fork 之前。其他缺失成员按会话 `updatedAt` 降序追加,缺失的归档成员放在最后。补齐不重复加入同一个 id。
- 补齐是内存派生,不自动迁移存储。一次会话顺序写入保存补齐后的完整序列,包括隐藏的归档行。单纯切换筛选不重写会话位置。
- 置顶成功后,会话被标记为置顶,同时移到所属完整序列的首位。完成时使用当前保存的位置与最新 Workspace 成员关系,保留请求等待期间完成的重排序。取消置顶只移除标记,不恢复更早的位置。置顶行与普通行的拖拽都修改同一条完整序列,显示下标不会覆盖其中的隐藏成员。
- 当前空白新会话排在两个分区之前,其余会话再按置顶区、非置顶区排列。手动模式下,两区分别保持完整序列中的相对顺序。最近更新模式下,两区都严格按 `updatedAt` 降序排序,以会话 id 打破平局。置顶记录只包含会话身份,不包含时间戳。
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
index b85c97a4b8..cd6b1146aa 100644
--- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
-2026-09-03-minimal-profiles-persistent-shell-only.md: d58210cb890e4264b02d591f213045b592ca6bfc
-2026-09-03-minimal-profiles-persistent-shell-only.zh.md: 481ef479c2dc01ae503f621df976529aeb6bdbe0
+2026-09-03-minimal-profiles-persistent-shell-only.md: 2bc4ef7ada0767d96c0ac448e19e13e8e06a945c
+2026-09-03-minimal-profiles-persistent-shell-only.zh.md: d54e5734ac04825e2d28f59d9104a01b5caf9fb7
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
index d58210cb89..2bc4ef7ada 100644
--- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md
@@ -16,7 +16,7 @@ The shipped minimal compositions expose exactly one platform-selected persistent
The standalone editor package remains available for explicit custom compositions. A trusted user-authored preset or higher profile patch must insert the editor into the Cordis tree with a filesystem provider in the same service scope; the shipped `minimal` and `sdk-minimal` defaults never insert it. The [Python SDK guide](../../../../docs/user/guide/python-sdk.md#opt-in-to-str_replace_editor) provides an executable patch example.
-The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior; the persistent PowerShell description and output remain unchanged.
+The shared [persistent Bash consumer](../../../../packages/shell/tool-bash-persistent/README.md#model-experience) uses the one-shot shell's command-status wording while retaining its persistent state. Settled commands append `[Command finished with exit code N]`, including success; timeout output includes `[Command timed out or OOM]` and the shell-reset notice. Trailing newlines are removed before the status trailer. Both minimal Bash descriptions state that network access depends on the task environment. Explicit compositions using this consumer share its output behavior. Persistent Bash and PowerShell settle cancelled commands only after shell cleanup; the existing ToolRuntime then publishes `ABORTED` instead of stringifying the user's cancellation object. Queued cancelled calls send no command, and execution or cleanup failures retain their errors.
Exact composition tests assert the single tool and the absence of a preset-local filesystem service. The `sdk-minimal` bundle test and built config dump assert that its row and dependency allowlists contain neither `fs-local` nor `dsh-tool-str-replace-editor`. Web and packaged-Python model-visible snapshots pin the one-tool schema roster. SDK profile smoke tests execute the guide's editor patch and verify file creation and viewing.
diff --git a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
index 481ef479c2..d54e5734ac 100644
--- a/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
+++ b/.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md
@@ -16,7 +16,7 @@ Status: implemented
独立 editor 包仍可用于显式自定义组合。受信任的用户自定义 preset 或更高优先级的 profile patch 必须将 editor 插入 Cordis tree,并在同一服务作用域内提供文件系统后端;随附的 `minimal` 与 `sdk-minimal` 默认组合不会插入它。[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md#opt-in-to-str_replace_editor)提供可执行的 patch 示例。
-共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为;持久 PowerShell 的描述与输出保持不变。
+共享的[持久 Bash 消费方](../../../../packages/shell/tool-bash-persistent/README.zh.md#model-experience)保留跨调用状态,并采用单次 shell 的命令状态文案。完成的命令追加 `[Command finished with exit code N]`,成功时也包含该标记;超时输出包含 `[Command timed out or OOM]` 和 shell 重置说明。追加状态标记前会移除输出末尾的全部换行。两份极简 Bash 描述都说明网络访问取决于任务环境。使用该消费方的显式组合共享相同的输出行为。持久 Bash 和 PowerShell 在 shell 清理完成后才结束已取消命令,由现有 ToolRuntime 发布 `ABORTED`,不把用户取消对象转成字符串。排队期间被取消的调用不发送命令,执行或清理失败仍保留各自的错误。
精确组合测试会断言单工具清单以及 preset 内不存在文件系统服务。`sdk-minimal` bundle 测试与构建后配置转储会断言配置项和依赖 allowlist 都不含 `fs-local` 或 `dsh-tool-str-replace-editor`。Web 与打包 Python 的模型可见快照会固定单工具 schema 清单。SDK profile 冒烟测试会执行指南中的 editor patch,并验证文件创建和查看。
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index 2530ec0497..fd4a0b2e9c 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -396,9 +396,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
// The child row is published before its inherited title rename settles;
// wait for that second RPC projection before freezing the ARIA tree.
await expect.poll(
- () => page.locator('[role="treeitem"][aria-selected="true"]').textContent(),
+ () => page.locator('[role="treeitem"]').allTextContents(),
{ timeout: 10_000 },
- ).toContain('Use the read tool twice (2)')
+ ).toEqual(expect.arrayContaining([expect.stringContaining('Use the read tool twice (2)')]))
+ expect(await sourceRow.textContent()).toContain('Use the read tool twice (1)')
const tree = await captureStableAria(
page,
'[role="tree"][aria-label="Sessions"]',
diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts
index b9b436b27a..3ace6ed5df 100644
--- a/apps/web/tests/sidebar-terminal.e2e.ts
+++ b/apps/web/tests/sidebar-terminal.e2e.ts
@@ -409,6 +409,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
await openTerminal(page)
const firstTab = await page.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
const firstProcess = processIdentity(0)
+ // Busy activity isolates layout recovery from unattended idle reclamation.
+ vi.spyOn(handles[0]!, 'inspectActivity').mockResolvedValue({ state: 'busy', revision: 1 })
await command(page, "printf 'WINDOW_A\\n'")
await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
await openTerminal(second)
@@ -433,11 +435,11 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
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)
+ await page.getByText('Ready for terminal input.').waitFor()
+ // The second window saved an empty layout; disabled recovery does not recreate the first window's tab.
+ expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
expect(alive(firstProcess)).toBe(true)
+ expect(handles).toHaveLength(2)
expect(tripwire.pageErrors).toEqual([])
expect(secondErrors.pageErrors).toEqual([])
})
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 42f630c481..106e1545fb 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 316670a432a2f608140d275aa1a69c11febef254
-config-catalog.zh.md: bc16d612f0f699fab61728c068c1d237b4b54c14
+config-catalog.md: dad6b57ae0712e04ad16729b4067c541f83c68d6
+config-catalog.zh.md: 4ba0378810f3db4797c0d41fa5230f7069438b3f
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 316670a432..dad6b57ae0 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -3058,7 +3058,7 @@ export interface Config {
}
```
-Source: [`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts)
+Source: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts)
@@ -3239,7 +3239,7 @@ export interface Config {
}
```
-Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts)
+Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index bc16d612f0..4ba0378810 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -3060,7 +3060,7 @@ export interface Config {
}
```
-来源:[`packages/shell/tool-bash-persistent/src/index.ts:435`](../packages/shell/tool-bash-persistent/src/index.ts)
+来源:[`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts)
@@ -3241,7 +3241,7 @@ export interface Config {
}
```
-来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts)
+来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:480`](../packages/shell/tool-pwsh-persistent/src/index.ts)
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 8ceb4a8da4..c09ac33888 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 228ed88ed20b9f9581496468e47079afd1542e4f
-README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a
+README.md: 684812717d29fae36f359f82a4a4b6a693956947
+README.zh.md: 7bd4730f93612170231c66ba04a338ef216111c6
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 228ed88ed2..684812717d 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -59,7 +59,7 @@ Queue edits replace pending content with non-empty text only.
`sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures.
-References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules.
+References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules.
## Session media references
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index edebf8bdda..7bd4730f93 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -59,7 +59,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
`sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。
-引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。
+引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。
## 会话媒体引用
diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts
index b6061ce2b4..f46b6cc8fe 100644
--- a/packages/api/session-controller/src/client/sessions/manager.ts
+++ b/packages/api/session-controller/src/client/sessions/manager.ts
@@ -7,6 +7,7 @@ import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type {
SessionControlBaseline,
SessionControlFrame,
+ SessionRenameValue,
SessionSummary,
SessionJob as JobView,
} from '../../types.ts'
@@ -502,6 +503,20 @@ export class SessionManager {
return result
}
+ /**
+ * Rename a Session and update its title projection without opening its history.
+ * @param sessionId - Session to rename.
+ * @param title - raw title text for Host normalization.
+ * @returns the accepted title and event position, or the Remote failure.
+ */
+ async rename(sessionId: SessionId, title: string): Promise> {
+ const result = await this.remote.session.rename({ sessionId, title })
+ if (result.ok) {
+ this.projectionStore(sessionId).apply('title', result.value.title, SessionSeq(result.value.seq))
+ }
+ return result
+ }
+
/**
* Merge a Host summary, replacing live state and filling missing metadata.
* Local create/fork placeholders only fill metadata on an existing row.
diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts
index a44c4147d9..28b22c796a 100644
--- a/packages/api/session-controller/src/client/sessions/service.ts
+++ b/packages/api/session-controller/src/client/sessions/service.ts
@@ -54,7 +54,7 @@ export interface SessionSummary {
export interface SessionListState {
/** Host list order; every id has a matching byId row in the same snapshot. */
ids: SessionId[]
- /** Host/catalog rows plus local fallback rows for live Client generations; only `ids` expresses Host-list membership. */
+ /** Host/catalog rows plus retained subagent fallbacks; only `ids` expresses Host-list membership. */
byId: Record
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
@@ -470,14 +470,8 @@ export class ClientSessions implements ISessions {
this.projectList()
const childId = result.value.sessionId
if (sourceTitle !== undefined) {
- const reference = this.retain(childId, { source: 'controllerOperation' })
- try {
- await reference.ready
- const renamed = await reference.binding.session.rename(increasedForkTitle(sourceTitle))
- if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
- } finally {
- reference.release()
- }
+ const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
+ if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
}
@@ -558,7 +552,9 @@ export class ClientSessions implements ISessions {
if (count === 0) this.retireScope(id, record)
else this.publishRetention(id)
})
- if (this.list.getSnapshot().byId[id] === undefined) this.projectList()
+ if (this.list.getSnapshot().byId[id] === undefined && this.manager.subagentAddress(id) !== undefined) {
+ this.projectList()
+ }
this.publishRetention(id)
return reference
}
@@ -666,15 +662,22 @@ export class ClientSessions implements ISessions {
}
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
+ const address = this.manager.subagentAddress(id)
+ if (address === undefined) continue
const previous = previousById[id]
const snapshot = record.session.getSnapshot()
- const address = this.manager.subagentAddress(id)
+ const projectionValues = this.manager.projectionValues(id)
+ const projectedTitle = projectionValues?.title
+ const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : previous?.title
byId[id] = {
...(previous ?? { id, displayTitle: id, updatedAt: 0 }),
running: snapshot.running,
retainedBy: record.retention.retainedBy,
blank: snapshot.blank,
- ...(address === undefined ? {} : { parentId: address.parentSessionId, origin: 'subagent' }),
+ parentId: address.parentSessionId,
+ origin: 'subagent',
+ ...(projectionValues === undefined ? {} : { projectionValues }),
+ ...(title === undefined ? {} : { title, displayTitle: title }),
}
}
this.list.set({ ids, byId, phase, projectionsBySession, jobsBySession })
diff --git a/packages/api/session-controller/tests/reference-ownership.client.spec.ts b/packages/api/session-controller/tests/reference-ownership.client.spec.ts
index 25253dfc24..16de92ef02 100644
--- a/packages/api/session-controller/tests/reference-ownership.client.spec.ts
+++ b/packages/api/session-controller/tests/reference-ownership.client.spec.ts
@@ -119,7 +119,7 @@ describe('Client reference sources', () => {
expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
b.svc.handleSessionRemoved(ID)
await vi.waitFor(() => {
- expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
+ expect(b.svc.list.getSnapshot().byId[ID]).toBeUndefined()
})
expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
expect(source.getSnapshot()).toBe(snapshot)
@@ -138,7 +138,7 @@ describe('Client reference sources', () => {
expect(reference.binding.session.getSnapshot().openState).toBe('cold')
expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { gateway: 1 } })
expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
- expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
+ expect(b.svc.list.getSnapshot().byId[ID]).toBeUndefined()
expect(mock.log.requests(FOLLOW)).toHaveLength(0)
expect(mock.remote.session.projections).not.toHaveBeenCalled()
await b.feed(true)
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index fa0d65f857..e2f87caa29 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -934,6 +934,9 @@ describe('fork', () => {
expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: 'source', atSeq: 7 })
expect(b.mock.remote.session.rename).toHaveBeenCalledExactlyOnceWith({ sessionId: 'child', title: childTitle })
+ expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
+ expect(b.svc.binding(sid('child'))).toBeUndefined()
+ expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
await Promise.resolve()
expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({
title: childTitle,
@@ -964,7 +967,7 @@ describe('fork', () => {
expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
})
- it('rejects child rename failure while preserving its catalog row and releasing the operation', async ({ bench }) => {
+ it('rejects child rename failure while preserving its catalog row without retaining the child', async ({ bench }) => {
const b = bench()
b.svc.handleControlFrame({
type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
@@ -978,11 +981,12 @@ describe('fork', () => {
expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
+ expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
})
})
describe('catalog arrival', () => {
- it('keeps a retained generation indexed after its Host row is removed', async ({ bench }) => {
+ it('keeps a retained binding alive without a catalog row after Host removal', async ({ bench }) => {
const b = bench()
b.svc.handleSessionAdded({ agentAvailable: true, sessionId: sid('s-new'), updatedAt: 1, running: false, blank: true })
await Promise.resolve()
@@ -993,9 +997,11 @@ describe('catalog arrival', () => {
b.svc.handleSessionRemoved(sid('s-new'))
await Promise.resolve()
expect(b.svc.list.getSnapshot().ids).not.toContain(sid('s-new'))
- expect(b.svc.list.getSnapshot().byId[sid('s-new')]?.retainedBy).toEqual({ controllerOperation: 1 })
+ expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toBeUndefined()
+ expect(b.svc.retainInfo(sid('s-new')).getSnapshot().referenceCount).toBe(1)
expect(b.svc.binding(sid('s-new'))).toBe(binding)
reference.release()
+ expect(b.svc.binding(sid('s-new'))).toBeUndefined()
expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toBeUndefined()
})
})
diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts
index cc8fe2ebfe..434b233d1e 100644
--- a/packages/boot/app-boot/src/package-meta.ts
+++ b/packages/boot/app-boot/src/package-meta.ts
@@ -58,7 +58,7 @@ function textOf(value: unknown, path: string): string | undefined {
function readObject(file: string): Record {
let contents: unknown
try {
- contents = JSON.parse(readFileSync(file, 'utf8')) as unknown
+ contents = JSON.parse(readFileSync(file, 'utf8'))
} catch (error) {
throw new Error(`${file}: ${String(error)}`)
}
diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
index 0737596fb5..2226aa0476 100644
--- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
+++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx
@@ -8,7 +8,7 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ReactNode } from 'react'
import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx'
-import type { PluginManagerPageProps } from '../src/client/PluginManagerPage.tsx'
+import type { PluginManagerPageProps } from '../src/client/index.ts'
import type { ConfigLedger } from '../src/client/config-ledger.ts'
import { rowKey, type InstallState, type PackageRow, type PackageView, type PluginManagerState } from '../src/client/manager-store.ts'
import { en, zh, type PluginManagerLocaleKey } from '../src/client/locales.ts'
@@ -101,15 +101,20 @@ function renderTab(state: Partial = {}, config: Partial {
+ if (!('view' in owner) || (owner.view !== 'summary' && owner.view !== 'page')) {
+ throw new Error('Configuration slot requires a summary or page view')
+ }
+ return bodies[`${name}:${opts?.only ?? opts?.entryKey ?? ''}`]?.(owner.view) ?? null
+ }
const props = {
t,
resolveText,
...actions,
usePluginManager: bindSnapshotSelector(store),
useConfigLedger: bindSnapshotSelector(ledger),
- renderSlot: (name: string, owner: { view: 'summary' | 'page' }, opts: { only?: string; entryKey?: string }) =>
- bodies[`${name}:${opts.only ?? opts.entryKey ?? ''}`]?.(owner.view) ?? null,
- } as unknown as PluginManagerPageProps
+ renderSlot,
+ } as PluginManagerPageProps
const { rerender } = render()
return {
store,
diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml
index 03b356e547..646ecb0d4d 100644
--- a/packages/client/ui-session/README.i18n.yaml
+++ b/packages/client/ui-session/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-session/README.md
-README.md: 22ece0c11eee6b2a59acbbd8c5a9650b593a7da4
-README.zh.md: 4b52bef28ad065a7ff10f468056723c4d301a0a0
+README.md: df85bf42b0eb6507662ab4e4a26d1fde6e08872e
+README.zh.md: 5f54c1db7b5daf2d113717d890dc90eeb2be87f9
diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md
index 22ece0c11e..df85bf42b0 100644
--- a/packages/client/ui-session/README.md
+++ b/packages/client/ui-session/README.md
@@ -10,7 +10,7 @@ English | [中文](README.zh.md)
Use this package to expose Session catalog, retain information, and unified UI status through standard Slot hooks. It materializes per-`SessionBinding` hooks and props, while `SessionProvider` can inherit an outer binding or bind an explicit `SessionReference`. It owns process-local pending-interaction and completion-reminder policy without owning Controller transport, history, or references.
-Running status comes from Host list baselines or status events. Catalog rows and local fallback rows do not establish running status; main view references still acknowledge completions.
+Running status comes from Host list baselines or status events. Subagent catalog rows and retained subagent fallback rows do not establish running status; main view references still acknowledge completions.
## Table of Contents
diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md
index 4b52bef28a..5f54c1db7b 100644
--- a/packages/client/ui-session/README.zh.md
+++ b/packages/client/ui-session/README.zh.md
@@ -10,7 +10,7 @@ kind: "package-reference"
使用本包可通过标准 Slot 钩子公开 Session catalog、retain 信息与统一 UI 状态。它按 `SessionBinding` 物化钩子和 prop,而 `SessionProvider` 可以继承外围 binding 或绑定显式 `SessionReference`。它拥有进程本地的 pending-interaction 与完成提醒策略,但不拥有 Controller transport、历史或 reference。
-运行状态来自 Host 列表基线或状态事件。目录和保留代际的 fallback 行不会建立运行状态;主视图引用仍会确认完成提醒。
+运行状态来自 Host 列表基线或状态事件。Subagent 目录行和已保留子会话的 fallback 行不会建立运行状态;主视图引用仍会确认完成提醒。
## 目录
diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts
index 1f94bce4bb..5017904d86 100644
--- a/packages/client/ui-session/src/client/index.ts
+++ b/packages/client/ui-session/src/client/index.ts
@@ -493,7 +493,7 @@ export class UiSession extends Service {
private reconcileStatus(): void {
const list = this.sessions.list.getSnapshot()
const present = new Set(Object.keys(list.byId) as SessionId[])
- // Rows from catalogs and retained Client bindings do not establish Host running state.
+ // Subagent catalog rows and retained subagent fallback rows do not establish Host running state.
for (const id of list.ids) {
const row = list.byId[id] as SessionSummary
const previous = this.running.get(id)
diff --git a/packages/client/ui-sidebar-terminal/README.i18n.yaml b/packages/client/ui-sidebar-terminal/README.i18n.yaml
index 58594b2566..75645087ed 100644
--- a/packages/client/ui-sidebar-terminal/README.i18n.yaml
+++ b/packages/client/ui-sidebar-terminal/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-terminal/README.md
-README.md: a874eb3ce4fed4e66893bf309a36330c355ce843
-README.zh.md: f54c86f24158a1dcf68e82189c74e700cd8e94c5
+README.md: 02f4c60d20a0cebb594d18085212c8c0bfdc2438
+README.zh.md: b8ab03ce8a62c2ba27a9258d12c7e5d08a178a72
diff --git a/packages/client/ui-sidebar-terminal/README.md b/packages/client/ui-sidebar-terminal/README.md
index a874eb3ce4..02f4c60d20 100644
--- a/packages/client/ui-sidebar-terminal/README.md
+++ b/packages/client/ui-sidebar-terminal/README.md
@@ -29,9 +29,9 @@ Open the right sidebar and click **New terminal** to open the remembered availab
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. Cleanup failures have no notification or manual retry action; saved unfinished close requests are retried when the Client plugin starts. Collapsing, switching tabs or Sessions, floating and fullscreen presentation preserve the process.
-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.
+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 absent from the saved layout do not reopen automatically and have no UI recovery entry; they remain subject to the controller's unattended idle reclamation and Session/Host disposal. 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.
@@ -43,7 +43,7 @@ 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. The body loads its package-local `client.terminal.js` chunk when a terminal view mounts, keeping xterm.js and FitAddon out of the startup `client.js`; they then 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 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.
+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.
diff --git a/packages/client/ui-sidebar-terminal/README.zh.md b/packages/client/ui-sidebar-terminal/README.zh.md
index f54c86f241..b8ab03ce8a 100644
--- a/packages/client/ui-sidebar-terminal/README.zh.md
+++ b/packages/client/ui-sidebar-terminal/README.zh.md
@@ -29,9 +29,9 @@ kind: "package-reference"
双击终端标签标题可重命名。其他页面持有输入权时,点击 **接管输入** 使当前连接可写。暂时断线时保留屏幕并提供 **重新连接**,不展示传输内部错误。已退出的 shell 仍显示退出码并提供 **新建终端**,不会自动重启。已退出的终端仍计入 Session 配额;达到上限时请关闭不用的标签。
-关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败时显示带**重试**操作的轻量通知;重试不会重新打开标签页。折叠、切换标签页或 Session、浮动和全屏都保留进程。
+关闭或替换终端标签页会立即移除标签页,并在后台结束进程。清理失败没有通知或手动重试操作;已保存的未完成关闭请求会在 Client 插件启动时重试。折叠、切换标签页或 Session、浮动和全屏都保留进程。
-刷新后,[侧栏恢复布局](../../client/ui-sidebar-right/README.zh.md#state),各终端在原标签页中重连保存的 Host 身份。折叠和非当前标签不会产生重复的恢复标签,也不改变选中项。没有保存标签关联的 Host 终端作为恢复标签打开;恢复失败时提供 **重试恢复终端**。保存的进程已消失时,显示本地化的不可用提示和 **新建终端**。点击后在原位置用全新终端替换失效标签;恢复过程不会自动创建替代进程。
+刷新后,[侧栏恢复布局](../../client/ui-sidebar-right/README.zh.md#state),各终端在原标签页中重连保存的 Host 身份。折叠和非当前标签不会产生重复的恢复标签,也不改变选中项。保存布局之外的 Host 终端不会自动打开,也没有 UI 恢复入口;它们仍由 controller 的无人持有空闲回收及 Session/Host 卸载清理管理。保存的进程已消失时,显示本地化的不可用提示和 **新建终端**。点击后在原位置用全新终端替换失效标签;恢复过程不会自动创建替代进程。
终端背景、默认文字、光标和选区跟随 DSH 主题,包括系统偏好和主题令牌覆盖。切换主题会保留运行中的 shell、输出和应用通过 OSC 设置的颜色。颜色重置命令恢复到当前 DSH 默认值。xterm 将文字对比度调整到 4.5:1;光标与所在单元格背景保持至少 3:1 的对比度,包括 Vim 配色方案。
@@ -43,7 +43,7 @@ kind: "package-reference"
插件向右侧栏注册 `terminal` 类型及正文和标题 seat。开始页入口使用紧凑的黑底终端图标,页签标题保留线条图标。无 React 依赖的终端模型属于 `api-terminal-controller`,通过框架 keyed hooks 暴露状态。`ui-primitives` 的 Menu 与 Button 提供 shell 选择和启动控件,支持键盘导航与选中标记。正文在 terminal 视图挂载时加载包内 `client.terminal.js` chunk,使 xterm.js 与 FitAddon 不进入启动 `client.js`;加载后由它们负责屏幕渲染和视口测量。正文在面板高度内为页签条下方预留 8px 间距。输入原样传到 PTY,包括 Tab 和控制字符。
-Session header contribution 查询 Host 终端,只打开没有现有标签关联的终端。终端 controller 独立保存每个全局唯一内容身份与 Host 的关联,并负责内容恢复;侧栏负责布局持久化。恢复视图不能分配替代进程。侧栏关闭 handler 通过[终端 controller](../../api/terminal-controller/README.zh.md#understand-the-implementation)安排清理并同步返回。浏览器组件清理和 tab 的 abort signal 只停止浏览器工作。
+终端 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) 负责无人持有时的空闲回收和长命令保护。
diff --git a/packages/client/ui-sidebar-terminal/src/client/index.ts b/packages/client/ui-sidebar-terminal/src/client/index.ts
index ce45a902fe..4512f74d0b 100644
--- a/packages/client/ui-sidebar-terminal/src/client/index.ts
+++ b/packages/client/ui-sidebar-terminal/src/client/index.ts
@@ -13,8 +13,8 @@ import { TerminalGuideIcon } from './TerminalIcon.tsx'
import { TerminalGuide, type TerminalGuideInjected } from './TerminalGuide.tsx'
import { LazyTerminalBody } from './LazyTerminalBody.tsx'
import { TerminalTitle } from './TerminalTitle.tsx'
-import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
-import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx'
+// import { TerminalRecovery, type TerminalRecoveryInjected } from './TerminalRecovery.tsx'
+// import { TerminalCleanup, type TerminalCleanupInjected } from './TerminalCleanup.tsx'
import type { TerminalBodyInjected, TerminalInjected } from './face.ts'
import { en, zh } from './locales.ts'
@@ -26,9 +26,9 @@ export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'w
* @param ctx - Client root Context with sidebar and terminal services.
*/
export function apply(ctx: Context): void {
- let disposed = false
- const recovered = new Map>()
- ctx.effect(() => () => { disposed = true; recovered.clear() }, 'ui-sidebar-terminal.lifetime')
+ // let disposed = false
+ // const recovered = new Map>()
+ // 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)
@@ -81,32 +81,32 @@ export function apply(ctx: Context): void {
ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
{ name: 'sidebar.right.pane.tab.title', key: id, locale: namespace, inject }, TerminalTitle,
)), 'ui-sidebar-terminal.title')
- ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
- name: 'conversation.session.header.actions', id, locale: namespace,
- inject: (sessionId): TerminalRecoveryInjected => ({
- restore: () => {
- let pending = recovered.get(sessionId)
- if (pending === undefined) {
- for (const tab of ctx.sidebarRight.tabsIn(sessionId)) {
- if (tab.kind === 'terminal') view(sessionId, tab.id)
- }
- pending = ctx.webTerminals.recover(sessionId).then((terminals) => {
- if (disposed) return
- for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', {
- params: { terminalId: info.id },
- })
- }).catch((error: unknown) => { recovered.delete(sessionId); throw error })
- recovered.set(sessionId, pending)
- }
- return pending
- },
- }),
- }, TerminalRecovery)), 'ui-sidebar-terminal.recovery')
- ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
- name: 'shell.overlay', id, locale: namespace,
- inject: (): TerminalCleanupInjected => ({
- hooks: { closeFailures: ctx.webTerminals.closeFailures },
- retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) },
- }),
- }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup')
+ // ctx.effect(() => ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
+ // name: 'conversation.session.header.actions', id, locale: namespace,
+ // inject: (sessionId): TerminalRecoveryInjected => ({
+ // restore: () => {
+ // let pending = recovered.get(sessionId)
+ // if (pending === undefined) {
+ // for (const tab of ctx.sidebarRight.tabsIn(sessionId)) {
+ // if (tab.kind === 'terminal') view(sessionId, tab.id)
+ // }
+ // pending = ctx.webTerminals.recover(sessionId).then((terminals) => {
+ // if (disposed) return
+ // for (const info of terminals) ctx.sidebarRight.openTabIn(sessionId, 'terminal', {
+ // params: { terminalId: info.id },
+ // })
+ // }).catch((error: unknown) => { recovered.delete(sessionId); throw error })
+ // recovered.set(sessionId, pending)
+ // }
+ // return pending
+ // },
+ // }),
+ // }, TerminalRecovery)), 'ui-sidebar-terminal.recovery')
+ // ctx.effect(() => ctx.slots.inject('shell.overlay', () => ctx.slots.register({
+ // name: 'shell.overlay', id, locale: namespace,
+ // inject: (): TerminalCleanupInjected => ({
+ // hooks: { closeFailures: ctx.webTerminals.closeFailures },
+ // retryClose: (terminalId) => { ctx.webTerminals.retryClose(terminalId) },
+ // }),
+ // }, TerminalCleanup)), 'ui-sidebar-terminal.cleanup')
}
diff --git a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
index c7ebf7e211..4143053e17 100644
--- a/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
+++ b/packages/client/ui-sidebar-terminal/tests/apply.client.spec.ts
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-/** Terminal type, copy, seats and explicit cleanup follow the plugin lifetime. */
+/** Terminal views, copy, and tab retention follow the plugin lifetime. */
import { createElement } from 'react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { renderToStaticMarkup } from 'react-dom/server'
@@ -16,8 +16,6 @@ import { apply as hostApply } from '../src/index.ts'
import { TerminalGuide, type TerminalGuideInjected } from '../src/client/TerminalGuide.tsx'
import { LazyTerminalBody } from '../src/client/LazyTerminalBody.tsx'
import { TerminalTitle } from '../src/client/TerminalTitle.tsx'
-import { TerminalRecovery, type TerminalRecoveryInjected } from '../src/client/TerminalRecovery.tsx'
-import { TerminalCleanup, type TerminalCleanupInjected } from '../src/client/TerminalCleanup.tsx'
import type { TerminalBodyInjected } from '../src/client/face.ts'
import { en, zh } from '../src/client/locales.ts'
@@ -27,8 +25,6 @@ vi.mock('../src/client/terminal.tsx', () => ({ TerminalBody: renderedTerminal })
afterEach(() => { cleanup(); renderedTerminal.mockClear() })
-const terminalInfo = (id: string): WebTerminalInfo => ({ id: id as WebTerminalId, title: id, shell: { path: '/bin/sh', name: 'sh', args: ['-i'] }, cwd: '/workspace', cols: 80, rows: 24, state: 'running', exitCode: null })
-
async function mountPlugin() {
const ctx = new Context()
const tabs = new SidebarRightTabRegistry(ctx)
@@ -79,7 +75,7 @@ async function mountPlugin() {
}
}
-it('registers terminal views, recovery and cleanup, then releases every contribution on unload', async () => {
+it('registers terminal views without recovery or cleanup slots, then releases contributions on unload', async () => {
expect(hostApply).not.toThrow()
const h = await mountPlugin()
try {
@@ -96,8 +92,6 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
['sidebar.right.tab.guide.entry', TerminalGuide, 'sidebarTerminal'],
['sidebar.right.pane.tab', LazyTerminalBody, 'sidebarTerminal'],
['sidebar.right.pane.tab.title', TerminalTitle, 'sidebarTerminal'],
- ['conversation.session.header.actions', TerminalRecovery, 'sidebarTerminal'],
- ['shell.overlay', TerminalCleanup, 'sidebarTerminal'],
])
const sessionId = 'session' as SessionId
const launcher = h.entries[0]!.inject(sessionId) as TerminalGuideInjected
@@ -132,10 +126,9 @@ it('registers terminal views, recovery and cleanup, then releases every contribu
h.setParams(undefined)
h.closeHandler(sessionId, { id: 'new-tab', contentId: 'sidebar://terminal/new' } as Parameters[1])
expect(h.terminals.close).toHaveBeenLastCalledWith(sessionId, 'new-tab', 'sidebar://terminal/new', undefined)
- const cleanupFace = h.entries[4]!.inject(sessionId) as TerminalCleanupInjected
- expect(cleanupFace.hooks.closeFailures).toBe(h.terminals.closeFailures)
- cleanupFace.retryClose('terminal' as Parameters[0])
- expect(h.terminals.retryClose).toHaveBeenCalledWith('terminal')
+ expect(h.terminals.recover).not.toHaveBeenCalled()
+ expect(h.terminals.retryClose).not.toHaveBeenCalled()
+ expect(h.openTabIn).not.toHaveBeenCalled()
} finally {
await h.dispose()
}
@@ -150,78 +143,6 @@ it('loads the terminal body implementation when its registered wrapper mounts',
await waitFor(() => { expect(renderedTerminal).toHaveBeenCalledOnce() })
})
-it('restores terminal occurrences before listing unrepresented Host terminals and shares recovery across headers', async () => {
- const h = await mountPlugin()
- h.tabsIn.mockReturnValue([{ id: 'collapsed-terminal', kind: 'terminal' }, { id: 'file', kind: 'text' }])
- const pending = Promise.withResolvers()
- h.terminals.recover.mockImplementationOnce(() => pending.promise)
- const sessionId = 'session' as SessionId
- const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
- const remounted = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
- const completion = recovery.restore()
- expect(h.terminals.view).toHaveBeenCalledWith(sessionId, 'collapsed-terminal', 'sidebar://terminal/content', undefined, undefined)
- expect(h.terminals.view).toHaveBeenCalledOnce()
- try {
- expect(remounted.restore()).toBe(completion)
- expect(h.terminals.recover).toHaveBeenCalledExactlyOnceWith(sessionId)
- expect(h.openTabIn).not.toHaveBeenCalled()
- pending.resolve([terminalInfo('build'), terminalInfo('tests')])
- await completion
- expect(h.openTabIn.mock.calls).toEqual([
- [sessionId, 'terminal', { params: { terminalId: 'build' } }],
- [sessionId, 'terminal', { params: { terminalId: 'tests' } }],
- ])
- await remounted.restore()
- expect(h.terminals.recover).toHaveBeenCalledTimes(1)
- expect(h.openTabIn).toHaveBeenCalledTimes(2)
- const otherSession = 'other-session' as SessionId
- await (h.entries[3]!.inject(otherSession) as TerminalRecoveryInjected).restore()
- expect(h.terminals.recover).toHaveBeenLastCalledWith(otherSession)
- expect(h.terminals.recover).toHaveBeenCalledTimes(2)
- expect(h.openTabIn).toHaveBeenCalledTimes(2)
- } finally {
- pending.resolve([])
- await completion
- await h.dispose()
- }
-})
-
-it('allows a failed Host lookup to be retried without marking the Session recovered', async () => {
- const h = await mountPlugin()
- const unavailable = new Error('Host unavailable')
- h.terminals.recover.mockRejectedValueOnce(unavailable)
- const sessionId = 'session' as SessionId
- const recovery = h.entries[3]!.inject(sessionId) as TerminalRecoveryInjected
- try {
- await expect(recovery.restore()).rejects.toBe(unavailable)
- expect(h.openTabIn).not.toHaveBeenCalled()
- await recovery.restore()
- await recovery.restore()
- expect(h.terminals.recover).toHaveBeenCalledTimes(2)
- } finally {
- await h.dispose()
- }
-})
-
-it('does not open retained terminals when their lookup completes after plugin unload', async () => {
- const h = await mountPlugin()
- const pending = Promise.withResolvers()
- h.terminals.recover.mockImplementationOnce(() => pending.promise)
- const recovery = h.entries[3]!.inject('session' as SessionId) as TerminalRecoveryInjected
- const completion = recovery.restore()
- try {
- expect(h.terminals.recover).toHaveBeenCalledOnce()
- await h.dispose()
- pending.resolve([terminalInfo('build')])
- await completion
- expect(h.openTabIn).not.toHaveBeenCalled()
- } finally {
- pending.resolve([])
- await completion
- await h.dispose()
- }
-})
-
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
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index fdcdf0ae9c..5f4245e471 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: dc9ecee1df515f8e9b4183b6d97c29301c848652
-README.zh.md: 065f77ac442943b5683f0d492b56ab7d6668ba99
+README.md: 0125f563e7d0f062dd37f58a218d76dd85b0fa51
+README.zh.md: 9db3ea3a39f7c314ebb7d874d858457f00501bd7
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index dc9ecee1df..0125f563e7 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -31,7 +31,7 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start
Pinned Sessions lead ordinary Sessions in both grouped and flat views. **Last updated** sorts each partition strictly by the latest user prompt or steer time, newest first; pin time does not affect it. **Manual** uses the relative positions in one complete Session sequence, including hidden archives. Returning to Last updated discards the manual layout, and entering Manual again freezes the then-current chronological order. The browser defaults to Last updated and remembers the selected mode across reloads.
-Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order, while other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership.
+Pinning moves the Session to the front of its complete saved sequence without changing the selected mode. It leads the pinned partition in Manual but need not lead it in Last updated. Unpinning leaves the saved position unchanged. Dragging either a pinned or ordinary row edits that same complete sequence and selects Manual; hidden archived members are retained. Missing pinned members prepend in pin-array order. New ordinary forks precede their sources in the complete sequence without inheriting pin membership; visible pins still lead ordinary rows. Other missing members append by recency with missing archives last. Supplementation stays in memory until a Session-order write records the complete result. Archive filtering alone changes neither saved positions nor account membership.
The selected blank **New Session** retains its provisional first slot and cannot be dragged; after its first prompt it becomes an ordinary draggable row, retaining that position in Manual or following its current timestamp in Last updated. A collapsed-group drag uses the target Session identity and keeps the source visible. Session display orders for real Workspaces, Ungrouped, and the flat list are browser-local; Workspace group drag order remains Host-durable. The [Session pinning and archive decision](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.md) records the ordering and recovery rules.
@@ -47,7 +47,7 @@ Collapsed search is one header action beside the view and add actions: activatin
### Managing sessions
-The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped.
+The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped.
Archive commits without a confirmation dialog and retains the Session's account position. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a toast with undo and filter-menu actions. Unarchive removes the archive mark without restoring a pin or changing the saved position.
@@ -65,7 +65,7 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
-----
-`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
+`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` opens its result only if no later navigation has superseded the request; New Session uses `openWorkspace`. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index 065f77ac44..9db3ea3a39 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -31,7 +31,7 @@ kind: "package-reference"
分组和平铺视图都先显示置顶 Session,再显示普通 Session。**最近更新**在各分区内严格按最近一次用户提示词或 steering(中途引导)时间降序排列,置顶时间不影响它。**手动排序**使用同一条完整 Session 序列中的相对位置,包括隐藏的归档项。返回最近更新会丢弃手动布局,再次进入手动排序时冻结当时的时间顺序。浏览器默认采用最近更新,并在重新加载后记住所选模式。
-置顶把 Session 移到完整保存序列的首位,但不切换所选模式。它在手动模式下排在置顶区最前,在最近更新模式下则不一定。取消置顶不改变保存的位置。拖拽置顶行或普通行都修改同一条完整序列并选择手动模式,隐藏的归档成员始终保留。缺失的置顶成员按置顶数组顺序补到头部,其他缺失成员按最近更新时间追加,缺失归档项放在最后。补齐仅在内存中完成,直到一次 Session 顺序写入保存完整结果。单纯切换归档筛选既不改变保存的位置,也不改变成员关系。
+置顶把 Session 移到完整保存序列的首位,但不切换所选模式。它在手动模式下排在置顶区最前,在最近更新模式下则不一定。取消置顶不改变保存的位置。拖拽置顶行或普通行都修改同一条完整序列并选择手动模式,隐藏的归档成员始终保留。缺失的置顶成员按置顶数组顺序补到头部。新增的普通 fork 在完整序列中插在来源之前,不继承置顶成员关系;可见置顶行仍排在普通行之前。其他缺失成员按最近更新时间追加,缺失归档项放在最后。补齐仅在内存中完成,直到一次 Session 顺序写入保存完整结果。单纯切换归档筛选既不改变保存的位置,也不改变成员关系。
当前选中的空白**新会话**保留临时首位且无法拖拽;首条提示词落地后,它成为可拖拽的普通行,在手动排序中保留该位置,在最近更新中按当前时间戳排列。折叠分组的拖拽使用目标 Session 身份,并保持来源行可见。真实 Workspace、Ungrouped 与单列表的 Session 显示顺序都保留在浏览器本地;Workspace 分组的拖拽顺序仍由 Host 持久化。[会话置顶与归档决定](../../../.agents/notes/implemented/feature/2026-09-18-session-pin-and-sidebar-archive.zh.md)记录排序与恢复规则。
@@ -47,7 +47,7 @@ kind: "package-reference"
### 管理会话
-Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。
+Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 在源会话最后一个已完成轮次处 fork,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。
Archive 不经确认对话框直接提交,并保留 Session 的记账位置。视图选项控制显隐:默认隐藏已归档 Session,显示已归档会将其纳入列表,仅显示已归档则隐藏普通 Session。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后,toast 提供撤销与打开筛选菜单的操作。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
@@ -65,7 +65,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
-----
-`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
+`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts
index 82796c0b9c..22138ec707 100644
--- a/packages/client/ui-workspace/src/client/navigation.ts
+++ b/packages/client/ui-workspace/src/client/navigation.ts
@@ -37,9 +37,9 @@ export interface UiWorkspace {
*/
openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise
/**
- * Fork a Session and open the child unless a later navigation supersedes it.
+ * Fork a Session without changing the current selection.
* @param sessionId - source Session.
- * @returns completion; a superseded request leaves its child available without selecting it.
+ * @returns completion after child creation and inherited-title increment.
*/
forkSession(sessionId: SessionId): Promise
/**
@@ -185,9 +185,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
}
async forkSession(sessionId: SessionId): Promise {
- const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
- const childId = await this.sessions.fork({ sessionId, increaseTitle: true })
- if (!navigation.aborted) this.replaceMain(childId, navigation, 'reveal')
+ await this.sessions.fork({ sessionId, increaseTitle: true })
}
startSession(workspaceId?: WorkspaceId): void {
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index 6950c15a4e..7bf2036fe9 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -163,11 +163,12 @@ export function orderByRecency(
/**
* Reconcile a browser-local manual order with current account membership.
+ * New ordinary forks precede their sources without changing saved entries' relative order.
* @param memberIds - authoritative account membership.
* @param savedOrder - previously saved browser-local order.
- * @param summaries - current Session summaries used to append newly known members by recency.
+ * @param summaries - current Session metadata; unknown new members wait for their summaries.
* @param rowState - global pin and archive membership; only account members can supplement the order.
- * @returns missing pins, retained saved slots, new ordinary members, then missing archives; unknown new members wait for their summaries.
+ * @returns saved relative positions plus missing members ordered by pin, fork source, recency, and archive status.
*/
export function reconcileManualOrder(
memberIds: readonly SessionId[],
@@ -198,7 +199,18 @@ export function reconcileManualOrder(
if (archived.has(id)) archives.push(id)
else ordinary.push(id)
}
- return [...pins, ...ordered, ...ordinary, ...archives]
+ const result = [...pins, ...ordered, ...ordinary, ...archives]
+ const pending = new Set(ordinary)
+ const placeFork = (id: SessionId): void => {
+ if (!pending.delete(id)) return
+ const parentId = summaries[id]?.parentId
+ if (parentId === undefined || parentId === id || !result.includes(parentId)) return
+ placeFork(parentId)
+ result.splice(result.indexOf(id), 1)
+ result.splice(result.indexOf(parentId), 0, id)
+ }
+ for (const id of [...ordinary].reverse()) placeFork(id)
+ return result
}
/**
diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts
index 507dadb724..22f46ee4fe 100644
--- a/packages/client/ui-workspace/tests/apply.client.spec.ts
+++ b/packages/client/ui-workspace/tests/apply.client.spec.ts
@@ -156,10 +156,11 @@ describe('ui-workspace apply', () => {
'session', { source: 'workspaceOperation' }, expect.any(Function),
)
expect(b.renameSession).toHaveBeenCalledWith('renamed session')
+ b.retain.mockClear()
+ const forkSession = vi.spyOn(b.ctx.uiWorkspace, 'forkSession')
browser.forkSession('session' as never)
- await vi.waitFor(() => {
- expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' })
- })
+ await forkSession.mock.results[0]!.value
+ expect(b.retain).not.toHaveBeenCalled()
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true })
await browser.renameWorkspace('ws' as never, 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts
index d37bddd386..5072250b20 100644
--- a/packages/client/ui-workspace/tests/tree.client.spec.ts
+++ b/packages/client/ui-workspace/tests/tree.client.spec.ts
@@ -156,6 +156,54 @@ describe('Session ordering', () => {
expect(saved).toEqual(['saved-archive', 'saved-plain', 'saved-pin'])
})
+ it('inserts new forks before their saved source while preserving saved positions', () => {
+ const sessions = list(
+ summary('source', 1), summary('other', 2),
+ { ...summary('older-fork', 3), parentId: sid('source') },
+ { ...summary('newer-fork', 4), parentId: sid('source') },
+ )
+ const order = reconcileManualOrder(sessions.ids, ['other', 'source'], sessions.byId)
+ expect(order).toEqual(['other', 'older-fork', 'newer-fork', 'source'])
+ expect(reconcileManualOrder(sessions.ids, ['source', 'other', 'older-fork', 'newer-fork'], sessions.byId))
+ .toEqual(['source', 'other', 'older-fork', 'newer-fork'])
+ })
+
+ it('places nested new forks before their parents even when catalog recency differs', () => {
+ const sessions = list(
+ summary('source', 1), summary('other', 2),
+ { ...summary('parent', 4), parentId: sid('source') },
+ { ...summary('child', 3), parentId: sid('parent') },
+ )
+ expect(reconcileManualOrder(sessions.ids, ['source', 'other'], sessions.byId))
+ .toEqual(['child', 'parent', 'source', 'other'])
+ })
+
+ it('appends forks with absent or self-referencing parents without duplicating rows', () => {
+ const sessions = list(
+ summary('source', 1),
+ { ...summary('orphan', 3), parentId: sid('absent') },
+ { ...summary('self', 2), parentId: sid('self') },
+ )
+ expect(reconcileManualOrder(sessions.ids, ['source'], sessions.byId))
+ .toEqual(['source', 'orphan', 'self'])
+ })
+
+ it.each(['workspace', 'ungrouped', 'flat'] as const)('keeps forks of pinned sources in the ordinary %s section', (mode) => {
+ const sessions = list(
+ summary('source', 1), summary('other-pin', 2), summary('ordinary', 3),
+ { ...summary('fork', 4), parentId: sid('source') },
+ )
+ const state = rowState({ pinned: ['source', 'other-pin'] })
+ const order = reconcileManualOrder(sessions.ids, ['source', 'other-pin', 'ordinary'], sessions.byId, state)
+ const rows = mode === 'flat'
+ ? deriveFlat(sessions, order, state, noAttention)
+ : deriveGroups(
+ sessions, mode === 'workspace' ? [workspace('alpha', order)] : [], state, noAttention,
+ view([mode === 'workspace' ? 'alpha' : UNGROUPED_KEY], order),
+ )[0]!.sessions
+ expect(rows.map(row => row.id)).toEqual(['source', 'other-pin', 'fork', 'ordinary'])
+ })
+
it('keeps archive filtering out of complete flat membership', () => {
const sessions = list(summary('plain', 1), summary('archived', 2))
const members = sessionMemberIds(sessions)
diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
index 625784bb5b..310bb3627d 100644
--- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
+++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
@@ -377,13 +377,30 @@ describe('UiWorkspaceService', () => {
expect(b.sessions.create).not.toHaveBeenCalled()
})
- it('forwards fork policy and rejects a failed fork', async () => {
+ it('forks with title increment without selecting or retaining the child, and rejects failure', async () => {
const b = bench()
+ b.uiWorkspace.openSession(sid('source'))
+ b.sessions.retain.mockClear()
+ b.selectPanel.mockClear()
await b.uiWorkspace.forkSession(sid('source'))
expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true })
- expect(b.sessions.retain).toHaveBeenCalledWith(sid('forked'), { source: 'mainView' })
+ expect(b.sessions.retain).not.toHaveBeenCalled()
b.sessions.fork.mockRejectedValueOnce(new Error('fork failed'))
await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed')
+ expect(b.sessions.retain).not.toHaveBeenCalled()
+ expect(b.selectPanel).not.toHaveBeenCalled()
+ expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
+ })
+
+ it('does not supersede a pending Workspace selection when a sidebar fork completes', async () => {
+ const b = bench({ workspaces: workspaceState([workspace('a')]) })
+ const created = Promise.withResolvers()
+ b.sessions.create.mockReturnValueOnce(created.promise)
+ const opening = b.uiWorkspace.openWorkspace(wid('a'))
+ await b.uiWorkspace.forkSession(sid('source'))
+ created.resolve(sid('chosen'))
+ await opening
+ expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('chosen'), { source: 'mainView' })
})
it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
index 0371943a75..df7a9d8900 100644
--- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts
@@ -348,9 +348,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'forkSession(sessionId: SessionId): Promise',
- description: 'Fork a Session and open the child unless a later navigation supersedes it.',
+ description: 'Fork a Session without changing the current selection.',
parameters: [{ name: 'sessionId', description: 'source Session.' }],
- returns: 'completion; a superseded request leaves its child available without selecting it.',
+ returns: 'completion after child creation and inherited-title increment.',
},
{
signature: 'connectWorkspace(workspaceId: WorkspaceId): Promise',
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index cf1de6b8bc..9d8b9b731d 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -1315,7 +1315,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'',
'client-ui-jobs JobListAction id \'job-list\'',
'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'',
- 'client-ui-sidebar-terminal TerminalRecovery',
'experimental-client-ui-agent-team TeamAction id \'agent-team\'',
],
replaceRisk: 'none',
@@ -2373,9 +2372,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'root\' (client-ui-layout), so it exists while that entry is mounted',
- occupants: [
- 'client-ui-sidebar-terminal TerminalCleanup',
- ],
+ occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-layout/src/client/index.ts:91',
diff --git a/packages/shell/tool-bash-persistent/README.i18n.yaml b/packages/shell/tool-bash-persistent/README.i18n.yaml
index 9b9d2fdeb8..df37df1263 100644
--- a/packages/shell/tool-bash-persistent/README.i18n.yaml
+++ b/packages/shell/tool-bash-persistent/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/tool-bash-persistent/README.md
-README.md: f6d6daa0f9c312b64e913aa811fadff3ad6b6acf
-README.zh.md: 19f88c9c130a0bb1001770869355b88cae943644
+README.md: f22004aa61b6099998d8a9e9a62cea7c6e5c3cf9
+README.zh.md: 6735ede17f63b9bfe5dc46581578435b07231806
diff --git a/packages/shell/tool-bash-persistent/README.md b/packages/shell/tool-bash-persistent/README.md
index f6d6daa0f9..f22004aa61 100644
--- a/packages/shell/tool-bash-persistent/README.md
+++ b/packages/shell/tool-bash-persistent/README.md
@@ -56,7 +56,7 @@ Commands share one shell per agent, so state persists until an `exit`, a timeout
### What can go wrong
-A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable.
+A call without an owning agent session fails with `bash requires an owning agent session`, and a composition without a PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. An interactive foreground child (for example a REPL) returns early with partial output only where the backend proves its stdin wait; elsewhere the call runs to `timeoutMs`, which closes the uncertain shell and reports the reset. Cancellation also resets and discards the result, even when a complete status marker is already observable. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command.
-----
diff --git a/packages/shell/tool-bash-persistent/README.zh.md b/packages/shell/tool-bash-persistent/README.zh.md
index 19f88c9c13..6735ede17f 100644
--- a/packages/shell/tool-bash-persistent/README.zh.md
+++ b/packages/shell/tool-bash-persistent/README.zh.md
@@ -56,7 +56,7 @@ kind: "package-reference"
### 可能出什么问题
-没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。
+没有拥有者 agent 会话的调用会以 `bash requires an owning agent session` 失败,没有 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。交互式前台子进程(例如 REPL)只有在后端证明其 stdin 等待时才提前返回部分输出;否则调用一直运行到 `timeoutMs`,随后关闭不确定的 shell 并报告重置。取消也会重置并丢弃结果,即使完整状态标记已经可观察。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。
-----
diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts
index 202449d964..c248ca94b5 100644
--- a/packages/shell/tool-bash-persistent/src/index.ts
+++ b/packages/shell/tool-bash-persistent/src/index.ts
@@ -302,7 +302,14 @@ async function executeCommand(
upstream: AbortSignal,
): Promise {
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
- const id = await shells.get(owner, commandDeadline.signal)
+ let id: TerminalSessionId
+ try {
+ id = await shells.get(owner, commandDeadline.signal)
+ } catch (error: unknown) {
+ // Initialization owns rollback; only this caller's cancellation becomes ABORTED.
+ if (upstream.aborted && error === upstream.reason) return ''
+ throw error
+ }
const marker = markers()
const wrapped = wrapCommand(command, marker)
let first = true
@@ -354,7 +361,8 @@ async function executeCommand(
}
if (commandDeadline.signal.aborted) {
await shells.reset(owner, 'persistent bash command aborted')
- commandDeadline.signal.throwIfAborted()
+ // ToolRuntime publishes ABORTED after this cancelled invocation settles.
+ return ''
}
if (latest.text.includes(marker.end)) {
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
@@ -420,7 +428,7 @@ function registerPersistentBash(ctx: Context, config: ResolvedConfig): void {
const owner = exec.agent
if (owner === undefined) throw new Error('bash requires an owning agent session')
return serialized(owner, async () => {
- exec.signal.throwIfAborted()
+ if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement.
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
})
},
diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts
index ce453d18ce..a79d116556 100644
--- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts
+++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -25,6 +25,7 @@ let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+ vi.restoreAllMocks()
})
async function agent(ctx: Context, cwd: string | undefined): Promise {
@@ -510,17 +511,163 @@ describe('tool-bash-persistent', () => {
const controller = new AbortController()
const cancelled = call(ctx, owner, 'hang', controller.signal)
const queued = call(ctx, owner, 'after cancellation')
- setTimeout(() => {
- controller.abort(new Error('caller stopped'))
- }, 5)
+ try {
+ await expect.poll(() => stub.sessions[0]!.sends).toBe(3)
+ controller.abort({ kind: 'user' })
- expect((await cancelled).isError).toBe(true)
- expect(text(await queued)).toBe('hello from stub\n[Command finished with exit code 0]')
- expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
- expect(stub.sessions).toHaveLength(2)
+ const result = await cancelled
+ expect(result).toMatchObject({
+ isError: true,
+ error: { message: 'tool call aborted', info: { name: 'AbortError', code: 'ABORTED' } },
+ })
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(text(await queued)).toBe('hello from stub\n[Command finished with exit code 0]')
+ expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
+ expect(stub.sessions).toHaveLength(2)
+ } finally {
+ controller.abort({ kind: 'user' })
+ await Promise.all([cancelled, queued])
+ }
},
)
+ it('settles an aborted queued call without sending it to a replacement shell', async () => {
+ const { ctx, owner, stub } = await setup({ backendType: 'stub' })
+ await call(ctx, owner, 'warm up')
+ const session = stub.sessions[0]!
+ session.mode = 'wait-for-abort'
+ const runningController = new AbortController()
+ // Dispatch observation distinguishes the tool's queue from cancellation before tool entry.
+ const execute = vi.spyOn(ctx.tools.get('bash', owner)!, 'execute')
+ const queuedController = new AbortController()
+ const running = call(ctx, owner, 'hang', runningController.signal)
+ const queued = call(ctx, owner, 'never sent', queuedController.signal)
+ try {
+ await expect.poll(() => session.sends).toBe(3)
+ await expect.poll(() => execute.mock.calls.length).toBe(2)
+ queuedController.abort({ kind: 'user' })
+ runningController.abort({ kind: 'user' })
+ const result = await queued
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' })
+ expect(session.sends).toBe(3)
+ expect(stub.sessions).toHaveLength(1)
+ expect(session.closed).toContain('persistent bash command aborted')
+ } finally {
+ runningController.abort({ kind: 'user' })
+ queuedController.abort({ kind: 'user' })
+ await Promise.all([running, queued])
+ }
+ })
+
+
+ it('settles cancellation during the first spawn and releases queued work', async () => {
+ const { ctx, owner, stub } = await setup()
+ const started = Promise.withResolvers()
+ const finishSpawn = Promise.withResolvers()
+ const spawn = stub.backend.spawn.bind(stub.backend)
+ vi.spyOn(stub.backend, 'spawn').mockImplementationOnce(async (spec) => {
+ started.resolve(undefined)
+ await finishSpawn.promise
+ spec.signal?.throwIfAborted()
+ return spawn(spec)
+ })
+ const controller = new AbortController()
+ const cancelled = call(ctx, owner, 'never started', controller.signal)
+ const queued = call(ctx, owner, 'after cancellation')
+ try {
+ await started.promise
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(undefined)
+ const result = await cancelled
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' })
+ expect(text(await queued)).toBe('hello from stub\n[Command finished with exit code 0]')
+ expect(stub.sessions).toHaveLength(1)
+ } finally {
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(undefined)
+ await Promise.all([cancelled, queued])
+ }
+ })
+
+ it('waits for rollback when a cancelled spawn returns a shell', async () => {
+ const { ctx, owner, stub } = await setup()
+ const started = Promise.withResolvers()
+ const finishSpawn = Promise.withResolvers()
+ const closing = Promise.withResolvers()
+ const finishClose = Promise.withResolvers()
+ const session = new StubPtySession('normal')
+ const close = session.close.bind(session)
+ vi.spyOn(session, 'close').mockImplementation(async (reason) => {
+ closing.resolve(undefined)
+ await finishClose.promise
+ await close(reason)
+ })
+ vi.spyOn(stub.backend, 'spawn').mockImplementationOnce(() => {
+ started.resolve(undefined)
+ return finishSpawn.promise
+ })
+ const controller = new AbortController()
+ let settled = false
+ const cancelled = call(ctx, owner, 'never initialized', controller.signal).then((result) => {
+ settled = true
+ return result
+ })
+ try {
+ await started.promise
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(session)
+ await closing.promise
+ expect(settled).toBe(false)
+ expect(session.sends).toBe(0)
+ finishClose.resolve(undefined)
+ const result = await cancelled
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(result.error?.info?.code).toBe('ABORTED')
+ expect(session.closed).toContain('PTY spawn rolled back')
+ expect(ctx.terminals.list(owner)).toEqual([])
+ } finally {
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(session)
+ finishClose.resolve(undefined)
+ await cancelled
+ }
+ })
+
+ it.each(['initialization', 'command', 'cleanup'] as const)('preserves a distinct %s failure during cancellation', async (phase) => {
+ const { ctx, owner } = await setup()
+ if (phase === 'command') await call(ctx, owner, 'warm up')
+ const controller = new AbortController()
+ const failure = new Error(`${phase} failed`)
+ vi.spyOn(ctx.terminals, 'startSend').mockImplementationOnce(() => {
+ controller.abort({ kind: 'user' })
+ throw phase === 'cleanup' ? new Error('initialization failed') : failure
+ })
+ if (phase === 'cleanup') vi.spyOn(ctx.terminals, 'kill').mockRejectedValueOnce(failure)
+ const result = await call(ctx, owner, 'fails', controller.signal)
+ expect(result.isError).toBe(true)
+ expect(text(result)).toBe(`Error: ${phase} failed`)
+ expect(result.error?.info?.code).not.toBe('ABORTED')
+ })
+
+ it('keeps a spawn deadline failure distinct from caller cancellation', async () => {
+ const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
+ const spawn = stub.backend.spawn.bind(stub.backend)
+ vi.spyOn(stub.backend, 'spawn').mockImplementationOnce(async (spec) => {
+ await new Promise((resolve) => { spec.signal!.addEventListener('abort', () => { resolve() }, { once: true }) })
+ spec.signal?.throwIfAborted()
+ return spawn(spec)
+ })
+ const controller = new AbortController()
+ const result = await call(ctx, owner, 'never started', controller.signal)
+ expect(controller.signal.aborted).toBe(false)
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('PERSISTENT_BASH_TIMEOUT')
+ expect(result.error?.info?.code).not.toBe('ABORTED')
+ expect(stub.sessions).toEqual([])
+ })
+
it.each(['init-exit', 'init-timeout'] as const)(
'fails initialization and closes the unusable shell for %s',
async (mode) => {
diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml
index 53d85e71a5..b31e3271a9 100644
--- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml
+++ b/packages/shell/tool-pwsh-persistent/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md
-README.md: 6b347dfbf8745a7bc56e2c62a5040e873bc1d8e2
-README.zh.md: ce8a6542b1f3494d47ba8dee01ba3630c280bc5e
+README.md: f90442e7e9d8a361a690f66f41a58f1a4fcdd708
+README.zh.md: d634e8e42e186318a8733ee9ef2024e2840a857c
diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md
index 6b347dfbf8..f90442e7e9 100644
--- a/packages/shell/tool-pwsh-persistent/README.md
+++ b/packages/shell/tool-pwsh-persistent/README.md
@@ -58,7 +58,7 @@ Commands share one shell per agent, so cwd, `$env:` variables, functions, and ba
### What can go wrong
-A call without an owning agent session fails with `pwsh requires an owning agent session`, and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout or cancellation closes the uncertain shell, discards the result, and reports the reset.
+A call without an owning agent session fails with `pwsh requires an owning agent session`, and a composition without a pwsh-dialect PTY backend activates the tool but fails its first call with `no PTY backend registered for "shell"`. A model redefinition of the `prompt` function removes the readiness marker, and the shell then settles on the silence tier instead of the marker fast path. Raw ESC characters inside a command are consumed by PSReadLine before execution and are unsupported. A timeout closes the uncertain shell and reports bounded partial output and the reset. Cancellation resets the shell and discards command output. After cleanup settles, ToolRuntime publishes `Error: tool call aborted` with code `ABORTED`; cancellation reasons are not rendered as command output. A queued cancelled call sends no command.
-----
diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md
index ce8a6542b1..d634e8e42e 100644
--- a/packages/shell/tool-pwsh-persistent/README.zh.md
+++ b/packages/shell/tool-pwsh-persistent/README.zh.md
@@ -58,7 +58,7 @@ kind: "package-reference"
### 可能出什么问题
-没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session` 失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时或取消会关闭不确定的 shell、丢弃结果并报告重置。
+没有拥有者 agent 会话的调用会以 `pwsh requires an owning agent session` 失败,没有 pwsh 方言 PTY 后端的组合会激活该工具,但首次调用以 `no PTY backend registered for "shell"` 失败。模型重定义 `prompt` 函数会移除就绪标记,shell 随后在静默层级而非标记快路径上结算。命令内的原始 ESC 字符会在执行前被 PSReadLine 消费,不受支持。超时关闭不确定的 shell,并报告受限的部分输出与重置。取消会重置 shell 并丢弃命令输出。清理完成后,ToolRuntime 发布 `Error: tool call aborted`,错误码为 `ABORTED`;取消原因不作为命令输出渲染。排队期间被取消的调用不发送命令。
-----
diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts
index f0a575363e..34f8cf7732 100644
--- a/packages/shell/tool-pwsh-persistent/src/index.ts
+++ b/packages/shell/tool-pwsh-persistent/src/index.ts
@@ -342,7 +342,14 @@ async function executeCommand(
upstream: AbortSignal,
): Promise {
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
- const id = await shells.get(owner, commandDeadline.signal)
+ let id: TerminalSessionId
+ try {
+ id = await shells.get(owner, commandDeadline.signal)
+ } catch (error: unknown) {
+ // Initialization owns rollback; only this caller's cancellation becomes ABORTED.
+ if (upstream.aborted && error === upstream.reason) return ''
+ throw error
+ }
const marker = markers()
const wrapped = wrapCommand(command, marker)
let first = true
@@ -395,7 +402,8 @@ async function executeCommand(
}
if (commandDeadline.signal.aborted) {
await shells.reset(owner, 'persistent pwsh command aborted')
- commandDeadline.signal.throwIfAborted()
+ // ToolRuntime publishes ABORTED after this cancelled invocation settles.
+ return ''
}
if (latest.text.includes(marker.end)) {
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped)
@@ -457,7 +465,7 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void {
const owner = exec.agent
if (owner === undefined) throw new Error('pwsh requires an owning agent session')
return serialized(owner, async () => {
- exec.signal.throwIfAborted()
+ if (exec.signal.aborted) return '' // ToolRuntime publishes ABORTED after settlement.
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
})
},
diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
index 54d88197e8..c260f818eb 100644
--- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
+++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
@@ -25,6 +25,7 @@ let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+ vi.restoreAllMocks()
})
async function agent(ctx: Context, cwd: string | undefined): Promise {
@@ -544,17 +545,163 @@ describe('tool-pwsh-persistent', () => {
const controller = new AbortController()
const cancelled = call(ctx, owner, 'hang', controller.signal)
const queued = call(ctx, owner, 'after cancellation')
- setTimeout(() => {
- controller.abort(new Error('caller stopped'))
- }, 5)
+ try {
+ await expect.poll(() => stub.sessions[0]!.sends).toBe(3)
+ controller.abort({ kind: 'user' })
- expect((await cancelled).isError).toBe(true)
- expect(text(await queued)).toBe('hello from stub')
- expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted')
- expect(stub.sessions).toHaveLength(2)
+ const result = await cancelled
+ expect(result).toMatchObject({
+ isError: true,
+ error: { message: 'tool call aborted', info: { name: 'AbortError', code: 'ABORTED' } },
+ })
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(text(await queued)).toBe('hello from stub')
+ expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted')
+ expect(stub.sessions).toHaveLength(2)
+ } finally {
+ controller.abort({ kind: 'user' })
+ await Promise.all([cancelled, queued])
+ }
},
)
+ it('settles an aborted queued call without sending it to a replacement shell', async () => {
+ const { ctx, owner, stub } = await setup({ backendType: 'stub' })
+ await call(ctx, owner, 'warm up')
+ const session = stub.sessions[0]!
+ session.mode = 'wait-for-abort'
+ const runningController = new AbortController()
+ // Dispatch observation distinguishes the tool's queue from cancellation before tool entry.
+ const execute = vi.spyOn(ctx.tools.get('pwsh', owner)!, 'execute')
+ const queuedController = new AbortController()
+ const running = call(ctx, owner, 'hang', runningController.signal)
+ const queued = call(ctx, owner, 'never sent', queuedController.signal)
+ try {
+ await expect.poll(() => session.sends).toBe(3)
+ await expect.poll(() => execute.mock.calls.length).toBe(2)
+ queuedController.abort({ kind: 'user' })
+ runningController.abort({ kind: 'user' })
+ const result = await queued
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' })
+ expect(session.sends).toBe(3)
+ expect(stub.sessions).toHaveLength(1)
+ expect(session.closed).toContain('persistent pwsh command aborted')
+ } finally {
+ runningController.abort({ kind: 'user' })
+ queuedController.abort({ kind: 'user' })
+ await Promise.all([running, queued])
+ }
+ })
+
+
+ it('settles cancellation during the first spawn and releases queued work', async () => {
+ const { ctx, owner, stub } = await setup()
+ const started = Promise.withResolvers()
+ const finishSpawn = Promise.withResolvers()
+ const spawn = stub.backend.spawn.bind(stub.backend)
+ vi.spyOn(stub.backend, 'spawn').mockImplementationOnce(async (spec) => {
+ started.resolve(undefined)
+ await finishSpawn.promise
+ spec.signal?.throwIfAborted()
+ return spawn(spec)
+ })
+ const controller = new AbortController()
+ const cancelled = call(ctx, owner, 'never started', controller.signal)
+ const queued = call(ctx, owner, 'after cancellation')
+ try {
+ await started.promise
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(undefined)
+ const result = await cancelled
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(result.error?.info).toEqual({ name: 'AbortError', code: 'ABORTED' })
+ expect(text(await queued)).toBe('hello from stub')
+ expect(stub.sessions).toHaveLength(1)
+ } finally {
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(undefined)
+ await Promise.all([cancelled, queued])
+ }
+ })
+
+ it('waits for rollback when a cancelled spawn returns a shell', async () => {
+ const { ctx, owner, stub } = await setup()
+ const started = Promise.withResolvers()
+ const finishSpawn = Promise.withResolvers()
+ const closing = Promise.withResolvers()
+ const finishClose = Promise.withResolvers()
+ const session = new StubTerminalSession('normal')
+ const close = session.close.bind(session)
+ vi.spyOn(session, 'close').mockImplementation(async (reason) => {
+ closing.resolve(undefined)
+ await finishClose.promise
+ await close(reason)
+ })
+ vi.spyOn(stub.backend, 'spawn').mockImplementationOnce(() => {
+ started.resolve(undefined)
+ return finishSpawn.promise
+ })
+ const controller = new AbortController()
+ let settled = false
+ const cancelled = call(ctx, owner, 'never initialized', controller.signal).then((result) => {
+ settled = true
+ return result
+ })
+ try {
+ await started.promise
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(session)
+ await closing.promise
+ expect(settled).toBe(false)
+ expect(session.sends).toBe(0)
+ finishClose.resolve(undefined)
+ const result = await cancelled
+ expect(text(result)).toBe('Error: tool call aborted')
+ expect(result.error?.info?.code).toBe('ABORTED')
+ expect(session.closed).toContain('PTY spawn rolled back')
+ expect(ctx.terminals.list(owner)).toEqual([])
+ } finally {
+ controller.abort({ kind: 'user' })
+ finishSpawn.resolve(session)
+ finishClose.resolve(undefined)
+ await cancelled
+ }
+ })
+
+ it.each(['initialization', 'command', 'cleanup'] as const)('preserves a distinct %s failure during cancellation', async (phase) => {
+ const { ctx, owner } = await setup()
+ if (phase === 'command') await call(ctx, owner, 'warm up')
+ const controller = new AbortController()
+ const failure = new Error(`${phase} failed`)
+ vi.spyOn(ctx.terminals, 'startSend').mockImplementationOnce(() => {
+ controller.abort({ kind: 'user' })
+ throw phase === 'cleanup' ? new Error('initialization failed') : failure
+ })
+ if (phase === 'cleanup') vi.spyOn(ctx.terminals, 'kill').mockRejectedValueOnce(failure)
+ const result = await call(ctx, owner, 'fails', controller.signal)
+ expect(result.isError).toBe(true)
+ expect(text(result)).toBe(`Error: ${phase} failed`)
+ expect(result.error?.info?.code).not.toBe('ABORTED')
+ })
+
+ it('keeps a spawn deadline failure distinct from caller cancellation', async () => {
+ const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
+ const spawn = stub.backend.spawn.bind(stub.backend)
+ vi.spyOn(stub.backend, 'spawn').mockImplementationOnce(async (spec) => {
+ await new Promise((resolve) => { spec.signal!.addEventListener('abort', () => { resolve() }, { once: true }) })
+ spec.signal?.throwIfAborted()
+ return spawn(spec)
+ })
+ const controller = new AbortController()
+ const result = await call(ctx, owner, 'never started', controller.signal)
+ expect(controller.signal.aborted).toBe(false)
+ expect(result.isError).toBe(true)
+ expect(text(result)).toContain('PERSISTENT_PWSH_TIMEOUT')
+ expect(result.error?.info?.code).not.toBe('ABORTED')
+ expect(stub.sessions).toEqual([])
+ })
+
it.each(['init-exit', 'init-timeout'] as const)(
'fails initialization and closes the unusable shell for %s',
async (mode) => {
diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json
index 33c09b4424..cf2aa8a7fe 100644
--- a/scripts/no-unknown-casts.baseline.json
+++ b/scripts/no-unknown-casts.baseline.json
@@ -671,9 +671,6 @@
"packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx": {
"04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1
},
- "packages/client/ui-plugin-manager/tests/components.client.spec.tsx": {
- "624ce7f0e7acb423fcd3937994cd5ca0916ac27b76cb56a7587025f209a14351": 1
- },
"packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx": {
"6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1
},
diff --git a/scripts/verify-package-meta.ts b/scripts/verify-package-meta.ts
index 70dcc6d7ee..f5a486678f 100644
--- a/scripts/verify-package-meta.ts
+++ b/scripts/verify-package-meta.ts
@@ -22,7 +22,7 @@ function sourceJsonFiles(dir: string): SourceJson[] {
const file = path.replaceAll('\\', '/')
let contents: unknown
try {
- contents = JSON.parse(readFileSync(join(dir, file), 'utf8')) as unknown
+ contents = JSON.parse(readFileSync(join(dir, file), 'utf8'))
} catch (_error) {
return { file, metadata: false, invalid: true }
}
diff --git a/snapshots/web/message-actions/fork.expected.md b/snapshots/web/message-actions/fork.expected.md
index 2adb2d0d6a..dec40e0ea9 100644
--- a/snapshots/web/message-actions/fork.expected.md
+++ b/snapshots/web/message-actions/fork.expected.md
@@ -1,5 +1,5 @@
- tree "Sessions":
- treeitem "Ungrouped" [expanded]
- - treeitem "Use the read tool twice (2) now" [selected]
- - treeitem "Use the read tool twice (1) now"
+ - treeitem "Use the read tool twice (2) now"
+ - treeitem "Use the read tool twice (1) now" [selected]
- treeitem "Use the read tool twice 1min"