Ver Fonte

Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload

creatixchu há 3 semanas atrás
pai
commit
268cf0101e
100 ficheiros alterados com 1163 adições e 199 exclusões
  1. 6 0
      .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml
  2. 36 0
      .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md
  3. 36 0
      .agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml
  6. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md
  7. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md
  8. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml
  9. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md
  10. 35 0
      .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md
  11. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.i18n.yaml
  12. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md
  13. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md
  14. 2 2
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml
  15. 1 1
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md
  16. 1 1
      .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md
  17. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml
  18. 0 0
      .agents/notes/implemented/feature/2026-07-27-web-session-search.md
  19. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md
  20. 2 2
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml
  21. 3 3
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md
  22. 3 3
      .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md
  23. 6 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml
  24. 39 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
  25. 39 0
      .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md
  26. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  27. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  28. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  29. 1 0
      apps/web/tests/agent-preset-authoring.e2e.ts
  30. 2 1
      apps/web/tests/agent-preset-selection.e2e.ts
  31. 0 1
      apps/web/tests/chat-long-interactions.e2e.ts
  32. 4 6
      apps/web/tests/navigation-panes.e2e.ts
  33. 6 1
      apps/web/tests/trajectory-virtualization.e2e.ts
  34. 2 2
      docs/subsystems/workspace.i18n.yaml
  35. 8 8
      docs/subsystems/workspace.md
  36. 8 8
      docs/subsystems/workspace.zh.md
  37. 11 1
      packages/api/session-controller/tests/agent.host.spec.ts
  38. 12 2
      packages/api/session-controller/tests/session-presets.host.spec.ts
  39. 6 1
      packages/api/workspace-controller/tests/workspace-controller.host.spec.ts
  40. 12 3
      packages/boot/app-boot/tests/app-boot.spec.ts
  41. 12 3
      packages/boot/app-boot/tests/config-dump.spec.ts
  42. 10 2
      packages/boot/app-boot/tests/config-reload.spec.ts
  43. 12 1
      packages/boot/app-boot/tests/hmr-config.spec.ts
  44. 11 2
      packages/boot/app-boot/tests/profile.spec.ts
  45. 12 3
      packages/boot/app-boot/tests/user-patches.spec.ts
  46. 6 1
      packages/boot/cmdline/tests/cmdline.spec.ts
  47. 6 1
      packages/bundle/headless/tests/startup.spec.ts
  48. 6 1
      packages/bundle/web-app/tests/startup.spec.ts
  49. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  50. 1 1
      packages/client/ui-trajectory/README.md
  51. 1 1
      packages/client/ui-trajectory/README.zh.md
  52. 2 2
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  53. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  54. 1 1
      packages/client/ui-workspace/README.md
  55. 1 1
      packages/client/ui-workspace/README.zh.md
  56. 14 2
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  57. 60 8
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  58. 15 2
      packages/client/ui-workspace/src/client/tree.ts
  59. 9 1
      packages/client/ui-workspace/tests/tree.client.spec.ts
  60. 156 5
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  61. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  62. 8 3
      packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts
  63. 25 2
      packages/llm/llm-deepseek/tests/file-store.spec.ts
  64. 15 2
      packages/llm/llm-deepseek/tests/upload-index.spec.ts
  65. 13 3
      packages/preset/agent-presets/tests/authoring.spec.ts
  66. 11 1
      packages/preset/agent-presets/tests/composition-inventory.spec.ts
  67. 19 2
      packages/preset/agent-presets/tests/discovery.spec.ts
  68. 9 2
      packages/preset/agent-presets/tests/metadata.spec.ts
  69. 13 1
      packages/preset/agent-presets/tests/mount.spec.ts
  70. 13 2
      packages/preset/agent-presets/tests/remote.spec.ts
  71. 10 2
      packages/preset/agent-presets/tests/settings.spec.ts
  72. 6 3
      packages/preset/agent-presets/tests/shipped-root.spec.ts
  73. 8 2
      packages/preset/agent-presets/tests/user-root.spec.ts
  74. 16 3
      packages/sandbox/sandbox-local/tests/local.spec.ts
  75. 15 8
      packages/sandbox/sandbox/tests/roots.spec.ts
  76. 6 2
      packages/shell/bash-local/tests/executor.spec.ts
  77. 16 7
      packages/shell/bash-sandbox/tests/sandbox.spec.ts
  78. 16 2
      packages/shell/pwsh-local/tests/executor.spec.ts
  79. 6 2
      packages/shell/tool-bash/tests/tools.spec.ts
  80. 10 2
      packages/shell/tool-pwsh/tests/tools.spec.ts
  81. 10 2
      packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts
  82. 10 2
      packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts
  83. 11 3
      packages/skill/tool-skill/tests/tool-skill.spec.ts
  84. 2 2
      packages/subprocess/subprocess-local/README.i18n.yaml
  85. 3 3
      packages/subprocess/subprocess-local/README.md
  86. 3 3
      packages/subprocess/subprocess-local/README.zh.md
  87. 1 1
      packages/subprocess/subprocess-local/src/index.ts
  88. 33 6
      packages/subprocess/subprocess-local/src/spawn.ts
  89. 4 1
      packages/subprocess/subprocess-local/src/windows-inspector.ts
  90. 52 2
      packages/subprocess/subprocess-local/tests/spawn.spec.ts
  91. 27 1
      packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts
  92. 2 2
      packages/util/workspace-path/README.i18n.yaml
  93. 1 1
      packages/util/workspace-path/README.md
  94. 1 1
      packages/util/workspace-path/README.zh.md
  95. 2 1
      packages/util/workspace-path/src/index.ts
  96. 6 0
      packages/util/workspace-path/tests/index.spec.ts
  97. 2 2
      packages/workspace/workspace/README.i18n.yaml
  98. 1 1
      packages/workspace/workspace/README.md
  99. 1 1
      packages/workspace/workspace/README.zh.md
  100. 9 10
      packages/workspace/workspace/src/index.ts

+ 6 - 0
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-session-search-result-reveal.md
+2026-09-03-session-search-result-reveal.md: dc43d5e06febf61852d4b8dcdd0b5ca290f623e0
+2026-09-03-session-search-result-reveal.zh.md: 0982498f5602ee18b20368d2b0dc04ab1ffae7f0

+ 36 - 0
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md

@@ -0,0 +1,36 @@
+# Agent Note: Session search result reveal
+
+Status: implemented
+Archived: 2026-09-03
+
+English | [中文](2026-09-03-session-search-result-reveal.zh.md)
+
+## Problem
+
+Selecting a Session search result opened its conversation while leaving the sidebar in the filtered search view. The user could not see where the Session belonged in the normal Workspace hierarchy. Clearing search alone was insufficient because the owning Workspace could be closed, the Session could be hidden beyond the five-row fold, and either grouped or flat navigation could place the row outside the scrollport.
+
+## Decision
+
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It records the target Session id, clears the query, collapses search, and opens the Session. In grouped browsing, `SessionTree` waits until the current Workspace stream has a complete Host baseline, derives and opens the owning Workspace or Ungrouped group, and transiently reveals the hidden remainder only when the target is behind the five-row fold. Flat browsing needs no fold override.
+
+The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Starting another non-empty search cancels an unacknowledged reveal. Metadata and content matches use the same transition because both result kinds resolve to a Session id.
+
+## Alternatives considered
+
+**Preserve the query after opening the Session.** This keeps the discovery context but leaves the user in the temporary result list and does not identify the Session's normal location.
+
+**Clear search without opening or unfolding the owning group.** The conversation would open while its selected row could remain hidden, reproducing the missing-location problem in a different sidebar state.
+
+**Persist an expanded-all preference for the group.** One navigation would permanently replace the bounded five-row presentation. The reveal instead expands the remainder only for the current tree mount.
+
+**Scroll from the browser parent.** The parent cannot complete the operation before a folded target row mounts. The row that owns the DOM element performs and acknowledges the scroll.
+
+## Consequences
+
+Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount when required, so the selected row supplies both hierarchy context and an on-screen location. Waiting for the current Workspace baseline prevents a reconnect's retained membership from acknowledging the reveal before replacement state arrives. A later search or ordinary render does not repeat the scroll after acknowledgment.
+
+The target row is the only completion signal. If another client archives or moves the Session between result selection and row mount, the reveal remains armed; the row will scroll if it mounts later, unless a new non-empty search cancels the reveal or the browser unmounts.
+
+## Testing
+
+UI tests cover a content-only hit in the sixth position of a closed Workspace, pending and reconnecting Workspace baselines, cancellation by a new search, transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition.

+ 36 - 0
.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md

@@ -0,0 +1,36 @@
+# Agent Note: Session 搜索结果显露
+
+Status: implemented
+Archived: 2026-09-03
+
+[English](2026-09-03-session-search-result-reveal.md) | 中文
+
+## 问题
+
+选择 Session 搜索结果会打开其对话,但侧边栏仍停留在筛选后的搜索视图。用户无法在常规 Workspace 层级中看到该 Session 的所属位置。仅清空搜索还不够,因为所属 Workspace 可能处于关闭状态,Session 可能隐藏在五行折叠之后,而分组或单列表导航都可能把该行放在滚动区域之外。
+
+## 决策
+
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会记录目标 Session id、清空查询、收起搜索并打开 Session。在分组浏览中,`SessionTree` 会等待当前 Workspace stream 取得完整 Host 基线,再解析并打开所属 Workspace 或 Ungrouped 分组;仅当目标位于五行折叠之后时,才会临时显露其余隐藏条目。单列表浏览不需要覆盖折叠状态。
+
+常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。开始另一项非空搜索会取消尚未确认的显露。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。
+
+## 考虑过的替代方案
+
+**打开 Session 后保留查询。** 这会保留发现上下文,却仍让用户停留在临时结果列表中,无法识别 Session 在常规层级中的位置。
+
+**只清空搜索,不打开或展开所属分组。** 对话会打开,但选中行仍可能隐藏,只是把缺失位置的问题带到另一种侧边栏状态。
+
+**为分组持久保存全部展开偏好。** 一次导航会永久取代有界的五行呈现。本次显露只在当前树挂载期间展开其余条目。
+
+**由浏览器父组件执行滚动。** 折叠的目标行挂载前,父组件无法完成操作。持有 DOM 元素的行负责滚动并确认完成。
+
+## 后果
+
+选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,并在需要时于该次树挂载期间显示隐藏条目,因此选中行同时提供层级上下文与屏幕内位置。等待当前 Workspace 基线可避免重连期间保留的旧归属信息在替换状态到达前确认显露。确认完成后,后续搜索或普通渲染不会重复滚动。
+
+目标行是唯一的完成信号。如果另一客户端在结果选择与行挂载之间归档或移动了 Session,显露会保持待处理;除非新的非空搜索取消显露或浏览器卸载,否则该行以后挂载时仍会触发滚动。
+
+## 测试
+
+UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、待定与重连中的 Workspace 基线、新搜索取消显露、临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -145,6 +145,9 @@
     "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23",
     "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16",
+    "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5",
+    "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9",
+    "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe",
     "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918",
     "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b",
     "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69",

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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/bug-fix/2026-07-31-same-basename-workspace-adoption.md
-2026-07-31-same-basename-workspace-adoption.md: a634972448d8a3fea3d08c5713602cec0584a44e
-2026-07-31-same-basename-workspace-adoption.zh.md: 82be4aee3b3da2e9fb02783f1cd9bf470802cae0
+2026-07-31-same-basename-workspace-adoption.md: d7c4f7a9d67d760dccbdc536b4894292a171ee3e
+2026-07-31-same-basename-workspace-adoption.zh.md: 968863a24c6e7c6237d814f20e9055fd029178a4

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md

@@ -6,11 +6,11 @@ English | [中文](2026-07-31-same-basename-workspace-adoption.zh.md)
 
 ## Problem
 
-A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its basename-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
+A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its directory-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id.
 
 ## Decision
 
-`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record still derives its title from `basename(path)` without suffixing or rewriting it.
+`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record derives its title from the final path segment, falling back to the root spelling when that segment is empty. The [fully qualified Workspace path decision](2026-09-03-fully-qualified-workspace-paths.md) owns path admission and the title fallback.
 
 The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation.
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md

@@ -6,11 +6,11 @@ Status: implemented
 
 ## 问题
 
-Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按 basename 派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
+Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按目录名称派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。
 
 ## 决策
 
-`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录仍从 `basename(path)` 派生标题,不添加后缀,也不改写标题。
+`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录从最终路径段派生标题,该路径段为空时回退到根路径拼写。[Workspace 完全限定路径决策](2026-09-03-fully-qualified-workspace-paths.zh.md)负责规定路径准入和标题回退。
 
 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md
+2026-09-03-fully-qualified-workspace-paths.md: 0bc6215ec5f6485856153b5a5c59d0ed68f485bb
+2026-09-03-fully-qualified-workspace-paths.zh.md: b8e67f9d34fc8ccb7759c95017dd3ab4fde56113

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md

@@ -0,0 +1,35 @@
+# Agent Note: Fully qualified Workspace paths
+
+Status: implemented
+
+English | [中文](2026-09-03-fully-qualified-workspace-paths.zh.md)
+
+## Problem
+
+Workspace path identity must name one directory independently of process state. POSIX relative paths, Windows drive-relative paths such as `C:work`, and Windows root-relative paths such as `\\work` can resolve against the Host cwd or the current directory retained for a drive. Passing those spellings to `realpath` can therefore register a different directory when host state changes. Filesystem roots also have an empty basename, which can create an empty default Workspace title.
+
+Windows drive roots need separate handling in browser-safe relative-path joins. Removing the trailing separator from `C:\\` produces `C:`, which changes an absolute path into a drive-relative path.
+
+## Decision
+
+`WorkspaceRegistry.create()` and `resolveByPath()` reject paths that are not fully qualified before calling `realpath`. POSIX requires an absolute path. Windows requires `win32.isAbsolute(path)` plus a parsed root that is neither `\\` nor `/`; this accepts drive-qualified and UNC paths while rejecting current-drive-root and drive-relative spellings without maintaining a second path grammar.
+
+Canonical paths remain the registry identity. A default title uses the final path segment, or `node:path`'s parsed root when that segment is empty. This refines the display rule owned by [same-basename Workspace adoption](2026-07-31-same-basename-workspace-adoption.md) without making titles unique.
+
+The browser-safe `resolveWorkspacePath()` removes trailing separators only after choosing the separator from the Workspace spelling. Backslash drive and UNC paths keep `\\`; forward-slash drive paths keep `/`; joining a drive root always retains the separator after the colon.
+
+## Alternatives considered
+
+**Resolve relative paths against the Host cwd.** Rejected because Workspace identity would depend on process state that callers do not supply and remote clients cannot observe.
+
+**Resolve relative paths against another stored Workspace.** Rejected because create and lookup requests do not identify such an anchor, and guessing one would make the same path spelling address different records.
+
+**Maintain a regular expression for drive and UNC syntax.** Rejected because `node:path.win32` already parses roots and absolute paths; a second grammar can diverge on separator variants and UNC roots.
+
+**Use an empty title for filesystem roots.** Rejected because the title is the primary Workspace label. The root spelling is short, stable, and already distinguishes drive and UNC roots.
+
+## Consequences
+
+Callers must submit fully qualified Workspace paths. Invalid path spellings fail before filesystem access, while nonexistent fully qualified paths still return the original filesystem error. Windows drive and UNC roots remain valid identities and have non-empty default titles; an UNC share root uses the share name as its final segment.
+
+Workspace-relative joins preserve the separator style already present in the Workspace root. Unit tests cover POSIX roots, drive roots, UNC roots, rejected drive-relative and current-drive-root paths, and both Windows separator styles.

