Ver Fonte

Merge remote-tracking branch 'origin/master' into feat/scrollbar-tokens

Chinesezjc há 1 mês atrás
pai
commit
fb5c4ef4e3
60 ficheiros alterados com 760 adições e 260 exclusões
  1. 3 3
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
  2. 4 4
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
  3. 4 4
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
  7. 3 3
      .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml
  8. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
  9. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md
  10. 3 3
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
  11. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
  12. 2 2
      .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
  13. 4 4
      docs/cordis-catalog/events.md
  14. 4 4
      docs/event-producer-consumer.md
  15. 13 2
      packages/client/AGENTS.md
  16. 2 2
      packages/client/runtime/README.i18n.yaml
  17. 1 1
      packages/client/runtime/README.md
  18. 1 1
      packages/client/runtime/README.zh.md
  19. 55 12
      packages/client/runtime/src/client/sessions/service.ts
  20. 1 10
      packages/client/runtime/src/client/slots.ts
  21. 70 12
      packages/client/runtime/tests/sessions-service.spec.ts
  22. 5 12
      packages/client/runtime/tests/slots-service.spec.ts
  23. 3 1
      packages/client/ui-conversation/src/client/apply.ts
  24. 12 5
      packages/client/ui-conversation/src/client/contract/slots.ts
  25. 0 4
      packages/client/ui-conversation/src/client/input/contract.ts
  26. 7 4
      packages/client/ui-conversation/src/client/input/facade.ts
  27. 7 9
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  28. 2 1
      packages/client/ui-conversation/tests/apply-inject.spec.tsx
  29. 2 1
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  30. 7 3
      packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
  31. 25 21
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  32. 7 1
      packages/client/ui-conversation/tests/input-bar.spec.tsx
  33. 2 0
      packages/client/ui-conversation/tests/input-matrix.spec.tsx
  34. 31 0
      packages/client/ui-conversation/tests/input-scenarios.spec.tsx
  35. 7 1
      packages/client/ui-conversation/tests/selection-survival.spec.ts
  36. 2 0
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  37. 4 10
      packages/client/ui-settings/src/client/SettingsRoot.tsx
  38. 18 12
      packages/client/ui-settings/src/client/contract/slots.ts
  39. 26 12
      packages/client/ui-settings/src/client/index.ts
  40. 8 5
      packages/client/ui-settings/tests/apply.spec.ts
  41. 11 8
      packages/client/ui-settings/tests/settings-root.spec.tsx
  42. 30 1
      packages/client/ui-skill/src/client/index.ts
  43. 27 0
      packages/client/ui-skill/tests/browser-plugin.spec.ts
  44. 3 3
      packages/client/ui-slash/README.i18n.yaml
  45. 1 1
      packages/client/ui-slash/README.md
  46. 1 1
      packages/client/ui-slash/README.zh.md
  47. 56 12
      packages/client/ui-slash/src/client/controller.ts
  48. 12 1
      packages/client/ui-slash/src/client/service.ts
  49. 10 0
      packages/client/ui-slash/src/types.ts
  50. 68 3
      packages/client/ui-slash/tests/service.spec.ts
  51. 33 3
      packages/client/ui-slots/src/index.ts
  52. 6 10
      packages/client/ui-slots/src/renderer.ts
  53. 4 0
      packages/client/ui-subagent/src/client/index.ts
  54. 32 5
      packages/client/ui-subagent/tests/browser-plugin.spec.ts
  55. 22 3
      packages/client/web-react/src/scoped-slots.tsx
  56. 10 10
      packages/client/web-react/src/session-provider.tsx
  57. 2 3
      packages/client/web-react/tests/scoped-slots-real-core.spec.tsx
  58. 33 6
      packages/client/web-react/tests/scoped-slots.spec.tsx
  59. 42 8
      packages/client/web-react/tests/session-provider.spec.tsx
  60. 2 3
      packages/client/web-react/tests/stale-authorization.spec.tsx

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml

@@ -1,6 +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
-2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8
-2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
+2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6
+2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md

@@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
 | runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
 | child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
 | store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
-| business | `I` | inject return type | plain data + callbacks (hooks banned) |
+| business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use<Name>` selector hooks (`InjectFace<I>`) |
 
 `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
 
@@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one
 
 ### inject: the registrant's business face, on its own ctx
 
-An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
+An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use<Name>` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
 
 ### Data-boundary discipline
 
-Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
+Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
 
 ### Tree context and the renderer seam
 
@@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a
 | Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
 | Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
 | `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
-| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine |
+| Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery |
 | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
 | Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
 | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |

+ 4 - 4
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md

@@ -45,7 +45,7 @@ ctx.slots.register({
 | 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
 | 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
 | store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
-| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) |
+| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use<Name>` 选择器 hook 到达(`InjectFace<I>`) |
 
 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
 
@@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会
 
 ### inject:注册方的业务面,立足自己的 ctx
 
-inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
+inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use<Name>` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
 
 ### 数据界线纪律
 
-hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
+hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
 
 ### 树上语境与渲染器安装缝
 
@@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
 | 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
 | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
 | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
-| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 |
+| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 |
 | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
 | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
 | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |

+ 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: 09afe6d9e879ae7529d309c3b5e656be849fa543
-2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5
+2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649
+2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151

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

@@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess
 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, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
+- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; 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.
 - `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 composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.

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

@@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件
 slot scope 是闭集 `root | session-maybe | session`:
 
 - `root` 只拿全局标准件,不接收 session 身份或供数。
-- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
+- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
 - `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。
 
 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml

@@ -1,6 +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
-2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b
-2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
+2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c
+2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md

@@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
 
 ### Reference sources (seeing only projections plus their own apply closures, on the root ctx)
 
-- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
-- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream).
+- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
+- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream).
 
 ### Fixture command routing and assembly
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md

@@ -28,8 +28,8 @@ Status: implemented
 
 ### 引用源(只见投影 + 自家 apply 闭包的 root ctx)
 
-- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
-- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。
+- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
+- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。
 
 ### fixture 命令路由与装配
 

+ 3 - 3
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml

@@ -1,6 +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
-2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
+2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md

@@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
 skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived:
 
 - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
-- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface.
+- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
 - `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
 - Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
-- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once.
+- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
 
 ### Per-session provide contributions and the private keyboard surface
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md

@@ -81,10 +81,10 @@ occurrence 表与 chip 三投影:
 skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生:
 
 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
-- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面
+- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录
 - `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。
 - 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
-- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮
+- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染
 
 ### per-session 供数贡献与键盘私面
 

+ 4 - 4
docs/cordis-catalog/events.md

@@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco
 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-consume-token` — bail
 
@@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec
 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-insert-reference` — bail
 
@@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr
 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts)
 
 ### `slash/input-insert-text` — bail
 
@@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p
 'slash/input-insert-text'(request: InsertTextRequest): true | undefined
 ```
 
-Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts)
+Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts)
 
 ## `subagent/*`
 

+ 4 - 4
docs/event-producer-consumer.md

@@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
-| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
-| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
+| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
 | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 13 - 2
packages/client/AGENTS.md

@@ -11,10 +11,21 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
 3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
-4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
+4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
-7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
+7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
+
+## Reactive read and contract-currency discipline
+
+How live data reaches render code, and what may cross a business boundary:
+
+1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
+2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
+3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
+4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
+5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
+6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
 
 ## Export discipline (client plugin packages)
 

+ 2 - 2
packages/client/runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/runtime/README.md
-README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
-README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
+README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
+README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5

+ 1 - 1
packages/client/runtime/README.md

@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
 ## Known Limitations and Deferred Work
 
 - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
-- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
+- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
 - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 ## 已知限制与暂缓事项
 
 - **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
-- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
+- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
 - **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。

+ 55 - 12
packages/client/runtime/src/client/sessions/service.ts

@@ -151,6 +151,13 @@ export class SessionsService {
   readonly list: SnapshotStore<SessionListState>
   /** The object-layer instance cluster and frame dispatch entry. */
   private readonly manager: SessionManager
+  /**
+   * Atomic current-session provide projection: selection changes and
+   * provider-roster changes publish through this one source (the renderer
+   * host's `sessions.provide` feed), so a roster change under a stable
+   * current id republishes the bundle instead of stranding mounted entries.
+   */
+  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
 
   /**
    * Persisted selection cell (the durable half of `list.current`). Private on
@@ -167,6 +174,10 @@ export class SessionsService {
   private readonly providers: SessionProvideDescriptor[] = []
   /** Static no-session projection, rebuilt only when the provider roster changes. */
   private maybeInfo: SessionMaybeProvideInfo
