Переглянути джерело

Merge pull request #4368 from deepseek-harness/refactor/client-session-references

refactor(client): own Session generations with explicit references
imccyu 2 тижнів тому
батько
коміт
f0aebe0d8b
100 змінених файлів з 2571 додано та 1478 видалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
  2. 16 14
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
  3. 16 14
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml
  5. 28 27
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
  6. 28 27
      .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
  11. 226 0
      .agents/notes/implemented/architecture/2026-09-15-client-session-references.md
  12. 226 0
      .agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
  13. 0 1
      apps/web/tests/agent-preset-authoring.e2e.ts
  14. 2 3
      apps/web/tests/chat-scroll-contract.e2e.ts
  15. 2 2
      apps/web/tests/details-session-lifecycle.e2e.ts
  16. 1 1
      apps/web/tests/sidebar-right.e2e.ts
  17. 3 0
      apps/web/tests/workflow-run.e2e.ts
  18. 2 2
      docs/module-graph.i18n.yaml
  19. 2 1
      docs/module-graph.md
  20. 2 1
      docs/module-graph.zh.md
  21. 2 2
      docs/subsystems/slots.i18n.yaml
  22. 4 4
      docs/subsystems/slots.md
  23. 4 4
      docs/subsystems/slots.zh.md
  24. 2 2
      docs/subsystems/web-client.i18n.yaml
  25. 4 4
      docs/subsystems/web-client.md
  26. 4 4
      docs/subsystems/web-client.zh.md
  27. 2 2
      packages/api/gateway/README.i18n.yaml
  28. 2 0
      packages/api/gateway/README.md
  29. 2 0
      packages/api/gateway/README.zh.md
  30. 38 46
      packages/api/gateway/src/client/remote-events.ts
  31. 80 5
      packages/api/gateway/tests/gateway.client.spec.ts
  32. 2 2
      packages/api/session-controller/README.i18n.yaml
  33. 1 0
      packages/api/session-controller/README.md
  34. 1 0
      packages/api/session-controller/README.zh.md
  35. 59 20
      packages/api/session-controller/src/client/contract/sessions.ts
  36. 20 3
      packages/api/session-controller/src/client/index.ts
  37. 22 24
      packages/api/session-controller/src/client/scope.ts
  38. 0 5
      packages/api/session-controller/src/client/sessions/lineage.ts
  39. 42 145
      packages/api/session-controller/src/client/sessions/manager.ts
  40. 270 274
      packages/api/session-controller/src/client/sessions/service.ts
  41. 17 8
      packages/api/session-controller/tests/client-apply.client.spec.ts
  42. 4 5
      packages/api/session-controller/tests/lineage.client.spec.ts
  43. 139 141
      packages/api/session-controller/tests/manager.client.spec.ts
  44. 324 0
      packages/api/session-controller/tests/reference-ownership.client.spec.ts
  45. 17 0
      packages/api/session-controller/tests/scope.client.spec.ts
  46. 39 9
      packages/api/session-controller/tests/session.client.spec.ts
  47. 141 260
      packages/api/session-controller/tests/sessions-service.client.spec.ts
  48. 5 5
      packages/bundle/web-app/cordis.patch.yml
  49. 5 5
      packages/client/locale/tests/language-row.client.spec.tsx
  50. 14 6
      packages/client/resources/tests/apply.client.spec.ts
  51. 2 3
      packages/client/store/src/contract.ts
  52. 1 0
      packages/client/ui-agent-preset/package.json
  53. 6 2
      packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
  54. 56 54
      packages/client/ui-agent-preset/src/client/index.ts
  55. 24 10
      packages/client/ui-agent-preset/src/client/seat-store.ts
  56. 181 30
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  57. 20 0
      packages/client/ui-agent-preset/tests/components.client.spec.tsx
  58. 3 0
      packages/client/ui-agent-preset/tsconfig.json
  59. 4 4
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  60. 2 2
      packages/client/ui-chat/src/client/apply.ts
  61. 21 16
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  62. 9 8
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  63. 5 4
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  64. 5 4
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  65. 1 0
      packages/client/ui-commands/package.json
  66. 21 12
      packages/client/ui-commands/src/client/service.ts
  67. 11 2
      packages/client/ui-commands/tests/browser-plugin.client.spec.ts
  68. 12 3
      packages/client/ui-commands/tests/service.client.spec.ts
  69. 3 0
      packages/client/ui-commands/tsconfig.json
  70. 1 0
      packages/client/ui-conversation/package.json
  71. 12 17
      packages/client/ui-conversation/src/client/apply.ts
  72. 1 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  73. 12 9
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  74. 41 44
      packages/client/ui-conversation/src/client/conversation/historical-images.ts
  75. 19 14
      packages/client/ui-conversation/src/client/input/hub.ts
  76. 3 3
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  77. 39 19
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  78. 4 4
      packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
  79. 25 28
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  80. 14 8
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  81. 5 4
      packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
  82. 11 3
      packages/client/ui-conversation/tests/historical-images.client.spec.ts
  83. 4 3
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  84. 4 3
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  85. 7 3
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  86. 4 3
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  87. 28 13
      packages/client/ui-conversation/tests/selection-survival.client.spec.tsx
  88. 37 12
      packages/client/ui-conversation/tests/service-orchestration.client.spec.ts
  89. 15 12
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  90. 3 0
      packages/client/ui-conversation/tsconfig.json
  91. 1 1
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  92. 1 0
      packages/client/ui-input-trigger/package.json
  93. 21 14
      packages/client/ui-input-trigger/src/client/service.ts
  94. 5 1
      packages/client/ui-input-trigger/tests/apply.client.spec.ts
  95. 12 3
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  96. 3 0
      packages/client/ui-input-trigger/tsconfig.json
  97. 0 2
      packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
  98. 2 1
      packages/client/ui-layout/src/client/DocumentTitle.tsx
  99. 5 6
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  100. 9 5
      packages/client/ui-layout/tests/document-title.client.spec.tsx

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
-2026-07-25-web-client-session-scope-and-provide-channel.md: 0f003edc4a53e7a04ea4f5613abe42c2d3926566
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1bae16cfaa1c54eebc6c8709ac6db5323b68d222
+2026-07-25-web-client-session-scope-and-provide-channel.md: 6126d06e48111a551a0dc57247659a3086986c66
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e7bf4aa6952b4fd7b187c91e2ac5d64aac32cabb

+ 16 - 14
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md

@@ -19,6 +19,8 @@ Hard constraints: the host is the single source of truth; every registration goe
 
 ## Decision
 
+The reference-owned lifetime and Provider targeting now follow [Client Session references](2026-09-15-client-session-references.md). This note retains the blank-Session and adoption rationale and describes their current realization.
+
 ### The parity model: client and host share one root state axis
 
 Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one piece (an atomic bundle, never split); the client side is the mirror of that birth — the instant a session row enters the list mirror, the client mints its Agent scope (actx + provide + the full input surface mounted):
@@ -48,13 +50,13 @@ id→ctx handoff is allowed in only three kinds of places (business providers ne
 - Root coordination services self-addressing: from a projection's sessionId back to the actx via `sessions.scope(id)`.
 - Root untagged listeners: looking up their own store by the payload's sessionId.
 
-### Scope lifecycle: anchored to the list mirror — birth is entering view, death is prune
+### Scope lifecycle: anchored to explicit references
 
-Session instances share the scope's lifecycle; liveness eligibility = host-listed (one criterion, shared by mint and prune):
+Session instances share the scope's lifecycle, while the catalog reports discoverability without retaining a generation:
 
-- Birth = a session row entering client view (the list baseline pull / the local `create()` echo / the `host/session-added` frame); a lazy first resolve mints the scope (resolution is a pure function, render-safe).
-- One prune tears down three things together: the Session instance, the scope fiber (cascading through every consumer hung on the actx), and the session-keyed slot store. The staged session (= `list.current`) is the exception: removed while still on stage, it keeps a frozen read-only view, torn down only once the stage moves away.
-- Reopening = lazily rebuilding the instance + `open()` pulling history (the host session log is the durable truth).
+- Birth = the first explicit `sessions.retain(target, options)`; it synchronously returns a reference and mints the Session binding and scope before history is ready.
+- Final release withdraws the exact generation before tearing down its Session instance, scope fiber (cascading through every consumer hung on the actx), and session-keyed slot store. Catalog removal does not end a generation while references remain.
+- Reopening = a later retain lazily rebuilding the generation and exposing history readiness through `reference.ready` (the Host Session log is the durable truth).
 - Remaining TODO: approval/question frames never enter history and cannot be recovered across a prune (the manager-level pendingBuffers cover only the never-instantiated window).
 
 ### The blank bit: the empty session's visible projection, conversion, and reuse
@@ -67,7 +69,7 @@ A session "materialized but with no first prompt" is governed by the summary-der
   - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member.
   - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank;
   - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank.
-- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the one with `session.id === sessions.current`, its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's current blank shows; the user-visible surface therefore holds at most one blank row globally.
+- List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally.
 - The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination.
 
 ### connectWorkspace: the sole entry point of New Session
@@ -77,23 +79,23 @@ A session "materialized but with no first prompt" is governed by the summary-der
 - The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing.
 - The create arm: on a miss, `session.create({workspaceId})` returns the new id.
 - An unknown workspaceId fails loud (never silently creating somewhere else).
-- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store and `sessions.binding(id)` resolves synchronously — `SessionRuntime.create` projects the list synchronously after RPC success before resolving, so a draft mover can write text into the new scope's machine before open, without waiting for a notifier flush.
-- The caller takes the id and does its own `sessions.open`; sending the first prompt is an ordinary `session.prompt` — the session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
-- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping host order on ties; only with no Workspace at all does it `sessions.clear()` into the no-session view. Create actions inside a Workspace group still hit that Workspace explicitly.
+- The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store. The view owner then retains it synchronously, so a draft mover can write text through that binding before history readiness without waiting for a notifier flush.
+- The caller takes the id and installs a `mainView` reference; sending the first prompt is an ordinary `session.prompt` — the Session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again.
+- The global New Session button defaults to `recentWorkspaceId`: first comparing each Workspace's newest Session `updatedAt`, falling back to the Workspace `createdAt` when it has no Sessions, and keeping Host order on ties; only with no Workspace at all does it clear the main-view reference into the no-Session view. Create actions inside a Workspace group still hit that Workspace explicitly.
 - At startup the runtime subscribes to the first complete baseline: a successfully restored current session is kept in place; otherwise it automatically calls `connectWorkspace(recentWorkspaceId)` and opens the returned blank session. The policy settles only once; a later user-initiated clear is never overridden by auto-selection again, and a connect failure waits for the next baseline projection to retry.
-- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the current one, the current input machine's non-empty draft moves to the target scope first, then `sessions.open(nextId)`. The old blank entity is not deleted — it merely leaves the list by no longer being current.
+- Re-picking the Workspace in the blank Hero also goes through `connectWorkspace`; when the target id differs from the main one, `ui-workspace` retains the target, moves the current input machine's non-empty draft through the preparation callback, and then publishes the new main reference. The old blank entity is not deleted — it merely leaves the list when its `mainView` reference is released.
 
-### Per-session provisioning: the `sessions.provide` standard-kit channel
+### Per-session provisioning: the `uiSession.provide` standard-kit channel
 
-The sole provisioning path by which session slot components fetch their own session data. Plugins declare a fixed key map through the static descriptor `sessions.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific session and tears them down with the scope. ui-renderer's `standardKit` single loop binds the hooks compartment into `use<Name>` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is.
+The sole provisioning path by which Session slot components fetch their own Session data. Plugins declare a fixed key map through the static descriptor `uiSession.provide({hooks, props, resolve})` (a duplicate key throws at registration); `resolve(binding)` materializes values for a specific binding and tears them down with its scope. ui-renderer's `standardKit` single loop binds the hooks compartment into `use<Name>` selector hooks (`observableHook`→uSES, anti-tearing) and passes the props compartment through as-is.
 
 Slot scope is the closed set `root | session-maybe | session`:
 
 - `root` receives only the global standard kit, with no session identity or provisioning.
-- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`.
+- `session-maybe` inherits the nearest `SessionProvider` binding with ADOPTION identity: an incarnation born Session-less keeps its React instance when that Provider receives its first binding, then remounts when the Provider switches generation or returns to absence. Component-local per-Session state clears when the Provider switches generation. Across a switch, only persisted Store values survive generation retirement; binding-owned sources survive only when another reference keeps that generation alive. With no binding, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. Provider-roster changes rematerialize the mounted binding without changing its identity, while the per-entry adoption bookkeeping lives in the renderer's `SessionMaybeEntry`.
 - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
 
-`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
+`conversation` is the resident `session-maybe` shell under its owning `SessionProvider`: `ConversationRoot`, HeroShell, the Workspace picker, the scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-Session → blank-Session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same Session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no Session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a binding appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
 
 Blank Sessions retain the header's leading and corner slots so navigation controls, including the right-sidebar opener, are available before the first message. Title, actions, utilities, and View tabs remain hidden in the blank phase. The header still requires a selected Session; the Files and Terminal entries use that Session's workspace and execution services without requiring a recorded Turn.
 

+ 16 - 14
.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md

@@ -19,6 +19,8 @@ web client 只有一张全局会话面:slot 全部从根上下文渲染,插
 
 ## 决策
 
+[Client Session 引用](2026-09-15-client-session-references.zh.md)现已定义引用所有的生命周期与 Provider 定位。本 Note 保留 blank Session 与收养语义的理由,并描述它们的当前实现。
+
 ### 对等模型:client 与 host 同一根状态轴
 
 host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作为不可拆分的原子整体);client 侧就是这次出生的镜像——会话行进入 list mirror 的瞬间,client 为它铸 Agent scope(actx + provide + 输入面全套挂上):
@@ -48,13 +50,13 @@ id→ctx 换乘只许三类位置(业务提供方永不换乘):
 - root 协调服务自寻址:从投影的 sessionId 经 `sessions.scope(id)` 找回 actx。
 - root untagged listener:按 payload 的 sessionId 查自有 store。
 
-### scope 生命周期:挂靠 list mirror,出生即视野、死亡即 prune
+### scope 生命周期:挂靠显式引用
 
-Session 实例与 scope 同生命周期,存活资格 = host listed(一个判据,mint 与 prune 共用):
+Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持有 generation:
 
-- 出生 = 会话行进入 client 视野(list 基线拉取 / `create()` 本地回声 / `host/session-added` 帧),lazy 首次 resolve 铸 scope(resolution 纯函数、渲染安全)。
-- prune 一次同拆三样:Session 实例、scope fiber(级联挂在 actx 上的一切消费方)、会话键控 slot store。暂存会话(= `list.current`)例外:被移除仍在台上时保留冻结只读视图,stage 移走才拆。
-- 重开 = lazy 重建实例 + `open()` 拉 history(host 会话日志是持久真相)。
+- 出生 = 第一次显式调用 `sessions.retain(target, options)`;它同步返回 reference,并在历史就绪前铸造 Session binding 与 scope。
+- 最后一份 reference 释放时,Controller 先撤下确切 generation,再拆除其 Session 实例、scope fiber(级联挂在 actx 上的一切消费方)与会话键控 slot store。仍有 reference 时,catalog 移除不会结束 generation。
+- 重开 = 后续 retain 惰性重建 generation,并通过 `reference.ready` 暴露历史就绪结果(Host Session 日志是持久真相)。
 - 遗留 TODO:approval/question 帧不进 history,跨 prune 不可恢复(manager 级 pendingBuffers 只覆盖「从未实例化」窗口)。
 
 ### blank 位:空会话的可见投影、转正与复用
@@ -67,7 +69,7 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
   - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。
   - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank;
   - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。
-- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示 `session.id === sessions.current` 的一条,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的 current blank 显示;因此用户可见面全局至多一条 blank 行。
+- 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。
 - 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。
 
 ### connectWorkspace:New Session 的唯一入口
@@ -77,23 +79,23 @@ Session 实例与 scope 同生命周期,存活资格 = host listed(一个判
 - 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。
 - 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。
 - 未知 workspaceId fail loud(不静默创建到别处)。
-- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store 且 `sessions.binding(id)` 同步可解析——`SessionRuntime.create` 在 RPC 成功后同步投影列表再 resolve,使 draft 搬运方可以在 open 之前往新 scope 的 machine 写文本,不等 notifier flush。
-- 调用方拿 id 自行 `sessions.open`;首条提示词发送就是普通 `session.prompt`——会话本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。
-- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才 `sessions.clear()` 进入无会话视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
+- 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store。视图 owner 随后同步 retain,因此 draft 搬运方可以在历史就绪前通过该 binding 写入文本,无需等待 notifier flush。
+- 调用方拿 id 安装一份 `mainView` reference;首条提示词发送就是普通 `session.prompt`——Session 本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。
+- 全局 New Session 按钮默认取 `recentWorkspaceId`:先比较各 Workspace 内 Session 的最新 `updatedAt`,无 Session 时回退 Workspace `createdAt`,同值保持 Host 顺序;只有完全没有 Workspace 时才释放主视图 reference,进入无 Session 视图。Workspace 分组内的创建动作仍显式命中该 Workspace。
 - 运行时启动时订阅首次完整基线:若已有恢复成功的 current 会话则保持不动,否则自动 `connectWorkspace(recentWorkspaceId)` 并 open 返回的 blank 会话。该策略只结算一次;之后用户主动 clear 不会再次被自动选择覆盖,连接失败则等下一次基线投影重试。
-- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与当前 id 不同,先把当前 input machine 的非空 draft 搬到目标 scope,再 `sessions.open(nextId)`。旧 blank 实体不删除,只因不再 current 而从列表隐藏。
+- blank Hero 中改选 Workspace 也走 `connectWorkspace`;若目标 id 与主视图 id 不同,`ui-workspace` 先 retain 目标,通过 preparation callback 搬运当前 input machine 的非空 draft,再发布新的主 reference。旧 blank 实体不删除,只因其 `mainView` reference 被释放而从列表隐藏。
 
-### 逐会话供数:`sessions.provide` 标准件通道
+### 逐会话供数:`uiSession.provide` 标准件通道
 
-会话 slot 组件「自己拿会话数据」的唯一供数路径。插件以静态描述符 `sessions.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定会话下物化值并随 scope 拆;ui-renderer `standardKit` 统一循环把 hooks 格绑成 `use<Name>` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。
+Session slot 组件「自己拿 Session 数据」的唯一供数路径。插件以静态描述符 `uiSession.provide({hooks, props, resolve})` 声明固定键表(重名 key 注册时 throw),`resolve(binding)` 在确定 binding 下物化值并随其 scope 拆;ui-renderer `standardKit` 统一循环把 hooks 格绑成 `use<Name>` 选择器钩子(`observableHook`→uSES,防 tearing)、props 格原样透传。
 
 slot scope 是闭集 `root | session-maybe | session`:
 
 - `root` 只拿全局标准件,不接收会话身份或供数。
-- `session-maybe` 以**收养(adoption)身份语义**跟随 current 会话(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个**会话到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格会话 entry 完全一致——切到不同会话重挂,跌回无会话也重挂为崭新的空态化身(之后再次收养)。因此组件本地的逐会话状态**由构造保证**随切换清零;需要活过切换的状态必须住会话绑定的源(machine、store、hooks)。无会话时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅运行时的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无会话时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。
+- `session-maybe` 以**收养(adoption)身份语义**继承最近 `SessionProvider` 的 binding:空态出生的化身在该 Provider 第一次收到 binding 时保持 React 实例,此后 Provider 切换 generation 或回到空态时重挂。Provider 切换 generation 时,组件本地的逐 Session 状态会清零。切换过程中,只有持久化 Store 值能活过 generation 退休;只有另一份 reference 保活该 generation 时,binding 自有 source 才能保留。无 binding 时,`sessionId`、`useSession`/`useInput` 的结果与 `inputActions` 均可缺省。Provider roster 变化会重新物化已挂载 binding,但不改变其 identity;逐 entry 的收养记账住在 renderer 的 `SessionMaybeEntry`。
 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换会话会重建该 entry 及其会话 store。
 
-`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无会话 → blank 会话的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
+`conversation` 是其 owner `SessionProvider` 下的 `session-maybe` 常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 Session → blank Session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 Session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 Session 时,其 machine faces 和消息动作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。binding 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不派发任何内容。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
 
 blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入口等导航控件在首条消息之前即可使用。标题、actions、utilities 和 View tabs 在 blank phase 中继续隐藏。header 仍要求已选中的 Session;Files 与 Terminal 入口使用该 Session 的工作区和执行服务,无需已有 Turn 记录。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md
-2026-08-20-client-session-conversation-ownership.md: 89f27f745c13e070b7f9794d98747bc73bda3b5a
-2026-08-20-client-session-conversation-ownership.zh.md: 79be1b898a1fb5a75e5438d55d739d84775fe6d9
+2026-08-20-client-session-conversation-ownership.md: 0d2f19cff1d4625d678a25c87bcc55fc3f2bfad4
+2026-08-20-client-session-conversation-ownership.zh.md: fdb2420efff88e0a5a33dba79bebf0d072280453

+ 28 - 27
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md

@@ -38,7 +38,7 @@ Client Session and Workspace objects belong to `api/session-controller/client` a
 
 The React adapters for Session and Workspace belong to `client/ui-session` and `client/ui-workspace`. The Store engine belongs to `client/store`; the Slot registry, scope materialization, and observable-to-hook binding belong to `client/ui-renderer`.
 
-The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines Session history, Remote streams, pagination cursors, and reconnect continuity; this note starts from the Client objects and sources published by Controllers.
+The system has no aggregate `client/runtime` package and no replacement central facade. [Session history and event transport](2026-08-18-session-history-and-event-transport.md) defines history continuity. [Client Session references](2026-09-15-client-session-references.md) owns reference acquisition, exact-generation lifetimes, main-area ownership, and unified UI status; this note owns the layering and source-registration rules.
 
 ## Layering principles
 
@@ -55,9 +55,10 @@ Each standard hook belongs to the `ui-*` package closest to its data semantics.
 | Hook | Owner | Source |
 | --- | --- | --- |
 | `useSessions` | `client/ui-session` | Session Controller global list |
-| `useSession` | `client/ui-session` | Current Session snapshot |
-| `useProjection` | `client/ui-session` | Current Session keyed projection |
-| `useSessionPendingInteraction` | `client/ui-session` | Aggregated pending domains |
+| `useSession` | `client/ui-session` | Bound Session snapshot |
+| `useProjection` | `client/ui-session` | Bound Session keyed projection |
+| `useSessionStatus` | `client/ui-session` | Running, effective pending request, and unread completion |
+| `useSessionRetainInfo` | `client/ui-session` | Read-only Controller reference-source counts |
 | `useWorkspaces` | `client/ui-workspace` | Workspace Controller list |
 | `useConversation` | `client/ui-conversation` | Conversation binding snapshot |
 | `useChat` | `client/ui-chat` | `chat` target source |
@@ -77,10 +78,10 @@ Adding a target does not add a branch to the renderer or Session Controller. The
 
 | Package | Owns | Explicitly does not own |
 | --- | --- | --- |
-| `api/session-controller/client` | Session objects, list, selection, commands, projections, queue, event windows, and Agent Contexts | Conversation targets, React, Slots, Workspace |
+| `api/session-controller/client` | Session objects, catalog, references, source counts, commands, projections, event windows, and Agent Contexts | Navigation, completion reminders, Conversation targets, React, Slots, Workspace |
 | `api/workspace-controller/client` | Workspace objects, ordering, archive state, commands, and snapshots | React, Session navigation policy, directory UI |
-| `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results |
-| `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data |
+| `client/ui-session` | Explicit Session scope, standard sources, `SessionProvider`, and unified UI status | Session transport, reference ownership, Conversation assembly, Approval/Question results |
+| `client/ui-workspace` | Workspace hook, browser UI, main-area reference, and cross-Controller navigation policy | Workspace transport, copies of Session data |
 | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots |
 | `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache |
 | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots |
@@ -117,9 +118,9 @@ Session data reaches the UI through this path:
        useChat            useTrajectory
 ```
 
-Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. For cross-domain navigation, `ui-workspace` temporarily reads the Session Controller and issues a selection or command.
+Workspace data enters the Workspace Controller from `ctx.remote.workspace`, then `ui-workspace` exposes it as `useWorkspaces`. `ui-workspace` reads explicit targets for cross-domain navigation and owns the main-area reference without making it a default business Context.
 
-Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.pendingInteractions` then supplies that same object to Session navigation state and Conversation composer selection.
+Approval and Question arrive from the Host waterfall through `ctx.remote.$on` at their respective UI owners. Each owner publishes a Pending object; `ui-session.sessionStatus` supplies the same effective object to Workspace indicators and Conversation composer selection.
 
 ## Session Controller Client
 
@@ -142,7 +143,7 @@ Whether a field derives from an event, control frame, or local command does not
 
 The Session Controller exposes three distinct read faces:
 
-1. The global Session list and current-selection source, used by navigation and `useSessions`.
+1. The Session catalog and local ownership sources, used by `useSessions` and read-only reference metadata consumers.
 2. A logical binding for each Session containing `sessionId`, a `SessionSnapshot` source, commands, and projection sources.
 3. A Conversation-facing `SessionEventSource` used only by the Conversation assembly core.
 
@@ -160,9 +161,9 @@ Initial open, reconnect, gap repair, and updates whose continuity cannot be prov
 
 ### Session binding lifecycle
 
-Each Session binding owns a Cordis Context and Fiber. The Session Controller creates and releases the binding.
+Each live Session generation owns a Cordis Context and Fiber. The Controller creates its binding on acquisition and retires it on final reference release or root disposal.
 
-Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Releasing a binding cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol.
+Objects that depend on a Session register cleanup through `binding.ctx.effect()`. Generation retirement cleans up Conversation bindings, UI materializations, and scoped Slot stores without a dedicated `onBindingRelease` or `onRelease` callback protocol.
 
 This cleanup does not require the Session Controller to know the roster of upper-layer consumers.
 
@@ -172,12 +173,12 @@ This cleanup does not require the Session Controller to know the roster of upper
 
 `client/ui-session` is the sole Session adapter between the Session Controller and the React/Slot system. It provides `ctx.uiSession` and:
 
-- observes the Session list, current selection, and per-Session bindings;
+- observes the Session catalog, local reference metadata, and explicitly supplied bindings;
 - installs the session and session-maybe scope adapters;
 - supplies `SessionProvider` rendering semantics;
 - supplies built-in Session snapshot, projection, and sessionId sources;
 - accepts Session-scoped source contributions from other domain packages;
-- aggregates pending interactions registered by business packages.
+- aggregates domain-owned pending interactions with running and completion-reminder facts in `sessionStatus`.
 
 It does not own Session transport, event folding, Conversation targets, or concrete business results.
 
@@ -193,12 +194,12 @@ The runtime rejects undeclared, missing, or duplicate standard props. `ui-sessio
 
 session and session-maybe use the same adapter with different binding semantics:
 
-- a strict session scope refuses to render without a current binding;
+- a strict session scope refuses to render without an explicitly supplied binding;
 - session-maybe uses a stable absent binding to preserve hook call order;
-- changing the current Session rebuilds the strict Session subtree under the `sessionId` key;
-- root and session-maybe entries may remain mounted across Session changes.
+- changing the exact Context generation remounts a bound subtree, including same-id replacement;
+- an unbound session-maybe entry adopts its first binding without remounting; root entries have no Session binding.
 
-Each real materialized binding retains the Controller binding's Context. `ui-session` removes the cache entry and withdraws the current binding through `binding.ctx.effect()`.
+Each UI materialization borrows the Controller binding's Context. `ui-session` removes that generation's cache entry and publishes absence through `binding.ctx.effect()`; it does not retain the Session.
 
 Changing the contribution roster rematerializes existing bindings and publishes a new source set. Source identity remains stable within one binding lifetime, as required by `useSyncExternalStore` caching.
 
@@ -206,9 +207,9 @@ Changing the contribution roster rematerializes existing bindings and publishes
 
 `SessionProvider` is a standard seat derived by `PropsRenderSlots` from a session-scoped child declaration, not a React Context imported directly by business components.
 
-It accepts ordinary `ReactNode` children rather than a `(sessionId) => ReactNode` render function; callers wrap `renderSlot('details', {})` directly.
+It accepts ordinary `ReactNode` children and a required `session={reference | undefined}`. The Provider borrows the caller-owned reference without acquiring or releasing it; callers wrap `renderSlot('details', {})` directly.
 
-Session identity comes from the scope binding and standard `sessionId` prop. The Provider handles only the absent branch and subtree isolation by Session identity; components do not obtain Session data through a Provider callback.
+Session identity reaches components through the explicit scope binding and standard `sessionId` prop. An absent Provider stays unbound, and neither nested Providers nor root entries fall back to a main-area Session.
 
 ### Pending interactions
 
@@ -220,7 +221,7 @@ Concurrent objects with the same key are rejected; replacement requests use a ne
 
 `ui-session` selects each Session's effective object using domain precedence. Higher precedence wins; at equal precedence, the later valid object in traversal order wins.
 
-The aggregate is published as `pendingInteractions: ObservableSnapshot<ReadonlyMap<SessionId, SessionPendingInteraction>>`; `useSessionPendingInteraction` is its React read face.
+The pending aggregate is private to `ui-session`; its effective request appears unchanged as `sessionStatus.getSnapshot().get(id)?.pendingInteraction`. `useSessionStatus` is the public UI read face.
 
 Session navigation state and composer takeover read the same effective object. They do not maintain separate status maps or takeover rosters.
 
@@ -242,7 +243,7 @@ These combined facts do not enter `WorkspaceSnapshot`:
 
 `client/ui-workspace` registers the Workspace list source as the root standard source `workspaces`, from which the renderer provides `useWorkspaces`.
 
-Initial selection, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI navigation policy. That policy may read both `ctx.workspaces` and `ctx.sessions` at decision time, but it issues only Controller commands and selection actions and does not publish a combined snapshot.
+Initial restoration, blank-Session reuse, new-session navigation, concurrent-create coalescing, and navigation after archival are UI policy. `ui-workspace` may read both Controllers, but it keeps the main target and reference in its own navigation owner instead of writing UI selection into a Controller snapshot.
 
 Directory pickers, directory browsing, and `openPath` are separate directory capabilities and do not enter the Workspace Controller.
 
@@ -286,7 +287,7 @@ The shell phase is a pure composition of Session lifecycle and Conversation targ
 
 ### Input and composer
 
-The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads the current Session's effective object through `useSessionPendingInteraction` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`.
+The composer chain belongs to `ui-conversation`; a concrete takeover belongs to its business package. `ConversationRoot` reads its bound Session's effective request through `useSessionStatus` and supplies it to chain selectors as `ComposerChainProps.pendingInteraction`.
 
 A selector is a pure function of owner currency. Its non-null result reaches the selected component as `matched`. A stable composer entry and the default composer remain mounted together, while the chain selects one effective presentation.
 
@@ -334,7 +335,7 @@ The Gateway requires only that Remote Event arguments and results are valid JSON
 
 ### One pending projection
 
-The Sidebar and composer consume the same `pendingInteractions` snapshot. Navigation displays approval, plan-review, or question state from the effective object's `kind`; each composer entry selects its own panel by object identity.
+The Sidebar and composer consume the same effective `sessionStatus` pending request. Navigation displays approval, plan-review, or question state from its `kind`; each composer entry selects its own panel by object identity.
 
 The same request identity drives both UI surfaces. A request that replaces another request of the same type uses a new key, so selectors and subscribers observe the identity change.
 
@@ -366,7 +367,7 @@ Stores hold viewing and interaction state such as drafts, View selection, Chat s
 
 When one plugin provides both a source and a Slot entry, it registers the source first and the entry second. Reverse Cordis disposal then removes the entry before the source, so a mounted entry never briefly loses a required hook.
 
-Releasing a Session binding cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers.
+Final Session-reference release cleans up UI materialization and scoped Stores through `binding.ctx.effect()`. Releasing a plugin fiber cleans up sources, listeners, and Slot entries through registration disposers.
 
 Every disposer is idempotent and depends on no implicit callback outside the Cordis lifecycle.
 
@@ -386,7 +387,7 @@ UI components do not receive `ctx`. Cross-package collaboration uses Cordis serv
 
 Before adding state, choose its sole owner from its consumption semantics: Host communication, commands, and entity lifecycle belong to an API Controller; data assembled from Session events but independent of a target belongs to the Conversation core; projections serving only one View belong to that target package; drafts, selections, and panel state belong to the UI package that owns the interaction.
 
-The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. A cross-domain decision reads multiple sources and immediately issues a command; it does not create a joined snapshot or cache another domain's object.
+The same fact must not be retained simultaneously in a Controller snapshot, Conversation snapshot, and Store. Cross-domain navigation reads sources at decision time. A UI-owned status source may compose independent running, pending-request, and completion-reminder facts, but must preserve domain ownership and object identity rather than duplicate those domains' state.
 
 These are signs of incorrect ownership: a Controller imports React; the renderer branches on business types; a component traverses Session events; a Store holds Session or Workspace entities; changing one target requires changing the Session Controller.
 
@@ -421,7 +422,7 @@ A target must not use another target's snapshot as its data source. Optional col
 5. When it can handle the request, create the Pending object, publish it through the publication function, await its result, and remove it in `finally`.
 6. Test concurrent keys, precedence, user cancellation, transport abort, plugin disposal, and delegation without a Session.
 
-A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer both read one effective object from `useSessionPendingInteraction`.
+A request does not register Slots, declare child Slots, mutate the Session snapshot, or create a separate state index. Sidebar and composer read the same effective object from `useSessionStatus`.
 
 ### Review checks
 

+ 28 - 27
.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md

@@ -38,7 +38,7 @@ Session 与 Workspace 的 Client 对象分别归 `api/session-controller/client`
 
 Session 与 Workspace 的 React 适配分别归 `client/ui-session` 和 `client/ui-workspace`。Store engine 归 `client/store`,Slot registry、scope materialization 和 observable-to-hook 绑定归 `client/ui-renderer`。
 
-系统不提供聚合式 `client/runtime` package,也不设置替代它的总控 facade。Session history、Remote stream、分页 cursor 和重连连续性由 [Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md) 定义;本 Note 从 Controller 发布的 Client 对象与 source 开始。
+系统没有聚合式 `client/runtime` 包,也没有替代的中央 facade。[Session 历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md)定义历史连续性。[Client 会话引用](2026-09-15-client-session-references.zh.md)拥有引用获取、精确代际生命周期、主区域所有权和统一 UI 状态;本篇拥有分层与数据源注册规则。
 
 ## 分层原则
 
@@ -55,9 +55,10 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
 | Hook | Owner | Source |
 | --- | --- | --- |
 | `useSessions` | `client/ui-session` | Session Controller 全局列表 |
-| `useSession` | `client/ui-session` | 当前 Session snapshot |
-| `useProjection` | `client/ui-session` | 当前 Session keyed projection |
-| `useSessionPendingInteraction` | `client/ui-session` | pending domain 聚合结果 |
+| `useSession` | `client/ui-session` | 已绑定会话快照 |
+| `useProjection` | `client/ui-session` | 已绑定会话的键控投影 |
+| `useSessionStatus` | `client/ui-session` | 运行状态、有效待处理请求和未读完成提醒 |
+| `useSessionRetainInfo` | `client/ui-session` | 控制器的只读引用来源计数 |
 | `useWorkspaces` | `client/ui-workspace` | Workspace Controller 列表 |
 | `useConversation` | `client/ui-conversation` | Conversation binding snapshot |
 | `useChat` | `client/ui-chat` | `chat` target source |
@@ -77,10 +78,10 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把
 
 | Package | 拥有内容 | 明确不拥有 |
 | --- | --- | --- |
-| `api/session-controller/client` | Session 对象、列表、选择、命令、projection、queue、事件窗口和 Agent Context | Conversation target、React、Slot、Workspace |
+| `api/session-controller/client` | 会话对象、目录、引用、来源计数、命令、投影、事件窗口与 Agent Context | 导航、完成提醒、Conversation target、React、Slot、Workspace |
 | `api/workspace-controller/client` | Workspace 对象、顺序、归档、命令和 snapshot | React、Session 导航策略、目录 UI |
-| `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 |
-| `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 |
+| `client/ui-session` | 显式会话作用域、标准数据源、`SessionProvider` 与统一 UI 状态 | 会话传输、引用所有权、Conversation 组装、Approval/Question 结果 |
+| `client/ui-workspace` | Workspace 钩子、浏览器 UI、主区域引用与跨控制器导航策略 | Workspace 传输、会话数据副本 |
 | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot |
 | `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache |
 | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot |
@@ -117,9 +118,9 @@ Session 数据按以下路径进入 UI:
        useChat            useTrajectory
 ```
 
-Workspace 数据从 `ctx.remote.workspace` 进入 Workspace Controller,再由 `ui-workspace` 暴露为 `useWorkspaces`;需要跨域导航时,`ui-workspace` 临时读取 Session Controller 并发出选择或命令。
+Workspace 数据由 `ctx.remote.workspace` 进入 Workspace 控制器,再由 `ui-workspace` 通过 `useWorkspaces` 提供。`ui-workspace` 为跨领域导航读取显式目标并持有主区域引用,不把它变为默认业务 Context。
 
-Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI owner。Owner 发布 Pending 对象,`ui-session.pendingInteractions` 再把同一对象送往 Session 导航状态和 Conversation composer selection。
+Approval 与 Question 通过 `ctx.remote.$on` 从 Host waterfall 到达各自的 UI owner。各 owner 发布 Pending 对象;`ui-session.sessionStatus` 向 Workspace 标识和 Conversation composer 选择提供同一个有效对象。
 
 ## Session Controller Client
 
@@ -142,7 +143,7 @@ Approval 与 Question 从 Host waterfall 经 `ctx.remote.$on` 到达各自 UI ow
 
 Session Controller 对外提供三个互不替代的读取面:
 
-1. 全局 Session list 与 current selection source,供导航和 `useSessions` 使用。
+1. 会话目录与本地所有权数据源,由 `useSessions` 和只读引用元数据消费方使用。
 2. 每个 Session 的逻辑 binding,包含 `sessionId`、`SessionSnapshot` source、commands 与 projection sources。
 3. Conversation-facing `SessionEventSource`,只供 Conversation assemble core 使用。
 
@@ -160,9 +161,9 @@ Session Controller 对外提供三个互不替代的读取面:
 
 ### Session binding 生命周期
 
-每个 Session binding 持有自己的 Cordis Context 与 Fiber。Session Controller 创建 binding,也负责释放它。
+每个活跃会话 generation 持有 Cordis Context 与 Fiber。控制器在获取引用时创建绑定,并在最后一份引用释放或根销毁时结束该绑定。
 
-依赖 Session 的对象把清理注册到 `binding.ctx.effect()`。Binding 释放会触发 Conversation binding、UI materialization 和 scoped Slot store 的清理,不存在额外的 `onBindingRelease` 或 `onRelease` 回调协议。
+依赖会话的对象通过 `binding.ctx.effect()` 注册清理。generation 结束会清理 Conversation 绑定、UI 物化结果和作用域 Slot 存储,无需单独的 `onBindingRelease` 或 `onRelease` 回调协议。
 
 这种清理方式不要求 Session Controller 了解上层消费者名册。
 
@@ -172,12 +173,12 @@ Session Controller 对外提供三个互不替代的读取面:
 
 `client/ui-session` 是 Session Controller 与 React/Slot 系统之间唯一的 Session adapter。它提供 `ctx.uiSession`,并负责:
 
-- 观察 Session list、current selection 和 per-Session binding;
+- 观察会话目录、本地引用元数据和显式提供的绑定;
 - 安装 session 与 session-maybe scope adapter;
 - 提供 `SessionProvider` 的呈现语义;
 - 内建 session snapshot、projection 和 sessionId source;
 - 接收其他领域 package 的 Session-scoped source contribution;
-- 聚合业务 package 注册的 pending interaction。
+- 在 `sessionStatus` 中组合领域持有的待处理交互、运行事实和完成提醒。
 
 它不拥有 Session transport、event folding、Conversation target 或具体业务结果。
 
@@ -193,12 +194,12 @@ Session Controller 对外提供三个互不替代的读取面:
 
 session 与 session-maybe 使用同一个 adapter,但绑定语义不同:
 
-- strict session scope 在没有 current binding 时拒绝渲染;
+- 严格会话作用域在没有显式提供的绑定时拒绝渲染;
 - session-maybe 使用稳定 absent binding,保持 hook 调用顺序;
-- current Session 切换以 `sessionId` 为 key 重建严格 Session subtree;
-- root 与 session-maybe entry 可以跨 Session 切换常驻。
+- 精确 Context generation 改变时重新挂载已绑定子树,同一 id 的替代 generation 也如此;
+- 未绑定的 session-maybe 条目无需重新挂载即可接纳首个绑定;root 条目没有会话绑定。
 
-每个真实 materialized binding 保留 Controller binding 的 Context。`ui-session` 通过 `binding.ctx.effect()` 删除缓存项并撤销 current binding。
+每个 UI 物化结果借用控制器绑定的 Context。`ui-session` 通过 `binding.ctx.effect()` 移除该 generation 的缓存项并发布空值,不会 retain 会话。
 
 Contribution roster 变化会重建已 materialize 的 binding 并发布新的 source 集合。同一 binding 生命周期内,source identity 保持稳定,以满足 `useSyncExternalStore` 的缓存要求。
 
@@ -206,9 +207,9 @@ Contribution roster 变化会重建已 materialize 的 binding 并发布新的 s
 
 `SessionProvider` 是 `PropsRenderSlots` 根据 session-scoped child 声明派生的标准席,不是业务 component 直接 import 的 React Context。
 
-它接收普通 `ReactNode` children,不接收 `(sessionId) => ReactNode` render function;调用方直接用它包裹 `renderSlot('details', {})`。
+它接收普通 `ReactNode` children 和必填的 `session={reference | undefined}`。Provider 借用调用方持有的引用,不获取或释放它;调用方直接包住 `renderSlot('details', {})`。
 
-Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provider 只负责 absent branch 与按 Session identity 隔离 subtree,组件不得借助 Provider 回调取得 Session 数据。
+会话身份通过显式作用域绑定和标准 `sessionId` prop 到达组件。空 Provider 保持未绑定,嵌套 Provider 和 root 条目都不会回退到主区域会话。
 
 ### Pending interaction
 
@@ -220,7 +221,7 @@ Session identity 通过 scope binding 和标准 `sessionId` prop 提供。Provid
 
 `ui-session` 使用各 domain 的 precedence 选出每个 Session 当前生效的对象。较高 precedence 胜出,相同 precedence 下后遍历到的有效对象胜出。
 
-聚合结果发布为 `pendingInteractions: ObservableSnapshot<ReadonlyMap<SessionId, SessionPendingInteraction>>`,`useSessionPendingInteraction` 是其 React 读取面。
+待处理聚合是 `ui-session` 的私有实现;其有效请求原样出现在 `sessionStatus.getSnapshot().get(id)?.pendingInteraction` 中。`useSessionStatus` 是公开 UI 读取接口。
 
 Session 导航状态和 composer takeover 必须读取同一个 effective object,不得分别维护 status map 或 takeover roster。
 
@@ -242,7 +243,7 @@ Session 导航状态和 composer takeover 必须读取同一个 effective object
 
 `client/ui-workspace` 把 Workspace list source 注册为 root 标准 source `workspaces`,renderer 由此提供 `useWorkspaces`。
 
-初始选择、blank Session 复用、新建导航、并发创建合并和归档后导航属于 UI navigation policy。该 policy 可以在决定时同时读取 `ctx.workspaces` 与 `ctx.sessions`,但只调用 Controller command 和 selection action,不发布联合 snapshot。
+启动恢复、空白会话复用、新会话导航、并发创建合并与归档后的导航属于 UI 策略。`ui-workspace` 可以读取两个控制器,但把主目标和引用保存在自己的导航 owner 中,不向控制器快照写入 UI 选择。
 
 目录 picker、目录浏览和 `openPath` 属于独立目录能力,不进入 Workspace Controller。
 
@@ -286,7 +287,7 @@ Shell phase 由 Session lifecycle 与 Conversation target activity 纯合成。S
 
 ### Input 与 composer
 
-Composer chain 属于 `ui-conversation`,具体 takeover 属于业务 package。`ConversationRoot` 从 `useSessionPendingInteraction` 读取当前 Session 的 effective object,并作为 `ComposerChainProps.pendingInteraction` 交给 chain selector。
+composer chain 属于 `ui-conversation`,具体接管属于业务包。`ConversationRoot` 通过 `useSessionStatus` 读取已绑定会话的有效请求,并作为 `ComposerChainProps.pendingInteraction` 提供给 chain selector。
 
 Selector 是 owner currency 的纯函数,非 null 结果作为 `matched` 传给获选 component。Stable composer entry 与默认 composer 可以同时常驻,chain 只选择一个有效呈现。
 
@@ -334,7 +335,7 @@ Gateway 只要求 Remote Event 参数和结果是合法 JSON 传输值,不复
 
 ### 单一 pending 投影
 
-Sidebar 与 composer 使用相同 `pendingInteractions` snapshot。导航根据 effective object 的 `kind` 显示审批、计划审阅或问题状态,composer entry 根据对象实例选择自己的面板。
+Sidebar 与 composer 消费 `sessionStatus` 中同一个有效待处理请求。导航根据其 `kind` 显示审批、计划审阅或问题状态;每个 composer 条目按对象身份选择自己的面板。
 
 同一请求 identity 同时驱动两处 UI。新请求替换同类型旧请求时使用新 key,因此 selector 与订阅者都观察到身份变化。
 
@@ -366,7 +367,7 @@ Store 只承载 draft、View selection、Chat selection、inspection request 和
 
 一个 plugin 同时提供 source 与 Slot entry 时,先注册 source,再注册 entry。Cordis 反向 disposal 先移除 entry,再移除 source,仍挂载的 entry 因而不会短暂失去必需 hook。
 
-Session binding 释放通过 `binding.ctx.effect()` 清理 UI materialization 与 scoped store。Plugin fiber 释放通过 registration disposer 清理 source、listener 和 Slot entry。
+最后一份会话引用释放后,通过 `binding.ctx.effect()` 清理 UI 物化结果和作用域存储。插件 fiber 释放通过注册 disposer 清理数据源、监听器和 Slot 条目。
 
 所有 disposer 都可重复调用,不依赖 Cordis 生命周期以外的隐式回调。
 
@@ -386,7 +387,7 @@ UI component 不接收 `ctx`。跨 package 协作使用 Cordis service、standar
 
 新增状态前先按消费语义确定唯一 owner:Host 通信、命令和实体生命周期归 API Controller;由 Session events 形成且与 target 无关的数据归 Conversation core;只服务一种 View 的投影归对应 target package;草稿、选择和面板状态归拥有该交互的 UI package。
 
-同一事实不得同时保存在 Controller snapshot、Conversation snapshot 和 Store。需要跨域决策时读取多个 source 并立即发出 command,不创建联合 snapshot,也不缓存另一领域的对象副本。
+同一个事实不能同时保存在控制器快照、Conversation 快照和存储中。跨领域导航在决策时读取数据源。UI 持有的状态数据源可以组合独立的运行、待处理请求和完成提醒事实,但必须保留领域归属与对象身份,不能复制这些领域的状态。
 
 以下信号表示 owner 选择错误:Controller 开始 import React;renderer 出现业务类型分支;组件遍历 Session events;Store 保存 Session 或 Workspace 实体;一个 target 的变化要求修改 Session Controller。
 
@@ -421,7 +422,7 @@ Target 不得读取另一个 target 的 snapshot 作为自己的数据源。可
 5. 可处理时创建 Pending 对象,使用 publication function 发布,等待结果,并在 `finally` 中移除。
 6. 测试并发 key、precedence、用户取消、transport abort、plugin disposal 和无 Session delegation。
 
-单次请求不得注册 Slot、声明 child Slot、修改 Session snapshot 或另建状态索引。Sidebar 与 composer 都从 `useSessionPendingInteraction` 读取同一个 effective object。
+请求不注册 Slot、不声明子 Slot、不修改会话快照,也不创建独立状态索引。Sidebar 与 composer 从 `useSessionStatus` 读取同一个有效对象。
 
 ### Review 检查点
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
-2026-09-08-global-main-panels.md: 6aa7df2dcdd28b94cc0084a0044764efb2e749e2
-2026-09-08-global-main-panels.zh.md: bbb10b2ef5784748a165ad54c907fac2a63c9b17
+2026-09-08-global-main-panels.md: 63530a6b498bd8099cf627d82c888846d3d96767
+2026-09-08-global-main-panels.zh.md: 776787c3de3b79ca7948dcf949ac0393f87e7808

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md

@@ -10,13 +10,13 @@ Plugins need application-wide views that do not belong to a Session. A Session-s
 
 ## Decision
 
-The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
+The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to Conversation. `ui-session` derives the root Session binding from `uiWorkspace`'s `mainView` ownership marker; `main.conversation` and its associated right Sidebar inherit that Provider binding. Other main entries receive no implicit Session binding. [Client Session references](2026-09-15-client-session-references.md) owns acquisition and source metadata; this note owns global panel selection.
 
 The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registered no panel entry when this landed, so an empty list has no DOM or spacing; the web bundle's plugin manager now registers the first one ([plugin management moves to the Web sidebar](2026-09-09-plugin-management-in-the-web-sidebar.md)). Selection validates the live main entry and rejects a missing key without replacing the current panel.
 
 One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
 
-`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event.
+`uiWorkspace.openSession(target)` acquires the explicit target before replacing its main reference and returning the main area to Conversation. `openWorkspace` and `forkSession` keep the layout's existing `beginNavigation()` signal and service lifetime. `openWorkspace` runs its existing synchronous preparation after acquisition and before replacing the main reference. Supersession prevents a late UI commit, not Session creation. Direct Session opening adds no global navigation cancellation. Panel navigation neither releases the retained main Session nor writes a Session event.
 
 DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md

@@ -10,13 +10,13 @@ Status: implemented
 
 ## 决策
 
-布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
+布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation。`ui-session` 根据 `uiWorkspace` 的 `mainView` 所有权标记得出根 Session binding;`main.conversation` 及其关联右 Sidebar 继承该 Provider binding。其他主面板条目不获得隐式会话绑定。[Client 会话引用](2026-09-15-client-session-references.zh.md)拥有引用获取与来源元数据;本篇拥有全局面板选择。
 
 侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。本决定落地时默认组合不注册面板条目,因此空列表没有 DOM 或间距;现在 web bundle 的插件管理器注册了第一个条目([插件管理移到 Web 侧栏](2026-09-09-plugin-management-in-the-web-sidebar.zh.md))。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
 
 渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
 
-`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。
+`uiWorkspace.openSession(target)` 先获取显式目标,再替换主引用并让中央区域返回 Conversation。`openWorkspace` 和 `forkSession` 保留布局既有的 `beginNavigation()` 信号与服务生命周期。`openWorkspace` 在获取完成后、替换主引用前执行既有的同步准备动作。请求被替代会阻止迟到的 UI 提交,不阻止会话创建。直接打开会话不增加全局导航取消。面板导航既不释放所持主会话,也不写入会话事件。
 
 DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。
 

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-client-session-references.md
+2026-09-15-client-session-references.md: 2d727809b237ca919296dac82a3b201ba2dd8fbf
+2026-09-15-client-session-references.zh.md: 34fa6526a57f43fd7636c68c7ed72bb982a129f2

+ 226 - 0
.agents/notes/implemented/architecture/2026-09-15-client-session-references.md

@@ -0,0 +1,226 @@
+# Agent Note: Client Session references, reference sources, and UI status
+
+Status: implemented
+
+English | [中文](2026-09-15-client-session-references.zh.md)
+
+## Problem
+
+The Session catalog, live Client objects, views, and asynchronous operations have different lifetimes. Catalog membership does not establish ongoing use. A borrowed binding cannot protect asynchronous work or distinguish successive Client generations with the same Session id. A global current Session makes independently bound components act on another view's Session.
+
+A reference count identifies ongoing use but not its consumers. Main-area highlighting, Sidebar ownership, and background operations need consumer-source information. Pending interactions and completion reminders also need one UI read interface so Workspace and Conversation do not independently combine the same status.
+
+Client history access and Host Agent execution have independent lifetimes. History opening can fail; local Context acquisition need not read history. Explicit ownership must preserve navigation, loading, error handling, and recovery behavior without adding unrelated UI policy.
+
+## Decision
+
+### Scope and ownership
+
+Client Session objects, Agent-scoped Client Contexts, references, consumer-source metadata, UI status, and explicit Provider composition use the ownership rules below. Host Session and Agent lifetimes, SlotFactory, activity dashboards, durable Session formats, and both SDKs' Host protocols remain independent.
+
+| Owner | Responsibility |
+| --- | --- |
+| Client Session Controller | Catalog, live generations, references, source counts, bindings, history windows, and existing Session control state |
+| `ui-session` | Explicit Session Provider integration and the unified UI status source |
+| View or operation | Its own reference, target, source identifier, and release point |
+| Workspace UI | Main-area target and reference, navigation, persisted target, and creation flow |
+| UI composition boundary | Supplies an owner-provided reference to one explicit `SessionProvider` |
+| Conversation and Sidebar subtrees | Consume only their Provider's Session, never a main-area reference or global selection |
+| Client Gateway | Invocation-lifetime Context ownership while handling a Host event |
+
+The [Client layering design](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.md) defines one-way data, adapter, renderer, and presentation dependencies. Reference-source bookkeeping does not give the Controller a dependency on UI packages.
+
+This decision partially supersedes the list-selected scope lifecycle in the [Web Client Session scope and provide-channel decision](2026-07-25-web-client-session-scope-and-provide-channel.md); that note retains the blank-Session and adoption rationale under explicit Provider ownership.
+
+### Addresses, bindings, and references
+
+`SessionTarget` is a known `SessionId` or a durable direct-parent `SubagentAddress`. It identifies what to acquire; it owns nothing. The Controller resolves an explicit address without requiring a preloaded parent catalog, while the Host validates its parent, child, and mode when history opens. Child discovery and an already known child address remain distinct from retaining the child. Navigation uses the same target representation rather than adding a separate navigation address.
+
+`SessionBinding` is the shared Client generation: `sessionId`, Session face, event source, and scoped Context. Multiple references to the same live generation share this binding. A new generation with the same id has a different binding and Context.
+
+`SessionReference` owns one use of one exact generation. It exposes read-only `sessionId` and `binding`, a `ready` Promise for the shared initial history opening, plus idempotent `release()` and `Symbol.dispose`. `ready` resolves to the exact binding when the corresponding `Session.open()` attempt resolves, including its stateful Remote-failure result. Reading `binding` after release or generation disposal fails. Retaining, releasing, or inspecting a reference does not create durable Sessions or start, stop, or retain a Host Agent.
+
+`SessionBinding` is a borrowed value and owns no lifetime. Only unreleased `SessionReference` objects contribute to source and total reference counts. Synchronous code may borrow a binding within its owner's reference lifetime; work that outlives that owner must acquire its own reference.
+
+| API | Result and caller obligation |
+| --- | --- |
+| `sessions.retain(target, options)` | `SessionReference`; returns immediately, and the caller owns the reference until release |
+| `sessions.using<T>(target, options, operation)` | `Promise<T>`; awaits `reference.ready`, awaits `operation(reference)`, releases its reference, and returns the operation's result |
+| `sessions.retainInfo(id)` | Stable read-only observable of local reference counts; no acquisition, scope creation, or history I/O |
+| `sessions.scope(id)` / `sessions.binding(id)` | Borrow an existing live generation or return `undefined`; neither opens nor extends its lifetime |
+| `sessions.sessionOf(ctx)` | Returns the matching live Session face or `undefined`; an ended Context cannot resolve to a same-id replacement |
+| `sessions.create(...)` / `sessions.fork(...)` | Existing Host operations returning a Session identity; retaining and displaying it remain explicit |
+
+`SessionRetainOptions` contains required `source: SessionReferenceSource` and optional `signal: AbortSignal`. Both acquisition methods accept `target: SessionTarget` and these options. Source keys are consumer-defined and declaration-merge extensible; there is no runtime source-registration protocol or default main-view source. A source is a usage label, not another Session address or a permission to act on it.
+
+### Acquisition, failure, and release
+
+Acquisition synchronously resolves the target, creates or retains the local Session, Context, fiber, and binding, records one reference and its source contribution, starts the generation's shared initial history opening, and returns the reference. Concurrent acquisitions share that opening but receive independent references and readiness waits. `reference.ready` resolves to the still-live exact binding when the shared `Session.open()` attempt resolves.
+
+Unknown Session-id addressing fails before a reference is returned; an explicit subagent address is validated by the Host during opening. A thrown opening failure, caller cancellation, reference release, or generation retirement rejects that reference's `ready`; one cancelled waiter does not cancel another owner's shared opening. A Remote failure represented by `openState: 'error'` follows `Session.open()` and resolves readiness, leaving the error renderable through the binding. The caller still owns a returned reference until release, while `sessions.using` releases its reference when readiness or its operation fails.
+
+`using reference = sessions.retain(target, options)` releases on exit from the enclosing scope; code that requires settlement of the initial history attempt awaits `reference.ready`. `sessions.using` is the callback helper: it waits for that settlement before invoking an operation, waits for its returned value or Promise, and then releases. Returned values must not rely on the helper's released reference remaining usable; a longer-lived consumer acquires its own reference. The helper propagates rejected readiness and operation failures without a fallback result, retry, or error presentation. Model-selection generation checks and error-state updates remain in ModelSelection.
+
+A fulfilled `ready` Promise means the initial `Session.open()` attempt settled; it does not assert `openState: 'open'` or promise an uninterrupted connection. Stateful opening failures and later stream failures remain observable through existing Session state. Callers retain their existing handling and presentation of those errors.
+
+Release removes only that reference and its source contribution. Final release withdraws the generation's admission and id mappings before disposing the Session and fiber. A later retain can create a fresh generation immediately; cleanup of the old generation cannot remove the replacement. `release()` initiates local cleanup synchronously, while root disposal awaits outstanding asynchronous teardown.
+
+The owning Client root invalidates all references during shutdown. It refuses new acquisitions, withdraws live mappings, and joins scoped cleanup and Session stream teardown. References cannot keep a disposed Client root alive. Catalog removal alone does not dispose a referenced generation.
+
+### Reference sources and Session list records
+
+The Controller owns source counts alongside each live generation's references. For every source, the count equals that generation's unreleased references bearing the source. A Session can have several sources, and a source can hold several references. Sources have no special lifecycle behavior in the allocator.
+
+Each available Session list row exposes a read-only `retainedBy` record from source key to positive reference count. An unretained row has an empty record; zero-count keys are absent. Acquisition failure and release update that projection, including removal of the final key. The source counts remain local facts when Host metadata is refreshed; Host responses cannot overwrite them.
+
+The generation owns the counts even if its Session has not reached the Host catalog or its catalog metadata has been removed. `byId` synthesizes a local fallback row for every live generation so Provider and ownership consumers can resolve it; `ids` remains the Host-list membership and ordering. A fallback row is not Host metadata. Every row's `retainedBy` projection uses the live generation's counts. Neither the reference objects nor the counts are persisted or sent to the Host.
+
+For example, `retainedBy = { mainView: 1, gateway: 2 }` means that the main view and two Host invocations own three references. Releasing the main-view reference removes only `mainView`; the Gateway invocations continue to own the generation. Source names in this example are consumer keys, not a closed Controller enum.
+
+`current` means main-view occupancy: a row has that marker exactly when its `mainView` source count is positive. It is one use of the general source record, not a separate `sessions.current` value or Session-selection service. Other consumers can derive their own markers from their source keys. The reference system does not impose a globally unique consumer or select one Session from multiple sources.
+
+The main-area owner manages its own target and reference transitions. The list does not infer selection from how many Providers happen to be mounted. Source records report actual ownership, including temporary overlap during acquisition; UI navigation remains responsible for its target, without assigning selection authority to the allocator.
+
+Window-level consumers may inspect source markers. Session business operations still use their supplied scope or explicit reference; they cannot find `mainView` in the catalog to recover a missing operation target. A background reference is ownership evidence, not evidence that a user viewed the Session.
+
+`SessionRetainInfo` contains `referenceCount` and the read-only `retainedBy` record. `sessions.retainInfo(id)` observes that local ownership independently of catalog membership and remains stable across same-id generation replacement. An identity without a live generation has zero references and an empty source record; reading this value does not assert that the identity exists on the Host.
+
+`ui-session` exposes `useSessionRetainInfo(sessionId, selector)` for an explicit identity and `useSessionRetainInfo(selector)` for the Session bound by the surrounding Provider. An unbound scope supplies absence to the latter form, never the main area's Session. The renderer constructs both forms from the same bare retain-info source. Consumers test the `mainView` source count to recognize the former current-Session role; other source keys remain equally queryable. Reading or subscribing does not retain the Session.
+
+### Unified UI Session status
+
+`ui-session` owns a React-free `sessionStatus` source and exposes it through the standard `useSessionStatus` hook. The snapshot is indexed by Session identity and supplies one UI status record per known Session. It combines these independent facts rather than reducing them to one mutually exclusive phase:
+
+| Field | Value | Meaning and owner |
+| --- | --- | --- |
+| `running` | `boolean` or `undefined` | Latest known Session running fact; absence of a baseline is not confirmed idle |
+| `pendingInteraction` | `SessionPendingInteraction` or `undefined` | Effective domain-owned request, or absence when no request is pending |
+| `completionUnread` | `boolean` | UI reminder for an observed stop that has not been acknowledged |
+
+Source counts remain authoritative in `SessionListState.byId[id].retainedBy`; UI status reads them for acknowledgement policy without owning another reference registry. Titles, Workspace associations, history, queues, and projection data remain with their existing owners.
+
+Pending domains retain `SessionPendingInteractionMap`, request identities, precedence, publication disposers, and teardown delegation. The unified status includes the same effective request object; it does not copy requests or create a second pending registry. Workspace status indicators and Conversation composer selection read `useSessionStatus` instead of separate `useSessionPendingInteraction` and `useCompletedSessionIds` hooks.
+
+Completion tracking subscribes to the existing `api-session/status` events so a running-to-idle transition is not lost in batched catalog snapshots. Catalog snapshots establish initial and reconnect baselines. A pending empty catalog is not evidence that Sessions disappeared. The update rules are:
+
+- An initial idle baseline does not create a completion reminder.
+- Observing running clears an earlier reminder and records the running baseline.
+- A known running-to-idle transition sets `completionUnread` only when the Session lacks main-view ownership.
+- Acquiring main-view ownership clears the reminder; retaining from an unrelated source does not.
+- Releasing main-view ownership does not manufacture a reminder for an earlier stop.
+- Removing a Session clears its completion reminder and running baseline; pending-request teardown stays with the request's domain.
+
+The reminder denotes an observed stop, not successful task completion or completion of a particular queued message. Main-view ownership preserves selection-based acknowledgement even while a global panel temporarily hides the Conversation. The design has no `ui-session/view-presence` event, mounted-Provider index, or implicit acknowledgement from Provider mount.
+
+The Session Controller publishes running and reference-source facts but owns no completion-reminder set, `consumeCompletion` method, or pending-interaction presentation. Reference acquisition does not execute completion-reminder business logic.
+
+### Explicit Providers and consumer lifetimes
+
+The single `SessionProvider` can inherit an outer binding or override its subtree with explicit `session={reference | undefined}`. It neither acquires nor releases ownership. The root Provider resolves its main binding from the `mainView` ownership marker without maintaining another current value; sibling and nested Providers affect only their own subtrees. Explicit absence stays absent instead of falling back to the main area.
+
+The Provider injects the selected binding without keying its whole body. Strict `session` entries remount when the binding Context changes. A blank `session-maybe` entry adopts its first binding without remounting; after adoption, another binding Context or a return to absence starts a new component incarnation.
+
+`uiWorkspace` owns the main-area reference with source `mainView`, and `ui-session` derives the root Provider from that reference's ownership marker on the Session record. Conversation, right Sidebar, preset, command, input, and model components beneath a Provider consume only its standard bound data. They do not read the main-area reference or a global selection, and they do not reinterpret an ID as a binding from another Provider.
+
+Each Provider occurrence establishes an independent rendering scope from its supplied `SessionReference`. Two references may identify different Sessions or share one `SessionBinding`; different bindings isolate business and view data, while Providers for the same binding share Session, Conversation, input, and other Session-level data but retain separate component-local state. The main area and Sidebar can mount two Conversations concurrently without either Provider replacement or teardown redirecting the other subtree.
+
+`ui-session` reuses one stable business observable per active `SessionBinding`. Generation caches for Conversation assembly, input shells, command popups, input-trigger controllers, and model directories use binding identity instead of Session IDs. Caches that must enumerate live values use `WeakMapWithValues<SessionBinding, Value>`, whose weak key table and strong value set provide identity lookup and value iteration. The container performs no cleanup; subscriptions, controllers, URLs, and other resources still release deterministically through `binding.ctx.effect()`. Provider-occurrence view state ends with that rendering scope, while final generation cleanup belongs to the binding Context.
+
+Descriptor changes assemble replacement sources before publication without recreating Session generations. A Provider validates its reference while reading it; a released reference, a reference from another Controller, or a reference that no longer matches an active binding cannot establish a scope.
+
+| Consumer | Acquisition and release |
+| --- | --- |
+| Main Conversation | `uiWorkspace` acquires the navigation target; `ui-session` establishes the root Provider from the `mainView` marker, and the Conversation subtree consumes only its Provider binding |
+| Associated right Sidebar | `RightbarRoot` inherits the root Provider without reading the main-area reference |
+| Independent Session view | Its owner retains the target and establishes a Provider from its own reference alongside the main area |
+| Host event handler in the Client | Holds a local Context reference through handler and reply settlement |
+
+Conversation references belong to their view owners, not Chat, Trajectory, or an individual action. Chat, Trajectory, commands, input, uploads, image reads, and model selection borrow the same binding during the Provider lifetime rather than acquiring a reference per action. Switching or closing that Conversation can end its in-flight local work. Sidebar-tab layout and resource ownership remain separate; only a Sidebar view that hosts a Conversation needs its own Session reference. Empty layouts and guide placeholders retain nothing.
+
+Scoped business objects capture their binding before awaiting work. They do not reinterpret an old Context or directory as a new generation with the same id. ModelSelection borrows its Provider binding while retaining its own selection-generation and error rules. Editor-detach callbacks may overlap scope teardown; optional trigger and popup resolution returns absence for that retired Context rather than resolving another generation.
+
+### Main-area navigation and presentation
+
+`uiWorkspace.openSession`, `openWorkspace`, `forkSession`, and `startSession` remain the navigation entry points. They accept or resolve explicit targets, change the main view, and return the main area to Conversation according to existing navigation policy. `retain` itself never navigates. There is no `registerNavigation` receiver protocol or second navigation service introduced by reference ownership.
+
+The existing `uiWorkspace` implementation directly owns the main-area reference and target. Its navigation methods update that owner rather than calling a receiver registered by Conversation. `ui-session` derives the root Provider binding from the source marker; the main Conversation and associated right Sidebar only inherit the Provider and neither depend on `uiWorkspace` nor see the main reference. An independent Sidebar Conversation establishes a nested Provider from its own reference and overrides only that subtree's binding. The main reference is not a global standard prop, subtree Hook, or default value for ID-based lookup.
+
+The main view privately persists its target identity and subagent address under `dsh.sessions.current`, never a reference. Startup restoration, initial Workspace selection, and clearing an archived main target remain UI responsibilities. Archiving or removing catalog metadata does not revoke independent references held by other consumers.
+
+| UI behavior | Final rule |
+| --- | --- |
+| Session-list highlight and blank-row treatment | Derive main-area occupancy from `retainedBy.mainView`, not the number of mounted Session Providers |
+| New Session Workspace | Explicit Workspace first, then the main Session's Workspace under the existing lookup rule, then the existing recent-Workspace policy |
+| Onboarding | Evaluate absence or blankness of the main-area Session, not all historical Sessions |
+| Browser document title | Conversation shows Session title plus product title; a global panel shows product title |
+| Chat/Trajectory restoration | Restore the view for the explicitly selected main target; independently bound views keep their own state |
+| Cordis inventory panel | One list without current/other grouping; no public runner getter for main-area selection |
+
+Source metadata does not change when DOM focus moves or when a global panel hides a retained view. The [global main-panel design](../../implemented/architecture/2026-09-08-global-main-panels.md) owns panel selection and layout; Session reference ownership does not replace it.
+
+Conversation retains its `hero`, `settling`, and `active` composition and existing history-loading and `openError` handling. Acquisition adds no outer loading/error phase presentation, extra composer-hiding condition, Retry button, or replacement Sidebar recovery panel. Existing error handlers continue to handle their errors; call sites without error presentation gain none. Promise rejection and correct reference release do not imply an additional UI handler.
+
+Workspace connection and fork preserve their existing navigation guards and panel-switch invalidation. Direct Session opening gains no additional global-navigation cancellation policy. Agent Team refresh preserves its originating-selection validity condition instead of starting a global navigation token before refresh. Reference acquisition does not broaden cancellation to unrelated navigation or running operations, and local cancellation does not roll back Host effects.
+
+### Presets and creation flows
+
+Preset directories and deployment defaults may be shared. A bound Session's preset is read or changed through its Provider binding; preset controllers are cached by `SessionBinding` rather than managed by one root current-Session follower. The hero preset seat uses the `session-maybe` Provider: it shows the creation-flow choice without a Session and operates on the exact bound blank Session after one arrives. Header labels read the same Provider-bound Session projection.
+
+A preset chosen before Session creation remains in the main Conversation's `session-maybe` preset surface. After Workspace creation or reuse establishes the main Provider over a blank Session, that surface applies the choice to its Provider-bound Session. When Settings changes the default preset or picker setting, the preset service selects the blank Session whose established Provider binding carries `mainView` ownership and updates that Session. Non-blank main Sessions, independent Sidebar Providers, and other background references remain unchanged; preset subtrees do not read the main reference or use a global current follower to find their target.
+
+### Host-event Context ownership and Typert
+
+A validated Host waterfall identity can arrive before catalog discovery. The Client Context resolver must remain synchronous. It acquires a local generation reference with the Gateway's source and returns `TypertOwnedValue<Context>` without opening history or refreshing child catalogs. A handler that needs history separately acquires a public reference and awaits its `ready` Promise, or uses `sessions.using`.
+
+Gateway owns the local reference until both handler use and reply settlement end. Context acquisition failures retain the existing report-and-delegate behavior, while handler failures produce rejected replies. Cancellation reaches the handler through its existing signal and suppresses late replies without releasing a Context still in use. Plugin shutdown joins the active connection generation's outstanding handlers; Connection starts no replacement generation until that source settles.
+
+`TypertOwnedValue` carries a value and its disposer through the generic Gateway. It has no additional reference count and does not teach Gateway about Session-specific ownership. Independently bundled Client modules share the owned-value marker. Client outgoing `identity(ctx)` remains synchronous; Host Context resolution and Host lifecycle are unchanged.
+
+## Alternatives considered
+
+**Catalog membership as ownership.** Discovery data cannot establish ongoing view or operation use, and some Context identities arrive before catalog membership.
+
+**Implicit main Session plus an explicit alternative.** Two target-resolution rules make reusable components depend on where they render. Explicit Providers supply the target, while generic source records serve window-level observation.
+
+**Provider subtrees reading `mainSession`.** A component would then have both a Provider target and a window-level target, so an independent Sidebar Conversation could be redirected by a main-area change. The main reference participates only in Provider assembly; subtrees consume their Provider.
+
+**Session ID as a generation-cache key.** Final release allows a replacement generation with the same ID to appear before old cleanup ends. An ID key can reuse the old object or let old cleanup remove the replacement. Business caches use weak binding identity, and the binding Context still owns resource cleanup.
+
+**Asynchronous `retain` that resolves only after history opens.** It delays Provider installation and main-view navigation until history arrives, so the existing loading state cannot render immediately. A synchronous reference separates ownership from its explicit `ready` result.
+
+**History I/O in synchronous Context resolution.** Host-event dispatch needs a scoped lifetime, not necessarily a history window; coupling them delays or blocks handlers before catalog discovery.
+
+**A dedicated current flag.** One consumer-specific flag cannot describe Sidebar and background ownership. A main-view marker is derivable from the general per-source counts.
+
+**Completion state in Session Controller, or separate pending and completion hooks.** Completion acknowledgement is UI policy. One UI status source composes independent facts while retaining domain-owned pending objects and Controller-owned running facts.
+
+**Provider presence as acknowledgement or release.** Mounting is neither an owner's lifetime nor selection-based acknowledgement. It cannot decide which background or temporarily hidden uses remain alive or count as viewed.
+
+**Bare ids in Providers or a global binding revision.** An id does not express generation ownership, and global refresh invalidates unrelated Session consumers.
+
+**A reference for every action.** The Provider already defines the Conversation's usage lifetime. Reacquiring for every click fragments view ownership into fine-grained sources. Only work that must outlive the Provider acquires another reference.
+
+**Navigation registration, new cancellation policies, and acquisition-specific recovery UI.** Reference ownership requires explicit targets, release, and failure propagation, not additional navigation or recovery behavior.
+
+**Client references retaining Host Agents.** History access and Host execution are independent uses; a long-lived Client view cannot define Host business-operation lifetime.
+
+## Verification
+
+- Acquisition tests cover shared initial opening, ordinary and unexpected open failures, independent waiter cancellation, exact-generation replacement, and root teardown reaching quiescence.
+- Source tests cover multiple sources, several references from one source, failed acquisition rollback, idempotent release, catalog refresh/removal, and late release of an ended generation without changing its replacement.
+- Retain-info hook tests cover explicit identities, Provider-bound defaults, unbound and nested scopes, source updates across generations, and reads that create neither references nor history requests.
+- Scope and Gateway tests cover synchronous Context acquisition before catalog discovery without history I/O, reported acquisition delegation, rejected handler replies, ownership through cancellation and settlement, and shared owned-value markers across bundles.
+- UI status tests cover pending precedence and teardown, event transitions lost by snapshot batching, initial and reconnect baselines, main-source acknowledgement, unrelated-source retention, and no Provider-presence event.
+- View tests cover explicit Providers for two different Sessions and for repeated uses of one Session, scoped presets, descriptor updates, and Sidebar occurrence/adoption lifetimes without new recovery controls.
+- Assembled browser scenarios preserve main-area highlighting, blank rows, onboarding, Workspace defaults, titles, and the defined navigation/error behavior. Existing recorded model turns remain the behavioral input when only Client ownership changes.
+- The public `retain` and `using` consumers, generated API catalogs, package contracts, and type checks agree on options and ownership. The helper keeps references through callback settlement and propagates both acquisition and operation failures.
+
+## Consequences
+
+Consumers must keep references until their real completion point; an unreleased reference still leaks within a live Client root. Retaining after final release creates a new generation and can reopen history. Source counts describe ownership, not visibility, task success, or authority; using the main marker as a business fallback would recreate implicit current-Session coupling.
+
+Final release discards non-persisted binding-owned state, including loaded history pages, Chat scroll anchors, preview wrapping, Files expansion, composer attachments, and undo history. Only persisted Session-keyed Store values or a generation kept alive by another reference survive a view switch; the runtime does not clear those persisted values.
+
+Every public `retain`, including the temporary references used by Session rename and fork-title assignment, starts the generation's shared initial history opening. These metadata operations await `reference.ready` before using the Session and therefore pay that history I/O for a cold generation.
+
+The catalog, UI status, and view target have separate owners and can publish independently. Their consumers must not infer a lifecycle transition solely from notification order. The main view remains an ordinary reference owner, while its navigation and presentation rules stay in UI rather than the reference allocator.

+ 226 - 0
.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md

@@ -0,0 +1,226 @@
+# Agent Note: Client Session 引用、引用来源与 UI 状态
+
+Status: implemented
+
+[English](2026-09-15-client-session-references.md) | 中文
+
+## 问题
+
+Session 目录、活跃 Client 对象、视图与异步操作具有不同生命周期。目录成员关系不能证明仍在使用。借用的 binding 无法保护异步工作,也无法区分同一 Session ID 的不同 Client 代。全局 current Session 会使独立绑定的组件操作另一个视图的 Session。
+
+引用计数能表明仍在使用,但不能识别使用方。主区域高亮、Sidebar 所有权与后台操作需要使用方来源信息。待处理交互与完成提醒也需要统一的 UI 读取接口,避免 Workspace 和 Conversation 各自组合相同状态。
+
+Client 历史访问与 Host Agent(智能体)执行具有独立生命周期。历史打开可能失败;获取本地 Context 不一定需要读取历史。显式所有权必须保持导航、加载、错误处理与恢复行为,不添加无关 UI 策略。
+
+## 决策
+
+### 范围与所有权
+
+Client Session 对象、Agent 作用域的 Client Context、引用、使用方来源元数据、UI 状态与显式 Provider 组合遵循下述所有权规则。Host Session 和 Agent 生命周期、SlotFactory、activity 列表视图、持久 Session 格式及两个 SDK 的 Host 协议保持独立。
+
+| 所有者 | 职责 |
+| --- | --- |
+| Client Session Controller | 目录、活跃代、引用、来源计数、binding、历史窗口与既有 Session 控制状态 |
+| `ui-session` | 显式 Session Provider 集成与统一 UI 状态来源 |
+| 视图或操作 | 自己的引用、目标、来源标识与释放时机 |
+| Workspace UI | 主区域目标与引用、导航、持久目标及创建流程 |
+| UI 组合边界 | 将所有者提供的引用交给一个明确的 `SessionProvider` |
+| Conversation 与 Sidebar 子树 | 只消费所在 Provider 的 Session,不读取主区域引用或全局选择 |
+| Client Gateway | 处理 Host 事件时调用期的 Context 所有权 |
+
+[Client 分层设计](../../implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md)定义数据、适配器、渲染器与展示层的单向依赖。引用来源统计不会使 Controller 依赖 UI 包。
+
+本决策部分取代 [Web Client Session scope 与 provide channel 决策](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中由 list 选择驱动的 scope 生命周期;后者保留显式 Provider 所有权下的 blank Session 与收养语义理由。
+
+### 地址、binding 与引用
+
+`SessionTarget` 是已知 `SessionId` 或持久的直接父子 `SubagentAddress`。它标识要获取的目标,不持有任何对象。Controller 解析显式地址时不要求预先加载 parent catalog,Host 则在打开历史时校验其 parent、child 与 mode。子会话发现与已知子会话地址均不等同于持有该子会话。导航使用同一种目标表示,不增加另一套导航地址。
+
+`SessionBinding` 是共享的 Client 代,包含 `sessionId`、Session 接口、事件来源与作用域 Context。同一活跃代的多个引用共享这个 binding。同 ID 的新一代具有不同的 binding 和 Context。
+
+`SessionReference` 持有某个确切代的一次使用。它公开只读 `sessionId` 和 `binding`、表示共享首次历史打开的 `ready` Promise,以及幂等 `release()` 与 `Symbol.dispose`。对应 `Session.open()` 尝试解析时,`ready` 解析为该确切 binding,包括以状态表示的 Remote failure 结果。引用释放或其代清理后,读取 `binding` 会失败。获取、释放或查看引用均不创建持久 Session,也不启动、停止或持有 Host Agent。
+
+`SessionBinding` 是借用值,不构成持有。只有未释放的 `SessionReference` 计入来源与总引用数。同步代码可以在拥有者引用的生命周期内借用 binding;需要越过该生命周期的工作必须拥有自己的引用。
+
+| API | 返回结果与调用方义务 |
+| --- | --- |
+| `sessions.retain(target, options)` | `SessionReference`;立即返回,调用方持有该引用直到释放 |
+| `sessions.using<T>(target, options, operation)` | `Promise<T>`;依次等待 `reference.ready` 与 `operation(reference)`,释放自己的引用,并返回操作结果 |
+| `sessions.retainInfo(id)` | 本地引用计数的稳定只读 observable,不获取引用、不创建作用域,也不执行历史 I/O |
+| `sessions.scope(id)` / `sessions.binding(id)` | 借用已存在的活跃代或返回 `undefined`,不打开它,也不延长其生命周期 |
+| `sessions.sessionOf(ctx)` | 返回匹配的活跃 Session 接口或 `undefined`;已结束的 Context 不能解析为同 ID 替代代 |
+| `sessions.create(...)` / `sessions.fork(...)` | 既有 Host 操作,返回 Session 身份;持有与展示仍需显式执行 |
+
+`SessionRetainOptions` 包含必填 `source: SessionReferenceSource` 与可选 `signal: AbortSignal`。两个获取方法均接受 `target: SessionTarget` 和这些 options。来源键由使用方定义,通过声明合并扩展;不提供运行时来源注册协议,也不默认使用主视图来源。来源是使用标签,不是另一种 Session 地址,也不授予操作权限。
+
+### 获取、失败与释放
+
+获取操作同步解析目标,创建或持有本地 Session、Context、fiber 和 binding,记录一份引用及其来源计数,启动该代共享的首次历史打开,然后返回引用。并发获取共享这次打开,但分别获得独立引用与就绪等待。共享的 `Session.open()` 尝试解析时,`reference.ready` 解析为仍然有效的确切 binding。
+
+未知 Session id 的寻址在返回引用前失败;显式 subagent 地址由 Host 在打开期间校验。抛出的打开失败、调用方取消、引用释放或该代结束会拒绝该引用的 `ready`;取消一个等待方不会取消其他所有者共享的打开操作。以 `openState: 'error'` 表示的 Remote failure 跟随 `Session.open()` 语义并解析就绪,错误仍可通过 binding 渲染。调用方持有已返回的引用直到释放,而 `sessions.using` 会在就绪或操作失败时释放自己的引用。
+
+`using reference = sessions.retain(target, options)` 在所在作用域退出时释放;需要等待首次历史尝试结算的代码等待 `reference.ready`。`sessions.using` 是面向回调调用方的辅助方法:它先等待该次结算,再调用操作并等待操作返回的普通值或 Promise,最后释放。返回值不能依赖辅助方法已经释放的引用仍可使用;需要更长生命周期的使用方自行获取引用。辅助方法传播被拒绝的就绪与操作失败,不提供兜底结果、重试或错误展示。模型选择的代际检查与错误状态更新保留在 ModelSelection。
+
+`ready` Promise 成功表示首次 `Session.open()` 尝试已经结算;它不保证 `openState: 'open'`,也不保证连接永不中断。以状态表示的打开失败与之后的流失败继续通过既有 Session 状态公开。调用方保留对这些错误的既有处理与展示。
+
+释放只移除该引用及其来源计数。最后释放在清理 Session 与 fiber 前撤回该代的准入和 ID 映射。后续 retain 可以立即创建新一代;旧代清理不能移除替代代。`release()` 同步发起本地清理,根清理等待尚未结束的异步释放工作。
+
+所属 Client 根在关闭时使全部引用失效。它拒绝新的获取,撤回活跃映射,并汇合作用域清理与 Session 流释放。引用不能使已清理的 Client 根继续存活。仅移除目录记录不会清理仍被引用持有的代。
+
+### 引用来源与 Session 列表记录
+
+Controller 将来源计数与每个活跃代的引用共同管理。每个来源的计数等于该代尚未释放且带有该来源的引用数。同一 Session 可以有多个来源,同一来源可以持有多份引用。分配器不赋予任何来源特殊生命周期行为。
+
+每条已有 Session 列表记录公开只读 `retainedBy`,由来源键映射到正数引用计数。未被持有的记录使用空对象;零计数键不存在。获取失败与释放更新该投影,包括删除最后一个来源键。Host 元数据刷新时,来源计数仍是本地事实,Host 响应不能覆盖它。
+
+即使 Session 尚未进入 Host 目录或其目录元数据已被移除,计数仍由对应代持有。`byId` 为每个活跃代合成本地兜底记录,以便 Provider 与所有权使用方解析它;`ids` 仍表示 Host 列表成员关系与顺序。兜底记录不是 Host 元数据。每条记录的 `retainedBy` 投影均使用活跃代的计数。引用对象与计数均不持久化,也不发送给 Host。
+
+例如,`retainedBy = { mainView: 1, gateway: 2 }` 表示主视图与两个 Host 调用持有三份引用。释放主视图引用只移除 `mainView`;Gateway 调用继续持有该代。示例中的来源名是使用方键,不是 Controller 内封闭的枚举。
+
+`current` 表示主视图占用:记录的 `mainView` 来源计数为正数时,就具有这个标记。这只是通用来源记录的一种用途,不是独立的 `sessions.current` 值或 Session 选择服务。其他使用方可以从自己的来源键派生标记。引用系统不要求全局唯一使用方,也不从多个来源中选择一个 Session。
+
+主区域所有者管理自己的目标与引用切换。列表不根据恰好挂载了多少 Provider 推断选择。来源记录反映实际所有权,包括获取期间的短暂重叠;UI 导航仍负责自己的目标,不把选择权交给分配器。
+
+窗口级使用方可以查看来源标记。Session 业务操作仍使用传入的作用域或显式引用,不得从目录中查找 `mainView` 来补齐缺失的操作目标。后台引用只能证明所有权,不能证明用户查看过 Session。
+
+`SessionRetainInfo` 包含 `referenceCount` 与只读 `retainedBy` 记录。`sessions.retainInfo(id)` 独立于目录成员关系观察本地所有权,并在同 ID 换代时保持稳定。没有活跃代的身份具有零引用和空来源记录;读取该值不代表该身份在 Host 上存在。
+
+`ui-session` 通过 `useSessionRetainInfo(sessionId, selector)` 读取明确身份,通过 `useSessionRetainInfo(selector)` 读取外围 Provider 绑定的 Session。未绑定作用域向后一种形式提供缺失值,不回退到主区域 Session。两种形式均由渲染器从相同的裸 retain-info 来源构造。使用方检查 `mainView` 来源计数以识别原 current-Session 角色,其他来源键也可同等查询。读取或订阅不会持有 Session。
+
+### 统一的 UI Session 状态
+
+`ui-session` 拥有不依赖 React 的 `sessionStatus` 来源,通过标准 `useSessionStatus` 钩子公开。快照按 Session 身份索引,为每个已知 Session 提供一条 UI 状态记录。它组合下列独立事实,不将它们压缩成互斥的单一阶段:
+
+| 字段 | 取值 | 含义与所有者 |
+| --- | --- | --- |
+| `running` | `boolean` 或 `undefined` | 最新已知的 Session 运行事实;缺少基线不表示已确认 idle |
+| `pendingInteraction` | `SessionPendingInteraction` 或 `undefined` | 领域拥有的有效请求;没有待处理请求时缺失 |
+| `completionUnread` | `boolean` | 已观察到停止、但尚未确认的 UI 提醒 |
+
+来源计数以 `SessionListState.byId[id].retainedBy` 为准;UI 状态读取它以执行确认策略,不维护另一套引用注册表。标题、Workspace 关联、历史、队列和投影数据保留在各自的既有所有者中。
+
+待处理领域保留 `SessionPendingInteractionMap`、请求身份、优先级、发布清理函数与卸载委托。统一状态包含同一个有效请求对象,不复制请求,也不创建第二套待处理注册表。Workspace 状态指示器与 Conversation composer 选择读取 `useSessionStatus`,不再分别读取 `useSessionPendingInteraction` 和 `useCompletedSessionIds` 钩子。
+
+完成跟踪订阅已有 `api-session/status` 事件,避免 running 到 idle 的变化在合批目录快照中丢失。目录快照建立初始与重连基线。pending 状态下的空目录不能证明 Session 已消失。更新规则如下:
+
+- 初始 idle 基线不产生完成提醒。
+- 观察到 running 时清除旧提醒,并记录运行基线。
+- 从已知 running 变为 idle 时,仅在 Session 没有主视图持有的情况下设置 `completionUnread`。
+- 获得主视图持有时清除提醒;无关来源的 retain 不清除提醒。
+- 释放主视图持有不会为更早的一次停止补造提醒。
+- 移除 Session 时清除其完成提醒与运行基线;待处理请求的清理仍属于请求领域。
+
+提醒表示观察到的一次停止,不代表任务成功,也不代表某条排队消息完成。即使全局面板暂时隐藏 Conversation,主视图所有权仍保持基于选择的确认语义。本设计不提供 `ui-session/view-presence` 事件、已挂载 Provider 索引或 Provider 挂载时的隐式确认。
+
+Session Controller 发布运行与引用来源事实,但不拥有完成提醒集合、`consumeCompletion` 方法或待处理交互展示。引用获取不执行完成提醒业务逻辑。
+
+### 显式 Provider、并行 Conversation 与缓存身份
+
+唯一的 `SessionProvider` 可以继承外层 binding,也可以用显式 `session={reference | undefined}` 覆盖本子树。它不获取或释放所有权。根 Provider 从 `mainView` 所有权标记解析主 binding,不维护另一份 current;并列或嵌套 Provider 只影响各自子树。显式缺失保持缺失,不回退到主区域。
+
+Provider 注入选定 binding,但不为整个 body 设置 key。严格 `session` entry 在 binding Context 改变时重新挂载。空白 `session-maybe` entry 接受首个 binding 时不重新挂载;接受后,切换到另一个 binding Context 或回到缺失状态会创建新的组件 incarnation。
+
+`uiWorkspace` 持有来源为 `mainView` 的主区域引用,`ui-session` 从该引用在 Session 记录中的所有权标记建立根 Provider。Provider 下的 Conversation、右 Sidebar、preset、命令、输入与模型组件只能读取 Provider 绑定的标准数据。它们不得读取主区域引用或全局选择,也不得按 Session ID 重新寻找一个可能属于其他 Provider 的 binding。
+
+每个 Provider occurrence 以传入的 `SessionReference` 建立独立渲染作用域。两个引用可以指向不同 Session,也可以共享同一 `SessionBinding`;不同 binding 的业务与观看数据完全分离,同一 binding 的 Provider 共享 Session、Conversation、输入等 Session 级数据,但保留各自的组件局部状态。主区域与 Sidebar 可以同时挂载两个 Conversation,任一 Provider 的替换或卸载不改变另一棵子树的目标。
+
+`ui-session` 为每个活跃 `SessionBinding` 复用稳定的业务 observable。Conversation assembly、input shell、command popup、input-trigger controller 与 model directory 等代际缓存以 binding 为弱键,不再以 Session ID 为键;需要枚举活跃值的缓存使用 `util-values` 的 `WeakMapWithValues<SessionBinding, Value>`,由弱键表和强值集合共同维护身份查询与值遍历。该容器不执行清理;订阅、控制器、URL 和其他资源仍通过 `binding.ctx.effect()` 确定性释放。Provider occurrence 的观看状态随该 Provider 的渲染作用域释放,最终 generation 清理由 binding Context 负责。
+
+Descriptor 变化先组装替代来源再发布,不重建 Session 代。Provider 在读取 reference 时校验它仍有效;已经释放、来自其他 Controller 或不再对应活跃 binding 的 reference 不能创建作用域。
+
+| 使用方 | 获取与释放 |
+| --- | --- |
+| 主 Conversation | `uiWorkspace` 获取导航目标;`ui-session` 从 `mainView` 标记建立根 Provider,Conversation 子树只消费 Provider 绑定 |
+| 关联右 Sidebar | `RightbarRoot` 继承根 Provider,不读取主区域引用 |
+| 独立 Session 视图 | 自己的所有者持有目标,并以自己的引用建立 Provider;与主区域同时运行 |
+| Client 中的 Host 事件 handler | 持有本地 Context 引用直到 handler 与回复结算 |
+
+Conversation 的引用属于其视图所有者,不属于 Chat、Trajectory 或某次点击。Chat、Trajectory、命令、输入、上传、图片读取与模型选择在 Provider 生命周期内借用同一 binding;它们不按行动重复获取引用。切换或关闭该 Conversation 可以结束仍在途的本地工作。Sidebar tab 的布局与资源所有权保持独立;只有承载 Conversation 的 Sidebar 视图所有者需要自己的 Session 引用。空布局与 guide 占位页不持有引用。
+
+作用域业务对象从 Provider 捕获自己的 binding,不把旧 Context 或目录重新解释成同 ID 的新一代。ModelSelection 借用 Provider binding,并保留自己的选择代际与错误规则。编辑器脱离回调可能与作用域清理重叠;可选 trigger 和 popup 对已结束的 Context 返回空值,不解析其他 generation。
+
+### 主区域导航与展示
+
+`uiWorkspace.openSession`、`openWorkspace`、`forkSession` 和 `startSession` 仍是导航入口。它们接受或解析明确目标,改变主视图,并按既有导航策略将主区域返回 Conversation。`retain` 本身从不导航。引用所有权不引入 `registerNavigation` 接收者协议,也不引入第二个导航服务。
+
+现有 `uiWorkspace` 实现直接持有来源为 `mainView` 的主区域引用与目标。导航方法直接更新该所有者,不调用 Conversation 注册的接收者。`ui-session` 根据来源标记把该引用对应的 binding 注入根 Provider;主 Conversation 和关联右栏只继承 Provider,既不依赖 `uiWorkspace`,也看不到主引用。独立 Sidebar Conversation 以自己的 reference 建立嵌套 Provider,并覆盖本子树的 binding。主引用不是全局标准 prop、子树 Hook 或按 ID 查询的默认值。
+
+主视图在 `dsh.sessions.current` 下私下持久化目标身份与子会话地址,不保存引用。启动恢复、初始 Workspace 选择与归档主目标后的清空仍属于 UI。归档或移除目录元数据不撤销其他使用方持有的独立引用。
+
+| UI 行为 | 最终规则 |
+| --- | --- |
+| Session 列表高亮与空白记录处理 | 从 `retainedBy.mainView` 派生主区域占用,不按已挂载 Session Provider 的数量判断 |
+| New Session 的 Workspace | 优先使用显式 Workspace,其次按既有查找规则使用主 Session 的 Workspace,最后使用既有最近 Workspace 策略 |
+| Onboarding | 判断主区域 Session 是否缺失或为空白,不判断所有历史 Session |
+| 浏览器文档标题 | Conversation 显示 Session 标题与产品标题;全局面板显示产品标题 |
+| Chat/Trajectory 恢复 | 为显式选中的主目标恢复视图;独立绑定的视图保留自己的状态 |
+| Cordis inventory 面板 | 使用不区分 current/other 的单一列表;runner 不提供主区域选择的公开 getter |
+
+DOM 焦点移动或全局面板隐藏仍被持有的视图时,来源元数据不变。[全局主面板设计](../../implemented/architecture/2026-09-08-global-main-panels.zh.md)拥有面板选择与布局;Session 引用所有权不替代它。
+
+Conversation 保留 `hero`、`settling`、`active` 组合与既有历史加载和 `openError` 处理。获取引用不增加外层 loading/error 阶段展示、额外隐藏 composer 的条件、Retry 按钮或替换 Sidebar 内容的恢复面板。已有错误处理方继续处理自己的错误;没有错误展示的调用点不增加展示。Promise 拒绝与正确释放引用不意味着额外增加 UI 处理方。
+
+Workspace 连接和 fork 保持既有导航检查与面板切换失效规则。直接打开 Session 不增加全局导航取消策略。Agent Team 刷新保留发起时选择仍然有效的条件,不在刷新前启动全局导航 token。引用获取不扩大取消范围,不影响无关导航或正在执行的操作;本地取消不回滚 Host 效果。
+
+### 预设与创建流程
+
+预设目录与部署默认值可以共享。已绑定 Session 的预设通过 Provider 的 binding 读取或修改;预设控制器按 `SessionBinding` 缓存,不由根级 current-Session 跟随器管理。Hero 的 preset seat 使用 `session-maybe` Provider:没有 Session 时显示创建流程选择,绑定空白 Session 后操作该确切 Session。标题标签读取同一 Provider 绑定 Session 的投影。
+
+Session 创建前选择的 preset 保留在主 Conversation 的 `session-maybe` preset surface 中。Workspace 创建或复用空白 Session 并建立主 Provider 后,该 surface 将选择应用到 Provider 绑定的 Session。设置页修改默认 preset 或 picker 设置时,preset 服务从 Provider 已建立的 binding 缓存中选择带 `mainView` 所有权标记的空白 Session,并更新该 Session。非空白主 Session、Sidebar 的独立 Provider 与其他后台引用均不受该设置动作影响;preset 子树不读取主引用,也不通过全局 current follower 寻找目标。
+
+### Host 事件 Context 所有权与 Typert
+
+经校验的 Host waterfall(瀑布式事件)身份可以先于目录发现到达。Client Context 解析器必须保持同步。它带上 Gateway 的来源获取本地代引用,返回 `TypertOwnedValue<Context>`,不打开历史,也不刷新子目录。需要历史的 handler 另行获取公开引用并等待其 `ready` Promise,或使用 `sessions.using`。
+
+Gateway 持有本地引用,直到 handler 使用与回复结算均结束。Context 获取失败保留既有的记录错误并委托语义,handler 失败产生拒绝回复。取消通过已有 signal 到达 handler,并抑制晚回复,但不会在 Context 仍被使用时提前释放。插件关闭汇合当前连接代的在途 handler;Connection 只在该来源结算后启动替代代。
+
+`TypertOwnedValue` 跨通用 Gateway 传递值及其清理操作。它没有额外引用计数,也不要求 Gateway 理解 Session 专用所有权。独立 Client bundle 共享 owned-value 标记。Client 发请求的 `identity(ctx)` 保持同步;Host Context 解析与 Host 生命周期不变。
+
+## 考虑过的替代方案
+
+**以目录成员关系作为所有权。** 发现数据无法证明视图或操作仍在使用,而且部分 Context 身份先于目录成员关系到达。
+
+**隐式主 Session 加显式替代路径。** 两套目标解析规则会使可复用组件依赖其渲染位置。显式 Provider 提供目标,通用来源记录服务于窗口级观察。
+
+**Provider 子树继续读取 `mainSession`。** 同一组件会同时拥有 Provider 与窗口级目标,Sidebar 的独立 Conversation 也会被主区域变化重定向。主引用只参与 Provider 装配,子树只读取 Provider。
+
+**以 Session ID 为代际缓存键。** 最终释放允许同 ID 新代在旧清理结束前出现,ID 键会复用旧对象或让旧清理删除新对象。业务缓存使用弱引用的 binding 身份,资源清理由 binding Context 负责。
+
+**只在历史打开后解析的异步 `retain`。** 它会把 Provider 安装与主视图导航推迟到历史到达之后,导致既有加载状态无法立即渲染。同步引用把所有权与显式的 `ready` 结果分开。
+
+**在同步 Context 解析中执行历史 I/O。** Host 事件派发需要作用域生命周期,不一定需要历史窗口;耦合两者会在目录发现之前延迟或阻止 handler。
+
+**专用 current 标志。** 一种使用方专用标志无法描述 Sidebar 与后台所有权。主视图标记可以从通用的按来源计数中派生。
+
+**Session Controller 中的完成状态,或独立的待处理与完成提醒钩子。** 完成确认属于 UI 策略。统一 UI 状态来源组合独立事实,同时保留领域拥有的待处理对象与 Controller 拥有的运行事实。
+
+**以 Provider 呈现决定确认或释放。** 挂载既不等于所有者生命周期,也不等于基于选择的确认。它无法决定哪些后台或暂时隐藏的使用仍应存活,或应当算作已查看。
+
+**Provider 中的裸 ID 或全局 binding revision。** ID 不表达代际所有权,全局刷新会使无关 Session 使用方失效。
+
+**每个行动各自获取引用。** Provider 已经定义 Conversation 的使用生命周期;为每次点击重复持有会把视图所有权拆成大量细粒度来源。只有明确需要越过 Provider 生命周期的工作才另行持有引用。
+
+**导航注册、新的取消策略与获取专用恢复 UI。** 引用所有权要求显式目标、释放与失败传播,不要求额外导航或恢复行为。
+
+**Client 引用持有 Host Agent。** 历史访问与 Host 执行是独立使用需求,长时间打开的 Client 视图不能定义 Host 业务操作生命周期。
+
+## 验证
+
+- 获取测试覆盖共享首次打开、普通与意外打开失败、独立等待方取消、确切代替换,以及根清理达到完全停稳。
+- 来源测试覆盖多个来源、同来源多份引用、获取失败回滚、幂等释放、目录刷新或移除,以及旧代的晚释放不改变替代代。
+- Retain-info 钩子测试覆盖明确身份、Provider 绑定默认值、未绑定与嵌套作用域、跨代来源更新,以及读取不创建引用或请求历史。
+- 作用域与 Gateway 测试覆盖目录发现前不执行历史 I/O 的同步 Context 获取、获取失败的记录与委托、handler 的拒绝回复、取消与结算期间的所有权,以及跨 bundle 共享的 owned-value 标记。
+- UI 状态测试覆盖待处理优先级与清理、会被快照合批丢失的事件变化、初始与重连基线、主来源确认、无关来源持有,以及不存在 Provider 呈现事件。
+- 视图验证覆盖两个不同 Session 和同一 Session 多次使用的并行显式 Provider、互不重定向的 Conversation、独立 Slot store、作用域 preset、descriptor 更新,以及没有新恢复控件的 Sidebar 生命周期。
+- 实际组合浏览器场景保持主区域高亮、空白记录、onboarding、Workspace 默认值、标题与约定的导航或错误行为。只有 Client 所有权改变时,已有模型轮次录制仍作为行为输入。
+- 公开 `retain`、`using`、Provider、生成 API 目录、包约定与类型检查对 options 和所有权保持一致。Provider 子树不读取主区域引用,独立所有者各自释放自己的引用。
+
+## 后果
+
+使用方必须把引用保留到真实结束点;在活跃 Client 根内,未释放引用仍会泄漏。最后释放后再次 retain 会创建新一代,并可能重新打开历史。来源计数描述所有权,不描述可见性、任务成功或操作权限;以主标记作为业务兜底会重新引入隐式 current-Session 耦合。
+
+最后一份 reference 释放后,未持久化的 binding 自有状态会被丢弃,包括已加载的历史页、Chat 滚动锚点、预览换行、Files 展开状态、composer 附件和 undo 历史。只有持久化的 Session-keyed Store 值或由另一份 reference 保活的 generation 能跨视图切换保留;runtime 不会清除这些持久值。
+
+每次公开 `retain` 都会启动该 generation 共享的首次历史打开,包括 Session 重命名和 fork 标题设置所用的临时引用。这些元数据操作在使用 Session 前等待 `reference.ready`,因此冷 generation 会承担这次历史 I/O。
+
+目录、UI 状态与视图目标由不同所有者管理,可以独立发布。使用方不能仅从通知顺序推断生命周期变化。主视图仍是普通引用所有者,其导航与展示规则留在 UI,不进入引用分配器。

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

@@ -254,7 +254,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
     // new-session screen with the self-referential preset staged, and the
     // blank session the flow produces composes from it on the host.
     await dialog.waitFor({ state: 'detached', timeout: 10_000 })
-    await page.getByRole('button', { name: '创造模式' }).waitFor({ timeout: 10_000 })
     await expect.poll(async () => {
       const response = await scaffold.hostFetch('/api/session/list', {
         method: 'POST',

+ 2 - 3
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -752,7 +752,7 @@ describe('web e2e: long Chat scroll contract', () => {
     })
   }, 180_000)
 
-  it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
+  it.skipIf(MODE === 'record')('keeps composer resizing on the correct scroll owner across reopened Sessions', async () => {
     await withScrollWorld({
       failureShot: 'web-e2e-chat-scroll-restore-composer',
       seeds: [
@@ -780,7 +780,6 @@ describe('web e2e: long Chat scroll contract', () => {
       await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
       await nextPaint(world.page)
       await expectSameFlowTop(world.page, sessionAnchor, RESPONSIVE_REFLOW_TOLERANCE)
-      const narrowSessionAnchor = await visibleFlowAnchor(world.page)
 
       await openSeed(
         world.page,
@@ -791,9 +790,9 @@ describe('web e2e: long Chat scroll contract', () => {
         world.page,
         RESTORE_FIXTURE_A,
       )
-      await expectSameFlowTop(world.page, narrowSessionAnchor)
 
       const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
+      await backToBottom.waitFor({ timeout: 15_000 })
       await backToBottom.evaluate((button) => {
         if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
         button.click()

+ 2 - 2
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -379,9 +379,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     expect(await panel.getAttribute('data-sidebar-right-panel')).toBe('push')
     expect(await paneSnapshot(page)).toEqual(retainedB)
     await open()
-    await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
+    await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('false')
     expect(await paneSnapshot(page)).toEqual(retainedB)
-    await checkpoint('B restored: normal mode and Files directory state')
+    await checkpoint('B restored: normal mode and collapsed Files directory')
     await close()
     await select(original, 'LIGHTHOUSE')
     await expect.poll(() => columns(page)).toEqual(normalColumns)

+ 1 - 1
apps/web/tests/sidebar-right.e2e.ts

@@ -857,7 +857,7 @@ describe('web e2e: shipped right Sidebar', () => {
         await expect.poll(async () => await settled.getAttribute('aria-selected')).toBe('true')
         await expect.poll(records, { timeout: 15_000 }).toEqual(before)
         expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
-        expect(await wrap.getAttribute('aria-pressed')).toBe('false')
+        expect(await wrap.getAttribute('aria-pressed')).toBe('true')
         expect(await column.locator('pre').first().innerText()).toContain('produced by the seeded turn')
         let warningStart = fxTripwire.warnings.length
         await fx.reload({ waitUntil: 'load' })

+ 3 - 0
apps/web/tests/workflow-run.e2e.ts

@@ -68,6 +68,9 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    const sessions = scaffold.ctx.sessions.list()
+    expect(sessions).toHaveLength(1)
+    scaffold.ctx.permissionPresets.set(sessions[0]!, 'danger-full-access')
   }, 120_000)
 
   afterAll(async () => {

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 6f910a27b19154135775384af4d922c6023cf276
-module-graph.zh.md: d0d1434987b205f93d3bdffb0202fb6084de6383
+module-graph.md: b2293415199536b32f2f5a6ef2147a10cde4fc47
+module-graph.zh.md: 50717b4b940cb80c2fb366554da85796fd028f3f

+ 2 - 1
docs/module-graph.md

@@ -1247,6 +1247,7 @@ flowchart TD
   pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer
   pkg_experimental_client_ui_agent_team --> pkg_client_ui_session
   pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots
+  pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace
   pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
   pkg_experimental_client_ui_agent_team --> pkg_session
   pkg_experimental_client_ui_agent_team --> pkg_typert_protocol
@@ -1578,7 +1579,7 @@ flowchart TD
 | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
-| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
+| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |

+ 2 - 1
docs/module-graph.zh.md

@@ -1249,6 +1249,7 @@ flowchart TD
   pkg_experimental_client_ui_agent_team --> pkg_client_ui_renderer
   pkg_experimental_client_ui_agent_team --> pkg_client_ui_session
   pkg_experimental_client_ui_agent_team --> pkg_client_ui_slots
+  pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace
   pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
   pkg_experimental_client_ui_agent_team --> pkg_session
   pkg_experimental_client_ui_agent_team --> pkg_typert_protocol
@@ -1580,7 +1581,7 @@ flowchart TD
 | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
 | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
 | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
-| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
+| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |

+ 2 - 2
docs/subsystems/slots.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/slots.md
-slots.md: 015ab8ee5752cbad46ea41aa4f7001e1aa9ad28f
-slots.zh.md: 92ac115947eb85f4346f3fad2d61b0603343a732
+slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363
+slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668

+ 4 - 4
docs/subsystems/slots.md

@@ -52,8 +52,8 @@ The slot declaration fixes two independent axes.
 | cardinality | `keyed` | The owner dispatches an `entryKey`; the matching cell renders with any key-specific props. |
 | cardinality | `chain` | Each entry supplies a pure `select(owner)` function. The first non-null result in priority order renders and receives that result as `matched`; otherwise the owner fallback renders. |
 | scope | `root` | One root-scoped component and store instance. |
-| scope | `session-maybe` | Follows current selection but stays renderable without a Session; Session values are optional. |
-| scope | `session` | Requires a resolved Session binding and receives definite Session values. |
+| scope | `session-maybe` | Inherits the surrounding Provider binding but stays renderable without one; Session values are optional. |
+| scope | `session` | Requires a resolved surrounding Provider binding and receives definite Session values. |
 
 `priority` is a shadowing rank for `single`, `list`, and `keyed` cells and an election order for `chain`. Lower values run or render first. Ordinary additive contributions should choose a fresh list `id` or keyed `key`; intentionally reusing a shipped cell replaces its presentation.
 
@@ -70,7 +70,7 @@ A registered component receives inputs assembled at its binding site. Components
 | localized `t` function | the registration's `locale` namespace | `PropsLocale<N>` |
 | selected chain value | the registration's `select` result | `matched` through `ComposedProps` |
 
-`SessionProvider` is also present in `PropsRenderSlots` when an entry declares a strict Session child. It binds that subtree to the current Session identity and remounts the body when the identity changes.
+`SessionProvider` is also present in `PropsRenderSlots` when an entry declares a `session` or `session-maybe` child. With no `session` prop it inherits the surrounding binding; an explicit `SessionReference` or `undefined` overrides only that subtree. The Provider does not key its whole body. A strict `session` entry remounts when its binding generation changes. A blank `session-maybe` entry adopts its first binding without remounting, then remounts for a later generation or a return to absence.
 
 Components never receive `ctx`. Parent-owned point-in-time values enter through the owner argument to `renderSlot`; shared view state uses a declared store; services and model objects stay in the `apply` closure and are projected into callbacks or observable sources.
 
@@ -80,7 +80,7 @@ The shipped adapters add these standard props. They are available according to t
 
 | Availability | Props | Owner |
 |---|---|---|
-| every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` |
+| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo` | `ui-session` |
 | every scope | `useWorkspaces` | `ui-workspace` |
 | every scope | `usePanelInfo` | `ui-layout` |
 | `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` |

+ 4 - 4
docs/subsystems/slots.zh.md

@@ -52,8 +52,8 @@ Slot 声明固定两个相互独立的维度。
 | cardinality | `keyed` | owner 传入 `entryKey`;匹配 cell 以该 key 对应的 props 渲染。 |
 | cardinality | `chain` | 每个 entry 提供纯 `select(owner)` 函数;按 priority 顺序遇到的第一个非 null 结果获选,并以 `matched` 传给组件;全部拒绝时渲染 owner fallback。 |
 | scope | `root` | 一个 root 作用域组件和 store 实例。 |
-| scope | `session-maybe` | 跟随当前选择,但没有 Session 时仍可渲染;Session 值是可选的。 |
-| scope | `session` | 要求可解析的 Session binding,并收到确定存在的 Session 值。 |
+| scope | `session-maybe` | 继承外围 Provider binding,但没有 binding 时仍可渲染;Session 值是可选的。 |
+| scope | `session` | 要求可解析的外围 Provider binding,并收到确定存在的 Session 值。 |
 
 对于 `single`、`list` 和 `keyed` cell,`priority` 是遮蔽优先级;对于 `chain`,它是选举顺序。数值越小越先运行或渲染。普通增量贡献应选用新的 list `id` 或 keyed `key`;复用已有 cell 表示有意替换其展示。
 
@@ -70,7 +70,7 @@ Slot 声明固定两个相互独立的维度。
 | 本地化 `t` 函数 | 注册项的 `locale` namespace | `PropsLocale<N>` |
 | chain 选中的值 | 注册项的 `select` 结果 | 通过 `ComposedProps` 提供的 `matched` |
 
-当 entry 声明 strict Session child 时,`PropsRenderSlots` 还会提供 `SessionProvider`。它把子树绑定到当前 Session identity,并在 identity 改变时重新挂载 body。
+当 entry 声明 `session` 或 `session-maybe` child 时,`PropsRenderSlots` 还会提供 `SessionProvider`。不传 `session` prop 时,它继承外围 binding;显式传入 `SessionReference` 或 `undefined` 时,只覆盖该子树。Provider 不为整个 body 设置 key。严格 `session` entry 在 binding generation 改变时重新挂载。空白 `session-maybe` entry 接受首个 binding 时不重新挂载,后续 generation 变化或回到缺失状态时才重新挂载。
 
 组件绝不会收到 `ctx`。父组件在某次渲染时已经知道的值通过 `renderSlot` 的 owner 参数进入;共享视图状态使用声明的 store;service 与 model object 留在 `apply` closure 中,只向组件投影 callback 或 observable source。
 
@@ -80,7 +80,7 @@ Slot 声明固定两个相互独立的维度。
 
 | 可用范围 | Props | Owner |
 |---|---|---|
-| 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` |
+| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo` | `ui-session` |
 | 所有 scope | `useWorkspaces` | `ui-workspace` |
 | 所有作用域 | `usePanelInfo` | `ui-layout` |
 | `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` |

+ 2 - 2
docs/subsystems/web-client.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/web-client.md
-web-client.md: 4e1b4948f8afe8d61f21a1f1c5cb08d6f341d7f5
-web-client.zh.md: 6cc3c7c79bca840143ccd9cda02b3542247ead4c
+web-client.md: c1c3c70d68aedc63da0a6e7aebd0b48d56886853
+web-client.zh.md: 42adffe958f9034f4d450d99dffcef85bcee19a2

+ 4 - 4
docs/subsystems/web-client.md

@@ -11,7 +11,7 @@ The Web Client is a browser-side Cordis application assembled from independently
 | Host application | business services and `packages/api/*-controller` Host entries | Own authoritative state, persistence, mutation ordering, access policy, and stream production. |
 | Transport and API assembly | `client/connection`, `api/gateway`, `api/remotes` | Establish a Client generation, expose generated `ctx.remote` methods and streams, forward selected Cordis events, and carry cancellation and results. |
 | Client models | `api/session-controller/client`, `api/workspace-controller/client` | Maintain React-free mirrors of Host state, resolve stream/unary races, own object identities and subscriptions, and expose narrow command services. |
-| UI adapters | `client/ui-session`, `client/ui-workspace` | Convert model observables into root or Session-scoped standard Slot sources without taking ownership of business state. |
+| UI adapters | `client/ui-session`, `client/ui-workspace` | Convert model observables into root or Provider-bound Session Slot sources and own view-level navigation and status policy. |
 | Conversation data | `client/ui-conversation`, target packages such as `ui-chat` and `ui-trajectory` | Assemble standard events and compact historical Assistant runs into independent target snapshots and own the shared conversation shell and input flow. |
 | Composition and rendering | `client/ui-slots`, `client/ui-renderer`, `client/ui-layout`, feature UI packages | Declare extension locations, derive component props, bind observables to React hooks, and mount the final tree. |
 
@@ -37,9 +37,9 @@ Each API controller package owns a paired Host and Client face. The Host side ow
 
 ### Sessions
 
-[`api/session-controller`](../../packages/api/session-controller/README.md) exposes Host commands for list, search, creation, selection data, prompt, queue, cancellation, pagination, and follow/control streams. Its Client side is organized as `ClientSessions → SessionManager → Session`:
+[`api/session-controller`](../../packages/api/session-controller/README.md) exposes Host commands for list, search, creation, prompt, queue, cancellation, pagination, and follow/control streams. Its Client side is organized as `ClientSessions → SessionManager → Session`:
 
-- `ClientSessions` provides `ctx.sessions`, owns Session scopes and stable `SessionBinding` objects, and projects the selected list state.
+- `ClientSessions` provides `ctx.sessions`, owns references, source counts, Session scopes, and stable `SessionBinding` objects, and projects catalog state without selecting a global current Session.
 - `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates.
 - Each `Session` owns one contiguous logical-event window represented by `SessionEventLikeEntry` values, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
 
@@ -53,7 +53,7 @@ This pairing is not a second source of business truth. Host controllers decide d
 
 ## Conversation and presentation
 
-`ui-session` installs the `session` scope adapter and publishes `useSessions`, `useSession`, `sessionId`, and `useProjection`. Domain adapters add further standard sources without putting React hooks on the model objects.
+`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects.
 
 `ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates durable Session events and Client-only `assistant/live-chunk` updates into stable business Contexts, and its view registry materializes target snapshots. Chat Assistant, Trajectory Assistant, and Turn Tail interpret both live chunks and the compact streams embedded in durable settlements, so reconnect and paged history reproduce the same Assistant state without durable token rows. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers.
 

+ 4 - 4
docs/subsystems/web-client.zh.md

@@ -11,7 +11,7 @@ Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。
 | Host 应用 | 业务 service 与 `packages/api/*-controller` Host entry | 拥有权威状态、持久化、mutation 顺序、访问策略与 stream 生产。 |
 | 传输与 API assembly | `client/connection`、`api/gateway`、`api/remotes` | 建立 Client generation,公开生成的 `ctx.remote` method 与 stream,转发选定的 Cordis event,并承载取消和结果。 |
 | Client model | `api/session-controller/client`、`api/workspace-controller/client` | 维护不依赖 React 的 Host 状态镜像,处理 stream/unary 竞态,拥有对象 identity 与订阅,并公开收窄的 command service。 |
-| UI adapter | `client/ui-session`、`client/ui-workspace` | 把 model observable 转换为 root 或 Session scope 的标准 Slot source,不接管业务状态所有权。 |
+| UI adapter | `client/ui-session`、`client/ui-workspace` | 把 model observable 转换为 root 或 Provider 绑定的 Session Slot source,并拥有视图级导航与状态策略。 |
 | Conversation 数据 | `client/ui-conversation`、`ui-chat` 与 `ui-trajectory` 等 target package | 把标准 event 与紧凑的 Assistant 历史批次组装成相互独立的 target snapshot,并拥有共享的 Conversation shell 与输入流程。 |
 | 组合与渲染 | `client/ui-slots`、`client/ui-renderer`、`client/ui-layout`、各 UI 功能包 | 声明扩展位置、推导组件 props、把 observable 绑定成 React hook,并挂载最终组件树。 |
 
@@ -37,9 +37,9 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
 
 ### Sessions
 
-[`api/session-controller`](../../packages/api/session-controller/README.zh.md)公开 Session list、search、creation、selection data、prompt、queue、cancellation、pagination 及 follow/control stream 等 Host command。其 Client 侧按 `ClientSessions → SessionManager → Session` 组织:
+[`api/session-controller`](../../packages/api/session-controller/README.zh.md)公开 Session list、search、creation、prompt、queue、cancellation、pagination 及 follow/control stream 等 Host command。其 Client 侧按 `ClientSessions → SessionManager → Session` 组织:
 
-- `ClientSessions` 提供 `ctx.sessions`,拥有 Session scope 与稳定的 `SessionBinding` object,并投影选中的 list state。
+- `ClientSessions` 提供 `ctx.sessions`,拥有 reference、source count、Session scope 与稳定的 `SessionBinding` object,并投影不含全局 current Session 选择的 catalog state。
 - `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。
 - 每个 `Session` 拥有一段由 `SessionEventLikeEntry` value 表示的连续逻辑 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
 
@@ -53,7 +53,7 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
 
 ## Conversation 与 presentation
 
-`ui-session` 安装 `session` scope adapter,并提供 `useSessions`、`useSession`、`sessionId` 和 `useProjection`。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
+`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
 
 `ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把持久 Session event 与 Client-only `assistant/live-chunk` update 关联成稳定的业务 Context,view registry 则 materialize target snapshot。Chat Assistant、Trajectory Assistant 与 Turn Tail 同时解释 live chunk 和持久 settlement 中嵌入的紧凑 stream,因此重连与分页历史无需持久 token 行即可复现相同 Assistant 状态。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。
 

+ 2 - 2
packages/api/gateway/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/gateway/README.md
-README.md: 68d51adb8f801d35ca9bc44cc449aed66c3b5c2d
-README.zh.md: 128db2e88267a7ad10fc26a6c1c22274ad51aed8
+README.md: 1460f5a27062ffcac09c68358dc7b186241a1baa
+README.zh.md: 79ed379faad8498cfdf249a0accb437f6d5f98e3

+ 2 - 0
packages/api/gateway/README.md

@@ -53,6 +53,8 @@ Every unary call resolves to `RemoteResult<T>` — `{ ok: true, value }` or `{ o
 
 `ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it under continuous, capped jittered exponential backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier.
 
+Client waterfall Context resolution stays synchronous. A resolver can return a borrowed Context or `TypertOwnedValue<Context>`; Gateway releases the owned value only after handler use and reply settlement. Context-resolution failures retain the existing report-and-delegate behavior, while handler failures produce rejected replies. Cancellation suppresses late replies without releasing a Context still used by the handler. Every handler must observe `request.signal` and settle after cancellation; plugin disposal and Connection generation replacement wait for outstanding handlers to settle. Session Context acquisition itself performs no history I/O.
+
 `ctx.remote` exposes no Connection lifecycle control. A consumer whose responsibility includes recovery reads `ctx.connection.state` and calls `ctx.connection.reconnect()` directly; ordinary Remote consumers stay on generated namespaces and `$stream()`.
 
 Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.

+ 2 - 0
packages/api/gateway/README.zh.md

@@ -53,6 +53,8 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source
 
 `ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall(瀑布式事件)允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。
 
+Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 Context 或 `TypertOwnedValue<Context>`;Gateway 仅在处理器使用和回复结算均结束后释放 owned value。Context 解析失败保留既有的记录错误并委托语义,处理器失败产生拒绝回复。取消会抑制迟到回复,但不会释放处理器仍在使用的 Context。每个 handler 都必须响应 `request.signal` 并在取消后结束;插件销毁与 Connection generation 替换会等待未结束的 handler 结算。Session Context 的获取本身不执行历史 I/O。
+
 `ctx.remote` 不暴露 Connection 生命周期控制。只有职责包含恢复的消费方才直接读取 `ctx.connection.state` 并调用 `ctx.connection.reconnect()`;普通 Remote 消费方仍只使用生成的 namespace 与 `$stream()`。
 
 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。

+ 38 - 46
packages/api/gateway/src/client/remote-events.ts

@@ -8,8 +8,10 @@ import type {
 } from '@deepseek-ai/dsh-client-connection/client'
 import type {
   TypertClientEventListener,
+  TypertOwnedValue,
   TypertRemoteEvent,
 } from '@deepseek-ai/dsh-typert-protocol'
+import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
 import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 import {
   REMOTE_EVENT_RESULT_ENDPOINT,
@@ -188,36 +190,42 @@ export class ClientRemoteEvents {
     signal: AbortSignal,
   ): Promise<void> {
     const adapter = this.ownerCtx.typert.contexts.getClient('agent')
-    let target: Context | undefined
+    let resolved: Context | TypertOwnedValue<Context> | undefined
     try {
-      target = adapter?.resolve(frame.agentId)
+      resolved = adapter?.resolve(frame.agentId)
     } catch (error) {
       this.reportError(frame.event, error)
     }
-    let outcome: RemoteEventReplyOutcome = { kind: 'next' }
-    if (target !== undefined) {
-      try {
-        outcome = await this.dispatchWaterfall(target, frame, signal)
-      } catch (error) {
-        if (signal.aborted) return
-        outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) }
+    const owned = isTypertOwnedValue(resolved) ? resolved : undefined
+    try {
+      const target = isTypertOwnedValue(resolved) ? resolved.value : resolved
+      let outcome: RemoteEventReplyOutcome = { kind: 'next' }
+      if (target !== undefined) {
+        try {
+          outcome = await this.dispatchWaterfall(target, frame, signal)
+        } catch (error) {
+          if (signal.aborted) return
+          outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) }
+        }
       }
+      if (signal.aborted) return
+      const result: RemoteEventResult = {
+        clientId,
+        eventId: frame.eventId,
+        outcome: outcome.kind === 'result' && outcome.value === undefined
+          ? { kind: 'result' }
+          : outcome,
+      }
+      const response = await this.connection.rpc.call(
+        '/api',
+        REMOTE_EVENT_RESULT_ENDPOINT,
+        { args: result },
+        signal,
+      )
+      if (!response.ok) throw new Error(response.error.message)
+    } finally {
+      owned?.[Symbol.dispose]()
     }
-    if (signal.aborted) return
-    const result: RemoteEventResult = {
-      clientId,
-      eventId: frame.eventId,
-      outcome: outcome.kind === 'result' && outcome.value === undefined
-        ? { kind: 'result' }
-        : outcome,
-    }
-    const response = await this.connection.rpc.call(
-      '/api',
-      REMOTE_EVENT_RESULT_ENDPOINT,
-      { args: result },
-      signal,
-    )
-    if (!response.ok) throw new Error(response.error.message)
   }
 
   private async dispatchWaterfall(
@@ -230,14 +238,12 @@ export class ClientRemoteEvents {
       agent: target,
       signal,
     }
-    const value = await abortable(
-      Promise.resolve(privateEvents(target).waterfall(
-        target,
-        this.eventKey(frame.event),
-        request,
-        () => Promise.resolve(REMOTE_EVENT_NEXT),
-      )),
-      signal,
+    // Cancellation reaches the handler, whose Context remains owned until it settles.
+    const value = await privateEvents(target).waterfall(
+      target,
+      this.eventKey(frame.event),
+      request,
+      () => Promise.resolve(REMOTE_EVENT_NEXT),
     )
     if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) {
       throw new TypeError('Remote event listener result is not lossless JSON data')
@@ -327,20 +333,6 @@ function invalidRemoteEventFrame(): never {
   throw new TypeError('client api: invalid forwarded Remote event frame')
 }
 
-/** Race listener completion against its delivery lifetime. */
-async function abortable<T>(value: T | PromiseLike<T>, signal: AbortSignal): Promise<T> {
-  signal.throwIfAborted()
-  let rejectAbort: ((reason: unknown) => void) | undefined
-  const aborted = new Promise<never>((_resolve, reject) => { rejectAbort = reject })
-  const onAbort = (): void => { rejectAbort?.(signal.reason) }
-  signal.addEventListener('abort', onAbort, { once: true })
-  try {
-    return await Promise.race([Promise.resolve(value), aborted])
-  } finally {
-    signal.removeEventListener('abort', onAbort)
-  }
-}
-
 function privateEvents(ctx: Context): PrivateEventContext {
   return ctx
 }

+ 80 - 5
packages/api/gateway/tests/gateway.client.spec.ts

@@ -1,4 +1,4 @@
-import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError, typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
 import { Context, Service } from '@deepseek-ai/cordis'
 import type { Fiber } from '@deepseek-ai/cordis'
 import { describe, expect, expectTypeOf, it, vi } from 'vitest'
@@ -1640,6 +1640,83 @@ describe('Client Typert API', () => {
     await client.dispose()
   })
 
+  it('holds an owned Context through handler and reply settlement', async () => {
+    const replyEntered = Promise.withResolvers<undefined>()
+    const reply = Promise.withResolvers<Awaited<ReturnType<ConnectionHandle['rpc']['call']>>>()
+    const call = vi.fn<ConnectionHandle['rpc']['call']>(() => {
+      replyEntered.resolve(undefined)
+      return reply.promise
+    })
+    const { ctx, client, carrier } = await eventBench(call)
+    const target = ctx.extend()
+    const release = vi.fn()
+    const entered = Promise.withResolvers<undefined>()
+    const handler = Promise.withResolvers<undefined>()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('owned-context') : undefined,
+      resolve: () => typertOwnedValue(target, release),
+    })
+    target.remote.$on('fixture/approval', async () => {
+      entered.resolve(undefined)
+      await handler.promise
+      expect(release).not.toHaveBeenCalled()
+      return 'allowed'
+    })
+    try {
+      carrier.emit(approvalFrame('owned-event', 'owned-context', 'wait'))
+      await entered.promise
+      expect(release).not.toHaveBeenCalled()
+      handler.resolve(undefined)
+      await replyEntered.promise
+      expect(release).not.toHaveBeenCalled()
+      reply.resolve({ ok: true, value: undefined })
+      await vi.waitFor(() => { expect(release).toHaveBeenCalledOnce() })
+    } finally {
+      handler.resolve(undefined)
+      reply.resolve({ ok: true, value: undefined })
+      await client.dispose()
+    }
+  })
+
+  it.each(['success', 'failure'] as const)('keeps cancelled Context ownership through handler %s and joins disposal', async (outcome) => {
+    const { ctx, client, carrier, call } = await eventBench()
+    const target = ctx.extend()
+    const release = vi.fn()
+    const entered = Promise.withResolvers<AbortSignal>()
+    const handler = Promise.withResolvers<undefined>()
+    ctx.typert.contexts.registerClient('agent', {
+      identity: candidate => candidate === target ? agentId('owned-cancelled') : undefined,
+      resolve: () => typertOwnedValue(target, release),
+    })
+    target.remote.$on('fixture/approval', async (request) => {
+      if (request.signal === undefined) throw new Error('expected invocation cancellation')
+      entered.resolve(request.signal)
+      await handler.promise
+      expect(release).not.toHaveBeenCalled()
+      return 'allowed'
+    })
+    let disposal: Promise<void> | undefined
+    try {
+      carrier.emit(approvalFrame('owned-cancel-event', 'owned-cancelled', 'wait'))
+      const signal = await entered.promise
+      carrier.emit({ type: 'cancel', eventId: 'owned-cancel-event' })
+      await vi.waitFor(() => { expect(signal.aborted).toBe(true) })
+      expect(release).not.toHaveBeenCalled()
+      let disposed = false
+      disposal = client.dispose().then(() => { disposed = true })
+      expect(disposed).toBe(false)
+      if (outcome === 'failure') handler.reject(new Error('cancelled handler failed'))
+      else handler.resolve(undefined)
+      await disposal
+      expect(release).toHaveBeenCalledOnce()
+      expect(call).not.toHaveBeenCalled()
+    } finally {
+      handler.resolve(undefined)
+      await disposal
+      await client.dispose()
+    }
+  })
+
   it('fails the Connection generation when a result RPC is rejected', async () => {
     const call = vi.fn<ConnectionHandle['rpc']['call']>().mockResolvedValue({
       ok: false,
@@ -1835,13 +1912,11 @@ describe('Client Typert API', () => {
     carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait'))
     const deliverySignal = await entered.promise
 
-    release.resolve(undefined)
     carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' })
     await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) })
-    await Promise.resolve()
-    expect(call).not.toHaveBeenCalled()
-
+    release.resolve(undefined)
     await client.dispose()
+    expect(call).not.toHaveBeenCalled()
   })
 
   it('cancels pending listener work when the generation ends', async () => {

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: a59f7dfc6e342b6eecf3aa60a7350a19e6daaf70
-README.zh.md: 986e75fdfd18a2b07fb208a480592c05b127aee7
+README.md: f67355be0cb9737ebc67bf589528878bccc26e6d
+README.zh.md: e52501c145c3789213aec517b0801e5e7ee2a4cb

Різницю між файлами не показано, бо вона завелика
+ 1 - 0
packages/api/session-controller/README.md


Різницю між файлами не показано, бо вона завелика
+ 1 - 0
packages/api/session-controller/README.zh.md


+ 59 - 20
packages/api/session-controller/src/client/contract/sessions.ts

@@ -14,13 +14,64 @@ import type { SessionSearchResultItem } from '../sessions/manager.ts'
 import type { SessionBinding, SessionListState } from '../sessions/service.ts'
 import type { SessionFace } from './session.ts'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { SessionReferenceSource } from '../index.ts'
 
 export type { AgentContext } from '../scope.ts'
 
+/** Known Session identity or durable direct-parent subagent address; an address owns no lifetime. */
+export type SessionTarget = SessionId | SubagentAddress
+
+/** One independent use of an exact Client generation, without Host Agent ownership. */
+export interface SessionReference extends Disposable {
+  readonly sessionId: SessionId
+  /** Shared binding; access fails after reference release or generation disposal. */
+  readonly binding: SessionBinding
+  /** This reference's cancellable wait for the shared initial `Session.open()` attempt to settle. */
+  readonly ready: Promise<SessionBinding>
+  /** Release once; the final reference starts local scope and history teardown. */
+  release(): void
+}
+
+/** Consumer identity and optional cancellation of one acquisition waiter. */
+export interface SessionRetainOptions {
+  readonly source: SessionReferenceSource
+  readonly signal?: AbortSignal | undefined
+}
+
+/** Local ownership counts, independent of catalog membership and never persisted. */
+export interface SessionRetainInfo {
+  readonly referenceCount: number
+  /** Positive source counts only; a source without references is absent. */
+  readonly retainedBy: Readonly<Partial<Record<SessionReferenceSource, number>>>
+}
+
 /** The sessions-service face injected as `ctx.sessions`. */
 export interface ISessions {
-  /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
+  /** Host catalog and local reference-source counts; navigation belongs to view owners. */
   readonly list: ObservableSnapshot<SessionListState>
+  /**
+   * Retain an exact Client generation and start its shared initial history opening.
+   * @param target - known identity or durable direct-parent address.
+   * @param options - required consumer source and optional independent waiter cancellation.
+   * @returns an owned reference immediately; await `reference.ready` when the initial open attempt must settle first.
+   */
+  retain(target: SessionTarget, options: SessionRetainOptions): SessionReference
+  /**
+   * Hold one reference through callback settlement, including synchronous and asynchronous failures.
+   * @param target - Session to acquire.
+   * @param options - source and acquisition cancellation.
+   * @param operation - callback using the reference only until its returned value or Promise settles.
+   * @returns the callback result after release; acquisition and callback failures propagate unchanged.
+   */
+  using<T>(target: SessionTarget, options: SessionRetainOptions, operation: (reference: SessionReference) => T | Promise<T>): Promise<T>
+  /**
+   * Observe local reference counts without retaining, creating a scope, or opening history.
+   * The returned source keeps stable identity across same-id generations and remains allocated
+   * until the Client root is disposed, even after its final subscriber leaves.
+   * @param id - explicit Session identity; Host existence is not implied.
+   * @returns a stable read-only source across same-id generations, with zero counts when none is live.
+   */
+  retainInfo(id: SessionId): ObservableSnapshot<SessionRetainInfo>
   /**
    * The `session.search` result bound the wire schema fixes, exposed to
    * presentation as injected data. Not per-connection state: every transport
@@ -30,23 +81,13 @@ export interface ISessions {
   /**
    * Create or adopt a Session on the Host.
    * @param opts - target workspace, directory, and optional preallocated identity.
-   * @returns the Session identity after its local binding is addressable.
+   * @returns the catalogued identity; retain it before borrowing its binding.
    */
   create(opts?: {
     workspaceId?: WorkspaceId
     cwd?: string
     sessionId?: SessionId
   }): Promise<SessionId>
-  /**
-   * Select a session as current.
-   * @param id - session id (must exist in the list; unknown ids fail loud).
-   */
-  open(id: SessionId): void
-  /**
-   * Open a healthy catalog child through its exact direct-parent address.
-   * @param address - catalog-derived parent and child ids.
-   */
-  openSubagent(address: SubagentAddress): void
   /**
    * Resolve an already discovered direct-parent address without opening it.
    * @param id - possible addressed child id.
@@ -66,8 +107,6 @@ export interface ISessions {
    */
   refreshSubagents(parentSessionId: SessionId): Promise<void>
 
-  /** Clear the current selection into the no-session view state. */
-  clear(): void
   /**
    * Refresh the Host-authoritative Session list.
    * @returns completion of the current or newly started Session-list refresh.
@@ -86,7 +125,7 @@ export interface ISessions {
   ): Promise<RemoteResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
   /**
    * Fork a session from a completed-turn prefix of the source; on resolution
-   * the child is in the list store and `open()` can target it.
+   * the child is in the catalog and may be explicitly retained.
    * @param opts - source session id, the optional event seq anchoring the
    *   cut (the boundary is the first turn/end at or after it; an in-log
    *   anchor in an open turn is unavailable rather than clipped backward),
@@ -96,9 +135,9 @@ export interface ISessions {
    */
   fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
   /**
-   * Resolve an Agent-scoped context view (use-and-discard).
+   * Borrow an already-retained Agent-scoped Context without extending its lifetime.
    * @param id - session id.
-   * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
+   * @returns the live scoped Context, or undefined without a retained generation.
    */
   scope(id: SessionId): AgentContext | undefined
   /**
@@ -111,13 +150,13 @@ export interface ISessions {
   /**
    * Resolve the session face behind an Agent-scoped context.
    * @param ctx - an Agent-scoped context.
-   * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
+   * @returns the matching live Session, or undefined for an untagged, foreign, or ended generation.
    */
   sessionOf(ctx: Context): SessionFace | undefined
   /**
-   * Resolve the stable session binding (scope-addressed assembly feed).
+   * Borrow an already-retained Session binding without extending its lifetime.
    * @param id - session id.
-   * @returns binding, or undefined for a session neither listed nor already scoped.
+   * @returns the live binding, or undefined without a retained generation.
    */
   binding(id: SessionId): SessionBinding | undefined
 }

+ 20 - 3
packages/api/session-controller/src/client/index.ts

@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent/types'
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 import type {} from '@deepseek-ai/dsh-client-file-upload/client'
+import { typertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
 import { createSessionControlStream } from './transport.ts'
 import { ClientSessions } from './sessions/service.ts'
 import type { SessionRemotes } from './sessions/remotes.ts'
@@ -48,7 +49,9 @@ export type {
   SessionFace,
   SubmissionHandle,
 } from './contract/session.ts'
-export type { ISessions } from './contract/sessions.ts'
+export type {
+  ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget,
+} from './contract/sessions.ts'
 export { MutableSessionEventSource } from './contract/events.ts'
 export type {
   AssistantLiveChunkEvent,
@@ -73,6 +76,17 @@ export type {
   SessionSnapshot,
 } from './contract/snapshot.ts'
 
+/** Consumer-owned reference labels; extend this map through the package's canonical /client entry. */
+export interface SessionReferenceSourceMap {
+  /** Temporary Client Controller work, including fork-title preparation. */
+  controllerOperation: unknown
+  /** A Client Gateway invocation's synchronous Context ownership. */
+  gateway: unknown
+}
+
+/** Declaration-merge-extensible labels carried by independent Client references. */
+export type SessionReferenceSource = Extract<keyof SessionReferenceSourceMap, string>
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Client Session object layer and Agent scope owner. */
@@ -125,8 +139,11 @@ export function apply(ctx: Context): void {
   ctx.effect(() => connection.generation.subscribe(connected), 'session-controller.client.generation')
   connected()
   ctx.typert.contexts.registerClient('agent', {
-    identity: candidate => sessions.scopeOf(candidate),
-    resolve: sessionId => sessions.resolveAgentScope(sessionId),
+    identity: candidate => sessions.sessionOf(candidate)?.sessionId,
+    resolve: (sessionId) => {
+      const reference = sessions.retainAgentScope(sessionId)
+      return typertOwnedValue(reference.binding.ctx, () => { reference.release() })
+    },
   })
   ctx.effect(() => async () => { await control.dispose() }, 'session-controller.client.control')
 }

+ 22 - 24
packages/api/session-controller/src/client/scope.ts

@@ -1,20 +1,4 @@
-/**
- * Client Agent-scope primitive: mint a Cordis context tagged with the owning
- * Agent's identity. The mechanism mirrors the host `dsh-scope` architecture
- * (no-op plugin fiber + context tag + `Context.filter` routing predicate);
- * the shape deliberately diverges: the filter lives on the actx itself
- * instead of a separate carrier object, so scoped dispatch is plain cordis —
- * `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no
- * wrapper. The host needs a detached carrier because its dispatch subject is
- * the business Agent object; client scope events carry only ids, so the
- * actx is the natural subject. The second divergence stands: the scope key
- * is the branded `SessionId` (value compared), not an object identity — the
- * agent and its session share one id (1:1, same axis; no separate AgentId
- * brand), and a client scope's identity IS that wire id. Third divergence,
- * deliberate: the client scopes the Agent IDENTITY, not a live Agent object
- * — a cold session's host Agent is already disposed while its client actx
- * stays alive for history viewing.
- */
+/** Client scope generations route local events independently of Host Agent residency. */
 import { Context as CordisContext } from '@deepseek-ai/cordis'
 import type { Context, Fiber } from '@deepseek-ai/cordis'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
@@ -29,11 +13,15 @@ export type AgentContext = Omit<Context, 'remote'> & {
 /** Context tag written by {@link createScope}. */
 const kScope = Symbol('dsh.client.scope')
 
+interface ScopeIdentity {
+  readonly sessionId: SessionId
+}
+
 /** A minted Agent scope and its disposal boundary. */
 export interface AgentScopeHandle {
   /**
    * Tagged context: scope-owned registrations and scoped dispatch both go
-   * through it (passing it as the dispatch subject routes to this agent's
+   * through it (passing it as the dispatch subject routes to this generation's
    * tagged listeners plus every untagged one).
    */
   ctx: AgentContext
@@ -47,19 +35,20 @@ function agentScope(): void {}
 /**
  * Mint an Agent scope under `ctx`: a no-op plugin fiber whose context
  * carries the agent tag and the dispatch filter — untagged listeners are
- * admitted globally, tagged listeners only for a matching agent.
+ * admitted globally, tagged listeners only for the same Client generation.
  * Registrations through the returned ctx dispose with the fiber.
  * @param ctx - client root context the scope fiber mounts under.
- * @param key - owning agent identity (the routing tag; agent id === session id).
+ * @param key - durable Session identity carried by this generation.
  * @returns the tagged context and its backing fiber.
  */
 export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
   const fiber = ctx.plugin(agentScope)
+  const identity: ScopeIdentity = { sessionId: key }
   const scoped = fiber.ctx.extend({
-    [kScope]: key,
+    [kScope]: identity,
     [CordisContext.filter](listenerCtx: Context): boolean {
-      const tag = scopeOf(listenerCtx)
-      return tag === undefined || tag === key
+      const tag = scopeIdentityOf(listenerCtx)
+      return tag === undefined || tag === identity
     },
   }) as AgentContext
   return {
@@ -74,5 +63,14 @@ export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
  * @returns its agent identity (the session id), or undefined for root contexts.
  */
 export function scopeOf(ctx: Context): SessionId | undefined {
-  return (ctx as Context & { [kScope]?: SessionId })[kScope]
+  return scopeIdentityOf(ctx)?.sessionId
+}
+
+/**
+ * Read the exact generation identity inherited by a Client Context.
+ * @param ctx - scoped or root Client Context.
+ * @returns the generation identity, or undefined for an unscoped Context.
+ */
+export function scopeIdentityOf(ctx: Context): ScopeIdentity | undefined {
+  return (ctx as Context & { [kScope]?: ScopeIdentity })[kScope]
 }

+ 0 - 5
packages/api/session-controller/src/client/sessions/lineage.ts

@@ -27,8 +27,6 @@ export interface SessionListEntry {
   cwd?: string
   /** Current host-computed projection values for list consumers. */
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
-  /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
-  completed: boolean
   /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
   depth: number
 }
@@ -38,12 +36,10 @@ export interface SessionListEntry {
  * follows the established input order; this projection never re-sorts a
  * hydrated list from mutable timestamps.
  * @param summaries - the host's session.list items.
- * @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
  * @returns display rows in render order.
  */
 export function flattenLineage(
   summaries: readonly TitledSessionSummary[],
-  completed?: ReadonlySet<SessionId>,
 ): SessionListEntry[] {
   const byId = new Map<SessionId, TitledSessionSummary>()
   for (const s of summaries) byId.set(s.sessionId, s)
@@ -70,7 +66,6 @@ export function flattenLineage(
     visited.add(s.sessionId)
     out.push({
       ...s,
-      completed: completed?.has(s.sessionId) ?? false,
       depth,
     })
     const kids = children.get(s.sessionId)

+ 42 - 145
packages/api/session-controller/src/client/sessions/manager.ts

@@ -1,6 +1,4 @@
-// SessionManager: the instance cluster Map<SessionId, Session> (lazy-built, resident) + the frame
-// dispatch entry + list state, constructed and held by ClientSessions (one per browser client).
-// List data never enters zustand; React connects via subscribe/getListSnapshot.
+/** Host catalog, durable projection caches, and explicitly retained Client instances. */
 
 import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
 import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
@@ -24,6 +22,7 @@ import { Notifier } from './notifier.ts'
 import { ProjectionValueStore } from './projection-store.ts'
 import { Session } from './session.ts'
 import type { SessionRemotes } from './remotes.ts'
+import type { SessionTarget } from '../contract/sessions.ts'
 
 function sessionSeqCursor(value: number): SessionSeqCursor {
   return value === -1 ? -1 : SessionSeq(value)
@@ -48,8 +47,6 @@ export interface SessionSearchResultItem {
 /** Immutable session-list snapshot for useSessionList. */
 export interface SessionListSnapshot {
   items: readonly SessionListEntry[]
-  /** Selected Session id (validated against items; masked to undefined while its session is off the list). */
-  current: SessionId | undefined
   state: 'idle' | 'loading' | 'error'
   /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
   phase: SessionListPhase
@@ -57,7 +54,6 @@ export interface SessionListSnapshot {
   subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
   /** Background jobs per session; an absent key is an empty set. */
   jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
-  currentAddress: SubagentAddress | undefined
 }
 
 /** One parent-addressed durable catalog projected through the sessions snapshot. */
@@ -95,14 +91,6 @@ export class SessionManager {
   private readonly sessions = new Map<SessionId, Session>()
   /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */
   private readonly sessionDisposals = new Set<Promise<void>>()
-  /**
-   * Sessions that finished running while not selected — the sidebar's green
-   * "done" reminder (manager-owned, survives connection generations; cleared
-   * on select and session-removed, re-armed by the next completion).
-   */
-  private readonly completedNotifications = new Set<SessionId>()
-  /** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */
-  private readonly prevRunning = new Map<SessionId, boolean>()
   /** Per-session projection value stores, retained independently of instance arrival (the
    *  title-snapshot precedent, generalized): push frames land here whether or not the Session
    *  is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
@@ -130,8 +118,6 @@ export class SessionManager {
    */
   private readonly jobsBySession = new Map<SessionId, readonly JobView[]>()
 
-  private selected: SessionId | undefined
-
   private listSnapshotCache: SessionListSnapshot
   /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry
    *  object when every field matches — wire refreshes mint all-new summary objects, so identity
@@ -142,67 +128,31 @@ export class SessionManager {
     this.listSnapshotCache = this.buildListSnapshot()
   })
 
-  /**
-   * @param remote - generated Remote namespaces the Session cluster calls.
-   * @param restoredSelection - persisted real-Session selection candidate.
-   */
-  constructor(
-    private readonly remote: SessionRemotes,
-    restoredSelection?: SessionId,
-    restoredAddress?: SubagentAddress,
-  ) {
-    this.selected = restoredSelection
-    if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress)
+  /** @param remote - generated Remote namespaces used by catalog and history readers. */
+  constructor(private readonly remote: SessionRemotes) {
     this.listSnapshotCache = this.buildListSnapshot()
   }
 
-  // ---- Selection ----
-
   /**
-   * Select a listed Session or a retained catalog-addressed child.
-   * @param sessionId - listed or catalog-addressed Session id.
+   * Resolve an acquisition target without materializing a Session.
+   * @param target - known identity or durable direct-parent address.
+   * @returns the resolved identity with its explicit or catalog-derived history route installed.
    */
-  select(sessionId: SessionId): void {
-    const address = this.navigationAddress(sessionId)
-    if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
-      throw new Error(`sessions.select: unknown session ${sessionId}`)
+  resolveTarget(target: SessionTarget): SessionId {
+    const id = typeof target === 'string' ? target : target.childSessionId
+    const address = typeof target === 'string' ? this.navigationAddress(id) : target
+    if (typeof target === 'string'
+      && !this.sessions.has(id)
+      && !this.summaries.some(summary => summary.sessionId === id)
+      && address === undefined) {
+      throw new Error(`sessions.retain: unknown session ${id}`)
     }
-    if (address !== undefined) this.addresses.set(sessionId, address)
-    this.sessions.get(sessionId)?.configureSubagent(
+    if (address !== undefined) this.addresses.set(id, address)
+    this.sessions.get(id)?.configureSubagent(
       address,
-      address === undefined
-        ? undefined
-        : this.catalogs.get(address.parentSessionId)?.parentAvailable,
+      address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.parentAvailable,
     )
-    this.selected = sessionId
-    // Looking at the session consumes its completion reminder (dot clears).
-    this.completedNotifications.delete(sessionId)
-    void this.refreshSubagents(sessionId)
-    this.notifier.notifyNow()
-  }
-
-  /**
-   * Select a healthy child through its durable direct-parent address.
-   * @param address - catalog-derived parent and child ids.
-   */
-  selectSubagent(address: SubagentAddress): void {
-    const catalog = this.catalogs.get(address.parentSessionId)
-    const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId)
-    if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
-      throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
-    }
-    this.addresses.set(address.childSessionId, address)
-    this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable)
-    this.selected = address.childSessionId
-    this.completedNotifications.delete(address.childSessionId)
-    void this.refreshSubagents(address.childSessionId)
-    this.notifier.notifyNow()
-  }
-
-  /** Clear the selection (the layout falls to the no-session view state). */
-  clearSelection(): void {
-    this.selected = undefined
-    this.notifier.notifyNow()
+    return id
   }
 
   /**
@@ -211,7 +161,7 @@ export class SessionManager {
    * @returns The direct-parent address, when navigation discovered one.
    */
   subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
-    return this.addresses.get(sessionId)
+    return this.navigationAddress(sessionId)
   }
 
   /**
@@ -234,20 +184,22 @@ export class SessionManager {
   // ---- Instance management ----
 
   /**
-   * Drop a session instance (scope-prune companion: instance
-   * and scope share one lifecycle). The host session log is the durable
-   * truth — a later get() lazily rebuilds and open() backfills history.
-   * @param sessionId - the session to drop.
+   * Withdraw an exact Client instance before running its teardown callbacks.
+   * @param sessionId - identity to withdraw.
+   * @param expected - instance being released; a replacement is left untouched.
+   * @returns completion of the detached instance's stream teardown.
    */
-  async drop(sessionId: SessionId): Promise<void> {
+  drop(sessionId: SessionId, expected: Session): Promise<void> {
     const session = this.sessions.get(sessionId)
+    if (session !== expected) return Promise.resolve()
     this.sessions.delete(sessionId)
-    if (session !== undefined) await this.startSessionDisposal(session)
+    this.addresses.delete(sessionId)
+    return this.startSessionDisposal(session)
   }
 
   /**
-   * Stop owned timers and every remaining Session instance.
-   * @returns when every Session Remote iterator has completed teardown.
+   * Stop catalog requests and dispose every resident Session.
+   * @returns once catalog requests and every Session stream have stopped.
    */
   async dispose(): Promise<void> {
     for (const timer of this.catalogDebounce.values()) clearTimeout(timer)
@@ -256,6 +208,7 @@ export class SessionManager {
     this.openCatalogs.clear()
     const sessions = [...this.sessions.values()]
     this.sessions.clear()
+    this.addresses.clear()
     for (const session of sessions) void this.startSessionDisposal(session)
     await this.drainSessionDisposals()
   }
@@ -278,7 +231,7 @@ export class SessionManager {
 
   /**
    * Lazy build: return the existing instance or construct one (no auto-open —
-   * open is triggered by the container's select callback).
+   * the reference allocator opens history after binding the scope).
    * @param sessionId - the session to get.
    * @returns the resident instance.
    */
@@ -458,25 +411,9 @@ export class SessionManager {
           const baseline: SessionSummary[] = this.listPhase === 'pending'
             ? [...result.value.items]
             : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
-          // Seed first observations from the pull-time baseline BEFORE replaying
-          // in-flight mutations, then reconcile the reminders after EVERY
-          // replayed mutation: an edge that happens entirely between mutations
-          // (baseline idle → running → idle) must still arm, which a single
-          // sync on the folded result would collapse away.
-          for (const s of baseline) {
-            if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running)
-          }
-          let summaries = baseline
-          for (const mutation of mutations) {
-            summaries = applyMutation(summaries, mutation)
-            this.summaries = summaries
-            this.syncCompletedNotifications()
-          }
-          this.summaries = summaries
+          this.summaries = mutations.reduce(applyMutation, baseline)
           this.listState = 'idle'
           this.listPhase = 'ready'
-          // Covers the empty-mutations pull (a plain baseline carries no edge).
-          this.syncCompletedNotifications()
           // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
           for (const s of this.summaries) {
             const session = this.sessions.get(s.sessionId)
@@ -624,8 +561,6 @@ export class SessionManager {
   private recordMutation(mutation: SessionListMutation): void {
     this.listMutations?.push(mutation)
     this.summaries = applyMutation(this.summaries, mutation)
-    // Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames.
-    this.syncCompletedNotifications()
     this.notifier.markDirty()
   }
 
@@ -702,7 +637,7 @@ export class SessionManager {
       this.markCatalogParentExpandable(summary.parentSessionId)
     }
     if (summary.parentSessionId !== undefined
-      && (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) {
+      && this.openCatalogs.has(summary.parentSessionId)) {
       this.scheduleCatalogRefresh(summary.parentSessionId)
     }
   }
@@ -778,13 +713,15 @@ export class SessionManager {
     this.listMutations = null
     this.listInflight = null
     void this.refreshList()
-    const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
-    if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
-    if (this.selected !== undefined) void this.refreshSubagents(this.selected)
-    for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
+    const parents = new Set(this.openCatalogs)
+    for (const id of this.sessions.keys()) {
+      const address = this.addresses.get(id)
+      if (address !== undefined) parents.add(address.parentSessionId)
+    }
+    for (const parentSessionId of parents) void this.refreshSubagents(parentSessionId)
   }
 
-  /** Debounce membership refetches while one parent catalog is selected or open. */
+  /** Debounce membership refetches for an explicitly consumed catalog. */
   private scheduleCatalogRefresh(parentSessionId: SessionId): void {
     if (this.catalogDebounce.has(parentSessionId)) return
     const timer = setTimeout(() => {
@@ -861,38 +798,6 @@ export class SessionManager {
     })
   }
 
-  /**
-   * Reconcile completion reminders against the latest summaries, eagerly after
-   * every mutation and pull (a snapshot-build-time pass would collapse
-   * consecutive status frames into one observation). A running→idle edge of a
-   * non-selected session arms its reminder; running disarms it; removal drops
-   * it. First observation only records the running bit — sessions already
-   * idle at load get no reminder.
-   */
-  private syncCompletedNotifications(): void {
-    const seen = new Set<SessionId>()
-    for (const s of this.summaries) {
-      seen.add(s.sessionId)
-      const prev = this.prevRunning.get(s.sessionId)
-      if (prev === undefined) {
-        this.prevRunning.set(s.sessionId, s.running)
-        continue
-      }
-      if (prev && !s.running) {
-        if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId)
-      } else if (s.running) {
-        this.completedNotifications.delete(s.sessionId)
-      }
-      this.prevRunning.set(s.sessionId, s.running)
-    }
-    for (const id of this.prevRunning.keys()) {
-      if (!seen.has(id)) this.prevRunning.delete(id)
-    }
-    for (const id of this.completedNotifications) {
-      if (!seen.has(id)) this.completedNotifications.delete(id)
-    }
-  }
-
   private buildListSnapshot(): SessionListSnapshot {
     const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
       // List rows read the generic 'title' projection key (host-computed unit
@@ -906,7 +811,7 @@ export class SessionManager {
         ...(projectionValues === undefined ? {} : { projectionValues }),
       }
     })
-    const fresh = flattenLineage(merged, this.completedNotifications)
+    const fresh = flattenLineage(merged)
     const items = fresh.map((entry) => {
       const prev = this.entryCache.get(entry.sessionId)
       if (
@@ -915,7 +820,6 @@ export class SessionManager {
         && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
         && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
         && prev.projectionValues === entry.projectionValues
-        && prev.completed === entry.completed
       ) return prev
       this.entryCache.set(entry.sessionId, entry)
       return entry
@@ -926,20 +830,13 @@ export class SessionManager {
     }
     const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
     if (!sameOrder) this.itemsCache = items
-    const selected = this.selected
-    const current = selected !== undefined
-      && (itemIds.has(selected) || this.addresses.has(selected))
-      ? selected
-      : undefined
     return {
       items: this.itemsCache,
-      current,
       state: this.listState,
       phase: this.listPhase,
       error: this.listError,
       subagentsByParent: Object.fromEntries(this.catalogs),
       jobsBySession: Object.fromEntries(this.jobsBySession),
-      currentAddress: current === undefined ? undefined : this.addresses.get(current),
     }
   }
 }

+ 270 - 274
packages/api/session-controller/src/client/sessions/service.ts

@@ -1,19 +1,4 @@
-/**
- * ClientSessions: root sessions service — list snapshot store (manager
- * projection; carries `current`, the persisted selection every
- * session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
- * Fiber + ctx.extend scope tag; one scope per session, agent id === session
- * id), stable SessionBinding cache, breadcrumb-route projection.
- *
- * Scope lifecycle is stage-driven: a scope is minted lazily on first
- * resolution (pure — resolution has no side effects and is render-safe);
- * the event window and deferred teardown key off the STAGED session, which
- * follows `list.current` exactly. Staging is the open signal: the window
- * opens ⟺ the session is on stage (the stage is `current`; the staged
- * state can widen to a multi-pane list later). A session leaving the list
- * tears its scope down immediately unless it is the staged one, whose scope
- * survives frozen (read-only view) until the stage moves on.
- */
+/** Client catalog and source-labelled ownership of exact Session generations. */
 import type { Context, Fiber } from '@deepseek-ai/cordis'
 import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
@@ -23,13 +8,16 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts'
 import type { SessionJob as JobView } from '../../types.ts'
 import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
 import {
-  createSnapshotStore, type SnapshotStore,
+  createSnapshotStore, notifySubscribers, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
 import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionEventSource } from '../contract/events.ts'
 import type { SessionFace } from '../contract/session.ts'
-import type { AgentContext, ISessions } from '../contract/sessions.ts'
-import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
+import type {
+  AgentContext, ISessions, SessionReference, SessionRetainInfo, SessionRetainOptions, SessionTarget,
+} from '../contract/sessions.ts'
+import type { SessionReferenceSource } from '../index.ts'
+import { createScope, scopeIdentityOf, scopeOf as scopeTagOf } from '../scope.ts'
 import { SessionManager } from './manager.ts'
 import type { SessionRemotes } from './remotes.ts'
 import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
@@ -47,8 +35,8 @@ export interface SessionSummary {
   /** Coarse durable origin for navigation filtering; not a continuation capability. */
   origin?: 'subagent'
   running: boolean
-  /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
-  completed?: boolean
+  /** Local ownership counts; Host metadata refreshes cannot overwrite them. */
+  readonly retainedBy: SessionRetainInfo['retainedBy']
   /**
    * Empty-log bit (host summary derivation mirror). New Session reuses a blank
    * one targeting the same workspace. Filtering stays with the consumer: the
@@ -61,17 +49,12 @@ export interface SessionSummary {
   projectionValues?: Readonly<Partial<SessionProjectionMap>>
 }
 
-/**
- * Session list store shape. `current` rides the same snapshot (arbitrated:
- * the single useSessions standard hook reads list and selection together —
- * sidebar highlighting and current-session consumers share one fact source).
- */
+/** Catalog metadata and local source counts; catalog membership owns no Client generation. */
 export interface SessionListState {
   /** Host-list order; addressed breadcrumb-only rows are excluded. */
   ids: SessionId[]
-  /** Host rows plus the current addressed subagent route used by navigation. */
+  /** Host/catalog rows plus local fallback rows for live Client generations; only `ids` expresses Host-list membership. */
   byId: Record<SessionId, SessionSummary>
-  current: SessionId | undefined
   /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
   phase: SessionListPhase
   /** Direct durable catalogs keyed by their selected parent address. */
@@ -82,14 +65,6 @@ export interface SessionListState {
    * set, so consumers read absence rather than a sentinel.
    */
   jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
-  /** Current session's catalog-derived address, absent on ordinary navigation. */
-  currentAddress: SubagentAddress | undefined
-}
-
-/** Persisted navigation cell: address survives refresh for correct history routing. */
-interface SessionSelection {
-  sessionId?: SessionId
-  subagentAddress?: SubagentAddress
 }
 
 /** Structured session-create failure. */
@@ -170,15 +145,94 @@ function increasedForkTitle(title: string): string {
   return `${title} (1)`
 }
 
+/** Source labels are dictionary keys, including names also present on Object.prototype. */
+function freezeRetainedBy(counts: Partial<Record<SessionReferenceSource, number>>): SessionRetainInfo['retainedBy'] {
+  Object.setPrototypeOf(counts, null)
+  return Object.freeze(counts)
+}
+
+const EMPTY_RETAIN_INFO: SessionRetainInfo = Object.freeze({ referenceCount: 0, retainedBy: freezeRetainedBy({}) })
+
 interface ScopeRecord {
   fiber: Fiber
   ctx: AgentContext
   binding: SessionBinding
-  /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
   session: Session
+  retention: SessionRetainInfo
+  live: boolean
+}
+
+interface RetentionObserver {
+  readonly source: ObservableSnapshot<SessionRetainInfo>
+  readonly listeners: Set<() => void>
+  published: SessionRetainInfo
 }
 
-/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
+/** A cancelled waiter releases only its own reference, not the shared opening. */
+async function waitForOpen(opening: Promise<void>, signal?: AbortSignal): Promise<void> {
+  if (signal === undefined) return opening
+  const aborted = Promise.withResolvers<never>()
+  const onAbort = (): void => { aborted.reject(signal.reason) }
+  signal.addEventListener('abort', onAbort, { once: true })
+  try {
+    if (signal.aborted) onAbort()
+    await Promise.race([opening, aborted.promise])
+  } finally {
+    signal.removeEventListener('abort', onAbort)
+  }
+}
+
+class ClientSessionReference implements SessionReference {
+  private readonly released = new AbortController()
+  private readonly readiness = Promise.withResolvers<SessionBinding>()
+  readonly ready = this.readiness.promise
+
+  constructor(
+    readonly sessionId: SessionId,
+    private record: ScopeRecord | undefined,
+    private releaseReference: (() => void) | undefined,
+  ) {
+    void this.ready.catch(() => {})
+  }
+
+  get binding(): SessionBinding {
+    if (this.record === undefined || !this.record.live) throw new Error(`Session reference "${this.sessionId}" is released`)
+    return this.record.binding
+  }
+
+  attachOpening(opening: Promise<void>, signal?: AbortSignal): void {
+    const waitSignal = signal === undefined
+      ? this.released.signal
+      : AbortSignal.any([this.released.signal, signal])
+    void waitForOpen(opening, waitSignal).then(
+      () => {
+        try {
+          waitSignal.throwIfAborted()
+          this.readiness.resolve(this.binding)
+        } catch (error: unknown) {
+          this.readiness.reject(error)
+        }
+      },
+      (error: unknown) => { this.readiness.reject(error) },
+    )
+  }
+
+  release(): void {
+    const reason = new Error(`Session reference "${this.sessionId}" is released`)
+    const release = this.releaseReference
+    this.released.abort(reason)
+    this.readiness.reject(reason)
+    this.record = undefined
+    this.releaseReference = undefined
+    release?.()
+  }
+
+  [Symbol.dispose](): void {
+    this.release()
+  }
+}
+
+/** Host catalog and local reference allocator; view selection remains outside the Controller. */
 export class ClientSessions implements ISessions {
   /**
    * The wire schema's own result bound, re-exposed for presentation plugins as
@@ -187,32 +241,15 @@ export class ClientSessions implements ISessions {
    * reports the same number.
    */
   readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
-  /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
+  /** Catalog metadata and local reference-source projection. */
   readonly list: SnapshotStore<SessionListState>
   /** The object-layer instance cluster and frame dispatch entry. */
   private readonly manager: SessionManager
-  /**
-   * Persisted selection cell (the durable half of `list.current`). Private on
-   * purpose: reads go through the list snapshot; writes through {@link
-   * ClientSessions.open} / {@link ClientSessions.clear}. Projection
-   * validates it against the live list instead of destructively pruning, so a
-   * selection survives transient list states (reconnect re-pull) and
-   * resurfaces when its session returns.
-   */
-  private readonly selection: SnapshotStore<SessionSelection>
-
   private readonly scopes = new Map<SessionId, ScopeRecord>()
-  /** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */
+  /** Stable per-id sources retained for the Client root lifetime, including across generation replacement. */
+  private readonly retainObservers = new Map<SessionId, RetentionObserver>()
   private readonly scopeDrops = new Set<Promise<void>>()
-  /**
-   * The staged session id — follows `list.current` exactly, holding its last
-   * defined value across masked gaps (a transiently absent selection blanks
-   * `current` without moving the stage, so reconnect re-pulls and removals
-   * keep the staged scope's frozen view alive until the stage moves on).
-   */
-  private watched: SessionId | undefined
-  /** Removed-while-staged sessions whose teardown waits for the stage to move away. */
-  private readonly deferredRemovals = new Set<SessionId>()
+  private closed = false
 
   /**
    * @param ctx - client root context (scope fibers mount under it).
@@ -222,61 +259,78 @@ export class ClientSessions implements ISessions {
     private readonly rootCtx: Context,
     remote: SessionRemotes,
   ) {
-    this.selection = createSnapshotStore<SessionSelection>(
-      {},
-      { persist: { name: 'dsh.sessions.current' } })
-    const restored = this.selection.getSnapshot()
-    this.manager = new SessionManager(
-      remote,
-      restored.sessionId,
-      restored.subagentAddress,
-    )
+    this.manager = new SessionManager(remote)
     this.list = createSnapshotStore<SessionListState>({
-      ids: [], byId: {}, current: undefined, phase: 'pending',
-      subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
-    })
-    // The manager owns wire truth; the store is its projection. Manager
-    // notifications are already microtask-batched.
-    const disposeManagerProjection = this.manager.subscribe(() => {
-      this.projectList()
-    })
-    // Stage follower: every current write (open() and projection alike)
-    // re-evaluates staging, so startup restore (persisted selection validated
-    // by the projection) and reconnect resurfacing open their window with no
-    // dedicated code path. Safe to run synchronously inside the store notify:
-    // the follower writes no list state — session.open()'s synchronous prefix
-    // touches only session-side state and its own microtask-batched notifier.
-    const disposeStageFollower = this.list.subscribe(() => {
-      this.followCurrent()
+      ids: [], byId: {}, phase: 'pending', subagentsByParent: {}, jobsBySession: {},
     })
+    const disposeManagerProjection = this.manager.subscribe(() => { this.projectList() })
     rootCtx.effect(() => async () => {
-      disposeStageFollower()
+      this.closed = true
       disposeManagerProjection()
       const scopes = [...this.scopes]
       this.scopes.clear()
-      this.deferredRemovals.clear()
-      this.watched = undefined
-      for (const [id, record] of scopes) this.startScopeDrop(id, record)
+      for (const [, record] of scopes) {
+        record.live = false
+        record.session.unbindScope()
+      }
+      const managerDisposal = this.manager.dispose()
+      for (const [id, record] of scopes) {
+        this.startScopeDrop(id, record)
+        this.publishRetention(id)
+      }
       await this.drainScopeDrops()
-      await this.manager.dispose()
+      await managerDisposal
     }, 'session-controller.client.sessions')
     rootCtx.reflect.provide('sessions', this, undefined)
   }
 
-  /**
-   * Select a listed or retained catalog-addressed session as current.
-   * @param id - listed or addressed session id.
-   */
-  open(id: SessionId): void {
-    this.manager.select(id)
+  retain(target: SessionTarget, options: SessionRetainOptions): SessionReference {
+    const { source, signal } = options
+    signal?.throwIfAborted()
+    if (this.closed) throw new Error('Session Controller is disposed')
+    const id = this.manager.resolveTarget(target)
+    const reference = this.retainScope(id, source)
+    try {
+      reference.attachOpening(this.manager.get(id).open(), signal)
+      return reference
+    } catch (error) {
+      reference.release()
+      throw error
+    }
   }
 
-  /**
-   * Open a healthy catalog child through its direct-parent address.
-   * @param address - catalog-derived parent and child ids.
-   */
-  openSubagent(address: SubagentAddress): void {
-    this.manager.selectSubagent(address)
+  async using<T>(
+    target: SessionTarget,
+    options: SessionRetainOptions,
+    operation: (reference: SessionReference) => T | Promise<T>,
+  ): Promise<T> {
+    const reference = this.retain(target, options)
+    try {
+      await reference.ready
+      return await operation(reference)
+    } finally {
+      reference.release()
+    }
+  }
+
+  retainInfo(id: SessionId): ObservableSnapshot<SessionRetainInfo> {
+    let observer = this.retainObservers.get(id)
+    if (observer === undefined) {
+      const listeners = new Set<() => void>()
+      observer = {
+        listeners,
+        published: this.retentionSnapshot(id),
+        source: {
+          getSnapshot: () => this.retentionSnapshot(id),
+          subscribe: (listener) => {
+            listeners.add(listener)
+            return () => { listeners.delete(listener) }
+          },
+        },
+      }
+      this.retainObservers.set(id, observer)
+    }
+    return observer.source
   }
 
   /**
@@ -306,17 +360,6 @@ export class ClientSessions implements ISessions {
     return this.manager.refreshSubagents(parentSessionId)
   }
 
-  /**
-   * Clear the current selection so the layout shows the no-session empty
-   * state (new-session affordance and the workspace preselection flow).
-   * Wipes the persisted selection too — a reload stays on empty until the
-   * user opens or starts a session. The staged scope keeps its frozen view
-   * per the masked-gap contract until the next open() moves the stage.
-   */
-  clear(): void {
-    this.manager.clearSelection()
-  }
-
   /**
    * Refresh the real Session baseline, reusing an in-flight pull.
    * @returns completion of the current or newly started baseline pull.
@@ -393,12 +436,8 @@ export class ClientSessions implements ISessions {
   }
 
   /**
-   * Create a session on the host. Resolution guarantee: by the time the
-   * promise resolves, the created session is in the list store and
-   * {@link ClientSessions.binding} resolves it — callers (New Session
-   * draft hand-off) may address the scope synchronously, without waiting a
-   * notifier flush. The synchronous projection below makes this structural
-   * rather than an accident of microtask ordering.
+   * Create a Host Session and publish its catalog row before resolving.
+   * Callers retain the returned identity before borrowing its binding.
    * @param opts - target workspace or directory and an optional preallocated id.
    * @returns the new session id.
    * @throws {SessionCreateError} with the requested id.
@@ -411,9 +450,8 @@ export class ClientSessions implements ISessions {
   }
 
   /**
-   * Fork a session from a completed-turn prefix of the source (same
-   * synchronous-addressability guarantee as {@link ClientSessions.create}:
-   * on resolution the child is in the list store and open() can target it).
+   * Fork a Session from a completed-turn prefix of the source and publish
+   * the child in the catalog before resolving.
    * @param opts - source session id, the optional event seq anchoring the
    *   cut (the boundary is the first turn/end at or after it; an in-log
    *   anchor in an open turn is unavailable rather than clipped backward),
@@ -444,33 +482,35 @@ export class ClientSessions implements ISessions {
     this.projectList()
     const childId = result.value.sessionId
     if (sourceTitle !== undefined) {
-      const child = this.binding(childId)?.session
-      if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
-      const renamed = await child.rename(increasedForkTitle(sourceTitle))
-      if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
+      const reference = this.retain(childId, { source: 'controllerOperation' })
+      try {
+        await reference.ready
+        const renamed = await reference.binding.session.rename(increasedForkTitle(sourceTitle))
+        if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
+      } finally {
+        reference.release()
+      }
     }
     return childId
   }
 
   /**
-   * Resolve an Agent-scoped context view (use-and-discard).
+   * Borrow an already-retained Agent-scoped Context.
    * @param id - session id (the agent identity — 1:1 same axis).
-   * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
+   * @returns the scoped Context, or undefined without a retained generation.
    */
   scope(id: SessionId): AgentContext | undefined {
-    return this.resolve(id)?.ctx
+    return this.scopes.get(id)?.ctx
   }
 
   /**
-   * Materialize the Agent scope named by a validated Host Remote Event.
-   * The first successful Session-list baseline becomes authoritative for its
-   * lifetime; until then, transport streams may address the scope in either
-   * arrival order.
-   * @param id - Host-projected Agent identity (the matching Session id).
-   * @returns the identity-stable Agent Context.
+   * Retain a validated Gateway identity synchronously, without history or catalog I/O.
+   * @param id - Host-projected Session identity, possibly not yet catalogued.
+   * @returns a Gateway-source reference owned by the invocation.
    */
-  resolveAgentScope(id: SessionId): AgentContext {
-    return (this.scopes.get(id) ?? this.materializeScope(id)).ctx
+  retainAgentScope(id: SessionId): SessionReference {
+    if (this.closed) throw new Error('Session Controller is disposed')
+    return this.retainScope(id, 'gateway')
   }
 
   /**
@@ -492,61 +532,76 @@ export class ClientSessions implements ISessions {
    * `agent.session`). Same service-method boundary as
    * {@link ClientSessions.scopeOf}.
    * @param ctx - an Agent-scoped context.
-   * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
+   * @returns the matching live Session, or undefined for an untagged or ended generation.
    */
   sessionOf(ctx: Context): SessionFace | undefined {
     const id = scopeTagOf(ctx)
     if (id === undefined) return undefined
-    return this.scopes.get(id)?.binding.session
+    const record = this.scopes.get(id)
+    return record !== undefined && scopeIdentityOf(record.ctx) === scopeIdentityOf(ctx)
+      ? record.binding.session
+      : undefined
   }
 
   /**
-   * Resolve the stable session binding (scope-addressed assembly feed). Pure
-   * resolution — no staging, no window side effects.
-   * @param id - session id.
-   * @returns binding, or undefined for a session neither listed nor already scoped.
+   * Borrow an already-retained binding without extending its lifetime.
+   * @param id - Session identity.
+   * @returns the live binding, or undefined without a retained generation.
    */
   binding(id: SessionId): SessionBinding | undefined {
-    return this.resolve(id)?.binding
+    return this.scopes.get(id)?.binding
   }
 
-  /**
-   * Move the stage to the list's current session: sweep teardowns deferred
-   * behind the previous occupant and pull the new occupant's history window.
-   * Staging IS the open signal — the window opens ⟺ the session is on stage
-   * — and open() is idempotent (an in-flight or completed open no-ops; a
-   * failed one retries the next time current is touched).
-   */
-  private followCurrent(): void {
-    const snapshot = this.list.getSnapshot()
-    const current = snapshot.current
-    // A masked gap (current blanked while the selection's session is
-    // transiently absent) holds the stage: tearing down on the gap would
-    // destroy exactly the frozen scope the mask exists to preserve.
-    if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
-    this.watched = current
-    this.sweepDeferred()
-    const record = this.resolve(current)
-    /* v8 ignore next 3 -- defensive: current is always a listed id (open()
-     * validates and the projection masks absent selections), so resolve
-     * cannot miss; kept so a future current writer cannot crash the notify. */
-    if (record !== undefined) {
-      void record.session.open()
-      void this.manager.refreshSubagents(current)
+  private retainScope(id: SessionId, source: SessionReferenceSource): ClientSessionReference {
+    const record = this.scopes.get(id) ?? this.materializeScope(id)
+    const previous = record.retention
+    record.retention = Object.freeze({
+      referenceCount: previous.referenceCount + 1,
+      retainedBy: freezeRetainedBy({ ...previous.retainedBy, [source]: (previous.retainedBy[source] ?? 0) + 1 }),
+    })
+    const reference = new ClientSessionReference(id, record, () => {
+      if (!record.live) return
+      const count = record.retention.referenceCount - 1
+      const { [source]: sourceCount = 0, ...otherSources } = record.retention.retainedBy
+      const retainedBy = sourceCount > 1 ? { ...otherSources, [source]: sourceCount - 1 } : otherSources
+      record.retention = count === 0
+        ? EMPTY_RETAIN_INFO
+        : Object.freeze({ referenceCount: count, retainedBy: freezeRetainedBy(retainedBy) })
+      if (count === 0) this.retireScope(id, record)
+      else this.publishRetention(id)
+    })
+    if (this.list.getSnapshot().byId[id] === undefined) this.projectList()
+    this.publishRetention(id)
+    return reference
+  }
+
+  private retentionSnapshot(id: SessionId): SessionRetainInfo {
+    return this.scopes.get(id)?.retention ?? EMPTY_RETAIN_INFO
+  }
+
+  private publishRetention(id: SessionId): void {
+    const state = this.list.getSnapshot()
+    const row = state.byId[id]
+    const retainedBy = this.retentionSnapshot(id).retainedBy
+    if (row !== undefined && row.retainedBy !== retainedBy) {
+      this.list.set({ ...state, byId: { ...state.byId, [id]: { ...row, retainedBy } } })
     }
+    const observer = this.retainObservers.get(id)
+    const snapshot = this.retentionSnapshot(id)
+    if (observer === undefined || observer.published === snapshot) return
+    observer.published = snapshot
+    notifySubscribers(observer.listeners, '[session-controller] reference sources')
   }
 
-  /**
-   * Lazily mint the scope + binding for an eligible session. Eligibility and
-   * prune share one predicate: listed on the host or selected
-   * through a retained subagent address. Breadcrumb-only ancestors remain
-   * summary data and do not keep scopes alive.
-   */
-  private resolve(id: SessionId): ScopeRecord | undefined {
-    const existing = this.scopes.get(id)
-    if (existing !== undefined) return existing
-    if (!this.eligible(id)) return undefined
-    return this.materializeScope(id)
+  private retireScope(id: SessionId, record: ScopeRecord, disposeFiber = true): void {
+    if (!record.live) return
+    record.live = false
+    if (this.scopes.get(id) === record) this.scopes.delete(id)
+    record.session.unbindScope()
+    const sessionDisposal = this.manager.drop(id, record.session)
+    this.projectList()
+    this.publishRetention(id)
+    this.startScopeDrop(id, record, disposeFiber, sessionDisposal)
   }
 
   /** Materialize one scope after its caller establishes that the id may be addressed. */
@@ -562,21 +617,19 @@ export class ClientSessions implements ISessions {
       ctx,
       binding,
       session,
+      retention: EMPTY_RETAIN_INFO,
+      live: true,
     }
     this.scopes.set(id, record)
+    ctx.effect(() => () => { this.retireScope(id, record, false) }, 'session-controller: exact generation')
     return record
   }
 
-  /** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
-  private eligible(id: SessionId): boolean {
-    const { ids, current } = this.list.getSnapshot()
-    return current === id || ids.includes(id)
-  }
-
   /** Project the manager's list snapshot into the store (title derivation is display-only). */
   private projectList(): void {
+    const previousById = this.list.getSnapshot().byId
     const {
-      items, current, phase, subagentsByParent, jobsBySession, currentAddress,
+      items, phase, subagentsByParent, jobsBySession,
     } = this.manager.getListSnapshot()
     const ids: SessionId[] = []
     const byId: Record<SessionId, SessionSummary> = {}
@@ -586,7 +639,7 @@ export class ClientSessions implements ISessions {
         id: entry.sessionId,
         displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
         running: entry.running,
-        ...(entry.completed ? { completed: true } : {}),
+        retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
         blank: entry.blank,
         updatedAt: entry.updatedAt,
         ...(entry.projectionValues === undefined
@@ -598,71 +651,46 @@ export class ClientSessions implements ISessions {
         ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
       }
     }
-    if (current !== undefined && currentAddress !== undefined) {
-      const seen = new Set<SessionId>()
-      let address: SubagentAddress | undefined = currentAddress
-      while (address !== undefined && !seen.has(address.childSessionId)) {
-        const childId = address.childSessionId
-        seen.add(childId)
-        const child = subagentsByParent[address.parentSessionId]?.entries
-          .find(entry => entry.kind === 'child' && entry.id === childId)
-        if (child?.kind !== 'child') break
+    for (const [parentId, catalog] of Object.entries(subagentsByParent)) {
+      for (const child of catalog.entries) {
+        if (child.kind !== 'child') continue
+        const childId = child.id
         const displayTitle = child.label ?? childId
         const summary = byId[childId]
         if (summary === undefined) {
           byId[childId] = {
-            id: childId,
-            displayTitle,
-            parentId: address.parentSessionId,
-            origin: 'subagent',
-            running: child.activity === 'running',
-            blank: false,
-            updatedAt: 0,
+            id: childId, displayTitle, parentId: parentId as SessionId,
+            origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0,
+            retainedBy: this.retentionSnapshot(childId).retainedBy,
           }
         } else if (summary.displayTitle !== displayTitle) {
           byId[childId] = { ...summary, displayTitle }
         }
-        const parent = byId[address.parentSessionId]
-        if (parent !== undefined && parent.origin !== 'subagent') break
-        address = this.manager.navigationAddress(address.parentSessionId)
       }
     }
-    const persisted = this.selection.getSnapshot().sessionId
-    // No current (cleared, or masked gap) wipes the persisted cell — a reload
-    // stays on empty; the in-memory selection still resurfaces a masked id.
-    if (current === undefined) {
-      if (persisted !== undefined) this.selection.set({})
-    } else if (byId[current] !== undefined
-      && (persisted !== current
-        || this.selection.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
-        || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
-        || this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
-      this.selection.set({
-        sessionId: current,
-        ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
-      })
-    }
-    this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
-    this.pruneScopes()
-  }
-
-  /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
-  private pruneScopes(): void {
-    if (this.list.getSnapshot().phase === 'pending') return
     for (const [id, record] of this.scopes) {
-      if (this.eligible(id)) continue
-      if (id === this.watched) {
-        this.deferredRemovals.add(id)
-        continue
+      if (byId[id] !== undefined) continue
+      const previous = previousById[id]
+      const snapshot = record.session.getSnapshot()
+      const address = this.manager.subagentAddress(id)
+      byId[id] = {
+        ...(previous ?? { id, displayTitle: id, updatedAt: 0 }),
+        running: snapshot.running,
+        retainedBy: record.retention.retainedBy,
+        blank: snapshot.blank,
+        ...(address === undefined ? {} : { parentId: address.parentSessionId, origin: 'subagent' }),
       }
-      this.scopes.delete(id)
-      this.deferredRemovals.delete(id)
-      this.startScopeDrop(id, record)
     }
+    this.list.set({ ids, byId, phase, subagentsByParent, jobsBySession })
   }
 
-  private startScopeDrop(id: SessionId, record: ScopeRecord): void {
-    const drop = this.dropScope(id, record)
+  private startScopeDrop(
+    id: SessionId,
+    record: ScopeRecord,
+    disposeFiber = true,
+    sessionDisposal = this.manager.drop(id, record.session),
+  ): void {
+    const drop = this.dropScope(record, disposeFiber, sessionDisposal)
     this.scopeDrops.add(drop)
     void drop.then(
       () => { this.scopeDrops.delete(drop) },
@@ -676,44 +704,12 @@ export class ClientSessions implements ISessions {
     }
   }
 
-  /**
-   * One teardown for the whole per-session axis: the scope
-   * fiber (cascading every actx-registered effect: input shell, slash
-   * controller, popup, plugin stores, listeners), the session-keyed slot
-   * registrations and the Session instance itself — the host session log is the
-   * durable truth, a reopen lazily rebuilds and backfills via open().
-   */
-  private async dropScope(id: SessionId, record: ScopeRecord): Promise<void> {
-    // Release the Session's dispatch point with the scope it belongs to (a
-    // surviving instance — the live Intent — rebinds when resolve re-mints).
-    record.session.unbindScope()
-    await Promise.allSettled([
-      record.fiber.dispose(),
-      this.manager.drop(id),
-    ])
-  }
-
-  /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
-  private sweepDeferred(): void {
-    for (const id of [...this.deferredRemovals]) {
-      /* v8 ignore next -- defensive: only the staged id ever defers, and every
-       * stage move sweeps first, so the set cannot contain the id the stage just
-       * moved to; kept as a guard against future extra sweep call sites. */
-      if (id === this.watched) continue
-      // Eligible again? (A re-added id cancels the deferred teardown.)
-      if (this.eligible(id)) {
-        this.deferredRemovals.delete(id)
-        continue
-      }
-      const record = this.scopes.get(id)
-      this.deferredRemovals.delete(id)
-      /* v8 ignore next -- defensive: prune deletes a scope and its deferral
-       * together, so a deferred id always still owns its record; kept so a
-       * future teardown path cannot double-dispose. */
-      if (record !== undefined) {
-        this.scopes.delete(id)
-        this.startScopeDrop(id, record)
-      }
-    }
+  /** Await the already-withdrawn Session and scoped cleanup to quiescence. */
+  private async dropScope(
+    record: ScopeRecord,
+    disposeFiber: boolean,
+    sessionDisposal: Promise<void>,
+  ): Promise<void> {
+    await Promise.allSettled([sessionDisposal, ...disposeFiber ? [record.fiber.dispose()] : []])
   }
 }

+ 17 - 8
packages/api/session-controller/tests/client-apply.client.spec.ts

@@ -3,12 +3,12 @@
  * arriving as emit frames on the `$events` stream, the control stream over
  * the real Connection, and Agent Context identity through the Typert registry.
  */
-import type { Context } from '@deepseek-ai/cordis'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { isTypertOwnedValue } from '@deepseek-ai/dsh-typert-protocol'
 import { afterEach, describe, expect, vi, type MockInstance } from 'vitest'
 import { ClientSessions } from '../src/client/sessions/service.ts'
 import type { SessionListValue } from '../src/types.ts'
@@ -138,12 +138,17 @@ describe('Session Controller Client apply', () => {
     const { client, sessions } = await bench(start)
     const adapter = client.ctx.typert.contexts.getClient('agent')
     const first = adapter?.resolve(sid('agent-early'))
-
-    expect(first).toBeDefined()
-    expect(sessions.scopeOf(first as Context)).toBe(sid('agent-early'))
-    expect(adapter?.resolve(sid('agent-early'))).toBe(first)
+    const second = adapter?.resolve(sid('agent-early'))
+    if (!isTypertOwnedValue(first) || !isTypertOwnedValue(second)) throw new Error('expected owned Contexts')
+    using firstOwner = first
+    using secondOwner = second
+    expect(sessions.scopeOf(firstOwner.value)).toBe(sid('agent-early'))
+    expect(secondOwner.value).toBe(firstOwner.value)
+    expect(sessions.retainInfo(sid('agent-early')).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { gateway: 2 } })
+    expect(mock.log.requests('session/follow')).toHaveLength(0)
     list.resolve(ok({ items: [] }))
     await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
+    expect(sessions.scope(sid('agent-early'))).toBe(firstOwner.value)
   })
 
   it('projects Agent Context identity in both directions and withdraws the adapter when the row unloads', async ({ mock, start }) => {
@@ -151,12 +156,16 @@ describe('Session Controller Client apply', () => {
     await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
 
     await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
-    await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() })
-    const scoped = sessions.scope(sid('agent-1')) as Context
+    expect(sessions.scope(sid('agent-1'))).toBeUndefined()
+    using reference = sessions.retainAgentScope(sid('agent-1'))
+    const scoped = reference.binding.ctx
     const adapter = client.ctx.typert.contexts.getClient('agent')
     expect(adapter?.identity(client.ctx)).toBeUndefined()
     expect(adapter?.identity(scoped)).toBe(sid('agent-1'))
-    expect(adapter?.resolve(sid('agent-1'))).toBe(scoped)
+    const resolved = adapter?.resolve(sid('agent-1'))
+    if (!isTypertOwnedValue(resolved)) throw new Error('expected invocation ownership')
+    using invocation = resolved
+    expect(invocation.value).toBe(scoped)
 
     await client.unload(SELF)
     expect(client.ctx.typert.contexts.getClient('agent')).toBeUndefined()

+ 4 - 5
packages/api/session-controller/tests/lineage.client.spec.ts

@@ -53,10 +53,9 @@ describe('Session lineage flattening', () => {
     }
   })
 
-  it('projects the completion-reminder set into rows (absent = false)', () => {
-    const out = flattenLineage([s('a', 10), s('b', 20)], new Set(['b' as SessionId]))
-    expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false)
-    expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true)
-    expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false)
+  it('keeps lineage rows free of completion presentation state', () => {
+    const out = flattenLineage([s('a', 10), s('b', 20)])
+    expect(out).toHaveLength(2)
+    for (const row of out) expect(row).not.toHaveProperty('completed')
   })
 })

+ 139 - 141
packages/api/session-controller/tests/manager.client.spec.ts

@@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types'
-import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import {
   createClientTest, type ClientTestFixtures, webApp,
@@ -43,12 +42,10 @@ function summary(sessionId: SessionId, over: SummaryOver = {}) {
 function makeManager(
   mock: RemoteMock,
   remote: ClientTestFixtures['remote'],
-  restoredSelection?: SessionId,
-  restoredAddress?: SubagentAddress,
 ): SessionManager {
   mock.load(sessionWorld)
   // Cases using this helper never open a Session, so they do not need the broader Client Remote's $stream member.
-  return new SessionManager(remote as unknown as SessionRemotes, restoredSelection, restoredAddress)
+  return new SessionManager(remote as unknown as SessionRemotes)
 }
 
 describe('SessionManager instances', () => {
@@ -63,7 +60,108 @@ describe('SessionManager instances', () => {
 
 })
 
+describe('SessionManager query lifetime', () => {
+  it.for(['list', 'catalog'] as const)('forwards an unexpected %s failure and still completes teardown', async (target, { mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    const failure = new Error('query implementation failed')
+    if (target === 'list') remote.session.list.mockRejectedValueOnce(failure)
+    else remote.subagents.list.mockRejectedValueOnce(failure)
+    try {
+      await expect(target === 'list' ? manager.refreshList() : manager.refreshSubagents(S1)).rejects.toBe(failure)
+    } finally {
+      await manager.dispose()
+    }
+  })
+
+  it('keeps a rejected Remote list failure in the observable state', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    const failure = new RemoteError('gateway/internal', 'list unavailable', {})
+    remote.session.list.mockRejectedValueOnce(failure)
+    try {
+      await manager.refreshList()
+      expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: failure })
+    } finally {
+      await manager.dispose()
+    }
+  })
+
+  it.for([false, true])('preserves a rejected Remote catalog failure with prior baseline %s', async (warm, { mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    const failure = new RemoteError('gateway/internal', 'catalog unavailable', {})
+    const entries = [{ kind: 'child' as const, id: S2, mode: 'one-shot' as const, activity: 'inactive' as const, hasChildren: false }]
+    try {
+      if (warm) {
+        remote.subagents.list.mockResolvedValueOnce(ok({ entries, parentAvailable: true }))
+        await manager.refreshSubagents(S1)
+        manager.handleSessionRemoved(S1)
+      }
+      remote.subagents.list.mockRejectedValueOnce(failure)
+      const refresh = manager.refreshSubagents(S1)
+      await refresh
+      expect(manager.getListSnapshot().subagentsByParent[S1]).toMatchObject({
+        state: 'error', error: failure, entries: warm ? entries : [],
+      })
+      expect(manager.getListSnapshot().subagentsByParent[S1]?.parentAvailable).toBe(warm ? false : undefined)
+    } finally {
+      await manager.dispose()
+    }
+  })
+
+  it('cancels a queued catalog membership refresh when its consumer closes', async ({ mock, remote }) => {
+    vi.useFakeTimers()
+    const manager = makeManager(mock, remote)
+    try {
+      manager.setSubagentCatalogOpen(S1, true)
+      await manager.refreshSubagents(S1)
+      manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
+      manager.setSubagentCatalogOpen(S1, false)
+      await vi.runAllTimersAsync()
+      expect(remote.subagents.list).toHaveBeenCalledOnce()
+    } finally {
+      await manager.dispose()
+      vi.useRealTimers()
+    }
+  })
+})
+
 describe('list lifecycle', () => {
+  it('fills missing durable links without overwriting established rows and projects first-send engagement', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    remote.session.list.mockResolvedValueOnce(ok({ items: [
+      summary(S1, { blank: true }), summary(S2, { cwd: '/existing', blank: true }),
+    ] }))
+    try {
+      await manager.refreshList()
+      manager.handleSessionAdded(summary(S1, { cwd: '/filled', parentSessionId: S2, origin: 'subagent', blank: false }))
+      manager.handleSessionAdded(summary(S2, { cwd: '/ignored', blank: true }))
+      manager.handleSessionActivity(S1, 50)
+      manager.handleSessionActivity(S2, 200)
+      await manager.get(S2).prompt([{ type: 'text', text: 'first message' }], 'queue')
+      expect(manager.getListSnapshot().items).toEqual(expect.arrayContaining([
+        expect.objectContaining({ sessionId: S1, cwd: '/filled', parentSessionId: S2, origin: 'subagent', blank: false }),
+        expect.objectContaining({ sessionId: S2, cwd: '/existing', updatedAt: 200, blank: false }),
+      ]))
+    } finally {
+      await manager.dispose()
+    }
+  })
+
+  it('projects cold Session additions and an empty-cut control baseline before history exists', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
+    try {
+      manager.handleSessionAdded({
+        ...summary(S1), projections: { asOfSeq: -1, values: { title: 'before history' } },
+      })
+      manager.handleControlFrame({
+        type: 'baseline', value: { jobs: { [S1]: [] }, projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } },
+      })
+      expect(manager.getListSnapshot().items[0]?.title).toBe('before history')
+      expect(manager.getListSnapshot().jobsBySession).toEqual({})
+    } finally {
+      await manager.dispose()
+    }
+  })
+
   it('single-flights refreshList and preserves the Host baseline order', async ({ mock, remote }) => {
     const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
     remote.session.list.mockReturnValue(gate.promise)
@@ -262,7 +360,7 @@ describe('Host Remote event routing', () => {
 })
 
 describe('subagent catalogs', () => {
-  it('keeps a catalog-discovered child address across ordinary selection and status frames', async ({ mock, remote, start }) => {
+  it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
     remote.session.list.mockResolvedValue(ok({ items: [
       summary(S1),
       summary(S2, { parentSessionId: S1, origin: 'subagent' }),
@@ -279,9 +377,9 @@ describe('subagent catalogs', () => {
     const manager = new SessionManager(client.ctx.remote)
     await manager.refreshList()
     await manager.refreshSubagents(S1)
-    manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
+    manager.resolveTarget({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
 
-    expect(manager.getListSnapshot().currentAddress).toEqual({
+    expect(manager.subagentAddress(S2)).toEqual({
       parentSessionId: S1, childSessionId: S2, mode: 'continuable',
     })
     expect(manager.get(S2).getSnapshot().subagent).toEqual({
@@ -290,8 +388,8 @@ describe('subagent catalogs', () => {
     })
     // Clicking the same child through an ordinary list-selection path must not
     // erase the catalog-derived address and fall back to session.* transport.
-    manager.select(S2)
-    expect(manager.getListSnapshot().currentAddress).toEqual({
+    manager.resolveTarget(S2)
+    expect(manager.subagentAddress(S2)).toEqual({
       parentSessionId: S1, childSessionId: S2, mode: 'continuable',
     })
     expect(manager.get(S2).getSnapshot().subagent).toEqual({
@@ -497,7 +595,7 @@ describe('subagent catalogs', () => {
       const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
       const second = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
       remote.subagents.list.mockReturnValue(first.promise)
-      const manager = makeManager(mock, remote, root)
+      const manager = makeManager(mock, remote)
       const refresh = manager.refreshSubagents(root)
       manager.setSubagentCatalogOpen(root, true)
 
@@ -556,7 +654,7 @@ describe('subagent catalogs', () => {
     const refresh = manager.refreshSubagents(root)
     first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
     await refresh
-    manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
+    manager.resolveTarget({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
 
     // The removal lands while a second pull is in flight: the invalidation
     // must survive the pre-removal ok response, so one trailing pull runs.
@@ -596,7 +694,7 @@ describe('subagent catalogs', () => {
     }))
     const manager = makeManager(mock, remote)
     await manager.refreshSubagents(root)
-    manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
+    manager.resolveTarget({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
     expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
 
     manager.handleSessionRemoved(root)
@@ -712,34 +810,29 @@ describe('remaining branches', () => {
     expect(manager.getListSnapshot().items).toBe(after.items)
   })
 
-  it('reuses refreshed rows and evicts missing rows while retaining the selection candidate', async ({ mock, remote }) => {
-    const manager = makeManager(mock, remote, S2)
+  it('reuses refreshed rows and evicts missing rows independently of Client instances', async ({ mock, remote }) => {
+    const manager = makeManager(mock, remote)
     remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
     await manager.refreshList()
     const first = manager.getListSnapshot()
-    expect(first.current).toBe(S2)
 
     remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
     await manager.refreshList()
     expect(manager.getListSnapshot().items).toBe(first.items)
-    expect(manager.getListSnapshot().current).toBe(S2)
 
     remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
     await manager.refreshList()
     expect(manager.getListSnapshot().items).toEqual([first.items[0]])
     expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
-    expect(manager.getListSnapshot().current).toBeUndefined()
 
     remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2)] as never[] }))
     await manager.refreshList()
     expect(manager.getListSnapshot().items[0]).toBe(first.items[0])
     expect(manager.getListSnapshot().items[1]).not.toBe(first.items[1])
-    expect(manager.getListSnapshot().current).toBe(S2)
 
     remote.session.list.mockResolvedValue(ok({ items: [] as never[] }))
     await manager.refreshList()
     expect(manager.getListSnapshot().items).toEqual([])
-    expect(manager.getListSnapshot().current).toBeUndefined()
 
     remote.session.list.mockResolvedValue(ok({ items: [summary(S1)] as never[] }))
     await manager.refreshList()
@@ -749,7 +842,7 @@ describe('remaining branches', () => {
   it('bounds cached-row ID reads linearly during repeated list refreshes', async ({ mock, remote }) => {
     const count = 1_000
     const summaries = Array.from({ length: count }, (_, i) => summary(`list-${i}` as SessionId))
-    const manager = makeManager(mock, remote, summaries[count - 1]!.sessionId)
+    const manager = makeManager(mock, remote)
     remote.session.list.mockResolvedValue(ok({ items: summaries as never[] }))
     await manager.refreshList()
     const first = manager.getListSnapshot()
@@ -765,7 +858,6 @@ describe('remaining branches', () => {
       await manager.refreshList()
       const snapshot = manager.getListSnapshot()
       expect(snapshot.items).toBe(first.items)
-      expect(snapshot.current).toBe(summaries[count - 1]!.sessionId)
       expect(reads).toBeLessThanOrEqual(count * 3)
     }
   })
@@ -872,17 +964,25 @@ describe('connected generation', () => {
     expect(remote.session.page).toHaveBeenCalledTimes(historyCallsBefore)
   })
 
-  it('retains the durable parent address and refreshes its catalogs across reconnect', async ({ mock, remote }) => {
+  it('retains the durable parent address and refreshes that parent across reconnect', async ({ mock, remote }) => {
     const address = {
       parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
     }
     const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
     const child = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
     remote.subagents.list.mockImplementation(payload => (payload === S1 ? parent.promise : child.promise))
-    const manager = makeManager(mock, remote, S2, address)
+    const manager = makeManager(mock, remote)
+    remote.subagents.list.mockResolvedValueOnce(ok({
+      entries: [{ kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'inactive', hasChildren: false }],
+      parentAvailable: true,
+    }))
+    await manager.refreshSubagents(S1)
+    manager.resolveTarget(address)
+    manager.get(S2)
+    remote.subagents.list.mockClear()
 
     manager.handleConnected()
-    expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
+    expect(manager.get(S2).getSnapshot().subagent).toEqual({ address, parentAvailable: true })
     parent.resolve(ok({ entries: [], parentAvailable: true }))
     child.resolve(ok({ entries: [], parentAvailable: true }))
 
@@ -890,132 +990,30 @@ describe('connected generation', () => {
       expect(remote.session.list).toHaveBeenCalledOnce()
     })
     await vi.waitFor(() => {
-      expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1, S2])
+      expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1])
     })
     expect(manager.get(S2).getSnapshot().subagent).toEqual({
       address,
       parentAvailable: true,
     })
-    expect(manager.getListSnapshot().currentAddress).toEqual(address)
+    expect(manager.subagentAddress(S2)).toEqual(address)
   })
 })
 
-describe('completed reminder', () => {
-  const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => {
-    manager.handleSessionStatus(sessionId, running)
-  }
-  const added = (manager: SessionManager, sessionId: SessionId): void => {
-    manager.handleSessionAdded(summary(sessionId))
-  }
-  const entry = (manager: SessionManager, sessionId: SessionId) =>
-    manager.getListSnapshot().items.find(item => item.sessionId === sessionId)
-
-  it('arms on a running→idle flip of a non-selected session and clears on select', ({ mock, remote }) => {
-    const manager = makeManager(mock, remote)
-    added(manager, S1)
-    added(manager, S2)
-    manager.select(S1)
-    expect(entry(manager, S2)?.completed).toBe(false)
-    status(manager, S2, true)
-    status(manager, S2, false)
-    expect(entry(manager, S2)?.completed).toBe(true)
-    // Opening the session consumes the reminder.
-    manager.select(S2)
-    expect(entry(manager, S2)?.completed).toBe(false)
-  })
-
-  it('never arms for the session being watched and re-arms after a switch-away re-run', ({ mock, remote }) => {
-    const manager = makeManager(mock, remote)
-    added(manager, S1)
-    added(manager, S2)
-    manager.select(S2)
-    status(manager, S2, true)
-    status(manager, S2, false)
-    expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder
-    // Switch away; a fresh run completing again arms the reminder.
-    manager.select(S1)
-    status(manager, S2, true)
-    status(manager, S2, false)
-    expect(entry(manager, S2)?.completed).toBe(true)
-  })
-
-  it('a re-run disarms the reminder while running and re-arms on its completion', ({ mock, remote }) => {
-    const manager = makeManager(mock, remote)
-    added(manager, S1)
-    added(manager, S2)
-    manager.select(S1)
-    status(manager, S2, true)
-    status(manager, S2, false)
-    expect(entry(manager, S2)?.completed).toBe(true)
-    // The user starts a new run without opening the session: running wins.
-    status(manager, S2, true)
-    expect(entry(manager, S2)?.completed).toBe(false)
-    status(manager, S2, false)
-    expect(entry(manager, S2)?.completed).toBe(true)
-  })
-
-  it('session-removed drops the reminder and a re-add starts clean', ({ mock, remote }) => {
+describe('running facts without UI reminders', () => {
+  it('replays running status during hydration without publishing a completion marker', async ({ mock, remote }) => {
+    const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
+    remote.session.list.mockReturnValueOnce(response.promise)
     const manager = makeManager(mock, remote)
-    added(manager, S1)
-    added(manager, S2)
-    manager.select(S1)
-    status(manager, S2, true)
-    status(manager, S2, false)
-    expect(entry(manager, S2)?.completed).toBe(true)
-    manager.handleSessionRemoved(S2)
-    expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined()
-    added(manager, S2)
-    expect(entry(manager, S2)?.completed).toBe(false)
-  })
-
-  it('a list refresh carrying the running→idle transition arms the reminder', async ({ mock, remote }) => {
-    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
-    const manager = makeManager(mock, remote)
-    await manager.refreshList()
-    manager.select(S1)
-    expect(entry(manager, S2)?.completed).toBe(false)
-    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] }))
-    await manager.refreshList()
-    expect(entry(manager, S2)?.completed).toBe(true)
-  })
-
-  it('never arms for sessions already idle at first observation', async ({ mock, remote }) => {
-    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
-    const manager = makeManager(mock, remote)
-    await manager.refreshList()
-    manager.select(S1)
-    expect(entry(manager, S2)?.completed).toBe(false)
-    remote.session.list.mockResolvedValue(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] }))
-    await manager.refreshList()
-    expect(entry(manager, S2)?.completed).toBe(false)
-  })
-
-  it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async ({ mock, remote }) => {
-    const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
-    remote.session.list.mockReturnValue(gate.promise)
-    const manager = makeManager(mock, remote)
-    const refresh = manager.refreshList()
-    // The session finishes while the first pull is still in flight; the pull
-    // response recorded it as running at pull time.
-    status(manager, S2, false)
-    gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] }))
-    await refresh
-    expect(entry(manager, S2)?.completed).toBe(true)
-  })
-
-  it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async ({ mock, remote }) => {
-    const gate = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.list>>>()
-    remote.session.list.mockReturnValue(gate.promise)
-    const manager = makeManager(mock, remote)
-    const refresh = manager.refreshList()
-    // The unknown session starts and finishes while the first pull is in
-    // flight; the pull-time baseline recorded it idle, so the running→idle
-    // edge lives entirely inside the replayed mutations.
-    status(manager, S2, true)
-    status(manager, S2, false)
-    gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] }))
-    await refresh
-    expect(entry(manager, S2)?.completed).toBe(true)
+    const refreshing = manager.refreshList()
+    manager.handleSessionStatus(S1, true)
+    manager.handleSessionStatus(S1, false)
+    response.resolve(ok({ items: [summary(S1)] }))
+    await refreshing
+    const entry = manager.getListSnapshot().items.find(item => item.sessionId === S1)
+    expect(entry).toMatchObject({ running: false })
+    expect(entry).not.toHaveProperty('completed')
+    expect(manager.getListSnapshot()).not.toHaveProperty('current')
   })
 })
 

+ 324 - 0
packages/api/session-controller/tests/reference-ownership.client.spec.ts

@@ -0,0 +1,324 @@
+/** Source-labelled Client references over real history transport and scoped Contexts. */
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, onTestFinished, vi } from 'vitest'
+import type {
+  SessionReference, SessionReferenceSource, SessionRetainInfo,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
+import { createClientTest, webApp, type TestClient } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
+import { ClientSessions } from '../src/client/sessions/service.ts'
+import { FOLLOW, followScript, type HistoryAnswer } from './remote/session.client.ts'
+
+declare module '@deepseek-ai/dsh-api-session-controller/client' {
+  interface SessionReferenceSourceMap {
+    referenceTestView: unknown
+    referenceTestWork: unknown
+  }
+}
+
+const viewSource: SessionReferenceSource = 'referenceTestView'
+const workSource: SessionReferenceSource = 'referenceTestWork'
+const ID = SessionId('reference-session')
+const EMPTY_HISTORY = ok({ records: [], hasMore: false })
+const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-api-gateway']) })
+
+async function bench(mock: RemoteMock, start: () => Promise<TestClient>, listed = true) {
+  const client = await start()
+  const ctx = new Context()
+  const svc = new ClientSessions(ctx, client.ctx.remote)
+  const unblock: Array<() => void> = []
+  onTestFinished(async () => {
+    for (const finish of unblock) finish()
+    await ctx.fiber.dispose()
+  })
+  mock.stream(FOLLOW, followScript(EMPTY_HISTORY))
+  const feed = async (include: boolean): Promise<void> => {
+    mock.remote.session.list.mockResolvedValue(ok({ items: include
+      ? [{ sessionId: ID, updatedAt: 1, running: false, blank: true }]
+      : [] }))
+    await svc.refresh()
+  }
+  if (listed) await feed(true)
+  return { svc, ctx, mock, feed, unblock }
+}
+
+describe('Client reference sources', () => {
+  it('observes unknown identities without creating a scope, reference, or history request', async ({ mock, start }) => {
+    const b = await bench(mock, start, false)
+    const source = b.svc.retainInfo(ID)
+    const before = source.getSnapshot()
+    const stop = source.subscribe(vi.fn())
+    expect(b.svc.retainInfo(ID)).toBe(source)
+    expect(source.getSnapshot()).toBe(before)
+    expect(before).toEqual({ referenceCount: 0, retainedBy: {} })
+    expect('set' in source).toBe(false)
+    expect(b.svc.scope(ID)).toBeUndefined()
+    expect(b.svc.binding(ID)).toBeUndefined()
+    expect(mock.log.requests(FOLLOW)).toHaveLength(0)
+    expect(() => b.svc.retain(ID, { source: viewSource })).toThrow('unknown session')
+    expect(source.getSnapshot()).toBe(before)
+    stop()
+  })
+
+  it('shares initial opening while exposing independent source contributions before the await', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const opening = Promise.withResolvers<Awaited<HistoryAnswer>>()
+    const entered = Promise.withResolvers<undefined>()
+    b.unblock.push(() => { opening.resolve(EMPTY_HISTORY) })
+    mock.stream(FOLLOW, followScript(() => { entered.resolve(undefined); return opening.promise }))
+    const source = b.svc.retainInfo(ID)
+    const first = b.svc.retain(ID, { source: viewSource })
+    const binding = b.svc.binding(ID)
+    const second = b.svc.retain(ID, { source: workSource })
+    const acquisitions = Promise.allSettled([first.ready, second.ready])
+    expect(binding).toBeDefined()
+    expect(source.getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { referenceTestView: 1, referenceTestWork: 1 } })
+    expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(source.getSnapshot().retainedBy)
+    await entered.promise
+    expect(mock.log.requests(FOLLOW)).toHaveLength(1)
+    opening.resolve(EMPTY_HISTORY)
+    await acquisitions
+    using a = first
+    using c = second
+    expect(a.binding).toBe(binding)
+    expect(c.binding).toBe(binding)
+    expect(a.binding.session.getSnapshot().openState).toBe('open')
+    a.release()
+    a[Symbol.dispose]()
+    expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { referenceTestWork: 1 } })
+    expect(() => a.binding).toThrow('is released')
+    c.release()
+    expect(source.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
+    expect(b.svc.binding(ID)).toBeUndefined()
+  })
+
+  it('counts repeated uses of one source and omits it after the final release', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    using first = b.svc.retain(ID, { source: workSource, signal: undefined })
+    using second = b.svc.retain(ID, { source: workSource })
+    await Promise.all([first.ready, second.ready])
+    const source = b.svc.retainInfo(ID)
+    expect(source.getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { referenceTestWork: 2 } })
+    first.release()
+    expect(source.getSnapshot().retainedBy.referenceTestWork).toBe(1)
+    second.release()
+    expect(source.getSnapshot().retainedBy.referenceTestWork).toBeUndefined()
+    expect(Object.keys(source.getSnapshot().retainedBy)).toEqual([])
+  })
+
+  it('keeps counts through catalog refresh/removal and projects them when the row returns', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    using reference = b.svc.retain(ID, { source: viewSource })
+    await reference.ready
+    const binding = reference.binding
+    const source = b.svc.retainInfo(ID)
+    const snapshot = source.getSnapshot()
+    await b.feed(true)
+    expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
+    b.svc.handleSessionRemoved(ID)
+    await vi.waitFor(() => {
+      expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
+    })
+    expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
+    expect(source.getSnapshot()).toBe(snapshot)
+    expect(b.svc.binding(ID)).toBe(binding)
+    await b.feed(true)
+    expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toBe(snapshot.retainedBy)
+    reference.release()
+    expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({})
+  })
+
+  it('retains a synchronous Gateway Context before catalog discovery without history I/O', async ({ mock, start }) => {
+    const b = await bench(mock, start, false)
+    const source = b.svc.retainInfo(ID)
+    using reference = b.svc.retainAgentScope(ID)
+    expect(reference.binding.ctx).toBe(b.svc.scope(ID))
+    expect(reference.binding.session.getSnapshot().openState).toBe('cold')
+    expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { gateway: 1 } })
+    expect(b.svc.list.getSnapshot().ids).not.toContain(ID)
+    expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
+    expect(mock.log.requests(FOLLOW)).toHaveLength(0)
+    expect(mock.remote.subagents.list).not.toHaveBeenCalled()
+    await b.feed(true)
+    expect(b.svc.list.getSnapshot().byId[ID]?.retainedBy).toEqual({ gateway: 1 })
+  })
+
+  for (const kind of ['remote', 'unexpected'] as const) it(`settles ${kind} initial opening failure through Session state and allows another acquisition after release`, async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    using local = b.svc.retainAgentScope(ID)
+    const binding = local.binding
+    const failure = kind === 'remote'
+      ? new RemoteError('session/not-found', 'opening failed', { sessionId: ID })
+      : new Error('opening failed')
+    mock.stream(FOLLOW, followScript(() => Promise.reject(failure)))
+    const failed = b.svc.retain(ID, { source: viewSource })
+    await expect(failed.ready).resolves.toBe(binding)
+    expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { gateway: 1, referenceTestView: 1 } })
+    expect(binding.session.getSnapshot().openState).toBe('error')
+    failed.release()
+    mock.stream(FOLLOW, followScript(EMPTY_HISTORY))
+    using retried = b.svc.retain(ID, { source: workSource })
+    await retried.ready
+    expect(retried.binding).toBe(binding)
+    expect(retried.binding.session.getSnapshot().openState).toBe('open')
+  })
+
+  it('cancels only one waiter while another owns the shared initial opening', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const opening = Promise.withResolvers<Awaited<HistoryAnswer>>()
+    b.unblock.push(() => { opening.resolve(EMPTY_HISTORY) })
+    mock.stream(FOLLOW, followScript(opening.promise))
+    const controller = new AbortController()
+    const cancelled = b.svc.retain(ID, { source: viewSource, signal: controller.signal })
+    const survivor = b.svc.retain(ID, { source: workSource })
+    const reason = new Error('waiter cancelled')
+    const rejected = expect(cancelled.ready).rejects.toBe(reason)
+    controller.abort(reason)
+    await rejected
+    expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 2, retainedBy: { referenceTestView: 1, referenceTestWork: 1 } })
+    cancelled.release()
+    expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { referenceTestWork: 1 } })
+    opening.resolve(EMPTY_HISTORY)
+    using reference = survivor
+    await reference.ready
+    expect(reference.binding.session.getSnapshot().openState).toBe('open')
+    expect(mock.log.requests(FOLLOW)).toHaveLength(1)
+  })
+
+  it('rejects a previously cancelled acquisition without creating a generation or publishing counts', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const controller = new AbortController()
+    const reason = new Error('acquisition cancelled')
+    controller.abort(reason)
+    const changed = vi.fn()
+    const source = b.svc.retainInfo(ID)
+    b.ctx.effect(() => source.subscribe(changed), 'test: reference observation')
+    const before = source.getSnapshot()
+    expect(() => b.svc.retain(ID, { source: viewSource, signal: controller.signal })).toThrow(reason)
+    expect(source.getSnapshot()).toBe(before)
+    expect(changed).not.toHaveBeenCalled()
+    expect(b.svc.binding(ID)).toBeUndefined()
+    expect(mock.log.requests(FOLLOW)).toHaveLength(0)
+  })
+
+  it('releases a synchronously cancelled readiness wait through using()', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const controller = new AbortController()
+    const failure = new Error('owner ended during acquisition')
+    const source = b.svc.retainInfo(ID)
+    b.ctx.effect(() => source.subscribe(() => {
+      if (source.getSnapshot().referenceCount > 0) controller.abort(failure)
+    }), 'test: acquisition cancellation')
+
+    await expect(b.svc.using(ID, { source: viewSource, signal: controller.signal }, () => undefined)).rejects.toBe(failure)
+
+    expect(source.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
+    expect(b.svc.binding(ID)).toBeUndefined()
+  })
+
+  it('withdraws the old generation before an observer retains a same-id replacement', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    using old = b.svc.retain(ID, { source: viewSource })
+    await old.ready
+    const binding = old.binding
+    const source = b.svc.retainInfo(ID)
+    const replacement = Promise.withResolvers<SessionReference>()
+    const stop = source.subscribe(() => {
+      if (source.getSnapshot().referenceCount !== 0) return
+      stop()
+      replacement.resolve(b.svc.retainAgentScope(ID))
+    })
+    b.ctx.effect(() => stop, 'test: generation replacement')
+    old.release()
+    using reference = await replacement.promise
+    await binding.ctx.fiber.dispose()
+    old.release()
+    expect(reference.binding).not.toBe(binding)
+    expect(reference.binding.session.getSnapshot().openState).toBe('cold')
+    expect(b.svc.sessionOf(binding.ctx)).toBeUndefined()
+    expect(source.getSnapshot()).toEqual({ referenceCount: 1, retainedBy: { gateway: 1 } })
+    expect(mock.log.requests(FOLLOW)).toHaveLength(1)
+  })
+
+  it('keeps one observable across replacement while late old cleanup cannot change its counts', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const source = b.svc.retainInfo(ID)
+    const old = b.svc.retain(ID, { source: viewSource })
+    await old.ready
+    const binding = old.binding
+    const entered = Promise.withResolvers<undefined>()
+    const resume = Promise.withResolvers<undefined>()
+    b.unblock.push(() => { resume.resolve(undefined) })
+    binding.ctx.effect(() => async () => { entered.resolve(undefined); await resume.promise }, 'test: delayed generation cleanup')
+    old.release()
+    expect(b.svc.binding(ID)).toBeUndefined()
+    expect(source.getSnapshot().referenceCount).toBe(0)
+    await entered.promise
+    using replacement = b.svc.retain(ID, { source: workSource })
+    await replacement.ready
+    expect(replacement.binding).not.toBe(binding)
+    const snapshot = source.getSnapshot()
+    resume.resolve(undefined)
+    await binding.ctx.fiber.dispose()
+    old.release()
+    expect(b.svc.retainInfo(ID)).toBe(source)
+    expect(source.getSnapshot()).toBe(snapshot)
+    expect(b.svc.sessionOf(binding.ctx)).toBeUndefined()
+    expect(b.svc.binding(ID)).toBe(replacement.binding)
+  })
+
+  it('invalidates references and zeroes their observable on root disposal', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const reference = b.svc.retain(ID, { source: viewSource })
+    await reference.ready
+    const source = b.svc.retainInfo(ID)
+    await b.ctx.fiber.dispose()
+    expect(source.getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
+    expect(() => reference.binding).toThrow('is released')
+    reference.release()
+    expect(() => b.svc.retain(ID, { source: workSource })).toThrow('Controller is disposed')
+    expect(() => b.svc.retainAgentScope(ID)).toThrow('Controller is disposed')
+  })
+})
+
+describe('ClientSessions.using', () => {
+  it('awaits callback settlement before releasing and returns its result', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const entered = Promise.withResolvers<SessionReference>()
+    const response = Promise.withResolvers<number>()
+    b.unblock.push(() => { response.resolve(7) })
+    const using = b.svc.using(ID, { source: workSource, signal: undefined }, (reference) => {
+      entered.resolve(reference)
+      return response.promise
+    })
+    const reference = await entered.promise
+    expect(b.svc.retainInfo(ID).getSnapshot().referenceCount).toBe(1)
+    response.resolve(7)
+    await expect(using).resolves.toBe(7)
+    expect(b.svc.retainInfo(ID).getSnapshot().referenceCount).toBe(0)
+    expect(() => reference.binding).toThrow('is released')
+  })
+
+  for (const kind of ['sync', 'async'] as const) it(`releases and propagates a ${kind} callback failure`, async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const failure = new Error('callback failed')
+    await expect(b.svc.using(ID, { source: workSource }, () => {
+      if (kind === 'sync') throw failure
+      return Promise.reject(failure)
+    })).rejects.toBe(failure)
+    expect(b.svc.retainInfo(ID).getSnapshot()).toEqual({ referenceCount: 0, retainedBy: {} })
+  })
+
+  it('calls the operation after a stateful opening failure', async ({ mock, start }) => {
+    const b = await bench(mock, start)
+    const operation = vi.fn(() => 7)
+    mock.stream(FOLLOW, followScript(() => Promise.reject(new Error('cannot open'))))
+    await expect(b.svc.using(ID, { source: workSource }, operation)).resolves.toBe(7)
+    expect(operation).toHaveBeenCalledOnce()
+    const info: SessionRetainInfo = b.svc.retainInfo(ID).getSnapshot()
+    expect(info).toEqual({ referenceCount: 0, retainedBy: {} })
+    expect(b.svc.binding(ID)).toBeUndefined()
+  })
+})

+ 17 - 0
packages/api/session-controller/tests/scope.client.spec.ts

@@ -39,6 +39,23 @@ function bench() {
 }
 
 describe('createScope', () => {
+  it('routes same-id generations independently while keeping untagged listeners shared', async () => {
+    const root = new Context()
+    const previous = createScope(root, sid('same'))
+    const replacement = createScope(root, sid('same'))
+    const seen: string[] = []
+    previous.ctx.on('test/scope-probe', () => { seen.push('old'); return undefined })
+    replacement.ctx.on('test/scope-probe', () => { seen.push('new'); return undefined })
+    root.on('test/scope-probe', () => { seen.push('root'); return undefined })
+    replacement.ctx.emit(replacement.ctx, 'test/scope-probe', { from: 'new' })
+    expect(seen).toEqual(['new', 'root'])
+    await previous.fiber.dispose()
+    seen.length = 0
+    replacement.ctx.emit(replacement.ctx, 'test/scope-probe', { from: 'new' })
+    expect(seen).toEqual(['new', 'root'])
+    await root.fiber.dispose()
+  })
+
   it('tags the ctx (scopeOf) and leaves the root untagged', () => {
     const { root, a } = bench()
     expect(scopeOf(a.ctx)).toBe(sid('a'))

+ 39 - 9
packages/api/session-controller/tests/session.client.spec.ts

@@ -5,14 +5,15 @@
  * proxies and is answered by endpoint name.
  */
 
-import { describe, expect, vi } from 'vitest'
+import { describe, expect, onTestFinished, vi } from 'vitest'
 import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
-import { JUMP_PAGE_MESSAGES, type Session } from '../src/client/sessions/session.ts'
+import { JUMP_PAGE_MESSAGES, Session } from '../src/client/sessions/session.ts'
 import { SessionEventStream } from '../src/client/transport.ts'
 import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts'
 import { entries, ev, historyValue, plainTurn } from './event-script.client.ts'
@@ -40,6 +41,15 @@ function eventSeqs(session: Session): number[] {
 }
 
 describe('Session open', () => {
+  it('rejects a second scope binding until the owned scope is unbound', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    const client = await start()
+    session.bindScope(client.ctx)
+    expect(() => { session.bindScope(client.ctx.extend()) }).toThrow('already has a bound scope')
+    session.unbindScope()
+    expect(() => { session.bindScope(client.ctx.extend()) }).not.toThrow()
+  })
+
   it('keeps a bare Session blank until an authoritative lifecycle signal arrives', async ({ mock, start }) => {
     const session = await sessionBench(mock, start, SID)
     expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false })
@@ -560,6 +570,31 @@ describe('prompt and cancel errors', () => {
       sessionId: SID, attachmentId: 'attachment-1',
     }])
   })
+
+  it('forwards attachment rejection without trying to decode absent bytes', async ({ mock, start }) => {
+    const session = await sessionBench(mock, start, SID)
+    const failure = new RemoteError('gateway/internal', 'attachment unavailable', {})
+    mock.remote.session.attachment.mockResolvedValueOnce(err(failure))
+    await expect(session.readAttachment(AttachmentId('missing'))).resolves.toMatchObject({ ok: false, error: failure })
+  })
+
+  it('reports an unmatched command and forwards execution failures without opening history', async ({ mock, start }) => {
+    const client = await start()
+    // The Gateway-only assembly has no commands contribution; this facade test supplies its unary dependency.
+    const session = new Session(SID, {
+      session: client.ctx.remote.session,
+      subagents: client.ctx.remote.subagents,
+      commands: mock.remote.commands,
+      $stream: client.ctx.remote.$stream.bind(client.ctx.remote),
+    })
+    onTestFinished(() => session.dispose())
+    mock.remote.commands.execute.mockResolvedValueOnce(ok(undefined))
+    await expect(session.command('/unknown')).resolves.toEqual({ ok: true, value: { matched: false } })
+    const failure = new RemoteError('gateway/internal', 'command unavailable', {})
+    mock.remote.commands.execute.mockResolvedValueOnce(err(failure))
+    await expect(session.command('/failed')).resolves.toMatchObject({ ok: false, error: failure })
+    expect(mock.log.requests(FOLLOW)).toHaveLength(0)
+  })
 })
 
 describe('rename', () => {
@@ -661,7 +696,7 @@ describe('remaining branches', () => {
     expect(notified).toBe(seen)
   })
 
-  it('rejects an opening page that does not end at the opening cursor', async ({ mock, start }) => {
+  it('records an opening page that does not end at the opening cursor', async ({ mock, start }) => {
     const session = await sessionBench(mock, start, SID)
     mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(0), 0, 'a', 'b')), { cursor: 11 }))
     await session.open()
@@ -722,7 +757,7 @@ describe('remaining branches', () => {
     expect(eventSeqs(session)).toHaveLength(6)
   })
 
-  it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async ({ mock, start }) => {
+  it('drops a stale opening without replacing the new generation error state', async ({ mock, start }) => {
     const session = await sessionBench(mock, start, SID)
     const stale = Promise.withResolvers<RemoteResult<SessionPage>>()
     mock.stream(FOLLOW, followScript(() => stale.promise))
@@ -770,11 +805,6 @@ describe('remaining branches', () => {
     expect(session.getSnapshot().promptError).toBeNull()
   })
 
-  it('dispose is a reserved no-op on resident instances', async ({ mock, start }) => {
-    const session = await sessionBench(mock, start, SID)
-    await expect(session.dispose()).resolves.toBeUndefined()
-  })
-
   it('carries raw history and follow events through the event feed', async ({ mock, start }) => {
     const session = await sessionBench(mock, start, SID)
     const historyCall = ev.toolCall(SessionSeq(6), 1, 'h1', 'bash', '{"cmd":"pwd"}')

+ 141 - 260
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -1,21 +1,15 @@
-/**
- * ClientSessions: list store projection (manager → {ids, byId, current}
- * with derived titles), the current-selection account (open validation and
- * persisted mask semantics), scope-tree
- * lifecycle (lazy mint / frozen survival / removed teardown with staged
- * deferral — the stage follows list.current), binding identity, breadcrumb
- * projection, create.
- */
+/** Client catalog projection, explicitly retained scopes, streams, and Host operations. */
 import { Context } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, vi } from 'vitest'
+import { describe, expect, vi } from 'vitest'
 import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SessionReference } from '../src/client/contract/sessions.ts'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
 import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock'
 import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
-import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
+import { ClientSessions, SessionCreateError, SessionForkError } from '../src/client/sessions/service.ts'
 import { scopeOf } from '../src/client/scope.ts'
 import type {
   SessionAssistantStreamBaseline, SessionFollowFrame, SessionFollowRequest,
@@ -32,6 +26,7 @@ interface Bench {
   ctx: Context
   mock: RemoteMock
   svc: ClientSessions
+  unblock: Array<() => void>
 }
 
 type BenchFactory = () => Bench
@@ -40,16 +35,18 @@ const it = createClientTest({ roster: API_ROSTER }).extend<{ bench: BenchFactory
   bench: async ({ mock, start }, use) => {
     mock.load(sessionWorld)
     const client = await start()
-    const contexts: Context[] = []
+    const benches: Bench[] = []
     try {
       await use(() => {
         const ctx = new Context()
-        contexts.push(ctx)
         const svc = new ClientSessions(ctx, client.ctx.remote)
-        return { ctx, mock, svc }
+        const b = { ctx, mock, svc, unblock: [] as Array<() => void> }
+        benches.push(b)
+        return b
       })
     } finally {
-      await Promise.all(contexts.map(ctx => ctx.fiber.dispose()))
+      for (const b of benches) for (const finish of b.unblock) finish()
+      await Promise.all(benches.map(b => b.ctx.fiber.dispose()))
     }
   },
 })
@@ -152,7 +149,8 @@ describe('scope tree', () => {
   it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await _reference.ready
     const binding = b.svc.binding(sid('s1'))
     if (binding === undefined) throw new Error('expected Session binding')
     await vi.waitFor(() => {
@@ -243,7 +241,8 @@ describe('scope tree', () => {
       { assistantStream: () => assistantStreamBaseline },
     ))
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await _reference.ready
     const binding = b.svc.binding(sid('s1'))
     if (binding === undefined) throw new Error('expected Session binding')
     await vi.waitFor(() => {
@@ -328,7 +327,8 @@ describe('scope tree', () => {
     }
     b.mock.stream(FOLLOW, followScript(history, { assistantStream: assistantStreamBaseline }))
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await _reference.ready
     const binding = b.svc.binding(sid('s1'))
     if (binding === undefined) throw new Error('expected Session binding')
     await vi.waitFor(() => {
@@ -404,7 +404,8 @@ describe('scope tree', () => {
       { assistantStream: () => assistantStreamBaseline },
     ))
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await _reference.ready
     const binding = b.svc.binding(sid('s1'))
     if (binding === undefined) throw new Error('expected Session binding')
     await vi.waitFor(() => {
@@ -437,72 +438,33 @@ describe('scope tree', () => {
     })
   })
 
-  it('retains a Host-addressed scope until the first Session baseline owns pruning', async ({ bench }) => {
+  it('holds a Host-addressed Context across an empty catalog baseline', async ({ bench }) => {
     const b = bench()
-    const scoped = b.svc.resolveAgentScope(sid('s-early'))
+    using reference = b.svc.retainAgentScope(sid('s-early'))
+    const scoped = reference.binding.ctx
     expect(scopeOf(scoped)).toBe('s-early')
-
-    b.svc.handleControlFrame({
-      type: 'baseline',
-      value: { jobs: {}, projections: {} },
-    })
-    await Promise.resolve()
-    expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped)
-
+    b.svc.handleControlFrame({ type: 'baseline', value: { jobs: {}, projections: {} } })
     await feedList(b, [])
+    expect(b.svc.scope(sid('s-early'))).toBe(scoped)
+    reference.release()
     expect(b.svc.scope(sid('s-early'))).toBeUndefined()
   })
 
-  it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async ({ bench }) => {
+  it('borrows only retained bindings and preserves them while the catalog changes', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
-    expect(b.svc.scope(sid('unknown'))).toBeUndefined()
-    const scoped = b.svc.scope(sid('s1'))
-    expect(scoped).toBeDefined()
-    expect(scopeOf(scoped as Context)).toBe('s1')
-    expect(scopeOf(b.ctx)).toBeUndefined()
-    const binding = b.svc.binding(sid('s1'))
-    b.svc.open(sid('s1'))
-    expect(b.svc.sessionOf(scoped as Context)).toBe(binding?.session)
-    expect(b.svc.binding(sid('s1'))).toBe(binding)
-    expect(binding?.ctx).toBe(scoped)
-  })
-
-  it('tears down an off-stage removed session but defers the staged one until the stage moves', async ({ bench }) => {
-    const b = bench()
-    await feedList(b, [{ id: 's1' }, { id: 's2' }])
-    const ctx1 = b.svc.scope(sid('s1'))
-    b.svc.open(sid('s1')) // s1 staged (current)
-    b.svc.scope(sid('s2')) // s2 scoped but off stage
-
-    await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down
-    expect(b.svc.scope(sid('s2'))).toBeUndefined()
-
-    await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives
-    expect(b.svc.scope(sid('s1'))).toBe(ctx1)
-
-    await feedList(b, [{ id: 's3' }])
-    b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1
     expect(b.svc.scope(sid('s1'))).toBeUndefined()
-  })
-
-  it('keeps the scope when the session merely stops running (frozen ≠ removed)', async ({ bench }) => {
-    const b = bench()
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
+    const binding = reference.binding
+    expect(b.svc.scope(sid('s1'))).toBe(binding.ctx)
+    expect(b.svc.sessionOf(binding.ctx)).toBe(binding.session)
+    await feedList(b, [])
+    expect(b.svc.binding(sid('s1'))).toBe(binding)
     await feedList(b, [{ id: 's1', running: true }])
-    const scoped = b.svc.scope(sid('s1'))
-    await feedList(b, [{ id: 's1', running: false }])
-    expect(b.svc.scope(sid('s1'))).toBe(scoped)
-  })
-
-  it('cancels a deferred teardown when the id reappears in the list', async ({ bench }) => {
-    const b = bench()
-    await feedList(b, [{ id: 's1' }])
-    const scoped = b.svc.scope(sid('s1'))
-    b.svc.open(sid('s1'))
-    await feedList(b, []) // removed while staged → deferred
-    await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged)
-    b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1
-    expect(b.svc.scope(sid('s1'))).toBe(scoped)
+    expect(b.svc.binding(sid('s1'))).toBe(binding)
+    reference.release()
+    expect(b.svc.binding(sid('s1'))).toBeUndefined()
   })
 
   it('closes an opened journal when its removed scope drops', async ({ bench }) => {
@@ -512,7 +474,8 @@ describe('scope tree', () => {
       return request.address.kind === 'session' && request.address.sessionId === sid('s1')
     })
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
     const session = b.svc.binding(sid('s1'))?.session
     if (session === undefined) throw new Error('expected the selected Session binding')
     await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(1) })
@@ -520,8 +483,8 @@ describe('scope tree', () => {
     session.subscribe(notified)
 
     await feedList(b, [])
-    await feedList(b, [{ id: 's2' }])
-    b.svc.open(sid('s2'))
+    expect(b.svc.binding(sid('s1'))?.session).toBe(session)
+    reference.release()
 
     await vi.waitFor(() => { expect(follows().filter(stream => stream.state === 'open')).toHaveLength(0) })
     notified.mockClear()
@@ -548,7 +511,8 @@ describe('Agent scope disposal lifecycle', () => {
       sessionId: sid('live'), updatedAt: 1, running: false, blank: true,
     })
     await Promise.resolve()
-    const scoped = b.svc.scope(sid('live'))
+    using reference = b.svc.retainAgentScope(sid('live'))
+    const scoped = reference.binding.ctx
     if (scoped === undefined) throw new Error('fixture Agent Context was not minted')
     await scoped.fiber.await()
     const scopeDisposed = vi.fn()
@@ -564,6 +528,7 @@ describe('Agent scope disposal lifecycle', () => {
     const abortObserved = vi.fn()
     let followSignal: AbortSignal | undefined
     const b = bench()
+    b.unblock.push(() => { closeGate.resolve(undefined) })
     b.mock.remote.session.follow.mockImplementation((request, signal) => {
       if (signal === undefined) throw new Error('fixture requires a signal')
       followSignal = signal
@@ -610,7 +575,8 @@ describe('Agent scope disposal lifecycle', () => {
     const readiness = b.ctx.plugin(() => undefined)
     await readiness
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await _reference.ready
     await vi.waitFor(() => {
       expect(b.svc.binding(sid('s1'))?.session.getSnapshot().openState).toBe('open')
     })
@@ -628,10 +594,11 @@ describe('Agent scope disposal lifecycle', () => {
     expect(settled).toHaveBeenCalledOnce()
   })
 
-  it('root disposal joins every Session drop already started by pruning under load', async ({ bench }) => {
+  it('root disposal joins every Session drop already started by final release under load', async ({ bench }) => {
     const closeGates = new Map<SessionId, PromiseWithResolvers<undefined>>()
     const aborted = new Set<SessionId>()
     const b = bench()
+    b.unblock.push(() => { for (const gate of closeGates.values()) gate.resolve(undefined) })
     b.mock.remote.session.follow.mockImplementation((request, signal) => {
       if (signal === undefined) throw new Error('fixture requires a signal')
       const sessionId = request.address.kind === 'session'
@@ -679,7 +646,12 @@ describe('Agent scope disposal lifecycle', () => {
     const held = sessionIds[0]
     if (retained === undefined || held === undefined) throw new Error('fixture requires sessions')
     await feedList(b, sessionIds.map(id => ({ id })))
-    for (const id of sessionIds) b.svc.open(id)
+    const references = new Map<SessionId, SessionReference>()
+    for (const id of sessionIds) {
+      const reference = b.svc.retain(id, { source: 'controllerOperation' })
+      await reference.ready
+      references.set(id, reference)
+    }
     await vi.waitFor(() => {
       for (const id of sessionIds) {
         expect(b.svc.binding(id)?.session.getSnapshot().openState).toBe('open')
@@ -688,6 +660,7 @@ describe('Agent scope disposal lifecycle', () => {
 
     const pruned = sessionIds.slice(0, -1)
     await feedList(b, [{ id: retained }])
+    for (const id of pruned) references.get(id)?.release()
     await vi.waitFor(() => { expect(aborted.size).toBe(pruned.length) })
     for (const id of pruned) expect(b.svc.scope(id)).toBeUndefined()
 
@@ -703,7 +676,6 @@ describe('Agent scope disposal lifecycle', () => {
       otherClosures.push(gate.promise)
     }
     await Promise.all(otherClosures)
-    await new Promise((resolve) => { setTimeout(resolve, 0) })
     expect(settled).not.toHaveBeenCalled()
 
     closeGates.get(held)?.resolve(undefined)
@@ -712,126 +684,42 @@ describe('Agent scope disposal lifecycle', () => {
   })
 })
 
-describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => {
-  afterEach(() => { vi.unstubAllGlobals() })
-
-  it('open() writes list.current; unknown ids fail loud', async ({ bench }) => {
-    const b = bench()
-    await feedList(b, [{ id: 's1' }])
-    expect(b.svc.list.getSnapshot().current).toBeUndefined()
-    b.svc.open(sid('s1'))
-    expect(b.svc.list.getSnapshot().current).toBe('s1')
-    expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/)
-    expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
-  })
-
-  it('clear() blanks list.current and the persisted selection', async ({ bench }) => {
-    const storage = new Map<string, string>()
-    vi.stubGlobal('localStorage', {
-      getItem: (k: string) => storage.get(k) ?? null,
-      setItem: (k: string, v: string) => { storage.set(k, v) },
-      removeItem: (k: string) => { storage.delete(k) },
-      clear: () => { storage.clear() },
-    })
-    const b = bench()
-    await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
-    expect(storage.get('dsh.sessions.current')).toContain('s1')
-    b.svc.clear()
-    expect(b.svc.list.getSnapshot().current).toBeUndefined()
-    // Persisted wipe: a fresh service with the same storage stays on empty.
-    const again = bench()
-    await feedList(again, [{ id: 's1' }])
-    expect(again.svc.list.getSnapshot().current).toBeUndefined()
-  })
-
-  it('masks (not destroys) the selection while its session is off the list', async ({ bench }) => {
+describe('borrow-only bindings', () => {
+  it('keeps catalog discovery separate from history opening and ownership', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
-    b.svc.open(sid('s1'))
-    await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state
-    expect(b.svc.list.getSnapshot().current).toBeUndefined()
-    await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces
-    expect(b.svc.list.getSnapshot().current).toBe('s1')
-  })
-
-  it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async ({ bench }) => {
-    const storage = new Map<string, string>()
-    vi.stubGlobal('localStorage', {
-      getItem: (k: string) => storage.get(k) ?? null,
-      setItem: (k: string, v: string) => { storage.set(k, v) },
-    })
-    const first = bench()
-    await feedList(first, [{ id: 's1' }])
-    first.svc.open(sid('s1'))
-    expect(storage.get('dsh.sessions.current')).toContain('s1')
-    // A fresh boot (same storage) recovers the selection once the list holds the session.
-    const second = bench()
-    await feedList(second, [{ id: 's1' }])
-    expect(second.svc.list.getSnapshot().current).toBe('s1')
+    expect(b.svc.binding(sid('s1'))).toBeUndefined()
+    expect(b.svc.scope(sid('s2'))).toBeUndefined()
+    expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
+    using second = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await second.ready
+    expect(second.binding).toBe(reference.binding)
+    expect(b.mock.log.requests(FOLLOW)).toHaveLength(1)
   })
 })
 
-describe('binding and stage lifecycle', () => {
-  it('binding() is pure resolution: no staging, no deferred sweep', async ({ bench }) => {
-    const b = bench()
-    await feedList(b, [{ id: 's1' }, { id: 's2' }])
-    b.svc.open(sid('s1')) // staged
-    b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
-    await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
-    expect(b.svc.scope(sid('s1'))).toBeDefined()
-  })
-
-  it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async ({ bench }) => {
+describe('catalog-addressed navigation', () => {
+  it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => {
     const b = bench()
-    await feedList(b, [{ id: 's1' }, { id: 's2' }])
-    const followStarts = () => b.mock.log.requests(FOLLOW).map((request) => {
-      const address = (request as SessionFollowRequest).address
-      return String(address.kind === 'session' ? address.sessionId : address.childSessionId)
-    })
-    // Resolution is addressing, not staging: no window pull.
-    b.svc.scope(sid('s1'))
-    b.svc.binding(sid('s1'))
-    expect(followStarts()).toEqual([])
-    b.svc.open(sid('s1'))
-    await vi.waitFor(() => {
-      expect(followStarts()).toEqual(['s1'])
-    })
-    // Same current again: no second pull.
-    b.svc.open(sid('s1'))
-    expect(followStarts()).toHaveLength(1)
-    // Stage moves: the new occupant opens.
-    b.svc.open(sid('s2'))
-    await vi.waitFor(() => {
-      expect(followStarts()).toEqual(['s1', 's2'])
-    })
-  })
+    b.mock.remote.subagents.list.mockResolvedValue(ok({
+      entries: [
+        { kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false },
+        { kind: 'diagnostic', id: sid('missing'), reason: 'unavailable' },
+      ],
+      parentAvailable: true,
+    }))
+    b.svc.setSubagentCatalogOpen(sid('root'), true)
+    await b.svc.refreshSubagents(sid('root'))
+    b.svc.setSubagentCatalogOpen(sid('root'), false)
 
-  it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async ({ bench }) => {
-    const storage = new Map<string, string>([
-      ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })],
-    ])
-    vi.stubGlobal('localStorage', {
-      getItem: (k: string) => storage.get(k) ?? null,
-      setItem: (k: string, v: string) => { storage.set(k, v) },
-    })
-    try {
-      const b = bench()
-      expect(b.mock.log.requests(FOLLOW)).toEqual([])
-      await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows
-      await vi.waitFor(() => {
-        expect(b.mock.log.requests(FOLLOW).map((request) => {
-          const address = (request as SessionFollowRequest).address
-          return String(address.kind === 'session' ? address.sessionId : address.childSessionId)
-        })).toEqual(['s1'])
-      })
-    } finally {
-      vi.unstubAllGlobals()
-    }
+    expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe('one-shot')
+    expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined()
+    expect(b.svc.binding(sid('one-shot'))).toBeUndefined()
+    expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce()
   })
-})
 
-describe('catalog-addressed navigation', () => {
   it('uses catalog labels for a listed addressed route', async ({ bench }) => {
     const b = bench()
     b.mock.remote.subagents.list.mockImplementation((payload) => {
@@ -863,15 +751,16 @@ describe('catalog-addressed navigation', () => {
     ])
     await b.svc.refreshSubagents(sid('root'))
     await b.svc.refreshSubagents(sid('child'))
-    b.svc.openSubagent({
+    using _reference = b.svc.retain({
       parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
-    })
+    }, { source: 'controllerOperation' })
+    await _reference.ready
 
     expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
     expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
   })
 
-  it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async ({ bench }) => {
+  it('projects a retained descendant and discovers ancestor addresses without retaining ancestor scopes', async ({ bench }) => {
     const b = bench()
     b.mock.remote.subagents.list.mockImplementation((payload) => {
       const parentSessionId = payload
@@ -898,22 +787,23 @@ describe('catalog-addressed navigation', () => {
     await feedList(b, [{ id: 'root' }])
     await b.svc.refreshSubagents(sid('root'))
     await b.svc.refreshSubagents(sid('child'))
-    b.svc.openSubagent({
+    using reference = b.svc.retain({
       parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
-    })
+    }, { source: 'controllerOperation' })
+    await reference.ready
 
     const list = b.svc.list.getSnapshot()
     expect(list.ids).toEqual([sid('root')])
     expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
     expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
     expect(b.svc.binding(sid('child'))).toBeUndefined()
-    expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
-
-    b.svc.open(sid('child'))
-    expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
     expect(b.svc.subagentAddress(sid('child'))).toEqual({
       parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
     })
+    using child = b.svc.retain(sid('child'), { source: 'controllerOperation' })
+    await child.ready
+    expect(b.svc.binding(sid('child'))).toBe(child.binding)
+    expect(b.svc.binding(sid('grandchild'))).toBe(reference.binding)
   })
 })
 
@@ -932,16 +822,19 @@ describe('create', () => {
     })
   })
 
-  it('resolves with the session already listed and binding-resolvable (no flush wait)', async ({ bench }) => {
+  it('publishes a created identity without implicitly retaining its binding', async ({ bench }) => {
     const b = bench()
     b.mock.remote.session.create.mockResolvedValue(ok({ sessionId: sid('born') }))
     const born = await b.svc.create({ workspaceId: 'ws' as never })
     // Synchronously after resolution — the draft hand-off contract: the
     // create echo IS the entity entering the client's view (blank row +
-    // resolvable scope/binding), no notifier flush in between.
+    // catalog publication), no notifier flush in between.
     expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true })
-    expect(b.svc.binding(born)).toBeDefined()
-    expect(b.svc.scope(born)).toBeDefined()
+    expect(b.svc.binding(born)).toBeUndefined()
+    expect(b.svc.scope(born)).toBeUndefined()
+    using reference = b.svc.retain(born, { source: 'controllerOperation' })
+    await reference.ready
+    expect(b.svc.binding(born)).toBe(reference.binding)
   })
 
   it('lists the published id after Workspace attachment fails (publication precedes attachment)', async ({ bench }) => {
@@ -966,6 +859,17 @@ describe('create', () => {
 })
 
 describe('fork', () => {
+  it('propagates a failed fork without creating or retaining a child', async ({ bench }) => {
+    const b = bench()
+    const error = new RemoteError('session/not-found', 'source missing', { sessionId: sid('source') })
+    b.mock.remote.session.fork.mockResolvedValue(err(error))
+    const failure = await b.svc.fork({ sessionId: sid('source') }).catch((cause: unknown) => cause)
+    expect(failure).toBeInstanceOf(SessionForkError)
+    expect(failure).toMatchObject({ sourceSessionId: 'source', rpcError: error })
+    expect(b.svc.list.getSnapshot().ids).toEqual([])
+    expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
+  })
+
   it.for([
     ['Roadmap', 'Roadmap (1)'],
     ['Roadmap (1)', 'Roadmap (2)'],
@@ -1020,7 +924,7 @@ describe('fork', () => {
     expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
   })
 
-  it('rejects when child rename fails while keeping the published child addressable', async ({ bench }) => {
+  it('rejects child rename failure while preserving its catalog row and releasing the operation', async ({ bench }) => {
     const b = bench()
     b.svc.handleControlFrame({
       type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2,
@@ -1031,25 +935,28 @@ describe('fork', () => {
 
     await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
       .rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
-    expect(b.svc.binding(sid('child'))).toBeDefined()
+    expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
+    expect(b.svc.binding(sid('child'))).toBeUndefined()
+    expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
   })
 })
 
-describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => {
-  it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async ({ bench }) => {
+describe('catalog arrival', () => {
+  it('keeps a retained generation indexed after its Host row is removed', async ({ bench }) => {
     const b = bench()
-    await feedList(b, [])
-    expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
-    b.svc.handleSessionAdded({
-      sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
-    })
+    b.svc.handleSessionAdded({ sessionId: sid('s-new'), updatedAt: 1, running: false, blank: true })
     await Promise.resolve()
-    const scoped = b.svc.scope(sid('s-new'))
-    expect(scoped).toBeDefined()
-    expect(scopeOf(scoped as Context)).toBe('s-new')
+    expect(b.svc.binding(sid('s-new'))).toBeUndefined()
+    const reference = b.svc.retain(sid('s-new'), { source: 'controllerOperation' })
+    await reference.ready
+    const binding = reference.binding
     b.svc.handleSessionRemoved(sid('s-new'))
     await Promise.resolve()
-    expect(b.svc.scope(sid('s-new'))).toBeUndefined()
+    expect(b.svc.list.getSnapshot().ids).not.toContain(sid('s-new'))
+    expect(b.svc.list.getSnapshot().byId[sid('s-new')]?.retainedBy).toEqual({ controllerOperation: 1 })
+    expect(b.svc.binding(sid('s-new'))).toBe(binding)
+    reference.release()
+    expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toBeUndefined()
   })
 })
 
@@ -1058,6 +965,8 @@ describe('blank mirror', () => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true }])
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true })
+    using _reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await _reference.ready
     b.svc.handleSessionStatus(sid('s1'), true)
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true })
@@ -1068,7 +977,9 @@ describe('blank mirror', () => {
   it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
-    const session = b.svc.binding(sid('s1'))!.session
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
+    const session = reference.binding.session
     expect(session.getSnapshot().blank).toBe(true)
     const gate = Promise.withResolvers<Awaited<ReturnType<typeof b.mock.remote.session.prompt>>>()
     b.mock.remote.session.prompt.mockReturnValue(gate.promise)
@@ -1086,7 +997,9 @@ describe('blank mirror', () => {
   it('keeps a rejected first prompt blank: hidden and still reusable', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }])
-    const session = b.svc.binding(sid('s1'))!.session
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
+    const session = reference.binding.session
     b.mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('gateway/internal', 'agent busy', {})))
     const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
     expect(result.ok).toBe(false)
@@ -1113,7 +1026,9 @@ describe('blank mirror', () => {
   it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1', blank: true }])
-    const session = b.svc.binding(sid('s1'))!.session
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
+    const session = reference.binding.session
     await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
     await Promise.resolve()
     expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false })
@@ -1133,46 +1048,12 @@ describe('coverage tails (branch duals)', () => {
     expect(byId[sid('no-base')]?.title).toBeUndefined()
   })
 
-  it('binding for an unknown session returns undefined and leaves the staged scope intact', async ({ bench }) => {
+  it('reading an unknown binding leaves an existing reference unchanged', async ({ bench }) => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
+    using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
+    await reference.ready
     expect(b.svc.binding(sid('ghost'))).toBeUndefined()
-    // Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing.
-    await feedList(b, [])
-    expect(b.svc.scope(sid('s1'))).toBeDefined()
+    expect(b.svc.binding(sid('s1'))).toBe(reference.binding)
   })
-
-  it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async ({ bench }) => {
-    const b = bench()
-    await feedList(b, [{ id: 's1' }])
-    b.svc.open(sid('s1'))
-    await vi.waitFor(() => { expect(b.mock.log.requests(FOLLOW)).toHaveLength(1) })
-    await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred
-    expect(b.svc.scope(sid('s1'))).toBeDefined()
-    // Resurfacing re-projects current = s1: same stage occupant, no second pull.
-    await feedList(b, [{ id: 's1' }])
-    expect(b.mock.log.requests(FOLLOW)).toHaveLength(1)
-    expect(b.svc.list.getSnapshot().current).toBe('s1')
-  })
-
-  it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async ({ bench }) => {
-    const b = bench()
-    await feedList(b, [{ id: 'a' }, { id: 'b' }])
-    b.svc.scope(sid('a'))
-    b.svc.open(sid('b')) // stage: b; both scoped
-    await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred
-    // Move the stage to a THIRD id while b stays deferred: sweep walks a set
-    // containing b (torn).
-    await feedList(b, [{ id: 'c' }])
-    b.svc.open(sid('c'))
-    expect(b.svc.scope(sid('b'))).toBeUndefined()
-    // Deferral for an id whose record was never minted: force the deferral
-    // via removed list state — sweep must tolerate the missing record.
-    await feedList(b, []) // c removed while staged → deferred (scope exists)
-    await feedList(b, [{ id: 'd' }])
-    b.svc.open(sid('d')) // sweep tears c
-    expect(b.svc.scope(sid('c'))).toBeUndefined()
-  })
-
 })

+ 5 - 5
packages/bundle/web-app/cordis.patch.yml

@@ -282,11 +282,6 @@
     - id: ui-cordis
       name: '@deepseek-ai/dsh-client-ui-cordis'
 
-    # Durable workflow lifecycle as an independent Chat node after the
-    # existing generic workflow tool row.
-    - id: ui-workflow-run
-      name: '@deepseek-ai/dsh-client-ui-workflow-run'
-
     # Turn tail: the changed-files card and delivery cards under each closing
     # assistant message. Remove this entry to turn the surface off; the tail
     # hole renders empty.
@@ -302,6 +297,11 @@
     - id: ui-workspace
       name: '@deepseek-ai/dsh-client-ui-workspace'
 
+    # Durable workflow lifecycle as an independent Chat node after the
+    # existing generic workflow tool row.
+    - id: ui-workflow-run
+      name: '@deepseek-ai/dsh-client-ui-workflow-run'
+
     # Input triggers: the '/' | '@' pipeline (ui-input-trigger), the command surface over
     # it (ui-commands), and the reference sources (ui-skill / ui-reference).
     - id: ui-input-trigger

+ 5 - 5
packages/client/locale/tests/language-row.client.spec.tsx

@@ -20,7 +20,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
 
 function emptySessions() {
   const store = createSnapshotStore<SessionListState>(
-    { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
+    { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
   return bindSnapshotSelector(store)
 }
 function emptyWorkspaces() {
@@ -30,9 +30,9 @@ function emptyWorkspaces() {
   return bindSnapshotSelector(store)
 }
 
-type AttentionSnapshot = Parameters<Parameters<LanguageRowComponentProps['useSessionPendingInteraction']>[0]>[0]
+type AttentionSnapshot = Parameters<Parameters<LanguageRowComponentProps['useSessionStatus']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
-const useSessionPendingInteraction: LanguageRowComponentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
+const useSessionStatus: LanguageRowComponentProps['useSessionStatus'] = selector => selector(noAttention)
 
 function mount(active = 'en') {
   // Real store instance — the sanctioned zero-machinery path for tests.
@@ -41,8 +41,8 @@ function mount(active = 'en') {
   const setLocale = vi.fn()
   const props: LanguageRowComponentProps = {
     useSessions: emptySessions(),
-    useSessionPendingInteraction,
-    usePanelInfo, useResource,
+    useSessionStatus,
+    usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 14 - 6
packages/client/resources/tests/apply.client.spec.ts

@@ -104,11 +104,15 @@ describe('client-resources apply', () => {
     expect(hook).toBeTypeOf('function')
   })
 
-  it('shares one address across Root and Session components without reopening on selection', async () => {
+  it('shares one address across independently bound views without reopening on rebinding', async () => {
     runtime = await boot()
     const rt = runtime
     const firstId = await rt.sessions.add({ id: 'first-session' })
-    const secondId = await rt.sessions.add({ id: 'second-session' }, { current: false })
+    const secondId = await rt.sessions.add({ id: 'second-session' })
+    const firstReference = rt.sessions.retain(firstId)
+    await firstReference.ready
+    const secondReference = rt.sessions.retain(secondId)
+    await secondReference.ready
     await rt.mount({ inject: [...inject], apply })
     const opened = Promise.withResolvers<undefined>()
     const open = vi.fn<ResourceProvider<'feed'>['open']>(async function* () {
@@ -140,20 +144,24 @@ describe('client-resources apply', () => {
       return null
     })
     rt.renderSlot('resources.probe', {})
-    rt.renderSlot('resources.sessionProbe', {})
-    rt.renderSlot('resources.sessionPeer', {})
+    const firstView = rt.renderSlot('resources.sessionProbe', {}, { session: firstReference })
+    const secondView = rt.renderSlot('resources.sessionPeer', {}, { session: secondReference })
     await act(async () => { await opened.promise })
     const snapshot = source.getSnapshot()
     expect(snapshot).toEqual({ status: 'live', value: 'shared data', failure: undefined })
     expect(seen.root).toBe(snapshot)
     expect(seen.first).toBe(snapshot)
     expect(seen.second).toBe(snapshot)
-    expect([seen.firstSession, seen.secondSession]).toEqual([firstId, firstId])
+    expect([seen.firstSession, seen.secondSession]).toEqual([firstId, secondId])
     expect(open).toHaveBeenCalledTimes(1)
     expect(open.mock.calls[0]![0]).toBe(A)
     expect(open.mock.calls[0]![1]).toStrictEqual({ signal: expect.any(AbortSignal) as AbortSignal })
 
-    await rt.sessions.setCurrent(secondId)
+    const replacement = rt.sessions.retain(secondId)
+    await replacement.ready
+    firstView.update({}, { session: replacement })
+    firstReference.release()
+    secondView.update({})
     expect([seen.firstSession, seen.secondSession]).toEqual([secondId, secondId])
     expect(rt.ctx.resources.source(A)).toBe(source)
     expect(seen.root).toBe(snapshot)

+ 2 - 3
packages/client/store/src/contract.ts

@@ -77,9 +77,8 @@ export interface StoreInstance<T, A extends ActionsDecl<T>> {
    */
   subscribe(fn: () => void): () => void
   /**
-   * Drop this instance's persisted value (no-op for non-persist specs). The
-   * framework calls it when the owning scope dies for good — a pruned session
-   * must not leave orphaned storage keys behind.
+   * Explicitly drop this instance's persisted value.
+   * Runtime scope teardown does not call this operation.
    */
   clearPersisted(): void
 }

+ 1 - 0
packages/client/ui-agent-preset/package.json

@@ -61,6 +61,7 @@
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",

+ 6 - 2
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx

@@ -82,8 +82,12 @@ export type AgentPresetSeatProps =
  * @param props - composed slot props.
  * @returns the chip, or null when the deployment composes no presets.
  */
-export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
+export function AgentPresetSeat({
+  sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, t,
+}: AgentPresetSeatProps) {
   const state = useAgentPresetSeat(snapshot => snapshot)
+  const main = useSessionRetainInfo(info => sessionId === undefined
+    || (info?.retainedBy.mainView ?? 0) > 0)
   const [open, setOpen] = useState(false)
   // The seq keys the banner, so picking the same broken preset twice replays
   // it rather than leaving the first one silently in place.
@@ -132,7 +136,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
 
   // Nothing to choose between: the deployment composes no presets and every
   // session shares the host composition.
-  if (!state.showPicker || !ready) return null
+  if (!main || !state.showPicker || !ready) return null
 
   // One wrapper span: the chip is a flex row with a gap, so loose character
   // spans would each pick up the gap between them.

+ 56 - 54
packages/client/ui-agent-preset/src/client/index.ts

@@ -13,7 +13,9 @@
  */
 
 // Type-only: pulls the Session Controller service merge (ctx.sessions).
-import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the ctx.remote merge and the forwarded-event key face
@@ -31,7 +33,7 @@ import { AgentPresetSeat } from './AgentPresetSeat.tsx'
 import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
 import { AgentPresetSection } from './AgentPresetSection.tsx'
 import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
-import { AgentPresetSeatController } from './seat-store.ts'
+import { AgentPresetSeatController, type AgentPresetStage } from './seat-store.ts'
 import { AgentPresetSectionController } from './section-store.ts'
 import { en, zh, type AgentPresetSettingsKey } from './locales.ts'
 import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
@@ -55,7 +57,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t
 
 /** Required services (cordis fiber inject). */
 export const inject = [
-  'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
+  'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
 ]
 
 /**
@@ -64,13 +66,40 @@ export const inject = [
  */
 export function apply(ctx: ClientContext): void {
   const controller = new AgentPresetSettingsController(ctx)
-  // One roster, three surfaces. The chip is registered in a later scope, so it
-  // subscribes here rather than being reached from this one.
-  const rosterReaders = new Set<() => void>()
+  const staged: AgentPresetStage = { id: undefined, introduce: false }
+  const seats = new WeakMapWithValues<SessionBinding, AgentPresetSeatController>()
+  const unboundSeat = new AgentPresetSeatController(ctx, () => undefined, staged)
+  const seatFor = (scope: ClientContext, binding: SessionBinding): AgentPresetSeatController => {
+    let seat = seats.get(binding)
+    if (seat !== undefined) return seat
+    seat = new AgentPresetSeatController(scope, () => {
+      if (scope.sessions.binding(binding.sessionId) !== binding) return undefined
+      const summary = scope.sessions.list.getSnapshot().byId[binding.sessionId]
+      return summary !== undefined
+        && (scope.sessions.retainInfo(binding.sessionId).getSnapshot().retainedBy.mainView ?? 0) > 0
+        ? summary
+        : undefined
+    }, staged)
+    seats.set(binding, seat)
+    binding.ctx.effect(() => () => {
+      seats.delete(binding)
+    }, 'ui-agent-preset: Provider binding')
+    return seat
+  }
   const section = new AgentPresetSectionController(ctx, () => {
     void controller.load()
-    for (const read of rosterReaders) read()
+    void unboundSeat.load()
+    for (const seat of seats.values) void seat.load()
   })
+  const mainBlankSeat = (scope: ClientContext): AgentPresetSeatController | undefined => {
+    const summary = Object.values(scope.sessions.list.getSnapshot().byId)
+      .find((session) => {
+        /* v8 ignore next -- retained source counts omit zero-valued entries. */
+        return session.blank && (session.retainedBy.mainView ?? 0) > 0
+      })
+    const binding = summary === undefined ? undefined : scope.sessions.binding(summary.id)
+    return binding === undefined ? undefined : seatFor(scope, binding)
+  }
 
   ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
 
@@ -82,6 +111,8 @@ export function apply(ctx: ClientContext): void {
       // The section reads the same roster and marks the same default, so a
       // change made from either surface converges both.
       if (section.store.getSnapshot().status !== 'idle') void section.load()
+      void unboundSeat.load()
+      for (const seat of seats.values) void seat.load()
     }
     const disposers = [
       ctx.remote.$on('settings/document-updated', (ns) => {
@@ -90,7 +121,6 @@ export function apply(ctx: ClientContext): void {
       }),
       ctx.on('connection/reset', () => {
         refresh()
-        for (const read of rosterReaders) read()
       }),
     ]
     return () => { for (const dispose of disposers) dispose() }
@@ -102,22 +132,17 @@ export function apply(ctx: ClientContext): void {
   // unbound with it, so the section's face reads the current binding per
   // render and simply hides the button while no flow exists.
   let creatorDraft: (() => void) | undefined
-  let activeSeat: AgentPresetSeatController | undefined
-
-  // The new-session chip and the header label: one controller, because the
-  // staged choice belongs to the flow rather than to any one session.
   ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
-    const seat = new AgentPresetSeatController(scope, () => {
-      const state = scope.sessions.list.getSnapshot()
-      return state.current === undefined ? undefined : state.byId[state.current]
-    })
-    activeSeat = seat
-    const seatInjected = (): AgentPresetSeatInjected => ({
-      hooks: { agentPresetSeat: seat.store },
-      load: () => seat.load(),
-      select: (id: string) => seat.select(id),
-      introduced: () => { seat.introduced() },
-    })
+    const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => {
+      const binding = sessionId === undefined ? undefined : scope.sessions.binding(sessionId)
+      const seat = binding === undefined ? unboundSeat : seatFor(scope, binding)
+      return {
+        hooks: { agentPresetSeat: seat.store },
+        load: () => seat.load(),
+        select: (id: string) => seat.select(id),
+        introduced: () => { seat.introduced() },
+      }
+    }
 
     const labelInjected = (): AgentPresetLabelInjected => ({
       hooks: { agentPresets: controller.store },
@@ -125,35 +150,12 @@ export function apply(ctx: ClientContext): void {
     })
 
     scope.effect(() => {
-      // Connecting a workspace either creates a blank session or reuses one,
-      // and either way the chip's pick predates it — so the stage is applied
-      // when the session arrives, not when it was made.
-      const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
-      // The chip opens on the deployment default, so a default changed from
-      // the settings surface moves it too — otherwise the screen that starts
-      // the next session keeps offering the previous default until a reload,
-      // which is exactly the session the setting claims to govern. A staged
-      // pick survives: `load()` prefers it over the refreshed fallback.
-      const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
-        if (ns !== AGENT_PRESET_SETTINGS_NS) return
-        void seat.load()
-      })
-      // Authoring writes a FILE, not a setting, so nothing on the wire
-      // announces it — without this the screen that starts the next session
-      // keeps offering the roster as it stood when the chip first loaded, and
-      // a preset authored to be used is missing from the one place it is used.
-      const readRoster = (): void => { void seat.load() }
-      rosterReaders.add(readRoster)
-      // Stage WITHOUT applying — the still-current running session would
-      // refuse the swap and drop the stage — then start the session it lands
-      // on: the chip's list-change applier composes the blank session the
-      // workspace connect produces or reuses.
       creatorDraft = () => {
         if (!section.store.getSnapshot().showPicker) return
-        // The introduce cue makes the chip announce the pick the user never
-        // made on this screen — the stage happened back in settings.
+        const seat = mainBlankSeat(scope) ?? unboundSeat
         seat.stage('cordis', true)
         scope.uiWorkspace.startSession()
+        void seat.apply()
       }
       const chip = scope.slots.register({
         name: 'conversation.hero.agentPreset',
@@ -169,11 +171,7 @@ export function apply(ctx: ClientContext): void {
         inject: labelInjected,
       }, AgentPresetLabel)
       return () => {
-        stop()
-        settingsMoved()
-        rosterReaders.delete(readRoster)
         creatorDraft = undefined
-        activeSeat = undefined
         chip()
         label()
       }
@@ -182,10 +180,14 @@ export function apply(ctx: ClientContext): void {
 
   /** Capture the exact blank Session one Settings action may update. */
   const captureBlankSessionSync = (): ((id: string) => Promise<string | undefined>) => {
-    const seat = activeSeat
+    const summary = Object.values(ctx.sessions.list.getSnapshot().byId)
+      .find(session => session.blank && (session.retainedBy.mainView ?? 0) > 0)
+    const binding = summary === undefined ? undefined : ctx.sessions.binding(summary.id)
+    const seat = binding === undefined ? undefined : seats.get(binding)
     const sessionId = seat?.blankSessionId()
     return async (id: string) => {
-      if (seat === undefined || sessionId === undefined || activeSeat !== seat) return undefined
+      if (seat === undefined || sessionId === undefined || binding === undefined
+        || seats.get(binding) !== seat) return undefined
       return await seat.syncBlankSession(sessionId, id)
     }
   }

+ 24 - 10
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -42,6 +42,14 @@ const INITIAL: AgentPresetSeatState = {
   showPicker: false, options: [], current: '', error: null, busy: false, introduce: false,
 }
 
+/** Mutable one-shot preset choice shared across Provider-bound seat controllers. */
+export interface AgentPresetStage {
+  /** Preset awaiting application; absence means no staged choice. */
+  id: string | undefined
+  /** Whether the receiving chip should announce the applied choice once. */
+  introduce: boolean
+}
+
 /** Stages the next session's preset and applies it when one appears. */
 export class AgentPresetSeatController {
   /** Chip snapshot the renderer subscribes to. */
@@ -53,9 +61,6 @@ export class AgentPresetSeatController {
    */
   private fallback = ''
 
-  /** Set while a pick is waiting for a session; cleared once applied. */
-  private staged: string | undefined
-
   /** Only the newest roster read may publish after overlapping refreshes. */
   private loadGeneration = 0
 
@@ -66,6 +71,7 @@ export class AgentPresetSeatController {
       SessionSummary,
       'id' | 'blank' | 'projectionValues'
     > | undefined,
+    private readonly staged: AgentPresetStage = { id: undefined, introduce: false },
   ) {}
 
   private set(patch: Partial<AgentPresetSeatState>): void {
@@ -85,7 +91,10 @@ export class AgentPresetSeatController {
       return
     }
     const { presets, modeSelectionEnabled } = roster.value
-    if (!modeSelectionEnabled) this.staged = undefined
+    if (!modeSelectionEnabled) {
+      this.staged.id = undefined
+      this.staged.introduce = false
+    }
     this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
     const session = this.currentSession()
     this.set({
@@ -97,10 +106,11 @@ export class AgentPresetSeatController {
       // an applied stage was consumed — the chip mounts (and loads) only
       // once the flow's session is current, so the reply can arrive after
       // apply() already composed it.
-      current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
+      current: this.staged.id ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
       error: null,
-      ...modeSelectionEnabled ? {} : { introduce: false },
+      introduce: modeSelectionEnabled && this.staged.introduce,
     })
+    await this.apply()
   }
 
   /**
@@ -134,7 +144,8 @@ export class AgentPresetSeatController {
    * chip should announce itself on the session it lands on.
    */
   stage(id: string, introduce = false): void {
-    this.staged = id
+    this.staged.id = id
+    this.staged.introduce = introduce
     this.set({ current: id, error: null, introduce })
   }
 
@@ -168,6 +179,7 @@ export class AgentPresetSeatController {
   /** Acknowledge the introduction cue once the chip has played it. */
   introduced(): void {
     if (!this.store.getSnapshot().introduce) return
+    this.staged.introduce = false
     this.set({ introduce: false })
   }
 
@@ -179,7 +191,7 @@ export class AgentPresetSeatController {
    * @returns once the switch settled, or immediately when there is nothing to do.
    */
   async apply(): Promise<void> {
-    const staged = this.staged
+    const staged = this.staged.id
     const session = this.currentSession()
     if (staged === undefined) {
       const current = session === undefined ? this.fallback : presetOf(session) ?? ''
@@ -190,12 +202,14 @@ export class AgentPresetSeatController {
     // A started session's history was produced under its own composition; the
     // host refuses the swap, so the stage is no longer meaningful.
     if (!session.blank || presetOf(session) === staged) {
-      this.staged = undefined
+      this.staged.id = undefined
+      this.staged.introduce = false
       return
     }
     this.set({ busy: true, error: null })
     const result = await this.ctx.remote.agentPresets.select(session.id, staged)
-    this.staged = undefined
+    this.staged.id = undefined
+    this.staged.introduce = false
     if (!result.ok) {
       const { error } = result
       this.set({

+ 181 - 30
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -166,7 +166,7 @@ function declareConversation(slots: SlotRegistry): () => void {
   return slots.register({
     name: 'conversation',
     children: {
-      'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
+      'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
       'conversation.session.header.actions': { kind: 'list', scope: 'session' },
     },
   } as never, () => null)
@@ -182,7 +182,7 @@ function uiWorkspaceDouble() {
 }
 
 /** A sessions double whose list can be moved and whose changes are pushed. */
-function sessionsDouble(state: {
+function sessionsDouble(ctx: Context, state: {
   current?: string
   byId: Record<string, {
     id: string
@@ -191,14 +191,47 @@ function sessionsDouble(state: {
   }>
 }) {
   const listeners = new Set<() => void>()
+  const bindings = new Map<string, { sessionId: SessionId; ctx: Context }>()
+  const snapshot = () => ({
+    ...state,
+    byId: Object.fromEntries(Object.entries(state.byId).map(([id, row]) => [
+      id,
+      {
+        ...row,
+        retainedBy: state.current === id ? { mainView: 1 } : {},
+      },
+    ])),
+  })
   return {
     list: {
-      getSnapshot: () => state,
+      getSnapshot: snapshot,
       subscribe: (fn: () => void) => {
         listeners.add(fn)
         return () => listeners.delete(fn)
       },
     },
+    binding: (id: string) => {
+      if (state.byId[id] === undefined) return undefined
+      let binding = bindings.get(id)
+      if (binding === undefined) {
+        binding = { sessionId: SessionId(id), ctx }
+        bindings.set(id, binding)
+      }
+      return binding
+    },
+    retainInfo: (id: string) => ({
+      getSnapshot: () => {
+        const retainedBy = snapshot().byId[id]?.retainedBy ?? {}
+        return {
+          referenceCount: Object.values(retainedBy).reduce((sum, count) => sum + count, 0),
+          retainedBy,
+        }
+      },
+      subscribe: (fn: () => void) => {
+        listeners.add(fn)
+        return () => listeners.delete(fn)
+      },
+    }),
     /** Push a list change the way the runtime's store does. */
     notify: () => { for (const fn of listeners) fn() },
   }
@@ -211,12 +244,13 @@ describe('ui-agent-preset apply', () => {
 
   it('declares the services it uses', () => {
     expect(inject).toEqual([
-      'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
+      'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
     ])
   })
 
   it('registers the settings section and no General row', async () => {
     const { ctx, slots } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
 
     await ctx.plugin({ inject: [...inject], apply }).await()
@@ -233,6 +267,7 @@ describe('ui-agent-preset apply', () => {
 
   it('registers into a declaration that arrives after apply', async () => {
     const { ctx, slots } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     await ctx.plugin({ inject: [...inject], apply }).await()
 
     declareRoot(slots)
@@ -242,6 +277,7 @@ describe('ui-agent-preset apply', () => {
 
   it('hands the section its own store and default write', async () => {
     const { ctx, slots } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
 
@@ -255,6 +291,7 @@ describe('ui-agent-preset apply', () => {
 
   it('routes the section actions to one controller', async () => {
     const { ctx, slots, calls } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
@@ -281,6 +318,7 @@ describe('ui-agent-preset apply', () => {
 
   it('refreshes a showing surface when its namespace changes, and ignores others', async () => {
     const { ctx, slots, calls, remote } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
@@ -288,13 +326,13 @@ describe('ui-agent-preset apply', () => {
     const before = calls.length
 
     remote.emit('settings/document-updated', ['agent-presets', 1])
-    await vi.waitFor(() => { expect(calls.length).toBe(before + 2) })
+    await vi.waitFor(() => { expect(calls.length).toBe(before + 3) })
     const afterRelevant = calls.length
 
     remote.emit('settings/document-updated', ['llm-deepseek', 1])
     await Promise.resolve()
 
-    // Both surfaces re-read on their own namespace; an unrelated one moves
+    // The directory, section, and unbound seat re-read on their own namespace; an unrelated one moves
     // neither, so this rules out a blanket refresh on every settings write.
     expect(calls.length).toBe(afterRelevant)
   })
@@ -304,7 +342,7 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
@@ -320,6 +358,7 @@ describe('ui-agent-preset apply', () => {
 
   it('leaves the section alone until it has been opened once', async () => {
     const { ctx, slots, calls, remote } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const before = calls.length
@@ -327,9 +366,9 @@ describe('ui-agent-preset apply', () => {
     remote.emit('settings/document-updated', ['agent-presets', 1])
     await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) })
 
-    // Only the header label's roster reloads: a section nobody opened has
+    // The directory and unbound seat reload: a section nobody opened has
     // nothing to converge, and reading the roster for it would be wasted.
-    expect(calls.length - before).toBe(1)
+    expect(calls.length - before).toBe(2)
   })
 
   it('registers the new-session chip and the header label, and drops both on disposal', async () => {
@@ -337,7 +376,7 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
     await fiber.await()
@@ -359,7 +398,7 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
 
@@ -386,6 +425,74 @@ describe('ui-agent-preset apply', () => {
     conversation()
   })
 
+  it('keys bound seats by Provider generation and refreshes every live seat', async () => {
+    const { ctx, slots, calls, remote } = await bench()
+    declareRoot(slots)
+    declareConversation(slots)
+    ctx.provide('conversation', {} as never)
+    const state: {
+      current?: string
+      byId: Record<string, {
+        id: string
+        blank: boolean
+        projectionValues?: { agentPreset?: string | null }
+      }>
+    } = {
+      current: 's1',
+      byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
+    }
+    const sessions = sessionsDouble(ctx, state)
+    ctx.provide('sessions', sessions as never)
+    ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
+    const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
+      .inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected
+    const first = injectSeat(SessionId('s1'))
+    const same = injectSeat(SessionId('s1'))
+    expect(same.hooks.agentPresetSeat).toBe(first.hooks.agentPresetSeat)
+
+    await first.load()
+    const beforeRefresh = calls.length
+    remote.emit('settings/document-updated', ['agent-presets', 1])
+    await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(beforeRefresh + 2) })
+
+    const section = (slots.entries('settings.section')[0]!
+      .inject as unknown as () => AgentPresetSectionInjected)()
+    await section.load()
+    section.beginCopy('standard')
+    section.setCopyId('mine')
+    section.setCopyName('Mine')
+    await section.confirmCopy()
+
+    delete state.current
+    sessions.notify()
+    await first.load()
+    state.byId = {}
+    sessions.notify()
+    await first.load()
+    await ctx.fiber.dispose()
+  })
+
+  it('does not sync a blank Session that is not retained by the main view', async () => {
+    const { ctx, slots, calls } = await bench()
+    declareRoot(slots)
+    declareConversation(slots)
+    ctx.provide('conversation', {} as never)
+    const sessions = sessionsDouble(ctx, {
+      byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
+    })
+    ctx.provide('sessions', sessions as never)
+    ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
+    const section = (slots.entries('settings.section')[0]!
+      .inject as unknown as () => AgentPresetSectionInjected)()
+    await section.load()
+
+    await section.makeDefault('minimal')
+
+    expect(calls).not.toContain('select:minimal')
+  })
+
   it('aligns Settings defaults with the current blank Session, never a running one', async () => {
     const { ctx, slots, calls } = await bench()
     declareRoot(slots)
@@ -397,14 +504,14 @@ describe('ui-agent-preset apply', () => {
         s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
       },
     }
-    const sessions = sessionsDouble(sessionState)
+    const sessions = sessionsDouble(ctx, sessionState)
     ctx.provide('sessions', sessions as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
     const section = (slots.entries('settings.section')[0]!
       .inject as unknown as () => AgentPresetSectionInjected)()
     const seat = (slots.entries('conversation.hero.agentPreset')[0]!
-      .inject as unknown as () => AgentPresetSeatInjected)()
+      .inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
     await Promise.all([section.load(), seat.load()])
 
     await section.makeDefault('minimal')
@@ -438,6 +545,7 @@ describe('ui-agent-preset apply', () => {
 
   it('reloads Host truth after a picker-policy save failure', async () => {
     const { ctx, slots, calls } = await bench({ failSettingsUpdate: true })
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
     await ctx.plugin({ inject: [...inject], apply }).await()
     const section = (slots.entries('settings.section')[0]!
@@ -458,7 +566,7 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
 
@@ -496,12 +604,13 @@ describe('ui-agent-preset apply', () => {
         projectionValues?: { agentPreset?: string | null }
       }>
     } = { byId: {} }
-    const sessions = sessionsDouble(state)
+    const sessions = sessionsDouble(ctx, state)
     ctx.provide('sessions', sessions as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
-    const chip = (slots.entries('conversation.hero.agentPreset')[0]!
-      .inject as unknown as () => AgentPresetSeatInjected)()
+    const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
+      .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
+    const chip = injectSeat()
 
     await chip.load()
     // Picked on the hero screen, where there is no session yet.
@@ -513,6 +622,8 @@ describe('ui-agent-preset apply', () => {
       id: 's1', blank: true, projectionValues: { agentPreset: 'standard' },
     }
     sessions.notify()
+    const bound = injectSeat(SessionId('s1'))
+    await bound.load()
 
     // Connecting a workspace produced the session; the stage reaches it there.
     await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
@@ -523,7 +634,7 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    const sessions = sessionsDouble({
+    const sessions = sessionsDouble(ctx, {
       current: 's1',
       byId: { s1: { id: 's1', blank: true } },
     })
@@ -531,7 +642,7 @@ describe('ui-agent-preset apply', () => {
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
     const chip = (slots.entries('conversation.hero.agentPreset')[0]!
-      .inject as unknown as () => AgentPresetSeatInjected)()
+      .inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
 
     await chip.load()
     await chip.select('minimal')
@@ -552,12 +663,12 @@ describe('ui-agent-preset apply', () => {
         s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } },
       },
     }
-    const sessions = sessionsDouble(state)
+    const sessions = sessionsDouble(ctx, state)
     ctx.provide('sessions', sessions as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
     const chip = (slots.entries('conversation.hero.agentPreset')[0]!
-      .inject as unknown as () => AgentPresetSeatInjected)()
+      .inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
 
     await chip.load()
     await chip.select('minimal')
@@ -576,7 +687,7 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
     const label = (slots.entries('conversation.session.header.actions')[0]!
@@ -592,13 +703,14 @@ describe('ui-agent-preset apply', () => {
     declareRoot(slots)
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
-    ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     const uiWorkspace = uiWorkspaceDouble()
     ctx.provide('uiWorkspace', uiWorkspace as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
-    const seat = (slots.entries('conversation.hero.agentPreset')[0]!
-      .inject as unknown as () => AgentPresetSeatInjected)()
+    const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
+      .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
+    const seat = injectSeat()
 
     await section.load()
     await section.setPickerVisible(false)
@@ -625,6 +737,34 @@ describe('ui-agent-preset apply', () => {
     conversation()
   })
 
+  it('applies the creator preset to an existing blank main Session', async () => {
+    const { ctx, slots, calls } = await bench()
+    declareRoot(slots)
+    const conversation = declareConversation(slots)
+    ctx.provide('conversation', {} as never)
+    const sessions = sessionsDouble(ctx, {
+      current: 's1',
+      byId: { s1: { id: 's1', blank: true, projectionValues: { agentPreset: 'standard' } } },
+    })
+    ctx.provide('sessions', sessions as never)
+    const uiWorkspace = uiWorkspaceDouble()
+    ctx.provide('uiWorkspace', uiWorkspace as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
+    const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
+    const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
+      .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
+    const seat = injectSeat(SessionId('s1'))
+
+    await seat.load()
+    await section.load()
+    section.startCreatorDraft?.()
+
+    await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
+    expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
+    expect(uiWorkspace.starts).toHaveLength(1)
+    conversation()
+  })
+
   it('keeps the applied composition when the roster load lands late', async () => {
     const { ctx, slots, calls } = await bench()
     declareRoot(slots)
@@ -638,19 +778,21 @@ describe('ui-agent-preset apply', () => {
         projectionValues?: { agentPreset?: string | null }
       }>
     } = { byId: {} }
-    const sessions = sessionsDouble(state)
+    const sessions = sessionsDouble(ctx, state)
     ctx.provide('sessions', sessions as never)
     ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
     await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
-    const seat = (slots.entries('conversation.hero.agentPreset')[0]!
-      .inject as unknown as () => AgentPresetSeatInjected)()
+    const injectSeat = slots.entries('conversation.hero.agentPreset')[0]!
+      .inject as unknown as (sessionId?: SessionId) => AgentPresetSeatInjected
 
     await section.load()
     section.startCreatorDraft?.()
     state.current = 's1'
     state.byId['s1'] = { id: 's1', blank: true }
     sessions.notify()
+    const boundSeat = injectSeat(SessionId('s1'))
+    await boundSeat.load()
     await vi.waitFor(() => { expect(calls).toContain('select:cordis') })
 
     // The chip mounts with the flow's session, so its roster load can land
@@ -659,14 +801,15 @@ describe('ui-agent-preset apply', () => {
     state.byId['s1'] = {
       id: 's1', blank: true, projectionValues: { agentPreset: 'cordis' },
     }
-    await seat.load()
+    await boundSeat.load()
 
-    expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
+    expect(boundSeat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
     conversation()
   })
 
   it('offers no creator draft while the conversation flow is absent', async () => {
     const { ctx, slots } = await bench()
+    ctx.provide('sessions', sessionsDouble(ctx, { byId: {} }) as never)
     declareRoot(slots)
 
     await ctx.plugin({ inject: [...inject], apply }).await()
@@ -679,6 +822,14 @@ describe('ui-agent-preset apply', () => {
 })
 
 describe('AgentPresetSeatController reconciliation', () => {
+  it('does not capture a non-blank Session', () => {
+    const controller = new AgentPresetSeatController({} as never, () => ({
+      id: SessionId('started'), blank: false,
+    }))
+
+    expect(controller.blankSessionId()).toBeUndefined()
+  })
+
   it('does not retarget a different blank Session after a Settings write', async () => {
     const select = vi.fn(() => Promise.resolve({ ok: true as const, value: 'minimal' }))
     let current = {

+ 20 - 0
packages/client/ui-agent-preset/tests/components.client.spec.tsx

@@ -10,6 +10,8 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SessionRetainInfo } from '@deepseek-ai/dsh-api-session-controller/client'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
 import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
 import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
 import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
@@ -38,6 +40,8 @@ const SEAT_READY: AgentPresetSeatState = {
   introduce: false,
 }
 
+const useSessionRetainInfo = <Selected,>(selector: (value: undefined) => Selected): Selected => selector(undefined)
+
 /** The runtime's own `{name}` substitution, so a test reads the shown text. */
 function translate(key: keyof typeof en, params?: Record<string, unknown>): string {
   const template = en[key]
@@ -49,12 +53,17 @@ function translate(key: keyof typeof en, params?: Record<string, unknown>): stri
 function renderSeat(
   state: Partial<AgentPresetSeatState> = {},
   select: () => Promise<string | undefined> = () => Promise.resolve(undefined),
+  session?: { id: string; retainInfo: SessionRetainInfo | undefined },
 ) {
   const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
   const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(select), introduced: vi.fn() }
   render(<AgentPresetSeat {...({
     ...actions,
+    sessionId: session === undefined ? undefined : SessionId(session.id),
     useAgentPresetSeat: bindSnapshotSelector(store),
+    useSessionRetainInfo: session === undefined
+      ? useSessionRetainInfo
+      : <Selected,>(selector: (value: SessionRetainInfo | undefined) => Selected) => selector(session.retainInfo),
     t: translate,
   } as unknown as AgentPresetSeatProps)} />)
   return actions
@@ -87,6 +96,17 @@ describe('the new-session chip', () => {
     expect(screen.queryByRole('button')).toBeNull()
   })
 
+  it('renders only for a Session retained by the main view', () => {
+    renderSeat({}, undefined, {
+      id: 's1', retainInfo: { referenceCount: 1, retainedBy: { mainView: 1 } },
+    })
+    expect(screen.getByRole('button')).toBeTruthy()
+    cleanup()
+
+    renderSeat({}, undefined, { id: 's1', retainInfo: undefined })
+    expect(screen.queryByRole('button')).toBeNull()
+  })
+
   it('reads the roster once and shows the staged preset by name', async () => {
     const actions = renderSeat()
 

+ 3 - 0
packages/client/ui-agent-preset/tsconfig.json

@@ -52,6 +52,9 @@
     },
     {
       "path": "../../core/session"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

+ 4 - 4
packages/client/ui-attachment/tests/message-image.client.spec.tsx

@@ -34,7 +34,7 @@ const attachment = {
   name: 'history.png',
 }
 
-type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionPendingInteraction']>[0]>[0]
+type AttentionSnapshot = Parameters<Parameters<MessageImagesProps['useSessionStatus']>[0]>[0]
 type TrajectorySnapshot = Parameters<Parameters<MessageImagesProps['useTrajectory']>[0]>[0]
 
 const noAttention: AttentionSnapshot = new Map()
@@ -46,7 +46,7 @@ const emptyTrajectory: TrajectorySnapshot = {
   partial: null,
   runningCalls: [],
 }
-const useSessionPendingInteraction: MessageImagesProps['useSessionPendingInteraction'] = selector => selector(noAttention)
+const useSessionStatus: MessageImagesProps['useSessionStatus'] = selector => selector(noAttention)
 const useConversation: MessageImagesProps['useConversation'] = selector => selector(EMPTY_CONVERSATION_SNAPSHOT)
 const useChat: MessageImagesProps['useChat'] = selector => selector(EMPTY_CHAT_SNAPSHOT)
 const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector(emptyTrajectory)
@@ -273,8 +273,8 @@ describe('ImageGallery', () => {
       sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
       useSession,
       useSessions,
-      usePanelInfo, useResource,
-      useSessionPendingInteraction,
+      usePanelInfo, useSessionRetainInfo: () => undefined, useResource,
+      useSessionStatus,
       useWorkspaces,
       useProjection: () => undefined,
       useConversation,

+ 2 - 2
packages/client/ui-chat/src/client/apply.ts

@@ -46,7 +46,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 
 /** Services required by the Chat target and its presentation registrations. */
 export const inject = [
-  'slots', 'sessions', 'uiSession', 'uiConversation', 'locale',
+  'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
   'settingsScope', 'remote', 'remote.session', 'sidebarRight',
 ]
 
@@ -157,7 +157,7 @@ export function apply(ctx: Context): void {
           },
           forkAt: (seq) => {
             ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
-              .then((childId) => { ctx.sessions.open(childId) })
+              .then((childId) => { ctx.uiWorkspace.openSession(childId) })
               .catch(() => {
                 // Fork or child-title failure leaves the source view unchanged.
               })

+ 21 - 16
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -2,10 +2,10 @@
 /** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
 import { describe, expect, it, vi } from 'vitest'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
-import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import {
-  SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
+  SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
@@ -56,20 +56,23 @@ async function bench() {
   const openWorkspacePath = vi.fn<ClientRemote['session']['openWorkspacePath']>(
     () => Promise.resolve({ ok: true, value: { opened: true } }),
   )
-  new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
+  runtime.remote.provideNamespaces({ session: { openWorkspacePath } })
+  const openSession = vi.fn<(id: SessionId) => void>()
   runtime.ctx.provide('uiWorkspace', {
     openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
       beforeOpen(ROOT)
-      runtime.sessions.open(ROOT)
+      openSession(ROOT)
     }),
-    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    openSession,
   } as never)
   const session = sessionFakeFor()
   await runtime.sessions.add({
     id: ROOT,
     summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
     session,
-  }, { current: false })
+  })
+  const rootReference = runtime.sessions.retain(ROOT)
+  await rootReference.ready
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
@@ -80,22 +83,23 @@ async function bench() {
   await runtime.mount({ inject: [...injectChat], apply: applyChat })
   runtime.renderRoot()
 
-  const chatViewApi = (id: SessionId) => {
+  const chatViewApi = (reference: SessionReference) => {
+    const id = reference.sessionId
     const entry = runtime.slots.entries('conversation.view')[0]!
-    const instance = runtime.storeOf('conversation.view', id) as ChatInstance
+    const instance = runtime.storeOf('conversation.view', reference) as ChatInstance
     const injected = (entry.inject as unknown as (
       sessionId: SessionId,
       actions: ChatActions,
     ) => ChatViewInjected)(id, instance.actions)
     return { instance, injected }
   }
-  return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi }
+  return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi, rootReference, openSession }
 }
 
 describe('Chat inject API', () => {
   it('loads older history and forks through the Session Controller', async () => {
     const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
+    const { injected } = b.chatViewApi(b.rootReference)
     injected.loadOlder()
     expect(b.session.loadOlder).toHaveBeenCalledOnce()
 
@@ -104,7 +108,7 @@ describe('Chat inject API', () => {
 
     injected.forkAt(17)
     await vi.waitFor(() => {
-      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
+      expect(b.openSession).toHaveBeenCalledWith(ROOT)
     })
     expect(b.runtime.sessions.calls).toContainEqual({
       method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
@@ -120,7 +124,7 @@ describe('Chat inject API', () => {
 
   it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => {
     const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
+    const { injected } = b.chatViewApi(b.rootReference)
     await injected.openFile('src/a.ts')
     // Files stay in the product: a relative path is handed to the Sidebar as an
     // address under this session's scope, not to a desktop opener.
@@ -143,7 +147,7 @@ describe('Chat inject API', () => {
 
   it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => {
     const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
+    const { injected } = b.chatViewApi(b.rootReference)
     injected.openSkill('review')
     const openReference = vi.fn(() => true)
     const sessionOf = vi.fn(() => ({ openReference }))
@@ -164,8 +168,9 @@ describe('Chat inject API', () => {
       id: NO_CWD,
       summary: { title: 'N', displayTitle: 'N' },
       session: sessionFakeFor(),
-    }, { current: false })
-    const { injected } = b.chatViewApi(NO_CWD)
+    })
+    using reference = b.runtime.sessions.retain(NO_CWD)
+    const { injected } = b.chatViewApi(reference)
     // The Host resolves the relative path against the root it holds for the
     // Session; the Client need not know it.
     await injected.openFile('src/a.ts')
@@ -190,7 +195,7 @@ describe('Chat inject API', () => {
 
   it('owns image loading, scroll memory, and optional closing-file mentions', async () => {
     const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
+    const { injected } = b.chatViewApi(b.rootReference)
     const owner = {} as never
 
     expect(injected.fileMentions(owner)).toBeUndefined()

+ 9 - 8
packages/client/ui-chat/tests/chat-apply.client.spec.tsx

@@ -2,7 +2,7 @@
 import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
 import {
-  SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
+  SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
 } from '@deepseek-ai/dsh-client-test-runtime'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -44,14 +44,15 @@ async function bench() {
   } as never)
   runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
   runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
+  const openSession = vi.fn<(id: SessionId) => void>()
   runtime.ctx.provide('uiWorkspace', {
     openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
       beforeOpen(SID)
-      runtime.sessions.open(SID)
+      openSession(SID)
     }),
-    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    openSession,
   } as never)
-  new TestRemote(runtime.ctx, {
+  runtime.remote.provideNamespaces({
     session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) },
   })
   const locale = new LocaleRuntime(runtime.ctx)
@@ -133,16 +134,16 @@ describe('Chat apply wiring', () => {
 
   it('keeps the Chat standard source total while its target enters and leaves', async () => {
     const b = await bench()
-    await b.runtime.sessions.add({ id: SID }, { current: false })
-    const binding = b.runtime.sessions.binding(SID)
-    if (binding === undefined) throw new Error('Chat source test Session binding is unavailable')
+    await b.runtime.sessions.add({ id: SID })
+    using reference = b.runtime.sessions.retain(SID)
+    const binding = reference.binding
     const resolveSource = (owner: SessionBinding): ObservableSnapshot<ChatSnapshot> => {
       const contribution = b.sourceDescriptor.resolve(owner) as {
         hooks: { chat: ObservableSnapshot<ChatSnapshot> }
       }
       return contribution.hooks.chat
     }
-    const source = b.runtime.ctx.uiSession.adapter.resolve(SID)!.hooks.chat as
+    const source = b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.chat as
       ObservableSnapshot<ChatSnapshot>
     expect(resolveSource(binding)).toBe(source)
     expect(resolveSource(binding)).toBe(source)

+ 5 - 4
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -20,7 +20,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
@@ -203,7 +203,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
 /** Empty sessions-list hook for the global standard-kit seat. */
 function emptySessions() {
   const store = createSnapshotStore<SessionListState>(
-    { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
+    { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} })
   return bindSnapshotSelector(store)
 }
 
@@ -380,9 +380,10 @@ function makeHarness(
     useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
     useTrajectory: (() => { throw new Error('unused') }),
     useSessions: emptySessions(),
+    useSessionRetainInfo: () => undefined,
     useResource,
-    useSessionPendingInteraction: bindSnapshotSelector(
-      createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
+    useSessionStatus: bindSnapshotSelector(
+      createSnapshotStore<SessionStatusSnapshot>(new Map()),
     ),
     useWorkspaces: emptyWorkspaces(),
     useProjection: (key: string) => {

+ 5 - 4
packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx

@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -14,7 +14,7 @@ afterEach(cleanup)
 
 function emptySessions() {
   return bindSnapshotSelector(createSnapshotStore<SessionListState>({
-    ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+    ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
   }))
 }
 
@@ -25,7 +25,7 @@ function emptyWorkspaces() {
 }
 
 function noPendingInteraction() {
-  return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
+  return bindSnapshotSelector(createSnapshotStore<SessionStatusSnapshot>(new Map()))
 }
 
 // The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
@@ -37,8 +37,9 @@ function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | t
   const props: TranscriptViewRowProps = {
     usePanelInfo: selector => selector({ activePanelId: null }),
     useSessions: emptySessions(),
-    useSessionPendingInteraction: noPendingInteraction(),
+    useSessionStatus: noPendingInteraction(),
     useWorkspaces: emptyWorkspaces(),
+    useSessionRetainInfo: () => undefined,
     useResource,
     useTranscriptView: bindSnapshotSelector(source),
     setTranscriptView,

+ 1 - 0
packages/client/ui-commands/package.json

@@ -60,6 +60,7 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "clsx": "^2.0.0"

+ 21 - 12
packages/client/ui-commands/src/client/service.ts

@@ -17,8 +17,9 @@ import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
@@ -59,7 +60,7 @@ function submittedCommandName(line: string): string {
 interface LiveState {
   readonly contributions: Map<string, CommandContribution>
   readonly decorations: Map<string, CommandDecoration>
-  readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
+  readonly popups: WeakMapWithValues<SessionBinding, PopupSelectController<ClientSessionContext>>
 }
 
 /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
@@ -67,7 +68,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
   static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands']
 
   private readonly directory: CommandDirectory
-  private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
+  private readonly live: LiveState = {
+    contributions: new Map(),
+    decorations: new Map(),
+    popups: new WeakMapWithValues(),
+  }
   /** `command`-namespace translator (composer refusal notices). */
   private readonly t: TranslateNS<'command'>
 
@@ -147,7 +152,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * @param name - command name without the leading slash.
    */
   dismiss(name: string): void {
-    for (const popup of this.live.popups.values()) {
+    for (const popup of this.live.popups.values) {
       // A catalog that went stale underneath the card takes its rows away; the
       // composer keeps the keyboard the card was holding, like every other
       // dismissal path.
@@ -162,27 +167,31 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * session's composer through the conversation input face.
    * @param actx - session-scope ctx.
    * @returns the resident controller.
+   * @throws when the Context no longer belongs to a retained Session generation.
    */
   popupFor(actx: ClientContext): PopupSelectController<ClientSessionContext> {
     const sessions = this.sessions()
-    const id = sessions.scopeOf(actx)
-    if (id === undefined) throw new Error('command.popupFor requires a session scope')
+    const session = sessions.sessionOf(actx)
+    const binding = session === undefined ? undefined : sessions.binding(session.sessionId)
+    if (binding === undefined || binding.session !== session) {
+      throw new Error('command.popupFor requires a retained Session scope')
+    }
     const { popups } = this.live
-    const existing = popups.get(id)
+    const existing = popups.get(binding)
     if (existing !== undefined) return existing
     const controller = new PopupSelectController<ClientSessionContext>({
-      consume: segment => actx.bail(actx, 'slash/input-consume-token', {
+      consume: segment => binding.ctx.bail(binding.ctx, 'slash/input-consume-token', {
         guard: segment.via === 'menu'
           ? { kind: 'span', span: segment.span }
           : { kind: 'bare-token', token: segment.token },
       }) === true,
       // The shell took the keyboard; the composer restores it, caret included.
-      focusComposer: () => { actx.get('conversation')?.input.for(actx).focus() },
+      focusComposer: () => { binding.ctx.get('conversation')?.input.for(binding.ctx).focus() },
     })
-    popups.set(id, controller)
-    actx.effect(() => () => {
+    popups.set(binding, controller)
+    binding.ctx.effect(() => () => {
       controller.dispose()
-      popups.delete(id)
+      popups.delete(binding)
     }, 'command: session popup')
     return controller
   }

+ 11 - 2
packages/client/ui-commands/tests/browser-plugin.client.spec.ts

@@ -29,9 +29,16 @@ async function bench() {
     },
   })
   const scopes = new Map<SessionId, Context>()
+  const bindings = new Map<SessionId, {
+    readonly sessionId: SessionId
+    readonly session: { readonly sessionId: SessionId }
+    readonly ctx: Context
+  }>()
   ctx.provide('sessions', {
     scope: (id: SessionId) => scopes.get(id),
     scopeOf: (c: Context) => scopeOf(c),
+    sessionOf: (c: Context) => bindings.get(scopeOf(c)!)?.session,
+    binding: (id: SessionId) => bindings.get(id),
     subagentAddress: (id: SessionId) => id === sid('child')
       ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
       : undefined,
@@ -49,8 +56,10 @@ async function bench() {
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
   const mint = (key: string) => {
-    const handle = createScope(ctx, sid(key))
-    scopes.set(sid(key), handle.ctx)
+    const id = sid(key)
+    const handle = createScope(ctx, id)
+    scopes.set(id, handle.ctx)
+    bindings.set(id, { sessionId: id, session: { sessionId: id }, ctx: handle.ctx })
     return handle
   }
   return { ctx, fiber, sources, slots: ctx.slots, mint }

+ 12 - 3
packages/client/ui-commands/tests/service.client.spec.ts

@@ -107,9 +107,12 @@ async function bench(opts: BenchOptions = {}) {
   })
   // Real scope tags behind a fake sessions face.
   const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
+  const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
   const removeSessions = ctx.provide('sessions', {
     scope: (id: SessionId) => scopes.get(id)?.ctx,
     scopeOf: (c: Context) => scopeOf(c),
+    sessionOf: (scopeCtx: Context) => [...bindings.values()].find(binding => binding.ctx === scopeCtx)?.session,
+    binding: (id: SessionId) => bindings.get(id),
     subagentAddress: (id: SessionId) => id === opts.addressed
       ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const }
       : undefined,
@@ -140,8 +143,14 @@ async function bench(opts: BenchOptions = {}) {
   const source = registered.get('/ command')
   if (source === undefined) throw new Error('command source not registered')
   const mint = (key: string) => {
-    const handle = createScope(ctx, sid(key))
-    scopes.set(sid(key), handle)
+    const id = sid(key)
+    const handle = createScope(ctx, id)
+    const binding = { sessionId: id, session: { sessionId: id }, ctx: handle.ctx }
+    scopes.set(id, handle)
+    bindings.set(id, binding)
+    handle.ctx.effect(() => () => {
+      if (bindings.get(id) === binding) bindings.delete(id)
+    })
     return handle
   }
   /** Warm one session's catalog through the source's own candidate pull. */
@@ -1001,7 +1010,7 @@ describe('popupFor', () => {
     const first = command.popupFor(a.ctx)
     expect(command.popupFor(a.ctx)).toBe(first)
     expect(command.popupFor(mint('s2').ctx)).not.toBe(first)
-    expect(() => command.popupFor(ctx)).toThrow('requires a session scope')
+    expect(() => command.popupFor(ctx)).toThrow('requires a retained Session scope')
   })
 
   it('a successful select dispatches the scoped consume-token and focuses the composer', async () => {

+ 3 - 0
packages/client/ui-commands/tsconfig.json

@@ -46,6 +46,9 @@
     },
     {
       "path": "../../core/session"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

+ 1 - 0
packages/client/ui-conversation/package.json

@@ -75,6 +75,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",

+ 12 - 17
packages/client/ui-conversation/src/client/apply.ts

@@ -1,7 +1,7 @@
 /** Registers the target-neutral Conversation assembly, shell, input, and docks. */
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -171,13 +171,15 @@ export function apply(ctx: Context, config: Config = Config({})): void {
   const restoreView = (sessionId: SessionId): void => {
     activateView(sessionId, readConversationViewPreference(sessionId))
   }
-  const restoreCurrentView = (): void => {
-    const sessionId = sessions.list.getSnapshot().current
-    if (sessionId !== undefined && sessions.binding(sessionId) !== undefined) {
-      restoreView(sessionId)
-    }
-  }
   const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
+  const bindings = new Set<SessionBinding>()
+  const trackedBindings = new WeakSet<SessionBinding>()
+  const trackBinding = (binding: SessionBinding): void => {
+    if (trackedBindings.has(binding)) return
+    trackedBindings.add(binding)
+    bindings.add(binding)
+    binding.ctx.effect(() => () => { bindings.delete(binding) }, 'ui-conversation: active Provider binding')
+  }
   const refreshViews = (): void => {
     const current = conversationViews.getSnapshot()
     const next = viewTabs()
@@ -187,20 +189,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
         return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label
       })
     if (!unchanged) conversationViews.set(next)
-    restoreCurrentView()
+    for (const binding of bindings) restoreView(binding.sessionId)
   }
   ctx.effect(() => {
-    let currentSessionId = sessions.list.getSnapshot().current
     const disposeViews = slots.subscribe('conversation.view', refreshViews)
     const disposeLocale = ctx.locale.subscribe(refreshViews)
-    const disposeCurrent = sessions.list.subscribe(() => {
-      const nextSessionId = sessions.list.getSnapshot().current
-      if (nextSessionId === currentSessionId) return
-      currentSessionId = nextSessionId
-      restoreCurrentView()
-    })
     return () => {
-      disposeCurrent()
       disposeLocale()
       disposeViews()
     }
@@ -226,6 +220,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
     hooks: ['conversation', 'input'],
     props: ['inputActions'],
     resolve: (binding) => {
+      trackBinding(binding)
       const shell = inputHub.shellFor(binding)
       const conversation = uiConversation.binding(binding)
       restoreView(binding.sessionId)
@@ -250,7 +245,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       'conversation.input.dock': { kind: 'list', scope: 'session' },
       'conversation.hero.brand.mark': { kind: 'single', scope: 'root' },
       'conversation.hero.workspace': { kind: 'single', scope: 'root' },
-      'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
+      'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
     },
     inject: (sessionId: SessionId | undefined): ConversationInjected => ({
       hooks: {

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

@@ -172,7 +172,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     /** Brand mark shown before the blank-session headline. */
     'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps }
     /** Agent-preset control staged for a New Session. */
-    'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
+    'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe'; owner: HeroAgentPresetOwnerProps }
     /** Full-width entries above the composer card. */
     'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
     /** Floating entries rendered inside the resident composer card. */

+ 12 - 9
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -5,6 +5,7 @@ import type {
   ISessions, SessionBinding, SessionEventSource, SessionEventWindow,
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import {
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
@@ -177,7 +178,7 @@ export class UiConversation extends Service {
   readonly events: ConversationEventRegistry
   /** Registry of target View definitions. */
   readonly views: ConversationViewRegistry
-  private readonly bindings = new Map<SessionId, BindingRecord>()
+  private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
   private readonly images: HistoricalImageCache
 
   /**
@@ -190,7 +191,7 @@ export class UiConversation extends Service {
     this.views = new ConversationViewRegistry(ctx)
     this.images = new HistoricalImageCache(ctx, sessions)
     const rebuild = (): void => {
-      for (const record of this.bindings.values()) record.binding.rebuild()
+      for (const record of this.bindings.values) record.binding.rebuild()
     }
     let rebuildQueued = false
     const scheduleRebuild = (): void => {
@@ -207,7 +208,7 @@ export class UiConversation extends Service {
       return () => {
         disposeViews()
         disposeEvents()
-        for (const record of [...this.bindings.values()]) this.drop(record, true)
+        for (const record of [...this.bindings.values]) this.drop(record, true)
       }
     }, 'ui-conversation assembly')
   }
@@ -221,15 +222,17 @@ export class UiConversation extends Service {
     const sessionId = typeof source === 'string' ? source : source.sessionId
     const owner = typeof source === 'string' ? this.sessions.binding(source) : source
     if (owner === undefined) throw new Error(`uiConversation.binding: unknown session "${sessionId}"`)
-    const current = this.bindings.get(owner.sessionId)
-    if (current?.source === owner) return current.binding
-    if (current !== undefined) this.drop(current, true)
+    if (this.sessions.binding(sessionId) !== owner) {
+      throw new Error(`uiConversation.binding: inactive session "${sessionId}"`)
+    }
+    const current = this.bindings.get(owner)
+    if (current !== undefined) return current.binding
     const binding = new BoundConversation(
       owner.eventSource,
       new ConversationNodeAssembler(this.events, this.views),
     )
     const record: BindingRecord = { source: owner, binding, disposeScope: () => {} }
-    this.bindings.set(owner.sessionId, record)
+    this.bindings.set(owner, record)
     const disposeScope = owner.ctx.effect(
       () => () => { this.drop(record, false) },
       'ui-conversation binding',
@@ -303,8 +306,8 @@ export class UiConversation extends Service {
   }
 
   private drop(record: BindingRecord, releaseScope: boolean): void {
-    if (this.bindings.get(record.source.sessionId) !== record) return
-    this.bindings.delete(record.source.sessionId)
+    if (this.bindings.get(record.source) !== record) return
+    this.bindings.delete(record.source)
     record.binding.dispose()
     if (releaseScope) record.disposeScope()
   }

+ 41 - 44
packages/client/ui-conversation/src/client/conversation/historical-images.ts

@@ -1,22 +1,21 @@
 /** Session-scoped durable image URL cache shared by Conversation targets. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 
 interface ImageUrlEntry {
-  readonly sessionId: SessionId
-  readonly generation: number
+  readonly binding: SessionBinding
   current?: string
   pending: Promise<string>
 }
 
 /** Resolve durable Conversation images and release their browser URLs with Session scope. */
 export class HistoricalImageCache {
-  private readonly entries = new Map<string, ImageUrlEntry>()
-  private readonly generations = new Map<SessionId, number>()
-  private readonly scopeDisposers = new Map<SessionId, () => void>()
+  private readonly entries = new WeakMapWithValues<SessionBinding, Map<string, ImageUrlEntry>>()
+  private readonly scopeDisposers = new WeakMapWithValues<SessionBinding, () => void>()
   private readonly urls = new Set<string>()
   private disposed = false
 
@@ -36,20 +35,19 @@ export class HistoricalImageCache {
    */
   resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
     if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed'))
-    const key = this.key(sessionId, attachment)
-    const cached = this.entries.get(key)
-    if (cached !== undefined) return cached.pending
     const binding = this.sessions.binding(sessionId)
     if (binding === undefined) {
       return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`))
     }
-    this.bindScope(sessionId, binding.ctx)
+    const entries = this.bindScope(binding)
+    const key = attachment.attachmentId
+    const cached = entries.get(key)
+    if (cached !== undefined) return cached.pending
     const entry: ImageUrlEntry = {
-      sessionId,
-      generation: this.generations.get(sessionId) ?? 0,
+      binding,
       pending: Promise.resolve(''),
     }
-    this.entries.set(key, entry)
+    entries.set(key, entry)
     entry.pending = this.loadCanonical(key, entry, attachment)
     return entry.pending
   }
@@ -61,7 +59,8 @@ export class HistoricalImageCache {
    * @returns current preview or canonical URL when cached.
    */
   peek(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined {
-    return this.entries.get(this.key(sessionId, attachment))?.current
+    const binding = this.sessions.binding(sessionId)
+    return binding === undefined ? undefined : this.entries.get(binding)?.get(attachment.attachmentId)?.current
   }
 
   /**
@@ -75,22 +74,21 @@ export class HistoricalImageCache {
    */
   seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean {
     if (this.disposed) return false
-    const key = this.key(sessionId, attachment)
-    if (this.entries.has(key)) return false
     const binding = this.sessions.binding(sessionId)
     if (binding === undefined) return false
-    this.bindScope(sessionId, binding.ctx)
+    const entries = this.bindScope(binding)
+    const key = attachment.attachmentId
+    if (entries.has(key)) return false
     const entry: ImageUrlEntry = {
-      sessionId,
-      generation: this.generations.get(sessionId) ?? 0,
+      binding,
       current: url,
       pending: Promise.resolve(url),
     }
     this.urls.add(url)
-    this.entries.set(key, entry)
+    entries.set(key, entry)
     entry.pending = this.loadCanonical(key, entry, attachment).catch((error: unknown) => {
-      if (this.entries.get(key) === entry && entry.current === url) {
-        this.entries.delete(key)
+      if (entries.get(key) === entry && entry.current === url) {
+        entries.delete(key)
         this.releaseUrl(url)
       }
       throw error
@@ -102,18 +100,12 @@ export class HistoricalImageCache {
     return true
   }
 
-  private key(sessionId: SessionId, attachment: ImageAttachmentRef): string {
-    return `${sessionId}:${attachment.attachmentId}`
-  }
-
   private loadCanonical(
     key: string,
     entry: ImageUrlEntry,
     attachment: ImageAttachmentRef,
   ): Promise<string> {
-    const binding = this.sessions.binding(entry.sessionId)
-    if (binding === undefined) return Promise.reject(new Error(`ui-conversation: unknown session "${entry.sessionId}"`))
-    return binding.session.readAttachment(attachment.attachmentId)
+    return entry.binding.session.readAttachment(attachment.attachmentId)
       .then((result) => {
         if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
         this.assertLive(key, entry)
@@ -132,35 +124,39 @@ export class HistoricalImageCache {
         return url
       })
       .catch((error: unknown) => {
-        if (this.entries.get(key) === entry && entry.current === undefined) this.entries.delete(key)
+        const entries = this.entries.get(entry.binding)
+        if (entries?.get(key) === entry && entry.current === undefined) entries.delete(key)
         throw error
       })
   }
 
   private assertLive(key: string, entry: ImageUrlEntry): void {
     if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed')
-    if (this.entries.get(key) !== entry
-      || (this.generations.get(entry.sessionId) ?? 0) !== entry.generation) {
+    if (this.entries.get(entry.binding)?.get(key) !== entry) {
       throw new Error('ui-conversation image scope was released before loading completed')
     }
   }
 
-  private bindScope(sessionId: SessionId, scope: Context): void {
-    if (this.scopeDisposers.has(sessionId)) return
-    const dispose = scope.effect(() => () => {
-      this.scopeDisposers.delete(sessionId)
-      this.release(sessionId)
+  private bindScope(binding: SessionBinding): Map<string, ImageUrlEntry> {
+    const existing = this.entries.get(binding)
+    if (existing !== undefined) return existing
+    const entries = new Map<string, ImageUrlEntry>()
+    this.entries.set(binding, entries)
+    const dispose = binding.ctx.effect(() => () => {
+      this.scopeDisposers.delete(binding)
+      this.release(binding, entries)
     }, 'ui-conversation historical image scope')
-    this.scopeDisposers.set(sessionId, () => { void dispose() })
+    const release = (): void => { void dispose() }
+    this.scopeDisposers.set(binding, release)
+    return entries
   }
 
-  private release(sessionId: SessionId): void {
-    this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1)
-    for (const [key, entry] of this.entries) {
-      if (entry.sessionId !== sessionId) continue
-      this.entries.delete(key)
+  private release(binding: SessionBinding, entries: Map<string, ImageUrlEntry>): void {
+    if (this.entries.get(binding) === entries) this.entries.delete(binding)
+    for (const entry of entries.values()) {
       if (entry.current !== undefined) this.releaseUrl(entry.current)
     }
+    entries.clear()
   }
 
   private releaseUrl(url: string): void {
@@ -171,10 +167,11 @@ export class HistoricalImageCache {
   private dispose(): void {
     if (this.disposed) return
     this.disposed = true
-    for (const dispose of [...this.scopeDisposers.values()]) dispose()
+    for (const dispose of [...this.scopeDisposers.values]) dispose()
     this.scopeDisposers.clear()
     for (const url of this.urls) revokeUrl(url)
     this.urls.clear()
+    for (const entries of this.entries.values) entries.clear()
     this.entries.clear()
   }
 }

+ 19 - 14
packages/client/ui-conversation/src/client/input/hub.ts

@@ -50,7 +50,7 @@ interface ConversationAttachmentFace {
 
 /** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */
 export class InputHub implements SessionInputResolver {
-  private readonly shells = new Map<SessionId, SessionInputShell>()
+  private readonly shells = new WeakMap<SessionBinding, SessionInputShell>()
 
   /**
    * @param ctx - client root context (services resolved lazily per call — boot order stays free).
@@ -68,9 +68,12 @@ export class InputHub implements SessionInputResolver {
    */
   for(actx: Context): SessionInput {
     const sessions = this.sessions()
-    const id = sessions.scopeOf(actx)
-    if (id === undefined) throw new Error('conversation.input.for requires a session scope')
-    return this.shell(id)
+    const session = sessions.sessionOf(actx)
+    const binding = session === undefined ? undefined : sessions.binding(session.sessionId)
+    if (binding === undefined || binding.session !== session) {
+      throw new Error('conversation.input.for requires a retained Session scope')
+    }
+    return this.shellFor(binding)
   }
 
   /**
@@ -82,9 +85,9 @@ export class InputHub implements SessionInputResolver {
    * @returns the shell.
    */
   shellFor(binding: SessionBinding): SessionInputShell {
-    const existing = this.shells.get(binding.sessionId)
+    const existing = this.shells.get(binding)
     if (existing !== undefined) return existing
-    const { sessionId: id, session, ctx: actx } = binding
+    const { session, ctx: actx } = binding
     const shell = new SessionInputShell({
       actx,
       inputTriggers: () => this.controller(actx),
@@ -110,7 +113,7 @@ export class InputHub implements SessionInputResolver {
         }),
       },
     })
-    this.shells.set(id, shell)
+    this.shells.set(binding, shell)
     // The one teardown axis: listeners, shell, and map entries all ride the
     // scope fiber (nothing here outlives the scope).
     actx.effect(() => {
@@ -127,7 +130,7 @@ export class InputHub implements SessionInputResolver {
       return () => {
         for (const off of offs) off()
         const drafts = shell.dispose()
-        this.shells.delete(id)
+        this.shells.delete(binding)
         const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
         for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId)
       }
@@ -142,8 +145,6 @@ export class InputHub implements SessionInputResolver {
    * @returns the shell.
    */
   shell(id: SessionId): SessionInputShell {
-    const existing = this.shells.get(id)
-    if (existing !== undefined) return existing
     const binding = this.sessions().binding(id)
     if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
     return this.shellFor(binding)
@@ -166,7 +167,8 @@ export class InputHub implements SessionInputResolver {
    * @returns whether its mounted composer currently accepts files.
    */
   canPickFiles(id: SessionId): boolean {
-    return this.shells.get(id)?.canPickFiles() === true
+    const binding = this.sessions().binding(id)
+    return binding !== undefined && this.shells.get(binding)?.canPickFiles() === true
   }
 
   /**
@@ -174,7 +176,8 @@ export class InputHub implements SessionInputResolver {
    * @param id - target Session.
    */
   pickFiles(id: SessionId): void {
-    this.shells.get(id)?.pickFiles()
+    const binding = this.sessions().binding(id)
+    if (binding !== undefined) this.shells.get(binding)?.pickFiles()
   }
 
   /**
@@ -184,8 +187,8 @@ export class InputHub implements SessionInputResolver {
    * @returns the resident controller, or undefined when no trigger provider is installed.
    */
   inputTriggers(id: SessionId): InputTriggerController | undefined {
-    const actx = this.sessions().scope(id)
-    return actx === undefined ? undefined : this.controller(actx)
+    const binding = this.sessions().binding(id)
+    return binding === undefined ? undefined : this.controller(binding.ctx)
   }
 
   /**
@@ -231,11 +234,13 @@ export class InputHub implements SessionInputResolver {
   }
 
   private controller(actx: Context): InputTriggerController | undefined {
+    if (this.sessions().sessionOf(actx) === undefined) return undefined
     const inputTriggers = this.rootCtx.get('inputTriggers') as InputTriggerServiceFace | undefined
     return inputTriggers?.sessionOf(actx)
   }
 
   private popup(actx: Context): PopupDismissFace | undefined {
+    if (this.sessions().sessionOf(actx) === undefined) return undefined
     const command = this.rootCtx.get('commandUi') as CommandFace | undefined
     return command?.popupFor(actx)
   }

+ 3 - 3
packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx

@@ -133,12 +133,12 @@ function WidthHandle(props: {
  * @returns the unchanged Conversation body subtree.
  */
 export function ConversationContent({
-  sessionId, session, phase, hero, useSessions, useSessionPendingInteraction,
+  sessionId, session, phase, hero, useSessions, useSessionStatus,
   useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
   selectWorkspace, t, onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd,
 }: ConversationContentProps) {
-  const pendingInteraction = useSessionPendingInteraction(snapshot =>
-    sessionId === undefined ? undefined : snapshot.get(sessionId))
+  const pendingInteraction = useSessionStatus(snapshot =>
+    sessionId === undefined ? undefined : snapshot.get(sessionId)?.pendingInteraction)
   const inputState = useInput(s => s)
   const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
   const workspaces = useWorkspaces(s => s)

+ 39 - 19
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 import { describe, expect, it, onTestFinished, vi } from 'vitest'
 import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client'
-import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import {
@@ -49,20 +49,37 @@ async function bench() {
   }
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const connectWorkspace = vi.fn(async () => ROOT)
+  const references = new Map<SessionId, SessionReference>()
+  const opened = vi.fn<(id: SessionId) => void>()
+  let mainReference: SessionReference | undefined
+  const replaceMain = (id: SessionId, beforeOpen?: (id: SessionId) => void): void => {
+    const next = runtime.sessions.retain(id, { source: 'mainView' })
+    try {
+      beforeOpen?.(id)
+    } catch (error: unknown) {
+      next.release()
+      throw error
+    }
+    mainReference?.release()
+    mainReference = next
+    opened(id)
+  }
+  const openSession = vi.fn((id: SessionId) => { replaceMain(id) })
   runtime.ctx.provide('uiWorkspace', {
     openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
       const id = await connectWorkspace()
-      beforeOpen(id)
-      runtime.sessions.open(id)
+      replaceMain(id, beforeOpen)
     },
-    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    openSession,
   } as never)
   const sessionFake = sessionFakeFor()
   await runtime.sessions.add({
     id: ROOT,
     summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
     session: sessionFake,
-  }, { current: false })
+  })
+  const rootReference = runtime.sessions.retain(ROOT)
+  references.set(ROOT, rootReference)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
@@ -76,7 +93,7 @@ async function bench() {
     runtime.slots.entries(key)[0]!
   const conversationApi = (id: SessionId) => {
     const entry = entryOf('conversation.session')
-    const instance = runtime.storeOf('conversation.session', id) as ConversationInstance
+    const instance = runtime.storeOf('conversation.session', references.get(id)) as ConversationInstance
     const injected = (entry.inject as unknown as (
       sessionId: SessionId,
       actions: ConversationActions,
@@ -89,7 +106,7 @@ async function bench() {
   }
   const headerApi = (id: SessionId) => {
     const entry = entryOf('conversation.session.header')
-    const instance = runtime.storeOf('conversation.session.header', id) as ConversationInstance
+    const instance = runtime.storeOf('conversation.session.header', references.get(id)) as ConversationInstance
     const injected = (entry.inject as unknown as (
       sessionId: SessionId,
       actions: ConversationActions,
@@ -108,7 +125,7 @@ async function bench() {
     conversationApi(id).injected.hooks.conversationViews
   return {
     runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi,
-    inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads,
+    inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, rootReference, references, opened,
   }
 }
 
@@ -199,7 +216,7 @@ describe('Conversation inject API', () => {
     await b.runtime.dispose()
   })
 
-  it('restores the selected View when a cached Session becomes current', async () => {
+  it('restores the selected View when a cached Session becomes Provider-bound', async () => {
     const b = await bench()
     const binding = b.runtime.ctx.uiConversation.binding(ROOT)
     const activate = vi.spyOn(binding, 'activate')
@@ -214,7 +231,7 @@ describe('Conversation inject API', () => {
         draft: '', view: 'custom', viewRequest: null,
       }))
 
-      b.runtime.ctx.uiSession.adapter.resolve(ROOT)
+      b.runtime.ctx.uiSession.adapter.bindingSource(b.rootReference).getSnapshot()
       expect(activate).toHaveBeenLastCalledWith('chat')
       activate.mockClear()
 
@@ -223,10 +240,13 @@ describe('Conversation inject API', () => {
         (() => null) as never,
       )
       await b.runtime.flush()
-      expect(activate).not.toHaveBeenCalled()
-
-      await b.runtime.sessions.setCurrent(ROOT)
       expect(activate).toHaveBeenLastCalledWith('custom')
+      activate.mockClear()
+
+      using mainReference = b.runtime.sessions.retain(ROOT, { source: 'mainView' })
+      await b.runtime.flush()
+      expect(mainReference.sessionId).toBe(ROOT)
+      expect(activate).not.toHaveBeenCalled()
     } finally {
       removeCustom?.()
       removeChat()
@@ -337,7 +357,7 @@ describe('Conversation inject API', () => {
 
     b.connectWorkspace.mockResolvedValueOnce(ROOT)
     await resident.selectWorkspace('workspace-1' as WorkspaceId)
-    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
+    expect(b.opened).toHaveBeenCalledWith(ROOT)
     expect(state.getSnapshot().draft).toBe('carry me')
 
     const other = 'other-1' as SessionId
@@ -349,10 +369,10 @@ describe('Conversation inject API', () => {
       },
     }))
     b.uploads.set(other, targetUpload)
-    await b.runtime.sessions.add({ id: other, session: {} }, { current: false })
+    await b.runtime.sessions.add({ id: other, session: {} })
     b.connectWorkspace.mockResolvedValueOnce(other)
     await resident.selectWorkspace('workspace-2' as WorkspaceId)
-    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
+    expect(b.opened).toHaveBeenCalledWith(other)
     expect(state.getSnapshot().draft).toBe('')
     expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
     await vi.waitFor(() => { expect(targetUpload).toHaveBeenCalledOnce() })
@@ -364,13 +384,13 @@ describe('Conversation inject API', () => {
     const b = await bench()
     b.connectWorkspace.mockResolvedValueOnce(ROOT)
     await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId)
-    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
+    expect(b.opened).toHaveBeenCalledWith(ROOT)
 
-    const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length
+    const opens = b.opened.mock.calls.length
     b.connectWorkspace.mockRejectedValueOnce(new Error('offline'))
     await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId))
       .rejects.toThrow('offline')
-    expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens)
+    expect(b.opened).toHaveBeenCalledTimes(opens)
     await b.runtime.dispose()
   })
 

+ 4 - 4
packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx

@@ -17,9 +17,8 @@ async function bench(options: { declareConversation?: boolean } = {}) {
   runtime.ctx.provide('uiWorkspace', {
     openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => {
       beforeOpen(SID)
-      runtime.sessions.open(SID)
     }),
-    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    openSession: vi.fn(),
   } as never)
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const locale = new LocaleRuntime(runtime.ctx)
@@ -87,8 +86,9 @@ describe('target-neutral Conversation apply wiring', () => {
 
   it('binds a cached locale-aware View roster only to its shell entries', async () => {
     const b = await bench()
-    await b.runtime.sessions.add({ id: SID }, { current: false })
-    expect(b.runtime.ctx.uiSession.adapter.resolve(SID)?.hooks.conversationViews).toBeUndefined()
+    await b.runtime.sessions.add({ id: SID })
+    using reference = b.runtime.sessions.retain(SID)
+    expect(b.runtime.ctx.uiSession.adapter.bindingSource(reference).getSnapshot().hooks.conversationViews).toBeUndefined()
     const header = b.runtime.slots.entries('conversation.session.header')[0]
     const source = (header?.inject?.() as {
       hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> }

+ 25 - 28
packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx

@@ -50,6 +50,23 @@ const LAYOUT_CHILDREN = {
   'main': { kind: 'keyed', scope: 'root' },
 } as const
 
+function provideWorkspaceNavigation(runtime: SlotTestRuntime): (id: SessionId) => void {
+  let mainReference: ReturnType<typeof runtime.sessions.retain> | undefined
+  const openSession = (id: SessionId): void => {
+    const next = runtime.sessions.retain(id, { source: 'mainView' })
+    mainReference?.release()
+    mainReference = next
+  }
+  runtime.ctx.provide('uiWorkspace', {
+    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(SID)
+      openSession(SID)
+    }),
+    openSession,
+  } as never)
+  return openSession
+}
+
 function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
   const [count, setCount] = useState(0)
   return (
@@ -61,13 +78,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
 
 async function bench(opts?: { blank?: boolean }) {
   const runtime = await SlotTestRuntime.create()
-  runtime.ctx.provide('uiWorkspace', {
-    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
-      beforeOpen(SID)
-      runtime.sessions.open(SID)
-    }),
-    openSession: (id: SessionId) => { runtime.sessions.open(id) },
-  } as never)
+  const openSession = provideWorkspaceNavigation(runtime)
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
@@ -81,6 +92,7 @@ async function bench(opts?: { blank?: boolean }) {
       prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
     },
   })
+  openSession(SID)
   await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
   await runtime.mount({ inject: [...inject], apply })
   return runtime
@@ -89,13 +101,7 @@ async function bench(opts?: { blank?: boolean }) {
 describe('resident composer', () => {
   it('renders the locked view state while no session exists at all', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.ctx.provide('uiWorkspace', {
-      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
-        beforeOpen(SID)
-        runtime.sessions.open(SID)
-      }),
-      openSession: (id: SessionId) => { runtime.sessions.open(id) },
-    } as never)
+    provideWorkspaceNavigation(runtime)
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)
@@ -122,13 +128,7 @@ describe('resident composer', () => {
 
   it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.ctx.provide('uiWorkspace', {
-      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
-        beforeOpen(SID)
-        runtime.sessions.open(SID)
-      }),
-      openSession: (id: SessionId) => { runtime.sessions.open(id) },
-    } as never)
+    const openSession = provideWorkspaceNavigation(runtime)
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)
@@ -159,6 +159,8 @@ describe('resident composer', () => {
       summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
       snapshot: { blank: true },
     })
+    openSession(SID)
+    await runtime.flush()
 
     expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
     expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
@@ -193,13 +195,7 @@ describe('resident composer', () => {
 describe('prompt rejection through the assembled composer', () => {
   it('renders the promptError alert strip and keeps the draft in the machine', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.ctx.provide('uiWorkspace', {
-      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
-        beforeOpen(SID)
-        runtime.sessions.open(SID)
-      }),
-      openSession: (id: SessionId) => { runtime.sessions.open(id) },
-    } as never)
+    const openSession = provideWorkspaceNavigation(runtime)
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)
@@ -213,6 +209,7 @@ describe('prompt rejection through the assembled composer', () => {
       summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
       session: { prompt, loadOlder: vi.fn<ISession['loadOlder']>() },
     })
+    openSession(SID)
     await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
     await runtime.mount({ inject: [...inject], apply })
     const view = runtime.renderRoot()

+ 14 - 8
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -1,5 +1,5 @@
 import { Context } from '@deepseek-ai/cordis'
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { createAssistantMessage, LlmAttemptId } from '@deepseek-ai/dsh-llm'
@@ -70,22 +70,27 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
   const list = createSnapshotStore<SessionListState>({
     ids: [],
     byId: {},
-    current: undefined,
     phase: 'ready',
     subagentsByParent: {},
     jobsBySession: {},
-    currentAddress: undefined,
   })
-  const sessions = {
+  const reference = {
+    sessionId: SESSION_ID,
+    binding,
+    ready: Promise.resolve(binding),
+    release: () => {},
+    [Symbol.dispose]() {},
+  }
+  const sessions: ISessions = {
     list,
     searchResultLimit: 50,
     create: () => Promise.reject(new Error('unused fake Sessions operation')),
-    open: () => {},
-    openSubagent: () => {},
+    retain: () => reference,
+    using: async (_target, _options, operation) => await operation(reference),
+    retainInfo: () => createSnapshotStore({ referenceCount: 1, retainedBy: {} }),
     subagentAddress: () => undefined,
     setSubagentCatalogOpen: () => {},
     refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')),
-    clear: () => {},
     refresh: () => Promise.reject(new Error('unused fake Sessions operation')),
     search: () => Promise.reject(new Error('unused fake Sessions operation')),
     fork: () => Promise.reject(new Error('unused fake Sessions operation')),
@@ -93,7 +98,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
     scopeOf: candidate => candidate === binding.ctx ? SESSION_ID : undefined,
     sessionOf: candidate => candidate === binding.ctx ? binding.session : undefined,
     binding: id => id === SESSION_ID ? binding : undefined,
-  } satisfies ISessions
+  }
   return { sessions, binding }
 }
 
@@ -127,6 +132,7 @@ async function bootRegistries(): Promise<{
   views: ConversationViewRegistry
 }> {
   const ctx = new Context()
+  onTestFinished(async () => { await ctx.fiber.dispose() })
   const { sessions, binding } = fakeSessions(ctx)
   const uiConversation = new UiConversation(ctx, sessions)
   return {

+ 5 - 4
packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx

@@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
 import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
@@ -22,7 +22,7 @@ afterEach(() => {
 
 function emptySessions() {
   return bindSnapshotSelector(createSnapshotStore<SessionListState>({
-    ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+    ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {},
   }))
 }
 
@@ -33,7 +33,7 @@ function emptyWorkspaces() {
 }
 
 function noPendingInteraction() {
-  return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
+  return bindSnapshotSelector(createSnapshotStore<SessionStatusSnapshot>(new Map()))
 }
 
 function mount() {
@@ -42,7 +42,8 @@ function mount() {
   const props: EnterBehaviorRowProps = {
     usePanelInfo: selector => selector({ activePanelId: null }),
     useSessions: emptySessions(),
-    useSessionPendingInteraction: noPendingInteraction(),
+    useSessionStatus: noPendingInteraction(),
+    useSessionRetainInfo: () => undefined,
     useResource,
     useWorkspaces: emptyWorkspaces(),
     useBusyEnter: bindSnapshotSelector(policy.busyEnter),

+ 11 - 3
packages/client/ui-conversation/tests/historical-images.client.spec.ts

@@ -13,13 +13,16 @@ describe('HistoricalImageCache', () => {
       id: 's1',
       session: { readAttachment: () => read.promise },
     })
+    const reference = runtime.sessions.retain(sessionId)
+    await reference.ready
     const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
     const attachment = {
       attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
     } as const
 
     const pending = cache.resolve(sessionId, attachment)
-    await runtime.sessions.remove(sessionId)
+    reference.release()
+    await runtime.flush()
     read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } })
 
     await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed')
@@ -35,6 +38,8 @@ describe('HistoricalImageCache', () => {
       const read = Promise.withResolvers<Awaited<ReturnType<SessionFace['readAttachment']>>>()
       const runtime = await SlotTestRuntime.create()
       const sessionId = await runtime.sessions.add({ id: 's1', session: { readAttachment: () => read.promise } })
+      const reference = runtime.sessions.retain(sessionId)
+      await reference.ready
       const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
       const attachment = {
         attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
@@ -49,8 +54,8 @@ describe('HistoricalImageCache', () => {
       expect(cache.peek(sessionId, attachment)).toBe('blob:canonical')
       expect(revoked).toContain('blob:seeded')
 
-      await runtime.sessions.remove(sessionId)
-      await Promise.resolve()
+      reference.release()
+      await runtime.flush()
       expect(revoked).toContain('blob:canonical')
       await runtime.dispose()
     } finally {
@@ -72,6 +77,8 @@ describe('HistoricalImageCache', () => {
           } as never),
         },
       })
+      const reference = runtime.sessions.retain(sessionId)
+      await reference.ready
       const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions)
       const attachment = {
         attachmentId: AttachmentId('image-missing'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
@@ -81,6 +88,7 @@ describe('HistoricalImageCache', () => {
       await expect(cache.resolve(sessionId, attachment)).rejects.toThrow('attachment-invalid: missing')
       expect(cache.peek(sessionId, attachment)).toBeUndefined()
       expect(revoked).toHaveBeenCalledWith('blob:seeded')
+      reference.release()
       await runtime.dispose()
     } finally {
       revoked.mockRestore()

+ 4 - 3
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -170,11 +170,12 @@ function bench(over?: BenchOptions) {
     SessionProvider: ({ children }) => children,
     useSession: bindSnapshotSelector(session),
     useConversation: bindSnapshotSelector(createSnapshotStore(conversationFixture())),
-    useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())),
+    useSessionStatus: bindSnapshotSelector(createSnapshotStore(new Map())),
+    useSessionRetainInfo: () => undefined,
     useResource,
     useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
-      ids: [], byId: {}, current: undefined, phase: 'ready',
-      subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+      ids: [], byId: {}, phase: 'ready',
+      subagentsByParent: {}, jobsBySession: {},
     })),
     useWorkspaces: bindSnapshotSelector(createSnapshotStore({
       items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

+ 4 - 3
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -14,7 +14,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import {
   bindSnapshotSelector, conversationSnapshot, sessionSnapshot,
 } from '@deepseek-ai/dsh-client-test-runtime'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -56,9 +56,10 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
       ids: [], byId: {}, current: undefined, phase: 'ready',
       subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
     })),
-    useSessionPendingInteraction: bindSnapshotSelector(
-      createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
+    useSessionStatus: bindSnapshotSelector(
+      createSnapshotStore<SessionStatusSnapshot>(new Map()),
     ),
+    useSessionRetainInfo: () => undefined,
     useResource,
     useWorkspaces: bindSnapshotSelector(createSnapshotStore({
       items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

+ 7 - 3
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -23,7 +23,7 @@ import type {
 import {
   bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime,
 } from '@deepseek-ai/dsh-client-test-runtime'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { DraftAttachmentId } from '../src/client/contract/input.ts'
@@ -123,6 +123,9 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
   const ctx = runtime.ctx
   const sessionId = 'scenario-s1' as SessionId
   await runtime.sessions.add({ id: sessionId, summary: { cwd: '/w/a' } })
+  const reference = runtime.sessions.retain(sessionId)
+  await reference.ready
+  onTestFinished(() => { reference.release() })
   await ctx.plugin(InputTriggerService).await()
   const inputTriggers = ctx.get('inputTriggers') as InputTriggerService
   register?.(inputTriggers)
@@ -147,9 +150,10 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
       ids: [], byId: {}, current: undefined, phase: 'ready',
       subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
     })),
-    useSessionPendingInteraction: bindSnapshotSelector(
-      createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
+    useSessionStatus: bindSnapshotSelector(
+      createSnapshotStore<SessionStatusSnapshot>(new Map()),
     ),
+    useSessionRetainInfo: () => undefined,
     useResource,
     useWorkspaces: bindSnapshotSelector(createSnapshotStore({
       items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,

+ 4 - 3
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -19,7 +19,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import {
   bindSnapshotSelector, conversationSnapshot, makeTranslate,
 } from '@deepseek-ai/dsh-client-test-runtime'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { InputState } from '../src/client/contract/input.ts'
 import { zh } from '../src/client/locales.ts'
@@ -103,9 +103,10 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected>
     t,
     usePanelInfo,
     useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
+    useSessionRetainInfo: () => undefined,
     useResource,
-    useSessionPendingInteraction: bindSnapshotSelector(
-      createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
+    useSessionStatus: bindSnapshotSelector(
+      createSnapshotStore<SessionStatusSnapshot>(new Map()),
     ),
     useWorkspaces: (() => { throw new Error('unused') }) as never,
     useProjection: (() => undefined) as never,

+ 28 - 13
packages/client/ui-conversation/tests/selection-survival.client.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 /** Exercises Conversation persistence through the real SlotRegistry store axis. */
-import { beforeEach, describe, expect, it } from 'vitest'
+import { beforeEach, describe, expect, it, onTestFinished } from 'vitest'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
@@ -12,6 +12,7 @@ type ConversationInstance = ReturnType<ReturnType<typeof createConversationStore
 
 async function createBench() {
   const runtime = await SlotTestRuntime.create()
+  onTestFinished(() => runtime.dispose())
   const conversation = createConversationStore()
   await runtime.root.declare({
     'conversation.session': { kind: 'single', scope: 'session' },
@@ -26,9 +27,9 @@ async function createBench() {
 function storeFor(
   current: Awaited<ReturnType<typeof createBench>>,
   slot: 'conversation.session' | 'conversation.session.header',
-  sessionId: SessionId,
+  reference: ReturnType<typeof current.runtime.sessions.retain>,
 ): ConversationInstance {
-  return current.runtime.storeOf(slot, sessionId) as ConversationInstance
+  return current.runtime.storeOf(slot, reference) as ConversationInstance
 }
 
 beforeEach(() => {
@@ -39,8 +40,11 @@ describe('Conversation state survives on its store seat', () => {
   it('shares one instance between the Session body and header', async () => {
     const b = await createBench()
     await b.runtime.sessions.add({ id: 's1' })
-    const body = storeFor(b, 'conversation.session', sid('s1'))
-    const header = storeFor(b, 'conversation.session.header', sid('s1'))
+    using reference = b.runtime.sessions.retain(sid('s1'))
+    await reference.ready
+    expect(reference.sessionId).toBe(sid('s1'))
+    const body = storeFor(b, 'conversation.session', reference)
+    const header = storeFor(b, 'conversation.session.header', reference)
 
     body.actions.setDraft('half-typed')
     header.actions.setView('trajectory')
@@ -54,35 +58,46 @@ describe('Conversation state survives on its store seat', () => {
     const b = await createBench()
     const oneId = sid('s1')
     await b.runtime.sessions.add({ id: 's1' })
+    using retained = b.runtime.sessions.retain(oneId)
+    await retained.ready
     await b.runtime.sessions.add({ id: 's2' })
-    const one = storeFor(b, 'conversation.session', oneId)
-    const two = storeFor(b, 'conversation.session', sid('s2'))
+    using second = b.runtime.sessions.retain(sid('s2'))
+    await second.ready
+    const one = storeFor(b, 'conversation.session', retained)
+    const two = storeFor(b, 'conversation.session', second)
     one.actions.setDraft('only one')
     two.actions.setDraft('only two')
 
     await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' })
 
-    expect(storeFor(b, 'conversation.session', oneId)).toBe(one)
+    expect(storeFor(b, 'conversation.session', retained)).toBe(one)
     expect(one.store.getSnapshot().draft).toBe('only one')
     expect(two.store.getSnapshot().draft).toBe('only two')
     await b.runtime.dispose()
   })
 
-  it('buries the instance and persisted draft with the Session scope', async () => {
+  it('recreates the instance and restores persisted state after the Session scope ends', async () => {
     const b = await createBench()
     await b.runtime.sessions.add({ id: 's1' })
-    const doomed = storeFor(b, 'conversation.session', sid('s1'))
+    using reference = b.runtime.sessions.retain(sid('s1'))
+    await reference.ready
+    const doomed = storeFor(b, 'conversation.session', reference)
     doomed.actions.setDraft('to be buried')
     doomed.actions.setView('chat')
     expect(localStorage.getItem('dsh.conversation.s1')).not.toBeNull()
 
+    reference.release()
+    await b.runtime.flush()
     await b.runtime.sessions.remove('s1')
 
-    expect(localStorage.getItem('dsh.conversation.s1')).toBeNull()
+    expect(localStorage.getItem('dsh.conversation.s1')).not.toBeNull()
     await b.runtime.sessions.add({ id: 's1' })
-    const reborn = storeFor(b, 'conversation.session', sid('s1'))
+    using replacement = b.runtime.sessions.retain(sid('s1'))
+    await replacement.ready
+    expect(replacement.binding).toBe(b.runtime.sessions.binding(sid('s1')))
+    const reborn = storeFor(b, 'conversation.session', replacement)
     expect(reborn).not.toBe(doomed)
-    expect(reborn.store.getSnapshot()).toEqual({ draft: '', view: null, viewRequest: null })
+    expect(reborn.store.getSnapshot()).toEqual({ draft: 'to be buried', view: 'chat', viewRequest: null })
     await b.runtime.dispose()
   })
 })

+ 37 - 12
packages/client/ui-conversation/tests/service-orchestration.client.spec.ts

@@ -36,6 +36,8 @@ async function bench(maxConcurrentFileUploads = 2) {
     id: 's1',
     session: { prompt, updateQueue, cancel, loadOlder },
   })
+  const reference = runtime.sessions.retain('s1' as SessionId)
+  await reference.ready
   // config.input is required (the apply shares its hub with the inject
   // factories); the bench passes its own instance explicitly.
   const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
@@ -48,10 +50,28 @@ async function bench(maxConcurrentFileUploads = 2) {
   const root = runtime.ctx.get('conversation') as ConversationController
   const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationController
   const shell = hub.shellFor(runtime.sessions.binding('s1')!)
-  return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder }
+  return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder, reference }
 }
 
 describe('ConversationController', () => {
+  it('does not revive a withdrawn generation when an old input submits before scoped cleanup', async () => {
+    const b = await bench()
+    try {
+      b.shell.setDraft('old draft')
+      b.reference.release()
+      const retain = vi.spyOn(b.runtime.sessions, 'retain')
+      b.shell.submit()
+      b.shell.steerQueue()
+      expect(b.runtime.sessions.binding('s1')).toBeUndefined()
+      expect(retain).not.toHaveBeenCalled()
+      await b.runtime.flush()
+      expect(b.prompt).not.toHaveBeenCalled()
+      retain.mockRestore()
+    } finally {
+      await b.runtime.dispose()
+    }
+  })
+
   it('routes operations through the public Session binding', async () => {
     const b = await bench()
     await b.scoped.send('hello')
@@ -107,7 +127,8 @@ describe('ConversationController', () => {
       ])
       if (attachment === undefined) throw new Error('draft attachment missing')
       b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id])
-      await b.runtime.sessions.remove('s1')
+      b.reference.release()
+      await b.runtime.flush()
       expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
       expect(revoked).toHaveBeenCalledWith('blob:draft-1')
     } finally {
@@ -117,7 +138,7 @@ describe('ConversationController', () => {
     await b.runtime.dispose()
   })
 
-  it('releases an image removed from the rail by an unsettled optimistic send', async () => {
+  it('releases an unsettled send preview during structural Session teardown', async () => {
     const b = await bench()
     const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached')
     const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
@@ -129,7 +150,7 @@ describe('ConversationController', () => {
       b.shell.addAttachments([attachment.id])
       b.shell.submit()
       expect(b.shell.snapshot.attachmentIds).toEqual([])
-      await b.runtime.sessions.remove('s1')
+      await b.runtime.sessions.disposeScopes()
       expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([])
       expect(revoked).toHaveBeenCalledWith('blob:detached')
     } finally {
@@ -225,7 +246,7 @@ describe('ConversationController', () => {
     const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name =>
       new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' })))
 
-    expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt'])
+    await vi.waitFor(() => { expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) })
     await expect(b.root.serializeDraftAttachments([drafts[2]!.id]))
       .rejects.toThrow('one or more files have not finished uploading')
     b.root.releaseDraftAttachment(drafts[4]!.id)
@@ -284,14 +305,14 @@ describe('ConversationController', () => {
         prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder,
       },
     })
-    b.runtime.sessions.open('s2' as never)
+    using other = b.runtime.sessions.retain('s2' as SessionId)
+    await other.ready
     reportProgress?.({ loaded: 3, total: 8 })
     expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({
       status: 'uploading', loaded: 3, total: 8,
     })
     expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id])
 
-    b.runtime.sessions.open('s1' as never)
     settled.resolve({
       ok: true,
       value: {
@@ -335,6 +356,8 @@ describe('ConversationController', () => {
       })),
     }
     await b.runtime.sessions.add({ id: 's2', session: target })
+    using _target = b.runtime.sessions.retain('s2' as SessionId)
+    await _target.ready
     b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id])
 
     expect(sourceSignal?.aborted).toBe(true)
@@ -447,7 +470,8 @@ describe('ConversationController', () => {
   it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => {
     const b = await bench()
     await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
-    await b.runtime.sessions.remove('s1')
+    b.reference.release()
+    await b.runtime.flush()
     await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
     await b.runtime.dispose()
     // No Client Sessions service at all: a bare context lacks the assembled controller.
@@ -468,8 +492,10 @@ describe('sendSession submission echo', () => {
     const b = await bench()
     const retire: { onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined } = {}
     const abandon = vi.fn()
+    const begun = Promise.withResolvers<BeginSubmissionInput>()
     const beginSubmission = vi.fn((input: BeginSubmissionInput) => {
       retire.onRetire = input.onRetire
+      begun.resolve(input)
       return { requestId: 'req-echo' as never, abandon }
     })
     await b.runtime.sessions.updateSessionSnapshot('s1', () => {})
@@ -481,7 +507,7 @@ describe('sendSession submission echo', () => {
       created.mockRestore()
       revoked.mockRestore()
     }
-    return { ...b, beginSubmission, abandon, retire, revoked, restore }
+    return { ...b, beginSubmission, begun: begun.promise, abandon, retire, revoked, restore }
   }
 
   it('registers the echo before serialization and prompts with its identity', async () => {
@@ -492,8 +518,7 @@ describe('sendSession submission echo', () => {
       ])
       const session = b.runtime.sessions.binding('s1')!.session
       const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue')
-      // Synchronous: the echo is registered before any encoding starts.
-      const echo = b.beginSubmission.mock.calls[0]?.[0]
+      const echo = await b.begun
       expect(echo?.mode).toBe('queue')
       expect(echo?.text).toBe('带图')
       expect(echo?.attachments).toHaveLength(1)
@@ -542,7 +567,7 @@ describe('sendSession submission echo', () => {
         expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready')
       })
       const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer')
-      const echo = b.beginSubmission.mock.calls[0]?.[0]
+      const echo = await b.begun
       expect(echo?.mode).toBe('steer')
       expect(echo?.attachments.map(attachment => attachment.type === 'image'
         ? { type: attachment.type, name: attachment.value.name }

+ 15 - 12
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -11,7 +11,7 @@ import {
   bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture,
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -130,14 +130,14 @@ function mount(
 ) {
   const root = sid('root')
   const parent = sid('parent')
-  const rootRow = { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 }
+  const rootRow = { id: root, displayTitle: 'Root', running: false, retainedBy: {}, blank: false, updatedAt: 1 }
   const parentRow = {
     id: parent, displayTitle: 'Parent', parentId: root, origin: 'subagent' as const,
-    running: false, blank: false, updatedAt: 2,
+    running: false, retainedBy: {}, blank: false, updatedAt: 2,
   }
   const childRow = {
     id: SID, displayTitle: 'Child', parentId: options.nestedSubagent === true ? parent : root,
-    cwd: '/projects/one', running: false, blank: options.summaryBlank ?? false, updatedAt: 3,
+    cwd: '/projects/one', running: false, retainedBy: { mainView: 1 }, blank: options.summaryBlank ?? false, updatedAt: 3,
     ...(options.summaryOrigin === undefined ? {} : { origin: options.summaryOrigin }),
   }
   const listed = options.omitSummaryRow !== true
@@ -150,16 +150,15 @@ function mount(
       ...listed && options.nestedSubagent === true && { [parent]: parentRow },
       ...listed && { [SID]: childRow },
     },
-    current: SID,
-    phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+    phase: 'ready', subagentsByParent: {}, jobsBySession: {},
   })
   const workspaces = createSnapshotStore<WorkspaceSnapshot>(workspaceState(workspaceRows))
   const session = createSnapshotStore<SessionSnapshot>(snapshot)
   const useSession = bindSnapshotSelector(session)
   const conversation = createSnapshotStore<ConversationSnapshot>(EMPTY_CONVERSATION_SNAPSHOT)
   const useConversation = bindSnapshotSelector(conversation)
-  const useSessionPendingInteraction = bindSnapshotSelector(
-    createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
+  const useSessionStatus = bindSnapshotSelector(
+    createSnapshotStore<SessionStatusSnapshot>(new Map()),
   )
   const store = createConversationStore().create()
   store.actions.setDraft('ordinary draft')
@@ -201,7 +200,8 @@ function mount(
           useSessions={props.useSessions}
           usePanelInfo={props.usePanelInfo}
           useResource={useResource}
-          useSessionPendingInteraction={useSessionPendingInteraction}
+          useSessionStatus={useSessionStatus}
+          useSessionRetainInfo={() => undefined}
           useWorkspaces={props.useWorkspaces}
           useProjection={(() => undefined)}
           useInput={useInput}
@@ -228,7 +228,8 @@ function mount(
           useSessions={props.useSessions}
           usePanelInfo={props.usePanelInfo}
           useResource={useResource}
-          useSessionPendingInteraction={useSessionPendingInteraction}
+          useSessionStatus={useSessionStatus}
+          useSessionRetainInfo={() => undefined}
           useWorkspaces={props.useWorkspaces}
           useProjection={(() => undefined)}
           useInput={useInput}
@@ -254,7 +255,8 @@ function mount(
           useConversation={useConversation}
           useSessions={props.useSessions}
           usePanelInfo={props.usePanelInfo}
-          useSessionPendingInteraction={useSessionPendingInteraction}
+          useSessionStatus={useSessionStatus}
+          useSessionRetainInfo={() => undefined}
           useWorkspaces={props.useWorkspaces}
           useProjection={(() => undefined)}
           useInput={useInput}
@@ -303,7 +305,8 @@ function mount(
     useSession,
     useConversation,
     useSessions: bindSnapshotSelector(sessions),
-    useSessionPendingInteraction,
+    useSessionStatus,
+    useSessionRetainInfo: () => undefined,
     useResource,
     useWorkspaces: bindSnapshotSelector(workspaces),
     useProjection: (() => undefined),

+ 3 - 0
packages/client/ui-conversation/tsconfig.json

@@ -89,6 +89,9 @@
     {
       "path": "../../util/crypto"
     },
+    {
+      "path": "../../util/values"
+    },
     {
       "path": "../../util/workspace-path"
     }

+ 1 - 1
packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -38,7 +38,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
 function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) {
   controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
-  const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
+  const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', subagentsByParent: {}, jobsBySession: {} }
   return {
     useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
     reloadPresentedHost: vi.fn(() => controller.loadHost()),

+ 1 - 0
packages/client/ui-input-trigger/package.json

@@ -58,6 +58,7 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "clsx": "^2.0.0"
   },

+ 21 - 14
packages/client/ui-input-trigger/src/client/service.ts

@@ -8,9 +8,9 @@
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { InputTriggerSource } from '../types.ts'
 import { InputTriggerController } from './controller.ts'
 import type { InputTriggerServiceContract } from './contract.ts'
@@ -24,14 +24,16 @@ interface LiveState {
   /** Registration order = menu group order = matchSpace/matchEnter poll order. */
   readonly sources: InputTriggerSource[]
   /** Per-session controllers; entries are deleted by their scope disposer. */
-  readonly controllers: Map<SessionId, InputTriggerController>
+  readonly controllers: WeakMapWithValues<SessionBinding, InputTriggerController>
 }
 
 /** The `ctx.inputTriggers` trigger pipeline service (root registry + controller resolution). */
 export class InputTriggerService extends Service implements InputTriggerServiceContract {
   static inject = ['sessions']
 
-  private readonly live: LiveState = { sources: [], controllers: new Map() }
+  private readonly live: LiveState = {
+    sources: [], controllers: new WeakMapWithValues(),
+  }
 
   /**
    * @param ctx - owning root context (the service registers itself as `slash`).
@@ -39,7 +41,7 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
   constructor(ctx: Context) {
     super(ctx, 'inputTriggers')
     ctx.on('locale/change', () => {
-      for (const controller of this.live.controllers.values()) controller.refreshOpenMenu()
+      for (const controller of this.live.controllers.values) controller.refreshOpenMenu()
     })
   }
 
@@ -56,7 +58,7 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
       throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
     }
     live.sources.push(src)
-    for (const controller of live.controllers.values()) {
+    for (const controller of live.controllers.values) {
       try {
         controller.sourceAdded(src)
       } catch (error) {
@@ -70,7 +72,7 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
       const at = live.sources.indexOf(src)
       if (at < 0) return
       live.sources.splice(at, 1)
-      for (const controller of live.controllers.values()) controller.sourceRemoved(src)
+      for (const controller of live.controllers.values) controller.sourceRemoved(src)
     }
   }
 
@@ -81,26 +83,31 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
    * single prewarm moment.
    * @param actx - session-scope ctx.
    * @returns the resident controller.
+   * @throws when the Context no longer belongs to a retained Session generation.
    */
   sessionOf(actx: ClientContext): InputTriggerController {
     const sessions = this.sessions()
-    const id = sessions.scopeOf(actx)
-    if (id === undefined) throw new Error('slash.sessionOf requires a session scope')
+    const session = sessions.sessionOf(actx)
+    const binding = session === undefined ? undefined : sessions.binding(session.sessionId)
+    if (binding === undefined || binding.session !== session) {
+      throw new Error('slash.sessionOf requires a retained Session scope')
+    }
+    const id = binding.sessionId
     const { live } = this
-    const existing = live.controllers.get(id)
+    const existing = live.controllers.get(binding)
     if (existing !== undefined) return existing
     const controller = new InputTriggerController({
-      actx,
+      actx: binding.ctx,
       sessionId: id,
       roster: {
         sources: trigger => live.sources.filter(s => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
         all: () => live.sources,
       },
     })
-    live.controllers.set(id, controller)
-    actx.effect(() => () => {
+    live.controllers.set(binding, controller)
+    binding.ctx.effect(() => () => {
       controller.dispose()
-      live.controllers.delete(id)
+      live.controllers.delete(binding)
     }, 'slash: session controller')
     return controller
   }

+ 5 - 1
packages/client/ui-input-trigger/tests/apply.client.spec.ts

@@ -28,10 +28,14 @@ async function bench() {
   )
   // Sessions face: mint one real scope for session 'a' and resolve it by id.
   const scope = createScope(ctx, sid('a'))
+  const session = { sessionId: sid('a') }
+  const binding = { sessionId: sid('a'), session, ctx: scope.ctx }
   ctx.provide('sessions', {
     scope: (id: SessionId) => (id === sid('a') ? scope.ctx : undefined),
     scopeOf: (c: Context) => scopeOf(c),
-  })
+    sessionOf: (c: Context) => c === scope.ctx ? session : undefined,
+    binding: (id: SessionId) => id === sid('a') ? binding : undefined,
+  } as never)
   const locale = new LocaleRuntime(ctx)
   // These specs assert the shipped Chinese copy. There is no jsdom `window`
   // in this lane, so browser-language detection never runs and the locale

+ 12 - 3
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -83,13 +83,22 @@ function controllerBench(sources: InputTriggerSource[] = [], key = 'a') {
 /** Real-service bench: a sessions face resolving scope tags to session ids. */
 async function serviceBench() {
   const root = new Context()
+  const bindings = new Map<SessionId, { sessionId: SessionId; session: { sessionId: SessionId }; ctx: Context }>()
   root.provide('sessions', {
     scopeOf: (c: Context) => scopeOf(c),
-  })
+    sessionOf: (ctx: Context) => [...bindings.values()].find(binding => binding.ctx === ctx)?.session,
+    binding: (id: SessionId) => bindings.get(id),
+  } as never)
   await root.plugin(InputTriggerService).await()
   const inputTriggers = root.get('inputTriggers') as InputTriggerService
   const mint = (key: string) => {
-    const scope = createScope(root, sid(key))
+    const id = sid(key)
+    const scope = createScope(root, id)
+    const binding = { sessionId: id, session: { sessionId: id }, ctx: scope.ctx }
+    bindings.set(id, binding)
+    scope.ctx.effect(() => () => {
+      if (bindings.get(id) === binding) bindings.delete(id)
+    })
     return { actx: scope.ctx, fiber: scope.fiber }
   }
   return { root, inputTriggers, mint }
@@ -174,7 +183,7 @@ describe('sessionOf', () => {
 
   it('throws off an unscoped context', async () => {
     const { root, inputTriggers } = await serviceBench()
-    expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a session scope/)
+    expect(() => inputTriggers.sessionOf(root)).toThrow(/requires a retained Session scope/)
   })
 
   it('warms the roster once at controller birth with the session projection', async () => {

+ 3 - 0
packages/client/ui-input-trigger/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../util/values"
+    },
     {
       "path": "../ui-primitives"
     },

+ 0 - 2
packages/client/ui-jobs/tests/job-list-action.client.spec.tsx

@@ -39,11 +39,9 @@ function props(jobs: readonly JobView[] | undefined): JobListActionProps {
   const state = {
     ids: [SESSION],
     byId: {},
-    current: SESSION,
     phase: 'ready',
     subagentsByParent: {},
     jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs },
-    currentAddress: undefined,
   } satisfies SessionListState
   function useSessions<T>(select: (snapshot: SessionListState) => T): T {
     return select(state)

+ 2 - 1
packages/client/ui-layout/src/client/DocumentTitle.tsx

@@ -17,7 +17,8 @@ export type DocumentTitleProps = Pick<PropsRuntime<'root'>, 'useSessions' | 'use
 export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null {
   const showSessionTitle = usePanelInfo(info => info.activePanelId === null)
   const title = useSessions((state) => {
-    const current = state.current
+    const current = Object.values(state.byId)
+      .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
     return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title
   })
   useEffect(() => {

+ 5 - 6
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -15,9 +15,9 @@ const useResource = (() => ({ status: 'none' as const, value: undefined, failure
 let selectedSession: SessionId | undefined
 let selectedSessionTitle: string | undefined
 let workspacesReady = true
-type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
+type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionStatus']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
-const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
+const useSessionStatus: AppFrameProps['useSessionStatus'] = selector => selector(noAttention)
 
 let observers: ResizeObserverStub[]
 class ResizeObserverStub {
@@ -72,15 +72,13 @@ function mountFrame(windowWidth = frameWidth) {
     ids: selectedSession === undefined ? [] : [selectedSession],
     byId: selectedSession === undefined ? {} : {
       [selectedSession]: {
-        id: selectedSession, displayTitle: 'Test', running: false, blank: false, updatedAt: 1,
+        id: selectedSession, displayTitle: 'Test', running: false, retainedBy: { mainView: 1 }, blank: false, updatedAt: 1,
         ...(selectedSessionTitle === undefined ? {} : { title: selectedSessionTitle }),
       },
     },
-    current: selectedSession,
     phase: 'ready',
     subagentsByParent: {},
     jobsBySession: {},
-    currentAddress: undefined,
   })
   const workspaceState: WorkspaceSnapshot = {
     items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -98,7 +96,8 @@ function mountFrame(windowWidth = frameWidth) {
       renderSlot={renderSlot}
       useSessions={useSessions}
       usePanelInfo={usePanelInfo}
-      useSessionPendingInteraction={useSessionPendingInteraction}
+      useSessionStatus={useSessionStatus}
+      useSessionRetainInfo={() => undefined}
       useResource={useResource}
       useWorkspaces={sel => sel(workspaceState)}
       t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}

+ 9 - 5
packages/client/ui-layout/tests/document-title.client.spec.tsx

@@ -18,12 +18,10 @@ function titleSources() {
   const sessionId = 'session-title' as SessionId
   const sessions = createSnapshotStore<SessionListState>({
     ids: [sessionId],
-    byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } },
-    current: sessionId,
+    byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, retainedBy: { mainView: 1 }, blank: false, updatedAt: 1 } },
     phase: 'ready',
     subagentsByParent: {},
     jobsBySession: {},
-    currentAddress: undefined,
   })
   const panelInfo = createSnapshotStore<PanelInfo>({ activePanelId: null })
   return {
@@ -42,7 +40,13 @@ describe('DocumentTitle', () => {
     expect(document.title).toBe('First title — DeepSeek Harness')
     act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) })
     expect(document.title).toBe('Revised title — DeepSeek Harness')
-    act(() => { sessions.update((state) => { state.current = undefined }) })
+    act(() => {
+      const state = sessions.getSnapshot()
+      sessions.set({
+        ...state,
+        byId: { ...state.byId, [sessionId]: { ...state.byId[sessionId]!, retainedBy: {} } },
+      })
+    })
     expect(document.title).toBe('DeepSeek Harness')
     mounted.unmount()
     expect(document.title).toBe('DeepSeek Harness')
@@ -68,7 +72,7 @@ describe('DocumentTitle', () => {
     expect(document.title).toBe('Product')
     act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) })
     expect(document.title).toBe('Product')
-    expect(sessions.getSnapshot().current).toBe(sessionId)
+    expect(sessions.getSnapshot().byId[sessionId]?.retainedBy.mainView).toBe(1)
     act(() => { panelInfo.set({ activePanelId: null }) })
     expect(document.title).toBe('Updated title — Product')
   })

Деякі файли не було показано, через те що забагато файлів було змінено