+ 35 - 0
.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: Workspace 完全限定路径
+
+Status: implemented
+
+[English](2026-09-03-fully-qualified-workspace-paths.md) | 中文
+
+## 问题
+
+Workspace 路径身份必须在不依赖进程状态的情况下指向唯一目录。POSIX 相对路径、`C:work` 等 Windows 驱动器相对路径,以及 `\\work` 等 Windows 当前驱动器根相对路径,可能依据宿主 cwd 或驱动器保留的当前目录完成解析。把这些拼写传给 `realpath`,会在宿主状态变化时注册不同目录。文件系统根目录的 basename 也为空,可能产生空的默认 Workspace 标题。
+
+浏览器安全的相对路径连接还需要单独处理 Windows 驱动器根。移除 `C:\\` 的尾部分隔符会得到 `C:`,从而把绝对路径变成驱动器相对路径。
+
+## 决策
+
+`WorkspaceRegistry.create()` 和 `resolveByPath()` 会在调用 `realpath` 前拒绝不是完全限定形式的路径。POSIX 要求绝对路径。Windows 要求 `win32.isAbsolute(path)`,且解析出的根既不是 `\\` 也不是 `/`;该规则接受驱动器限定路径与 UNC 路径,同时无需维护第二套路径语法即可拒绝当前驱动器根拼写和驱动器相对拼写。
+
+规范路径继续作为注册表身份。默认标题使用最终路径段;该路径段为空时,则使用 `node:path` 解析出的根。该规则细化了[接纳 basename 相同 Workspace](2026-07-31-same-basename-workspace-adoption.zh.md)拥有的显示规则,但不会要求标题唯一。
+
+浏览器安全的 `resolveWorkspacePath()` 会先根据 Workspace 拼写选择分隔符,再移除尾部分隔符。使用反斜杠的驱动器与 UNC 路径保留 `\\`,使用正斜杠的驱动器路径保留 `/`,与驱动器根连接时始终保留冒号后的分隔符。
+
+## 考虑过的替代方案
+
+**依据宿主 cwd 解析相对路径。** 不予采纳,因为 Workspace 身份将依赖调用方未提供、远程客户端无法观察的进程状态。
+
+**依据另一个已存储 Workspace 解析相对路径。** 不予采纳,因为 create 和 lookup 请求没有指定这种锚点,猜测锚点会让同一路径拼写指向不同记录。
+
+**维护用于驱动器和 UNC 语法的正则表达式。** 不予采纳,因为 `node:path.win32` 已经解析根和绝对路径;第二套语法可能在分隔符变体和 UNC 根上发生偏差。
+
+**为文件系统根目录使用空标题。** 不予采纳,因为标题是 Workspace 的主要标签。根路径拼写简短、稳定,并且已经可以区分驱动器与 UNC 根。
+
+## 后果
+
+调用方必须提交完全限定的 Workspace 路径。无效路径拼写会在文件系统访问前失败,而不存在的完全限定路径仍返回原始文件系统错误。Windows 驱动器根与 UNC 根继续作为有效身份,并具有非空默认标题;UNC share 根使用 share 名称作为最终路径段。
+
+Workspace 相对路径连接会保留 Workspace 根中已有的分隔符风格。单元测试覆盖 POSIX 根、驱动器根、UNC 根、被拒绝的驱动器相对路径与当前驱动器根路径,以及两种 Windows 分隔符风格。

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md
+2026-09-03-hidden-windows-subprocess-windows.md: 97af84ddc0a51483fa0f1147caee0ea0374c240d
+2026-09-03-hidden-windows-subprocess-windows.zh.md: d8f986278e974342cd24abd6fd3192165762c1f9

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md

@@ -0,0 +1,29 @@
+# Agent Note: Suppressing Windows subprocess windows
+
+Status: implemented
+
+English | [中文](2026-09-03-hidden-windows-subprocess-windows.zh.md)
+
+## Problem
+
+The local subprocess provider can run under a GUI or service host with no visible console. Windows creates a new visible window for a child process when the host does not supply one, so an ordinary command or a `taskkill` helper can flash and take focus even though the harness has no user-facing terminal for that process.
+
+## Decision
+
+The provider sets `windowsHide: true` on every non-terminal `spawn` and on both synchronous `taskkill` call sites. The main child uses this option only for the Windows execution path; `taskkill` is itself Windows-only. Terminal processes retain the visibility and console behavior owned by the PTY implementation.
+
+The option hides console windows and GUI windows that honor the Windows process startup visibility setting. Callers do not choose this behavior because the local provider owns whether its background process management creates host windows.
+
+## Alternatives considered
+
+**Hide only the main child.** Rejected because cancellation, timeout escalation, terminal teardown, and host-exit cleanup can still launch `taskkill` and flash a console window.
+
+**Expose a caller option.** Rejected because consumers cannot reliably know whether the local host has a console, and inconsistent choices would reintroduce focus-stealing process-management windows.
+
+**Hide only console programs.** Rejected because Node exposes one Windows startup option rather than a reliable pre-spawn executable classification, and probing the target would add platform-specific races without preserving a useful product behavior.
+
+## Consequences
+
+Background subprocess operations do not create visible Windows child or `taskkill` windows. A directly launched GUI program that honors the startup visibility setting also starts hidden; consumers that need an interactive visible application must use a capability that owns that user interaction instead of the background subprocess provider.
+
+Unit tests inject the process launchers and pin `windowsHide` for the main child and both `taskkill` paths without creating host-global windows or terminating real processes.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 隐藏 Windows 子进程窗口
+
+Status: implemented
+
+[English](2026-09-03-hidden-windows-subprocess-windows.md) | 中文
+
+## 问题
+
+本地 subprocess provider 可以在没有可见控制台的 GUI 或服务宿主中运行。宿主未提供控制台时,Windows 会为子进程创建新的可见窗口,因此普通命令或 `taskkill` 辅助进程可能闪现并抢占焦点,即使 harness 并未为该进程提供面向用户的 terminal。
+
+## 决策
+
+provider 对每次非 terminal `spawn` 以及两处同步 `taskkill` 调用都设置 `windowsHide: true`。主子进程只在 Windows 执行路径使用此选项;`taskkill` 本身只用于 Windows。terminal 进程继续采用 PTY 实现拥有的可见性与控制台行为。
+
+该选项会隐藏控制台窗口,以及遵循 Windows 进程启动可见性设置的 GUI 窗口。调用方不能选择此行为,因为本地 provider 负责决定其后台进程管理是否创建宿主窗口。
+
+## 考虑过的替代方案
+
+**只隐藏主子进程。** 不予采纳,因为取消、超时升级、terminal 拆卸与宿主退出清理仍可能启动 `taskkill` 并闪现控制台窗口。
+
+**暴露调用方选项。** 不予采纳,因为消费方无法可靠判断本地宿主是否拥有控制台,不一致的选择会重新引入抢占焦点的进程管理窗口。
+
+**只隐藏控制台程序。** 不予采纳,因为 Node 只暴露一个 Windows 启动选项,无法在 spawn 前可靠区分可执行文件类型;探测目标还会增加平台特定竞态,却不能保留有用的产品行为。
+
+## 后果
+
+后台 subprocess 操作不会创建可见的 Windows 子进程或 `taskkill` 窗口。直接启动且遵循启动可见性设置的 GUI 程序也会以隐藏方式运行;需要交互式可见应用的消费方必须使用拥有该用户交互的能力,而不是后台 subprocess provider。
+
+单元测试注入进程 launcher,固定主子进程和两条 `taskkill` 路径的 `windowsHide`,且不会创建宿主全局窗口或终止真实进程。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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-07-27-trajectory-inspection-ledger.md
-2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f
-2026-07-27-trajectory-inspection-ledger.zh.md: 6a38147a075480882db5c39a0810404654c6c849
+2026-07-27-trajectory-inspection-ledger.md: 75f8480d3e52c6cc1f2eefd7ef9d5a2c03b40dee
+2026-07-27-trajectory-inspection-ledger.zh.md: 82af4904d90a6fbfc811d176dd4b859b26d4774c

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md

@@ -23,7 +23,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested
 - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels.
 - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes.
 - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation.
-- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
+- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. The virtualizer's stable-key `followOnAppend` behavior owns structural appends while the user remains at the bottom; the component writes the tail position itself only for a non-virtual ledger. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write.
 - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window.
 - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger.
 - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay.

+ 1 - 1
.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md

@@ -23,7 +23,7 @@ Status: implemented
 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。
 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早的 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失。
 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。
-- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
+- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。用户保持在底部时,虚拟化器基于稳定键的 `followOnAppend` 行为负责结构性追加;组件只对非虚拟记录表自行写入末尾位置。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。
 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。
 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。
 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-session-search.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-07-27-web-session-search.md
-2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2
-2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0
+2026-07-27-web-session-search.md: d7a979a278113f9dcc170ec12f9d0fb37e1d55b7
+2026-07-27-web-session-search.zh.md: ba125ba884a1c93f3d2ac23b7f750c6ca216ed5f

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
.agents/notes/implemented/feature/2026-07-27-web-session-search.md


+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md

@@ -14,7 +14,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
 
 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。
 
-[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。
+[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行会打开对应 Session、退出搜索,并在常规浏览器中显露选中行;不会跳转至确切事件。
 
 结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。
 
@@ -41,4 +41,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据
 
 ## 测试
 
-宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。
+宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证搜索退出且选中的 Session 在常规浏览器中可见。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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-08-11-workspace-sidebar-order-and-folding.md
-2026-08-11-workspace-sidebar-order-and-folding.md: 4582631da79c946a2d3a713bc0d3a085ebed4cd0
-2026-08-11-workspace-sidebar-order-and-folding.zh.md: 6dfd443599b6c6696bbe12c19ebcde411d86a517
+2026-08-11-workspace-sidebar-order-and-folding.md: a8026936905d42ccf5f30a02684310324d8ee359
+2026-08-11-workspace-sidebar-order-and-folding.zh.md: 83a66c9b4a5f8dd34b56c6d8a8af2d18f77e983b

+ 3 - 3
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md

@@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio
 
 ### Session folding and view order
 
-Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
+Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result waits for the current Workspace stream's complete Host baseline before opening its owning group, and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts.
 
 The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot.
 
@@ -51,9 +51,9 @@ Search is a header action while collapsed and expands across the title and trail
 - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account.
 - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position.
 - A newly selected blank New Session row enters grouped and flat orders first once. Grouped folding renders it in addition to five non-blank rows until its first prompt, then applies the ordinary quota.
-- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture; the selected blank New Session may add one provisional row. Closing the Workspace resets only the transient gesture.
+- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture or search-result navigation; the selected blank New Session may add one provisional row. Closing the Workspace resets transient remainder expansion.
 - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md).
 
 ## Testing
 
-Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session.
+Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, initial and reconnecting Workspace-baseline waits, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session.

+ 3 - 3
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md

@@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
 
 ### Session 折叠与视图顺序
 
-每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
+每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等待当前 Workspace stream 取得完整 Host 基线后再打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。
 
 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。
 
@@ -51,9 +51,9 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行
 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。
 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。
 - 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次。分组折叠会在五条非空白行之外额外渲染该行,直至首条提示词落地后恢复普通配额。
-- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组只重置这项临时手势。
+- 未执行明确的**展开其余**手势或搜索结果导航时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组会重置临时的其余条目展开状态。
 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.zh.md)确立的手动顺序含义。
 
 ## 测试
 
-领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。
+领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、初始与重连 Workspace 基线等待、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。

+ 6 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md
+2026-08-28-test-temp-dir-self-cleanup.md: 175fe82b0ad01c2afda3abb5f66ff455045a3cd6
+2026-08-28-test-temp-dir-self-cleanup.zh.md: c2fcd07d1619d1556ac99b336f27b89a5f46e426

+ 39 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md

