A hand-damaged preset was silent until the worst moment. An unparsable composition listed as an ordinary selectable row and failed only at the next session start — set as default, every new session failed. A directory whose composition file was deleted vanished from the roster while still occupying its id: copy answered "delete the existing preset first" while remove answered "not found", a dead end. Discovery now owns health: every id-shaped directory is a roster slot, broken when its composition is missing or unloadable, checked with the loader's own entryListSchema dialect (!!js included) so health never rejects what the loader accepts. `broken` rides AgentPreset, the agentPreset.list entry, and the UI row; mount/recompose/standingKeyFor refuse broken up front with the discovery-reported reason, while resolve/read/remove still answer. The section renders marked red cards — unselectable, uncopyable, deletable, location kept on custom rows — and both pickers drop broken rows entirely. The cordis preset's persona now forbids editing the shipped install (corrupting cordis would disable the mode itself) and points authoring at $DSH_HOME/.agent-presets; its skill teaches preset.yml metadata, the copy-first workflow, the one-escalation sandbox reality, and honest verification. Exercised live: asked to edit the shipped composition the composed agent refuses citing both rules; asked for real presets (simple and complex) it lands them under the user root with one approved escalation each and self-checks with the loader dialect.
26 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, reasoningEffort?, workspaceRoot?}, provides ctx.apiProxy). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap ctx.apiProxy themselves. The shipped core composition lives in packages/bundle/base/cordis.patch.yml.
The default route (api-gateway settings section)
{provider, model, reasoningEffort?} is also the gateway's user-settings section, registered under api-gateway: the composition entry is the base layer and settings.yaml layers the user's own choice over it. workspaceRoot is deliberately outside the section — a launcher fact, not a preference.
A session resolves its route from three tiers, re-read on every access rather than seeded once: a selection made in this process, else the session's own latest logged request/header, else this default. Re-reading is what makes both directions hold — a session that has run a turn derives its route from its log forever after, so changing the default never retargets it, while a session still blank (New Session reuses one rather than minting another) starts from a default saved after it was created.
session.selectModel records an accepted switch as the new default, which is how the default is chosen in practice: there is no separate gesture. What it stores is the RESOLVED target, so an adapter-materialized default effort is pinned as the user saw it and a later adapter-default change does not silently move stored defaults. The write replaces the section wholesale rather than merging, because switching to a model with no reasoning effort has to clear a stored one; a storage failure is logged without undoing the switch, which already applies to its own session. A deployment with no settings provider keeps the composition entry and a switch stays process-local.
The section's reasoningEffort has no counterpart in the plugin config, deliberately: the seam merges the user layer over the composition entry per field, so an absent key cannot override a present one and a composition-set effort would survive every later switch to a model without one. A deployment default for effort belongs on the adapter profile, which resolves per model.
The stored route is not validated against the registry, in either direction. A default naming a route the Models page has since removed still reaches session.models as the session's current — matching no advertised group, which is precisely what makes a selector prompt for a replacement instead of naming a model the deployment cannot reach. Repairing it silently would also break the deliberate converse: an adapter may serve a model its catalog does not advertise.
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.
Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in selected and non-empty custom text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as bad-response.
session.history reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. maxMessages counts user/message and assistant/message events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
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.rename accepts an explicit user title (resuming a cold session first), delegating to ctx.sessionTitle.rename — the accepted session/title event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its title projection cell ahead of the push frame; a title that normalizes to empty returns title-invalid.
session.fork maps an optional event anchor to the first turn/end at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns fork-unavailable rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged provider/model/reasoning target, and lineage before joining the source Workspace. If Workspace attachment fails, workspace-attach-failed carries the already-published child id so clients can reconcile it. The SessionStore fork decision owns the boundary rationale.
Session model routing is a session-domain contract. session.models returns the selected provider/model/reasoning target separately from provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. The current target may be absent from the groups and is never injected as a synthetic row; clients can prompt for a replacement without turning the directory into a routing whitelist. 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. session.models additionally reports routable: whether an adapter currently serves the current target's route, which is deliberately NOT derivable from the groups — a route serving a model it stopped advertising is absent from them yet perfectly usable, while a route whose adapter is gone can serve nothing. session.prompt refuses on that same fact with model-unavailable rather than spending the pre-step path to fail inside an adapter; a client that disables its composer is an affordance, and this method stays callable regardless.
Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete next-turn queue from durable agent/inbox/spliced mutations and broadcasts authoritative session/queue snapshots after each change and on reconnect; pending next-step steering stays outside this Web projection. Within next-step, user-origin messages carry the steering placement while injected context (approval notices, task completion, attached snapshots) carries context and is not surfaced until claimed. The message-local agent/inbox/inserted, claimed, and discarded notifications remain available to lifecycle observers but do not build the queue view. session.updateQueue addresses one MessageId; edit and remove mutate the attached Agent through Inbox.splice(). A claim's pure deletion splice wins races before pre-step admission, so a later operation returns queue-item-not-found. session.cancel aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events.
Workspace and Session lists are separate reconnect baselines. workspace.create({ name }) creates a uniquely titled directory under the configured root, while workspace.create({ path }) adopts an existing canonical directory and permits basename-derived titles to repeat. 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. workspace.archiveSession adds one session to the registry-global archive set and answers the full updated set; workspace.list carries that set as the reconnect baseline and host/archived-sessions-changed pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with session-not-found. 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().
session.search is a bounded content-search projection over the sessions visible through session.list. The gateway asks the optional ctx.sessionQuery service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
A stale continuation discards every partial result, deduplication entry, and cursor from that provider attempt, then restarts at the first page against the original list-derived visibility snapshot without discarding the learned provider page size. Limit probes and stale retries share the same limit of at most 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an internal business error. The carrier request signal cancels persistence listing, cold-summary collection, and every search call, including a limit or stale rejection observed concurrently with cancellation. A deployment without the service, or any unrecovered index/query failure, also returns an internal business error so clients can retain metadata-only matches.
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 does not use the default 30-second unary timeout, while 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, and xdg-open on desktop Linux). For .html, .htm, .xhtml, and .svg, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through wslpath -w and hands the resulting Windows/UNC path to Windows Invoke-Item, including browser-renderable documents, instead of assuming a Linux desktop association. The browser carrier applies the same loopback, same-origin restriction as host.pickDirectory.
The agentPreset.list domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its trust (a user preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a broken reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. agentPreset.select recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers agent-preset-locked. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one.
agentPreset.read, copy, openDocument, and remove manage the compositions themselves. read reports the text with its trust, for the read-only viewer. Authoring is copy-only: copy takes { from, agentPreset, name? } — two ids the Host resolves against its own roots plus an optional display name — and copies the source's whole directory, so no composition text crosses the wire and a copy is exactly as loadable as its source; an uncontainable or already-taken id answers agent-preset-invalid, and remove refuses a shipped preset as agent-preset-read-only. openDocument hands one locally authored preset's DIRECTORY to the platform opener — the request carries an id, never a path, so no browser payload can select an arbitrary filesystem target; where the deployment has no native opener the reply is { opened: false, path } for the surface to show as text, a shipped preset is refused like remove, and the gateway's nativeOpen config pins the capability where platform detection (canOpenNativePath) would mislead. These four are loopback-pinned in dsh-client-connection: a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop. list and select stay ordinary — the roster carries ids and trust and every preset picker needs it, and choosing a preset grants nothing session.create's own agentPreset did not, over a default that already carries bash. list reports two path-free capability flags: authorable, whether the deployment configures a root a new preset could be copied to, and hasDocument, whether openDocument would open natively rather than answer a path.
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). skill.list serves the composer's menu: it returns every user-invocable skill with its modelInvocable flag, so menus can mark user-only (disable-model-invocation) entries whose only entry point the slash gesture is. Listing is the skill domain's only RPC — invocation itself is an ordinary session.prompt whose whitespace-bounded /name tokens dsh-tool-skill recognizes at the pre-step boundary and answers with injected <skill_content> context, so every front end (web, TUI, ACP, hand-typed text) shares one deterministic path with no dedicated invocation wire. 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. Command handlers may legitimately outlast the 30-second transport health deadline, so command.execute carries only caller/connection cancellation; that signal cancels the running handler. host/commands-changed is the catalog invalidation frame: clients refetch command.list instead of diffing.
The settings.*, credentials.*, and llm.* domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (ctx.llm.listConfigurableProviders()) plus a small explicit allowlist — the Web preference permission and the product-owned ui-onboarding; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers settings-not-exposed — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. settings.describe returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/base/user — a field's presence in user marks it user-overridden), the secrets slot list, the section's revision, and the boolean hasDocument capability flag. The browser receives no Host path: pathless settings.openDocument asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select an arbitrary filesystem target. settings.update/settings.replace write the user layer; settings.mutate applies path ops (set/unset) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry expectedRevision; a stale one answers settings-conflict with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into settings-rejected. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an update/mutate payload or credentials.set. credentials.describe returns value-free views (configured/source/writable), and credentials.set/credentials.unset map a shadowed-reference refusal onto credential-rejected. llm.providers merges the configurable-provider directory with live routes (dormant entries carry active: false; undeclared live routes append with no settings address) and llm.models is the session-independent catalog. llm.discoverModels interrogates a provider endpoint the page is still drafting: settingsNs selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later settings.mutate decides what a route serves — so its apiKey is the third payload on which a secret may ride, alongside settings.update/mutate and credentials.set. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which subscribeEnvelopes() observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into model-discovery-failed, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. Three invalidation frames keep every surface converged without polling: host/settings-changed {ns} (settings/document-updated passthrough, so a raw change whose resolved value is unchanged still reaches clients), host/credentials-changed {ref} (reference names only, never values), and host/models-changed — fired by llm/adapters-updated and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a permission or ui-onboarding change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (settings.describe/openDocument/update/replace/mutate, credentials.describe/set/unset), to loopback same-origin requests — the host.pickDirectory privileged set. A composition without a settings or credential provider answers those domains with an actionable internal error naming the missing plugin.
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 run 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
- Pending-interaction state is host-side — the wire shape is POST
/api/respondplusRpcReceipt; the table insrc/api-proxy.tshandles questions only and has no approval entries. - Reserved seams stay out of
RpcMethodMap—prompt.mode: 'inject',task.list, and a describehostInstanceIdare documented reservations; model discovery usesllm.models. 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. - Search failures include provider diagnostics — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
- 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). - A cold session's
updatedAtcounts a mere pickup as a write (per-file backends only) — the attached projection excludes thesession/end-seedboundary, because picking a session up is not activity, but a cold session'supdatedAtis its log file's mtime and every durable write refreshes that, the boundary included.agentFor()resumes a cold session on first touch, so merely opening one in a client writes it. This applies only wherelocate()resolves a per-session artifact, i.e. JSONL; SQLite returnsundefined, so its cold sessions fall back tocreatedAtand are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the last-activity-index Agent Note.