rfc: session projections and command lifecycle logging (proposed, bilingual)

This commit is contained in:
imccyu
2026-07-27 14:00:43 +08:00
parent 5b5f089bd0
commit ed72b56f53
3 changed files with 308 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
2026-07-27-session-projection-and-command-log.md: 0378530c42b0a041c2dc4a248228c0a3fa6a757a
2026-07-27-session-projection-and-command-log.zh.md: 6f5e6efb40e949b0bc04bc0e85061c084f52c91a

View File

@@ -0,0 +1,151 @@
# Agent Note: Session projections and command lifecycle logging
Status: proposed
English | [中文](2026-07-27-session-projection-and-command-log.zh.md)
## Problem
Three in-flight web features — todo (#497), goal (#527), and plan mode (#587) — each derive per-session state from the session log and surface it in the browser client, and each invented its own copy of the same machinery:
- **The client core class absorbs every domain.** All three add private fields, fetch choreography, and event switches to the client runtime's `Session` class and project their values through `ConversationSnapshot`. Plan alone adds seven private fields and a three-layer fence (request version, event version, latest-live cache); goal adds a write-revision fence plus a coalesced refetch loop; todo adds a projection field and an event case. A fourth domain means editing the core class a fourth time.
- **Three baseline channels.** Todo rides a `todos` field on the history tail page — computed by `backscanTodos` **inside api-proxy**, business folding living in the carrier; plan adds a dedicated `session.planMode` unary; goal adds `goals.get`. Same problem, three wire shapes.
- **Command results are unrecoverable.** `/goal`, `/plan`, and every other slash command return their outcome only in the `command.execute` RPC response, surfaced as a transient composer notice on the issuing tab. Nothing reaches the session log: a refresh, another tab, resume, or fork loses the record that the command ever ran. The domain *state* changes are durable (goal commits `goal/change` metadata, plan commits `plan/mode`), but the command invocation and its verdict are not.
The underlying gap is architectural: the client has no seam for a plugin to observe session events in a session's scope and keep its own derived state, and the host has no uniform way to hand a client the current value of log-derived state whose history may have been paged out of the client's window.
## Proposal
Four infrastructure pieces, then the domains become pure contributors.
### Whole-value event rule
A state-carrying log event MUST carry the complete post-change state, never a delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). Under this rule the client-side fold degenerates to **last-wins**: a domain's state is the whole value carried by the highest-seq domain event seen. No client-side state machine (goal's revision/CAS/phase checks stay at the host write path), no history dependence, out-of-order immunity by seq comparison, and self-healing — a missed event is corrected by the next one.
### Host projection registry (`dsh-session-projection`, new package)
A light interface package: the merge-extensible type map, the registry service, zod at the boundary. Capability-seam three-way split: domain host plugins contribute, carriers consume, neither knows the other.
```ts
export interface SessionProjectionMap {} // the single type table for the whole chain
export interface ProjectionProvider<K extends keyof SessionProjectionMap> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
get(agent: Agent): SessionProjectionMap[K] // MUST be synchronous; whole current value
}
declare module 'cordis' {
interface Context { sessionProjections: SessionProjectionRegistry }
}
```
- Values are wire JSON payloads; the same map typed end to end (host provider, wire block, client cell, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
- `get` runs against the host's full in-memory log (`agent.session.events`) — pagination exists only in the history slice returned to the client, never in the provider's view, so "the window lacks the event" cannot lose state on the host. A last-wins domain may backscan (bounded: first hit from the tail terminates; the events live in memory); a domain with an expensive fold keeps an incremental cache keyed by observed seq (goal's `GoalCache` is the template). Either way the provider returns the current whole value synchronously.
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
- The package owns `./invariant` (every served key has a live registration).
### Wire: projections block on the history tail page
```ts
// session.history response, tail page only (beforeSeq absent):
{ events, hasMore,
projections?: { asOfSeq: number, values: Partial<SessionProjectionMap> } }
```
The api-proxy history handler, after slicing the tail page, reads `session.seq`, then synchronously walks the registry — no `await` anywhere, so every key's value and `asOfSeq` form one consistent cut, and `asOfSeq` equals the window tail seq. Api-proxy holds zero domain knowledge (the same carrier/contributor relationship as `viewFor` against `ctx.tools`).
No new RPC method. The timing coincidence is exact: every moment the client needs a fresh baseline (open, reconnect resync, gap repair) already pulls the tail page, and the only path that never needs one (loadOlder) is the only path that passes `beforeSeq`. The client therefore has **no** independent "refetch the baseline" decision at all. Window content is never a signal: "no domain event in the window" is unanswerable there by construction, and only the baseline answers it.
Retired by this block: `session.planMode` (read side; `setPlanMode` stays), `goals.get` (read side; the six mutation RPCs stay, their responses no longer feed state — the mux event arrives anyway), the `todos` rider field, and `backscanTodos` in api-proxy (moves into the todo domain's provider, in `tool-todo`).
### Client: session-scope event dispatch and projection cells
The client runtime `Session` object gains a dispatch seam at its two event entrances — `appendLive(event)` (live signal) and `installWindow(…)` (window-replace signal, plus baseline reset when the response carries a projections block). Live and window-replace are distinguishable signals: that distinction is what #527 hand-rolled to avoid refetch storms and #587 hand-rolled to re-scan replacement windows. The core class returns to pure transcript concerns; the domain switches leave `applyEventSideEffects`.
Domain client plugins register **projection cells** at scope materialization (the `InputHub.shellFor` pattern; teardown rides the scope fiber):
```ts
export interface ProjectionCellSpec<K extends keyof SessionProjectionMap> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the baseline at the wire boundary
fromEvent(event: SessionEvent): SessionProjectionMap[K] | undefined // whole value, or not-my-event
}
```
Framework semantics, implemented once for all cells: a `lastAppliedSeq` watermark initialized from the baseline's `asOfSeq`; one application rule — `event.seq > watermark` and `fromEvent` hit ⇒ take the whole value, raise the watermark, `markDirty` (Notifier batching); live and window-replace events pass the same filter, so replayed old pages are dropped by seq and can never roll state back; a baseline reset re-seeds value and watermark, and a key absent from the block marks the capability absent. All the per-domain fences (#587's three layers, #527's write revision) dissolve into this one seq rule. Plan's pending intent stays out of the log (turn-enclosure) but inside the projection value — the host's `planMode.get()` already returns exactly that shape; pending is not propagated to other tabs (accepted: it is the issuing tab's local "awaiting boundary" fact; other tabs see the commit event).
### React: `useProjection`, the fifth framework hook seat
The existing four seats cannot host this state (store discipline bans business objects; inject bans hooks; `ConversationSnapshot` is being evacuated). `useProjection` becomes a framework seat, minted in web-react (the one hook constructor), delivered through the same standard-kit channel as `useSession` (`provideInfo` → SessionProvider → props):
```ts
type UseProjection = {
<K extends keyof SessionProjectionMap>(key: K): SessionProjectionMap[K] | undefined
<K extends keyof SessionProjectionMap, S>(
key: K, selector: (v: SessionProjectionMap[K] | undefined) => S,
eq?: (a: S, b: S) => boolean): S
}
```
`undefined` uniformly means capability absent (host plugin unmounted, client plugin unmounted, or baseline not yet landed). Cells expose bare `{subscribe, getSnapshot}`; `bindSnapshotSelector` with per-cell caching does the rest — reference stability holds because whole values are frozen event data, identical between events. Write paths are unchanged: mutation callbacks stay in the inject share (callbacks out of inject, live state out of `useProjection`).
The one existing violation of "no hooks through inject" — `DetailsInjected.useSelection` — is folded in with this change: selection is viewing state living in the chat store, so the details registration declares the shared store handle and the component reads `props.useStore(s => s.selection)`; `useSelection` leaves the inject contract.
### Command lifecycle in the log
Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing:
```ts
'command/run': { commandId: string; name: string; line: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
Both merged into `OutOfBandSessionEventMap`. The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to pure admission (matched or not, syntax errors back to the composer immediately); the one-shot notice channel (`runDetached``noticeFor`) is retired.
The client flow builder gains one generic command node (run/done paired by `commandId`; cross-window cuts soft-fall like tool pairs). Rendering goes through a new keyed slot `'conversation.chat.commandview'`, key = command name, **fallback = a generic command card** (zero registration required — the former notice text now renders durably in the flow). A domain upgrades by registering one row component, drawing on `command/run.line` and its own cell state — the same shape as tool rows after the toolview dissolution.
## Delivery plan
Infrastructure first; the three in-flight PRs are left untouched and re-target after the base lands (their migration mapping is the guide):
1. **Host base**: `dsh-session-projection` + api-proxy projections block. Mergeable with zero domains registered (block simply absent).
2. **Client base**: dispatch seam + cell framework + `useProjection` seat + the `useSelection` fold-in. Parallel with 1 (fixtures feed synthetic baselines).
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement. Parallel with 1.
4. **Domain re-targets** (after 1+2): todo first (smallest: provider in `tool-todo`, cell from `todo/write`, drop the rider field), then plan (drop the unary and the fences), then goal (drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
## Alternatives considered
**A dedicated `session.projections` RPC** — rejected: baseline-refresh moments coincide exactly with tail-page pulls, so a separate unary buys a second round-trip, a second seq to reconcile, and a client-side "when to refetch" decision that the rider design deletes outright.
**Naming the seam `registerFold`** — rejected: `get` does not promise a fold (goal reads a cache, plan overlays un-logged pending intent from service memory); `fold*` in this repo names pure `(events) => state` functions and the registry would dilute that. Projection is the event-sourcing term for exactly this read-model role, and both #587's note title and #497's comments already use it.
**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear.
**Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package.
**A separate client-side `SessionProjectionViews` type table** — rejected: one `SessionProjectionMap` typed end to end is the wire-passthrough discipline (no second DTO vocabulary); values are JSON payloads and rendering belongs to slots.
**Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots).
**Propagating plan's pending intent across tabs** — deferred, not designed in: pending is deliberately un-logged (turn enclosure), a live non-logged control frame (the `session/queued` precedent) can add it later without touching this model.
**Making mutation RPC responses feed cell state** — rejected: the committed mux event arrives immediately and carries the same whole value with a seq; responses feeding state is what required #527's write-revision fence.
## Acceptance criteria
- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host `register`, one client cell registration, and inject callbacks — no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files beyond its own `SessionProjectionMap` merge.
- The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent.
- Replayed window events cannot regress cell state (watermark test); a baseline landing after a newer mux commit cannot overwrite it (seq rule test).
- A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone.
- `useProjection` reaches components through the standard props kit; no hook crosses an inject contract (including `useSelection`).
## Risks
- **Whole-value rule is load-bearing**: a future domain logging deltas breaks last-wins silently. Mitigation: the rule is stated here and in the projection package README; cell `fromEvent` signatures make delta shapes unrepresentable without deliberate effort.
- **Synchronous `get` discipline**: a provider that awaits would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
- **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model.
- **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume.
- **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first; the migration mapping section in the design ledger names each PR's keep/drop list.

View File

@@ -0,0 +1,151 @@
# Agent Note: Session projections and command lifecycle logging
Status: proposed
[English](2026-07-27-session-projection-and-command-log.md) | 中文
## Problem
三个在途的 web 功能——todo#497、goal#527、plan mode#587)——都要从会话日志推导按会话的状态并呈现到浏览器客户端,而三者各自发明了一套同样的机制:
- **客户端核心类吸收每一个领域。** 三者都往客户端运行时的 `Session` 类里添加私有字段、拉取编排和事件 switch 分支,并经 `ConversationSnapshot` 投出各自的值。仅 plan 一家就加了七个私有字段和三层栅栏请求版本、事件版本、最新活值缓存goal 加了写 revision 栅栏外加一个合并式重取循环todo 加了一个投影projection字段和一条事件 case 分支。再来第四个领域,就要第四次改动核心类。
- **三条基线通道。** todo 搭在历史尾页的 `todos` 字段上——由 **api-proxy 内部**的 `backscanTodos` 计算业务折叠fold逻辑寄居在载体里plan 加了一个专用的 `session.planMode` 一元 RPCgoal 加了 `goals.get`。同一个问题三种协议格式wire format
- **命令结果不可恢复。** `/goal``/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下刷新、另开标签页、恢复resume或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的goal 提交 `goal/change` 元数据plan 提交 `plan/mode`),但命令调用本身及其结论不是。
底层缺口是架构性的:客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。
## Proposal
先立四件基础设施,之后各领域都退化为纯贡献方。
### 全量值事件规则
携带状态的日志事件必须携带变更后的完整状态,绝不携带增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。在该规则下,客户端侧的折叠退化为 **last-wins**:一个领域的状态,就是已见 seq 最高的该领域事件所携带的全量值。无需客户端状态机goal 的 revision/CAS/阶段检查留在 host 侧写路径),不依赖历史,靠 seq 比较获得乱序免疫,而且自愈——漏掉的事件会被下一个事件纠正。
### host 侧投影注册表(`dsh-session-projection`,新包)
一个轻量的接口包packagemerge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分:领域 host 插件负责贡献,载体负责消费,两侧互不相识。
```ts
export interface SessionProjectionMap {} // the single type table for the whole chain
export interface ProjectionProvider<K extends keyof SessionProjectionMap> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
get(agent: Agent): SessionProjectionMap[K] // MUST be synchronous; whole current value
}
declare module 'cordis' {
interface Context { sessionProjections: SessionProjectionRegistry }
}
```
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通host 提供方、协议块、客户端 cell、React 钩子)——没有第二张 DTO 表也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
- `get` 面向 host 的全量内存日志(`agent.session.events`)运行——分页只存在于返回给客户端的历史切片里,绝不出现在提供方的视野中,所以「窗口里缺这个事件」在 host 侧不可能丢状态。last-wins 领域可以回扫(有界:从尾部起首个命中即终止;事件本就在内存里);折叠开销大的领域维护一份以已见 seq 为键的增量缓存goal 的 `GoalCache` 即范本)。无论哪种方式,提供方都同步返回当前全量值。
- 注册是 effectdisposer 随 fiber 走):插件卸载后其 key 从后续响应中消失客户端将其读作能力缺失——HMR热模块替换语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
### 协议层:历史尾页上的 projections 块
```ts
// session.history response, tail page only (beforeSeq absent):
{ events, hasMore,
projections?: { asOfSeq: number, values: Partial<SessionProjectionMap> } }
```
api-proxy 的历史处理器切出尾页后读取 `session.seq`,然后同步遍历注册表——全程没有一个 `await`,因此所有 key 的值与 `asOfSeq` 构成同一个一致切面,且 `asOfSeq` 等于窗口尾部 seq。api-proxy 不持有任何领域知识(与 `viewFor` 面向 `ctx.tools` 是同一种载体/贡献方关系)。
不新增 RPC 方法。时机上的重合是精确的客户端每一个需要新基线的时刻打开、重连重同步、缺口修补本来就要拉尾页而唯一永远不需要基线的路径loadOlder恰好是唯一传 `beforeSeq` 的路径。因此客户端**完全没有**独立的「重取基线」决策。窗口内容从不充当信号:「窗口里没有该领域的事件」这个问题在窗口内从构造上就无法回答,只有基线能回答它。
随此块下线的旧通道:`session.planMode`(读侧;`setPlanMode` 保留)、`goals.get`(读侧;六个变更 RPC 保留但其响应不再喂状态——mux 事件反正会到)、`todos` 搭载字段,以及 api-proxy 里的 `backscanTodos`(移入 todo 领域的提供方,落在 `tool-todo`)。
### 客户端:会话 scope 的事件分发与投影 cell
客户端运行时的 `Session` 对象在它的两个事件入口——`appendLive(event)`(实时信号)与 `installWindow(…)`(窗口替换信号,响应携带 projections 块时附带基线重置)——获得一个分发 seam。实时与窗口替换是可区分的两种信号#527 为避免重取风暴手工造出的、#587 为重扫替换窗口手工造出的,正是这个区分。核心类回归纯 transcript文本记录关切各领域的 switch 分支撤出 `applyEventSideEffects`
领域客户端插件在 scope 物化时注册**投影 cell**(即 `InputHub.shellFor` 模式;销毁随 scope fiber 走):
```ts
export interface ProjectionCellSpec<K extends keyof SessionProjectionMap> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the baseline at the wire boundary
fromEvent(event: SessionEvent): SessionProjectionMap[K] | undefined // whole value, or not-my-event
}
```
框架语义对所有 cell 只实现一次:一条从基线 `asOfSeq` 初始化的 `lastAppliedSeq` 水位线watermark唯一一条应用规则——`event.seq > watermark``fromEvent` 命中 ⇒ 取全量值、抬高水位线、`markDirty`Notifier 批处理);实时事件与窗口替换事件过同一道过滤,所以重放的旧页按 seq 被丢弃,永远不可能把状态往回滚;基线重置会重设值与水位线,块中缺席的 key 则把对应能力标记为缺失。所有按领域自造的栅栏(#587 的三层、#527 的写 revision都消融进这一条 seq 规则。plan 的待定意图不入日志turn-enclosure但在投影值之内——host 的 `planMode.get()` 返回的恰是这个形状;待定态不向其他标签页传播(已接受:它是发起标签页本地的「等待边界」事实;其他标签页看到的是提交事件)。
### React`useProjection`,第五个框架钩子席位
既有四个席位都装不下这份状态store 纪律禁止业务对象inject 禁止钩子;`ConversationSnapshot` 正在被清退)。`useProjection` 成为一个框架席位,在 web-react唯一的钩子铸造点铸造经与 `useSession` 相同的标准套件通道(`provideInfo` → SessionProvider → props送达
```ts
type UseProjection = {
<K extends keyof SessionProjectionMap>(key: K): SessionProjectionMap[K] | undefined
<K extends keyof SessionProjectionMap, S>(
key: K, selector: (v: SessionProjectionMap[K] | undefined) => S,
eq?: (a: S, b: S) => boolean): S
}
```
`undefined` 统一表示能力缺失host 插件未挂载、客户端插件未挂载或基线尚未到达。cell 只暴露裸的 `{subscribe, getSnapshot}`;其余交给带逐 cell 缓存的 `bindSnapshotSelector`——引用稳定性成立,因为全量值是冻结的事件数据,两次事件之间恒等不变。写路径不变:变更回调留在 inject 共享面(回调出自 inject活状态出自 `useProjection`)。
「钩子不得穿过 inject」的唯一既有违例——`DetailsInjected.useSelection`——随本变更一并收编:选中态是住在聊天 store 里的查看状态,因此 details 注册声明共享 store 句柄,组件改读 `props.useStore(s => s.selection)``useSelection` 退出 inject 契约。
### 日志中的命令生命周期
两个仅日志(非 surface、模型不可见事件镜像 `tool/call`/`tool/result` 的配对:
```ts
'command/run': { commandId: string; name: string; line: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
两者都合并进 `OutOfBandSessionEventMap`。host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done``text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据不是呈现版式如何编排仍由客户端在渲染时计算满足「呈现永不入日志」这条红线。想让模型知道结果的领域继续做它们今天在做的事plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
由于已提交事件会在 mux 流上广播刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为纯准入判定(是否匹配命中、语法错误立即打回 composer一次性通知通道`runDetached``noticeFor`)就此下线。
客户端 flow 构建器新增一个通用命令节点run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run.line` 与自己的 cell 状态——与 toolview 解散之后的工具行同一形状。
## Delivery plan
基础设施先行;三个在途 PRPull Request原样不动待基座落地后重新对接它们的迁移映射即指南
1. **host 基座**`dsh-session-projection` + api-proxy 的 projections 块。零领域注册也可合入(此时块直接缺席)。
2. **客户端基座**:分发 seam + cell 框架 + `useProjection` 席位 + `useSelection` 收编。与 1 并行fixture测试前置数据喂合成基线
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线。与 1 并行。
4. **领域重新对接**(在 1+2 之后):先 todo最小提供方进 `tool-todo`cell 取自 `todo/write`,删掉搭载字段),再 plan删掉一元 RPC 和各道栅栏),最后 goal删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject
## Alternatives considered
**专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。
**把 seam 命名为 `registerFold`**——不予采纳:`get` 并不承诺折叠goal 读缓存plan 从服务内存叠加未入日志的待定意图);本仓库里 `fold*` 专指纯 `(events) => state` 函数注册表会稀释这一命名。projection投影正是事件溯源中指称这种读模型角色的术语#587 的 Note 标题与 #497 的评论也都已在使用它。
**`invalidate` 式 cell标脏遇领域事件就重取**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-winsgoal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。
**把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属TUI、ACPAgent Client Protocol、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。
**独立的客户端 `SessionProjectionViews` 类型表**——不予采纳:一张 `SessionProjectionMap` 端到端贯通正是协议直通纪律(不设第二套 DTO 词汇);值就是 JSON 载荷,渲染归 slot 管。
**用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot
**把 plan 的待定意图跨标签页传播**——推迟不纳入本设计待定态是刻意不入日志的turn enclosure一种实时的非日志控制帧先例 `session/queued`)日后可以在完全不动本模型的前提下补上它。
**让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。
## Acceptance criteria
- 领域插件把按会话的日志派生状态送达 React只需写全量值事件声明、一次 host 侧 `register`、一次客户端 cell 注册、以及 inject 回调——除自己那份 `SessionProjectionMap` merge 之外,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
- 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seqloadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。
- 重放的窗口事件不能让 cell 状态倒退(水位线测试);在更新的 mux 提交之后才落地的基线不能覆盖该提交seq 规则测试)。
- 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。
- `useProjection` 经标准 props 套件抵达组件;没有任何钩子穿过 inject 契约(包括 `useSelection`)。
## Risks
- **全量值规则是承重结构**:未来某个领域若记增量事件,会无声地破坏 last-wins。缓解该规则写明在本 Note 与投影包的 README 里cell 的 `fromEvent` 签名使增量形状若非刻意为之便无从表达。
- **同步 `get` 纪律**:提供方一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律invariant 配套在可行范围内断言同步性;其余由评审把关。
- **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key模型本身不用改。
- **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。
- **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价;设计台账中的迁移映射一节逐一列出每个 PR 的保留/删除清单。