@@ -0,0 +1,39 @@
+# Agent Note: Unit tests remove the dsh-* temp dirs they create
+
+Status: implemented
+
+English | [中文](2026-08-28-test-temp-dir-self-cleanup.zh.md)
+
+## Problem
+
+Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead.
+
+## Decision
+
+Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning test's teardown:
+
+- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point.
+- Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test.
+- The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged.
+- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampled `dsh-subprocess-*` dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference.
+
+## Verification
+
+- Targeted local runs of every changed unit spec passed (the 36 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane.
+- CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host.
+
+## Alternatives considered
+
+### Keep the sweep-only approach (rejected in review)
+
+Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path.
+
+### Introduce a shared temp-dir helper package
+
+Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit.
+
+## Consequences
+
+- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown; a `dsh-subprocess-local` per-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit.
+- Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path.
+- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep.

+ 39 - 0
.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md

@@ -0,0 +1,39 @@
+# Agent Note:单测删除自己创建的 dsh-* 临时目录
+
+Status: implemented
+
+[English](2026-08-28-test-temp-dir-self-cleanup.md) | 中文
+
+## Problem
+
+测试进程用 `mkdtemp(join(tmpdir(), 'dsh-*'))` 创建 `/tmp/dsh-*` 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 `/tmp`),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 `dsh-tmp-sweep` timer 与 CI lane sweep(保留未合并,在分支 `fix/ci-tmp-residue-cleanup` 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。
+
+## Decision
+
+为 spec 文件创建的每个 `dsh-*` 临时目录补上删除路径,挂在所属测试的 teardown 上:
+
+- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。
+- 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。
+- 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。
+- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时,仅当目录**未持有任何已完成的 spill 文件**才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样 `dsh-subprocess-*` 目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。
+
+## Verification
+
+- 本地定向跑过全部改动单测 spec 通过(36 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。
+- CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。
+
+## Alternatives considered
+
+### 保留纯 sweep 方案(review 否决)
+
+Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。
+
+### 引入共享临时目录 helper 包
+
+未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。
+
+## Consequences
+
+- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;`dsh-subprocess-local` 未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。
+- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。
+- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 5a88c49b0811e80c19e3a39cf5604a74460fc3e2
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 829b07b789c2cfe74bcaf3b9db03acffd156887b
+2026-07-24-web-gui-browser-e2e-lane.md: 2047351b5642664a800fb048acee56dc80dcc77d
+2026-07-24-web-gui-browser-e2e-lane.zh.md: dad0f69d64dbb802d3a5db097720bb97c30ea8cf

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -28,6 +28,8 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side
 
 No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
 
+Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.
+
 Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home.
 
 ### Expected outputs

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -28,6 +28,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。
 
+分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。
+
 每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。
 
 ### 预期输出

+ 1 - 0
apps/web/tests/agent-preset-authoring.e2e.ts

@@ -65,6 +65,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
+    await rm(userRoot, { recursive: true, force: true })
   })
 
   it('offers the roster with copy as the only way to create', async () => {

+ 2 - 1
apps/web/tests/agent-preset-selection.e2e.ts

@@ -9,7 +9,7 @@
 //
 // Zero model calls: no replay fixture mounts, so a stray stream fails loud.
 import { fileURLToPath } from 'node:url'
-import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import type { Browser, Page } from 'playwright'
@@ -232,6 +232,7 @@ describe('web e2e: agent-preset selection', () => {
   afterAll(async () => {
     await browser?.close()
     await scaffold?.close()
+    await rm(presetRoot, { recursive: true, force: true })
   })
 
   it('offers the chip on the new-session screen, beside the workspace picker', async () => {

+ 0 - 1
apps/web/tests/chat-long-interactions.e2e.ts

@@ -90,7 +90,6 @@ async function openSeed(page: Page): Promise<void> {
   const resultCount = await results.count()
   if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
   await results.click()
-  await results.click()
   await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
     .last().waitFor({ timeout: 30_000 })
   await nextPaint(page)

+ 4 - 6
apps/web/tests/navigation-panes.e2e.ts

@@ -208,14 +208,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
 
     await result.click()
-    // Search navigation addresses the session, not a specific event, and the
-    // query remains until the user explicitly clears it.
-    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
+    // Search navigation returns to the browser with the opened Session row exposed.
+    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
+    const selectedRow = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"][aria-selected="true"]')
+    await expect.poll(() => selectedRow.count(), { timeout: 10_000 }).toBe(1)
     await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
     await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
-    await page.getByRole('button', { name: 'Clear search' }).click()
-    await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
-    await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
   }, 90_000)
 
   it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {

+ 6 - 1
apps/web/tests/trajectory-virtualization.e2e.ts

@@ -155,10 +155,15 @@ async function rowTop(page: Page, key: string): Promise<number | null> {
 
 async function loadToFirstTurn(page: Page): Promise<void> {
   const marker = FIXTURE.markers.user(1)
+  const loadMore = page.locator('[data-history-load] button')
   for (let attempt = 0; attempt < 12; attempt += 1) {
-    await scrollToRatio(page, 0)
     if (await page.getByText(marker, { exact: false }).count() > 0) return
+    await expect.poll(() => loadMore.evaluateAll(buttons =>
+      buttons.length === 0 || !(buttons[0] as HTMLButtonElement).disabled,
+    ), { timeout: 15_000 }).toBe(true)
     const before = await logicalRows(page)
+    await scrollToRatio(page, 0)
+    if (await page.getByText(marker, { exact: false }).count() > 0) return
     const anchor = await firstVisibleRow(page)
     await expect.poll(async () => ({
       marker: await page.getByText(marker, { exact: false }).count() > 0,

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/workspace.md
-workspace.md: 82bbe310d490653bae5d88f41d1fcbe76833b431
-workspace.zh.md: 9bf63b7a90f9136173fbec16b55a22821fa380e5
+workspace.md: 3c53300a5be6678802da950d22854da76fa46bf1
+workspace.zh.md: b651a21f7641619c28bad9d3fdf368c718ca2c29

+ 8 - 8
docs/subsystems/workspace.md

@@ -40,7 +40,7 @@ interface Workspace {
    */
   readonly path: string
 
-  /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
+  /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */
   readonly title: string
 
   /** ISO-8601 creation instant, stamped at create and never rewritten. */
@@ -117,7 +117,7 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session
 
 ## The registry: `ctx.workspaceRegistry`
 
-`WorkspaceRegistry` ([signatures](#ctxworkspaceregistry--workspaceregistry)) owns registration and resolution. `create(path, title?)` canonicalizes the path, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? basename(path)` prepended to the durable registry order (different canonical paths may share a display title). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — and an unmarked order/table mismatch fails loud as corruption.
+`WorkspaceRegistry` ([signatures](#ctxworkspaceregistry--workspaceregistry)) owns registration and resolution. `create(path, title?)` requires a fully qualified path, canonicalizes it, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? defaultWorkspaceTitle(path)` prepended to the durable registry order (different canonical paths may share a display title, and a path with no final segment uses its root spelling). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same fully qualified realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — and an unmarked order/table mismatch fails loud as corruption.
 
 Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`.
 
@@ -250,13 +250,13 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c
 
 ```ts cordis-catalog
 /**
- * Create or reuse a workspace for an existing directory. The path is
- * canonicalized through `fs.realpath`; a nonexistent path rejects with the
- * original error and a non-directory rejects. Repeated calls for the same
- * canonical path return the existing entity without changing its title.
+ * Create or reuse a workspace for an existing directory. The fully qualified
+ * path is canonicalized through `fs.realpath`; a relative, nonexistent, or
+ * non-directory path rejects. Repeated calls for the same canonical path
+ * return the existing entity without changing its title.
  * A newly created workspace is prepended to the durable registry order.
  * Different canonical paths may share a display title.
- * @param path - Existing directory to own, in any path spelling.
+ * @param path - Existing directory to own, in a fully qualified path spelling.
  * @param title - Display title used only when a new record is created.
  * @returns the existing or newly durable workspace.
  */
@@ -309,7 +309,7 @@ archiveSession(sessionId: SessionId): Promise<void>
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
  * directory returns `undefined`.
- * @param path - Existing directory path in any spelling.
+ * @param path - Existing directory path in a fully qualified spelling.
  * @returns the workspace owning the canonical path, when one exists.
  */
 async resolveByPath(path: string): Promise<Workspace | undefined>

+ 8 - 8
docs/subsystems/workspace.zh.md

@@ -40,7 +40,7 @@ interface Workspace {
    */
   readonly path: string
 
-  /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
+  /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */
   readonly title: string
 
   /** ISO-8601 creation instant, stamped at create and never rewritten. */
@@ -117,7 +117,7 @@ interface Workspace {
 
 ## 注册表:`ctx.workspaceRegistry`
 
-`WorkspaceRegistry`([签名](#ctxworkspaceregistry--workspaceregistry))拥有注册与解析。`create(path, title?)` 规范化路径,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? basename(path)` 的记录并前插到持久的注册表顺序中(不同规范路径可以共享同一显示标题)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete,并回滚被中断的 create(注册可以重建,因此回滚是安全方向)——而没有标记的顺序/表不一致则作为损坏大声失败。
+`WorkspaceRegistry`([签名](#ctxworkspaceregistry--workspaceregistry))拥有注册与解析。`create(path, title?)` 要求完全限定路径并将其规范化,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? defaultWorkspaceTitle(path)` 的记录并前插到持久的注册表顺序中(不同规范路径可以共享同一显示标题,没有最终路径段时使用根路径拼写)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套完全限定 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete,并回滚被中断的 create(注册可以重建,因此回滚是安全方向)——而没有标记的顺序/表不一致则作为损坏大声失败。
 
 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。
 
@@ -250,13 +250,13 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c
 
 ```ts cordis-catalog
 /**
- * Create or reuse a workspace for an existing directory. The path is
- * canonicalized through `fs.realpath`; a nonexistent path rejects with the
- * original error and a non-directory rejects. Repeated calls for the same
- * canonical path return the existing entity without changing its title.
+ * Create or reuse a workspace for an existing directory. The fully qualified
+ * path is canonicalized through `fs.realpath`; a relative, nonexistent, or
+ * non-directory path rejects. Repeated calls for the same canonical path
+ * return the existing entity without changing its title.
  * A newly created workspace is prepended to the durable registry order.
  * Different canonical paths may share a display title.
- * @param path - Existing directory to own, in any path spelling.
+ * @param path - Existing directory to own, in a fully qualified path spelling.
  * @param title - Display title used only when a new record is created.
  * @returns the existing or newly durable workspace.
  */
@@ -309,7 +309,7 @@ archiveSession(sessionId: SessionId): Promise<void>
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
  * directory returns `undefined`.
- * @param path - Existing directory path in any spelling.
+ * @param path - Existing directory path in a fully qualified spelling.
  * @returns the workspace owning the canonical path, when one exists.
  */
 async resolveByPath(path: string): Promise<Workspace | undefined>

+ 11 - 1
packages/api/session-controller/tests/agent.host.spec.ts

@@ -1,4 +1,4 @@
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
@@ -22,8 +22,12 @@ import { installSessionReadTestServices, testSessionPersistence } from './test-r
 
 const roots: Context[] = []
 
+/** Session cwd roots created per test, removed after their context settles. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
 })
 
 async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
@@ -297,6 +301,7 @@ describe('ApiSession create or adoption', () => {
   it('shares one in-flight creation between concurrent callers', async () => {
     const { ctx, agents } = await harness()
     const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
+    tempDirs.push(cwd)
     const meta = header('concurrent-create', cwd)
     const created = unpublishedAgent(ctx, meta)
     let release!: () => void
@@ -317,6 +322,7 @@ describe('ApiSession create or adoption', () => {
   it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
     const ordinary = await harness()
     const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
+    tempDirs.push(cwd)
     const ordinaryMeta = header('create-race', cwd)
     const winner = agent(ordinary.ctx, ordinaryMeta)
     vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
@@ -328,6 +334,7 @@ describe('ApiSession create or adoption', () => {
 
     const child = await harness()
     const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
+    tempDirs.push(childCwd)
     const childId = SessionId('create-child-race')
     vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
       child.ctx.sessions.create(childId, {
@@ -342,6 +349,7 @@ describe('ApiSession create or adoption', () => {
   it('validates ownership and cwd on the Agent returned by creation', async () => {
     const child = await harness()
     const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
+    tempDirs.push(childCwd)
     const childMeta = {
       ...header('returned-child', childCwd),
       parentSession: SessionId('parent'),
@@ -357,6 +365,7 @@ describe('ApiSession create or adoption', () => {
 
     const wrong = await harness()
     const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
+    tempDirs.push(requestedCwd)
     const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
     vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
       agent: wrongAgent,
@@ -437,6 +446,7 @@ describe('ApiSession create or adoption', () => {
   it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
     const { agents } = await harness()
     const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
+    tempDirs.push(parent)
     const file = join(parent, 'file')
     writeFileSync(file, 'not a directory')
     await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))

+ 12 - 2
packages/api/session-controller/tests/session-presets.host.spec.ts

@@ -1,6 +1,6 @@
 /** Session creation and adoption rules for Agent preset identity. */
 
-import { mkdtempSync, realpathSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
@@ -10,9 +10,17 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session } from '@deepseek-ai/dsh-session'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { createSessionTestRemote } from './test-remote.ts'
 
+/** Booted contexts and their temp roots, torn down after each test. */
+const contexts: Context[] = []
+const tempDirs: string[] = []
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 function stubAgent(session: Session): Agent {
   return { id: session.id, session, status: 'idle' } as unknown as Agent
 }
@@ -42,7 +50,9 @@ function roster(ids: readonly string[]): unknown {
 
 async function harness(presets?: readonly string[]) {
   const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-')))
+  tempDirs.push(cwd)
   const ctx = new Context()
+  contexts.push(ctx)
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   if (presets !== undefined) {

+ 6 - 1
packages/api/workspace-controller/tests/workspace-controller.host.spec.ts

@@ -1,4 +1,4 @@
-import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs'
+import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -22,8 +22,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
 
 const roots: Context[] = []
 
+/** Workspace roots created per test, removed after their context settles. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
 })
 
 interface Deferred<T> {
@@ -39,6 +43,7 @@ function deferred<T>(): Deferred<T> {
 
 async function harness() {
   const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-')))
+  tempDirs.push(root)
   const ctx = new Context()
   roots.push(ctx)
   await ctx.plugin(SessionStore)

+ 12 - 3
packages/boot/app-boot/tests/app-boot.spec.ts

@@ -1,8 +1,8 @@
-import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve, sep } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
@@ -13,7 +13,16 @@ import {
 
 const NAME = 'dsh-test-bin'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 describe('resolveConfigPath', () => {
   it('resolves relative to the given cwd outside replay mode', () => {

+ 12 - 3
packages/boot/app-boot/tests/config-dump.spec.ts

@@ -7,18 +7,27 @@
  * shared overlay whose row exists only on another surface.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import * as yaml from 'js-yaml'
 import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
 import { loadOverlayPatches, renderConfigDump } from '../src/index.ts'
 
 const NAME = 'dsh-test-bin'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-config-dump-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 function writeBase(dir: string): string {
   const base = join(dir, 'base.yml')

+ 10 - 2
packages/boot/app-boot/tests/config-reload.spec.ts

@@ -4,10 +4,10 @@
  * previous generation has been retained or restored.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { Include } from '@deepseek-ai/cordis-plugin-include'
 import { boot } from '../src/index.ts'
@@ -16,6 +16,11 @@ const NAME = 'dsh-test-bin'
 
 const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n'
 
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
 interface TreeFixture {
   ctx: Context
   dir: string
@@ -24,6 +29,7 @@ interface TreeFixture {
 
 async function bootTree(configBody: string, files: Record<string, string> = {}): Promise<TreeFixture> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-'))
+  tempRoots.push(dir)
   writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
   for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content)
   writeFileSync(join(dir, 'cordis.yml'), configBody)
@@ -282,6 +288,7 @@ describe('loader tree replacement', () => {
 describe('include refresh with overlay patches', () => {
   it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-'))
+    tempRoots.push(dir)
     writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
     writeFileSync(join(dir, 'base.yml'), '- id: noop\n  name: ./noop.mjs\n  config:\n    value: base\n')
     writeFileSync(join(dir, 'cordis.yml'), [
@@ -347,6 +354,7 @@ describe('include patches layered over one base', () => {
     // must therefore be able to reach a row an earlier layer inserted, or
     // bundle-only rows would be invisible to the user's patch layer.
     const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-'))
+    tempRoots.push(dir)
     writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN)
     writeFileSync(join(dir, 'base.yml'), '- id: shared\n  name: ./noop.mjs\n  config:\n    value: base\n')
     writeFileSync(join(dir, 'cordis.yml'), [

+ 12 - 1
packages/boot/app-boot/tests/hmr-config.spec.ts

@@ -7,7 +7,10 @@ import { Context } from '@deepseek-ai/cordis'
 import Hmr from '@deepseek-ai/cordis-plugin-hmr'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Timer from '@deepseek-ai/cordis-plugin-timer'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+/** Every per-test tree root, removed once the booted watcher has been disposed. */
+const hmrRoots: string[] = []
 
 async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise<Context> {
   const ctx = new Context()
@@ -32,6 +35,10 @@ async function eventually(test: () => boolean, message: string): Promise<void> {
 }
 
 describe('HMR exact config paths', () => {
+  afterEach(() => {
+    for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+  })
+
   it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => {
     const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-'))
     const alias = `${target}-alias`
@@ -85,6 +92,7 @@ describe('HMR exact config paths', () => {
 
   it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
     const ctx = await bootHmr(dir)
     const observed: string[] = []
@@ -111,6 +119,7 @@ describe('HMR exact config paths', () => {
 
   it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => {
     const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(root)
     const dir = join(root, 'later')
     const filename = join(dir, 'plugins.yml')
     const ctx = await bootHmr(root)
@@ -129,6 +138,7 @@ describe('HMR exact config paths', () => {
 
   it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
     writeFileSync(filename, 'one')
     const ctx = await bootHmr(dir)
@@ -170,6 +180,7 @@ describe('HMR exact config paths', () => {
 
   it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-'))
+    hmrRoots.push(dir)
     const filename = join(dir, 'plugins.yml')
     const ctx = await bootHmr(dir)
     const failure = Promise.withResolvers<{ filename: string; error: Error }>()

+ 11 - 2
packages/boot/app-boot/tests/profile.spec.ts

@@ -11,7 +11,7 @@ import {
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
 import {
   composeEntries,
   healProfilesModuleFallback,
@@ -26,7 +26,16 @@ import {
   type Profile,
 } from '../src/index.ts'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */
 function stageInstallation(

+ 12 - 3
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -4,11 +4,11 @@
  * a real Loader tree, kept live through transactional HMR.
  */
 
-import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Hmr from '@deepseek-ai/cordis-plugin-hmr'
 import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
@@ -23,7 +23,16 @@ import {
 
 const NAME = 'dsh-test-bin'
 
-const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
+const tempRoots: string[] = []
+afterAll(() => {
+  for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
+const tmp = (): string => {
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-user-patches-'))
+  tempRoots.push(dir)
+  return dir
+}
 
 async function eventually(test: () => boolean, message: string): Promise<void> {
   const deadline = Date.now() + 10_000

+ 6 - 1
packages/boot/cmdline/tests/cmdline.spec.ts

@@ -4,7 +4,7 @@
  * active, then resolves that row's config against its injection-ready context.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { EventEmitter } from 'node:events'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -34,6 +34,9 @@ interface Fixture {
 
 const disposers: (() => Promise<void>)[] = []
 
+/** Fixture tree roots, removed after their booted tree has been disposed. */
+const tempDirs: string[] = []
+
 const readyApp: AppReady = {
   onReady(listener) {
     listener()
@@ -59,6 +62,7 @@ function controlledAppReady(): { service: AppReady; commit(): void } {
 
 afterEach(async () => {
   for (const dispose of disposers.splice(0)) await dispose()
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   internals.stdin = process.stdin
   internals.stdout = process.stdout
   internals.stderr = process.stderr
@@ -103,6 +107,7 @@ async function bootFixture(
   options: { objectInject?: boolean; withoutProvider?: boolean } = {},
 ): Promise<Fixture> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-'))
+  tempDirs.push(dir)
   const observed: Observed = { exits: [], out: '' }
   writeFileSync(join(dir, 'reader.mjs'), `
 export const name = 'reader'

+ 6 - 1
packages/bundle/headless/tests/startup.spec.ts

@@ -4,7 +4,7 @@
  * the consumer pending.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -24,8 +24,12 @@ interface Observed {
 
 const disposers: (() => Promise<void>)[] = []
 
+/** Fixture tree roots, removed after their booted tree has been disposed. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   for (const dispose of disposers.splice(0)) await dispose()
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   internals.stdout = process.stdout
   internals.stderr = process.stderr
 })
@@ -37,6 +41,7 @@ afterEach(async () => {
  */
 async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-'))
+  tempDirs.push(dir)
   const observed: Observed = { exits: [], out: '' }
   writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n')
   // Loader imports through Node's resolver, so this fixture delegates to the

+ 6 - 1
packages/bundle/web-app/tests/startup.spec.ts

@@ -3,7 +3,7 @@
  * releases a consumer whose config reads `ctx.webStartup` directly.
  */
 
-import { mkdtempSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { pathToFileURL } from 'node:url'
@@ -23,8 +23,12 @@ interface Observed {
 
 const disposers: (() => Promise<void>)[] = []
 
+/** Fixture tree roots, removed after their booted tree has been disposed. */
+const tempDirs: string[] = []
+
 afterEach(async () => {
   for (const dispose of disposers.splice(0)) await dispose()
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
   internals.stdout = process.stdout
   internals.stderr = process.stderr
 })
@@ -39,6 +43,7 @@ async function bootProvider(args: string[]): Promise<{
   observed: Observed
 }> {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-'))
+  tempDirs.push(dir)
   const observed: Observed = { exits: [], out: '' }
   writeFileSync(join(dir, 'reader.mjs'), `
 export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config }

+ 2 - 2
packages/client/ui-trajectory/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-trajectory/README.md
-README.md: 76ed5e85ce79d087e8ecb007930843d328b431d8
-README.zh.md: a12f4ca6ac125b35e237d89549ddeb17baccba02
+README.md: 3f4509c4bcd282e2bc06ca3c00de5308783501b8
+README.zh.md: 75d00e677109c7fa60563d6fc60bac47a13ee006

+ 1 - 1
packages/client/ui-trajectory/README.md

@@ -47,7 +47,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re
 
 ### Virtual rows
 
-Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
+Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. The virtualizer owns bottom following for structural appends; non-virtual ledgers use a direct tail-position write. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events.
 
 ### Layout
 

+ 1 - 1
packages/client/ui-trajectory/README.zh.md

@@ -47,7 +47,7 @@ kind: "package-reference"
 
 ### 虚拟行
 
-长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
+长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。虚拟化器负责结构性追加后的底部跟随;非虚拟记录表会直接写入末尾位置。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。
 
 ### 布局
 

+ 2 - 2
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1909,6 +1909,7 @@ export function TrajectoryTable({
     overscan: VIRTUAL_OVERSCAN_ROWS,
     scrollMargin: virtualScrollMargin,
     scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
+    followOnAppend: 'auto',
   })
   const virtualIndexByRecordId = useMemo(() => {
     const indexes = new Map<string, number>()
@@ -2284,8 +2285,7 @@ export function TrajectoryTable({
       return
     }
     if (!followsTableTail.current) return
-    if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
-    else pane.scrollTop = pane.scrollHeight
+    if (!virtualizationEnabled) pane.scrollTop = pane.scrollHeight
   }, [
     historyLoading,
     historyStartSeq,

+ 2 - 2
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: 174d8368683f49d91dda1e35c413605b364a44e5
-README.zh.md: 1f589be35548374a562a56832672bc9add85b850
+README.md: c7e0e7e53886b6ae1a4de7c40ed6041a61280446
+README.zh.md: 695e89acb967924f0f617b7c17b6581ee38f8ba5

+ 1 - 1
packages/client/ui-workspace/README.md

@@ -33,7 +33,7 @@ View options combine grouping with one browser-persisted Session order per accou
 
 ### Search
 
-Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20 and opens the selected Session without clearing the query.
+Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank query replaces either browsing mode with one flat result list — case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20. Choosing a result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required.
 
 ### Managing sessions
 

+ 1 - 1
packages/client/ui-workspace/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 搜索
 
-折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,打开所选 Session 时不会清除查询。
+折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果。选择结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。
 
 ### 管理会话
 

+ 14 - 2
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -5,7 +5,7 @@
  * except workspace Rename/Delete and session Rename/Fork/Archive; the session
  * and workspace hover cards are suppressed while a menu is open.
  */
-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
 import {
   HoverCard, IconAlarmClockOutline16, IconArchiveOutline20, IconBranchOutline16,
@@ -370,12 +370,15 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
  * @param props.onRename - open the session rename dialog (id + current title).
  * @param props.onFork - fork a session at its last completed turn.
  * @param props.onArchive - archive a session by id.
+ * @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
  * @param props.drag - optional draggable-row wiring.
  * @param props.flat - omit the empty status slot in the hierarchy-free flat list.
  * @param props.t - the browser root's locale seat.
  * @returns the session row.
  */
-export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: {
+export function SessionNodeItem({
+  node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t,
+}: {
   node: SessionNode
   currentId: string | undefined
   now: number
@@ -386,6 +389,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
   onFork: (id: SessionNode['id']) => void
   /** Archive this session (row menu action; commits without a dialog). */
   onArchive: (id: SessionNode['id']) => void
+  /** Scroll this row into view after search navigation, then acknowledge it. */
+  onReveal?: (() => void) | undefined
   /** Present only on draggable rows (workspace-group sessions outside search). */
   drag?: RowDragProps | undefined
   /** The row is rendered without a parent Workspace header. */
@@ -399,6 +404,12 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
   const primaryStatus = statuses[0]
   const showStatus = primaryStatus.state !== 'done' || row.completed
   const [menuOpen, setMenuOpen] = useState(false)
+  const rowRef = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    if (onReveal === undefined) return
+    rowRef.current?.scrollIntoView({ block: 'nearest' })
+    onReveal()
+  }, [onReveal])
   // Archive hides the row through the registry-global archive set and never
   // touches the session log, so it is not styled as destructive and needs no
   // confirmation dialog.
@@ -411,6 +422,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
   // Figma session cell: pad 8, status slot 16, then a 4px title gap.
   const ownRow = (
     <div
+      ref={rowRef}
       className={clsx(
         css.sessionRow, selected && css.selected, menuOpen && css.menuOpen,
         flat && !showStatus && css.flatSessionRowWithoutStatus,

+ 60 - 8
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -22,7 +22,9 @@ import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkspaceBrowserProps } from '../contract/slots.ts'
 import type { SessionNode, SessionOrderBy } from '../tree.ts'
-import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from '../tree.ts'
+import {
+  deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, UNGROUPED_KEY,
+} from '../tree.ts'
 import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
 import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
 import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
@@ -238,6 +240,8 @@ type SessionTreeProps = Pick<
   /** Host account home for POSIX hover-path abbreviation. */
   home?: string | undefined
   workspaces: readonly WorkspaceView[]
+  /** Whether the current Workspace stream has a complete Host baseline. */
+  workspaceReady: boolean
   /** Explicit persisted zero-or-five-session state by Workspace group. */
   groupExpansion: Readonly<Record<string, boolean>>
   /** Persist one Workspace group's zero-or-five-session state. */
@@ -262,19 +266,28 @@ type SessionTreeProps = Pick<
   onSessionArchive: (sessionId: SessionNode['id']) => void
   /** Session order behavior: fixed after edits, or additionally promoted by user activity. */
   orderBy: SessionOrderBy
+  /** One Session chosen from search that must be exposed and scrolled into view. */
+  revealSessionId?: SessionId | undefined
+  /** Acknowledge that the chosen Session row has been revealed. */
+  onSessionRevealed: (sessionId: SessionId) => void
 }
 
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 function SessionTree({
   useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds,
+  workspaceReady,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore, insertSessionBefore, orderBy,
   groupExpansion, setGroupExpanded,
   sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
+  revealSessionId, onSessionRevealed,
 }: SessionTreeProps) {
   const list = useSessions(s => s)
   const pendingInteractions = useSessionPendingInteraction(s => s)
   const current = list.current
+  const revealGroup = revealSessionId === undefined || !workspaceReady
+    ? undefined
+    : owningGroupKey(workspaces, revealSessionId)
   const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
   // Transient drag marker state; the selected mode owns the resulting order.
   const [drag, setDrag] = useState<DragState | null>(null)
@@ -284,10 +297,9 @@ function SessionTree({
   const previousOrderBy = useRef(orderBy)
   const nativeDragActive = drag !== null || workspaceDrag !== null
   useNativeDragAcceptance(nativeDragActive)
-  const currentGroup = current === undefined
+  const currentGroup = current === undefined || !workspaceReady
     ? undefined
-    : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
-      ?? UNGROUPED_KEY
+    : owningGroupKey(workspaces, current)
   useEffect(() => {
     if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return
     setGroupExpanded(currentGroup, true)
@@ -347,6 +359,17 @@ function SessionTree({
     }),
     [list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount],
   )
+  useEffect(() => {
+    if (revealGroup === undefined || groupExpansion[revealGroup] === true) return
+    setGroupExpanded(revealGroup, true)
+  }, [groupExpansion, revealGroup, setGroupExpanded])
+  useEffect(() => {
+    if (revealSessionId === undefined || revealGroup === undefined) return
+    const group = groups.find(candidate => candidate.key === revealGroup)
+    if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return
+    if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return
+    setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup])
+  }, [groups, revealGroup, revealSessionId])
   const now = Date.now()
   const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
     if (sessionDropCommitted.current) return
@@ -564,6 +587,9 @@ function SessionTree({
                     onRename={onSessionRename}
                     onFork={forkSession}
                     onArchive={onSessionArchive}
+                    onReveal={node.id === revealSessionId && group.key === revealGroup
+                      ? () => { onSessionRevealed(node.id) }
+                      : undefined}
                     drag={dragProps}
                     t={t}
                   />
@@ -594,7 +620,8 @@ function SessionTree({
 function FlatList({
   useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
   archivedSessionIds,
-  orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
+  orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder,
+  revealSessionId, onSessionRevealed, t,
 }: Pick<
   SessionTreeProps,
   | 'useSessions'
@@ -609,6 +636,8 @@ function FlatList({
   | 'sessionUpdatedAtByAccount'
   | 'syncSessionOrderAccount'
   | 'setSessionOrder'
+  | 'revealSessionId'
+  | 'onSessionRevealed'
   | 't'
 >) {
   const list = useSessions(s => s)
@@ -683,6 +712,9 @@ function FlatList({
               onRename={onSessionRename}
               onFork={forkSession}
               onArchive={onSessionArchive}
+              onReveal={node.id === revealSessionId
+                ? () => { onSessionRevealed(node.id) }
+                : undefined}
               flat
               drag={{
                 start: () => {
@@ -827,6 +859,7 @@ export function WorkspaceBrowser({
   const home = useHostInfo(info => info.home)
   const workspaces = useWorkspaces(state => state.items)
   const workspacePhase = useWorkspaces(state => state.phase)
+  const workspaceStreamState = useWorkspaces(state => state.state)
   const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
   // Live occupancy of this surface's directory-flow hole (the same source the
   // flow reads): a composition without a picking affordance can add nothing.
@@ -841,9 +874,9 @@ export function WorkspaceBrowser({
     return current !== undefined && state.byId[current]?.blank === true ? current : undefined
   })
   const currentBlankAccount = currentBlankSessionId === undefined
+    || workspacePhase !== 'ready'
     ? undefined
-    : (workspaces.find(workspace => workspace.sessionIds.includes(currentBlankSessionId))
-      ?.workspaceId as string | undefined) ?? UNGROUPED_KEY
+    : owningGroupKey(workspaces, currentBlankSessionId)
   const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined)
   useEffect(() => {
     if (currentBlankSessionId === undefined || currentBlankAccount === undefined) {
@@ -874,6 +907,7 @@ export function WorkspaceBrowser({
   // does not silently drop an in-progress filter.
   const [query, setQuery] = useState('')
   const [searchExpanded, setSearchExpanded] = useState(false)
+  const [revealSessionId, setRevealSessionId] = useState<SessionId | undefined>(undefined)
   const normalizedQuery = sanitizeSearchQuery(query).trim()
   const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
     query: '',
@@ -889,6 +923,19 @@ export function WorkspaceBrowser({
   const wsPlusRef = useRef<HTMLButtonElement>(null)
   const composingRef = useRef(false)
 
+  const openSearchResult = (sessionId: SessionId): void => {
+    setRevealSessionId(sessionId)
+    setQuery('')
+    setSearchExpanded(false)
+    open(sessionId)
+  }
+  const acknowledgeSessionReveal = (sessionId: SessionId): void => {
+    setRevealSessionId(current => current === sessionId ? undefined : current)
+  }
+  useEffect(() => {
+    if (normalizedQuery !== '') setRevealSessionId(undefined)
+  }, [normalizedQuery])
+
   // Rail search = expand + land in the search box: the flag arms before the
   // expand request; once the shell flips wide the input mounts and takes focus.
   const [searchOnExpand, setSearchOnExpand] = useState(false)
@@ -1206,7 +1253,7 @@ export function WorkspaceBrowser({
             <SearchResults
               useSessions={useSessions}
               useSessionPendingInteraction={useSessionPendingInteraction}
-              open={open}
+              open={openSearchResult}
               workspaces={workspaces}
               archivedSessionIds={archivedSessionIds}
               query={normalizedQuery}
@@ -1227,6 +1274,8 @@ export function WorkspaceBrowser({
                 sessionUpdatedAtByAccount={sessionUpdatedAtByAccount}
                 syncSessionOrderAccount={actions.syncSessionOrderAccount}
                 setSessionOrder={actions.setSessionOrder}
+                revealSessionId={revealSessionId}
+                onSessionRevealed={acknowledgeSessionReveal}
                 t={t}
               />
             )
@@ -1238,6 +1287,7 @@ export function WorkspaceBrowser({
                 onSessionArchive={onSessionArchive}
                 forkSession={forkSession}
                 workspaces={workspaces}
+                workspaceReady={workspacePhase === 'ready' && workspaceStreamState !== 'loading'}
                 groupExpansion={groupExpansion}
                 setGroupExpanded={actions.setGroupExpanded}
                 sessionOrderByAccount={sessionOrderByAccount}
@@ -1250,6 +1300,8 @@ export function WorkspaceBrowser({
                 insertWorkspaceBefore={insertWorkspaceBefore}
                 insertSessionBefore={insertSessionBefore}
                 orderBy={orderBy}
+                revealSessionId={revealSessionId}
+                onSessionRevealed={acknowledgeSessionReveal}
                 home={home}
                 t={t}
                 onRenameRequest={(workspaceId, currentTitle) => {

+ 15 - 2
packages/client/ui-workspace/src/client/tree.ts

@@ -20,6 +20,20 @@ import {
 /** Group key for Sessions outside every Workspace. */
 export const UNGROUPED_KEY = ''
 
+/**
+ * Resolve the Workspace browser group that owns one Session.
+ * @param workspaces - authoritative Workspace membership.
+ * @param sessionId - Session whose browser group is required.
+ * @returns owning Workspace id, or {@link UNGROUPED_KEY} when no Workspace accounts for it.
+ */
+export function owningGroupKey(
+  workspaces: readonly WorkspaceView[],
+  sessionId: SessionId,
+): string {
+  return (workspaces.find(workspace => workspace.sessionIds.includes(sessionId))
+    ?.workspaceId as string | undefined) ?? UNGROUPED_KEY
+}
+
 /** Pending interaction kinds with dedicated Workspace-row presentation. */
 export type SessionPendingInteractionStatus = 'approval' | 'plan-review' | 'question'
 type SessionPendingInteractions = ReadonlyMap<SessionId, SessionPendingInteractionBase>
@@ -287,8 +301,7 @@ export function deriveGroups(
   const descendants = indexSubagentDescendants(list.byId)
   const currentGroup = list.current === undefined
     ? undefined
-    : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
-        ?? UNGROUPED_KEY
+    : owningGroupKey(workspaces, list.current)
   const groups: GroupNode[] = []
   for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) {
     const expanded = expandedGroups.has(g.key)

+ 9 - 1
packages/client/ui-workspace/tests/tree.client.spec.ts

@@ -5,7 +5,7 @@ import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-s
 import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
-  deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel,
+  deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, workspaceLabel,
   UNGROUPED_KEY,
 } from '../src/client/tree.ts'
 import { createWorkspaceViewStore } from '../src/client/stores.ts'
@@ -40,6 +40,14 @@ const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({
   scheduledAt,
 })
 
+describe('owningGroupKey', () => {
+  it('returns the owning Workspace id or the Ungrouped key', () => {
+    const workspaces = [workspace('first', ['owned'])]
+    expect(owningGroupKey(workspaces, sid('owned'))).toBe('first')
+    expect(owningGroupKey(workspaces, sid('loose'))).toBe(UNGROUPED_KEY)
+  })
+})
+
 describe('deriveGroups', () => {
   it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
     const sessions = list(summary('newer', 20), summary('older', 10))

+ 156 - 5
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -16,7 +16,13 @@ import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
 import { zh } from '../src/client/locales.ts'
 
 afterEach(cleanup)
-beforeEach(() => { localStorage.clear(); createWorkspaceViewStore().create().actions.setOrderBy('manual') })
+const scrollIntoView = vi.fn()
+beforeEach(() => {
+  localStorage.clear()
+  createWorkspaceViewStore().create().actions.setOrderBy('manual')
+  Element.prototype.scrollIntoView = scrollIntoView
+  scrollIntoView.mockClear()
+})
 
 // The seat's key domain is workspace ∪ common; the stub mirrors the real
 // lookup chain (namespace, then common vocabulary, then the key).
@@ -664,7 +670,7 @@ describe('WorkspaceBrowser', () => {
     expect(input.value).toBe('kept')
   })
 
-  it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => {
+  it('opens a Host content hit, exits search, and reveals its hidden grouped row', async () => {
     vi.useFakeTimers()
     try {
       const open = vi.fn()
@@ -672,12 +678,19 @@ describe('WorkspaceBrowser', () => {
         items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }],
         hasMore: true,
       }))
-      mount({
+      const b = mount({
         useSessions: hook(sessionState([
+          summary('newest-1', 6),
+          summary('newest-2', 5),
+          summary('newest-3', 4),
+          summary('newest-4', 3),
+          summary('newest-5', 2),
           summary('body-hit', 1, { displayTitle: 'Research notes' }),
         ])),
         useWorkspaces: hook(workspaceState([
-          workspace('research', ['body-hit'], 'Research Workspace'),
+          workspace('research', [
+            'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'body-hit',
+          ], 'Research Workspace'),
         ])),
         open,
         searchSessions,
@@ -696,12 +709,150 @@ describe('WorkspaceBrowser', () => {
       expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy()
       fireEvent.click(screen.getByRole('treeitem'))
       expect(open).toHaveBeenCalledWith(sid('body-hit'))
-      expect(input.value).toBe('waterfall token')
+      expect(input.value).toBe('')
+      expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull()
+      expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy()
+      expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true })
+      const targetRow = screen.getByText('Research notes').closest('[role="treeitem"]')
+      expect(targetRow).toBeTruthy()
+      expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
+      expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
+      expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow)
     } finally {
       vi.useRealTimers()
     }
   })
 
+  it('waits for authoritative Workspace membership before revealing a grouped search result', async () => {
+    const sessions = sessionState([
+      summary('newest-1', 6),
+      summary('newest-2', 5),
+      summary('newest-3', 4),
+      summary('newest-4', 3),
+      summary('newest-5', 2),
+      summary('target', 1, { displayTitle: 'Needle session' }),
+    ])
+    const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const }
+    const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) })
+    const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
+    fireEvent.change(input, { target: { value: 'needle' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+
+    expect(scrollIntoView).not.toHaveBeenCalled()
+    expect(b.store.getSnapshot().groupExpansion).toEqual({})
+
+    rerender(b, {
+      useWorkspaces: hook(workspaceState([workspace('research', [
+        'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'target',
+      ])])),
+    })
+    await waitFor(() => {
+      expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true })
+      expect(screen.getByText('Needle session')).toBeTruthy()
+    })
+    const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]')
+    expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow)
+    expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
+  })
+
+  it('waits for the reconnect baseline before resolving reveal membership', async () => {
+    const sessions = sessionState([
+      summary('newest-1', 6),
+      summary('newest-2', 5),
+      summary('newest-3', 4),
+      summary('newest-4', 3),
+      summary('newest-5', 2),
+      summary('target', 1, { displayTitle: 'Needle session' }),
+    ])
+    const reconnecting = {
+      ...workspaceState([workspace('stale', [...sessions.ids])]),
+      state: 'loading' as const,
+    }
+    const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(reconnecting) })
+    const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
+    fireEvent.change(input, { target: { value: 'needle' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+
+    expect(b.store.getSnapshot().groupExpansion).toEqual({})
+    expect(scrollIntoView).not.toHaveBeenCalled()
+
+    rerender(b, {
+      useWorkspaces: hook(workspaceState([workspace('current', [...sessions.ids])])),
+    })
+    await waitFor(() => {
+      expect(b.store.getSnapshot().groupExpansion).toEqual({ current: true })
+      expect(screen.getByText('Needle session')).toBeTruthy()
+    })
+    const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]')
+    expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow)
+    expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale')
+  })
+
+  it('keeps the bounded group projection when the revealed result is already within it', () => {
+    const sessions = sessionState([
+      summary('target', 6, { displayTitle: 'Needle session' }),
+      summary('second', 5),
+      summary('third', 4),
+      summary('fourth', 3),
+      summary('fifth', 2),
+      summary('hidden', 1),
+    ])
+    mount({
+      useSessions: hook(sessions),
+      useWorkspaces: hook(workspaceState([workspace('research', [...sessions.ids])])),
+    })
+    const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
+    fireEvent.change(input, { target: { value: 'needle' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+
+    expect(screen.getByText('Needle session')).toBeTruthy()
+    expect(screen.queryByText('hidden')).toBeNull()
+    expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy()
+    expect(scrollIntoView).toHaveBeenCalledOnce()
+  })
+
+  it('cancels a pending row reveal when a new search begins', () => {
+    const sessions = sessionState([summary('target', 1, { displayTitle: 'Needle session' })])
+    const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const }
+    const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) })
+    const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
+    fireEvent.change(input, { target: { value: 'needle' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+
+    fireEvent.change(input, { target: { value: 'another query' } })
+    fireEvent.change(input, { target: { value: '' } })
+    rerender(b, { useWorkspaces: hook(workspaceState([workspace('research', ['target'])])) })
+
+    expect(b.store.getSnapshot().groupExpansion).toEqual({})
+    expect(scrollIntoView).not.toHaveBeenCalled()
+  })
+
+  it('returns search navigation to the flat list and scrolls to the selected row', () => {
+    const open = vi.fn()
+    const b = mount({
+      useSessions: hook(sessionState([
+        summary('target', 2, { displayTitle: 'Needle session' }),
+        summary('other', 1),
+      ])),
+      useWorkspaces: hook(workspaceState([workspace('alpha', ['target', 'other'])])),
+      open,
+    })
+    fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
+    const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
+    fireEvent.change(input, { target: { value: 'needle' } })
+    fireEvent.click(screen.getByRole('treeitem'))
+
+    expect(open).toHaveBeenCalledWith(sid('target'))
+    expect(input.value).toBe('')
+    expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull()
+    const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]')
+    expect(targetRow).toBeTruthy()
+    expect(screen.queryByText('alpha')).toBeNull()
+    expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow)
+    expect(b.store.getSnapshot().groupExpansion).toEqual({})
+  })
+
   it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => {
     vi.useFakeTimers()
     try {

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

@@ -2891,8 +2891,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       {
         signature: 'async create(path: string, title?: string): Promise<Workspace>',
-        description: 'Create or reuse a workspace for an existing directory. The path is canonicalized through `fs.realpath`; a nonexistent path rejects with the original error and a non-directory rejects. Repeated calls for the same canonical path return the existing entity without changing its title. A newly created workspace is prepended to the durable registry order. Different canonical paths may share a display title.',
-        parameters: [{ name: 'path', description: 'Existing directory to own, in any path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }],
+        description: 'Create or reuse a workspace for an existing directory. The fully qualified path is canonicalized through `fs.realpath`; a relative, nonexistent, or non-directory path rejects. Repeated calls for the same canonical path return the existing entity without changing its title. A newly created workspace is prepended to the durable registry order. Different canonical paths may share a display title.',
+        parameters: [{ name: 'path', description: 'Existing directory to own, in a fully qualified path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }],
         returns: 'the existing or newly durable workspace.',
       },
       {
@@ -2928,7 +2928,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
         description: 'Resolve by canonical directory path without creating or mutating a workspace. A missing path rejects during `realpath`; an existing unowned directory returns `undefined`.',
-        parameters: [{ name: 'path', description: 'Existing directory path in any spelling.' }],
+        parameters: [{ name: 'path', description: 'Existing directory path in a fully qualified spelling.' }],
         returns: 'the workspace owning the canonical path, when one exists.',
       },
     ],

+ 8 - 3
packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts

@@ -100,9 +100,14 @@ describe('workspace-write containment', () => {
   })
 
   it('a write to the platform temp area lands (parity with the bash runner grant)', async () => {
-    const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt')
-    await fs.writeText(await target(path), 'temp')
-    expect(await readFile(path, 'utf8')).toBe('temp')
+    const dir = await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-'))
+    try {
+      const path = join(dir, 'temp.txt')
+      await fs.writeText(await target(path), 'temp')
+      expect(await readFile(path, 'utf8')).toBe('temp')
+    } finally {
+      await rm(dir, { recursive: true, force: true })
+    }
   })
 
   it('an absolute path outside the workspace is denied, no file created', async () => {

+ 25 - 2
packages/llm/llm-deepseek/tests/file-store.spec.ts

@@ -1,7 +1,7 @@
-import { mkdtemp } from 'node:fs/promises'
+import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
 import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts'
@@ -31,6 +31,12 @@ const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' }
 const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 }
 const NOW = 1_700_000_000_000
 
+/** Every temp store root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 function requestUrl(input: string | URL | Request): string {
   if (typeof input === 'string') return input
   return input instanceof URL ? input.href : input.url
@@ -64,6 +70,7 @@ function uploadFetch(now: () => number = () => NOW) {
 describe('DeepSeekFileStore', () => {
   it('singleflights the first upload and reuses the durable mapping across store instances', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const remote = uploadFetch()
     const first = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl })
@@ -84,6 +91,7 @@ describe('DeepSeekFileStore', () => {
 
   it('keeps a shared upload alive while another waiter remains', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     let complete: ((response: Response) => void) | undefined
     let uploadSignal: AbortSignal | undefined
@@ -123,6 +131,7 @@ describe('DeepSeekFileStore', () => {
 
   it('aborts the shared upload after its only waiter cancels', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     let uploadSignal: AbortSignal | undefined
     const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
@@ -149,6 +158,7 @@ describe('DeepSeekFileStore', () => {
 
   it('normalizes a non-Error cancellation reason', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => (
       new Promise<Response>((_resolve, reject) => {
         init?.signal?.addEventListener('abort', () => {
@@ -176,6 +186,7 @@ describe('DeepSeekFileStore', () => {
 
   it('starts a fresh upload while the cancelled transport is settling', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     let requests = 0
     const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => {
       requests += 1
@@ -222,6 +233,7 @@ describe('DeepSeekFileStore', () => {
 
   it('does not persist an upload whose response is missing and retries on the next request', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const good = uploadFetch()
     let first = true
@@ -242,6 +254,7 @@ describe('DeepSeekFileStore', () => {
 
   it('rejects an upload response whose byte count differs from the request version', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
       id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000,
       filename: 'dsh-wrong.png', purpose: 'user_data',
@@ -262,6 +275,7 @@ describe('DeepSeekFileStore', () => {
     ['image/gif', 'gif'],
   ] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const remote = uploadFetch()
     const store = new DeepSeekFileStore({
       index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)),
@@ -279,6 +293,7 @@ describe('DeepSeekFileStore', () => {
 
   it('normalizes a non-Error failure from the durable upload index', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     vi.spyOn(index, 'get').mockRejectedValue('index unavailable')
     const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
@@ -291,6 +306,7 @@ describe('DeepSeekFileStore', () => {
 
   it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     let now = NOW
     const remote = uploadFetch(() => now)
@@ -311,6 +327,7 @@ describe('DeepSeekFileStore', () => {
 
   it('releases an indexed file through DELETE and removes only that mapping', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const remote = uploadFetch()
     const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl })
@@ -323,6 +340,7 @@ describe('DeepSeekFileStore', () => {
 
   it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     vi.spyOn(index, 'commit').mockResolvedValue({
       accepted: false,
@@ -352,6 +370,7 @@ describe('DeepSeekFileStore', () => {
 
   it('reclaims one owned file after quota rejection and retries the upload once', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     let uploads = 0
     const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
       if (init?.method === 'POST') {
@@ -394,6 +413,7 @@ describe('DeepSeekFileStore', () => {
 
   it('preserves a quota error when no harness-owned file can be reclaimed', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => {
       if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({
         error: { message: 'file count quota exceeded', code: 'file_quota' },
@@ -418,6 +438,7 @@ describe('DeepSeekFileStore', () => {
 
   it('finishes pagination before deleting cursor files during quota recovery', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const deleted = new Set<string>()
     const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
       const target = new URL(requestUrl(input))
@@ -456,6 +477,7 @@ describe('DeepSeekFileStore', () => {
 
   it('stops pagination when a page omits or repeats its cursor', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     for (const mode of ['missing', 'repeated'] as const) {
       let page = 0
       const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => {
@@ -482,6 +504,7 @@ describe('DeepSeekFileStore', () => {
 
   it('releases every batch and clears the scoped upload index', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch })
     const reclaim = vi.spyOn(store, 'reclaimOldestOwned')

+ 15 - 2
packages/llm/llm-deepseek/tests/upload-index.spec.ts

@@ -1,7 +1,7 @@
-import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
 import { DeepSeekFileId } from '../src/file-id.ts'
 import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
@@ -9,6 +9,12 @@ import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts'
 const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`)
 const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`)
 
+/** Every temp index root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 describe('DeepSeekUploadIndex', () => {
   it('normalizes trailing endpoint slashes in the credential scope', () => {
     expect(deepSeekFileScope('https://api.deepseek.com///', 'key'))
@@ -17,6 +23,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('isolates API-key namespaces and reuses only records above the refresh margin', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const first = deepSeekFileScope('https://api.deepseek.com', 'first-key')
     const second = deepSeekFileScope('https://api.deepseek.com', 'second-key')
@@ -38,6 +45,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('keeps a reusable cross-process winner and removes only an exact generation', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
     const first = {
@@ -56,6 +64,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'index.json')
     await writeFile(path, '{bad', 'utf8')
     const index = new DeepSeekUploadIndex(path)
@@ -128,6 +137,7 @@ describe('DeepSeekUploadIndex', () => {
     })}]}`,
   ])('treats an invalid persisted index as empty %#', async (text) => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'index.json')
     await writeFile(path, text, 'utf8')
     const index = new DeepSeekUploadIndex(path)
@@ -138,6 +148,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('rejects duplicate persisted mappings as a corrupt cache', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'index.json')
     const scope = deepSeekFileScope('https://api.deepseek.com', 'key')
     const record = {
@@ -151,6 +162,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('drops expired records on commit and clears only the selected namespace', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const index = new DeepSeekUploadIndex(join(dir, 'index.json'))
     const first = deepSeekFileScope('https://api.deepseek.com', 'first')
     const second = deepSeekFileScope('https://api.deepseek.com', 'second')
@@ -171,6 +183,7 @@ describe('DeepSeekUploadIndex', () => {
 
   it('propagates non-cache filesystem read failures', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-'))
+    roots.push(dir)
     const path = join(dir, 'directory')
     await mkdir(path)
     const index = new DeepSeekUploadIndex(path)

+ 13 - 3
packages/preset/agent-presets/tests/authoring.spec.ts

@@ -6,7 +6,7 @@
  * stays read-only.
  */
 
-import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
 import { existsSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
@@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import { beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import AgentPresets, {
   COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config,
 } from '@deepseek-ai/dsh-agent-presets'
@@ -23,6 +23,12 @@ import AgentPresets, {
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const VALID = '- id: tool-alpha\n  name: ../../plugins/contribute.js\n  config:\n    tool: alpha\n'
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 let ctx: Context
 let userRoot: string
 
@@ -49,6 +55,7 @@ async function seedPreset(
 
 beforeEach(async () => {
   userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-'))
+  roots.push(userRoot)
   ctx = new Context()
   ctx.baseUrl = pathToFileURL(FIXTURES).href + '/'
   await ctx.plugin(Loader)
@@ -201,6 +208,7 @@ describe('deleting a preset', () => {
 describe('a deployment with more than one user root', () => {
   it('refuses to delete a preset the writable root does not own', async () => {
     const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-'))
+    roots.push(second)
     await seedPreset(second, 'elsewhere')
     const layered = new Context()
     layered.baseUrl = pathToFileURL(FIXTURES).href + '/'
@@ -246,7 +254,9 @@ describe('a deployment with no writable root', () => {
 
 describe('a user root that does not exist yet', () => {
   it('is created by the first copy', async () => {
-    const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset')
+    const absentRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-absent-'))
+    roots.push(absentRoot)
+    const absent = join(absentRoot, 'nested', 'preset')
     const fresh = new Context()
     fresh.baseUrl = pathToFileURL(FIXTURES).href + '/'
     await fresh.plugin(Loader)

+ 11 - 1
packages/preset/agent-presets/tests/composition-inventory.spec.ts

@@ -5,7 +5,7 @@
  * read reported broken by reason instead of dropped.
  */
 
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -33,6 +33,9 @@ const VALID = '- id: prompt\n  name: \'@deepseek-ai/dsh-system-prompt\'\n'
 
 const contexts: Context[] = []
 
+/** Every temp root created by this file, removed after its contexts settle. */
+const roots: string[] = []
+
 /** A Loader-context evaluator over an empty scope, enough for literal gates. */
 const evaluateExpression = (expression: string): unknown => evaluate({}, expression)
 /** An evaluator that refuses every expression, leaving rows conditional. */
@@ -41,6 +44,7 @@ const refuseExpression = (): never => { throw new Error('no loader context') }
 afterEach(async () => {
   vi.restoreAllMocks()
   await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
 })
 
 async function harness(roster: Config): Promise<Context> {
@@ -63,6 +67,7 @@ async function harness(roster: Config): Promise<Context> {
 describe('fileComposition', () => {
   it('flattens groups and keeps refused expressions conditional', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
+    roots.push(dir)
     const path = join(dir, COMPOSITION_FILE)
     await writeFile(path, [
       '- id: alpha',
@@ -124,6 +129,7 @@ describe('fileComposition', () => {
 
   it('evaluates decidable gates the way a mount would', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
+    roots.push(dir)
     const path = join(dir, COMPOSITION_FILE)
     await writeFile(path, [
       '- id: off',
@@ -144,6 +150,7 @@ describe('fileComposition', () => {
 
   it('answers broken for a file that stopped reading as a composition', async () => {
     const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-'))
+    roots.push(dir)
 
     const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression)
     expect(missing).toHaveProperty('broken')
@@ -201,6 +208,7 @@ describe('mountedCompositionRows', () => {
 describe('AgentPresets.compositionInventory', () => {
   it('reads unmounted presets from their files, marking the default and metadata', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'documented'))
     await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [
       VALID.trimEnd(),
@@ -287,6 +295,7 @@ describe('AgentPresets.compositionInventory', () => {
 
   it('prefers the standing mount over a file that broke after mounting', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-composition-volatile-'))
+    roots.push(root)
     await mkdir(join(root, 'volatile'))
     const plugin = join(FIXTURES, 'plugins', 'contribute.js')
     await writeFile(
@@ -344,6 +353,7 @@ describe('AgentPresets.compositionInventory', () => {
 
   it('keeps a broken preset on the inventory with its discovery reason', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'damaged'))
     const ctx = await harness({
       default: 'minimal',

+ 19 - 2
packages/preset/agent-presets/tests/discovery.spec.ts

@@ -1,8 +1,8 @@
-import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets'
 
 const fsHarness = vi.hoisted(() => ({
@@ -31,6 +31,12 @@ const HARNESS = new URL('.', import.meta.url).href
 const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const }
 const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const }
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 beforeEach(() => {
   fsHarness.nextReadError = undefined
 })
@@ -38,6 +44,7 @@ beforeEach(() => {
 describe('display order', () => {
   it('puts declared order first, then everything else by id', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-order-'))
+    roots.push(root)
     for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) {
       await mkdir(join(root, id), { recursive: true })
       await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
@@ -57,6 +64,7 @@ describe('display order', () => {
 
   it('breaks a tie between equal declared orders by id', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-'))
+    roots.push(root)
     for (const id of ['yankee', 'alpha']) {
       await mkdir(join(root, id), { recursive: true })
       await writeFile(join(root, id, COMPOSITION_FILE), '[]\n')
@@ -94,6 +102,7 @@ describe('preset discovery', () => {
 
   it('skips a directory whose name no preset id could ever claim', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-'))
+    roots.push(root)
     await mkdir(join(root, '.hidden'))
     await mkdir(join(root, 'Has_Caps'))
     await mkdir(join(root, 'usable'))
@@ -129,6 +138,7 @@ describe('preset discovery', () => {
 
   it('ignores a plain file sitting beside the preset directories', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
+    roots.push(root)
     await writeFile(join(root, 'stray.yml'), '- id: x\n')
     await mkdir(join(root, 'real'))
     await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n')
@@ -140,6 +150,7 @@ describe('preset discovery', () => {
 
   it('reports a root it cannot read rather than treating it as empty', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-'))
+    roots.push(root)
     const notADirectory = join(root, 'file-as-root')
     await writeFile(notADirectory, 'not a directory\n')
 
@@ -168,6 +179,7 @@ describe('composition health', () => {
    */
   async function scanned(composition: string): Promise<string | undefined> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-'))
+    roots.push(root)
     await mkdir(join(root, 'probe'))
     await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
     const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
@@ -208,6 +220,7 @@ describe('composition health', () => {
 
   it('reports a composition that stats but cannot be read', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-'))
+    roots.push(root)
     await mkdir(join(root, 'sealed'))
     const path = join(root, 'sealed', COMPOSITION_FILE)
     await writeFile(path, '[]\n')
@@ -235,6 +248,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
   /** One directory under a fresh root holding `composition`, scanned. */
   async function scanned(composition: string): Promise<string | undefined> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-'))
+    roots.push(root)
     await mkdir(join(root, 'probe'))
     await writeFile(join(root, 'probe', COMPOSITION_FILE), composition)
     const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS)
@@ -272,6 +286,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
 
   it('resolves a preset-relative row against the preset\'s own directory', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-'))
+    roots.push(root)
     await mkdir(join(root, 'probe'))
     await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n')
     await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n  name: ./own-plugin.mjs\n- id: gone\n  name: ./deleted.mjs\n')
@@ -311,6 +326,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
     // The fast path, and the one that has to answer alone: this package has a
     // directory and nothing to import, so a resolver would reject it.
     const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-'))
+    roots.push(home)
     await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true })
     await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n')
     await mkdir(join(home, 'presets', 'probe'), { recursive: true })
@@ -326,6 +342,7 @@ describe('rows naming a plugin that cannot be resolved', () => {
     // What a stale profile install leaves behind: the name is still in
     // `node_modules`, pointing at a checkout that is gone.
     const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-'))
+    roots.push(home)
     await mkdir(join(home, 'node_modules', '@scope'), { recursive: true })
     await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg'))
     await mkdir(join(home, 'presets', 'probe'), { recursive: true })

+ 9 - 2
packages/preset/agent-presets/tests/metadata.spec.ts

@@ -6,15 +6,22 @@
  * from the file a user can write.
  */
 
-import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts'
 
+/** Every temp preset directory created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 /** A preset directory holding exactly the given metadata text. */
 async function presetDir(content?: string): Promise<string> {
   const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-'))
+  tempDirs.push(dir)
   await mkdir(dir, { recursive: true })
   if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content)
   return dir

+ 13 - 1
packages/preset/agent-presets/tests/mount.spec.ts

@@ -13,7 +13,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import AgentPresets, {
   COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent,
 } from '@deepseek-ai/dsh-agent-presets'
@@ -86,6 +86,13 @@ function rootResolves(ctx: Context, name: string): boolean {
 }
 
 let ctx: Context
+
+/** Every temp preset root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 beforeEach(async () => {
   ctx = await harness()
 })
@@ -93,6 +100,7 @@ beforeEach(async () => {
 describe('composing an agent from a preset', () => {
   it('hands an absolute plugin path to Node as a file URL', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-'))
+    roots.push(root)
     const presetDir = join(root, 'absolute')
     const plugin = join(FIXTURES, 'plugins', 'contribute.js')
     await mkdir(presetDir)
@@ -360,6 +368,7 @@ describe('composing from a broken preset', () => {
   /** A roster whose only user preset carries `composition`. */
   async function rosterWith(composition: string): Promise<Context> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-'))
+    roots.push(root)
     await mkdir(join(root, 'damaged'))
     await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition)
     return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false })
@@ -428,6 +437,7 @@ describe('the preset file is an input, never a persistence target', () => {
     // committed fixture would be mutated by the very run that proves the bug
     // and every later run would compare against the damaged file and pass.
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-'))
+    roots.push(root)
     const dir = join(root, 'self-disposing')
     await mkdir(dir)
     const path = join(dir, COMPOSITION_FILE)
@@ -625,6 +635,7 @@ describe('replacing a composition', () => {
     // A preset root this test owns, so removing the composition mid-flight
     // cannot disturb the shipped fixtures.
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-'))
+    roots.push(root)
     const seeded: [string, string][] = [['first', `- id: only\n  name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n  config:\n    tool: only\n`], ['broken', `- id: nope\n  name: ${join(FIXTURES, 'plugins', 'throws.js')}\n  config:\n    message: refuses\n`]]
     for (const [id, body] of seeded) {
       await mkdir(join(root, id))
@@ -679,6 +690,7 @@ describe('editing a composition file', () => {
    */
   async function editable(id: string): Promise<{ scoped: Context; path: string }> {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-'))
+    roots.push(root)
     await mkdir(join(root, id))
     const path = join(root, id, COMPOSITION_FILE)
     await writeFile(path, rowFor('before'))

+ 13 - 2
packages/preset/agent-presets/tests/remote.spec.ts

@@ -4,7 +4,7 @@
  * which is the only one of the three that mutates an agent.
  */
 
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -34,7 +34,13 @@ const ROOTS = [
 // temp preset directory these tests seed would report the composition broken.
 const VALID = '- id: prompt\n  name: \'@deepseek-ai/dsh-system-prompt\'\n'
 
-afterEach(() => vi.restoreAllMocks())
+/** Every temp preset root created by this file, removed after each test. */
+const roots: string[] = []
+
+afterEach(async () => {
+  vi.restoreAllMocks()
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
 
 async function remoteFailure(operation: Promise<unknown>): Promise<RemoteFailure> {
   try {
@@ -97,6 +103,7 @@ const recordedPreset = (agent: Agent): unknown =>
 describe('the roster a client reads', () => {
   it('projects path-free rows, marking the default and carrying published metadata', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'documented'), { recursive: true })
     await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
     await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
@@ -122,6 +129,7 @@ describe('the roster a client reads', () => {
 
   it('keeps a broken preset on the roster with its reason', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'damaged'), { recursive: true })
     const ctx = await harness({
       default: 'standard',
@@ -174,6 +182,7 @@ describe('reading one composition', () => {
 
   it('carries the display metadata a preset published', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'documented'), { recursive: true })
     await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID)
     await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n')
@@ -241,6 +250,7 @@ describe('authoring over Remote', () => {
 
   it('copies and deletes through the Remote adapters', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     const ctx = await harness({
       default: 'standard',
       roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }],
@@ -432,6 +442,7 @@ describe('switching one session\'s composition', () => {
 
   it('reports an unusable composition with its discovery reason', async () => {
     const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-'))
+    roots.push(userRoot)
     await mkdir(join(userRoot, 'damaged'), { recursive: true })
     const ctx = await harness({
       default: 'standard',

+ 10 - 2
packages/preset/agent-presets/tests/settings.spec.ts

@@ -4,7 +4,7 @@
  * so a person can change which preset new sessions get without a restart.
  */
 
-import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -19,13 +19,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets'
 
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }]
 const NS = SETTINGS_NAMESPACE
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(async () => {
+  for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
+})
+
 /**
  * A composition with a real file-backed settings provider. `settingsFiber` is
  * the provider's own handle, so a test can take it away the way a reload does.
@@ -34,6 +40,7 @@ async function harness(
   extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [],
 ): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> {
   const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-'))
+  roots.push(home)
   const settingsFile = join(home, 'settings.yaml')
   await writeFile(settingsFile, '{}\n')
 
@@ -119,6 +126,7 @@ describe('the default preset as a user setting', () => {
 
   it('clears a user default it has just deleted', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-'))
+    roots.push(root)
     await mkdir(join(root, 'mine'))
     await writeFile(
       join(root, 'mine', COMPOSITION_FILE),

+ 6 - 3
packages/preset/agent-presets/tests/shipped-root.spec.ts

@@ -9,7 +9,7 @@
  * suite: the derived writable root is resolved in the constructor.
  */
 
-import { mkdtemp, readFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -24,16 +24,19 @@ import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh
 const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures')
 const SYSTEM_ROOT = join(FIXTURES, 'system')
 
+let home: string
 let previousHome: string | undefined
 
 beforeEach(async () => {
   previousHome = process.env.DSH_HOME
-  process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-'))
+  home = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-'))
+  process.env.DSH_HOME = home
 })
 
-afterEach(() => {
+afterEach(async () => {
   if (previousHome === undefined) delete process.env.DSH_HOME
   else process.env.DSH_HOME = previousHome
+  await rm(home, { recursive: true, force: true })
 })
 
 /** Boot a roster with the shipped root left to the plugin's default. */

+ 8 - 2
packages/preset/agent-presets/tests/user-root.spec.ts

@@ -10,7 +10,7 @@
  * temporary home, or it would reach the developer's real one.
  */
 
-import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
 import { existsSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
@@ -31,15 +31,20 @@ const VALID = '- id: tool-alpha\n  name: ../../plugins/contribute.js\n  config:\
 let home: string
 let previousHome: string | undefined
 
+/** Extra per-test roots, removed with the home directory. */
+const explicitRoots: string[] = []
+
 beforeEach(async () => {
   home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-'))
   previousHome = process.env.DSH_HOME
   process.env.DSH_HOME = home
 })
 
-afterEach(() => {
+afterEach(async () => {
   if (previousHome === undefined) delete process.env.DSH_HOME
   else process.env.DSH_HOME = previousHome
+  await rm(home, { recursive: true, force: true })
+  for (const root of explicitRoots.splice(0)) await rm(root, { recursive: true, force: true })
 })
 
 /** Boot a roster over the fixture system root, with the derived root left to the plugin. */
@@ -120,6 +125,7 @@ describe('the harness-home preset root', () => {
 
   it('yields to a configured user root for authoring, which writableRoot takes first', async () => {
     const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-'))
+    explicitRoots.push(explicit)
     const ctx = await roster({
       roots: [
         { path: SYSTEM_ROOT, trust: 'system' as const },

+ 16 - 3
packages/sandbox/sandbox-local/tests/local.spec.ts

@@ -7,10 +7,10 @@
  * are all exercised through the real `confine()` path.
  */
 
-import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
 import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
@@ -24,6 +24,12 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s
 const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
 const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
 
+/** Every temp dir created by this file (fake launchers and runner entries), removed after each test. */
+const tempDirs: string[] = []
+afterEach(() => {
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) {
   const ctx = new Context()
   await ctx.plugin(LocalSandboxProvider, config)
@@ -39,12 +45,15 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter
  * `sandbox-windows-acl/lib/runner.js`.
  */
 function absentRunnerEntry(): string {
-  return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js')
+  const dir = mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-'))
+  tempDirs.push(dir)
+  return join(dir, 'runner.js')
 }
 
 /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */
 function fakeLauncher(report = 'landlock: fully enforced'): string {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
+  tempDirs.push(dir)
   const launcher = join(dir, 'landlock-run')
   writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
   return launcher
@@ -53,6 +62,7 @@ function fakeLauncher(report = 'landlock: fully enforced'): string {
 /** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */
 function fakeSeatbeltExec(status: number): string {
   const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-'))
+  tempDirs.push(dir)
   const exec = join(dir, 'sandbox-exec')
   writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 })
   return exec
@@ -316,6 +326,7 @@ describe('the default landlock probe (launcher CLI contract)', () => {
 
   it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
+    tempDirs.push(dir)
     const launcher = join(dir, 'landlock-run')
     writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
     const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
@@ -337,6 +348,7 @@ describe('probeTimeoutMs config', () => {
     // spawnSync blocks the worker and fork/exec latency inflates wall-clock)
     // cannot flip either verdict; the vitest timeout clears the patient budget.
     const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
+    tempDirs.push(dir)
     const launcher = join(dir, 'landlock-run')
     writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
 
@@ -440,6 +452,7 @@ describe('the windows-acl probe (runner invocation contract)', () => {
 
   it('prefers the built lib/runner.js entry when the resolved file exists', async () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-'))
+    tempDirs.push(dir)
     const builtEntry = join(dir, 'runner.js')
     writeFileSync(builtEntry, '')
     const { sandbox } = await setup({}, {

+ 15 - 8
packages/sandbox/sandbox/tests/roots.spec.ts

@@ -4,16 +4,22 @@
  * deriving from `writableRoots` — cannot drift.
  */
 
-import { realpathSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
-import { mkdtempSync } from 'node:fs'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
 
+/** Every temp root created by this file, removed after each test. */
+const roots: string[] = []
+afterEach(() => {
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
+
 describe('canonicalPath', () => {
   it('resolves symlinks (an existing path realpaths)', () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-'))
+    roots.push(dir)
     expect(canonicalPath(dir)).toBe(realpathSync.native(dir))
   })
 
@@ -29,11 +35,12 @@ describe('writableRoots', () => {
 
   it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => {
     const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-'))
-    const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
-    expect(roots).toContain(realpathSync.native(ws))
-    expect(roots).toContain(canonicalPath('/tmp'))
-    expect(roots).toContain(realpathSync.native(tmpdir()))
+    roots.push(ws)
+    const writable = writableRoots({ mode: 'workspace-write', workspaceRoot: ws })
+    expect(writable).toContain(realpathSync.native(ws))
+    expect(writable).toContain(canonicalPath('/tmp'))
+    expect(writable).toContain(realpathSync.native(tmpdir()))
     // Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide).
-    expect(new Set(roots).size).toBe(roots.length)
+    expect(new Set(writable).size).toBe(writable.length)
   })
 })

+ 6 - 2
packages/shell/bash-local/tests/executor.spec.ts

@@ -1,7 +1,7 @@
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { afterAll, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -10,6 +10,10 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
 async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
   const ctx = new Context()
   await ctx.plugin(LocalSubprocessRuntime)

+ 16 - 7
packages/shell/bash-sandbox/tests/sandbox.spec.ts

@@ -8,7 +8,7 @@
 import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -23,6 +23,10 @@ import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
 /** One recorded provider call: the argv handed over and the policy it rode with. */
 interface ConfineCall {
   argv: string[]
@@ -518,12 +522,17 @@ describe('result facts', () => {
 
   it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => {
     const { bash } = await setup()
-    const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked')
-    mkdirSync(lockedDir)
-    chmodSync(lockedDir, 0o555)
-    const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
-    expect(result.exitCode).not.toBe(0)
-    expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+    const deniedRoot = mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-'))
+    try {
+      const lockedDir = join(deniedRoot, 'locked')
+      mkdirSync(lockedDir)
+      chmodSync(lockedDir, 0o555)
+      const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` }))
+      expect(result.exitCode).not.toBe(0)
+      expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
+    } finally {
+      rmSync(deniedRoot, { recursive: true, force: true })
+    }
   })
 
   it('carries the provider\'s partial-enforcement fact through unchanged', async () => {

+ 16 - 2
packages/shell/pwsh-local/tests/executor.spec.ts

@@ -9,11 +9,11 @@
  * writes CRLF on Windows, so exact text assertions normalize line endings.
  */
 
-import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { spawnSync } from 'node:child_process'
-import { describe, expect, it } from 'vitest'
+import { afterAll, afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -24,6 +24,16 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell'
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
+/** Per-test temp dirs, removed after each test. */
+const tempDirs: string[] = []
+afterEach(() => {
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 // The probe follows the executor's own resolution (Program Files installs on
 // Windows are found even when bare `pwsh` is not on PATH).
 const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0
@@ -114,6 +124,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
 
   it('returns the first EXISTING win32 candidate, else pwsh', () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-'))
+    tempDirs.push(dir)
     const store = join(dir, 'store')
     mkdirSync(store, { recursive: true })
     writeFileSync(join(store, 'pwsh.exe'), '')
@@ -131,6 +142,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
     // Store app execution aliases stat as EACCES but lstat as a link; a
     // dangling symlink reproduces that split on every platform.
     const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-'))
+    tempDirs.push(dir)
     const store = join(dir, 'store')
     mkdirSync(store, { recursive: true })
     const link = join(store, 'pwsh.exe')
@@ -141,6 +153,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () =>
 
   it('skips a directory candidate and falls through to the PATH-resolution default', () => {
     const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-'))
+    tempDirs.push(dir)
     const store = join(dir, 'store')
     mkdirSync(join(store, 'pwsh.exe'), { recursive: true })
     expect(resolvePwshPath(undefined, {
@@ -201,6 +214,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => {
   it('uses config cwd, overridable per call', async () => {
     const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-'))
     const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-'))
+    tempDirs.push(first, second)
     const { bash } = await setup({ cwd: first })
     const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' }))
     expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true)

+ 6 - 2
packages/shell/tool-bash/tests/tools.spec.ts

@@ -1,7 +1,7 @@
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
 import { ShellExecutor } from '@deepseek-ai/dsh-shell'
@@ -29,6 +29,10 @@ const testToolSignal = new AbortController().signal
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
 
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+})
+
 /** Foreground-only harness: no job runtime (backgrounding fails loud here). */
 async function setup() {
   const ctx = new Context()

+ 10 - 2
packages/shell/tool-pwsh/tests/tools.spec.ts

@@ -10,9 +10,9 @@
  * is pinned separately in integration.spec.ts.
  */
 
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { mkdtempSync, realpathSync } from 'node:fs'
+import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join, resolve as resolvePath } from 'node:path'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
@@ -38,6 +38,12 @@ import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts'
 
 const testToolSignal = new AbortController().signal
 
+/** Per-test temp dirs (session cwd/home fixtures), removed after each test. */
+const tempDirs: string[] = []
+afterEach(() => {
+  for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
+})
+
 /**
  * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()`
  * returns the armed foreground script, `start()` returns the armed background
@@ -388,6 +394,7 @@ describe('argument validation', () => {
 describe('execution through the bash seam', () => {
   it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => {
     const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-'))
+    tempDirs.push(dshHome)
     const { ctx, bash } = await setup({}, dshHome)
     bash.handler = () => runResult('hi\n')
     const agent = registerFakeAgent(ctx, 'session-1')
@@ -538,6 +545,7 @@ describe('per-call sandbox policy resolution', () => {
   it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => {
     const { ctx, bash } = await setupSandboxed()
     const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-'))
+    tempDirs.push(sessionCwd)
     const agent = registerFakeAgent(ctx, 'policy-session')
     Object.assign(agent.session.header, { cwd: sessionCwd })
     const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent)

+ 10 - 2
packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts

@@ -3,7 +3,7 @@ import type { Stats } from 'node:fs'
 import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SkillRegistry from '@deepseek-ai/dsh-skill'
 
@@ -91,8 +91,16 @@ vi.mock('chokidar', () => ({
 
 const SkillFileSystem = await import('../src/index.ts')
 
+/** Every temp dir created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 async function tempDir(name: string): Promise<string> {
-  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  tempDirs.push(dir)
+  return dir
 }
 
 async function writeSkill(root: string, name: string): Promise<void> {

+ 10 - 2
packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import { tmpdir } from 'node:os'
@@ -7,8 +7,16 @@ import SkillRegistry from '@deepseek-ai/dsh-skill'
 import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
 import * as SkillFileSystem from '../src/index.ts'
 
+/** Every temp dir created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 async function tempDir(name: string): Promise<string> {
-  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  tempDirs.push(dir)
+  return dir
 }
 
 async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {

+ 11 - 3
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -1,5 +1,5 @@
-import { describe, expect, it } from 'vitest'
-import { mkdir, writeFile } from 'node:fs/promises'
+import { afterEach, describe, expect, it } from 'vitest'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
 import { join } from 'node:path'
 import { tmpdir } from 'node:os'
 import { Context } from '@deepseek-ai/cordis'
@@ -17,8 +17,16 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
 
 const testToolSignal = new AbortController().signal
 
+/** Every temp dir created by this file, removed after each test. */
+const tempDirs: string[] = []
+afterEach(async () => {
+  for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
 async function tempDir(name: string): Promise<string> {
-  return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
+  tempDirs.push(dir)
+  return dir
 }
 
 async function writeSkill(root: string, name: string, description: string, body: string): Promise<void> {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subprocess/subprocess-local/README.md
-README.md: dd9edbc99578f5411fad993cf88f93429dc9cab2
-README.zh.md: 7bb43f7bbce5ef6b128edc9ef2104d35a5887845
+README.md: d8e44d160a01fa2f394aaf781cf90728b0506c77
+README.zh.md: 4a3fed4fa8525be1428a61c8bdc1c3fb8497f263

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

@@ -25,7 +25,7 @@ Mount `dsh-subprocess-local` in any composition that runs child processes on the
 <a id="use-this-package"></a>
 ## Use this package
 
-Mount the provider beside its consumers and start processes exactly as the subprocess service specifies; this package decides only how those processes run on the host.
+Mount the provider beside its consumers and start processes exactly as the subprocess service specifies; this package decides only how those processes run on the host. On Windows, non-terminal children and `taskkill` helpers start with their windows hidden so background operations do not take focus. This also hides GUI windows that honor the process startup visibility setting.
 
 ### Mounting the provider
 
@@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina
 
 ### Shutdown behavior
 
-Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
+Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it holds no completed spill file (completed spill files are retained as full-output recovery artifacts until an external cleanup). Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
 
 ### What can go wrong
 
@@ -127,7 +127,7 @@ These limits define when the provider is a poor fit or needs special operational
 - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor.
 - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner.
 - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work.
-- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them.
+- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file.
 
 <a id="dev-note"></a>
 ### Dev Note

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

@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-把提供方与它的消费方挂载在同一组合中,并完全按子进程服务的规定启动进程;本包只决定这些进程在宿主机上如何运行。
+把提供方与它的消费方挂载在同一组合中,并完全按子进程服务的规定启动进程;本包只决定这些进程在宿主机上如何运行。在 Windows 上,非终端子进程与 `taskkill` 辅助进程会隐藏窗口,因此后台操作不会抢占焦点。遵循进程启动可见性设置的 GUI 窗口也会被隐藏。
 
 ### 挂载提供方
 
@@ -50,7 +50,7 @@ kind: "package-reference"
 
 ### 关闭行为
 
-正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。
+正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其未持有任何已完成的 spill 文件时;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理)。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。
 
 ### 可能出错的地方
 
@@ -127,7 +127,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目
 - **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。
 - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。
 - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。
-- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。
+- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。
 
 <a id="dev-note"></a>
 ### 开发备注

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

@@ -39,7 +39,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
   private live = new Set<LocalSubprocessHandle>()
   /** Live terminals retained through normal quiescence or host-exit finalization. */
   private terminals = new Set<LocalTerminalHandle>()
-  /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
+  /** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
   internals: SpawnInternals = {}
   /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
   terminalInspector: ProcessInspector | undefined

+ 33 - 6
packages/subprocess/subprocess-local/src/spawn.ts

@@ -7,10 +7,10 @@
  * @module dsh-subprocess-local/spawn
  */
 
-import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
+import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process'
 import type { Readable } from 'node:stream'
 import { randomBytes } from 'node:crypto'
-import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs'
+import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { setTimeout as sleepMs } from 'node:timers/promises'
@@ -26,6 +26,12 @@ import type {
 } from '@deepseek-ai/dsh-subprocess'
 import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts'
 
+type SpawnProcess = (
+  program: string,
+  args: readonly string[],
+  options: SpawnOptions,
+) => ChildProcess
+
 /**
  * Build a child environment: explicit caller entries override the scrubbed
  * parent base using the target platform's environment-key semantics. A string
@@ -46,8 +52,10 @@ export function childEnv(extra?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv
   return Object.fromEntries(entries)
 }
 
-/** Injectable knobs so tests can exercise spill and platform behavior deterministically. */
+/** Injectable process, spill, and platform operations. */
 export interface SpawnInternals {
+  /** Process spawner (defaults to `node:child_process` `spawn`). */
+  spawn?: SpawnProcess
   /** Directory for spill files (defaults to the OS temp dir). */
   spillDir?: string
   /** Windows tree-termination runner (defaults to `taskkill /PID <pid> /T /F`). */
@@ -84,13 +92,27 @@ let defaultSpillDir: string | undefined
 /**
  * The default spill location: a private (0700) per-process directory under
  * the OS tmpdir, created lazily. Predictable world-readable paths would let
- * other local users read command output or pre-create symlinks.
+ * other local users read command output or pre-create symlinks. At a
+ * JavaScript-observable process exit the directory is removed only when it
+ * holds no completed spill file (spill files are retained as full-output
+ * recovery artifacts until an external cleanup).
  */
 function privateSpillDir(): string {
   defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-'))
   return defaultSpillDir
 }
 
+// The per-process spill directory is removed at process exit when it holds no
+// completed spill file: a directory that never spilled is empty and is safe to
+// remove, while a directory holding completed spill files keeps them (their
+// content is retained until an external cleanup). A SIGKILLed process cannot
+// run this at all; its residue is left to OS temp hygiene.
+/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */
+process.once('exit', () => {
+  if (defaultSpillDir === undefined) return
+  try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ }
+})
+
 /**
  * Collects one stream with a bounded in-memory tail. With a spill cap, on
  * first overflow a spill file is created and every chunk (including those
@@ -278,7 +300,10 @@ export function taskkillProcessTree(pid: number): void {
   // Outcome deliberately unchecked: an already-absent tree (status 128), exit
   // races, and a missing taskkill binary (spawnSync reports, never throws) are
   // as tolerable here as ESRCH is for a POSIX group signal.
-  spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
+  spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], {
+    stdio: 'ignore',
+    windowsHide: true,
+  })
 }
 
 /**
@@ -329,6 +354,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
   }
   const spillDir = internals.spillDir ?? privateSpillDir()
   const platform = internals.platform ?? process.platform
+  const spawnProcess = internals.spawn ?? spawn
   const taskkill = internals.taskkill ?? taskkillProcessTree
   const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers
 
@@ -347,7 +373,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
   const stdinMode = spec.stdio.stdin
 
   const env = childEnv(spec.env)
-  const child = spawn(program, args, {
+  const child = spawnProcess(program, args, {
     cwd: spec.cwd,
     env,
     stdio: [
@@ -358,6 +384,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter
     // `detached` gives teardown a tree root on POSIX (its own process group);
     // Windows terminates by root pid through taskkill /T instead.
     detached: platform !== 'win32',
+    windowsHide: platform === 'win32',
   })
 
   const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => {

+ 4 - 1
packages/subprocess/subprocess-local/src/windows-inspector.ts

@@ -145,7 +145,10 @@ function taskkillTree(pid: number, force: boolean): void {
   if (pid <= 0) return
   // Outcome deliberately unchecked: an already-absent tree, exit races, and a
   // missing taskkill binary are as tolerable here as ESRCH is for POSIX.
-  spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' })
+  spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], {
+    stdio: 'ignore',
+    windowsHide: true,
+  })
 }
 
 declare const nativePtr: unique symbol

+ 52 - 2
packages/subprocess/subprocess-local/tests/spawn.spec.ts

@@ -1,7 +1,8 @@
-import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs'
+import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process'
+import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { dirname, join } from 'node:path'
-import { describe, expect, it, vi } from 'vitest'
+import { afterAll, describe, expect, it, vi } from 'vitest'
 import {
   childEnv,
   killGroup,
@@ -12,6 +13,11 @@ import {
 import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 
+vi.mock('node:child_process', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:child_process')>()
+  return { ...actual, spawnSync: vi.fn(actual.spawnSync) }
+})
+
 /**
  * Translate the suite's POSIX command strings into node one-liners on Windows,
  * where no bash exists; the translated commands keep the same observable
@@ -82,6 +88,14 @@ vi.mock('node:fs', async (importOriginal) => {
 
 const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-'))
 
+/** The per-process default spill dir captured by the default-spill test. */
+let defaultSpillDir: string | undefined
+
+afterAll(() => {
+  rmSync(spillDir, { recursive: true, force: true })
+  if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true })
+})
+
 type SpecOverrides = Partial<Parameters<typeof spawnSubprocess>[0]> & {
   stdoutMaxBytes?: number
   stderrMaxBytes?: number
@@ -631,6 +645,30 @@ describe('stdio dispositions', () => {
 })
 
 describe('windows tree semantics (injected platform)', () => {
+  it('hides the child window without changing output, exit, stdio, or tree-root options', async () => {
+    let options: Parameters<typeof nodeSpawn>[2]
+    const result = await finish(spawnSubprocess(spec('echo hello'), {
+      spillDir,
+      platform: 'win32',
+      spawn: (program, args, spawnOptions) => {
+        options = spawnOptions
+        return nodeSpawn(program, args, spawnOptions)
+      },
+    }))
+
+    expect(options!).toMatchObject({
+      windowsHide: true,
+      detached: false,
+      stdio: ['ignore', 'pipe', 'pipe'],
+    })
+    expect(result).toMatchObject({
+      exitCode: 0,
+      signal: null,
+      stdout: { text: 'hello\n', truncated: false },
+      stderr: { text: '', truncated: false },
+    })
+  })
+
   it('host-exit termination routes through taskkill immediately', async () => {
     const killed: number[] = []
     const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 60_000 }), {
@@ -773,6 +811,17 @@ describe.skipIf(process.platform === 'win32')('tree-survivor escalation (termina
 })
 
 describe('coverage seams', () => {
+  it('hides the taskkill helper window', () => {
+    const taskkill = vi.mocked(nodeSpawnSync)
+    taskkill.mockReturnValueOnce({} as never)
+    taskkillProcessTree(77)
+    expect(taskkill).toHaveBeenLastCalledWith(
+      'taskkill',
+      ['/PID', '77', '/T', '/F'],
+      { stdio: 'ignore', windowsHide: true },
+    )
+  })
+
   it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => {
     expect(() => { taskkillProcessTree(-1) }).not.toThrow()
     expect(() => { taskkillProcessTree(0) }).not.toThrow()
@@ -1086,6 +1135,7 @@ describe('environment and spill-file hardening', () => {
       spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }),
     ))
     const dir = dirname(result.stdout.spillPath!)
+    defaultSpillDir = dir
     expect(dir).toMatch(/dsh-subprocess-/)
     const mode = statSync(dir).mode & 0o777
     expect(mode).toBe(0o700)

+ 27 - 1
packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts

@@ -1,4 +1,5 @@
-import { describe, expect, it } from 'vitest'
+import { spawnSync as nodeSpawnSync } from 'node:child_process'
+import { describe, expect, it, vi } from 'vitest'
 import {
   createWindowsProcessInspector,
   isInvalidHandle,
@@ -12,6 +13,11 @@ import type {
   WindowsProcessState,
 } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts'
 
+vi.mock('node:child_process', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:child_process')>()
+  return { ...actual, spawnSync: vi.fn(actual.spawnSync) }
+})
+
 function fakeInternals() {
   const entries: ProcessEntry[] = []
   const states = new Map<number, WindowsProcessState>()
@@ -86,6 +92,26 @@ describe('windowsProcessTree', () => {
 })
 
 describe('WindowsProcessInspector (injected internals)', () => {
+  it('hides the default taskkill helper window for both termination tiers', () => {
+    const taskkill = vi.mocked(nodeSpawnSync)
+    taskkill.mockReturnValueOnce({} as never).mockReturnValueOnce({} as never)
+    const inspector = createWindowsProcessInspector()
+    inspector.signalGroup(77, 'SIGKILL')
+    inspector.signalGroup(78, 'SIGTERM')
+    expect(taskkill).toHaveBeenNthCalledWith(
+      1,
+      'taskkill',
+      ['/PID', '77', '/T', '/F'],
+      { stdio: 'ignore', windowsHide: true },
+    )
+    expect(taskkill).toHaveBeenNthCalledWith(
+      2,
+      'taskkill',
+      ['/PID', '78', '/T'],
+      { stdio: 'ignore', windowsHide: true },
+    )
+  })
+
   it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => {
     const fake = fakeInternals()
     const inspector = new WindowsProcessInspector(fake.internals)

+ 2 - 2
packages/util/workspace-path/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/util/workspace-path/README.md
-README.md: 72addf2d85c62ef5afbfc4824af8c96c59bfbecb
-README.zh.md: 2a115f1a9dbdb23b7121dea41eb6da75b1515358
+README.md: 1b09bda74c8836655789c57fa05d03a2892c16b9
+README.zh.md: 25128e5fec504e1724f5cc7952fed976985912d5

+ 1 - 1
packages/util/workspace-path/README.md

@@ -22,7 +22,7 @@ Browser-safe path helpers shared by Workspace-facing client and controller packa
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths but does not access a filesystem or canonicalize `.` and `..` segments.
+- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths, preserves the Workspace path's separator when joining a relative path, and does not access a filesystem or canonicalize `.` and `..` segments.
 - **Home abbreviation is POSIX-only** — Windows paths remain unchanged because a portable browser cannot infer Windows home-path equivalence safely.
 
 

+ 1 - 1
packages/util/workspace-path/README.zh.md

@@ -22,7 +22,7 @@ kind: "package-library"
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。
+- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,拼接相对路径时保留 Workspace 路径的分隔符,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。
 - **主目录缩写仅支持 POSIX**——Windows 路径保持不变,因为可移植浏览器无法安全推断 Windows 主目录路径等价关系。
 
 

+ 2 - 1
packages/util/workspace-path/src/index.ts

@@ -17,9 +17,10 @@ function isWindowsStylePath(value: string): boolean {
 export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
   if (path.startsWith('/') || isWindowsStylePath(path)) return path
   if (cwd === undefined || cwd === '') return path
+  const separator = isWindowsStylePath(cwd) && cwd.includes('\\') ? '\\' : '/'
   const base = cwd.replace(/[/\\]+$/, '')
   const relative = path.replace(/^[/\\]+/, '')
-  return `${base}/${relative}`
+  return `${base}${separator}${relative}`
 }
 
 /**

+ 6 - 0
packages/util/workspace-path/tests/index.spec.ts

@@ -13,6 +13,12 @@ describe('Workspace path helpers', () => {
     expect(resolveWorkspacePath('/w', '\\\\server\\share')).toBe('\\\\server\\share')
   })
 
+  it('keeps Windows drive-root and directory joins fully qualified', () => {
+    expect(resolveWorkspacePath('C:\\', 'src\\a.ts')).toBe('C:\\src\\a.ts')
+    expect(resolveWorkspacePath('C:\\work\\', 'src\\a.ts')).toBe('C:\\work\\src\\a.ts')
+    expect(resolveWorkspacePath('C:/work/', 'src/a.ts')).toBe('C:/work/src/a.ts')
+  })
+
   it('abbreviates only descendants of a POSIX home', () => {
     expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~')
     expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~')

+ 2 - 2
packages/workspace/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/workspace/workspace/README.md
-README.md: 49ca861742fe9ea5f36edbaeb8d9700c475b1d50
-README.zh.md: 707044970b716577d2f1f95e558c341e217985c4
+README.md: eb3d2a4f89b6023d8a916a41723500186cc10d93
+README.zh.md: 5059a6edd69a82bd8b0a364793ca1b5c941d67e2

+ 1 - 1
packages/workspace/workspace/README.md

@@ -50,7 +50,7 @@ With these rows mounted, creating a project shows up in the list immediately and
 
 ### Creating and ordering projects
 
-Create a project from any directory that exists: give its path and an optional title, and the project appears in the list, newest first. A path that does not exist, or a file instead of a directory, is rejected and nothing changes; creating a project for a directory that already has one returns the existing project unchanged. Rename a project at any time, and move it to any position in the list:
+Create a project from any fully qualified directory that exists: filesystem roots such as `C:\` and ordinary directories are valid. Relative paths, Windows drive-relative paths such as `C:work`, missing paths, and files are rejected without creating a project; creating a project for a directory that already has one returns the existing project unchanged. Rename a project at any time, and move it to any position in the list:
 
 ```text
 // Host consumer code, after the composition above is loaded:

+ 1 - 1
packages/workspace/workspace/README.zh.md

@@ -50,7 +50,7 @@ kind: "package-reference"
 
 ### 创建与排序项目
 
-从任何存在的目录创建项目:给出路径和可选标题,项目即出现在列表中,新到旧排列。不存在的路径或文件而非目录会被拒绝,且不会有任何变化;为已有项目的目录再次创建会原样返回现有项目。你可以随时重命名项目,并把它移动到列表中的任意位置:
+从任何存在且完整限定的目录创建项目:`C:\` 等文件系统根目录和普通目录都有效。相对路径、`C:work` 等 Windows 盘符相对路径、不存在的路径和文件都会被拒绝,且不会创建项目;为已有项目的目录再次创建会原样返回现有项目。你可以随时重命名项目,并把它移动到列表中的任意位置:
 
 ```text
 // Host consumer code, after the composition above is loaded:

+ 9 - 10
packages/workspace/workspace/src/index.ts

@@ -7,7 +7,6 @@
 
 import { randomUUID } from 'node:crypto'
 import { stat } from 'node:fs/promises'
-import { basename } from 'node:path'
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-session-persistence'
@@ -16,7 +15,7 @@ import { WorkspaceEntity } from './entity.ts'
 import type { WorkspaceEntityHost } from './entity.ts'
 
 export { WorkspaceMoveInvalidError } from './entity.ts'
-import { realpathNormalize } from './paths.ts'
+import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts'
 import { workspaceDomainSpec } from './spec.ts'
 import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
 import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts'
@@ -140,13 +139,13 @@ export class WorkspaceRegistry extends Service {
   }
 
   /**
-   * Create or reuse a workspace for an existing directory. The path is
-   * canonicalized through `fs.realpath`; a nonexistent path rejects with the
-   * original error and a non-directory rejects. Repeated calls for the same
-   * canonical path return the existing entity without changing its title.
+   * Create or reuse a workspace for an existing directory. The fully qualified
+   * path is canonicalized through `fs.realpath`; a relative, nonexistent, or
+   * non-directory path rejects. Repeated calls for the same canonical path
+   * return the existing entity without changing its title.
    * A newly created workspace is prepended to the durable registry order.
    * Different canonical paths may share a display title.
-   * @param path - Existing directory to own, in any path spelling.
+   * @param path - Existing directory to own, in a fully qualified path spelling.
    * @param title - Display title used only when a new record is created.
    * @returns the existing or newly durable workspace.
    */
@@ -271,7 +270,7 @@ export class WorkspaceRegistry extends Service {
    * Resolve by canonical directory path without creating or mutating a
    * workspace. A missing path rejects during `realpath`; an existing unowned
    * directory returns `undefined`.
-   * @param path - Existing directory path in any spelling.
+   * @param path - Existing directory path in a fully qualified spelling.
    * @returns the workspace owning the canonical path, when one exists.
    */
   async resolveByPath(path: string): Promise<Workspace | undefined> {
@@ -287,7 +286,7 @@ export class WorkspaceRegistry extends Service {
       if (entity.path === canonical) return entity
     }
 
-    const workspaceName = title ?? basename(canonical)
+    const workspaceName = title ?? defaultWorkspaceTitle(canonical)
     const table = this.requireTable()
     const state = this.requireState()
     const id = WorkspaceId(randomUUID())
@@ -459,7 +458,7 @@ export class WorkspaceRegistry extends Service {
         const createdAt = new Date(group.newestAt).toISOString()
         const record: WorkspaceRecord = {
           path: group.path,
-          title: basename(group.path),
+          title: defaultWorkspaceTitle(group.path),
           sessionIds,
           createdAt,
           updatedAt: createdAt,

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff