apps/web/tests/README.md records why these e2e type-check in the Host aggregate and why importing a Client package there pulls its project tree into the Host build graph, with mirroring as the standing answer. The Agent Note drops the directory-picker face split (assessed and declined) and the grep-level gate in favour of that README. docs: regenerate the catalogs and retarget the moved declarations The forwarded-event change moved three owner packages' cordis `Events` declarations and their branded types into client-safe `./types` modules, and the settings-scope split moves the shell spec into ui-settings-general. Point the type-equivalence manifest and the affected Agent Note at those homes, register the new `remote/*` event scope and the `ctx.settingsScope` service in the catalog partition, and re-run the generators. `$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note fences that quote a bare member signature are marked `ignore-check`: they are declaration fragments, not compilable units. refactor(client): make ui-settings the settings domain's base layer The settings-namespace transport lived in client/runtime, where every feature could value-import it because runtime is a platform module. It belongs to the settings domain, but moving it into ui-settings as a shared function fails twice: the client bundle purity gate forbids cross-plugin value imports, and ui-settings reached ui-sidebar for its shell, so any feature depending on it closed a cycle through ui-layout and ui-theme. Both halves move. `ctx.settingsScope` is now a cordis service — the collaboration shape the purity gate prescribes, and the service proxy binds `this.ctx` to the caller, so a bound scope's disposer belongs to the calling fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its navigation, and the nav-row projection) moves to ui-settings-general, which already owns the chrome and the General section. What stays in ui-settings is what carries no `ui-*` dependency: the scope service and the canonical settings slot types, `settings.general.item` included. That type was parked in the locale package precisely because the declarer was unreachable without a cycle; every registrant now depends on this base layer, so it comes home. The scope CONTRACT stays in client/runtime: a feature service accepts a scope through its own signature without depending on the surface that binds it. The forwarded settings invalidation replaces the deleted client-side `settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches `$on` through the gateway's Client half plus the allowlist's type-only subpath rather than api-remotes' Client face: that face imports a Host-tsdown-generated artifact, and this package is reachable from the Host build graph through its callers. refactor(client): reach the settings transport through ctx.settingsScope Every feature that owns a preference row switches from value-importing a shared binder to the settings domain's service, and declares the two injections that binding needs: `settingsScope` for the transport and `remote` for the forwarded invalidation it subscribes to on the caller's own context. The rows stay with the features that own the preferences — Language with locale, Appearance with ui-theme, Composer Enter with ui-conversation. Only their route to the transport changes, so no settings surface moves and no feature gains a dependency on the shell. The `settings.general.item` slot type now arrives from ui-settings, the base layer every registrant already depends on, which retires the re-export outlet ui-theme kept and the parked declaration in the locale package. client/runtime drops its settings-form and schemastery dependencies with the transport that used them. test(client): bind the settings transport in the specs that boot a preference row Every bench that activates a plugin owning a preference row now supplies the two services that plugin injects: the forwarded-event port and the scope service. Specs that exercise no settings path get the minimal doubles; the ones that do drive their refresh chains through `remote/host-event`, the same signal client/runtime republishes from a forwarded frame, replacing the deleted client-side `settings/changed` event. Also fixes a publication defect the built-invariant gate catches once it runs: api-remotes' invariant companion shared the allowlist module with the package index, so rolldown hoisted it into a third chunk beside the two bundled entries — a file the mechanically derived publication list does not carry, leaving an installed companion unable to import it. The companion now reads the allowlist through this package's own published `./types` subpath, which the bundle keeps external, so each entry stays self-contained. The dynamic-subscription cast in apiproxy is gone: after the vendored cordis rescope, `on` accepts the rest-parameter handler directly, and the allowlist's shape assertion still carries the safety argument. fix(client): carry the settings-scope move across the release manifests Rebasing onto the publishable release set replaced every manifest's dependency block, so the packages this change touches restate their additions in the workspace-protocol form: the base layer's own transport dependencies, and the `ui-settings` plus `remote` edges each preference-row owner now needs. ui-settings-general takes clsx with the shell it received, and client/runtime drops the settings-form and schemastery dependencies that left with the transport. fix(api-gateway): give each $on subscription its own registration and containment Two defects in the forwarded-event subscription table, both raised in review: A set keyed on listener identity stored one entry when two callers subscribed the same function object to the same event, so the first frame reached it once instead of twice and either disposer silenced the surviving registration. Subscriptions are now records addressed by registration, which is what "the disposer belongs to the calling fiber" requires. A listener declared void may still be `async`, and the synchronous `try/catch` could not see its rejection: the promise was dropped and surfaced as an unhandled rejection outside the documented containment. Delivery now attaches a rejection handler when a listener returns a promise, so both failure modes are logged and isolated alike. Delivery also iterates a snapshot, so a listener that subscribes or disposes during a frame no longer changes who receives that frame, and production matches the TestRemote double instead of relying on live Set iteration order. Both fixes are pinned by tests that fail against the previous implementation. The double gains its own spec for the `$mount` refusal and the unsubscribed-name drop — per-file coverage reaches it — plus a note that it propagates a throwing listener where production contains one, so no spec mistakes it for the containment guarantee. Three prose corrections: `assertJsonArgs` states where its throw actually surfaces (the emitter's listener containment, not load or emit time), the browser e2e README names every standing Client import rather than claiming one exception, and two comments and a test title state the forwarded event instead of the deleted client-side one. refactor(remote): deliver forwarded frames through ctx.remote.$dispatch The carrier used to relay each decoded frame over an internal `remote/host-event` cordis event so the delivery port could stay off the Remote contract. The relay was the wrong shape twice over: it put a client-face event into a scan whose subject is the Host vocabulary, forcing a walk exemption for something that is not a Host event at all, and it made a direct handoff between two Client plugins look like a broadcast any plugin participates in. `TypeRTClientRemote` now carries both roles of one surface — consumers subscribe with `$on`, and whoever owns the Host frame sink hands frames over with `$dispatch` — so client/runtime calls the Remote service directly and the event declaration is gone. A cordis service method is the collaboration shape the client bundle purity gate prescribes, and it needs no relay to satisfy it. The trade is that the handoff is now developer-visible: any plugin holding `ctx.remote` can synthesize a forwarded event. That is the exposure the relay already had — `ctx.emit` was equally reachable — stated in the contract instead of hidden behind a private subscriber. runtime reaches `ctx.remote` through the gateway's Client face rather than api-remotes': that face imports a Host-tsdown-generated artifact, and this project sits in the Host build graph. refactor(api-remotes): keep the allowlist value out of types.ts `src/types.ts` carries only types by package convention, but it held the forwarded-event array, so the type-only subpath published runtime code. The array moves to `src/remote-events.ts` and `types.ts` derives its projection from it; both compiler faces list both files, so the Host forwarding loop and the consumer key face still read one declaration and the package's exports are unchanged. The invariant companion returns to an empty installer. Its dispatch-shape check was the only reason the companion imported the allowlist, which made the two bundled entries share a module: rolldown hoisted it into a third chunk that the mechanically derived publication list does not carry, so an installed companion could not import it. Dropping the check retires that coupling along with the subpath-import and bundle-external workarounds it needed, and the shape the check enforced at runtime is the part the Host face's `TypeRTForwardableEvent` assertion already refuses at compile time. test(ui-task): bind the locale plugin's new injections in its bench The bench boots the real locale plugin, which now injects the settings-scope service and the forwarded-event port, so it stayed pending and left `ctx.locale` undefined. Supplies both doubles like the other benches that boot a plugin owning a preference row. docs: close the documentation gates for the forwarded-event surface Regenerates the two graph catalogs and re-records every bilingual pair this branch edited. Several pairs needed real work beyond the record: - The generators write only the English side, so the Chinese sides of `event-producer-consumer` and `module-graph` had drifted: the former still listed the three deleted client-face events and pointed at declaration sites this branch moved into `types.ts` modules, and the latter carried a stale dependency graph. - `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides. - The pairing contract requires both sides to link the same target, so the apiproxy README and the design note now link the English note from both languages, and the note's code blocks are byte-identical across the pair (a translated comment inside a fence counts as divergence). - `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane documents a discipline reviewers apply, so it belongs in the bilingual corpus rather than in the pairing exemption list. - Four fences in the design note are marked `ignore-check`: each quotes a member signature, a union arm, or a snippet that names symbols it does not import, so none is a compilable unit. docs(agent-note): transition the forwarded-event note to implemented The design shipped in this PR, so the pair moves into `implemented/` and takes that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`, and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what pins the behavior) and `## Consequences` (what the shipped shape costs). Facts that moved after the proposal are corrected rather than preserved: the allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`, the delivery port is `$dispatch` rather than an internal cordis event, and the invariant companion is an explained empty installer. `Verification` states the two `$on` defects the review found — independent registration identity and async-rejection containment — since those are now the properties tests pin. Supersession is partial, so five active notes stay active and gain a cross-link each: `web-config-plane`, `web-client-session-scope`, `config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and `permission-default-for-new-sessions` each described a frame this change replaced. Only the mechanism sentence is annotated; every conclusion those notes own is untouched, and `host/models-changed` remains apiproxy's own derived frame in all of them. Also pins the disposer's idempotence: calling one `$on` disposer twice must not splice a surviving twin registration out from under its owner. fix: docs fix: test
22 KiB
Agent Note: Remote event delivery (ctx.remote.$on)
Status: implemented
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 pure invalidation events — "a registry changed, refetch it" — whose payloads are already JSON and whose emission never binds an AgentScope: commands/change, credentials/updated, settings/document-updated. Reaching one UI subscriber takes 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-emits it as a Client cordis event, and finally the consumer's ctx.on(...). Adding one such event edits five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side Events declaration), and not one of them states 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.tsholds the allowlist of forwardable Host events, and it is the single control point over what a consumer may subscribe to.src/types.tsbeside it derives the type projection and fills the selection seat, staying type-only per the package convention. Both files are listed in thefilesof 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 nohost/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:
HostFramegains one wrapper variant,host/remote-event. No new downlink. - Event signatures get no second table. Each owner package moves its cordis
Eventsdeclaration into its client-safe, type-only./typesexport, so both faces read the same declaration and$on's listener type isEvents[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 three packages needed that here: their entries reach only SettingsNamespace, SettingsUpdateSource, and CredentialRef, all pure types.
The three pure passthrough events ride this path, and their HostFrame variants are gone. Everything with derivation stays untouched: host/models-changed (a fan-in of llm/adapters-updated with provider/agent-default namespace filtering), 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-type-meta)
type-meta gains one shape predicate, one selection seat, and one member on TypeRTClientRemote. No runtime code:
/** 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 CLIENT_EXTERNALS, the INLINE_SAFE wire layer, and generated /remote contributions, and 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 = [
'commands/change',
'credentials/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-type-meta' {
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 is needed). The registration position is part of the contract: this block must sit before the settings/document-updated listener. Cordis fires in registration order, and host/models-changed is an invalidation frame derived from that same Host event; placing the forwarded frame after the derived one flips the relative order of two frames from one emit compared with the previous behavior (two config cases observe it).
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-slash 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-models, ui-permission, ui-command) 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
| Location | Change |
|---|---|
dsh-type-meta |
src/types.ts gains TypeRTForwardableEvent, TypeRTRemoteEventSelection, and TypeRTRemoteEvent; TypeRTClientRemote gains $on and $dispatch. Types only, no runtime |
api/gateway Client half |
ClientRemoteService 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/remotes |
New 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 three owner ./types; the Client half re-exports those three plus @deepseek-ai/dsh-api-gateway/client |
Root tsconfig.base.json |
Three paths entries (dsh-settings/types, dsh-credentials/types, dsh-api-remotes/types), all pointing at the source plane |
dsh-commands / dsh-settings / dsh-credentials |
The interface Events sub-block moves into each package's client-safe ./types (settings and credentials create that export, moving the brands and pure types with it; index keeps re-exporting them and keeps the constructors; files gains lib/types/**/*.js) |
host/apiproxy |
HostFrame gains host/remote-event and loses host/commands-changed/-settings-changed/-credentials-changed with their zod branches; events.host() subscribes by allowlist ahead of the settings/document-updated listener and validates through assertJsonArgs; that listener stays to keep feeding host/models-changed |
dsh-session |
src/types.ts re-exports JsonValue so wire contract files can use the client-safe subpath |
client/runtime |
The bridge's three ctx.emit branches collapse into ctx.remote.$dispatch(frame.event, frame.args), adding a remote injection; the Events merge drops commands/changed, settings/changed, and credentials/changed (models/changed stays) |
| Five consumers | ui-command / ui-models / ui-settings-general / ui-permission / ui-agent-preset subscribe through ctx.remote.$on(...), following ui-goal's precedent for the type-only facade import and the 'remote' injection |
client/connection |
The fixture's emitHost produces host/remote-event |
apps/web/tests + apps/cli |
Client 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 would cover models-changed's fan-in and workspace view derivation in one step, 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-eventframe on the real host stream per Host emit, witheventthe Host name andargsequal 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 notvoid.$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', …)resolvesnsasSettingsNamespace: 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. - For one emit, the forwarded frame and the invalidation frame derived from the same Host event keep the pre-change relative order (two config cases observe it).
assertJsonArgsis unit-tested directly rather than by driving a malformed emit through the event bus: a typedctx.emitcannot construct one, since every allowlisted event has a statically JSON-safe payload.- The three
HostFramevariants, the three Client-sideEventsdeclarations, and the three bridge branches are gone in the same change;host/models-changedbehavior is unchanged.
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 namesapi-remotesas 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.tsandsrc/types.tsbelong to both projects, so each emits an identical declaration into the sharedlib/types. Content is byte-identical and the.tsbuildinfofiles stay separate, so this is harmless in practice; the README's build-boundary section states the exception and its cause (thepathsentry points at source). - The carrier handoff is developer-visible. Any Client plugin holding
ctx.remotecan call$dispatchand synthesize a forwarded event. That exposure predates the verb —ctx.emitwas equally reachable while an internal event relayed the frame — and matches whatconnection/resetalready allows for a fabricated reconnect; the Client is one trust domain. Tests pin the handoff-to-$onconversion and do not pretend the port authenticates its caller. - A malformed argument fails in the emitter's containment, not at load.
assertJsonArgsthrows 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/testsagainst their source; the safety net is only that a drift misses a selector. The rule lives inapps/web/tests/README.mdand 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, andconnection/resetalready 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 unsplitdirectory-picker-browse/-nativepair andapi/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 importingdsh-client-runtime/clientrely onruntimealready 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'sTypeRTForwardableEventassertion already refuses both deviations at compile time, so the companion is an explained empty installer.