# Agent Note: 会话投影与命令生命周期日志记录

Status: proposed

[English](2026-07-27-session-projection-and-command-log.md) | 中文

## 问题

三个在途的 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` 一元 RPC；goal 加了 `goals.get`。同一个问题，三种协议格式（wire format）。
- **命令结果不可恢复。** `/goal`、`/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果，以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下：刷新、另开标签页、恢复或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的（goal 提交 `goal/change` 元数据，plan 提交 `plan/mode`），但命令调用本身及其结论不是。

底层缺口是架构性的：客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态；host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。

## 提案

先立四件基础设施，之后各领域都退化为纯贡献方。

### 全量值事件规则

携带状态的日志事件必须携带变更后的完整状态，绝不携带裸增量。三个领域现状已然合规：`todo/write` 是整表快照，`plan/mode` 是一个完整布尔值，`goal/change` 元数据是完整的 `GoalSnapshot`（或一个全量值清除墓碑）。该规则让每个领域的状态转移始终足够廉价（框架逐事件驱动它），让值在协议层自描述，并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫，且自愈：漏掉的更新会被下一次更新纠正。

### host 侧投影注册表（`dsh-session-projection`，新包）

一个轻量的接口包：merge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分：领域 host 插件负责贡献，载体负责消费，两侧互不相识。

领域注册的是一个**状态驱动计算单元（state-driven computation unit）**——三个纯函数外加若干声明——绝不是一个不透明的 getter。驱动它是框架的职责（订阅、水位线（watermark）、缓存，以及后续的检查点机制），领域只负责数学本身。投影服务于所有业务领域（会话标题、plan、goal、权限、todos）；命令只是其中一条触发路径，在本契约中没有任何特殊地位。

```ts ignore-check
export interface SessionProjectionMap {}   // the single type table for the whole chain

export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
  key: K
  schema: ZodType<SessionProjectionMap[K]>  // validates the payload before it leaves the host
  /** State for the empty log. */
  init(): S
  /** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
  apply(state: S, event: SessionEvent): S
  /** State → wire payload (the read-side projection). */
  view(state: S): SessionProjectionMap[K]
  /** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
  stateVersion: number
}

declare module 'cordis' {
  interface Context { sessionProjections: SessionProjectionRegistry }
}
```

- 值就是协议层的 JSON 载荷；同一张类型表经 `import type` 端到端贯通（host 侧单元、协议块、React 钩子）——没有第二张 DTO 表，也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事，永远不归投影层管。
- **host 是投影唯一的计算地点。** 框架主动驱动（eager drive）每个已注册的单元：每个已提交的会话事件都经过 `apply`；对某事件不感兴趣的单元返回同一个状态引用，而引用未变（`Object.is`）就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值（基线块 + 下文的推送帧）。这消除了双重实现陷阱（plan 的双事件折叠只在 host 写一遍），也消除了一切客户端侧领域代码。
- **状态永远靠计算得出，绝不入日志。** 日志只存事件；单元的状态住在框架的按会话水位线缓存里（每单元一份 `{state, observedSeq}`），并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存（persisted projection cache）**：形如 `(sessionId, key, ver, seq, val)` 的行（`ver` = 单元的 `stateVersion`，`seq` = 水位线，`val` = 状态 JSON）。一行永远不会是错的，至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方：取缓存状态（或 `init()`），只对超出其水位线的事件做正向 `apply`，再对结果做 `view`。冷列表（跨全部 workspace 列出每个会话的标题）变成一次索引读，至多外加一小段尾部回放；session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略：节流（次数/间隔，可配置）外加两个强制点——`turn/end` 与 detach（由活转冷的时刻）。两次写入之间崩溃的代价是尾部回放更长一些，绝不会是值出错。
- 领域的输入事件集由领域自己选择：todos 只折叠 `todo/write`；plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录（见 plan 一节）；goal 折叠 `goal/change` 元数据；会话标题折叠其标题事件（顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影）。
- 注册是 effect（disposer 随 fiber 走）：插件卸载后其 key 从后续响应中消失，客户端将其读作能力缺失——HMR（热模块替换）语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册，因此不带注册表的 headless 组装完全不受影响。
- 该包拥有 `./invariant`（每个被服务的 key 都有一条存活的注册）。

### 协议层：历史尾页上的 projections 块

```ts ignore-check
// session.history response, tail page only (beforeSeq absent):
{ events, hasMore,
  projections?: { asOfSeq: number, values: Partial<SessionProjectionMap> } }
```

