# Conflicts: # docs/core-data-structures/session.i18n.yaml # packages/client/ui-trajectory/README.i18n.yaml # packages/client/ui-trajectory/README.zh.md # packages/core/session/README.i18n.yaml # packages/core/session/README.zh.md
@deepseek-ai/dsh-client-runtime
English | 中文
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (connectWorkspace). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (commands/changed, settings/changed, credentials/changed, models/changed) so surface caches refetch without touching the stream. 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. Contract: api-contracts v3 §4. Each Session holds a generic ProjectionValueStore seeded from the history-tail projections block and updated by session/projection frames 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.
Workspace and Session lists
Workspace and Session lists have independent monotone pending → ready baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes workspace.list as the baseline. 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 answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay 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. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.
WorkspacesService.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. WorkspacesService.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.
SlotsService gives the renderer separate bare observables for useSessions and useWorkspaces; web-react creates the hooks. Workspace business state does not enter SessionListState or an entry store.
SessionsService.search(query, signal) is a stateless one-shot action over the session.search RPC. 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
WorkspacesService.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) or calls session.create({workspaceId}), returning the session id for the caller to open. SessionSummary.blank mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by session.list / the host/session-added frame, flipped false by the first ACCEPTED local prompt() (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any running: true status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. SessionsService.create accepts an optional caller-preallocated SessionId and throws SessionCreateError (carrying requestedSessionId) on failure.
Pending queue projection
ConversationSnapshot.queue is the Host's authoritative transient inbox snapshot and carries both queued and pending-steering occurrences with their resolved placement. Each row carries its InboxItemId, stable MessageId, complete editable text when every content block is text, and a flattened preview. session/queue replaces the whole projection, while an accepted live steering/message event retires only the first matching current steering occurrence so the durable node can take over before the following Host snapshot; history replay never consumes a later occurrence that reused the same MessageId. Reconnect buffering retains only the latest snapshot, and neither ordinary durable turn events nor running-status changes guess that an item was claimed. Session.updateQueue() sends edit, remove, and strict-steer operations without optimistic mutation; claim and closed-window races surface queue-item-not-found and steer-unavailable.
The human transcript
ConversationSnapshot.nodes is the human transcript, not the model surface. TranscriptAdapter projects the raw window in log order — every append-origin surface event (isAppendSurfaceEvent) at its own log position, plus one CompactionSummaryNode marker per landed compaction checkpoint — and never consults surface order. ConversationSnapshot.turnEnds maps each completed turn in that window to its turn/end seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned tool/result and a regenerated assistant/message rewrite one node for the model and mark no boundary. A checkpoint is a user/message carrying the compaction seam's plugin source that replaced a surface range; an appending plugin-sourced user/message is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free dsh-compact/checkpoint leaf, so renaming it there fails tsc here; a value import of the package would fail the client purity gate, and the package root is unreachable even as a type (it reaches dsh-session's root, whose Context merge collides the host sessions with this program's). tests/compact-checkpoint-pin.spec.ts covers the same drift behaviorally.
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only command/run / command/done nodes splice in by seq, Session merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's compact/summary provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
Request inspection
SessionHistoryInspection.requests is 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 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 sub-dispatch index
ConversationSnapshot.codeDispatches groups a run_code call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a tool/code-dispatch-start event lands the RunningToolCall form (rows derive the running ring from the shape) and its tool/code-dispatch settlement replaces it in place with the ToolResultNode form, callTime carrying the paired start's time. A settle whose start fell outside the replay window appends directly with callTime: null (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript nodes flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
Session title projection
SessionManager retains the latest validated session/title control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its lastSeq before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing SessionSummary.title is therefore only the actual durable title; displayTitle is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. ISession.rename settles the title projection cell directly from the unary response's {title, seq} under the same higher-seq-wins rule — the list row and every useProjection('title') reader update ahead of the push frame, whose later replay of the same seq is a no-op.
Model retry projection
The Session object validates plugin-owned, provider-routed llm/retry payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is scheduled until a following retry turn starts; an aborted or disposed source turn marks it cancelled, while the retry turn marks it started. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal turn/end error without a retry projects one turn-error node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with API key is invalid, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay apply the same projection, 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.
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.
Session model selection
Each resident Session owns a modelSelection snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the idle/loading/ready/selecting/error state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure.
Addressed subagent conversations
SessionListState.subagentsByParent carries direct durable catalogs and currentAddress records the catalog-derived {parentSessionId, childSessionId} for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have parentId. An addressed Session loads and reconnects through subagent.history, sends through subagent.prompt, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse origin: 'subagent' classification for navigation filtering; the recorded address, not origin, remains transport authority. Catalog reads are single-flight; the Host baseline and host/session-status both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. An origin-classified host/session-added immediately marks any loaded direct parent row hasChildren: true and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into ConversationSnapshot.subagent so presentation can replace the composer with a read-only explanation without activating the parent.
Model Experience
None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.
KV Cache effect
Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.
Known Limitations and Deferred Work
loader.unloadis a stub (throws not-implemented) — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.- Scope teardown is stage-driven, single-occupant today — the staged session follows
list.currentexactly (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 thecurrentProvideInfoobservable. 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
/clientsubpath — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).