DSH / Atlas
2026-08-10implementedarchitecture

Remote event delivery (ctx.remote.$on)

Remote 事件投递(ctx.remote.$on)

[Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy. The Host owns a family of one-way events whose payloads are already JSON and whose emission ne

English

Problem

Typert Gateway targeted method calls cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every one-way Host-to-consumer push therefore still rides the legacy API Proxy.

The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: agent-preset/selected, commands/change, credentials/reference-updated, llm/adapters-updated, and settings/document-updated. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written HostFrame variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's ctx.on(...). Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side Events declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis Events merge.

That duplicated declaration is also lossy: the Client side restates it as settings/changed(ns: string), flattening a branded type into bare string — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol.

Decision

The consumer Remote surface carries one one-way subscription verb, ctx.remote.$on(event, listener), driven by an allowlist and forwarding verbatim:

  • packages/api/remotes/src/remote-events.ts holds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to. src/types.ts beside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are listed in the files of both of this package's faces, so the Host forwarding loop and the consumer key surface read one declaration.
  • The wire event name is the Host cordis event name (settings/document-updated) with no host/ prefix, and the payload is the Host argument list, element for element, with no projection, redaction, or renaming.
  • The carrier reuses the existing host stream: HostFrame gains one wrapper variant, host/remote-event. No new downlink.
  • Event signatures get no second table. Each owner package moves its cordis Events declaration into its client-safe, type-only ./types export, so both faces read the same declaration and $on's listener type is Events[Event] itself. "Verbatim" then holds by construction rather than by proof.
  • Only cordis's type shape is borrowed, not its event system: delivery semantics, the subscription registry, and failure containment belong to Typert.

When an Events entry's signature reaches a Host-only symbol (a Service, Agent, a Context), the answer is to split the code until the entry lands cleanly in ./types — never a declaration half-left in index.ts, and never a structurally equivalent shadow type in ./types. None of the five packages needs that here: their entries reach only SettingsNamespace, SettingsUpdateSource, CredentialRef, and SessionId, all pure types. The agent-presets package renames its previous vocabulary module to preset.ts, leaving the exported types.ts dedicated to the client-safe event declaration.

All five events ride this path, and their dedicated HostFrame variants or Client aliases are gone. Model consumers subscribe directly to both owner inputs, llm/adapters-updated and settings/document-updated; preset-derived consumers subscribe to agent-preset/selected. Frames that actually project or deduplicate data stay dedicated: host/workspace-changed/-removed/host/archived-sessions-changed (view derivation plus per-connection dedup state), and host/session-added/-removed/host/session-status/host/agent-error (live-object projection or frame-time derived fields).

skills/change, tools/change, and system-prompt/change have the same shape but no consumer today; under "require a current owner and need" they stay out of the allowlist and are recorded here only as the extension seat.

Consumer contract (dsh-typert-protocol)

type-meta gains one shape predicate, one selection seat, and one member on TypertClientRemote. No runtime code:

import type { Events } from '@deepseek-ai/cordis'

/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */
export type TypertForwardableEvent = {
  [Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
    ? ReturnType<Events[Event]> extends void ? Event : 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: Events[Event]): () => void

Events resolves per program: the full Host vocabulary in the Host program, whatever the Client face can see in the Client program. The same predicate therefore holds on both sides without dragging Host declarations into the Client.

The surface separates the consumer verb from the carrier handoff: consumers subscribe with $on, and whoever owns the Host frame sink hands each decoded frame over with $dispatch. It cannot be a module-level function reaching across Client plugins — the client bundle purity gate (packages/client/tsdown.client.ts) admits value imports only from the implicit PLATFORM_MODULES plus PRELOADED_CLIENT_EXTERNALS baseline, the package's dsh.client.external requests, the INLINE_SAFE wire layer, and generated /remote contributions. Inlining around it would copy ClientRemoteService into the runtime bundle, making instanceof permanently false. A cordis service method is the collaboration shape that gate prescribes:

$dispatch(event: string, args: readonly unknown[]): void

client/runtime — the owner of the host frame sink — calls it directly, so the frame reaches the subscription table without an intermediate event to relay it. The event parameter is string, not TypertRemoteEvent: this is a wire boundary, and a name nobody subscribed to is dropped silently.

Delivery shares no implementation with the cordis event system: one-way only, no waterfall/bail/parallel/serial modes and no @mode concept (ReturnType extends void is the static expression of that rule), no this binding, no EventOptions, prepend, or priority. Listeners run in registration order, and one that throws is contained and logged — it must never take down the frame pump (the same posture ConnectionController already applies to its sinks).

The allowlist: one declaration both faces read

packages/api/remotes/src/remote-events.ts is listed in the files of both tsconfig.host.json and tsconfig.client.json, and is the allowlist's single home; src/types.ts derives its type face:

// remote-events.ts — the value
export const API_REMOTE_FORWARDED_EVENTS = [
  'agent-preset/selected',
  'commands/change',
  'credentials/reference-updated',
  'llm/adapters-updated',
  'settings/document-updated',
] as const

// types.ts — the type face, derived
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]

declare module '@deepseek-ai/dsh-typert-protocol' {
  interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
}

Forwarding one more event is therefore one line in that array: the type projection, $on's key surface, and the Host forwarding loop all derive from it. ctx.remote.$on('slots/changed', …) (a Client-local event) and $on('skills/change', …) (declared but unselected) are both compile errors.

The Host face adds one shape assertion, binding the Host event vocabulary to that same array:

API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[]

It is an expression statement rather than a named constant, which noUnusedLocals would reject (the underscore prefix exempts parameters only). It enforces three things: the name is real (the predicate is keyed on keyof Events), the event binds no Scope (goal/changed and kin have a ThisParameterType other than unknown and drop out — the static expression of "no AgentScope dependency"), and the event is one-way (a non-void return, i.e. a waterfall/bail shape, drops out).

"Verbatim" is proved nowhere because it holds by construction: $on's listener type comes from the one cordis Events declaration in the owner package's ./types, and Host forwarding reads that same declaration. There is no second declaration that could drift.

JSON-safety is a runtime concern: before forwarding, apiproxy validates each argument with dsh-session's isJsonValue and throws loudly when one fails, because that is an allowlist composition mistake rather than untrusted input.

Wire contract (apiproxy)

| { type: 'host/remote-event'; event: string; args: JsonValue[] }

The zod branch keeps args: z.array(z.unknown()): the frame arrives from JSON.parse, so every element is already a JSON value, and the structural contract belongs to the owner package's Events declaration — the same posture the existing session/projection frame takes with its value.

events.host() subscribes by allowlist when the stream opens. Each stream owns its disposers, so no broadcast set or derived invalidation listener is needed.

api/events.ts is a wire contract file the browser side also compiles, so every type it references must come from an owner package's client-safe, type-only subpath, never the package root. Evidence: importing one type from @deepseek-ai/dsh-session root drags the root's declare module 'cordis' { interface Context { sessions: SessionStore } } into the Client compilation face and overrides the Client's ctx.sessions: ISessions, producing 18 errors in the unrelated ui-input-trigger and ui-conversation. JsonValue therefore needs a re-export from dsh-session/src/types.ts.

The apps/web browser e2e belong to the Host face

The apps/web/tests/** e2e type-check in the root tsconfig.host.json: they boot a real harness in-process and read ctx.apiProxy, the Host SessionStore's get/create/flush, and ctx.sessionProjectionCache. Driving a browser at runtime does not make a file part of the Client program — moving them into the Client aggregate immediately produces 21 errors, because one program cannot hold both faces' merges for the same Context key.

That yields a discipline this design depends on: when those tests import a value or a type from a Client package, they pull that package's whole project — and every project it references — into the Host build graph. Four consumers (ui-settings-general, ui-settings-models, ui-permission, ui-commands) reference api/remotes' Client face, and that face cannot compile until Host tsdown has generated @deepseek-ai/dsh-goal/remote. The result is a build-order deadlock: Host tsc needs the Client face, which needs the generated artifact, which Host tsdown produces after Host tsc.

The few Client-owned symbols are therefore mirrored on the test side (scaffold.ts exports the mirrored welcome-notice constants; the two chat e2e keep importing dsh-client-runtime/client because the runtime project is already in the Host graph), which lets those four consumers leave the Host graph. The 15 Client project references in apps/cli/tsconfig.json lost their owner-map role and are gone. Each mirrored value matches its source verbatim; a drift shows up as a missed selector or an unsuppressed notice, both loud failures.

Change inventory

LocationChange
dsh-typert-protocolsrc/types.ts gains TypertForwardableEvent, TypertRemoteEventSelection, and TypertRemoteEvent; TypertClientRemote gains $on and $dispatch. Types only, no runtime
api/gateway Client halfClientRemoteService implements $on (subscriptions addressed by registration, ctx.effect ownership for the calling fiber) and $dispatch (snapshot delivery in registration order, containing a listener that throws or rejects)
api/remotesNew src/remote-events.ts (the allowlist value) and src/types.ts (type projection, selection seat), both listed in both faces' files; a ./types export with lib/types/**/*.js added to files; the Host face adds the shape assertion and import type {} for the five owner ./types; the Client half re-exports those five plus @deepseek-ai/dsh-api-gateway/client
Root tsconfig.base.jsonClient-safe paths entries for settings, credentials, llm, agent-presets, and api-remotes types point at the source plane
dsh-commands / dsh-settings / dsh-credentials / dsh-llm / dsh-agent-presetsEach forwarded interface Events member lives in the owner's client-safe ./types; agent-presets moves its previous domain vocabulary to preset.ts so the exported file itself remains types.ts
host/apiproxyHostFrame gains host/remote-event and loses the five dedicated passthrough or invalidation variants with their zod branches; events.host() subscribes by allowlist and validates through assertJsonArgs
dsh-sessionsrc/types.ts re-exports JsonValue so wire contract files can use the client-safe subpath
client/runtimeThe five Client-event bridge branches collapse into ctx.remote.$dispatch(frame.event, frame.args), adding a remote injection and deleting their duplicated Events declarations
Seven consumersui-commands / ui-model-selection / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset / ui-skill subscribe through ctx.remote.$on(...), following ui-goal's precedent for the type-only facade import and the 'remote' injection
client/connectionThe fixture's emitHost produces host/remote-event
apps/web/tests + apps/cliClient symbols mirrored on the test side (see above); apps/cli/tsconfig.json drops its 15 Client project references

Alternatives considered

Open a general downlink channel for Remote events (the push counterpart of ctx.connection.rpc, a third WebSocket). This best matches "Connection owns the carrier, the Gateway never touches transport", but it means a new stream in the Host downlink, WebApiClient, ConnectionController, the fixture, and the web e2e — a cost out of proportion to this change. Reusing the host stream costs a temporary tenancy inside a legacy frame union; when that stream moves, the wrapper moves with it and the consumer contract does not change.

Declare a separate TypertRemoteEventMap in type-meta and let owner packages merge into it. The consumer key set would equal exactly "events declared remotely deliverable", but every signature would be written a second time outside cordis Events, requiring a bidirectional extends proof to stop the two from drifting, plus a new type-meta dependency for three owner packages. Sharing the one Events declaration makes that equivalence structural, so the table is not created.

Have the typert generator project Host Events declarations (codec, .d.ts, declaration map, like /remote). The generator already analyzes Host events, but it cannot see projection or redaction intent, and it would change the generator and the build surface. Verbatim forwarding needs no projection.

Give forwardable events a payload projection function (a { name, project, zod } forwarding table). This could fold the two model-directory inputs into one derived invalidation and also cover workspace view derivation, at the cost of hand-aligning projection logic with payload types — the central table the method side just removed.

Move the apps/web browser e2e into the Client aggregate. "Client tests belong to the Client face" looks right and fails immediately with 21 errors: those tests use Host services, and in the Client program ctx.sessions is ISessions.

Split directory-picker-browse/-native into Host and Client faces so no Client package reaches the Host graph. The direction is right — they are genuinely unsplit dual-half packages — but the change lands in another owner's packages and buys only a cleaner build graph; once this design mirrors the Client symbols on the test side, it no longer needs the split. Assessed and declined.

Verification

What pins this behavior:

  • A real composition test puts one host/remote-event frame on the real host stream per Host emit, with event the Host name and args equal element for element.
  • Type-level negatives reject three candidate classes: a name that is not an event, a Scope-bound event (goal/changed), and an event whose return is not void. $on('slots/changed', …) (Client-local) and $on('skills/change', …) (declared but unselected) both fail to compile, so $on's key surface equals the allowlist.
  • On the consumer side, $on('settings/document-updated', …) resolves ns as SettingsNamespace: the brand survives the wire.
  • $on's disposer belongs to the calling fiber, and two registrations of one function object retire independently — a table keyed on listener identity would collapse them, so subscriptions are addressed by registration.
  • Delivery contains a listener that throws AND one that rejects a returned promise: the declared return is void, so nobody awaits an async listener, and its rejection would otherwise escape this containment entirely. Delivery iterates a snapshot, so subscribing or disposing mid-frame cannot change who receives that frame.
  • assertJsonArgs is unit-tested directly rather than by driving a malformed emit through the event bus: a typed ctx.emit cannot construct one, since every allowlisted event has a statically JSON-safe payload.
  • The five dedicated HostFrame variants, five Client-side aliases, and their bridge branches are absent. The model directories observe both owner inputs, while command, skill, and session-row consumers observe the preset owner's committed-selection event.

Consequences

  • Tenancy inside a legacy frame union. The contract lives in apiproxy's HostFrame, so a reader may assume apiproxy owns Remote events. The frame's JSDoc names api-remotes as the allowlist owner, and apiproxy's README records the tenancy under known limitations. When the host stream moves off that package, the wrapper moves with it and the consumer contract does not change.
  • Two files break api/remotes' face-disjointness contract. src/remote-events.ts and src/types.ts belong to both projects, so each emits an identical declaration into the shared lib/types. Content is byte-identical and the .tsbuildinfo files stay separate, so this is harmless in practice; the README's build-boundary section states the exception and its cause (the paths entry points at source).
  • The carrier handoff is developer-visible. Any Client plugin holding ctx.remote can call $dispatch and synthesize a forwarded event. That exposure predates the verb — ctx.emit was equally reachable while an internal event relayed the frame — and matches what connection/reset already allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-$on conversion and do not pretend the port authenticates its caller.
  • A malformed argument fails in the emitter's containment, not at load. assertJsonArgs throws inside the forwarding listener, so the emitting seam's listener containment logs it and drops that frame: loud in the Host log rather than at load or at the emit point.
  • Mirrored test values can drift. Nothing mechanically checks the Client constants mirrored in apps/web/tests against their source; the safety net is only that a drift misses a selector. The rule lives in apps/web/tests/README.md and is held by review — a grep-level gate was considered and deliberately skipped.
  • Capabilities given up. No projected or redacted payloads, no Scope-bound events (agentCtx.remote.$on), and no replay on reconnect — these are pure invalidation signals, and connection/reset already covers refetching after a reconnect. The mux stream's session events, answerable frames, and snapshot baselines stay out of scope.
  • Client packages remain in the Host graph. Twelve projects (connection, runtime, ui-slots, and kin) still reach it through the unsplit directory-picker-browse/-native pair and api/gateway → client/connection. They compile and no longer implicate api/remotes' Client face, so they did not block this change; splitting those packages would remove a few but was assessed and declined. The two chat e2e importing dsh-client-runtime/client rely on runtime already being in that graph — incidental, not a guarantee.
  • The invariant companion holds no runtime check. An earlier revision asserted the dispatch shape (thisArg === null, mode === 'emit') over the live event bus, which coupled the companion to the allowlist value and made rolldown hoist it into a third bundle chunk the mechanical publication list does not carry. The Host face's TypertForwardableEvent assertion already refuses both deviations at compile time, so the companion is an explained empty installer.

中文

问题

Typert Remote 方法调用只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的单向事件推送因此仍然全部压在遗留的 API Proxy 上。

Host 拥有 agent-preset/selectedcommands/changecredentials/reference-updatedllm/adapters-updatedsettings/document-updated 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、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 持有一份可转发 host 事件名单,它同时是「消费端能订阅什么」的唯一控制点。旁边的 src/types.ts 由它派生类型投影并填充 selection 座位,按包约定保持纯类型。两个文件都同时列进本包 host 与 client 两个 face 的 files,两侧读同一份。
  • wire 上的事件名 就是 host cordis 事件原名settings/document-updated),不加 host/ 前缀;载荷 就是 host 的实参列表,逐元素原样过 JSON,无投影、无脱敏、无改名。
  • 载体寄生现有 host 流HostFrame 加一个包裹帧 host/remote-event,不新开下行通道。
  • 事件签名不另立表:owner 包把自己的 cordis Events 声明搬进 client-safe 的 ./types 纯类型出口,两侧读同一份——$on 的 listener 类型就是 Events[Event] 本身。「原样」不需要证明,是构造性成立的。
  • 只借 cordis 的类型形状,不接 cordis 的事件系统:投递语义、注册表、异常处置全归 Typert 自己。

