DSH / Atlas
2026-08-18implementedarchitecture

Session history, control state, and Remote event transport

会话历史、控制状态与 Remote 事件传输

The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay. These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists nee

English

Problem

The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay.

These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation.

Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects.

Commands such as prompt, create, fork, and model selection do need to create or resume an Agent according to their own semantics. Activation authority must belong to each Remote method, not be decided implicitly by the carrier, parameter types, or a shared lookup.

The legacy API Proxy all-Session mux, HostFrame, and Workspace notifications encode domain data, baselines, errors, and connection lifecycle in one handwritten protocol. Each additional state duplicates frame declarations, a Client bridge, reconnect handling, and cleanup logic, while API Proxy cannot return to owning only business methods that have not yet migrated.

Host-to-Client Cordis events also have two invocation modes. Ordinary notifications only need broadcast delivery; Agent-scoped waterfalls such as Approval and Question must let a Client claim, delegate through next(), return a result, or reject while preserving one Host invocation identity across multiple Clients, disconnects, and cancellation.

These requirements need one general transport lifecycle without making Gateway understand Session, Workspace, Approval, or Question business data.

Decision

API Gateway owns Remote transport, stream lifecycles, and Remote Event coordination. Session Controller and Workspace Controller own their Host APIs, wire types, and Client domain adapters. Client Runtime only composes and consumes these objects; it does not implement another carrier state machine.

Current ownership is:

[client/connection]
|-- Host description
|-- Connection generation
`-- unary RPC transport

[api/gateway/client]
|-- RemoteStream
|-- RemoteSnapshotStream
|-- RemoteJournalStream
`-- ctx.remote.$on + $events pump

[api/session-controller]
|-- ctx.remote.session unary commands
|-- session.control snapshot stream
|-- session.page + session.follow journal
`-- Session Client adapters

[api/workspace-controller]
|-- ctx.remote.workspace unary commands
|-- workspace.follow snapshot stream
`-- Workspace Client model and adapter

[api/remotes]
`-- application Remote Event allowlist and Host Cordis source

[client/runtime]
`-- compose Session and Workspace domain state for consumers

API Proxy owns neither the Session or Workspace Remote namespace nor the Host downlink event carrier. /api/events.host, HostFrame, stream/error, ServerRequest, and their WebSocket/SSE branches do not participate in this data path.

Connection generation and physical connections

The browser's Client Remote plugin starts RemoteStreamMuxClient idempotently on activation and connects to /api/remote.mux immediately. The physical WebSocket remains resident even when there is no business logical stream, but the mux performs no independent retry scheduling.

