Session projections and command lifecycle logging
会话投影与命令生命周期日志记录
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 `Conv
English
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
Sessionclass and project their values throughConversationSnapshot. 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
todosfield on the history tail page — computed bybackscanTodosinside api-proxy, business folding living in the carrier; plan adds a dedicatedsession.planModeunary; goal addsgoals.get. Same problem, three wire shapes. - Command results are unrecoverable.
/goal,/plan, and every other slash command return their outcome only in thecommand.executeRPC 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 commitsgoal/changemetadata, plan commitsplan/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 bare 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). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one.
Host projection registry (dsh-session-projection, new package)
A light Service Definition package: merge-extensible host-state and client-view type maps, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other.
What a domain registers is a state-driven computation unit — a pure fold plus declarations and an optional client view — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the computation. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
export interface SessionProjectionStateMap {} // host fold states
export interface SessionProjectionMap {} // client-visible whole values
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
key: K
stateSchema: ZodType<S>
persist?: boolean // host-only units opt in; client-visible units always persist
/** 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
/** Client view; omitted for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
viewSchema: ZodType<SessionProjectionMap[K]>
view(state: S): SessionProjectionMap[K]
} : never
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
stateVersion: number
}
declare module 'cordis' {
interface Context { sessionProjections: SessionProjectionRegistry }
}
SessionProjectionStateMaptypes host fold states;SessionProjectionMapremains the one client DTO table shared by the wire block and React hook viaimport type. A unit may remain host-only by omittingwire. How a client value is rendered is the slot system's business, never the projection layer's. The state/view split is specified by the implemented state and client-view note.- The host is the only place a projection is computed. The framework drives every registered unit forward eagerly: each committed session event passes through
apply; a unit uninterested in an event returns the same state reference, and an unchanged reference (Object.is) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code. - State is always computed, never logged. The log holds events only; the unit's state lives in the framework's per-session watermark cache (
{state, observedSeq}per unit) and, in a later phase, in a persisted projection cache on the domain-KV storage seam: rows of(sessionId, key, ver, seq, val)(ver= the unit'sstateVersion,seq= the watermark,val= the state JSON). A row is never wrong, only possibly stale — itsseqsays exactly how stale. The one read recipe, cold and live alike: take the cached state (orinit()), forward-apply only the events past its watermark,viewthe result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points —turn/endand detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value. - A domain's input event set is its own choice: todos folds
todo/writealone; plan foldsplan/modeplus its own/plancommand/runrecords (see the plan section); goal foldsgoal/changemetadata; session title folds its title events (retiring the bespokesession/titleframe and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs). - 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).
Shipped consumer: the subagent identity unit
The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: subagent list identity via the projection unit registers a subagent unit — the durable mode/label identity folded last-wins from subagent/descriptor — and SubagentRuntime.listChildren reads it through snapshot() for a live child (the watermark cache, zero log reads) and restore({}, events, 0) over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision.
Wire: projections block on the history tail page
// 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, synchronously walks the registry — no await anywhere, so every key's value and asOfSeq form one consistent cut. asOfSeq is the last event's seq (session.seq - 1; -1 for an empty log, the same vocabulary as session/subscribed.lastSeq), so a push frame carrying the first post-baseline change always compares strictly greater. 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 and setPlanMode (both sides — plan selection goes through the standard command channel, see the plan section), 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 unit, in tool-todo).
Push frame and the client value store (domains write zero client code)
Because the host is the only computation site, finished values reach clients over one new mux frame:
// MuxFrame union + schema branch:
{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
The framework emits it whenever a unit's state reference changes (Object.is gate above); seq is the unit's watermark at emission. This is live push state, never logged — the same posture as the tool-view view slot: replay recomputes on the host.
The client object layer keeps one generic value store per session: key → { value, seq }, seeded by the tail page's projections block and updated by the frame, under the single rule higher seq wins. Replayed baselines cannot roll a newer frame back; a lost frame costs staleness until the next frame or baseline, never wrongness. No fromEvent, no per-domain cell registration, no client-side domain folding — a domain ships projection support with zero client code (the SessionProjectionMap merge serves both sides through the /types outlet). The bespoke session/title frame and the manager's title-snapshot map retire into this generic pair. All the per-domain fences (#587's three layers, #527's write revision) dissolve into the one seq rule.
Plan through the standard command channel (worked example)
Plan mode demonstrates the full pattern — trigger path, run plane, and replay plane, cleanly separated:
- Trigger path: the web plan toggle sends
/plan//plan offthroughcommand.executelike any other command; the dedicatedsetPlanMode/planModeRPCs are retired. The user's request is durably recorded as that command'scommand/run { name: 'plan', args: 'off' | '' }— structured fields, no line parsing. - Run plane (unchanged): the plan-mode service keeps its in-memory pending intent and flushes
plan/modeat the next turn boundary. On cold start the service rebuilds its intent queue from the replay plane ("empty run state means the replay state"). - Replay plane: plan's projection unit folds two event types — its own
command/runrecords setwanted;plan/modesetsactiveand clearswanted;viewderives{ active, pending: wanted !== null && wanted !== active }. Pending is thereby a pure replay quantity: host restarts recover it, other tabs fold the same events (cross-tab pending for free), and a cold read answering{ active: false, pending: true }is accurate ("an unfulfilled selection awaits resume").
A domain's input event set is its own choice — that is the general rule this example instantiates. Whether "the user asked for X" appears in a projection (plan folds its command records) or only in the flow (the command node renders anyway) is per-domain semantics, never a framework concern.
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 ui-renderer (the one hook constructor), delivered through the same standard-kit channel as useSession (provideInfo → SessionProvider → props):
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, or no baseline/frame has carried the key). The value store exposes bare per-key {subscribe, getSnapshot} faces; bindSnapshotSelector with per-key caching does the rest — reference stability holds because a key's value reference changes only when a frame or baseline lands. 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:
'command/run': { commandId: string; name: string; args?: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
The host command executor (packages/interaction/commands) appends command/run before invoking the handler and command/done at settlement — direct standalone appends on the receiving agent's session, in the same shape as every other plugin-owned log-only event after the synthetic-turn removal: no turn wraps them (turns describe model-loop executions only), persistence drains them at ordinary checkpoints, and the commands package's own invariant companion enforces the run/done pairing. The payload is structured — name and, by default, args are the parser's own split (parseCommand's name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. A definition sets recordInput: false when its authoritative domain event owns the payload; command/run then omits args rather than duplicating it. 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 admission — { matched, commandId? }: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. 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's structured fields and its own projection value (useProjection) — 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):
- Host base:
dsh-session-projection(unit contract, eager drive, watermark cache) + api-proxy projections block + thesession/projectionpush frame. Mergeable with zero domains registered (block and frames simply absent). - Client base: the generic value store +
useProjectionseat; retire the per-domain cell machinery and, with title's unit registered, thesession/titleframe and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile). - Command channel: the two events, executor logging, generic node + keyed slot, notice retirement,
{matched, commandId?}admission. Parallel with 1. - Domain re-targets (after 1+2): todo (unit in
tool-todo, drop the rider field), then plan (two-event unit, RPCs retired, toggle →/plan), then goal (goal/changeunit, dropgoals.get, move the sixSessionmethods into the domain plugin's inject). - Persisted projection cache (later phase, after the domain-KV storage seam): the
(sessionId, key, ver, seq, val)rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
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.
An opaque get(agent) provider contract — rejected: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — get has nothing to run against), or resume from a mid-log position. Registering the (init, apply, view) unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
A live-only overlay hook (live?(agent, base)) for plan's pending intent — rejected: it existed solely because the user's plan selection was not in the log. Routing the selection through the standard command channel puts command/run on the account, pending becomes a pure replay quantity, and the projection remains a pure fold with an optional client view.
Naming the registration API registerFold — superseded by the unit contract: the registered object now genuinely is a fold, but fold* in this repo names pure (events) => state helper functions while this registry accepts a keyed, schema'd, versioned unit. Projection remains the event-sourcing term for the read-model role, and both #587's note title and #497's comments already use it.
Client-side folding (per-domain projection cells with a fromEvent) — rejected: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code.
Bounded reverse scan over the log tail (absorber declarations) — rejected for now: nothing supports it today, it only serves domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row + forward tail replay — the same recipe as the client's baseline + catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve.
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 second client DTO table — rejected: SessionProjectionMap remains the single client vocabulary shared by wire and UI. SessionProjectionStateMap is not another client view table; it types host fold state so internal state may differ from the value sent to clients.
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).
A dedicated plan/select selection event (structured domain event instead of folding command records) — rejected in favor of the command channel: command/run's structured {name, args} already records the selection, the /plan grammar and its fold live in the same plugin (domain-internal coupling, not cross-domain), and one less event type. The handler must call set() before any failable path so the logged request and the run plane cannot diverge — a domain-internal ordering constraint, documented at the handler.
Keeping setPlanMode as a dedicated RPC — rejected: plan selection is a user command like any other; the command channel gives it durable recording, flow rendering, multi-tab visibility, and admission semantics without a bespoke wire method. Web UI affordances (a toggle) compose the command line internally.
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 unit
register, itsSessionProjectionMapmerge, and inject callbacks — zero client-side code, no edits to the clientSessionclass,ConversationSnapshot, api-proxy, or the wire schema files. - The history tail page carries
projectionswithasOfSeqequal 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. - A stale baseline cannot overwrite a newer
session/projectionframe, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths). - 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.
useProjectionreaches components through the standard props kit; no hook crosses an inject contract (includinguseSelection).- Session titles ride the generic pair (baseline block + projection frame); the bespoke
session/titleframe and the client title-snapshot map are gone.
Risks
- Whole-value rule is load-bearing: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition.
- Synchronous unit discipline:
init/apply/viewthat await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest. - Live registry churn is not pushed: loading or unloading a domain plugin mid-session changes the key set, but no session event fires and no frame is pushed; open clients hold the stale key until the next tail pull (reconnect, gap repair, open). Accepted as a dev-only (HMR) staleness window — a registry-change push can be added to the change feed later without contract impact.
- Eager drive costs on busy sessions: every committed event passes every registered unit's
apply. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change. - 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.
中文
问题
三个在途的 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.executeRPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下:刷新、另开标签页、恢复或 fork 都会丢掉「该命令曾经运行过」的记录。领域状态变更是持久的(goal 提交goal/change元数据,plan 提交plan/mode),但命令调用本身及其结论不是。
底层缺口是架构性的:客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态;host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。
提案
先立四件基础设施,之后各领域都退化为纯贡献方。
全量值事件规则
携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。三个领域现状已然合规:todo/write 是整表快照,plan/mode 是一个完整布尔值,goal/change 元数据是完整的 GoalSnapshot(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈:漏掉的更新会被下一次更新纠正。
host 侧投影注册表(dsh-session-projection,新包)
一个轻量的 Service Definition 包:merge-extensible 的 host 状态与客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。
领域注册的是一个状态驱动计算单元(state-driven computation unit)——纯折叠、若干声明及可选客户端视图——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责计算。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本约定中没有任何特殊地位。
export interface SessionProjectionStateMap {} // host fold states
export interface SessionProjectionMap {} // client-visible whole values
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
key: K
stateSchema: ZodType<S>
persist?: boolean // host-only units opt in; client-visible units always persist
/** 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
/** Client view; omitted for host-only units. */
wire?: K extends keyof SessionProjectionMap ? {
viewSchema: ZodType<SessionProjectionMap[K]>
view(state: S): SessionProjectionMap[K]
} : never
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
stateVersion: number
}
declare module 'cordis' {
interface Context { sessionProjections: SessionProjectionRegistry }
}
SessionProjectionStateMap描述 host 折叠状态;SessionProjectionMap继续作为协议块和 React 钩子经import type共享的唯一客户端 DTO 表。单元省略wire即保持 host-only。客户端值如何渲染是 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外加它自己的/plancommand/run记录(见 plan 一节);goal 折叠goal/change元数据;会话标题折叠其标题事件(顺带下线专设的session/title帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在
ctx.inject(['sessionProjections'], …)下注册,因此不带注册表的 headless 组装完全不受影响。 - 该包拥有
./invariant(每个被服务的 key 都有一条存活的注册)。
已交付的消费方:subagent 身份单元
注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:subagent 列表经投影单元读取身份注册了 subagent 单元——从 subagent/descriptor 按 last-wins 折叠出的持久化 mode/label 身份——SubagentRuntime.listChildren 对 live child 经 snapshot() 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 restore({}, events, 0) 读取。注册表约定不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。
协议层:历史尾页上的 projections 块
// 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 帧送达客户端:
// 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/planModeRPC 下线。用户的请求被持久记录为该命令的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 成为一个框架席位,在 ui-renderer(唯一的钩子铸造点)铸造,经与 useSession 相同的标准套件通道(provideInfo → SessionProvider → props)送达:
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 的配对:
'command/run': { commandId: string; name: string; args?: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
host 侧命令执行器(packages/interaction/commands)在调用处理器前追加 command/run,在结算时追加 command/done——在接收 agent(智能体)的会话上直接独立追加,与合成轮次移除之后所有插件自有 log-only 事件同一形状:没有轮次包裹它们(轮次只描述模型循环执行),持久化在常规检查点排空它们,run/done 配对由 commands 包自己的 invariant 伴生插件把守。载荷是结构化的——name 以及默认携带的 args 来自解析器自己的切分(parseCommand 的 name 与 rawInput),因此消费方(折叠自己命令记录的投影单元、富命令卡片)永远无需重新解析行文本。当载荷由权威领域事件持有时,命令定义会设置 recordInput: false;此时 command/run 省略 args,而不是重复该载荷。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)原样不动,待基座落地后重新对接(它们的迁移映射即指南):
- host 基座:
dsh-session-projection(单元约定、主动驱动、水位线缓存)+ api-proxy 的 projections 块 +session/projection推送帧。零领域注册也可合入(此时块与帧直接缺席)。 - 客户端基座:通用值仓 +
useProjection席位;下线按领域的 cell 机制,并在标题单元注册后一并下线session/title帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。 - 命令通道:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、
{matched, commandId?}准入。与 1 并行。 - 领域重新对接(在 1+2 之后):先 todo(单元进
tool-todo,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发/plan),最后 goal(goal/change单元,删掉goals.get,把六个Session方法移入领域插件的 inject)。 - 持久投影缓存(后续阶段,待 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 上了账,待定态成为纯回放量,投影继续由纯折叠与可选客户端视图构成。
把注册 API 命名为 registerFold——已被单元约定取代:注册对象如今确实是一个折叠,但本仓库里 fold* 专指纯 (events) => state 辅助函数,而该注册表接收的是带 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 导入边。
第二张客户端 DTO 类型表——不予采纳:SessionProjectionMap 仍是协议与 UI 共享的唯一客户端词汇。SessionProjectionStateMap 不是另一张客户端视图表;它描述 host 折叠状态,使内部状态可以不同于发往客户端的值。
用事件广播收集、替代注册表遍历——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 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、自己那份SessionProjectionMapmerge、以及 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 要变基到挪动后的地基上。这是基础设施先行的既定代价。