Overview
dsh-host-apiproxy
Source-level overviewThe 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?, coldBlankProbeMaxBytes?}, 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.Collapse technical overview
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?, coldBlankProbeMaxBytes?}, 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.This is an atomic module already shipped with Harness, not a standalone profile layer.
Capabilities
What it contributes
README / EN
Package documentation
@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?, coldBlankProbeMaxBytes?}, 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 compaction/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 no other domain's 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. The gateway owns two units: sessionListMetadata caches the monotonic blank-to-nonblank transition and latest human prompt time used by session.list, while imageLimits publishes the attachments config enforced at prompt admission as a per-boot constant (apply keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed.
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). HEAD runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. 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 0–9 (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.
session.prompt and subagent.prompt accept optional request-local clientTimeZone provenance. When present, the Host validates and canonicalizes UTC or an IANA Area/Location before Agent entry, rejects invalid input with invalid-time-zone, and records the canonical value on that exact user-rpc message beside its rpcId. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it.
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 jobs ride the same live-push posture. When ctx.jobs is composed, the gateway subscribes to its change feed and broadcasts a whole session/jobs 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 JobView 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.insertBefore({ workspaceId, beforeWorkspaceId? }) commits one registry-order move and answers the complete order; a pure reorder emits host/workspace-order-changed with that complete order, while unknown sources or anchors return workspace-not-found. 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 whether a turn has started: 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. Attached summaries fold the live log. A cold summary trusts cached blank: false, but treats cached true and a cache miss as unverified; when locate() reports an artifact no larger than the coldBlankProbeMaxBytes eligibility threshold (default 1 KiB), the gateway reads that Session with readFrom() and folds both blankness and the latest human prompt. A larger, location-less, vanished, or unreadable artifact remains visible. After an asynchronous cold read, a Session that attached meanwhile is summarized from its live log instead. updatedAt uses the live fold, the exact small-artifact fold, or the projection cache in that order, falling back to createdAt; pickup boundaries and other writes never promote a Session.
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. host.describe.canOpenPath advertises whether that handoff can reach a user-visible desktop: explicit gateway nativeOpen wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as host.pickDirectory; clients combine both facts before presenting a native action.
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. Forwarded agent-preset/selected is its per-session counterpart, emitted from the logged selection 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, the host-plane plugin sections agent-loop, bash, and web-search-deepseek that the plugin configuration page edits, 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. llm/adapters-updated is forwarded beside settings/document-updated; concrete model consumers subscribe to both owner events directly because topology commits and settings documents can independently change their directories. 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 union —
host/remote-eventlives inHostFrameso 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 isdsh-api-remotes' and the consumer verb isctx.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/respondplusRpcReceipt; the table insrc/api-proxy.tshandles questions only and has no approval entries. - Reserved seams stay out of
RpcMethodMap—prompt.mode: 'inject',job.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). - Cold-list hints degrade only toward visibility and older ordering — a projection-cache miss or stale
lastPromptAtfalls back tocreatedAtunless an eligible small artifact supplies an exact fold, so a recently worked large Session may sort too low until the next checkpoint. A blank artifact larger thancoldBlankProbeMaxBytes, or one from a backend withoutlocate(), remains visible. The threshold is checked beforereadFrom()rather than enforced by persistence, so concurrent artifact growth may increase one probe's read cost without changing blankness safety. The bounded blank-verification decision owns this safety direction; an authoritative exact recency index remains scoped in the last-activity-index proposal.
LIMITATIONS
Known limitations
- **Forwarded Remote events are parasitic on this legacy frame union** — `host/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](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md)). - **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 `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.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](../directory-picker-native/README.md)). - **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold, so a recently worked large Session may sort too low until the next checkpoint. A blank artifact larger than `coldBlankProbeMaxBytes`, or one from a backend without `locate()`, remains visible. The threshold is checked before `readFrom()` rather than enforced by persistence, so concurrent artifact growth may increase one probe's read cost without changing blankness safety. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