The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured websocketHeartbeatIntervalMs interval (two seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. Before each Ping, the Host marks the socket as awaiting Pong and terminates it at the next interval if no Pong arrived.

After an initial connection failure or the loss of a connected socket, open logical streams end their current physical generation with RemoteStreamCarrierError. ConnectionController owns the continuous exponential retry schedule with a capped delay; each attempt asks the mux to replace any candidate or active socket exactly once before reopening $events. A user-requested reconnect resets the attempt sequence and bypasses the delay through the same path (decision).

The browser's network-status events are inputs to the same Controller. offline withdraws the Connection generation and suspends automatic retries; the next online transition restarts the base backoff. These events never establish connectivity: only a fresh $events ready frame publishes a Connection generation.

In-process connection.rpc.open uses the same logical endpoint semantics while bypassing the browser WebSocket mux.

The Gateway-internal $events logical stream is the sole generation source for ConnectionHandle. It does not depend on whether any business $on subscription exists, so connection health does not vary with the number of UI listeners.

The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends { type: 'ready', clientId, host: { home } }; this frame proves that the current generation can receive increments and carries the stable Host path-display fact.

ConnectionController publishes connected only after $events readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready.

Unexpected normal completion of $events, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes $events under its bounded backoff unless the browser is offline or a user requests an immediate retry.

Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state.

Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops retry delays, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.

General Remote stream model

Gateway Client provides three React-independent, single-consumer lifecycle objects:

RemoteStream<Item>
|-- RemoteSnapshotStream<Snapshot, Delta>
`-- RemoteJournalStream<Page, Entry, Cursor>

Domain Controllers use them through composition or thin adapters; Session and Workspace do not inherit a common Controller base class that knows domain frames.

RemoteStream

ctx.remote.$stream(options) returns a RemoteStream<Item> responsible for reopening, cancellation, and disposal of one logical stream across physical generations.

Each item carries a monotonic generation, that generation's AbortSignal, and accept(). A domain consumer calls accept() only after validating the opening cursor or baseline.

Only RemoteStreamCarrierError permits retry. When the Host remains available, one independent reopen is allowed; otherwise the stream waits for a new Connection generation. Business errors, protocol errors, and opening failures terminate immediately.

restart() replaces only the current physical generation and preserves the logical stream. dispose() permanently ends the logical stream, pending retry, and iterator, then waits for quiescence.

RemoteStream does not understand baselines, deltas, pages, cursors, sequence numbers, or any domain frame.

RemoteSnapshotStream

RemoteSnapshotStream<Snapshot, Delta> requires each generation to start with exactly one complete snapshot, followed only by deltas.

An update before the snapshot or a second snapshot in the same generation is a terminal protocol error.

The generation is accepted only after its snapshot has been applied successfully. The previously published state remains readable while the carrier reconnects, and the new generation's snapshot replaces the old mirror atomically.

The domain adapter supplies frame discrimination, snapshot replacement, a delta reducer, carrier state, and a terminal failure sink. The general layer parses no Session or Workspace fields.

Session control and Workspace state each use an independent RemoteSnapshotStream.

RemoteJournalStream

RemoteJournalStream<Page, Entry, Cursor> combines one live follow with a page method in the same namespace. It applies to an append-only journal with a stable order, paginated history, and a live tail.

Initial opening establishes follow and obtains its opening cursor before reading the initial page. Live entries produced while the page request is pending already enter the follow queue, closing the race between reading history and subscribing afterward.

The general layer removes overlap between the page and queued entries by cursor, verifies continuity, and publishes one complete window after the page covers the opening cursor.

Contiguous live entries publish append; older history pages publish prepend. Reconnect, cursor jumps, or continuity that cannot be proven trigger a tail-page repair.

The old window remains readable during repair. The page and live entries accumulated during that read form a continuous window and publish one replace, never exposing a half-repaired state.

If a page request is canceled with its physical carrier generation, the journal waits for the next generation's opening cursor and rereads the page at that cursor. This cancellation does not leak to the domain object as a terminal page failure.

RemoteJournalStream owns the opening cursor, resume cursor, pagination, reconnect catch-up, overlap removal, and gap repair. A domain Session object does not copy these state machines.

Session Controller

packages/api/session-controller provides Host ctx.sessionController and the generated ctx.remote.session namespace.

It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through session/modelCatalog because it is not Session-specific.

The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner.

Other Host Remote namespaces reuse the same identity rules through ctx.sessionController.inspect() or resolveAgent(); they do not retain a second Session resolver.

Activation policy

Session Remote methods pass SessionId or SessionAddress; parameter types do not trigger a general Typert Session lookup.

Each method explicitly selects a cold inspection, live-only lookup, or resume-capable resolution:

OperationSource or result without a live AgentActivation rule
session.list, searchheaders and projection cache; a bounded small-log read can resolve uncertain blanknessNever resumes an Agent
session.page(address)attached Session or persistence logNever resumes an Agent
session.follow(address)one live or prepared observation carrying the opening page and projectionsPublishes the snapshot first, then promotes an ordinary cold Session once in the background
session.control()current attached Agents, pending registry, and process-local registriesBaseline and reconnect do not resume an Agent
session.attachment, fork source readauthorized durable Session dataA read does not resume an Agent
session.updateQueuelive Agent or ordinary persisted SessionResumes an ordinary cold Session before mutating its Inbox
session.cancelonly the current live AgentDoes not resume vanished state
models, selectModel, rename, promptcommand resolves the target SessionResumes only when the method explicitly permits it
create and fork targetnew Session/AgentThe user command supplies creation authority

Reading titles, lists, and projections does not require an Agent. An observation operation cannot inherit resume authority merely because another Remote endpoint uses Agent lookup.

SessionQuery.observeSession() chooses an attached Session or serves a cold one from the reader's own prepared cache, filled through a persistence read handle. The cache shares concurrent cold reads and pins an entry until every observation lease is released. An observation computes either all registered projections or none; callers may expose a subset, but no caller creates a partial projection state.

session.list never performs an unbounded cold-log scan. It uses cached projection hints when available and may fully observe only an individually stored artifact within the configured small-log byte limit to distinguish an abandoned blank Session. Missing or unreadable hints keep the row visible with unknown metadata.

model/selection is a required-on-read durable event because it changes the model route used by the next request. Its projection records both the last request selection and a later pending selection; prompt assembly consumes the pending value when the matching request/header is committed.

Session journal

session.page returns a history window clipped on message boundaries with contiguous internal sequence numbers. Every request must carry an explicit throughSeq; this value comes from the corresponding session.follow generation's opening cursor and fixes the read at the same log cut. A tail page without beforeSeq must end exactly at throughSeq, where -1 denotes an empty log. beforeSeq only selects an older page before that cut and cannot replace the synchronization cursor. maxMessages limits user/assistant message count without dropping chunks, tools, or state events between those messages.

The tail page also carries a projection baseline no later than throughSeq; older pages carry only historical entries. The Client merges pages and subsequent live control updates by projection watermark.

Ordinary Sessions and direct subagents use one SessionAddress protocol. A direct-subagent address carries parent Session, child Session, and mode; a cold Host read verifies durable ownership and descriptor rather than authorizing access from the child id alone.

session.follow installs session/event and session/created listeners before observing an attached or prepared Session.

The first follow response is a complete { type: 'snapshot', header, cursor, events, hasMore, projections } frame. Every reconnect sends another complete snapshot replacement; the protocol has no afterSeq. Events committed during observation remain buffered and are emitted after the snapshot in sequence order.

A cold ordinary Session can publish its prepared snapshot immediately. After that first frame, the Controller transfers a retained observation to one background promotion; follow does not wait for activation. Direct-subagent addresses never use this promotion path.

Client SessionEventStream extends RemoteJournalStream and supplies only session.follow, session.page, the Session sequence algorithm, and repair requests. The general layer validates and publishes the opening snapshot directly. It calls session.page({ throughSeq }) only for older history or when a later event reveals a sequence gap.

ctx.remote.session.follow(address, pageArgs) ----------------|
  snapshot(header, cursor, page, projections), event*        |[]> SessionEventStream
ctx.remote.session.page(address, throughSeq, pageArgs) -------|    |-- replace(window)
                                                                  |-- prepend(history)
                                                                  `-- append(live entry)

Each Client Session owns only one current events: SessionEventStream | undefined. The read-only SessionEventSource gives the materialized event window to Conversation consumers.

A Session's openGeneration only prevents an asynchronous result retired by resync, address replacement, or disposal from writing into current state. It does not participate in transport retry.

A terminal failure from the initial page, repair page, or follow enters the current Session's openError. A stale business epoch or stale stream cannot overwrite newer state.

Session live control

session.control() is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript.

Each generation emits a complete baseline first, followed by jobs and projection deltas. The baseline reads process-local registries and folded projection values without resuming cold Agents.

Jobs use complete replacement values and apply last-wins. Projection updates carry monotonically increasing revisions, while a new baseline replaces the complete projection map. Session and owner disposal clear stale mirrors.

The original approval/request and user-questions/request events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call next(), the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests.

The projection baseline and a tail page's log cut are produced independently. The Client always retains the value with the higher sequence number. Subscribing to live projection does not start an Agent merely to obtain a value.

Session added, removed, activity, running status, and Agent error without a turn position do not enter the stateful control stream; they are ctx.remote.$on notifications that are either repairable from a list baseline or need no replay.

Session-list updatedAt is max(header.createdAt, sessionListMetadata.lastPromptAt). Only a user-originated user/message updates lastPromptAt; it can be recovered from a cold projection and does not depend on whether a browser follows that Session.

Workspace Controller

packages/api/workspace-controller provides Host ctx.workspaceController and the generated ctx.remote.workspace namespace.

It owns create, rename, delete, insertBefore, insertSessionBefore, archiveSession, unarchiveSession, and follow. Workspace registry remains the durable source of truth; the Controller owns Remote commands, projection, and error mapping.

WorkspaceFeed synchronously observes storage domain/changed, and each follow generation emits a complete baseline before upsert, remove, order, and archived deltas.

A complete order frame is authoritative for Workspace ordering. It avoids having the Client infer display order from upsert arrival order and converges after a reconnect baseline.

createWorkspaceStateStream() assembles workspace.follow as a RemoteSnapshotStream. Client Runtime only starts and owns that stream.

ClientWorkspaceModel lives on Workspace Controller's Client face. It owns baseline/increment parsing, the materialized list, the archived set, command-result echo, and merge rules for races between unary and stream arrivals.

A successful unary command can update the local model immediately; a later stream commit still corrects state with the Host projection and complete order. Deleted Workspace ids are recorded so a delayed result cannot reinsert them.

ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream
                                      |-- replace(baseline)
                                      |-- upsert/remove(view)
                                      |-- replace(order)
                                      `-- replace(archived ids)

Workspace Remote methods, state feed, and Client data model do not pass through API Proxy or depend on host/workspace-* notifications.

Remote Event

Remote Event reuses owner packages' Cordis Events declarations. The original Host event is the sole business signature, and Client ctx.remote.$on(event, listener) derives its parameters, waterfall result, and next() from that declaration.

The allowlist in packages/api/remotes is the sole source of application selection. Each entry explicitly marks emit or waterfall; this mode determines Host listening, the legal Client key set, and the wire frame type together.

The system declares no RemoteInvocationMap, requires no second Client @Remote, and does not infer invocation mode by checking whether the final runtime argument is a function.

Remote Event downlink frames form an explicit discriminated union:

ready     { type, clientId }
emit      { type, event, args }
waterfall { type, event, eventId, agentId, request }
cancel    { type, eventId }

Both WebSocket JSON and in-process carrier entry points start from unknown and validate the discriminant plus exact fields. Dispatch after validation accepts only the typed union. TypeScript static types do not replace wire validation.

Ordinary emit arguments must be lossless JSON. The Client calls parallel() on a Cordis key private to each Remote instance, preserving registration order, calling-fiber ownership, and listener-error isolation.

The private key prevents Host events and same-named Client-local Cordis events from triggering one another. Client Remote maintains neither its own subscription registry nor a handwritten listener chain.

Returning waterfalls currently support Agent scope only. The event signature must contain one request with a direct agent field followed by a next() returning the same result type, and the whole event returns a Promise.

The Host projects only top-level agent and signal fields from the request: agent becomes top-level agentId in the frame, signal becomes the delivery lifetime, and all remaining fields must be lossless JSON as a whole.

The Client synchronously resolves or materializes an Agent Context from agentId, restores the current delivery signal into the request's direct signal field, and invokes Cordis waterfall() on the target Context's private key. Before the first successful Session-list baseline, the Session-backed adapter lets transport materialize a scope; after that baseline, the list lifecycle owns scope liveness.

The system does not scan arbitrarily deep objects, transmit path arrays or placeholders, deep-clone/restore Context and AbortSignal, or wait for a future Agent Context.

When no Client adapter is registered, its resolver returns no Context, or resolution throws, that Client immediately returns next. It does not subscribe to a registry, recheck races after resolution, or create a temporary Fiber for one delivery.

Gateway Host retains eventId, the Host continuation, and delivered Client generations for every unfinished waterfall. A new Client generation receives a replay of the same pending event.

Each generation's queue guarantees one delivery, so the Client stores no seen set. clientId + eventId binds a result to the current generation; a reply from an old connection cannot complete delivery on a new one.

When several Clients receive a waterfall, the first result or rejection completes the Host invocation and sends cancel to the other Clients. Gateway continues the original Cordis chain only after every delivered Client returns next.

Host caller-signal cancellation, Agent Context disposal, Client-generation completion, and losing-Client cancellation all terminate their corresponding waits.

The Client returns next, result, or rejection through the existing HTTP unary RPC $events/result; downlink events continue to share the Remote WebSocket mux, with no duplex WebSocket for responses.

Gateway only verifies that a waterfall return value has a lossless JSON representation; it does not interpret business fields. Semantics such as whether a Question answer belongs to an offered option remain owned by the requester or UI domain and are not revalidated by transport.

When UserQuestionService observes that the caller's AbortSignal was canceled during a request and the provider threw an ordinary error, it normalizes that failure to UserQuestionError with ASK_ABORTED while retaining the original error as cause. A domain error already supplied by the provider preserves its identity.

A failure of $events/result fails the current Connection generation. Host withdraws that Client's delivery with the generation, the pending event is replayed in the next generation, and Client maintains no second result-retry queue.

Ordinary $on notifications are not replayed after disconnect. State whose correctness depends on recovery must have a query, cursor, or opening baseline and cannot rely on eventual Remote Event delivery.

An event is not replayed when its Client listener registers after arrival. HMR has no dedicated redelivery semantics.

API Proxy's remaining boundary

Session Controller and Workspace Controller provide generated Remote namespaces directly; API Remotes and API Gateway provide Host-to-Client events directly.

Client Connection maintains only Host generation, description, and generic RPC. It does not parse domain frames.

Client Runtime only receives domain changes produced by Controller adapters. It recognizes no HostFrame, session/subscribed, session/event mux frame, or host/workspace-* frame.

API Proxy carries only independent business APIs it owns. Session, Workspace, Remote Event, and Connection generation do not depend on it.

Alternatives considered

Resume an Agent whenever any Session stream opens. Viewing history, reading a title, reconnecting a tab, or observing background state would gain execution side effects, and multiple browsers could trigger duplicate resumes. Cold logs and projections already have persistence sources.

Permit session.follow only for live Agents. The first transcript render would have to resume an Agent or reintroduce the race between unary history and live subscription. Following by identity before a cold read covers both history and future explicit activation.

Split Session transport and Session commands into two public packages. Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently.

Move jobs, projections, Workspace, and logs to ordinary $on. Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit $on.

Make every domain Controller inherit a page/follow/retry base class. Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics.

Declare a separate Client invocation map for Remote Event. A second map or Client @Remote would copy owner Cordis event signatures and create a drift point. Deriving $on listeners and results from the same Events declaration preserves equivalence by construction.

Project Agent scope through arbitrary object depth. Recursive Context and AbortSignal scans need path, placeholder, clone, and restore protocols and turn incidental object structure into a wire promise. Top-level agent and signal cover current waterfalls.

Wait for a Client Agent Context or adapter before dispatching. Registry waiters, post-resolution race checks, and temporary delivery Fibers add lifecycle to a Client that can synchronously resolve or materialize its target. Returning next when the resolver cannot provide a target immediately preserves Cordis waterfall semantics.

Use an independent physical WebSocket or duplex stream for Remote Event. Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink $events plus HTTP $events/result expresses request/response without a third connection.

Send application-level JSON heartbeat frames. This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics.

Retain API Proxy's Host mux. This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently.

Update Session list time from aggregate session/event. List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable lastPromptAt projection expresses the ordering fact directly.

Verification

Gateway mux tests pin connection without logical streams, idle residency, one physical attempt per request, configurable Ping/Pong without application messages, active-stream carrier failure, cancellation, and no reconnect after disposal.

Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure.

RemoteStream tests pin single consumption, retry reset after opening acceptance, generation-only restart(), no retry for terminal errors, and disposal quiescence.

RemoteSnapshotStream tests pin exactly one opening snapshot per generation, rejection of an update before a snapshot, rejection of duplicate snapshots, and reconnect replacement.

RemoteJournalStream tests pin snapshot-first opening, contiguous append, historical prepend, reconnect replacement, gap repair, and one atomic replacement.

Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection.

Session control tests pin baseline-first delivery, no cold-Session resume, jobs replacement, and the projection watermark.

Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry.

Workspace Host tests pin baseline-first delivery, upsert/remove, authoritative order, archived set, and follower disposal.

Workspace Client tests pin snapshot replacement, unary/stream races, no resurrection after delete, stable ordering, and terminal failure.

Remote Event type tests reject unselected events, non-void unscoped events, non-Agent-scoped waterfalls, and modes that disagree with signatures.

Remote Event Host tests pin listener-before-ready, payload validation, pending replay, first result across multiple Clients, all-next delegation, rejection, Host cancellation, Context release, and losing-Client cancellation.

Remote Event Client tests pin instance-private keys, Cordis registration order, Agent Context resolution, next, result, rejection, cancellation, rejection of stale-generation replies, and Connection-generation failure when $events/result fails. User Question tests pin normalization of in-progress signal cancellation and preservation of its cause.

Missing, duplicate, and withdrawn sources; non-ready first items; unknown discriminants; extra fields; and non-JSON values all fail loudly at their respective wire entries.

Static checks pin that API Proxy exports no Session/Workspace Host-frame carrier and Client Runtime contains no corresponding bridge.

Consequences

The browser can read a durable Session while its Agent is stopped. Opening an ordinary Session publishes the prepared snapshot before one background promotion begins; list, search, page, and other observation-only reads never activate it.

Durable logs repair a missing suffix by sequence number and page; Session control and Workspace state converge through opening snapshots; ordinary Remote Events promise no replay. Recovery semantics follow the data kind instead of imitating one another.

Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation.

Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code.

Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary.

The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller.

Remote waterfalls preserve first claim across multiple Clients, continuation of the Host chain after every Client calls next, reconnect replay of pending calls, and end-to-end cancellation. The current protocol supports only top-level Agent scope and lossless-JSON requests and results.

This decision extends the allowlist and single Cordis-signature design from Remote event delivery: ordinary notifications use emit, while Agent-scoped async waterfalls use the same ctx.remote.$on surface with explicit waterfall mode. It creates no second invocation map.

This decision takes over the Session, Workspace, and Host-event carriers retained by simple unary API Proxy migration while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by background job display.

中文

问题

浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。

这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;jobs、projection 值和 Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。

观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。

prompt、create、fork、模型选择等命令又确实需要按各自语义创建或恢复 Agent。激活权限必须属于具体 Remote 方法,而不能由 carrier、参数类型或共享 lookup 暗中决定。

旧 API Proxy 的全 Session mux、HostFrame 与 Workspace 通知把领域数据、baseline、错误和连接生命周期编码进同一手写协议。每增加一种状态都要复制帧定义、Client bridge、重连和清理逻辑,API Proxy 也无法退回只承接尚未迁移的业务方法。

Host 向 Client 的 Cordis 事件还有两种调用语义。普通通知只需要广播;Approval 与 Question 一类 Agent-scoped waterfall 必须允许 Client claim、调用 next() 委托、返回结果或拒绝,并在多 Client、断线和取消下保持一次 Host 调用的身份。

这些需求需要一个通用 transport 生命周期,但不能让 Gateway 理解 Session、Workspace、Approval 或 Question 的业务数据。

决定

API Gateway 拥有 Remote transport、stream 生命周期和 Remote Event 协调;Session Controller 与 Workspace Controller 拥有各自的 Host API、wire 类型和 Client 领域 adapter;Client Runtime 只装配并消费这些对象,不再实现另一套 carrier 状态机。

当前所有权如下:

[client/connection]
|-- Host description
|-- Connection generation
`-- unary RPC transport

[api/gateway/client]
|-- RemoteStream
|-- RemoteSnapshotStream
|-- RemoteJournalStream
`-- ctx.remote.$on + $events pump

[api/session-controller]
|-- ctx.remote.session unary commands
|-- session.control snapshot stream
|-- session.page + session.follow journal
`-- Session Client adapters

[api/workspace-controller]
|-- ctx.remote.workspace unary commands
|-- workspace.follow snapshot stream
`-- Workspace Client model and adapter

[api/remotes]
`-- application Remote Event allowlist and Host Cordis source

[client/runtime]
`-- compose Session and Workspace domain state for consumers

API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 下行事件 carrier。/api/events.host、HostFrame、stream/error、ServerRequest 及其 WebSocket/SSE 分支不参与这条数据链路。

Connection generation 与物理连接

浏览器的 Client Remote 插件激活时幂等启动 RemoteStreamMuxClient,并立即连接 /api/remote.mux。没有业务 logical stream 时物理 WebSocket 仍保持常驻,但 mux 不运行独立的 retry 调度。

Host 按配置的 websocketHeartbeatIntervalMs 间隔(默认 2 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。每次 Ping 前,Host 把 socket 标记为等待 Pong;若到下一间隔仍未收到 Pong,Host 会终止该 socket。

首次建连失败或已连接 socket 丢失后,已打开的 logical stream 会以 RemoteStreamCarrierError 结束当前物理 generation。ConnectionController 拥有持续且间隔封顶的指数 retry 调度;每次尝试都要求 mux 恰好一次替换候选或活动 socket,再重开 $events。用户要求的重连通过同一路径重置 attempt 序列并跳过等待(见决策)。

浏览器网络状态事件是同一 Controller 的输入。offline 会撤回 Connection generation 并暂停自动 retry;下一次 online 转换会从基础退避档重新开始。这些事件不会建立连接;只有新的 $events ready 帧才会发布 Connection generation。

进程内 connection.rpc.open 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。

Gateway 内部 $events logical stream 是 ConnectionHandle 唯一的 generation source。它不依赖是否已有业务 $on 订阅,因此连接健康状态不会随 UI listener 数量变化。

Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送 { type: 'ready', clientId, host: { home } };该 frame 证明当前 generation 已经能够接收增量,并携带稳定的 Host 路径显示信息。

ConnectionController 只有在 $events ready 后才发布 connected,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。

$events 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,随后按有界退避重新建立 $events;浏览器离线时暂停,用户要求立即重试时则跳过等待。

Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。

Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止重试等待,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。

通用 Remote stream 模型

Gateway Client 提供三个不依赖 React、只允许一个 consumer 的生命周期对象:

RemoteStream<Item>
|-- RemoteSnapshotStream<Snapshot, Delta>
`-- RemoteJournalStream<Page, Entry, Cursor>

领域 Controller 通过组合或薄 adapter 使用它们;Session 与 Workspace 不继承一个知道领域帧的共同 Controller 基类。

RemoteStream

ctx.remote.$stream(options) 返回 RemoteStream<Item>,负责一个 logical stream 跨物理 generation 的重开、取消和 dispose。

每个 item 携带单调 generation、该 generation 的 AbortSignal 与 accept()。领域 consumer 只有在验证 opening cursor 或 baseline 后才调用 accept()。

只有 RemoteStreamCarrierError 可触发重试。Host 仍可用时允许一次独立重开;否则等待新的 Connection generation。业务错误、协议错误和 opening 失败直接终止。

restart() 只淘汰当前物理 generation,保留 logical stream;dispose() 永久结束 logical stream、pending retry 与 iterator,并等待 quiescence。

RemoteStream 不理解 baseline、delta、page、cursor、seq 或任何领域 frame。

RemoteSnapshotStream

RemoteSnapshotStream<Snapshot, Delta> 要求每个 generation 恰好以一份完整 snapshot 开始,之后只能出现 delta。

update 早于 snapshot 或同 generation 出现第二份 snapshot 都是 terminal protocol error。

snapshot 成功应用后才接受该 generation。carrier 重连期间保留上一份已发布状态,新 generation 的 snapshot 一次性替换旧镜像。

领域 adapter 提供 frame 判别、snapshot replacement、delta reducer、carrier 状态和 terminal failure sink;通用层不解析 Session 或 Workspace 字段。

Session control 与 Workspace state 各使用一个独立的 RemoteSnapshotStream。

RemoteJournalStream

RemoteJournalStream<Page, Entry, Cursor> 组合一个 live follow 与同 namespace 的 page 方法,适用于有稳定顺序、可分页历史和 live tail 的 append-only journal。

首次打开先建立 follow 并取得 opening cursor,再读取 initial page。page 请求期间产生的 live entries 已进入 follow 队列,因此不会落在“先读历史、后订阅”的竞态窗口中。

通用层按 cursor 去除 page 与 queued entries 的重叠,验证连续性,并在 page 覆盖 opening cursor 后发布一份完整 window。

连续 live entry 发布 append,更早的历史页发布 prepend。重连、cursor 跳跃或无法证明连续性时触发 tail page repair。

repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼成连续窗口后只发布一次 replace,不会把半修复状态暴露给消费者。

若 page 请求随物理 carrier generation 一起取消,journal 等待下一 generation 的 opening cursor,再以新 cursor 重读 page;该取消不会作为 terminal page failure 泄漏给领域对象。

RemoteJournalStream 拥有 opening cursor、resume cursor、分页、重连 catch-up、重叠去重和 gap repair。领域 Session 对象不复制这些状态机。

Session Controller

packages/api/session-controller 提供 Host ctx.sessionController 与生成的 ctx.remote.session namespace。

它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 session/modelCatalog 公开,因为它不属于特定 Session。

包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。

其他 Host Remote namespace 通过 ctx.sessionController.inspect() 或 resolveAgent() 复用同一身份规则,不保留第二份 Session resolver。

激活策略

Session Remote 方法传递 SessionId 或 SessionAddress,不靠参数类型触发通用 Typert Session lookup。

每个方法显式选择冷检查、live-only 查找或允许 resume 的解析方式:

操作无 live Agent 时的数据来源或结果激活规则
session.list、searchheader 与投影缓存;可通过有界的小日志读取判断不确定的 blank 状态永不恢复 Agent
session.page(address)attached Session 或 persistence 日志永不恢复 Agent
session.follow(address)一份携带 opening page 与 projection 的 live 或 prepared observation先发布 snapshot,再在后台把普通冷 Session 提升一次
session.control()当前 attached Agent、pending registry 与进程内 registrybaseline 与重连不恢复 Agent
session.attachment、fork 源读取已授权的持久 Session 数据读取不恢复 Agent
session.updateQueuelive Agent 或普通持久 Session修改 Inbox 前恢复普通冷 Session
session.cancel仅命中当前 live Agent不为已消失状态恢复 Agent
models、selectModel、rename、prompt命令解析目标 Session仅按方法约定显式恢复
create 与 fork 目标新 Session/Agent用户命令提供创建授权

读取 title、列表和投影不要求 Agent。观察操作不能因为另一个 Remote endpoint 使用了 Agent lookup 而继承其恢复权限。

SessionQuery.observeSession() 选择 attached Session,或从读取方自己的 prepared cache——经由持久化读句柄填充——提供冷 Session。该 cache 共享并发冷读取,并在所有 observation lease 释放前固定同一条目。一次 observation 要么计算所有已注册 projection,要么完全不计算;调用方可以只公开其中一部分,但不会建立只计算部分 projection 的中间状态。

session.list 不会无界扫描冷日志。它优先使用缓存的 projection hint,仅在独立存储 artifact 不超过配置的小日志字节上限时,才可能完整观察日志以判断不确定的 blank 状态。hint 缺失或不可读时,列表仍保留该行,并把 metadata 视为未知。

model/selection 是 required-on-read 的持久 event,因为它改变下一次请求使用的 model route。对应 projection 同时记录最近一次 request selection 与之后的 pending selection;prompt assembly 在提交匹配的 request/header 时消费 pending value。

Session 日志

session.page 返回一段按消息边界裁剪、内部 seq 连续的历史窗口。每个请求必须显式携带 throughSeq;该值来自对应 session.follow generation 的 opening cursor,并把本次读取固定在同一个日志切点。无 beforeSeq 的 tail page 必须精确结束于 throughSeq,其中 -1 表示空日志;beforeSeq 只选择该切点之前的更早页面,不能替代同步 cursor。maxMessages 限制 user/assistant 消息数,不丢弃这些消息之间的 chunk、tool 或状态事件。

tail page 同时携带不晚于 throughSeq 的 projection baseline;旧页只携带历史 entries。Client 以 projection watermark 合并 page 与后续 live control 更新。

普通 Session 与 direct subagent 使用同一个 SessionAddress 协议。direct subagent 地址同时携带父 Session、子 Session 与 mode,Host 冷读时验证持久 ownership 和 descriptor,不能只凭 child id 越权读取。

session.follow 在观察 attached 或 prepared Session 前先安装 session/event 与 session/created listener。

首次 follow 返回完整的 { type: 'snapshot', header, cursor, events, hasMore, projections } frame。每次重连都发送另一份完整 snapshot replacement;协议不含 afterSeq。观察期间提交的 event 会保留在缓冲区,并在 snapshot 之后按 seq 发出。

普通冷 Session 可以立即发布 prepared snapshot。首帧之后,Controller 把 retained observation 交给一次后台 promotion;follow 不等待激活。Direct-subagent 地址不会进入该 promotion 路径。

Client 的 SessionEventStream 继承 RemoteJournalStream,只提供 session.follow、session.page、Session seq 算法与 repair request。通用层直接校验并发布 opening snapshot;仅在读取更早历史或后续 event 暴露 seq gap 时调用 session.page({ throughSeq })。

ctx.remote.session.follow(address, pageArgs) ----------------|
  snapshot(header, cursor, page, projections), event*        |[]> SessionEventStream
ctx.remote.session.page(address, throughSeq, pageArgs) -------|    |-- replace(window)
                                                                  |-- prepend(history)
                                                                  `-- append(live entry)

每个 Client Session 只持有一个当前 events: SessionEventStream | undefined。只读 SessionEventSource 把已物化 event window 交给 Conversation consumer。

Session 的 openGeneration 只阻止被 resync、地址替换或 dispose 淘汰的异步结果写回;它不参与 transport retry。

initial page、repair page 或 follow 的 terminal failure 进入当前 Session 的 openError。旧业务 epoch 或旧 stream 的失败不能覆盖新状态。

Session live control

session.control() 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。

每个 generation 先发完整 baseline,再发 jobs 与 projection 增量帧。baseline 读取进程内 registry 和已折叠的 projection 值,不恢复冷 Agent。

jobs 使用完整 replacement 值并按 last-wins 应用。Projection update 携带单调递增 revision,新 baseline 则替换完整 projection map。Session 与 owner disposal 会清理陈旧镜像。

原始 approval/request 与 user-questions/request 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 next(),原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。

projection baseline 与 tail page 的日志切点独立产生,Client 总是保留较高 seq 的值。订阅 live projection 不会为取得值而启动 Agent。

Session added、removed、activity、running status 与无 turn 位置的 Agent error 不进入 stateful control stream;它们是可由列表 baseline 修复或无需重放的 ctx.remote.$on 通知。

Session 列表的 updatedAt 取 max(header.createdAt, sessionListMetadata.lastPromptAt)。lastPromptAt 只由用户来源的 user/message 更新,可从冷 projection 恢复,不依赖浏览器是否正在跟随该 Session。

Workspace Controller

packages/api/workspace-controller 提供 Host ctx.workspaceController 与生成的 ctx.remote.workspace namespace。

它拥有 create、rename、delete、insertBefore、insertSessionBefore、archiveSession、unarchiveSession 与 follow。Workspace registry 仍是持久事实来源,Controller 负责 Remote 命令、投影和错误映射。

WorkspaceFeed 同步观察 storage domain/changed,并为每个 follow generation 先发送完整 baseline,再发送 upsert、remove、order 与 archived 增量。

完整 order frame 是 Workspace 排序的权威值。它避免 Client 根据 upsert 到达顺序猜测展示顺序,也能在重连 baseline 后收敛。

createWorkspaceStateStream() 把 workspace.follow 装配为 RemoteSnapshotStream。Client Runtime 只负责启动和持有该 stream。

ClientWorkspaceModel 位于 Workspace Controller 的 Client 面,拥有 baseline/increment 解析、已物化列表、归档集合、命令结果回显及 unary 与 stream 到达竞态的合并规则。

成功的 unary 命令可以立即更新本地模型;后到的 stream commit 仍以 Host projection 与完整 order 校正状态。已删除 Workspace 的 id 被记录,延迟结果不能把它重新插回列表。

ctx.remote.workspace.follow() -|[]> RemoteSnapshotStream
                                      |-- replace(baseline)
                                      |-- upsert/remove(view)
                                      |-- replace(order)
                                      `-- replace(archived ids)

Workspace Remote 方法、状态 feed 和 Client 数据模型均不经过 API Proxy,也不依赖 host/workspace-* 通知。

Remote Event

Remote Event 复用 owner 包的 Cordis Events 声明。Host 原事件是唯一业务签名,Client ctx.remote.$on(event, listener) 从同一声明推导参数、waterfall 结果与 next()。

packages/api/remotes 的 allowlist 是应用选择的唯一来源。每项显式标注 emit 或 waterfall,该 mode 同时决定 Host 监听方式、Client 合法键集和 wire frame 类型。

系统不声明 RemoteInvocationMap,不要求 Client 再写一份 @Remote,也不以最后一个运行时参数是否为函数来猜测调用模式。

Remote Event 下行帧是显式 discriminated union:

ready     { type, clientId }
emit      { type, event, args }
waterfall { type, event, eventId, agentId, request }
cancel    { type, eventId }

WebSocket JSON 与进程内 carrier 的入口都从 unknown 开始按 type 和精确字段验证;验证完成后的分发只接收 typed union。TypeScript 静态类型不替代 wire 校验。

普通 emit 参数必须是无损 JSON。Client 在每个 Remote 实例私有的 Cordis key 上调用 parallel(),保留注册顺序、调用方 fiber 所有权和 listener 错误隔离。

私有 key 防止 Host 事件与 Client 本地同名 Cordis 事件互相触发。Client Remote 不维护自己的 subscription registry 或手写 listener chain。

可返回的 waterfall 当前只支持 Agent scope。事件签名必须是一个含直接 agent 字段的 request,加一个返回同类型结果的 next(),整体返回 Promise。

Host 只投影 request 一级的 agent 与 signal:agent 变为 frame 的一级 agentId,signal 成为 delivery lifetime,其余字段必须整体为无损 JSON。

Client 用 agentId 同步解析或物化 Agent Context,把当前 delivery signal 放回 request 的直接 signal 字段,再在目标 Context 的私有 key 上调用 Cordis waterfall()。Session-backed adapter 在首个成功 Session 列表 baseline 到达前允许 transport 先物化 scope;baseline 到达后由列表生命周期接管 scope 存活判断。

系统不扫描任意深度对象,不传 path array 或 placeholder,不 deep clone/restore Context 和 AbortSignal,也不等待未来出现的 Agent Context。

Client adapter 未注册、resolver 未返回 Context 或解析抛错时,本 Client 立即返回 next。它不订阅 registry、不做 resolve 后竞态复查,也不为一次 delivery 创建临时 Fiber。

Gateway Host 为每个未完成 waterfall 保存 eventId、Host continuation 与已投递 Client generation。新 Client generation 会收到同一 pending event 的重放。

每个 generation 的队列保证一次投递,因此 Client 不保存 seen 集合。clientId + eventId 绑定结果与当前 generation,旧连接的回包不能完成新连接上的 delivery。

多 Client 同时接收 waterfall 时,第一个 result 或 rejection 完成 Host 调用,并向其余 Client 发送 cancel。只有所有已投递 Client 都返回 next 时,Gateway 才继续原 Cordis chain。

Host caller signal 取消、Agent Context 释放、Client generation 结束和 losing-client cancellation 都会终止对应的等待。

Client 通过现有 HTTP unary RPC $events/result 回送 next、result 或 rejection;下行事件仍复用 Remote WebSocket mux,不为应答建立 duplex WebSocket。

Gateway 只验证 waterfall 返回值能无损表示为 JSON,不解释业务字段。Question 回答的 option 归属等语义由请求方或 UI 领域承担,transport 不重复校验。

UserQuestionService 在请求期间观察到调用方 AbortSignal 已取消、且 provider 抛出普通错误时,将其归一为 UserQuestionError 的 ASK_ABORTED,并把原错误保留为 cause;provider 已给出的领域错误保持不变。

$events/result 失败会令当前 Connection generation 失败。Host 随 generation 撤销该 Client 的 delivery,pending event 在下一 generation 重放,Client 不维护第二套结果重试队列。

普通 $on 通知在断线后不重放。凡正确性依赖恢复的数据必须有 query、cursor 或 opening baseline,不能依赖 Remote Event 恰好送达。

Client listener 晚于事件到达才注册时不补送;HMR 也没有专用补投语义。

API Proxy 的剩余边界

Session Controller 与 Workspace Controller 直接提供生成 Remote namespace;API Remotes 与 API Gateway 直接提供 Host-to-Client 事件。

Client Connection 只维护 Host generation、description 与通用 RPC,不解析领域 frame。

Client Runtime 只接收 Controller adapter 产出的领域变更,不识别 HostFrame、session/subscribed、session/event mux frame 或 host/workspace-* frame。

API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace、Remote Event 或 Connection generation 的依赖。

备选方案

建立任意 Session stream 时自动恢复 Agent。 这会让查看历史、读取 title、重连标签页或观察后台状态产生执行副作用,也会让多个浏览器触发重复恢复;冷日志和投影已有 persistence 来源。

只允许 live Agent 使用 session.follow。 这会迫使 transcript 首屏恢复 Agent,或重新引入 unary history 与 live subscription 之间的竞态;按 identity 先 follow 再冷读能同时覆盖历史和未来的显式激活。

把 Session transport 与 Session commands 拆成两个公开包。 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。

把 jobs、projection、Workspace 与日志都改成普通 $on。 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 $on。

让每个领域 Controller 继承一个 page/follow/retry 基类。 Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。

给 Remote Event 新建一份 Client invocation 声明。 第二张 map 或 Client @Remote 会复制 owner Cordis 事件签名并形成漂移点;从同一 Events 声明推导 $on listener 和结果类型可以构造性地保持一致。

把 Agent scope 做成任意深度对象投影。 递归扫描 Context 与 AbortSignal 需要 path、placeholder、clone 和 restore 协议,并把偶然对象结构升级成 wire 约定;一级 agent 与 signal 足以覆盖当前 waterfall。

等待 Client Agent Context 或 adapter 后再分发。 registry waiter、竞态复查和临时 delivery Fiber 会为一个可同步解析或物化目标的 Client 增加额外生命周期;resolver 当下不能提供目标时立即 next 保持 Cordis waterfall 语义。

给 Remote Event 使用独立物理 WebSocket 或 duplex stream。 Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 $events 加上 HTTP $events/result 足以表达 request/response,不需要第三条连接。

发送应用层 JSON 心跳帧。 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。

继续保留 API Proxy 的 Host mux。 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。

从聚合 session/event 更新 Session 列表时间。 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 lastPromptAt 投影直接表达排序事实。

验证

Gateway mux 测试固定无 logical stream 时建连、空闲常驻、每次请求只做一次物理尝试、可配置且不产生应用消息的 Ping/Pong、活动 stream carrier failure、取消和 dispose 后不再重连。

Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。

RemoteStream 测试固定单 consumer、opening acceptance 后清零 retry、restart() 只替换 generation、terminal error 不重试和 dispose quiescence。

RemoteSnapshotStream 测试固定每 generation 恰好一份 opening snapshot、update-before-snapshot 拒绝、重复 snapshot 拒绝和重连 replacement。

RemoteJournalStream 测试固定 snapshot-first opening、连续 append、历史 prepend、重连 replacement、gap repair 与一次性 replacement。

Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。

Session control 测试固定 baseline-first、冷 Session 不恢复、jobs replacement 与 projection watermark。

Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。

Workspace Host 测试固定 baseline-first、upsert/remove、权威 order、archived set 和 follower disposal。

Workspace Client 测试固定 snapshot replacement、unary/stream 竞态、删除不复活、稳定排序和 terminal failure。

Remote Event 类型测试拒绝未选择事件、非 void 的 unscoped 事件、非 Agent-scoped waterfall 和签名不匹配的 mode。

Remote Event Host 测试固定 listener-before-ready、payload 校验、pending replay、多 Client first-result、all-next delegation、rejection、Host cancellation、Context release 和 losing-client cancel。

Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent Context 解析、next、result、rejection、cancel、旧 generation 回包拒绝和 $events/result 失败导致 generation 结束;User Question 测试固定进行中 signal 取消的错误归一化及 cause 保留。

缺失 source、重复 source、撤回 source、非 ready 首项、未知 discriminant、额外字段与非 JSON 值都在各自 wire 入口响亮失败。

静态检查固定 API Proxy 不再导出 Session/Workspace Host frame carrier,Client Runtime 不再包含对应 bridge。

后果

浏览器可以在 Agent 停止时读取持久 Session。打开普通 Session 时先发布 prepared snapshot,再开始一次后台 promotion;list、search、page 及其他只读 observation 不会激活 Agent。

持久日志用 seq 与 page 修复缺失后缀;Session control 和 Workspace state 用 opening snapshot 收敛;普通 Remote Event 不承诺重放。恢复语义由数据类型决定,不再互相模拟。

Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。

每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。

Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。

通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。

Remote waterfall 保留多 Client 首个 claim、全体 next 后继续 Host chain、断线重放 pending 和端到端取消;代价是当前协议只支持一级 Agent scope 与无损 JSON 请求/结果。

本决定扩展Remote 事件投递的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 emit,Agent-scoped async waterfall 使用同一 ctx.remote.$on 面和显式 waterfall mode;不建立第二套 invocation map。

本决定接管简单一元 API Proxy 迁移中保留的 Session、Workspace 与 Host event carrier,并保留后台任务展示所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。