一条 Events 条目若签名里够到了 host-only 符号(Service、Agent、Context 等),处理方式是把代码拆到能干净落进 ./types 为止;不接受「一半留 index、一半搬走」的分裂声明,也不接受在 ./types 里造结构等价的影子类型。这五个包都不需要拆:它们的条目只够到纯类型。agent-presets 把原词汇模块改名为 preset.ts,让导出的 types.ts 专门承载 client-safe 事件声明。

五条事件全部走这条路径,专用帧与 Client 别名都已删除。模型消费方直接订阅 llm/adapters-updatedsettings/document-updated;preset 消费方订阅 agent-preset/selected。真正需要投影或去重的数据仍保留专用帧。

skills/changetools/changesystem-prompt/change 是同形状的纯失效事件但目前没有任何消费者,按「每个抽象都要有当前 owner 与需求」不进名单,只作为扩展位记录在此。

消费端契约(dsh-typert-protocol)

type-meta 加一个形状谓词、一个选择座位TypertClientRemote一个成员;零运行时代码:

import type { Events } from '@deepseek-ai/cordis'

/** Cordis events shaped for one-way remote delivery: no Scope binding, void return. */
export type TypertForwardableEvent = {
  [Event in keyof Events]: unknown extends ThisParameterType<Events[Event]>
    ? ReturnType<Events[Event]> extends void ? Event : 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: Events[Event]): () => void