+  /** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
+  private currentProvideInfoSnapshot: SessionMaybeProvideInfo
+  /** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
+  private readonly currentProvideInfoListeners = new Set<() => void>()
   /**
    * The staged session id — follows `list.current` exactly, holding its last
    * defined value across masked gaps (a transiently absent selection blanks
@@ -198,7 +209,11 @@ export class SessionsService {
     // 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.
-    this.list.subscribe(() => { this.followCurrent() })
+    // The current-provide projection follows the same current writes.
+    this.list.subscribe(() => {
+      this.followCurrent()
+      this.updateCurrentProvideInfo()
+    })
     // The runtime's own contribution comes first: useSession rides the same
     // provide channel every plugin uses (no renderer special case).
     this.providers.push({
@@ -206,6 +221,14 @@ export class SessionsService {
       resolve: binding => ({ hooks: { session: binding.session } }),
     })
     this.maybeInfo = this.materializeMaybeProvideInfo()
+    this.currentProvideInfoSnapshot = this.maybeInfo
+    this.currentProvideInfo = {
+      getSnapshot: () => this.currentProvideInfoSnapshot,
+      subscribe: (fn) => {
+        this.currentProvideInfoListeners.add(fn)
+        return () => { this.currentProvideInfoListeners.delete(fn) }
+      },
+    }
     rootCtx.reflect.provide('sessions', this, undefined)
   }
 
@@ -238,6 +261,30 @@ export class SessionsService {
     for (const record of this.scopes.values()) {
       record.provideInfo = this.materializeProvideInfo(record.binding)
     }
+    this.updateCurrentProvideInfo()
+  }
+
+  /**
+   * Re-derive the current selection's provide bundle and publish it when it
+   * changed. Bundles are identity-stable per (scope, roster)
+   * materialization, so an identity compare is exact; synchronous notify —
+   * both call sites (list.subscribe, provide()) already sit behind their own
+   * batching or registration edges.
+   */
+  private updateCurrentProvideInfo(): void {
+    const next = this.maybeProvideInfo(this.list.getSnapshot().current)
+    if (next === this.currentProvideInfoSnapshot) return
+    this.currentProvideInfoSnapshot = next
+    for (const fn of [...this.currentProvideInfoListeners]) {
+      try {
+        fn()
+      } catch (error) {
+        // Contain subscriber failures: this notify runs inside the list
+        // notification, where a throwing render-side subscriber would starve
+        // later listeners and abort the projection pass that scheduled it.
+        console.error('sessions.currentProvideInfo subscriber failed:', error)
+      }
+    }
   }
 
   /** Build the static no-session kit and reject duplicate declared names. */
@@ -404,25 +451,21 @@ export class SessionsService {
   }
 
   /**
-   * Resolve the render-layer standard-props bundle (SessionProvider's feed
-   * through the renderer host; ctx never enters the render layer). Pure
-   * resolution — render-safe: SessionProvider calls this during render, so no
-   * staging, no window side effects (StrictMode double-invokes and concurrent
-   * discarded passes must stay free).
-   * @param id - session id.
-   * @returns the provide info, or undefined for a session neither listed nor already scoped.
+   * Resolve one session's render-layer standard-props bundle (ctx never
+   * enters the render layer; the renderer subscribes to
+   * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
+   * no staging, no window side effects (StrictMode double-invokes and
+   * concurrent discarded passes must stay free).
    */
-  provideInfo(id: string): SessionProvideInfo | undefined {
+  private provideInfo(id: string): SessionProvideInfo | undefined {
     return this.resolve(id as SessionId)?.provideInfo
   }
 
   /**
    * Resolve the current-session-optional standard kit. Unknown or absent ids
    * return the static no-session projection rather than removing hook props.
-   * @param id - current session id, when selected.
-   * @returns a definite or no-session provide bundle.
    */
-  maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
+  private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
     return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
   }
 

+ 1 - 10
packages/client/runtime/src/client/slots.ts

@@ -246,13 +246,6 @@ export class SlotsService extends Service {
     if (workspaces === undefined) {
       throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
     }
-    // Identity-stable view: current rides the list snapshot (arbitrated), but
-    // the provider consumes it as its own observable; one cached object keeps
-    // the renderer's per-source hook cache stable.
-    const current = {
-      getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
-      subscribe: (fn: () => void) => sessions.list.subscribe(fn),
-    }
     this._host = {
       subscribe: (key, fn) => this._core.subscribe(key, fn),
       getVersion: key => this._core.getVersion(key),
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
         entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
       sessions: {
         list: sessions.list,
-        current,
-        provideInfo: id => sessions.provideInfo(id),
-        maybeProvideInfo: id => sessions.maybeProvideInfo(id),
+        provideInfo: sessions.currentProvideInfo,
       },
       workspaces: { list: workspaces.list },
     }

+ 70 - 12
packages/client/runtime/tests/sessions-service.spec.ts

@@ -79,7 +79,8 @@ describe('scope tree', () => {
     expect(scopeOf(scoped as Context)).toBe('s1')
     expect(scopeOf(b.ctx)).toBeUndefined()
     const binding = b.svc.binding(sid('s1'))
-    expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
+    b.svc.open(sid('s1'))
+    expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
     expect(b.svc.binding(sid('s1'))).toBe(binding)
     expect(binding?.ctx).toBe(scoped)
   })
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
 })
 
 describe('cell (render-layer session kit)', () => {
-  it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
+  it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }])
-    const info = b.svc.provideInfo('s1')
-    expect(info).toBeDefined()
-    expect(info?.sessionId).toBe('s1')
+    b.svc.open(sid('s1'))
+    const info = b.svc.currentProvideInfo.getSnapshot()
+    expect(info.sessionId).toBe('s1')
     // The bundle carries bare observables; hook binding happens in React.
-    expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
-    expect(b.svc.provideInfo('s1')).toBe(info)
-    expect(b.svc.provideInfo('ghost')).toBeUndefined()
+    expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
+    // Re-staging the same id republishes nothing: identity holds.
+    b.svc.open(sid('s1'))
+    expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
+  })
+
+  it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }, { id: 's2' }])
+    const absent = b.svc.currentProvideInfo.getSnapshot()
+    expect(absent.sessionId).toBeUndefined()
+    expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
+    const notified = vi.fn()
+    b.svc.currentProvideInfo.subscribe(notified)
+    b.svc.open(sid('s1'))
+    const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
+    expect(s1Bundle.sessionId).toBe('s1')
+    expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
+    expect(notified).toHaveBeenCalledTimes(1)
+    b.svc.open(sid('s2'))
+    const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
+    expect(s2Bundle.sessionId).toBe('s2')
+    expect(s2Bundle).not.toBe(s1Bundle)
+    expect(notified).toHaveBeenCalledTimes(2)
+    b.svc.clear()
+    await Promise.resolve() // clearSelection projects through the manager notifier
+    expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
+  })
+
+  it('a provider roster change under a stable current id republishes the bundle', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    b.svc.open(sid('s1'))
+    const before = b.svc.currentProvideInfo.getSnapshot()
+    const notified = vi.fn()
+    b.svc.currentProvideInfo.subscribe(notified)
+    const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
+    const dispose = b.svc.provide({
+      hooks: ['extra'],
+      props: ['marker'],
+      resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
+    })
+    const added = b.svc.currentProvideInfo.getSnapshot()
+    expect(added).not.toBe(before)
+    expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
+    expect(added.hooks['extra']).toBe(source)
+    expect(notified).toHaveBeenCalledTimes(1)
+    dispose()
+    const removed = b.svc.currentProvideInfo.getSnapshot()
+    expect(removed).not.toBe(added)
+    expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
+    expect(notified).toHaveBeenCalledTimes(2)
+  })
+
+  it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
+    const b = bench()
+    await feedList(b, [{ id: 's1' }])
+    const notified = vi.fn()
+    const off = b.svc.currentProvideInfo.subscribe(notified)
+    off()
+    b.svc.open(sid('s1'))
+    expect(notified).not.toHaveBeenCalled()
   })
 
