`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean.
8.8 KiB
@deepseek-ai/dsh-host-apiproxy
English | 中文
The API gateway every client shape shares: the TS contract (src/api/, zero Node dependencies, importable from the browser), the fetch carrier pair (src/fetch/: toFetchHandler on the host side, AbstractApiClient plus platform subclasses on the client side), and the host-side implementation (src/api-proxy.ts: createApiProxy plus the default-exported ApiProxyService gateway plugin — config {provider, model, workspaceRoot?}, provides ctx.apiProxy). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap ctx.apiProxy themselves. The shipped core composition lives in apps/cli/base.cordis.yml.
Contract layer (/api)
Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: ClientRequest (POST /api/<method> body), ServerResponse (that POST's response body), ServerRequest (SSE frame), ClientResponse (POST /api/respond body). Responses always echo the matching request's rpcId and never mint a new one. Method parameter/return structures live only in the domain interface signatures (SessionsApi, HostApi, EventsApi); RpcMethodMap registers the methods and every other position derives via RequestPayload<K>/ResponseValue<K>. Zod schemas anchor satisfies z.ZodType<Wire<T>> and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride RpcResult's error branch (RpcErrorDetailsMap closes the code set); HTTP status expresses only the carrier. Every /api POST must declare the application/json media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind.
The layering/protocol decisions are recorded in the GUI layering and RPC protocol RFC; the browser-side consumption architecture in the web client architecture RFC.
session.history's tail page (beforeSeq absent) additionally carries an optional projections block — the watermark snapshot of every unit registered on ctx.sessionProjections (@deepseek-ai/dsh-session-projection), with asOfSeq = the last event seq the values reflect (-1 on an empty log). The gateway also subscribes to the registry's change feed and mints a session/projection mux frame per changed unit ({sessionId, key, value, seq} — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep values/value wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
Session titles ride the generic projection pair like every other domain — the history-tail projections block plus session/projection frames under the title key (the bespoke session/title frame is retired). Titles do not join session.list; cold sessions remain metadata-only there until opening or resuming attaches their logs.
Session model routing is a session-domain contract. session.models returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. session.selectModel validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns model-unavailable.
Workspace and Session lists are separate reconnect baselines. workspace.create creates a unique name or adopts an existing directory, workspace.delete removes only the Workspace registration, session.create accepts an optional preallocated Session id, and host/workspace-changed, host/workspace-removed, plus host/session-added carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in session.list and become Ungrouped. SessionSummary.blank and the host/session-added frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first host/session-status(running:true), and treat session.list as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of list().
Directory picking delegates to the composed ctx.directoryPicker backend (the directory-picker seam); a method called outside the composed capability's kind fails with directory-picker-unavailable (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under native, host.pickDirectory opens one native chooser and returns its selected path (null on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under browse, host.listDirectory returns one name-sorted directory level with breadcrumb ancestry, a home anchor, and host-owned hidden flags (absent path = home directory), and host.createDirectory creates one validated child segment; the backend's typed failures map 1:1 onto the directory-unreadable/directory-exists/directory-create-failed codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other /api request.
host.openPath opens a filesystem path with the operating system's default application (open on macOS, Invoke-Item on Windows, xdg-open on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as host.pickDirectory.
The command.* and skill.* domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by sessionId (a served session always has an Agent; command.* resumes cold sessions through the same path as session.*, while skill.list resolves the project root from the session header without touching the Agent registry). command.execute runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle commandId when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged command/run/command/done lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. host/commands-changed is the catalog invalidation frame: clients refetch command.list instead of diffing.
Carrier layer (/client + root)
AbstractApiClient holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (subscribeEnvelopes) — while platform subclasses supply only the doFetch transport aspect. InProcessApiClient over toFetchHandler(api) is the isomorphic point: the full wire serialization/validation path with no network, used by dsh -p headless.
Model Experience
None, as the package defines the client↔host wire contract and carriers; nothing here reaches a model request.
KV Cache effect
None; this package neither assembles nor sends a provider request.
Known Limitations and Deferred Work
respondrouting is shipped, but pending-interaction state is host-side work — the wire shape (POST/api/respond,RpcReceipt) is final; the pending table that makes late/duplicate answers meaningful lives insrc/api-proxy.tsand is still minimal (questions only, no approvals).- Reserved seams stay out of
RpcMethodMap—session.fork,prompt.mode: 'inject',task.list,host.listModels, and a describehostInstanceIdare documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - No protocol version field — client and host ship together;
host.describegains a version negotiation field only when an independently released client exists. - Linux native picker requires desktop tooling — under the
nativecapability,host.pickDirectoryreports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the native backend README).