Events 按程序解析:host 程序里是 host 事件全集,client 程序里是 client 编译面看得见的那些——同一个谓词在两侧各自成立,不需要把 host 声明拖进 client。

契约把消费动词与载体交接分开:消费方用 $on 订阅,持有 host 帧 sink 的一方用 $dispatch 把解码后的帧交进来。它不能是一个跨插件的模块级函数:client bundle 纯度门禁(packages/client/tsdown.client.ts)只放行隐式的 PLATFORM_MODULESPRELOADED_CLIENT_EXTERNALS 基座、包自身的 dsh.client.external 请求、INLINE_SAFE wire 层与 /remote 生成物值导入。靠 inline 绕过会把 ClientRemoteService 复制一份进 runtime bundle、令 instanceof 恒假。cordis 服务方法正是该门禁指定的协作形态:

$dispatch(event: string, args: readonly unknown[]): void

持有 host 帧 sink 的 client/runtime 直接调用它,帧不经中转事件即到达订阅表。event 形参是 string 而非 TypertRemoteEvent:这是 wire 边界,收到无人订阅的名字即静默丢弃。

投递语义与 cordis 事件系统不共用实现:只有单向投递,没有 waterfall / bail / parallel / serial 模式,也没有 @mode 概念(ReturnType extends void 是这条纪律的静态表达);不绑 this;没有 EventOptionsprepend、优先级;按注册顺序逐个调用,单个 listener 抛错就地隔离并记日志——它绝不能拖垮帧泵(沿用 ConnectionController 对 sink 异常的既有处置)。