api-proxy 的历史处理器切出尾页后同步遍历注册表——全程没有一个 `await`，因此所有 key 的值与 `asOfSeq` 构成同一个一致切面。`asOfSeq` 是**最后一个事件的 seq**（`session.seq - 1`；空日志为 `-1`，与 `session/subscribed.lastSeq` 同一套词汇），因此携带基线之后首个变更的推送帧在比较时恒严格更大。api-proxy 不持有任何领域知识（与 `viewFor` 面向 `ctx.tools` 是同一种载体/贡献方关系）。

不新增 RPC 方法。时机上的重合是精确的：客户端每一个需要新基线的时刻（打开、重连重同步、缺口修补）本来就要拉尾页，而唯一永远不需要基线的路径（loadOlder）恰好是唯一传 `beforeSeq` 的路径。因此客户端**完全没有**独立的「重取基线」决策。窗口内容从不充当信号：「窗口里没有该领域的事件」这个问题在窗口内从构造上就无法回答，只有基线能回答它。

随此块下线的旧通道：`session.planMode` 与 `setPlanMode`（读写两侧——plan 选择改走标准命令通道，见 plan 一节）、`goals.get`（读侧；六个变更 RPC 保留，但其响应不再喂状态——mux 事件反正会到）、`todos` 搭载字段，以及 api-proxy 里的 `backscanTodos`（移入 todo 领域的单元，落在 `tool-todo`）。

### 推送帧与客户端值仓（领域零客户端代码）

既然 host 是唯一计算地点，成品值经一个新的 mux 帧送达客户端：

```ts ignore-check
// MuxFrame union + schema branch:
{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
```

只要某单元的状态引用发生变化（上文的 `Object.is` 闸门），框架就发出该帧；`seq` 是发出时该单元的水位线。这是实时推送状态，绝不入日志——与 tool-view 的 `view` slot 同一姿态：回放时在 host 重新计算。

客户端对象层为每个会话维护一个**通用值仓（value store）**：`key → { value, seq }`，由尾页的 projections 块播种、由该帧更新，唯一规则是 **seq 高者胜**。重放的基线无法把更新的帧往回滚；丢失一个帧的代价只是陈旧——到下一个帧或基线为止——绝不会出错。没有 `fromEvent`，没有按领域的 cell 注册，没有客户端侧领域折叠——领域交付投影支持只需**零客户端代码**（`SessionProjectionMap` merge 经 `/types` 出口同时服务两侧）。专设的 `session/title` 帧与 manager 的标题快照表都收编进这对通用机制。所有按领域自造的栅栏（#587 的三层、#527 的写 revision）都消融进这一条 seq 规则。

### plan 走标准命令通道（完整示例）

plan mode 完整演示了这套模式——触发路径、运行面、回放面，三者干净分离：

- **触发路径**：web 的 plan 开关像任何其他命令一样经 `command.execute` 发送 `/plan` / `/plan off`；专设的 `setPlanMode`/`planMode` RPC 下线。用户的*请求*被持久记录为该命令的 `command/run { name: 'plan', args: 'off' | '' }`——结构化字段，无需解析行文本。
- **运行面**（不变）：plan-mode 服务在内存里保持待定意图，并在下一个轮次边界落下 `plan/mode`。冷启动时服务从回放面重建其意图队列（「运行态为空即以回放态为准」）。
- **回放面**：plan 的投影单元折叠**两**种事件——它自己的 `command/run` 记录设置 `wanted`；`plan/mode` 设置 `active` 并清除 `wanted`；`view` 推导出 `{ active, pending: wanted !== null && wanted !== active }`。待定态由此成为纯回放量：host 重启能恢复它，其他标签页折叠同样的事件（跨标签页待定态随之自动获得），冷读回答 `{ active: false, pending: true }` 也是准确的（「一个未兑现的选择正等待恢复」）。

领域的输入事件集由领域自己选择——本示例落实的正是这条一般规则。「用户请求过 X」是出现在投影里（plan 折叠自己的命令记录），还是只出现在 flow 里（命令节点反正会渲染），属于各领域自己的语义，永远不是框架的关切。

### React：`useProjection`，第五个框架钩子席位

既有四个席位都装不下这份状态（store 纪律禁止业务对象；inject 禁止钩子；`ConversationSnapshot` 正在被清退）。`useProjection` 成为一个框架席位，在 web-react（唯一的钩子铸造点）铸造，经与 `useSession` 相同的标准套件通道（`provideInfo` → SessionProvider → props）送达：