-  it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
+  it('binding() is pure resolution: no staging, no deferred sweep', async () => {
     const b = bench()
     await feedList(b, [{ id: 's1' }, { id: 's2' }])
     b.svc.open(sid('s1')) // staged
-    b.svc.provideInfo('s2') // resolution only — must NOT move the stage
-    b.svc.binding(sid('s2'))
+    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()
   })
@@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
     const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
     // Resolution is addressing, not staging: no window pull.
     b.svc.scope(sid('s1'))
-    b.svc.provideInfo('s1')
     b.svc.binding(sid('s1'))
     expect(historyCalls()).toHaveLength(0)
     b.svc.open(sid('s1'))

+ 5 - 12
packages/client/runtime/tests/slots-service.spec.ts

@@ -97,18 +97,13 @@ function fakeWorkspaces() {
   return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
 }
 
-/** Minimal sessions face for the host seam (list observable + provide bundle). */
+/** Minimal sessions face for the host seam (list observable + current provide projection). */
 function fakeSessions() {
   const state = { ids: [], byId: {}, current: undefined as string | undefined }
+  const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
   return {
     list: { getSnapshot: () => state, subscribe: () => () => undefined },
-    provideInfo: (id: string) => (id === 'known'
-      ? {
-        sessionId: id,
-        hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
-        props: {},
-      }
-      : undefined),
+    currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
   }
 }
 
@@ -232,13 +227,11 @@ describe('host face', () => {
     expect(host.entriesOf('t.host')).toHaveLength(0)
   })
 
-  it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => {
+  it('exposes the session list and the atomic current provide projection', async () => {
     const bench = await boot()
     const host = captureHost(bench)
     expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
-    expect(host.sessions.current.getSnapshot()).toBeUndefined()
-    expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
-    expect(host.sessions.provideInfo('ghost')).toBeUndefined()
+    expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
   })
 
   it('exposes the independent Workspace list source', async () => {

+ 3 - 1
packages/client/ui-conversation/src/client/apply.ts

@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
       'conversation.input.model': { kind: 'single', scope: 'session' },
     },
     inject: (sessionId: SessionId): ComposerBarInjected => {
+      const shell = inputHub.shell(sessionId)
       return {
-        keyboard: inputHub.keyboard(sessionId),
+        keyboard: shell,
         stop: () => {
           scopedConversation(sessions, sessionId).cancel().catch(() => {
             // Stop failure surfaces via snapshot.promptError; nothing to restore.
           })
         },
+        hooks: { notices: shell.notices, lexicon: shell.lexicon },
       }
     },
   }, InputBar)

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

@@ -1,11 +1,11 @@
 /** Conversation slot declarations and their composed component props. */
 import type { ReactNode, RefObject } from 'react'
 import type {
-  MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
+  InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
-import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
+import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
 import type { createChatStore } from '../stores.ts'
 import type { CallId, SelectionTarget, ViewTab } from './views.ts'
 
@@ -220,6 +220,13 @@ export interface ComposerBarInjected {
   keyboard: ComposerKeyboard
   /** Cancel the in-flight turn. */
   stop: () => void
+  /** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
+  hooks: {
+    /** Latest surfaced notice (null after none; seq keys re-render of repeats). */
+    notices: ObservableSnapshot<InputNotice | null>
+    /** Hot plain-text reference lexicon for the decoration scan (decision 21). */
+    lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
+  }
 }
 
 /**
@@ -231,11 +238,11 @@ export interface InputControlOwnerProps {
   locked: boolean
 }
 
-/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
+/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
 export type ComposerBarProps =
   PropsRuntime<'conversation.composer.bar'>
   & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
-  & ComposerBarInjected
+  & InjectFace<ComposerBarInjected>
 
 /**
  * Composer chain currency: what ConversationRoot dispatches at its

+ 0 - 4
packages/client/ui-conversation/src/client/input/contract.ts

@@ -77,8 +77,6 @@ export interface InputNotice {
  * satisfies it structurally.
  */
 export interface ComposerKeyboard {
-  /** Latest surfaced notice store (null after none). */
-  readonly notices: SnapshotStore<InputNotice | null>
   /** Live machine state for event-handler reads (render reads go through useInput). */
   readonly snapshot: InputState
   /** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
   space(): boolean
   /** Dismiss the popupSelect shell (any interaction outside the box). */
   dismissPopup(): void
-  /** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
-  lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
 }
 
 /** One queued-message row projected from the session/queued frames (T9 supplies the store). */

+ 7 - 4
packages/client/ui-conversation/src/client/input/facade.ts

@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
   }
 
   /**
-   * Hot plain-text reference lexicons for the decoration scan (decision 21).
-   * @returns the controller's per-trigger aggregation; empty Map without a pipeline.
+   * Hot plain-text reference lexicon source for the decoration scan
+   * (decision 21): delegates to the controller's aggregated store. Stable
+   * identity per shell; without a pipeline the snapshot is the empty Map and
+   * subscribers never fire.
    */
-  lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
-    return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
+  readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
+    getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
+    subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
   }
 
   /**

+ 7 - 9
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -1,11 +1,12 @@
 /** The default composer body: the 'conversation.composer.bar' slot entry
  * (decision 20). Machine state arrives through the standard provide channel
  * (useInput + inputActions); the keyboard/DOM command face and stop arrive
- * through this entry's own inject; layout-phase inputs (variant, placeholder,
+ * through this entry's own inject, whose hooks compartment binds
+ * useNotices/useLexicon; layout-phase inputs (variant, placeholder,
  * region-slot content) ride the owner props. Session facts
  * (running/removed/promptError) are self-selected via useSession. */
 