名单:两个 face 共读的同一份声明

packages/api/remotes/src/remote-events.ts 同时列进 tsconfig.host.jsontsconfig.client.jsonfiles,是名单的唯一家src/types.ts 由它派生类型面:

// remote-events.ts — the value
export const API_REMOTE_FORWARDED_EVENTS = [
  'agent-preset/selected',
  'commands/change',
  'credentials/reference-updated',
  'llm/adapters-updated',
  'settings/document-updated',
] as const

// types.ts — the type face, derived
export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]

declare module '@deepseek-ai/dsh-typert-protocol' {
  interface TypertRemoteEventSelection extends Record<ApiRemoteForwardedEvent, true> {}
}

于是加一个事件只改这一行数组:类型投影、$on 的键面、host 的转发循环全部从它派生。ctx.remote.$on('slots/changed', …)(client 本地事件)或 $on('skills/change', …)(名单没开)都是编译错误

host 半再加一处形状断言,把 host 事件词汇的约束落到同一份名单上:

API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[]

写成表达式语句而不是命名常量:后者会被 noUnusedLocals 判为未使用(下划线前缀只豁免参数)。它卡住三件事:名字合法(谓词以 keyof Events 为基)、不绑 Scopegoal/changed 那族的 ThisParameterType 不是 unknown,被排除——「不依赖 AgentScope」的静态表达)、单向(非 void 返回的 waterfall/bail 形状被排除)。