```ts ignore-check
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 插件未挂载，或尚无任何基线/帧携带过该 key）。值仓只暴露按 key 的裸 `{subscribe, getSnapshot}` 面；其余交给带逐 key 缓存的 `bindSnapshotSelector`——引用稳定性成立，因为一个 key 的值引用只在帧或基线落地时才变化。写路径不变：变更回调留在 inject 共享面（回调出自 inject，活状态出自 `useProjection`）。

「钩子不得穿过 inject」的唯一既有违例——`DetailsInjected.useSelection`——随本变更一并收编：选中态是住在聊天 store 里的查看状态，因此 details 注册声明共享 store 句柄，组件改读 `props.useStore(s => s.selection)`；`useSelection` 退出 inject 契约。

### 日志中的命令生命周期

两个仅日志（非 surface、模型不可见）事件，镜像 `tool/call`/`tool/result` 的配对：

```ts ignore-check
'command/run':  { commandId: string; name: string; args: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```

host 侧命令执行器（`packages/ui/commands`）在调用处理器前追加 `command/run`，在结算时追加 `command/done`——在接收 agent 的会话上直接独立追加，与[合成轮次移除](../../implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md)之后所有插件自有 log-only 事件同一形状：没有轮次包裹它们（轮次只描述模型循环执行），持久化在常规检查点排空它们，run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——`name` 与 `args` 就是解析器自己的切分（`parseCommand` 的 name 与 rawInput），因此消费方（折叠自己命令记录的投影单元、富命令卡片）永远无需重新解析行文本。`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据，不是呈现（版式如何编排仍由客户端在渲染时计算，满足「呈现永不入日志」这条红线）。想让模型知道结果的领域继续做它们今天在做的事（plan 的旁白、goal 的注入）——那是领域自己的决定，保持不变。

由于已提交事件会在 mux 流上广播，刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`：该行是否匹配命中，以及命中时新铸的配对 id，发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道（`runDetached` → `noticeFor`）就此下线。

客户端 flow 构建器新增一个通用命令节点（run/done 按 `commandId` 配对；跨窗口截断时与工具配对同样软降级）。渲染走一个新的 keyed slot `'conversation.chat.commandview'`，key = 命令名，**兜底 = 通用命令卡片**（零注册即可用——从前的通知文本现在持久地渲染在 flow 里）。领域要升级展示，只需注册一个行组件，取材于 `command/run` 的结构化字段与自己的投影值（`useProjection`）——与 toolview 解散之后的工具行同一形状。

## 交付计划

基础设施先行；三个在途 PR（Pull Request）原样不动，待基座落地后重新对接（它们的迁移映射即指南）：

1. **host 基座**：`dsh-session-projection`（单元契约、主动驱动、水位线缓存）+ api-proxy 的 projections 块 + `session/projection` 推送帧。零领域注册也可合入（此时块与帧直接缺席）。
2. **客户端基座**：通用值仓 + `useProjection` 席位；下线按领域的 cell 机制，并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1（在此之前 fixture（测试前置数据）喂合成帧）。
3. **命令通道**：两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
4. **领域重新对接**（在 1+2 之后）：先 todo（单元进 `tool-todo`，删掉搭载字段），再 plan（双事件单元、RPC 下线、开关改发 `/plan`），最后 goal（`goal/change` 单元，删掉 `goals.get`，把六个 `Session` 方法移入领域插件的 inject）。
5. **持久投影缓存**（后续阶段，待 domain-KV 存储 seam 就绪后）：`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入，以及持久化侧供冷尾部回放用的按 seq 起读原语。

## 备选方案

**专设一个 `session.projections` RPC**——不予采纳：基线刷新时刻与尾页拉取精确重合，单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq，以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。

**不透明的 `get(agent)` 提供方契约**——曾是第一稿，后被否决：计算模型藏在领域内部时，框架永远无法为状态做检查点、无法服务冷会话（没有 agent、没有已加载的日志——`get` 无处可跑）、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架，领域只留纯数学；有 host 侧行为需求的领域，其服务订阅照旧自持，与投影单元互不牵连。

**为 plan 待定意图专设的仅实时叠加钩子（`live?(agent, base)`）**——不予采纳：它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后，`command/run` 上了账，待定态成为纯回放量，投影契约保持恰好三个纯函数。

**把 seam 命名为 `registerFold`**——已被单元契约取代：注册对象如今确实是一个折叠，但本仓库里 `fold*` 专指纯 `(events) => state` 辅助函数，而该 seam 注册的是带 key、带 schema、带版本的单元。投影仍是事件溯源中指称读模型角色的术语，#587 的 Note 标题与 #497 的评论也都已在使用它。

**客户端侧折叠（带 `fromEvent` 的按领域投影 cell）**——曾是第二稿，后被否决：一旦 plan 的单元要折叠两种事件，客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值（标题帧先例的泛化）保住唯一计算地点，并把客户端简化为一个由 seq 把守的通用值仓；领域零客户端代码。

**对日志尾部的有界反向扫描（absorber 声明）**——暂不采纳：今天没有任何东西支持它，它只服务于「每个事件都携带完整折叠状态」的领域，而持久投影缓存以统一方式覆盖同一冷读需求（缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方）。只有当出现检查点机制服务不了的真实冷读路径时才重议。

**`invalidate` 式 cell（标脏，遇领域事件就重取）**——不予采纳：它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins；goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。

**把注册表挂到 `ctx.apiProxy` 名下**——不予采纳：会话投影并非 web 专属（TUI、ACP（Agent Client Protocol）、headless 都是未来消费方），且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。

**独立的客户端 `SessionProjectionViews` 类型表**——不予采纳：一张 `SessionProjectionMap` 端到端贯通正是协议直通纪律（不设第二套 DTO 词汇）；值就是 JSON 载荷，渲染归 slot 管。

**用事件广播收集、替代注册表遍历**——不予采纳：异步监听器给不出那个单一的同步切面，而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照；注册表才是本仓库承接贡献的通行形状（`ctx.tools`、提示词片段、slot）。

**专设 `plan/select` 选择事件（用结构化领域事件替代折叠命令记录）**——不予采纳，改用命令通道：`command/run` 的结构化 `{name, args}` 已经记录了选择，`/plan` 的语法与其折叠逻辑同住一个插件（领域内耦合，非跨领域），还少一种事件类型。处理器必须在任何可能失败的路径之前调用 `set()`，使已入日志的请求与运行面不可能分叉——这是领域内部的顺序约束，文档写在处理器处。

**保留 `setPlanMode` 专用 RPC**——不予采纳：plan 选择就是一条普通的用户命令；命令通道给它持久记录、flow 渲染、多标签页可见性与准入语义，不需要专设协议方法。Web UI 的交互组件（一个开关）在内部拼出命令行即可。

**让变更 RPC 的响应喂 cell 状态**——不予采纳：已提交的 mux 事件即刻到达，携带同一个全量值外加 seq；「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。

## 验收标准

- 领域插件把按会话的日志派生状态送达 React，只需写：全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码，不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
- 历史尾页携带 `projections`，其 `asOfSeq` 等于窗口尾部 seq；loadOlder 页永不携带；未装注册表的部署照常返回不带该块的历史，客户端把所有 key 视为缺席。
- 陈旧的基线不能覆盖更新的 `session/projection` 帧，重放的帧也不能让值仓倒退（两条路径都做 seq 高者胜测试）。
- 在一个标签页执行的斜杠命令，刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点；未注册的命令渲染通用卡片；命令结果的 composer 通知路径彻底移除。
- `useProjection` 经标准 props 套件抵达组件；没有任何钩子穿过 inject 契约（包括 `useSelection`）。
- 会话标题搭乘这对通用机制（基线块 + 投影帧）；专设的 `session/title` 帧与客户端标题快照表彻底移除。

## 风险

- **全量值规则是承重结构**：未来某个领域若只记裸增量，就无法凭其最新事件服务消费方，还会让自己的单元复杂化。缓解：该规则写明在本 Note 与投影包的 README 里；单元契约让完整状态在每次转移处都是显式的。
- **单元的同步纪律**：`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律，invariant 配套在可行范围内断言同步性；其余由评审把关。
- **注册表的实时增删不做推送**：会话中途加载或卸载领域插件会改变键集，但不会触发任何会话事件、也不会推任何帧；开着的客户端持有陈旧的 key 直到下次尾页拉取（重连、缺口修补、打开）。接受为仅开发期（HMR）的陈旧时窗——日后可以在变更流上加一个注册表变更推送，契约不受影响。
- **忙碌会话上的主动驱动开销**：每个已提交事件都要过每个已注册单元的 `apply`。按构造，单元的逐事件开销很低（全量值规则），不匹配的事件返回同一引用，且已注册领域的数量很小；若真出现热点路径，可以加按单元的事件类型预过滤，契约不变。
- **投影载荷膨胀**：每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值（一张 todo 清单、一份 goal 快照）；将来若某领域的值很大，可以在请求上加逐 key 的 opt-out 或惰性 key，模型本身不用改。
- **命令日志体量**：每条斜杠命令两个仅日志事件；上限由人敲命令的频率决定，相对分片体量可忽略不计。
- **重新对接的返工**：三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价；设计台账中的迁移映射一节逐一列出每个 PR 的保留/删除清单。
