Remote event delivery (`ctx.remote.$on`)
Remote 事件投递(ctx.remote.$on)
[Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) initially cover targeted calls with one result per request and deliberately leave Session streams and stateful interactions elsewhere. Host-to-consumer events need a delivery mechanism that is not owned by the API Proxy domain. The Host owns one-way events such as `agent-preset/selected`, `commands/change`, `credenti
English
Problem
Typert Remote method calls initially cover targeted calls with one result per request and deliberately leave Session streams and stateful interactions elsewhere. Host-to-consumer events need a delivery mechanism that is not owned by the API Proxy domain.
The Host owns one-way events such as agent-preset/selected, commands/change, credentials/reference-updated, llm/adapters-updated, and settings/document-updated. They do not depend on AgentScope, and their payloads are already JSON. Requiring every event to cross a handwritten API Proxy frame, a handwritten Client Runtime bridge, and a Client event alias adds no fact beyond the owner event declaration.
That duplicate declaration is also lossy: the Client side restates an event as settings/changed(ns: string), flattening a branded type to bare string, contrary to the Remote-method rule that consumer types point to the business package's one canonical symbol.
Decision
The consumer Remote surface has one event-subscription verb, ctx.remote.$on(event, listener), with allowlist-driven, verbatim forwarding:
packages/api/remotes/src/remote-events.tsowns one list of forwardable Host events with explicitemit/waterfallmodes. It is also the sole control point for what consumers may subscribe to. Adjacentsrc/types.tsderives the type projection and fills the selection seat while remaining type-only. Both files appear in thefilesof the package's Host and Client faces, so both read one declaration.- The event name on the wire is the original Host Cordis name (
settings/document-updated) without ahost/prefix. The payload is the Host argument list, element for element through JSON, without projection, redaction, or renaming. api/remotesregisters the Host source with API Gateway. Gateway reserves internal logical endpoint$eventson the existing/api/remote.mux, adding no physical connection and giving API Proxy no event interpretation. Waterfall results return through HTTP unary endpoint$events/result.- Event signatures have no second table. Owner packages place their Cordis
Eventsdeclarations in Client-safe, type-only./typesexports so both faces read the same declaration.$onlistener parameters, result, andnext()derive fromEvents[Event]; verbatim correspondence holds by construction. - Only Cordis's type declarations are shared. Delivery semantics, registration, and failure handling belong to Typert.
When an Events member reaches a Host-only symbol such as a Service, Agent, or Context, the code is split until the declaration can live cleanly in ./types. A declaration is never split between index.ts and types.ts, and types.ts does not invent a structurally equivalent shadow type. Every current owner exposes its selected event declaration from a Client-safe type export.
All allowlisted events use this path, and dedicated frames and Client aliases are removed. Model consumers subscribe directly to llm/adapters-updated and settings/document-updated; preset consumers subscribe to agent-preset/selected; stateless Session and dynamic-Cordis notifications use emit; Approval and Question use Agent-scoped waterfall. Data that needs a baseline, projection, or deduplication retains a dedicated Remote stream.
skills/change, tools/change, and system-prompt/change have the same pure invalidation form but no shipped consumer. The rule that every abstraction needs a current owner and need keeps them outside the allowlist; they remain only an extension point recorded here.
Consumer contract (dsh-typert-protocol)
Type metadata adds event-form predicates, mode entries, a selection seat, and one member of TypertClientRemote, with no runtime code:
import type { Events } from '@deepseek-ai/cordis'
type TypertForwardingMode<Event extends keyof Events> =
unknown extends ThisParameterType<Events[Event]>
? TypertEventResult<Event> extends void ? 'emit' : never
: TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
/** Cordis event names that can cross the Remote Event carrier without a second signature. */
export type TypertForwardableEvent = {
[Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
}[keyof Events]
/** Event and dispatch mode accepted by the Remote Event source. */
export type TypertForwardableEventEntry = {
[Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
? Mode extends 'emit' | 'waterfall'
? { readonly event: Event; readonly mode: Mode }
: never
: never
}[keyof Events]
/** The Host assembly's forwarding selection; api/remotes' allowlist fills it, no other package does. */
export interface TypertRemoteEventSelection {}
/** `$on`'s legal keys: selected, and present in the current compilation face. */
export type TypertRemoteEvent = Extract<keyof Events, keyof TypertRemoteEventSelection>
/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */
$on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<Event>): () => void
Events resolves per program: the complete Host event vocabulary in a Host program and only declarations visible to the Client compilation face in a Client program. The same predicate therefore holds on both sides without bringing Host declarations into the Client.
The contract exposes only the consumer verb. ClientRemoteService registers the one internal $events pump as a Connection generation source when it activates, independently of whether any $on subscription exists. Browsers open $events through the shared Remote mux; in-process compositions open the same logical stream through connection.rpc.open. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. TypertClientRemote exposes no producer operation, so a business plugin cannot synthesize a Host event.
Each time the Host opens $events, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening { type: 'ready', clientId, host: { home } } before iterating the event source. ConnectionController publishes connected only after that item arrives, so baseline reads cannot race ahead of incremental listeners.
A physical mux disconnect ends the logical stream with RemoteStreamCarrierError. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation and reopens $events after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat $on as a reliable journal.
The Client dispatches on a Cordis key private to each Remote instance. Ordinary emit uses parallel() and contains listener failures; Agent-scoped waterfall uses waterfall() on the resolved Agent Context and allows a result, rejection, or next() delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events.
The allowlist: one declaration read by both faces
packages/api/remotes/src/remote-events.ts appears in both tsconfig.host.json and tsconfig.client.json and is the allowlist's sole home. src/types.ts derives the type face:
// remote-events.ts — the value
export const API_REMOTE_FORWARDED_EVENTS = [
{ event: 'agent-preset/selected', mode: 'emit' },
{ event: 'approval/request', mode: 'waterfall' },
...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
{ event: 'commands/change', mode: 'emit' },
{ event: 'credentials/reference-updated', mode: 'emit' },
{ event: 'cordis/request-run', mode: 'emit' },
{ event: 'cordis/request-run-resolved', mode: 'emit' },
{ event: 'cordis/dynamic-package', mode: 'emit' },
{ event: 'cordis/dynamic-retract', mode: 'emit' },
{ event: 'cordis/inspect-query', mode: 'emit' },
{ event: 'cordis/inspect-query-resolved', mode: 'emit' },
{ event: 'llm/adapters-updated', mode: 'emit' },
{ event: 'settings/document-updated', mode: 'emit' },
{ event: 'user-questions/request', mode: 'waterfall' },
] as const satisfies readonly TypertForwardableEventEntry[]
// types.ts — the type face, derived
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event']
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
}
Adding an event is therefore one array entry: type projection, the $on key set, Host dispatch mode, and the forwarding loop all derive from it. ctx.remote.$on('slots/changed', …) for a Client-local event and $on('skills/change', …) for a declared but unselected event are compile errors.
The declaration's trailing satisfies applies Host event-vocabulary and mode constraints to the same allowlist:
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[]
It enforces three properties: the name exists because the predicate is keyed by keyof Events; the selected mode matches the signature; and the signature is either an unscoped void notification or a waterfall with top-level Agent scope, a same-result next(), and a Promise return. Other Scope, bail, parallel, and serial forms are excluded.
Verbatim correspondence is not proved separately because it holds by construction. $on's listener type and Host forwarding both read the owner package's one Cordis Events declaration, so no second declaration can drift.
JSON safety remains a runtime concern. Before queueing, the API Remotes Host source checks every argument with dsh-session's isJsonValue and fails loudly when one is invalid, because this is an allowlist composition error rather than untrusted input.
Wire protocol (API Gateway Remote mux)
ready { type, clientId }
emit { type, event, args }
waterfall { type, event, eventId, agentId, request }
cancel { type, eventId }
The Client opens internal logical stream $events with payload { args: {} }. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in api/remotes, so disconnecting one Client neither consumes nor withdraws another Client's events.
The Client requires an opening ready item with a non-empty clientId and host.home; every later item is checked for exact fields by discriminant. The ready item establishes the Connection generation and supplies the stable Host path-display fact. An ordinary emit with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use eventId to correlate $events/result and agentId to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields.
$events is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become ctx.remote.<namespace>. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only.
The apps/web browser e2e belongs to the Host face
The apps/web/tests/** e2e files typecheck in root tsconfig.host.json: they boot a real harness in process and directly access ctx.connection, Host SessionStore.get/create/flush, and ctx.sessionProjectionCache. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces errors because one program cannot hold both faces' merges for the same Context key.
This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (ui-settings-general, ui-settings-models, ui-permission, and ui-commands) reference API Remotes' Client face, which cannot compile until Host tsdown generates @deepseek-ai/dsh-goal/remote. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated goal/remote, which Host tsdown emits after Host tsc.
The few required Client symbols are mirrored on the test side: scaffold.ts exports the mirrored welcome-notice constants, while the two chat e2e files import dsh-client-runtime/client directly because the Runtime project already belongs to the Host graph. This removes those four consumers from the Host graph, and the 15 Client project references in apps/cli/tsconfig.json no longer serve an owner-map role. Each mirror is byte-identical to its source; drift produces a selector mismatch or an unsuppressed notice and fails loudly.
Change inventory
| Location | Change |
|---|---|
dsh-typert-protocol | src/types.ts provides forwardable-mode derivation, selection, and Client-listener projection; TypertClientRemote exposes only $on. Types only, no runtime |
api/gateway | Host provides one Remote event source, $events, pending-waterfall coordination, and $events/result; Client registers the private pump as the Connection generation source and owns frame validation and Cordis dispatch |
api/remotes | src/remote-events.ts (mode-bearing allowlist value) and src/types.ts (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions |
Root tsconfig.base.json | Adds source-plane paths entries for dsh-settings/types, dsh-credentials/types, and dsh-api-remotes/types |
dsh-commands / dsh-settings / dsh-credentials | Moves each interface Events member to the owner's Client-safe ./types; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include lib/types/**/*.js in published files |
dsh-session | Exposes isJsonValue for validation of every event argument by the API Remotes Host source |
client/runtime | Removes the bridge from Host frames to the Remote subscription table; it only publishes connection/reset after a Connection generation is established |
| Consumers | Client plugins subscribe directly through ctx.remote.$on(...), import owner event declarations type-only, and inject 'remote' |
client/connection | Provides the one generation-source registration point; ConnectionController publishes the Host facts from $events ready, and the fixture emits events from the same source |
apps/web/tests + apps/cli | Mirrors Client symbols on the test side as described above and removes 15 Client project references from apps/cli/tsconfig.json |
Alternatives considered
Continue using API Proxy's Host downlink. This reuses Connection generation and connection/reset but leaves the Remote event allowlist, queue, schema, and Client Runtime bridge in API Proxy and prevents domain transports from sharing the lifecycle of other Remote streams. With API Gateway's resident /api/remote.mux, $events adds only one internal logical stream and belongs naturally in Gateway.
Open a third physical WebSocket or duplex stream for Remote events. An independent channel could own connection state but would duplicate authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect backoff already provided by Gateway mux. Internal $events retains an independent logical stream, while waterfall results reuse HTTP unary calls.
Declare a separate TypertRemoteEventMap in type metadata and let owner packages declaration-merge into it. The consumer key set would exactly equal remotely deliverable events, but every signature would be written again outside Cordis Events, requiring a bidirectional equivalence proof and new type-metadata dependencies for owner packages. Sharing one Events declaration makes equivalence structural, so the second map is not created.
Have the Typert generator project Host Events declarations. The generator already analyzes Host events, but it cannot infer projection or redaction intent and would expand the generator and build surface. Verbatim forwarding needs no projection.
Give forwardable events a payload projection function. A { event, project, zod } table could combine model-directory inputs and derive Workspace views, but would manually align projection logic with payload types and recreate the central table removed from Remote methods.
Move the apps/web browser e2e into the Client aggregate. The intuition that browser tests belong to the Client face fails with 21 errors because the tests use Host services while the Client program's ctx.sessions is ISessions.
Split directory-picker-browse/-native into Host and Client faces. This would remove Client packages from the Host graph, but changes another owner's packages for only a cleaner build graph. Mirroring the required Client symbols on the test side removes the need for that split.
Verification
- A real Host-source composition test proves that two Client streams each receive
{ event, args }, disconnecting one does not affect the other, and non-JSON arguments fail loudly without poisoning later valid delivery. - Type negatives reject unselected events, non-
voidunscoped events, non-Agent-scoped waterfalls, and allowlist modes that disagree with signatures.$on('slots/changed', …)and$on('skills/change', …)both fail to compile, so$on's key set equals the allowlist. - Consumer
$on('settings/document-updated', …)resolvesnsasSettingsNamespace, preserving the brand across the wire. - A
$ondisposer belongs to the calling fiber, and registering the same function object twice produces independently removable registrations; subscriptions are addressed by registration rather than listener identity. - Ordinary notifications contain both a throwing listener and a listener returning a rejected Promise. Waterfall tests pin Client result,
next(), rejection, cancellation, first claim across multiple Clients, and reconnect replay of a pending request. - Gateway tests cover missing, duplicate, and withdrawn sources; payload rejection; ready-before-event ordering; and browser and in-process carriers. Client tests cover generation-source registration, description/increment readiness order, reopen after physical failure, Host errors and unexpected completion, non-ready opening items, malformed event items,
$events/resultfailure, and disposal quiescence. host/remote-event, public$dispatch, the Client Runtime bridge, and API Proxy's allowlist dependency are absent; consumers observe owner events directly.
Consequences
- Gateway has one non-generated endpoint.
$eventshas no business namespace and does not enter the Typert descriptor. It is the internal connection point between Gateway and API Remotes and defines the Client Connection generation lifetime. Strict empty-payload validation, opening-ready validation, and single-source registration prevent it from becoming another handwritten business API. - Two files break API Remotes' face-disjointness rule.
src/remote-events.tsandsrc/types.tsbelong to both projects and emit identical declarations into sharedlib/types. Their content is byte-identical and.tsbuildinfofiles remain separate, so this is safe in practice; the README records why source-planepathsrequire the exception. - Producer operations remain private. Business plugins can call only
$on. Host-source registration and Client dispatch are absent fromTypertClientRemote; test doubles drive subscriptions through their ownemitoperations rather than impersonating a production API. - Malformed arguments fail at emit. An API Remotes listener throws before queueing, so Host
ctx.emitimmediately observes an allowlist composition error and the queue can still deliver subsequent valid events. - Test-side mirrors can drift. No mechanism compares mirrored Client constants under
apps/web/testswith their source. Drift instead produces a selector mismatch.apps/web/tests/README.mdrecords the review rule; a grep-level gate is deliberately omitted. - Capabilities deliberately omitted. Payload projection and redaction are unsupported, scopes other than Agent are unsupported, and ordinary notifications are not replayed. Recoverable state needs a query, cursor, or opening baseline; a waterfall is replayed only while its original Host invocation remains pending.
- Some Client packages remain in the Host graph. Twelve projects, including
connection,runtime, andui-slots, remain reachable through unsplitdirectory-picker-browse/-nativeandapi/gateway → client/connection. They compile and no longer pull in API Remotes' Client face, so this change does not split them. Directdsh-client-runtime/clientimports in two chat e2e files rely on Runtime's current presence in that graph rather than a general guarantee. - The package intentionally publishes no invariant companion. A prior revision asserted delivery form on the live event bus, coupling diagnostics to the allowlist and causing Rolldown to emit a third bundle chunk omitted by the mechanically derived publication list. The Host-face
TypertForwardableEventEntryassertion already rejects those mismatches at compile time, and the package README records why no independent runtime relation remains.
Related
- Remote duplex stream: every Remote stream carries a Client uplink on the same logical stream; the duplex
$eventslisted under its Deferred section replaces the unary$events/resultpath once it lands.
中文
问题
Typert Remote 方法调用最初只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的事件需要一个不归 API Proxy 领域所有的投递机制。
Host 拥有 agent-preset/selected、commands/change、credentials/reference-updated、llm/adapters-updated、settings/document-updated 等单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。若每条事件都要穿过 API Proxy 手写帧、Client Runtime 手写桥和 Client 事件别名才能抵达 UI,这些层不会陈述 owner 事件之外的新事实。
那份重复声明还是有损的:client 侧写成 settings/changed(ns: string),brand 类型在这一跳被拍平成裸 string,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。
决策
消费端 Remote 面持有一个事件订阅动词 ctx.remote.$on(event, listener);名单驱动、原样转发:
packages/api/remotes/src/remote-events.ts持有一份带emit/waterfallmode 的可转发 Host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的src/types.ts由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件都同时列进本包 Host 与 Client 两个 face 的files,两侧读同一份。- wire 上的事件名 就是 host cordis 事件原名(
settings/document-updated),不加host/前缀;载荷 就是 host 的实参列表,逐元素原样过 JSON,无投影、无脱敏、无改名。 - Host source 由
api/remotes注册到 API Gateway;Gateway 在既有/api/remote.mux上保留内部 logical endpoint$events,不增加物理连接,也不让 API Proxy 解释事件。waterfall 结果通过 HTTP 一元 endpoint$events/result返回。 - 事件签名不另立表:owner 包把自己的 cordis
Events声明搬进 client-safe 的./types纯类型出口,两侧读同一份——$on的 listener 参数、结果和next()都由Events[Event]推导。「原样」不需要证明,是构造性成立的。 - 但只借 cordis 的类型形状,不接 cordis 的事件系统:投递语义、注册表、异常处置全归 Typert 自己。
一条 Events 条目若签名里够到了 host-only 符号(Service、Agent、Context 等),处理方式是把代码拆到能干净落进 ./types 为止;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 ./types 里造结构等价的影子类型。当前名单内各 owner 都从 client-safe 类型出口提供同一份事件声明。
名单内事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 llm/adapters-updated 和 settings/document-updated;preset 消费方订阅 agent-preset/selected;Session 与动态 Cordis 的无状态通知使用 emit;Approval 与 Question 使用 Agent-scoped waterfall。真正需要 baseline、投影或去重的数据仍保留专用 Remote stream。
skills/change、tools/change、system-prompt/change 是同形状的纯失效事件但没有任何已交付消费者,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。
消费端契约(dsh-typert-protocol)
type-meta 加事件形状谓词、mode 条目、选择座位和 TypertClientRemote 的一个成员;零运行时代码:
import type { Events } from '@deepseek-ai/cordis'
type TypertForwardingMode<Event extends keyof Events> =
unknown extends ThisParameterType<Events[Event]>
? TypertEventResult<Event> extends void ? 'emit' : never
: TypertWaterfallEvent<Event> extends never ? never : 'waterfall'
/** Cordis event names that can cross the Remote Event carrier without a second signature. */
export type TypertForwardableEvent = {
[Event in keyof Events]: TypertForwardingMode<Event> extends never ? never : Event
}[keyof Events]
/** Event and dispatch mode accepted by the Remote Event source. */
export type TypertForwardableEventEntry = {
[Event in keyof Events]: TypertForwardingMode<Event> extends infer Mode
? Mode extends 'emit' | 'waterfall'
? { readonly event: Event; readonly mode: Mode }
: never
: never
}[keyof Events]
/** The Host assembly's forwarding selection; api/remotes' allowlist fills it, no other package does. */
export interface TypertRemoteEventSelection {}
/** `$on`'s legal keys: selected, and present in the current compilation face. */
export type TypertRemoteEvent = Extract<keyof Events, keyof TypertRemoteEventSelection>
/** Subscribe to one forwarded Host event; the returned disposer belongs to the calling fiber. */
$on<Event extends TypertRemoteEvent>(event: Event, listener: TypertClientEventListener<Event>): () => void
Events 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。
契约只公开消费动词。ClientRemoteService 激活时就把内部唯一的 $events pump 注册为 Connection generation source,与当前有无 $on 订阅无关;浏览器通过共享 Remote mux 打开 $events,进程内组合通过 connection.rpc.open 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,TypertClientRemote 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。
每次 Host 打开 $events 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 { type: 'ready', clientId, host: { home } },再开始迭代事件 source。ConnectionController 只有在该项到达后才发布 connected,因此 baseline 读取不会跑在增量 listener 前面。
物理 mux 断开会让 logical stream 以 RemoteStreamCarrierError 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation,在退避后重开 $events;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 $on 当作可靠日志。
Client 以 Remote 实例私有 Cordis key 分发。普通 emit 使用 parallel() 并隔离 listener 失败;Agent-scoped waterfall 在解析出的 Agent Context 上使用 waterfall(),允许结果、拒绝或 next() 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。
名单:两个 face 共读的同一份声明
packages/api/remotes/src/remote-events.ts 同时列进 tsconfig.host.json 与 tsconfig.client.json 的 files,是名单的唯一家;src/types.ts 由它派生类型面:
// remote-events.ts — the value
export const API_REMOTE_FORWARDED_EVENTS = [
{ event: 'agent-preset/selected', mode: 'emit' },
{ event: 'approval/request', mode: 'waterfall' },
...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
{ event: 'commands/change', mode: 'emit' },
{ event: 'credentials/reference-updated', mode: 'emit' },
{ event: 'cordis/request-run', mode: 'emit' },
{ event: 'cordis/request-run-resolved', mode: 'emit' },
{ event: 'cordis/dynamic-package', mode: 'emit' },
{ event: 'cordis/dynamic-retract', mode: 'emit' },
{ event: 'cordis/inspect-query', mode: 'emit' },
{ event: 'cordis/inspect-query-resolved', mode: 'emit' },
{ event: 'llm/adapters-updated', mode: 'emit' },
{ event: 'settings/document-updated', mode: 'emit' },
{ event: 'user-questions/request', mode: 'waterfall' },
] as const satisfies readonly TypertForwardableEventEntry[]
// types.ts — the type face, derived
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event']
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
}
于是加一个事件只改这一行数组:类型投影、$on 的键面、Host dispatch mode 与转发循环全部从它派生。ctx.remote.$on('slots/changed', …)(Client 本地事件)或 $on('skills/change', …)(名单没开)都是编译错误。
数组声明末尾的 satisfies 把 Host 事件词汇与 mode 约束落到同一份名单上:
API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEventEntry[]
它卡住三件事:名字合法(谓词以 keyof Events 为基)、mode 匹配签名,以及只接受无 scope 的 void 通知或带一级 Agent scope、同结果 next() 和 Promise 返回的 waterfall。其他 Scope、bail、parallel 与 serial 形状都被排除。
「原样」不在任何地方证明,而是构造性成立:$on 的 listener 类型取自 owner 包 ./types 里那一份 cordis Events 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。
载荷 JSON-safe 交给运行时:api/remotes 的 Host source 在入队前用 dsh-session 的 isJsonValue 逐元素校验,不合格抛错 fail loud(这是名单配置错误,不是外部输入)。
线协议(API Gateway Remote mux)
ready { type, clientId }
emit { type, event, args }
waterfall { type, event, eventId, agentId, request }
cancel { type, eventId }
Client 以 endpoint $events 和 payload { args: {} } 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 api/remotes 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。
Client 要求首项是带非空 clientId 与 host.home 的 ready;后续 item 按 discriminant 精确校验字段。ready 项建立 Connection generation,并提供稳定的 Host 路径显示信息。普通 emit 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 eventId 关联 $events/result,并由 agentId 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。
$events 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 ctx.remote.<namespace>。应用选择仍只存在于 api/remotes 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。
apps/web 的 browser e2e 属于 Host 面
apps/web/tests/** 那批 e2e 在根 tsconfig.host.json 做类型检查:它们在进程内起真 harness、直接访问 ctx.connection、Host SessionStore.get/create/flush 与 ctx.sessionProjectionCache。运行时用浏览器 ≠ 类型上属于 Client 程序——把它们搬进 Client 聚合会报错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。
由此得到一条对本设计要紧的连带纪律:这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图。ui-settings-general/ui-settings-models/ui-permission/ui-commands 四个消费者 references api/remotes 的 client face,而该 face 必须等 host tsdown 生成 @deepseek-ai/dsh-goal/remote 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → goal/remote → host tsdown → 排在 host tsc 之后。
所需的客户端符号在测试侧镜像了一份(scaffold.ts 导出镜像后的 welcome-notice 常量,两个 chat e2e 直接引 dsh-client-runtime/client 因为 runtime 工程本来就在 host 图里),从而让那 4 个消费者离开了 host 图;apps/cli/tsconfig.json 里 15 条 client 工程引用随之失去 owner-map 职责,已一并删除。镜像值与源逐字一致,漂移的表现是选择器失配或通知未被抑制,都是响亮失败。
改动清单
| 位置 | 改动 |
|---|---|
dsh-typert-protocol | src/types.ts 提供 forwardable mode 推导、selection 与 Client listener 投影;TypertClientRemote 只公开 $on。纯类型,零运行时 |
api/gateway | Host 半提供唯一 Remote event source、$events stream、pending waterfall 协调和 $events/result;Client 半把私有 pump 注册为 Connection generation source,负责 frame 校验和 Cordis 分发 |
api/remotes | src/remote-events.ts(带 mode 的名单值)与 src/types.ts(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution |
根 tsconfig.base.json | 加 dsh-settings/types、dsh-credentials/types、dsh-api-remotes/types 三条 paths,全部指向源平面 |
dsh-commands / dsh-settings / dsh-credentials | interface Events 子块移入各自 client-safe 的 ./types(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;files 补 lib/types/**/*.js) |
dsh-session | isJsonValue 供 api/remotes Host source 校验每个事件参数 |
client/runtime | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 connection/reset |
| 消费方 | Client 插件直接订阅 ctx.remote.$on(...),type-only 引入 owner 事件声明并把 'remote' 加进 inject |
client/connection | 提供唯一 generation source 注册位;ConnectionController 发布 $events ready 携带的 Host 信息,fixture 也从同一 source 产生事件 |
apps/web/tests + apps/cli | 客户端符号镜像(见上节);apps/cli/tsconfig.json 删 15 条 client 工程引用 |
备选方案
**继续寄生 API Proxy 的 Host downlink。**这样可以复用 Connection generation 和 connection/reset,但会让 API Proxy 保留 Remote 事件 allowlist、队列、schema 和 Client Runtime bridge,领域传输也无法随其他 Remote stream 共用生命周期。API Gateway 已有常驻 /api/remote.mux 后,$events 只增加一个 internal logical stream,不需要第三条 WebSocket,因此转移到 Gateway 的成本和所有权都更合理。
**给 Remote 事件另开第三条物理 WebSocket 或 duplex stream。**独立通道能拥有自己的连接状态,但会重复 Gateway mux 已经提供的认证升级、复用、取消、错误映射和退避重连。内部 $events endpoint 保留独立 logical stream,waterfall 结果复用 HTTP 一元调用。
在 type-meta 立一张独立的 TypertRemoteEventMap,让 owner 包 declare-merge 进去。消费端键集会精确等于「被声明为可远程投递的事件」;代价是每条事件的签名要在 cordis Events 之外再写一遍,于是需要一条双向 extends 的等价性证明来防漂移,还要给三个 owner 包新增 type-meta 依赖。共用同一份 Events 声明让等价性变成构造性成立,这张表因此不立。
让 typert generator 从 host Events 声明生成事件投影(codec + .d.ts + 声明映射,与 /remote 同族)。generator 已经在分析 host 事件;但它拿不到投影与脱敏语义,且要动生成器与构建面。原样转发这条路本就不需要投影。
给可转发事件加载荷投影函数({ 事件名, 投影, zod } 转发表)。能一举覆盖 models-changed 的 fan-in 与 workspace 的 view 派生;代价是投影逻辑与载荷类型手工对齐,回到方法侧刚刚消灭的中心表形态。
把 apps/web 的 browser e2e 搬进 client 聚合。看似「客户端测试归客户端面」,实测立刻 21 条错:它们用 host 服务,而 client 程序里 ctx.sessions 是 ISessions。已否。
给 directory-picker-browse/-native 做 host/client 双 face 切分,从根上让客户端包不进 host 图。方向正确(它们确实是未切分的双半包),但改动落在别人属地,而收益只是「构建图更干净」——本设计在测试侧镜像客户端符号之后已经不需要它。已评估不做。
验证
钉住该行为的东西:
- Host source 真组合测试:两个 Client stream 各自收到 host emit 的
{ event, args },其中一个断开不会影响另一个;非 JSON 实参会响亮拒绝且不会毒化后续合法事件。 - 类型层负例拒绝未选择事件、非
void的无 scope 事件、非 Agent-scoped waterfall,以及声明 mode 与签名不符的条目。$on('slots/changed', …)(Client 本地事件)与$on('skills/change', …)(已声明但未选中)都编译失败——因此$on的键面恰好等于名单。 - 消费端
$on('settings/document-updated', …)把ns解析为SettingsNamespace:brand 穿过 wire 存活。 $on的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。- 普通通知同时收容抛出的 listener 与拒绝所返回 Promise 的 listener;waterfall 测试固定 Client result、
next()、拒绝、取消、多 Client 首个 claim 和重连重放 pending request。 - Gateway 测试覆盖 source 缺失、重复注册、撤销中止、payload 拒绝、ready 先于事件,以及浏览器与进程内两种 carrier;Client 测试覆盖 generation source 注册边界、描述与增量就绪顺序、物理失败后重开、Host 错误与意外结束、非 ready 首项、畸形事件项、
$events/result失败和 dispose quiescence。 host/remote-event、公开$dispatch、Client Runtime bridge 和 API Proxy 的 allowlist 依赖都不存在;各消费方直接观察 owner 事件。
后果
- Gateway 有一个非生成 endpoint:
$events不对应业务 namespace,也不进入 Typert descriptor;它是 Gateway 与api/remotes之间的内部连接点,同时定义 Client Connection generation 的存活期。严格的空 payload 校验、opening ready 校验和单 source 注册限制它不会演化成第二个手写业务 API。 - 两个文件打破了 api/remotes 的 face 互斥约定:
src/remote-events.ts与src/types.ts同属两个工程,各自向共享的lib/types发射一份相同声明。内容逐字节相同、.tsbuildinfo各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(paths指向源码面)。 - 生产方保持私有:业务插件只能调用
$on;Host source 注册和 Client 派发都不在TypertClientRemote上暴露,测试 double 以自己的emit方法驱动订阅,不伪装成生产接口。 - 畸形实参在 emit 点失败:
api/remoteslistener 在入队前抛出,因此调用 Hostctx.emit的操作立即看到名单配置错误;队列仍可继续投递后续合法事件。 - 测试侧镜像值可能漂移:没有任何机制核对
apps/web/tests中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在apps/web/tests/README.md,由 review 守;grep 级门禁经评估后刻意不做。 - 放弃的能力:不支持投影或脱敏载荷,不支持 Agent 以外的 Scope,也不为普通通知提供重放。需要可靠恢复的状态必须拥有查询、cursor 或 opening baseline;waterfall 只重放仍处于同一次 Host 调用生命周期内的 pending request。
- 仍有 client 包留在 host 图里:12 个工程(
connection、runtime、ui-slots等)经未拆分的directory-picker-browse/-native与api/gateway → client/connection仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引dsh-client-runtime/client依赖runtime本来就在图里——属偶然而非保证。 - 本包不发布 invariant companion:早先的修订曾在活事件总线上断言投递形状(
thisArg === null、mode === 'emit'),这让诊断逻辑与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。Host 面的TypertForwardableEventEntry断言已在编译期拒绝这些偏离,包 README 也记录了不再存在独立运行时关系的原因。
相关
- Remote 双工流:所有 Remote 流在同一逻辑流上承载客户端上行;其「后续」列出的双工
$events落地后取代 unary$events/result路径。