「原样」不在任何地方证明,而是构造性成立$on 的 listener 类型取自 owner 包 ./types 里那一份 cordis Events 声明,host 转发读的是同一份,不存在可以彼此偏离的第二份声明。

载荷 JSON-safe 交给运行时:apiproxy 转发前用 dsh-sessionisJsonValue 逐元素校验,不合格抛错 fail loud(这是名单配置错误,不是外部输入)。

线协议(apiproxy)

| { type: 'host/remote-event'; event: string; args: JsonValue[] }

zod 侧 args: z.array(z.unknown()):帧本身来自 JSON.parse,元素必然已是 JSON 值,结构契约由 owner 包的 Events 声明承担——与既有 session/projection 帧的 value 同 posture。

events.host() 打开时按名单挂监听;每条流自持 disposers,无需新增广播集合或派生失效 listener。

api/events.ts 是浏览器侧也要编译的 wire 契约文件,所以它引用的每个类型都必须走 owner 包的 client-safe type-only 子路径,绝不能走包根出口。实证:从 @deepseek-ai/dsh-session 根引一个类型,就把根出口的 declare module 'cordis' { interface Context { sessions: SessionStore } } 拖进 client 编译面、把 client 的 ctx.sessions: ISessions 顶掉,在完全无关的 ui-input-trigger / ui-conversation 里炸出 18 条错。JsonValue 因此需要 dsh-session/src/types.ts 补一条 re-export。

apps/web 的 browser e2e 属于 Host 面

apps/web/tests/** 那批 e2e 在tsconfig.host.json 做类型检查:它们在进程内起真 harness、直接摸 ctx.apiProxy、host SessionStore.get/create/flushctx.sessionProjectionCache运行时用浏览器 ≠ 类型上属于 client 程序——把它们搬进 client 聚合会立刻报 21 条错,因为一个 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-protocolsrc/types.tsTypertForwardableEventTypertRemoteEventSelectionTypertRemoteEventTypertClientRemote$on$dispatch。纯类型,零运行时
api/gateway client 半ClientRemoteService 实现 $on(订阅按注册项寻址、ctx.effect 归属调用方 fiber)与 $dispatch(快照后按注册顺序派发,收容抛出或拒绝的 listener)
api/remotes新增 src/remote-events.ts(名单值)与 src/types.ts(类型投影 + 选择座位),两者都双列进两个 face 的 files./types 出口 + fileslib/types/**/*.js;host 半加形状断言并 import type {} 三个 owner 包的 ./types;client 半 export type {} 那三个 ./types@deepseek-ai/dsh-api-gateway/client
tsconfig.base.jsondsh-settings/typesdsh-credentials/typesdsh-api-remotes/types 三条 paths,全部指向平面
dsh-commands / dsh-settings / dsh-credentialsinterface Events 子块移入各自 client-safe 的 ./types(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;fileslib/types/**/*.js
host/apiproxyHostFramehost/remote-event、删除五个专用变体及其 zod;events.host() 按名单挂监听并通过 assertJsonArgs 校验
dsh-sessionsrc/types.tsexport type { JsonValue },让 wire 契约文件能走 client-safe 子路径
client/runtime五条 Client 事件桥分支收敛为 ctx.remote.$dispatch(frame.event, frame.args),并删除重复声明
5 个消费者ui-commands / ui-settings-models / ui-settings-general / ui-permission / ui-agent-preset 改订 ctx.remote.$on(...);照 ui-goal 先例 type-only 引 @deepseek-ai/dsh-api-remotes/client 并把 'remote' 加进 inject
client/connectionfixture 的 emitHosthost/remote-event
apps/web/tests + apps/cli客户端符号镜像(见上节);apps/cli/tsconfig.json 删 15 条 client 工程引用

备选方案

给 Remote 事件新开一条通用下行通道ctx.connection.rpc 的推送对偶,第三条 WebSocket)。最符合「Connection 独占载体、Gateway 不碰传输」;但要同时改 host 下行、WebApiClientConnectionController、fixture 与 web e2e 各一条流,代价与本次收益不匹配。寄生 host 流的代价是新契约暂时寄居在 legacy 帧联合里——host 流将来整体搬家时它随之搬走,消费端契约不变。

在 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.sessionsISessions。已否。