-import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
 import clsx from 'clsx'
 import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
 ]
 
 export function InputBar({
-  useSession, useInput, inputActions, keyboard, stop, renderSlot,
+  useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
   variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
 }: InputBarProps) {
   const input = useInput(s => s)
-  const noticeStore = keyboard.notices
-  const notice = useSyncExternalStore(
-    (fn: () => void) => noticeStore.subscribe(fn),
-    () => noticeStore.getSnapshot(),
-  )
+  const notice = useNotices(s => s)
+  const lexicon = useLexicon(s => s)
   const promptError = useSession(s => s.promptError)
   const running = useSession(s => s.running)
   const disabled = useSession(s => s.removed)
@@ -244,7 +242,7 @@ export function InputBar({
   // claim token highlights through behind the textarea glyphs; each U+FFFC
   // placeholder renders as a chip (the textarea's own glyph is invisible, the
   // backdrop chip supplies the visual); the claim hint is ghost text.
-  const deco = deriveDecorations(input, keyboard.lexicon())
+  const deco = deriveDecorations(input, lexicon)
   const backdrop: ReactNode[] = []
   {
     // Segment boundaries: the token range end, every chip offset, and every

+ 2 - 1
packages/client/ui-conversation/tests/apply-inject.spec.tsx

@@ -87,12 +87,13 @@ async function bench() {
     }
   }
   const providers: TestProvider[] = []
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   const sessionsFake = {
     list: listStore,
     binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
     scope: (id: SessionId) => mint(id),
     provideInfo: () => undefined,
-    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+    currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
     scopeOf,
     sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),

+ 2 - 1
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -32,12 +32,13 @@ async function bench() {
     current: undefined,
     phase: 'ready',
   })
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   const sessionsFake = {
     list: listStore,
     binding: vi.fn(),
     scope: () => undefined,
     provideInfo: () => undefined,
-    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+    currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     provide: vi.fn(() => () => {}),
     create: vi.fn(),
     open: vi.fn(),

+ 7 - 3
packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx

@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
   // Provide-channel contributions land in this bundle the way the runtime
   // materializes them; the renderer host serves it through provideInfo.
   const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
+  // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
+  // materialized on first render after the provide contributions landed.
+  let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
   const sessionsFake = {
     list,
     binding: (id: SessionId) => (id === SID
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
     provideInfo: (id: string) => (id === SID
       ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
       : undefined),
-    maybeProvideInfo: (id: string | undefined) => (id === SID
-      ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
-      : { hooks: provided.hooks, props: provided.props }),
+    currentProvideInfo: {
+      getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
+      subscribe: () => () => {},
+    },
     create: vi.fn(),
     open: vi.fn(),
   }

+ 25 - 21
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
 
 const SID = 's1' as SessionId
 
+/** Identity-stable no-session bundle (uSES getSnapshot contract). */
+const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
+
 afterEach(cleanup)
 // The chat store persists under its declared key; clear between cases.
 beforeEach(() => {
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
       subscribe: (fn: () => void) => session.subscribe(fn),
     },
   })
+  const provideInfo = (id: string) => {
+    if (id !== SID) return undefined
+    if (info === undefined) {
+      const hooks: Record<string, unknown> = { session }
+      const props: Record<string, unknown> = {}
+      for (const provider of providers) {
+        const c = provider(bindingOf(SID))
+        Object.assign(hooks, c.hooks ?? {})
+        Object.assign(props, c.props ?? {})
+      }
+      info = { sessionId: SID, hooks, props }
+    }
+    return info
+  }
   ctx.provide('sessions', {
     list,
     binding: bindingOf,
     scope: () => actxFake,
-    provideInfo: (id: string) => {
-      if (id !== SID) return undefined
-      if (info === undefined) {
-        const hooks: Record<string, unknown> = { session }
-        const props: Record<string, unknown> = {}
-        for (const provider of providers) {
-          const c = provider(bindingOf(SID))
-          Object.assign(hooks, c.hooks ?? {})
-          Object.assign(props, c.props ?? {})
-        }
-        info = { sessionId: SID, hooks, props }
-      }
-      return info
-    },
-    maybeProvideInfo(id: string | undefined) {
-      // `this` inside an object-literal method is any under strict lint; the
-      // fake resolves through its own provideInfo above.
-      /* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
-         @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
-      return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
+    provideInfo,
+    currentProvideInfo: {
+      getSnapshot: () => provideInfo(SID),
+      subscribe: () => () => {},
     },
     provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
     scopeOf: () => SID,
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
       binding: () => undefined,
       scope: () => undefined,
       provideInfo: () => undefined,
-      maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+      currentProvideInfo: {
+        getSnapshot: () => ABSENT_INFO,
+        subscribe: () => () => {},
+      },
       provide: () => () => {},
       create: vi.fn(),
       open: vi.fn(),

+ 7 - 1
packages/client/ui-conversation/tests/input-bar.spec.tsx

@@ -56,7 +56,11 @@ function bench(over?: BenchOptions) {
     // Lexicon-only stub: adjudication untouched (undefined slash methods are
     // never reached — these benches drive plain-draft flows only).
     ...(lex !== undefined
-      ? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
+      ? {
+        slash: (() => ({
+          lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
+        })) as unknown as NonNullable<ShellDeps['slash']>,
+      }
       : {}),
   })
   if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
@@ -87,6 +91,8 @@ function bench(over?: BenchOptions) {
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
+    useNotices: bindSnapshotSelector(shell.notices),
+    useLexicon: bindSnapshotSelector(shell.lexicon),
     stop,
     renderSlot,
     variant: over?.variant ?? 'composer',

+ 2 - 0
packages/client/ui-conversation/tests/input-matrix.spec.tsx

@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
+    useNotices: bindSnapshotSelector(shell.notices),
+    useLexicon: bindSnapshotSelector(shell.lexicon),
     renderSlot: (() => null) as InputBarProps['renderSlot'],
     stop: vi.fn(),
     variant: 'composer',

+ 31 - 0
packages/client/ui-conversation/tests/input-scenarios.spec.tsx

@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
     useInput: bindSnapshotSelector(shell.state),
     inputActions: shell.actions,
     keyboard: shell,
+    useNotices: bindSnapshotSelector(shell.notices),
+    useLexicon: bindSnapshotSelector(shell.lexicon),
     renderSlot: (() => null) as InputBarProps['renderSlot'],
     stop: vi.fn(),
     variant: 'composer',
@@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
   })
 })
 
+describe('scenario: reference decoration lights up when the lexicon settles', () => {
+  it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
+    let roll: readonly string[] | undefined
+    let notify: (() => void) | undefined
+    const b = await scopedBench((slash) => {
+      slash.registerSource({
+        trigger: '/', name: 'skill',
+        candidates: () => Promise.resolve([]),
+        onPick: () => undefined,
+        lexicon: () => roll,
+        subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
+          notify = listener
+          return () => { notify = undefined }
+        },
+      } as never)
+    })
+    // Typed before the catalog settled: a plain token, no decoration.
+    b.type('/deploy now')
+    expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
+    // The catalog settles (ui-skill's settle path fires the same notification).
+    act(() => {
+      roll = ['deploy']
+      notify?.()
+    })
+    const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
+    expect(mark?.textContent).toBe('/deploy')
+  })
+})
+
 describe('scenario I: unknown /xyz + enter', () => {
   it('adjudication misses in one hop and the whole line rides the default sink', async () => {
     const b = await bench()

+ 7 - 1
packages/client/ui-conversation/tests/selection-survival.spec.ts

@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
 
 const sid = (s: string): SessionId => s as SessionId
 
+/** Identity-stable no-session bundle (uSES getSnapshot contract). */
+const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
+
 interface Bench {
   slots: SlotsService
   chat: ReturnType<typeof createChatStore>
@@ -23,7 +26,10 @@ function bench(): Bench {
       ids: [], byId: {}, current: undefined, phase: 'ready',
     }),
     provideInfo: () => undefined,
-    maybeProvideInfo: () => ({ hooks: {}, props: {} }),
+    currentProvideInfo: {
+      getSnapshot: () => ABSENT_INFO,
+      subscribe: () => () => {},
+    },
     provide: () => () => {},
   })
   ctx.provide('workspaces', {

+ 2 - 0
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -118,6 +118,8 @@ function mount(
           useInput={useInput}
           inputActions={inputActions}
           keyboard={wiring}
+          useNotices={bindSnapshotSelector(wiring.notices)}
+          useLexicon={bindSnapshotSelector(wiring.lexicon)}
           stop={stop}
           renderSlot={(() => null) as InputBarProps['renderSlot']}
           {...bar}

+ 4 - 10
packages/client/ui-settings/src/client/SettingsRoot.tsx

@@ -10,7 +10,7 @@
 import { useCallback, useEffect, useId, useRef, useState } from 'react'
 import clsx from 'clsx'
 import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { SettingsRootComponentProps } from './contract/slots.ts'
+import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
 import css from './SettingsRoot.module.css'
 
 /** Nav glyph by section id; unknown ids fall back to the settings gear. */
@@ -20,7 +20,7 @@ function navIcon(id: string) {
 }
 
 type PanelProps = {
-  rows: ReturnType<SettingsRootComponentProps['sections']>
+  rows: readonly SettingsSectionRow[]
   renderSlot: SettingsRootComponentProps['renderSlot']
   onClose: () => void
 }
@@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
  * @returns the settings shell element tree.
  */
 export function SettingsRoot(props: SettingsRootComponentProps) {
-  const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props
+  const { wide, useSections, renderSlot } = props
   const [open, setOpen] = useState(false)
   const close = useCallback(() => { setOpen(false) }, [])
 
   // The ledger tick keeps the nav rows fresh: registrants re-register with
   // freshly localized text on locale change, and the trigger/header/close
   // seats re-render through their own outlets' subscriptions.
-  // State = ledger version: same-version notifications dedupe to no render.
-  const [, setSectionsRev] = useState(() => sectionsVersion())
-  useEffect(
-    () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
-    [subscribeSections, sectionsVersion],
-  )
-  const rows = sections()
+  const rows = useSections(s => s)
 
   return (
     <>

+ 18 - 12
packages/client/ui-settings/src/client/contract/slots.ts

@@ -7,7 +7,7 @@
  * setting never means editing the shell; copy that belongs to no single
  * feature (chrome, the General section) is owned by ui-settings-general.
  */
-import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
 // into every program that sees this contract.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
   children?: never
 }
 
+/** One nav row projected from a settings.section registration's options. */
+export interface SettingsSectionRow {
+  id: string
+  order: number
+  label: string
+}
+
 /**
  * Registrant-private injected share of the settings shell (assembled in
- * apply): ledger projections only — the shell reads no locale state.
+ * apply): the ledger's nav-row projection as a hooks-compartment source —
+ * the shell reads no locale state and subscribes through the bound hook.
  */
 export type SettingsRootInjected = {
-  /** Read the settings.section ledger version (nav invalidation). */
-  sectionsVersion: () => number
-  /** Subscribe to settings.section ledger changes. */
-  subscribeSections: (listener: () => void) => () => void
-  /** Project the settings.section ledger into nav rows (id/order/label). */
-  sections: () => readonly { id: string; order: number; label: string }[]
+  hooks: {
+    /** settings.section ledger projected into ordered nav rows. */
+    sections: HostObservable<readonly SettingsSectionRow[]>
+  }
 }
 
 /**
  * Full component props of the settings shell root: the sidebar owner share
- * (wide/rail state) plus the declared render shares and the injected face.
- * No store is registered — modal open state and active section id are
- * component-local viewing state.
+ * (wide/rail state) plus the declared render shares and the injected face
+ * (hooks compartment bound to useSections). No store is registered — modal
+ * open state and active section id are component-local viewing state.
  */
 export type SettingsRootComponentProps =
   PropsRuntime<'sidebar.settings'>
   & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
-  & SettingsRootInjected
+  & InjectFace<SettingsRootInjected>

+ 26 - 12
packages/client/ui-settings/src/client/index.ts

@@ -10,12 +10,12 @@
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SettingsRootInjected } from './contract/slots.ts'
+import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
 import { SettingsRoot } from './SettingsRoot.tsx'
 
 export type {
   SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
-  SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
+  SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
 } from './contract/slots.ts'
 
 /**
@@ -32,17 +32,31 @@ export const inject = ['slots']
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
+  // Ledger → nav-row projection as an observable source (uSES contract:
+  // getSnapshot returns the cached rows until the ledger version moves).
+  let rowsVersion = -1
+  let rows: readonly SettingsSectionRow[] = []
   const injected = (): SettingsRootInjected => ({
-    sectionsVersion: () => ctx.slots.getVersion('settings.section'),
-    subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
-    sections: () => ctx.slots.entries('settings.section')
-      .map(e => ({
-        /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
-        id: e.options.id ?? '',
-        order: e.options.order ?? 0,
-        label: e.options.label ?? '',
-      }))
-      .sort((a, b) => a.order - b.order),
+    hooks: {
+      sections: {
+        getSnapshot: () => {
+          const version = ctx.slots.getVersion('settings.section')
+          if (version !== rowsVersion) {
+            rowsVersion = version
+            rows = ctx.slots.entries('settings.section')
+              .map(e => ({
+                /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
+                id: e.options.id ?? '',
+                order: e.options.order ?? 0,
+                label: e.options.label ?? '',
+              }))
+              .sort((a, b) => a.order - b.order)
+          }
+          return rows
+        },
+        subscribe: listener => ctx.slots.subscribe('settings.section', listener),
+      },
+    },
   })
   ctx.effect(() => {
     const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>

+ 8 - 5
packages/client/ui-settings/tests/apply.spec.ts

@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
     const b = await bench()
     declare(b.slots)
     await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const injected = injectedOf(b.slots)
+    const { sections } = injectedOf(b.slots).hooks
     // The shell ships no sections of its own — registrants fill the ledger.
-    expect(injected.sections()).toEqual([])
+    expect(sections.getSnapshot()).toEqual([])
     b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
     // No order and no label: both projection defaults apply.
     b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
-    expect(injected.sections()).toEqual([
+    const rows = sections.getSnapshot()
+    expect(rows).toEqual([
       { id: 'a', order: 0, label: '' },
       { id: 'z', order: 20, label: 'Z' },
     ])
-    expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
+    // Snapshot identity is stable until the ledger moves (uSES contract).
+    expect(sections.getSnapshot()).toBe(rows)
     const listener = vi.fn()
-    const off = injected.subscribeSections(listener)
+    const off = sections.subscribe(listener)
     b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
     await Promise.resolve()
     expect(listener).toHaveBeenCalled()
+    expect(sections.getSnapshot()).not.toBe(rows)
     off()
   })
 

+ 11 - 8
packages/client/ui-settings/tests/settings-root.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useEffect, useState } from 'react'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
 import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
     { id: 'models', order: 10, label: 'Models' },
   ],
 }: { wide?: boolean; rows?: Row[] } = {}) {
-  // Mutable row store standing in for the ledger; bump() plays a change.
+  // Mutable row source standing in for the bound useSections hook; bump()
+  // plays a ledger change through the same observable contract.
   let current = rows
-  let version = 0
   const listeners = new Set<() => void>()
   const renderSlot = vi.fn(
     ((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
     useSessions: unusedHook,
     useWorkspaces: unusedHook,
     wide,
-    sectionsVersion: () => version,
-    subscribeSections: (listener) => {
-      listeners.add(listener)
-      return () => { listeners.delete(listener) }
+    useSections: (select) => {
+      const [, force] = useState(0)
+      useEffect(() => {
+        const listener = () => { force(n => n + 1) }
+        listeners.add(listener)
+        return () => { listeners.delete(listener) }
+      }, [])
+      return select(current)
     },
-    sections: () => current,
     renderSlot,
   }
   const view = render(<SettingsRoot {...props} />)
   const bump = (next: Row[]) => {
     act(() => {
       current = next
-      version += 1
       for (const fn of [...listeners]) fn()
     })
   }

+ 30 - 1
packages/client/ui-skill/src/client/index.ts

@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
   // Session-keyed catalog cache; single-flight per key. Plugin-closure state:
   // the fiber effect below is its teardown boundary.
   const fetches = new Map<SessionId, CatalogFetch>()
+  // Per-session lexicon invalidation listeners (subscribeLexicon consumers).
+  const lexiconListeners = new Map<SessionId, Set<() => void>>()
+
+  const notifyLexicon = (sessionId: SessionId): void => {
+    for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
+      try {
+        listener()
+      } catch (error) {
+        // Contain listener failures: settlement notifies from an ignored
+        // promise chain (a throw would surface as an unhandled rejection)
+        // and one faulty consumer must not starve the others.
+        console.error('[ui-skill] lexicon listener failed:', error)
+      }
+    }
+  }
 
   const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
     const existing = fetches.get(sessionId)
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
     fetches.set(sessionId, entry)
     promise.then(
       // Settled snapshot backs the synchronous lexicon reads.
-      (skills) => { entry.settled = skills },
+      (skills) => {
+        entry.settled = skills
+        notifyLexicon(sessionId)
+      },
       // A failed fetch must not poison the key: the next consumer retries.
       () => {
         if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
     if (entry === undefined) return
     fetches.delete(key)
     entry.abort.abort()
+    notifyLexicon(key)
   }
 
   const clearAll = (): void => {
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
     lexicon(session) {
       return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
     },
+    subscribeLexicon(session, listener) {
+      const key = session.sessionId
+      const listeners = lexiconListeners.get(key) ?? new Set()
+      listeners.add(listener)
+      lexiconListeners.set(key, listeners)
+      return () => {
+        listeners.delete(listener)
+        if (listeners.size === 0) lexiconListeners.delete(key)
+      }
+    },
     onPick({ candidate }) {
       // Decision 21: plain-text reference — the literal lands in the draft
       // and ships to the model verbatim (trailing space closes the token).

+ 27 - 0
packages/client/ui-skill/tests/browser-plugin.spec.ts

@@ -208,6 +208,33 @@ describe('lexicon', () => {
     // Another session's key is independent — cold until its own fetch.
     expect(source.lexicon!(proj('s2'))).toBeUndefined()
   })
+
+  it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
+    const { list } = countingList()
+    const { ctx, source } = await bench(list)
+    const s1 = vi.fn()
+    const s2 = vi.fn()
+    source.subscribeLexicon!(proj('s1'), s1)
+    source.subscribeLexicon!(proj('s2'), s2)
+    await source.candidates(proj('s1'), req(''))
+    expect(s1).toHaveBeenCalledTimes(1)
+    expect(s2).not.toHaveBeenCalled()
+    // Reset invalidates every cached session: each key notifies its own listeners.
+    await source.candidates(proj('s2'), req(''))
+    ctx.emit('connection/reset')
+    expect(s1).toHaveBeenCalledTimes(2)
+    expect(s2).toHaveBeenCalledTimes(2)
+  })
+
+  it('an unsubscribed lexicon listener stops receiving notifications', async () => {
+    const { list } = countingList()
+    const { source } = await bench(list)
+    const listener = vi.fn()
+    const off = source.subscribeLexicon!(proj('s1'), listener)
+    off()
+    await source.candidates(proj('s1'), req(''))
+    expect(listener).not.toHaveBeenCalled()
+  })
 })
 
 describe('pick and codec', () => {

+ 3 - 3
packages/client/ui-slash/README.i18n.yaml

@@ -1,6 +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
-README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
-README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
+#   pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
+README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
+README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
+Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
 
 Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
 

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
+输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
 
 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
 

+ 56 - 12
packages/client/ui-slash/src/client/controller.ts

@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
 export class SlashController {
   /** Menu state store (per-session; survives session switches, dies with the scope). */
   readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
+  /**
+   * Aggregated hot reference lexicon, grouped by trigger (decision 21):
+   * sources implementing the lexicon hook are polled with the session
+   * projection; undefined answers (roll not hot yet) are skipped; multiple
+   * sources on one trigger concatenate in registration order. A snapshot
+   * store because rolls change asynchronously (catalog settles, children
+   * spawn/exit) — render-side consumers subscribe instead of re-reading a
+   * mutable answer.
+   */
+  readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
+    createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
 
   /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
   private hit: TriggerHit | null = null
   private fetch: AbortController | null = null
   private disposed = false
+  /** Per-source lexicon unsubscribers (sources without the hook never enter). */
+  private readonly lexiconOffs = new Map<SlashSource, () => void>()
 
   constructor(private readonly deps: SlashControllerDeps) {
     // Scope-birth prewarm: sessions are always agent-backed, so the one-time
     // roster warm here replaces the projection-transition watch — there are
     // no capability steps to react to.
     const projection = this.project()
-    for (const src of deps.roster.all()) src.warm?.(projection)
+    for (const src of deps.roster.all()) {
+      src.warm?.(projection)
+      this.watchLexicon(src, projection)
+    }
+    this.refreshLexicon()
   }
 
   /**
@@ -220,6 +237,23 @@ export class SlashController {
     if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
       this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
     }
+    this.lexiconOffs.get(source)?.()
+    this.lexiconOffs.delete(source)
+    this.refreshLexicon()
+  }
+
+  /**
+   * Admit a source registered after this controller's birth (root registry
+   * change notification): warm it and fold its roll into the live lexicon —
+   * the constructor-time prewarm covers only the roster present at scope
+   * birth.
+   * @param source - the newly registered source.
+   */
+  sourceAdded(source: SlashSource): void {
+    const projection = this.project()
+    source.warm?.(projection)
+    this.watchLexicon(source, projection)
+    this.refreshLexicon()
   }
 
   /** Scope teardown: close and abort (the service deletes the map entry). */
@@ -228,6 +262,8 @@ export class SlashController {
     this.stopFetch()
     this.reduce({ type: 'close' })
     this.hit = null
+    for (const off of this.lexiconOffs.values()) off()
+    this.lexiconOffs.clear()
   }
 
   /** The session projection handed to sources (agent-backed identity; constant per scope). */
@@ -248,25 +284,33 @@ export class SlashController {
     return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
   }
 
-  /**
-   * Aggregate the sources' plain-text reference lexicons (decision 21),
-   * grouped by trigger: sources implementing the hook are polled with the
-   * session projection (onSpace's poll pattern); undefined answers (roll not
-   * hot yet) are skipped; multiple sources on one trigger concatenate in
-   * registration order.
-   * @returns trigger → decorated-name roll for the decoration scan.
-   */
-  lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
+  /** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
+  private refreshLexicon(): void {
     const projection = this.project()
     const rolls = new Map<TriggerChar, readonly string[]>()
     for (const src of this.deps.roster.all()) {
       if (src.lexicon === undefined) continue
-      const names = src.lexicon(projection)
+      let names: readonly string[] | undefined
+      try {
+        names = src.lexicon(projection)
+      } catch (error) {
+        // A faulty source drops silently with a console record (the
+        // candidate-fetch failure policy); the refresh runs inside
+        // notification callbacks, where a throw would starve other consumers.
+        console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
+        continue
+      }
       if (names === undefined) continue
       const prev = rolls.get(src.trigger)
       rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
     }
-    return rolls
+    this.lexicon.set(rolls)
+  }
+
+  /** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
+  private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
+    if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
+    this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
   }
 
   /** Launch the candidate fetch for one hit generation, superseding the previous one. */

+ 12 - 1
packages/client/ui-slash/src/client/service.ts

@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
   }
 
   /**
-   * Register one trigger source.
+   * Register one trigger source. Live session controllers are notified so a
+   * source arriving after scope birth still warms and joins the lexicon.
    * @param src - the source; (trigger, name) must be unique — duplicates throw.
    * @returns the disposer (callers wrap registration in ctx.effect). Disposal
    * while a controller shows the source's menu group drops that group.
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
       throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
     }
     live.sources.push(src)
+    for (const controller of live.controllers.values()) {
+      try {
+        controller.sourceAdded(src)
+      } catch (error) {
+        // Contain faulty source callbacks (warm/subscribeLexicon): the
+        // registration must stand with a usable disposer and the remaining
+        // controllers must still be notified.
+        console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
+      }
+    }
     return () => {
       const at = live.sources.indexOf(src)
       if (at < 0) return

+ 10 - 0
packages/client/ui-slash/src/types.ts

@@ -165,6 +165,16 @@ export interface SlashSource {
    * (the render path must stay synchronous and side-effect free).
    */
   lexicon?(session: ClientSessionContext): readonly string[] | undefined
+  /**
+   * Subscribe to changes of this source's {@link SlashSource.lexicon} answer
+   * for one session (backing data settled, invalidated, or refreshed). The
+   * controller re-polls lexicon on each notification; a source whose roll
+   * never changes after warm omits the hook.
+   * @param session - stable session projection.
+   * @param listener - invalidation callback.
+   * @returns unsubscribe.
+   */
+  subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
   /** Reference codec; required for sources producing insert outcomes. */
   readonly codec?: ReferenceCodec
 }

+ 68 - 3
packages/client/ui-slash/tests/service.spec.ts

@@ -126,6 +126,18 @@ describe('registerSource', () => {
     slash.registerSource(deferredSource('/', 'beta').source)
   })
 
+  it('a source registered after controller birth warms in every live controller', async () => {
+    const { slash, mint } = await serviceBench()
+    const ca = slash.sessionOf(mint('a').actx)
+    const cb = slash.sessionOf(mint('b').actx)
+    const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] })
+    slash.registerSource(late.source)
+    expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') })
+    expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') })
+    expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
+    expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
+  })
+
   it('HMR shape: dispose of the registering fiber removes the source', async () => {
     const { root, slash, mint } = await serviceBench()
     const controller = slash.sessionOf(mint('a').actx)
@@ -513,7 +525,7 @@ describe('lexicon', () => {
       skill,
       lexSource('@', 'subagent', ['worker-1']),
     ])
-    const rolls = controller.lexicon()
+    const rolls = controller.lexicon.getSnapshot()
     expect([...rolls.keys()]).toEqual(['/', '@'])
     expect(rolls.get('/')).toEqual(['commit-helper', 'review'])
     expect(rolls.get('@')).toEqual(['worker-1'])
@@ -522,7 +534,7 @@ describe('lexicon', () => {
 
   it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => {
     const { controller } = controllerBench([lexSource('/', 'skill', undefined)])
-    expect(controller.lexicon().size).toBe(0)
+    expect(controller.lexicon.getSnapshot().size).toBe(0)
   })
 
   it('two sources on one trigger concatenate in registration order', () => {
@@ -531,10 +543,63 @@ describe('lexicon', () => {
       lexSource('/', 'prompt', ['c']),
       lexSource('@', 'subagent', undefined), // not hot: '@' stays absent
     ])
-    const rolls = controller.lexicon()
+    const rolls = controller.lexicon.getSnapshot()
     expect(rolls.get('/')).toEqual(['b', 'a', 'c'])
     expect(rolls.has('@')).toBe(false)
   })
+
+  it('a source lexicon notification republishes the aggregated store', () => {
+    let roll: readonly string[] | undefined = undefined
+    let notify: (() => void) | undefined
+    const source: SlashSource = {
+      trigger: '/',
+      name: 'skill',
+      candidates: () => Promise.resolve([]),
+      onPick: () => undefined,
+      lexicon: () => roll,
+      subscribeLexicon: (_session, listener) => {
+        notify = listener
+        return () => { notify = undefined }
+      },
+    }
+    const { controller } = controllerBench([source])
+    expect(controller.lexicon.getSnapshot().size).toBe(0)
+    const seen: number[] = []
+    controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) })
+    roll = ['commit-helper']
+    notify?.()
+    expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper'])
+    expect(seen).toEqual([1])
+    controller.dispose()
+    expect(notify).toBeUndefined()
+  })
+
+  it('a source registered after scope birth is warmed and folded into the live lexicon', () => {
+    const { controller, sources } = controllerBench([])
+    expect(controller.lexicon.getSnapshot().size).toBe(0)
+    const warm = vi.fn()
+    const late: SlashSource = {
+      trigger: '/',
+      name: 'late',
+      candidates: () => Promise.resolve([]),
+      onPick: () => undefined,
+      warm,
+      lexicon: () => ['fresh'],
+    }
+    sources.push(late)
+    controller.sourceAdded(late)
+    expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') })
+    expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh'])
+  })
+
+  it('a removed source leaves the aggregated lexicon', () => {
+    const src = lexSource('/', 'skill', ['gone'])
+    const { controller, sources } = controllerBench([src])
+    expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone'])
+    sources.splice(sources.indexOf(src), 1)
+    controller.sourceRemoved(src)
+    expect(controller.lexicon.getSnapshot().size).toBe(0)
+  })
 })
 
 describe('arbitrate', () => {

+ 33 - 3
packages/client/ui-slots/src/index.ts

@@ -14,6 +14,7 @@
  * consumer merges keys in and the intersection is what keeps them string-typed.
  * The rule fires on the empty-map view, not on real redundancy. */
 import type { ReactNode } from 'react'
+import type { HostObservable } from './renderer.ts'
 import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
 
 export * from './store.ts'
@@ -214,11 +215,40 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
  */
 export type SlotComponent<P> = (props: P) => ReactNode
 
+/**
+ * Registrant hooks compartment: bare observable sources (getSnapshot +
+ * subscribe pairs) supplied under the reserved `hooks` key of an inject
+ * face. The registrant-private twin of the `sessions.provide` hooks
+ * compartment: the renderer binds each source into a `use<Name>` selector
+ * hook, so the sources never reach the component and plugin-private reactive
+ * facts ride the same subscription machinery as the standard kit instead of
+ * hand-rolled component subscriptions.
+ */
+export type HooksSources = Record<string, HostObservable<unknown>>
+
+/**
+ * Selector-hook share synthesized from a hooks compartment: each source
+ * `name` becomes a `use<Name>` selector hook over its snapshot type.
+ */
+export type PropsHooks<HS extends HooksSources> = {
+  [N in keyof HS & string as `use${Capitalize<N>}`]:
+  SnapshotSelectorHook<HS[N] extends HostObservable<infer T> ? T : never>
+}
+
+/**
+ * The component-side view of an inject face: the reserved `hooks`
+ * compartment (when declared) arrives as bound `use<Name>` selector hooks;
+ * every other member passes through verbatim.
+ */
+export type InjectFace<I extends object> =
+  I extends { hooks: infer HS extends HooksSources } ? Omit<I, 'hooks'> & PropsHooks<HS> : I
+
 /**
  * The four-share component props intersection: runtime share (SlotMap) +
  * child-render share (children declaration) + store share (declared handle) +
- * the registrant's injected business face. Each share derives from its single
- * source of truth; components reference this composition, never re-type it.
+ * the registrant's injected business face (its hooks compartment bound, see
+ * {@link InjectFace}). Each share derives from its single source of truth;
+ * components reference this composition, never re-type it.
  */
 export type ComposedProps<
   K extends keyof SlotMap & string,
@@ -226,7 +256,7 @@ export type ComposedProps<
   H,
   I extends object,
   M = never,
-> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & I & MatchedShare<SlotMap[K], M>
+> = PropsRuntime<K> & PropsRenderSlots<S> & PropsStore<H> & InjectFace<I> & MatchedShare<SlotMap[K], M>
 
 /**
  * Inject factory parameter list, derived from the registration's declaration:

+ 6 - 10
packages/client/ui-slots/src/renderer.ts

@@ -105,18 +105,14 @@ export interface SlotRendererHost {
   sessions: {
     /** Session list source backing the useSessions standard hook. */
     list: HostObservable<unknown>
-    /** Current-session source used by SessionProvider. */
-    current: HostObservable<string | undefined>
-    /** Resolve a definite session bundle, or undefined when the id is unknown. */
-    provideInfo(id: string): SessionProvideInfo | undefined
     /**
-     * Resolve the current-session-optional standard props bundle. The result
-     * always carries the static provider roster, even when `id` is absent or
-     * cannot resolve to a live session.
-     * @param id - current session id, when selected.
-     * @returns the optional provide info.
+     * Atomic current-session provide projection used by SessionProvider:
+     * selection changes and provider-roster changes publish through this one
+     * source, so a stable current id cannot strand mounted entries on an
+     * obsolete hook/prop schema. Carries the static roster with sessionId
+     * undefined while no current session resolves.
      */
-    maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo
+    provideInfo: HostObservable<SessionMaybeProvideInfo>
   }
   /** Workspace-side standard-kit sources. */
   workspaces: {

+ 4 - 0
packages/client/ui-subagent/src/client/index.ts

@@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void {
       // The list snapshot is always warm — the full running-children roster.
       return childLabels(session, '')
     },
+    subscribeLexicon(_session, listener) {
+      // The roll derives from the list snapshot, so its change feed IS the list's.
+      return sessions.list.subscribe(listener)
+    },
     onPick({ candidate }) {
       // Decision 21: plain-text reference — the literal lands in the draft
       // and ships to the model verbatim (trailing space closes the token).

+ 32 - 5
packages/client/ui-subagent/tests/browser-plugin.spec.ts

@@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) {
   const byId: Record<string, SessionSummary> = {}
   for (const s of sessions) byId[s.id] = s
   const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
-  return { list: { getSnapshot: () => snapshot } }
+  const subs = new Set<() => void>()
+  return {
+    list: {
+      getSnapshot: () => snapshot,
+      subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } },
+    },
+    notify: () => { for (const fn of [...subs]) fn() },
+    listenerCount: () => subs.size,
+  }
 }
 
