Files
deepseek-harness/packages/client/runtime

@deepseek-ai/dsh-client-runtime

English | 中文

Client cordis boot and React-free object services: SlotRegistry wraps SlotCore and supplies renderer data sources; SessionRuntime owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. Each durable window uses one Session Controller journal stream; one Host-wide snapshot stream supplies queue, jobs, projections, approvals, and questions. WorkspaceRuntime depends on SessionRuntime and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (connectWorkspace); Workspace Controller supplies its reconnecting snapshot stream. Domain packages subscribe to forwarded Host events through ctx.remote.$on.

Client sessions are always Host-born (Session+Agent+cwd in one session.create); the client holds no pre-entity session state. A session's Agent scope, the client mirror of Host dsh-scope keyed by the shared Agent/Session id, is born when its row enters the list mirror and dies with the prune. Each Session holds a generic ProjectionValueStore seeded from Session list or page projection blocks and updated by control-stream projection replacements under higher-seq-wins. Domain keys, including todos, are read via projections.faceOf / useProjection, not via ConversationSnapshot. The store also publishes one reference-stable whole-value map through SessionSummary.projectionValues, allowing global list consumers to reuse the same projections without creating per-session subscriptions.

For each prompt that can reach a local root or continuable child Agent, the runtime samples the browser's current Intl.DateTimeFormat().resolvedOptions().timeZone and attaches it to that one Session or subagent prompt RPC. It is neither cached nor included in Session creation or fork state, so travel and concurrent tabs keep message-local provenance. A browser that cannot provide a non-empty zone fails the prompt locally instead of silently substituting deployment state.

Settings owners share the React-free SettingsScopeSpec, SettingsScope, and snapshot types defined here. ui-settings owns ctx.settingsScope.bind(spec), its Host transport, schema validation, and lifecycle; see its package contract.

Slot declaration injection

ctx.slots.inject(name, callback) makes a full SlotMap key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs callback synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct slots.register() into an undeclared slot still throws.

The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several slots.register() calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the declaration-injection decision.

Workspace and Session lists

Workspace and Session lists have independent monotone pendingready baseline phases and separate refresh activity/error state. Incremental upsert/removal/order frames and unary mutation echoes arriving during a list request replay over its response. Every successful Workspace baseline re-establishes Host-durable Workspace order so reconnects adopt changes committed while this client was offline. WorkspaceRuntime.insertBefore installs an optimistic order immediately; only the latest unary echo may replace it, a newer Host order frame outranks an older echo, and a latest rejected request restores the last Host-confirmed order rather than an earlier uncommitted drag. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.

SessionSummary.pendingInteraction classifies the live user action blocking a Session as approval, plan-review, or question. SessionManager tracks control-stream requested/resolved frames by stable interactionId even before a Session object is instantiated; pre-instantiation state retains every live request, replaces duplicates, and removes resolved requests so the list status always has a matching answerable PendingWait when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct plan-review status. Every control generation begins with a complete baseline that replaces the pending set and therefore restores only requests that remain answerable.

WorkspaceRuntime.delete(workspaceId) removes the registration from the client projection after the successful unary response; the matching host/workspace-removed frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.

WorkspaceListState.archivedSessionIds mirrors the Host's registry-global archive set (a readonly SessionId[] in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the workspace.list baseline, the archiveSession unary echo, and the host/archived-sessions-changed frame each install the complete set. WorkspaceRuntime.archiveSession(sessionId) archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a workspace.list request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.

SlotRegistry gives the renderer separate bare observables for useSessions and useWorkspaces; ui-renderer creates the hooks. Workspace business state does not enter SessionListState or an entry store.

abbreviateHomePath is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged.

indexSubagentDescendants() derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted origin: 'subagent' ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.

SessionListState.jobsBySession mirrors the Session Controller control stream, keyed by Session and needing no Session instance. Each control baseline replaces the complete map; later jobs frames are last-wins replacements for one Session. An empty set is stored as an absent key, so absence and [] are one representation and consumers never test a sentinel. The forwarded api-session/removed event also clears that Session's jobs.

SessionRuntime.search(query, signal) is a stateless one-shot action over ctx.remote.session.search. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. searchResultLimit re-exposes SESSION_SEARCH_RESULT_LIMIT — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.

New Session and the blank mirror

