Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier
Status: implemented
English | 中文
Division of labor: this document = the layering model + the channel-independent RPC protocol; the protocol's Web implementation combines HTTP uplink with the WebSocket downlink carrier, while the browser object layer is in the web client architecture note.
We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities:
- One
dshprocess supporting bothdsh web(serve) anddsh --profile headless(headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technologies as
dsh web
That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly.
At the same time the physical channels differ per consumer (browser HTTP/WebSocket, in-process fetch/SSE, IPC later), so we also need a channel-independent message model and a single contract source of truth — "adding a method" and "swapping a carrier" must not entangle each other, and every message on the wire must be type-validatable, observable, and reconcilable.
Directories layer as follows:
packages/host/*: packages provide host-side capability only (representing the Node.js engineering core built on the existing harness plugin system), and additionally- the unified backend protocol (fetch, HTTP, streaming interfaces…) — definitions and support, see the "Message protocol" sections below
packages/client/*: packages provide client-side capability only; every package stays single-sided. Three kinds live here (the axes are owned by the client plugin loading note):- Pure libraries (
ui-slots,web-react,ui-primitives, plus theloaderkernel package): ordinary root-index packages, statically bundled into the shell; the first three are seeded into the module table. - Static-arrival entry packages (
connection,runtime,ui-theme,i18n,hmr): nodsh.clientkey and no browser bundle — the shell bundles theirsrc/client/half and registers it withctx.modules; they are governed as entries of the host-authored graph like everything else. - Fetch-arrival plugin packages (
ui-layout,ui-sidebar,ui-conversation,ui-trajectory): dual-entry — the root index is the node half (an emptyapply, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.jsondsh.clientdeclaration); the implementation lives undersrc/client/, shipped as the./clientsubpath (a tsdown closure-factory bundle). Cross-plugin consumption of/clientis type-only; value cooperation goes through cordis services.
- Pure libraries (
apps/holds the externally exported applications, assembled from Client / Host mixtures.apps/web(dsh-web-frontend) is the vite application: a thinmain.tsover the shell API exported bydsh-client-web.apps/cli(@deepseek-ai/dsh) dispatches commands:dsh web= Host + webserver + the builtdsh-web-frontenddist;dsh --profile headless= a direct core Agent/Session entry point, with zero Host, HTTP, or browser layer.- A future Electron application reuses the same web client packages over an IPC fetch carrier.
apps/* (applications: apps/web = vite app, apps/cli = bin dispatch)
│ consume
▼
packages/host/* packages/client/*
apiproxy front layer: protocol pure libs: ui-slots / web-react / ui-primitives
runtime assembly / host entity dsh.client plugins ×8 (node half = empty apply,
webserver Web HTTP carriage client half = src/client/)
│ ctx.plugin(...) ▲ import only apiproxy's /api /client subpaths
▼ │ (type-only + the client base class)
harness core packages ──────────────────┘ (types reach the browser via import type)
Direction discipline (every rule auditable from package deps):
runtime → apiproxyis one-way; apiproxy depends only on type definitions.- Client-side packages never import host-side package runtime (they consume only the two browser-safe subpaths
/apiand/client). webserverdoes not depend onruntime: it provides an implementation of the{ fetch }interface — "webserver ← runtime" is a runtime injection relationship, not a package dependency.- Cross-package client imports use the
/clientsubpath for plugin packages, and between plugin packages they are type-only — a cross-plugin value import is a build error at the tsdown purity gate (value cooperation goes through cordis services; the client plugin loading note owns the edge rules).
TypeScript checks in two aggregate programs referenced by a solution root (tsconfig.json = solution; tsconfig.host.json = host side + tests, excluding packages/client; tsconfig.client.json = client packages and their tests): both sides merge the cordis Context interface under the same keys (sessions, loader) with different services, so one program would see both declaration merges and report a collision. Shared leaves (session/llm/tools/apiproxy…) build once and are referenced by both programs (topology).
On the protocol side: TS interfaces (packages/host/apiproxy/src/api/, zero Node dependencies, browser-importable); wire messages unify under a bidirectional model — each logical message is classified by "who initiates × request/response" (two axes, four cells, called the four quadrants below), decoupled from the physical channel; clients all inherit AbstractApiClient (protocol invariants live entirely in the base class, platform differences are just the doFetch transport aspect).
| Layer | Package | Responsibility | Key discipline |
|---|---|---|---|
| Front layer | dsh-host-apiproxy |
TS/zod definitions (api/) + the fetch abstraction (fetch/: handler + client base class) | Keep it simple — every consumer needs it; importable from Node and browser alike; protocol content in the "Message protocol" sections below; clients must not bypass api through ctx |
| Assembly layer | dsh-host-runtime |
Plugin composition + ApiProxy integration + the web UI plugin mount (in-memory Loader tree over the eight dsh.client packages); home of host-level configuration (defaults/persistenceRoot, future user profile) | Which plugins mount and with what defaults is decided only here; shells must not alter the assembly |
| Carrier layer | dsh-host-webserver |
Web HTTP and upgrade: static serving + /api/*→handler forwarding + WebSocket upgrade route + close semantics; plugin bundle endpoint + __DSH_BOOT__ manifest injection (fed by the web plugin registry) |
Web (browser access) only; zero workspace dependencies (the registry arrives by structural injection); Electron does not reuse it |
| Client libraries | dsh-client-ui-slots / dsh-client-web-react / dsh-client-ui-primitives |
Slot registry core / ctx↔React glue / pure React atoms | Zero cordis runtime dependency in components; seeded into the loader module table by the shell |
| Client plugins | dsh-client-connection / dsh-client-runtime / dsh-client-ui-theme / dsh-client-i18n / dsh-client-ui-layout / dsh-client-ui-sidebar / dsh-client-ui-conversation / dsh-client-ui-trajectory |
Browser-side cordis plugin tree (wire consumer, core services, theme, i18n, layout, sidebar, conversation, trajectory) — see the web client architecture note | Dual entry (node half = empty apply; implementation in src/client/); the consumption face goes exclusively through ApiProxy |
| Application | @deepseek-ai/dsh (apps/cli) + dsh-web-frontend (apps/web, the vite application) |
Coarse bin dispatch + one assembly module per application (web.ts / headless.ts); the vite app is a thin main over the dsh-client-web shell surface |
Applications use dynamic imports so they never load each other; workspace knowledge like dist location stays in the app |
Packages under packages/host/* and packages/client/* must carry the directory-group prefix in the package name: host/runtime → dsh-host-runtime, client/runtime → dsh-client-runtime. The directory name does not repeat the group prefix (host/ already expresses it). The package-name tail therefore ≠ the directory name, so the dsh-* wildcard in tsconfig.base.json (which resolves by directory name) misses them — each package in these two groups needs an explicit paths entry, including separate entries for the client packages' /client subpaths so source-level resolution matches the exports map.
- Pick a fetch impersonation: browser same-origin HTTP / in-process
host.handler.fetchinjection / your own transport-aspect subclass (e.g. future Electron IPC, see the "Subclass table" below). - Write an assembly module under
apps/:startHost()+ a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. - Import
dsh-host-webserveronly if you need HTTP carriage, otherwise zero ports.
The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while dsh --profile headless mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via ctx.plugin(entry-point plugin) without fetch.
The sections from here down are the protocol body carried by the front layer (dsh-host-apiproxy). The wire has exactly four message kinds (the four quadrants) — the Web carriage in the right column is only an example; swapping the carrier (in-process/IPC) leaves the quadrants unchanged:
client 发起 server 发起
request ① ClientRequest ③ ServerRequest
(POST /api/<method> body) (WebSocket message:session 事件、审批/问答 requested)
response ② ServerResponse ④ ClientResponse
(该 POST 的 HTTP 应答体) (POST /api/respond body,回填 ③ 的 rpcId)
| Type | Discriminant tag | Fields | rpcId ownership | Web carriage |
|---|---|---|---|---|
ClientRequest |
'client-request' |
rpcId method payload |
client mints | POST /api/<method> body |
ServerResponse |
'server-response' |
rpcId result |
echoes ① | that POST's response body (always HTTP 200) |
ServerRequest |
'server-request' |
rpcId method payload |
server mints | WebSocket text message |
ClientResponse |
'client-response' |
rpcId result |
echoes ③ | POST /api/respond body |
RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse, narrowed via switch (message.type).
rpcId discipline (RpcId is a branded string with constructor RpcId()):
- Whoever initiates mints; a response always echoes the corresponding request's rpcId and never mints a new id.
- server-requests split into two kinds, distinguished statically by
method(= the frame type), with no third kind: answerable frames (approval/requested,question/requested) carry a stable logical request id (minted once on acceptance, reused verbatim on baseline replay, echoed by the client's answer); pure-push frames (session/eventetc.) carry an rpcId identifying that one push (freshly minted each time). - Business code never mints: unary minting funnels into the client base class
callUnary, frame minting funnels into the host side.
Domain interface signatures perceive only the narrow forms: RpcRequest<P> = { rpcId, payload }, RpcResponse<T> = { rpcId, result: RpcResult<T> }. The carrier layer completes narrow forms into full forms (adding the type tag and method); direction is never inferred from the channel. RpcResult<T> = { ok: true; value } | { ok: false; error: RpcError } — methods do not throw business errors.
The HTTP response body of a ClientResponse is RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' } — a carrier-layer receipt, not an RpcMessage (a response has no response); late/duplicate answers get not-pending, and the logical convergence point is the */resolved frames.
Method parameter/return structures live only in the interface method signatures; the map registers the methods themselves; every other position (handler, client, store, tests) references the derived generics — copying literals or introducing flat named types is banned:
export interface RpcMethodMap {
'session.list': SessionsApi['list'] // map key 即 wire 路径段
// …其余方法同形登记,全集见 api/rpc-map.ts
}
// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束)
export type RequestPayload<K> = Parameters<RpcMethodMap[K]>[0]['payload']
export type ResponseValue<K> =
Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : neverStream methods (events.mux/events.host) stay out of the map (not unary); respond stays out of the map (it is a client-response, not a method call).
One example row of an error code:
| code | details | when |
|---|---|---|
bad-request |
{ issues: ZodIssue[] } |
wire/payload zod validation failed |
The full code set is RpcErrorDetailsMap in api/rpc.ts. RpcError is the distributive union expanded from the map: code discriminates, details narrows automatically after a switch; details is required — a new code = one map row + one error-schema branch, and omission is a compile error. Transport failures (network down, host not up) are thrown by the carrier as exceptions; the two layers never mix.
- Two-level parse: the full-form schema once (type/rpcId/method structure + the handler checking path==method) → the business payload dispatched by method/frame type for a second parse; rejection =
bad-request. - Anchoring: schemas uniformly
satisfies z.ZodType<Wire<T>>(api/rpc.schema.ts).Wire<T>is a deep "| undefined" widening — the repo enablesexactOptionalPropertyTypeswhile zod.optional()outputsT | undefined, so anchoring the original type is unusable across the board; on the JSON wire, absence and undefined are indistinguishable, so the widening loses no validation semantics. Passthrough wide branches (SessionEvent/ContentBlock/frame unions/RpcError) and brand-id schemas use explicit casts with comments. - Brand casts have one point each: every schema file funnels its id cast into one place (
rpcIdSchemais the only cast point in rpc.schema.ts).
The root interface is ApiProxy = { sessions, host, events, respond } (api/index.ts). A new client-request domain = one new file pair (<domain>.ts + <domain>.schema.ts) + one root-interface field + one map row.
One example row (the table structure is the reading key):
| method key | request payload | return value | semantics |
|---|---|---|---|
session.list |
{ cursor?: string } (cursor is a reserved seat, unimplemented) |
{ items: SessionSummary[] } |
persisted sessions, updatedAt descending; v1 builds no index |
The remaining methods (session.create/session.history/session.rename/session.prompt/session.cancel/host.describe) are not re-copied here — signatures are the source of truth; see api/sessions.ts, api/host.ts, and RpcMethodMap.
Two logical streams: the mux stream (/api/events.mux, all-session aggregate) and the host stream (/api/events.host, host-level events). The browser consumes one downlink WebSocket per stream, while the in-process fetch carrier retains SSE with the same event framing; see the WebSocket downlink carrier for the physical boundary. One example frame row:
| frame type | payload | when |
|---|---|---|
session/event |
{ sessionId; event: SessionEvent } |
core passthrough: core events pass verbatim, assistant/chunk IS the token stream, no separate delta frame |
The remaining frame types are not re-copied here; the full unions are MuxFrame/HostFrame in api/events.ts. Three semantic points to know: session/subscribed carries lastSeq for history-race detection; the approval/question requested frames are answerable (stable rpcId) and the resolved frames are the convergence surface; host/agent-error is the only outlet for live failures with no turn position.
Passthrough discipline: events/messages/content blocks on the wire ARE the core types (SessionEvent/ContentBlock) — no second DTO set; types reach the browser through the import type dependency chain. SessionEventMap is merge-extensible: the client applies its documented default (ignore) to unknown types, and the event schema keeps a "valid envelope + unknown type" branch — the envelope stays strict; this is not field-level passthrough.
- History = event replay: one fold (client side); history pagination and live increments share one code path; the server maintains no second materialized-snapshot system. History page boundaries align to message boundaries (never cut mid-message; chunks group with their finalized message), and the tail page includes the in-flight partial's chunks.
- Prompt correlation: the prompt's rpcId rides MessageSource (
'user-rpc') into theuser/messageevent; the client uses it to promote the optimistic echo. - Reconnect = rebuild: no resume cursor (
mux'ssincesignature is a reserved seat, ignored if passed); on disconnect reopen the stream + refetch history; comparesubscribed.lastSeqwith the history tail seq and backfill once if there is a gap. - Cold session handling follows ownership:
session.historyand the source read forsession.forkinspect persistence without an Agent, while Agent-bound ordinary-session methods such aspromptresume through a deduplicated in-flight table. Session-backed subagents reject that generic resume path, and attachment status is not exposed to clients (runningalready covers it). - Approvals/questions: the requested frame mints a stable rpcId on acceptance; first answer wins, and the host's in-memory pending table (keyed by rpcId) is the only referee; after a mux reopen, still-pending requested frames replay after the subscribed frame (rpcId reused verbatim — refresh recovery). The audit events
approval/asked/decidedcontinue through the durable log — frames = the live control plane, events = the durable audit. Status: the contract and frame types are shipped; the host-side pending table/wire answerer is unimplemented (respondinapi-proxy.tsis a stub, alwaysnot-pending); PendingCard v1 is display-only. - No protocol version: client and host release bound together;
host.describehas no protocolVersion field; introduce one when an independently released client appears. - Reserved-method discipline: the map holds only implemented methods; an unknown method fails loud at envelope parse (
bad-request) — no not-implemented fallback code. The reservation list (implementing = copy the signature into the domain interface + add the map row + add the schema pair):session.fork,prompt.modegaining'inject',task.list,host.listModels, describe gaininghostInstanceId. (session.renamegraduated from this list: it appends a user-sourcesession/titleevent.)
Protocol invariants live in the base class; platform differences are two aspects: the abstract method doFetch(url, init) (transport) + the overridable onEnvelope (observation).
The same domain tree as ApiProxy, but unary methods take the business payload directly — the carrier mints the rpcId and wraps the envelope; business code never mints, and code needing this call's rpcId reads it from the returned RpcResponse echo. ApiProxy is the narrow-form signature contract the impl side implements; IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two. Methods derive per key from RpcMethodMap — a map row addition updates them mechanically.
| Path | Content |
|---|---|
callUnary |
mint → tap → POST full form → serverResponseSchema parse → rpcId echo check (mismatch throws) → tap → emit narrow form |
readSse |
streaming fetch (not EventSource), \n\n framing, data: concatenation, ServerRequest full-form parse, tap, emit narrow RpcRequest<frame> |
respond |
client-response passthrough (rpcId is an echo — never minted here); response body parsed by rpcReceiptSchema |
| unary deadline | Ordinary unary calls use AbortSignal.timeout (default 30s, constructor-tunable); user-paced host.pickDirectory and command.execute omit that deadline but keep caller/connection cancellation; streams have no deadline |
resolveBase |
browser = same-origin origin; no-location environment (Node) = the http://dsh.internal fake authority |
All four quadrant full forms pass through onEnvelope; the base implementation is an instance-owned microtask-batched buffer (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via subscribeEnvelopes(listener) (receiving whole batches as readonly RpcMessage[], returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier).
| Subclass | Package | doFetch | Purpose |
|---|---|---|---|
InProcessApiClient |
apiproxy itself | the injected { fetch } handler |
The isomorphic point: new InProcessApiClient(toFetchHandler(api)) never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product dsh --profile headless drives core directly |
WebApiClient |
dsh-client-connection | globalThis.fetch uplink + one same-origin WebSocket downlink per logical stream |
the browser client; physical boundary in the WebSocket downlink carrier |
FixtureApiClient |
dsh-client-connection | unused (protocol-layer override) | serverless UI development (?fixture): overrides the callUnary/openMux/openHost/respond virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) |
| IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged |
Add a unary method (5 steps): ① add the method signature to the domain interface (parameters/return inline — this is the single source of truth); ② add one RpcMethodMap row; ③ add the request/value schema pair in <domain>.schema.ts (anchored Wire<RequestPayload<'…'>>); ④ add one handler UNARY_ROUTES row (the handler's Web carriage is in the web client architecture note); ⑤ implement in the impl (echo request.rpcId). On the client side, add the passthrough row to the IApiClient/AbstractApiClient domain method tables.
Add a frame type (3 steps): ① add a branch to the MuxFrame/HostFrame union (answerable frames must note the stable-rpcId semantics); ② add a frame-schema branch; ③ the consumers' fold/routing documented-default already covers unknown types — add an explicit branch as needed.
Add an error code (2 steps): ① add one RpcErrorDetailsMap row (details required); ② add one rpcErrorSchema discriminatedUnion branch.
Plug in a new carrier: subclass AbstractApiClient implementing only doFetch; to intercept at the protocol layer (like the fixture), override the callUnary/openMux/openHost virtuals instead. Contract and base class stay unchanged.
Promote a reserved method: copy the reserved signature into the domain interface → add the map row → add the schema pair → add the UNARY_ROUTES row → implement.
Every client consumes one contract: adding a unary method is a five-step mechanical change from a single signature, swapping a carrier touches only a doFetch subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while host.pickDirectory and command.execute may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved methods (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
| Rejected | One-line reason |
|---|---|
| Packaging by product (a web family, an electron family) | Products share host/client capabilities rather than an application implementation; capability-provider layering means a new application needs zero new packages |
| A package per mixture (e.g. a standalone headless package) | A mixture has exactly one consumer (its own app); packaging it is ownerless abstraction, while assembly in the app is readable and disposable |
| Consuming clients connecting to ctx directly (skipping the apiproxy layer) | Clients require wire validation, observability, and multi-client consistency. Direct headless is a local entry point with no client boundary and uses the public Agent/Session seams rather than a client command plane |
| webserver depending on runtime (saving the handler injection) | Structural-typing injection keeps webserver reusable by sidecars/tests with zero workspace deps; a package dependency would drag assembly knowledge into the carrier layer |
| Package names without the group prefix (continuing dsh-) | dsh-runtime/dsh-web-ui lose their belonging in the flat npm namespace; the cost is one explicit paths entry per package |
| Reusing the in-repo JSON-RPC 2.0 (dsh-sdk-jsonrpc-server) | Numeric error codes degrade to a single fallback code, contracts get aligned by hand in two copies, and naming drifts without a convention |
| A three-envelope model (Request/Response/Frame envelopes, signatures direction-blind) | rpcId correlation is logical-layer; frame and response direction semantics inferred from the channel break the moment the carrier changes |
| Named Request/Response type pairs as the source of truth (map registering type pairs) | Flat named types are a second name for the same fact; signature inference makes adding a method a one-place change |
| REST-style paths | The consumer is our own client with no third-party REST expectations; RPC mapping straight onto the method table is more mechanical |
| A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax |
| Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer |
| A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope |
Applying the 30-second transport deadline to command.execute |
Command duration is operation work, not a transport-health budget; the deadline kills valid long-running handlers, while caller/connection cancellation already supplies the required stop path |