directory-picker-browse/-native 做 host/client 双 face 切分,从根上让客户端包不进 host 图。方向正确(它们确实是未切分的双半包),但改动落在别人属地,而收益只是「构建图更干净」——本设计在测试侧镜像客户端符号之后已经不需要它。已评估不做

验证

钉住该行为的东西:

  • 一个真组合测试:host 每 emit 一次,真实 host 流就出一帧 host/remote-eventevent 为 host 原名、args 与实参逐元素相等。
  • 类型层负例拒绝三类候选:不是事件的名字、绑 Scope 的事件(goal/changed)、返回值非 void 的事件。$on('slots/changed', …)(client 本地事件)与 $on('skills/change', …)(已声明但未选中)都编译失败——因此 $on 的键面恰好等于名单。
  • 消费端 $on('settings/document-updated', …)ns 解析为 SettingsNamespace:brand 穿过 wire 存活。
  • $on 的 disposer 归属调用方 fiber;同一个函数对象订阅两次时两条注册各自独立退订——按 listener 身份做键的表会把它们合并,所以订阅按注册项寻址。
  • 投递同时收容抛出的 listener 与拒绝所返回 promise 的 listener:声明返回值是 void,没人 await 异步 listener,其拒绝否则会完全逃出这层收容。投递遍历快照,因此派发中订阅或退订都不会改变本帧的接收者集合。
  • assertJsonArgs 直接单测,而不是从事件总线造畸形 emit:类型化的 ctx.emit 造不出来——名单内每条事件的载荷在静态上都是 JSON-safe 的。
  • 五个专用帧、五条 Client 别名及其桥分支都不存在;各消费方直接观察 owner 事件。

后果

  • 寄居在 legacy 帧联合里:契约住在 apiproxy 的 HostFrame 中,读者可能误以为 apiproxy 拥有 Remote 事件。该帧的 JSDoc 点名名单归 api-remotes,apiproxy README 在 known limitations 记录这项寄居。host 流将来整体搬家时,包裹帧随之搬走,消费端契约不变。
  • 两个文件打破了 api/remotes 的 face 互斥约定src/remote-events.tssrc/types.ts 同属两个工程,各自向共享的 lib/types 发射一份相同声明。内容逐字节相同、.tsbuildinfo 各自独立,实践上无害;README 的构建边界节陈述了这个例外及其成因(paths 指向源码面)。
  • 载体交接是开发者可见的:任何持有 ctx.remote 的 client 插件都能调 $dispatch 合成一条转发事件。这个暴露面早于该动词存在——先前由内部事件中转帧时,ctx.emit 同样可达——与 connection/reset 可被伪造成重连同一量级(client 是单一信任域)。测试只钉「交接到 $on 的转换」,不假装该端口鉴别调用方。
  • 畸形实参在发射方的收容里失败,而非加载期assertJsonArgs 在转发监听内抛出,因此由发射 seam 自己的 listener 收容记录并丢弃该帧——响亮地出现在 host 日志里,而不是加载时或 emit 点。
  • 测试侧镜像值可能漂移:没有任何机制核对 apps/web/tests 中镜像的 client 常量与其源;安全网只是漂移会让选择器失配。规则写在 apps/web/tests/README.md,由 review 守;grep 级门禁经评估后刻意不做。
  • 放弃的能力:不支持投影或脱敏载荷、不支持 Scope 化事件(agentCtx.remote.$on)、重连不重放——这些都是纯失效信号,且 connection/reset 已覆盖重连后的重新拉取。mux 流的会话事件、可应答帧与快照基线不在范围内。
  • 仍有 client 包留在 host 图里:12 个工程(connectionruntimeui-slots 等)经未拆分的 directory-picker-browse/-nativeapi/gateway → client/connection 仍可达 host 图。它们都能编译且不再牵连 api/remotes 的 client face,因此没有阻塞本次改动;拆分那些包能减少几个,但经评估后不做。两个 chat e2e 直接引 dsh-client-runtime/client 依赖 runtime 本来就在图里——属偶然而非保证。
  • invariant companion 不做运行期检查:早先的修订曾在活事件总线上断言投递形状(thisArg === nullmode === 'emit'),这让 companion 与名单值耦合,并使 rolldown 把它提成第三个 bundle chunk——而机械推导的发布文件清单并不携带它。host 面的 TypertForwardableEvent 断言在编译期已拒绝这两种偏离,因此该 companion 是一个带说明的空 installer。