-/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
-async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
+/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */
+async function fullBench(sessions: SessionSummary[]) {
   const ctx = new Context()
   let captured: SlashSource | undefined
+  const face = sessionsWith(sessions)
   ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
-  ctx.provide('sessions', sessionsWith(sessions))
+  ctx.provide('sessions', face)
   await ctx.plugin({ inject: [...inject], apply }).await()
-  return captured!
+  return { source: captured!, face }
+}
+
+/** Source-only bench for the behavior-contract suites. */
+async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
+  return (await fullBench(sessions)).source
 }
 
 const FAMILY: SessionSummary[] = [
@@ -113,6 +127,19 @@ describe('lexicon', () => {
     expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
     expect(source.lexicon!(proj('childless'))).toEqual([])
   })
+
+  it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => {
+    const { source, face } = await fullBench(FAMILY)
+    let notified = 0
+    const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 })
+    expect(face.listenerCount()).toBe(1)
+    face.notify()
+    expect(notified).toBe(1)
+    off()
+    expect(face.listenerCount()).toBe(0)
+    face.notify()
+    expect(notified).toBe(1)
+  })
 })
 
 describe('pick and codec', () => {

+ 22 - 3
packages/client/web-react/src/scoped-slots.tsx

@@ -5,8 +5,8 @@
 import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import {
   SlotOwnershipError, StaleAuthorizationError,
-  type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
-  type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
+  type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
+  type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
   const args: unknown[] = []
   if (info !== undefined) args.push(info.sessionId)
   if (actions !== undefined) args.push(actions)
-  return (inject as (...args: unknown[]) => InjectedProps)(...args)
+  return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
+}
+
+/**
+ * Bind an inject face's reserved `hooks` compartment (bare observable
+ * sources, see HooksSources) into `use<Name>` selector hooks — the
+ * registrant-private twin of the provide-bundle binding in standardKit.
+ * Runs once per cached inject result; hook identity rides observableHook's
+ * per-source cache.
+ */
+function bindInjectHooks(face: InjectedProps): InjectedProps {
+  const sources = face['hooks']
+  if (sources === undefined) return face
+  const { hooks: _hooks, ...rest } = face
+  const bound: InjectedProps = rest
+  for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
+    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
+    bound[hookName] = observableHook(source)
+  }
+  return bound
 }
 
 function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {

+ 10 - 10
packages/client/web-react/src/session-provider.tsx

@@ -90,9 +90,9 @@ function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S,
  */
 export function SessionMaybeProvider({ children }: { children: ReactNode }) {
   const host = useHost()
-  const id = observableHook(host.sessions.current)(s => s)
+  const info = observableHook(host.sessions.provideInfo)(s => s)
   return (
-    <BindingContext.Provider value={host.sessions.maybeProvideInfo(id)}>
+    <BindingContext.Provider value={info}>
       {children}
     </BindingContext.Provider>
   )
@@ -107,17 +107,17 @@ export interface SessionProviderProps {
 }
 
 /**
- * Framework-wired session area: subscribes to the host's current-session
- * source, resolves the session cell, and remounts the body under
- * `key={sessionId}` so a session switch rebuilds the session subtree. This
- * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
- * branded type at the component boundary.
+ * Framework-wired session area: subscribes to the host's current provide
+ * source and remounts the body under `key={sessionId}` so a session switch
+ * rebuilds the session subtree. This dependency-inverted layer uses plain
+ * string ids; `PropsRuntime` applies the branded type at the component
+ * boundary.
  */
 export function SessionProvider({ empty, children }: SessionProviderProps) {
   const host = useHost()
-  const id = observableHook(host.sessions.current)(s => s)
-  const info = id === undefined ? undefined : host.sessions.provideInfo(id)
-  if (id === undefined || info === undefined) return <>{empty?.() ?? null}</>
+  const info = observableHook(host.sessions.provideInfo)(s => s)
+  const id = info.sessionId
+  if (id === undefined) return <>{empty?.() ?? null}</>
   return (
     <BindingContext.Provider value={info} key={id}>
       {children(id)}

+ 2 - 3
packages/client/web-react/tests/scoped-slots-real-core.spec.tsx

@@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
 
 /** Passthrough host over the real core (store/session seats unused here). */
 function hostOver(core: SlotCore): SlotRendererHost {
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   return {
     subscribe: (key, fn) => core.subscribe(key, fn),
     getVersion: key => core.getVersion(key),
@@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost {
     storeOf: () => undefined,
     sessions: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-      current: { getSnapshot: () => undefined, subscribe: () => () => {} },
-      provideInfo: () => undefined,
-      maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
+      provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     },
     workspaces: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },

+ 33 - 6
packages/client/web-react/tests/scoped-slots.spec.tsx

@@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { act, fireEvent, render } from '@testing-library/react'
 import { useEffect, type ReactNode } from 'react'
 import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError,
   type RenderOpts, type SessionProvideInfo,
@@ -85,7 +86,9 @@ function makeHost() {
   const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
   const list = observable<{ ids: string[] }>({ ids: [] })
   const workspaces = observable<{ ids: string[] }>({ ids: [] })
-  const current = observable<string | undefined>(undefined)
+  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
+  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+  let currentId: string | undefined
   const infos = new Map<string, SessionProvideInfo>()
 
   const bump = (key: string) => {
@@ -123,10 +126,7 @@ function makeHost() {
     },
     sessions: {
       list,
-      current,
-      provideInfo: id => infos.get(id),
-      maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
-        ?? { sessionId: undefined, hooks: {}, props: {} },
+      provideInfo: provide,
     },
     workspaces: { list: workspaces },
   }
@@ -134,7 +134,14 @@ function makeHost() {
     host,
     list,
     workspaces,
-    current,
+    // Same driver surface as the old current cell: set(id) publishes the
+    // resolved bundle (or the absent projection) through the provide source.
+    current: {
+      set: (id: string | undefined) => {
+        currentId = id
+        provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
+      },
+    },
     declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
     add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
       const entry = entryOf(partial)
@@ -161,6 +168,7 @@ function makeHost() {
         props: {},
       }
       infos.set(id, info)
+      if (currentId === id) provide.set(info)
       return info
     },
   }
@@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
     expect(inject).toHaveBeenCalledWith()
   })
 
+  it('binds the inject hooks compartment into use<Name> selector hooks (sources never reach the component)', () => {
+    const h = makeHost()
+    h.declare('k.single', SINGLE_ROOT)
+    const badge = observable('cold')
+    const seen: Record<string, unknown>[] = []
+    h.add('k.single', {
+      component: (props: { useBadge?: <S>(sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
+        seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
+        return null
+      },
+      inject: () => ({ plain: 'kept', hooks: { badge } }),
+    })
+    mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
+    // The raw compartment is consumed by the binding; the plain member passes through.
+    expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
+    act(() => { badge.set('hot') })
+    expect(seen.at(-1)!['read']).toBe('hot')
+  })
+
   it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
     const h = makeHost()
     h.declare('k.session', SINGLE_SESSION)

+ 42 - 8
packages/client/web-react/tests/session-provider.spec.tsx

@@ -9,7 +9,7 @@
 import { useEffect, useRef } from 'react'
 import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
-import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
 import {
   createSlotRenderer, SessionProvider,
   type SessionProvideInfo, type SlotRendererHost,
@@ -26,12 +26,14 @@ function observable<T>(initial: T) {
 }
 
 /**
- * Minimal host: SessionProvider only reads sessions.current/cell, but it must
+ * Minimal host: SessionProvider only reads sessions.provideInfo, but it must
  * render inside the renderer tree (HostContext), so the harness mounts a real
  * root entry whose body is the test's render-prop provider.
  */
 function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
-  const current = observable<string | undefined>(undefined)
+  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
+  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+  let currentId: string | undefined
   const infos = new Map<string, SessionProvideInfo>()
   const sessionEntries: StoredEntry[] = []
   const rootEntry: StoredEntry = {
@@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
     storeOf: () => undefined,
     sessions: {
       list: observable<unknown>({ ids: [] }),
-      current,
-      provideInfo: id => infos.get(id),
-      maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id))
-        ?? { sessionId: undefined, hooks: { session: undefined }, props: {} },
+      provideInfo: provide,
     },
     workspaces: { list: observable<unknown>({ items: [] }) },
   }
   return {
     host,
-    current,
+    // Same driver surface as the old current cell: set(id) publishes the
+    // resolved bundle (or the absent projection) through the provide source.
+    current: {
+      set: (id: string | undefined) => {
+        currentId = id
+        provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
+      },
+    },
     addSession: (id: string) => {
       // Bare source per bundle (identity-stable): the machinery binds useSession from it.
       const info: SessionProvideInfo = {
@@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
         props: {},
       }
       infos.set(id, info)
+      if (currentId === id) provide.set(info)
       return info
     },
+    /** Swap one session's bundle in place (roster-change stand-in); republish when current. */
+    replaceSession: (info: SessionProvideInfo) => {
+      infos.set(info.sessionId, info)
+      if (currentId === info.sessionId) provide.set(info)
+    },
     registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
   }
 }
@@ -149,6 +161,28 @@ describe('SessionProvider', () => {
     expect(seen.at(-1)!['sessionId']).toBe('s2')
   })
 
+  it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
+    const seen: unknown[] = []
+    const h = makeHost({
+      root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
+    })
+    const original = h.addSession('s1')
+    h.registerSession({
+      component: (props: { feature?: string }) => {
+        seen.push(props.feature)
+        return null
+      },
+      options: {},
+    })
+    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
+    act(() => { h.current.set('s1') })
+    expect(seen.at(-1)).toBeUndefined()
+    // A provider-roster change rematerializes the bundle; the provide source
+    // must carry it to already-mounted entries without a selection change.
+    act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
+    expect(seen.at(-1)).toBe('now-live')
+  })
+
   it('fails loud when mounted outside the renderer tree (no host channel)', () => {
     const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
     expect(() => render(

+ 2 - 3
packages/client/web-react/tests/stale-authorization.spec.tsx

@@ -21,6 +21,7 @@ function makeHost() {
   const versions = new Map<string, number>()
   const subs = new Map<string, Set<() => void>>()
   const live = new Set<StoredEntry>()
+  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
   const bump = (key: string) => {
     versions.set(key, (versions.get(key) ?? 0) + 1)
     for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -39,9 +40,7 @@ function makeHost() {
     storeOf: () => undefined,
     sessions: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-      current: { getSnapshot: () => undefined, subscribe: () => () => {} },
-      provideInfo: () => undefined,
-      maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }),
+      provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
     },
     workspaces: {
       list: { getSnapshot: () => ({}), subscribe: () => () => {} },