WorkspaceRuntime.connectWorkspace(workspaceId) resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (blank && cwd == workspace.path && sessionIds.includes(id) — the Host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls ctx.remote.session.create({workspaceId}), returning the Session id for the caller to open. The shared startSession action targets an explicit Workspace first, then the current Session's Workspace, then the derived recent Workspace; with no Workspace it clears into the blank New Session page. SessionSummary.blank mirrors the Host's derived empty-log bit and only ever lowers on the client: it is seeded by session.list or api-session/added, flips false after the first accepted local prompt() and on any api-session/status event with running: true, and is re-aligned by every list pull. List surfaces hide blank rows; the store carries every row. SessionRuntime.create accepts an optional caller-preallocated SessionId and throws SessionCreateError carrying requestedSessionId on failure.

Session.composerPhase treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.

Pending queue projection

ConversationSnapshot.queue is the Host's authoritative transient snapshot of both agent.inbox.nextTurn and nextStep. Rows are tagged queued, steering, or context; each carries its MessageId, complete editable text when every content block is text, and a flattened preview. Every control generation starts with complete queue snapshots, and later queue replacements follow agent/inbox/spliced changes; message-local inserted, claimed, and discarded notifications are not used to reconstruct the projection. Session.updateQueue() sends mutations without optimistic client state, so the next Host snapshot is the visible commit and a claim race can surface queue-item-not-found.

Conversation assembly

Each Session gives its contiguous event window to a ConversationNodeAssembler. Plugins register business Definitions that map one event to a stable {kind, id}, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair.

Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log seq; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The Conversation Node cookbook gives the complete registration and pagination path.

ui-conversation registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads ConversationSnapshot.chat.legacy.nodes, while Session mirrors that legacy slice into the top-level nodes, partial, and runningCalls public compatibility fields without running a second business fold. ui-trajectory registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold.

The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the client Tool presentation decision.

Trajectory request data

Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric turn and step; compaction requests carry step: 0 and a turn owner that may be null. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized assistant/message retains its durable result seq and provider provenance but does not complete the request; step/end classifies that request as an error. A session/end-seed boundary closes an unmatched compaction request as an error at the boundary time with Compaction was interrupted before completion.; a later start projects as an independent request instead of overwriting the orphan.

Code Mode child-call tree

Every ToolCallBlock recursively owns its children through subCalls, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with callTime: null. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target.

Session title projection

SessionManager retains the generic title projection independently of list and Session-instance arrival. Session list summaries, page tails, and control frames all seed the same store; higher event seqs replace lower ones, and a replacement control baseline truncates rows beyond its watermark before seeding its values. Explicit Session removal clears the store. List updatedAt is Host-owned and derives from the latest human prompt, so a title change does not affect recency. The client-facing SessionSummary.title is only the durable title; displayTitle is always present and falls back through the cwd basename and Session id. ISession.rename settles the title projection cell directly from the Remote response's {title, seq} under the same higher-seq-wins rule, so list and useProjection('title') readers update before a later replay of the same seq.

Model retry projection

The Host-owned LLM retry invariant validates provider-routed llm/retry and llm/retry-started records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed attempt's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is scheduled until the matching started record arrives; closing its owning Step or Turn first marks it cancelled, while the started record marks it started. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal turn/end error projects one turn-error node from its durable message and optional code — after exhausted retries it renders beside the settled retry notice; AUTH projections replace provider copy that may echo credential fragments with API key is invalid, while the raw diagnostic remains in the session log. An intermediate failure that scheduled another retry keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.

A turn/end whose reason is max-tokens projects one turn-max-tokens node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore.

Session forking

ISessions.fork({sessionId, atSeq?, increaseTitle?}) resolves only after the child summary is locally addressable, carrying source lineage and cwd with blank: false; callers choose whether to open it. With increaseTitle: true, the client renames the child from the source session's persisted title: a trailing (N) or N is incremented without changing bracket style, while any other title gets (1) appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A workspace-attach-failed response still identifies a child already published by the Host, so SessionManager reconciles that partial success before SessionForkError reaches the caller instead of making a retry create a duplicate child.

Model selection ownership

Session Runtime carries no model-selection snapshot. ui-model-selection owns one scoped ModelDirectory per Session and calls ctx.remote.session.models and selectModel directly; Runtime supplies only Session scope and address information. That package resets its directory on connection/reset, shares one latest-generation-wins store between its two selectors, and disposes it with the Session scope.

Model Experience

None, as this package adds no model-visible content; model selection belongs to ui-model-selection and the Host Session Controller.

KV Cache effect

None directly; this package neither selects a model nor alters the prompt prefix.

Known Limitations and Deferred Work

  • loader.unload is a stub — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
  • Scope teardown is stage-driven and single-occupant — the staged session follows list.current exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (binding()/scope()) is pure addressing, render-safe; the render layer reads the current bundle through the currentProvideInfo observable. The staged state can widen to a multi-pane list when concurrent panes land.
  • Value imports of this package from plugin bundles must use the /client subpath — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches.