Files
deepseek-harness/packages/host/apiproxy
imccyu 01ecb43ebc docs: state the Host-face rule for the browser e2e and settle the follow-ups
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
2026-08-11 19:25:41 +08:00
..

@deepseek-ai/dsh-host-apiproxy

English | 中文

The API gateway shared by every client consists of the TypeScript API 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 {nativeOpen?, sessionExportCompressionLevel?}, provides ctx.apiProxy). This package registers no routes; carriers such as HTTP wrap ctx.apiProxy themselves. The shipped Web composition lives in packages/bundle/web-app/cordis.patch.yml, while its default Agent model selection belongs to @deepseek-ai/dsh-agent-default-model in the base bundle.

The shared Agent default (agent-default-model Settings section)

ApiProxyService consumes ctx.agentDefaultModel; it does not own a provider/model config or settings section. The shared service registers {provider, model, reasoningEffort?} under agent-default-model: the base bundle's composition entry is the lower layer and settings.yaml layers the user's choice over it.

A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged request/header, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created.

session.selectModel saves an accepted switch as the deployment default; there is no separate gesture. It stores the resolved ModelSelection, including an adapter-materialized default effort. The complete-section write clears a stored effort when the selected model has none. A storage failure is logged without undoing the session selection. A deployment with no settings provider keeps the composition entry and the switch remains session-local.

The section's reasoningEffort has no counterpart in the agent-default-model 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 selection is independent of catalog membership. A default naming an unavailable provider still reaches session.models as the session's current, allowing the selector to request a replacement instead of silently choosing another model. Conversely, an adapter may serve a model that 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 compact/summary record 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-log export is a host-only download surface, not an RPC: GET /api/session.export?sessionId=…&includeDescendants=true streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's readRaw — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under subagents/<id>/, and every image any included log references under media/<attachmentId>.<ext> (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative SessionStore.flush durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated sessionExportCompressionLevel 09 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; ApiProxy.downloads.sessionLog implements it.

Session titles ride the generic projection pair like every other domain — the history-tail projections block plus session/projection frames under the title key. 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 ModelSelection, 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 records why the anchor maps to that turn/end.

Session model selection is a session-domain contract. session.models returns the current ModelSelection separately from provider-grouped advisory models, exact-model reasoning metadata, and provider-local lookup failures. The selection may be absent from the groups and is never injected as a synthetic row; clients can prompt for another selection without turning the directory into a routing whitelist. session.selectModel validates the optional adapter-owned reasoning effort and assigns the complete selection for the next prompt assembly. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable provider or unsupported effort returns model-unavailable. session.models additionally reports routable: whether an adapter currently serves the selected provider. This is deliberately not derivable from the groups because an adapter may serve an unadvertised model. session.prompt refuses on the same fact with model-unavailable before opening a turn; a disabled composer is a client affordance, and the method remains callable.

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.

Background tasks ride the same live-push posture. When ctx.tasks is composed, the gateway subscribes to its change feed and broadcasts a whole session/tasks snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends []). A change carrying an owner reads through that exact Agent, so a push stays correct while its scope tears down; the baseline reads ctx.agents.get(sessionId), which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire TaskView drops ownerSession, reported, and outputLimitBytes: the frame's own sessionId carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.

Workspace and Session lists are separate reconnect baselines. 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 invocation path is the slash gesture. 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 entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—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. commands/change rides the forwarded-event frame as the registry-wide catalog invalidation signal: clients refetch command.list instead of diffing. host/session-preset-changed is its per-session counterpart, framed off the logged agent-preset/selected commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (command.list, skill.list) go stale with no registry change to announce it.

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 preferences locale, permission, ui-conversation, and ui-theme, 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 any 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. Invalidations keep every surface converged without polling. settings/document-updated and credentials/updated ride the verbatim forwarded-event frame (see below), so a raw settings change whose resolved value is unchanged still reaches clients, and a credential invalidation still carries reference names only, never values. host/models-changed stays a derived frame of this package's own: it is fired by llm/adapters-updated and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a locale, permission, ui-conversation, ui-theme, or ui-onboarding change emits only its forwarded 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) remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product dsh --profile headless is a direct core entry point and does not mount this package.

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

  • Forwarded Remote events are parasitic on this legacy frame unionhost/remote-event lives in HostFrame so the delivery path could reuse the existing host stream instead of opening a third downlink, which makes it read as if this package owned the Remote event contract. It does not: the allowlist is dsh-api-remotes' and the consumer verb is ctx.remote.$on. When the host stream moves off this package, the frame moves with it and the consumer contract is unaffected (rationale).
  • Pending-interaction state is host-side — the wire uses POST /api/respond plus RpcReceipt; the table in src/api-proxy.ts handles questions only and has no approval entries.
  • Reserved seams stay out of RpcMethodMapprompt.mode: 'inject', task.list, and a describe hostInstanceId are documented reservations; model discovery uses llm.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.describe gains 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 native capability, host.pickDirectory reports 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 updatedAt counts a mere pickup as a write (per-file backends only) — the attached projection excludes the session/end-seed boundary, because picking a session up is not activity, but a cold session's updatedAt is 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 where locate() resolves a per-session artifact, i.e. JSONL; SQLite returns undefined, so its cold sessions fall back to createdAt and 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.