Merge origin/master into web-permission-sandbox

39 conflicts resolved per the reattachment plan (missions worktree-projbiz
0728-1859): baseline wins for deleted packages (host/runtime, old ui/acp,
ui-sidebar Rows/tree) and retired specs; unions for wire-layer exports and
client summary fields; the approval takeover, waitingApprovals tracking, and
PendingApproval domain face carry over onto the master structure. The two
new host specs follow the runtime->apiproxy rename. Dead PR-side wiring
(ConversationInjected permissions/setPermission spread, InputBar controls
prop, boot.ts sandbox composition) resolves to master and its replacement
lands in follow-up commits.
This commit is contained in:
imccyu
2026-07-28 21:20:00 +08:00
3427 changed files with 156166 additions and 59003 deletions

View File

@@ -0,0 +1,70 @@
/**
* Client Agent-scope primitive: mint a Cordis context tagged with the owning
* Agent's identity. The mechanism mirrors the host `dsh-scope` architecture
* (no-op plugin fiber + context tag + `Context.filter` routing predicate);
* the shape deliberately diverges: the filter lives on the actx itself
* instead of a separate carrier object, so scoped dispatch is plain cordis —
* `actx.bail(actx, event, payload)` / `actx.emit(actx, ...)` — with no
* wrapper. The host needs a detached carrier because its dispatch subject is
* the business Agent object; client scope events carry only ids, so the
* actx is the natural subject. The second divergence stands: the scope key
* is the branded `SessionId` (value compared), not an object identity — the
* agent and its session share one id (1:1, same axis; no separate AgentId
* brand), and a client scope's identity IS that wire id. Third divergence,
* deliberate: the client scopes the Agent IDENTITY, not a live Agent object
* — a cold session's host Agent is already disposed while its client actx
* stays alive for history viewing.
*/
import { Context as CordisContext } from 'cordis'
import type { Context, Fiber } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** Context tag written by {@link createScope}. */
const kScope = Symbol('dsh.client.scope')
/** A minted Agent scope and its disposal boundary. */
export interface AgentScopeHandle {
/**
* Tagged context: scope-owned registrations and scoped dispatch both go
* through it (passing it as the dispatch subject routes to this agent's
* tagged listeners plus every untagged one).
*/
ctx: Context
/** Backing fiber (dispose tears down every scope-owned registration). */
fiber: Fiber
}
/** Shared no-op plugin backing each Agent scope fiber. */
function agentScope(): void {}
/**
* Mint an Agent scope under `ctx`: a no-op plugin fiber whose context
* carries the agent tag and the dispatch filter — untagged listeners are
* admitted globally, tagged listeners only for a matching agent.
* Registrations through the returned ctx dispose with the fiber.
* @param ctx - client root context the scope fiber mounts under.
* @param key - owning agent identity (the routing tag; agent id === session id).
* @returns the tagged context and its backing fiber.
*/
export function createScope(ctx: Context, key: SessionId): AgentScopeHandle {
const fiber = ctx.plugin(agentScope)
return {
fiber,
ctx: fiber.ctx.extend({
[kScope]: key,
[CordisContext.filter](listenerCtx: Context): boolean {
const tag = scopeOf(listenerCtx)
return tag === undefined || tag === key
},
}),
}
}
/**
* Read the nearest agent tag inherited by a context.
* @param ctx - any client context.
* @returns its agent identity (the session id), or undefined for root contexts.
*/
export function scopeOf(ctx: Context): SessionId | undefined {
return (ctx as Context & { [kScope]?: SessionId })[kScope]
}

View File

@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
}
}
// ---- defineStore shell (slot terminal design §4) ----
// The type authority is ui-slots' store family (create(scopeKey?) and
// clearPersisted() included); this module houses only the engine-backed
// implementation. The one engine-side widening left: instances expose the
// raw engine store for framework/test surfaces.
// ui-slots owns the contract; this module supplies the engine implementation.
/** A live engine instance: the contract instance plus the raw engine store. */
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {

View File

@@ -1,43 +1,45 @@
/**
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
* SlotsService (declaration ledger + renderer seam + store axis, built-in
* 'root'), SessionsService (list store + current selection + scope tree +
* object layer), and the cordis Context/Events merges. apply mounts
* ctx.slots + ctx.sessions and wires the connection stream loop into the
* object layer. A static-arrival entry: the web shell bundles this module
* and mounts it through the host graph (module loading lives in
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
*/
/** Browser runtime services for slots, sessions, workspaces, and connection-stream delivery. */
import type { Context } from 'cordis'
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { MaybeSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from './slots.ts'
import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
// ui-layout: the framework slot is declared by the framework package).
export type { RootOwnerProps } from './slots.ts'
export { SessionsService, scopeOf } from './sessions/service.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
// The snapshot-store engine lives here since the store migration (the data
// layer owns its substrate; web-react is React glue only). The './client'
// main export is the single serving door — no store subpath.
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
export type { SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot,
RunningToolCall, SteeringMessageNode,
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
AssistantBlock, AssistantMessageNode, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
// PendingWait is a value export: tests construct fixture waits directly.
export { PendingWait } from './sessions/pending.ts'
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
// Projection value store (session-projection RFC, push model): host-computed
// whole values per key; domains ship projection support with zero client code.
export type {
ProjectionsBaseline, ProjectionValueStore, SessionProjectionMap, UseProjection,
} from './sessions/projection-store.ts'
export type { PermissionOption, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/** The permission select material as the object layer serves it to UI plugins. */
@@ -48,16 +50,7 @@ export interface PermissionSelect {
currentValue: string
}
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
// concrete types live here, where their subjects live) ----
/**
* The client cordis context face: the base Context plus the service keys
* this package's declaration merge contributes (slots/sessions/loader) and
* every later plugin's merge. A plain alias — the merges land on Context
* itself inside the client program; the name marks intent at consumer seams.
*/
/** Client-side Cordis context after declaration merging. */
export type ClientContext = Context
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
@@ -77,15 +70,25 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* every session-scope slot component receives these from the framework.
*/
interface SessionStandardProps {
/** Selector hook over this session's conversation snapshot. */
useSession: SnapshotSelectorHook<ConversationSnapshot>
/** The framework-resolved session id (owners never pass it). */
sessionId: SessionId
/** The fifth framework hook seat: key-addressed projection reader (undefined = capability absent). */
useProjection: UseProjection
}
/** Global standard kit, real members: the session-list hook every slot component receives. */
/** Standard kit for slots that remain mounted while current session changes. */
interface SessionMaybeStandardProps {
useSession: MaybeSnapshotSelectorHook<ConversationSnapshot>
/** Current session id; absent in the no-session state. */
sessionId: SessionId | undefined
/** Key-addressed projection reader; every key reads absent while no session is current. */
useProjection: UseProjection
}
/** Props injected into every global slot component. */
interface GlobalStandardProps {
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
useSessions: SnapshotSelectorHook<SessionListState>
/** Selector hook over real Workspaces and their independent baseline lifecycle. */
useWorkspaces: SnapshotSelectorHook<import('./workspaces/service.ts').WorkspaceListState>
}
}
@@ -97,28 +100,57 @@ declare module 'cordis' {
* @param key - the mutated SlotMap key.
*/
'slots/changed'(key: string): void
/**
* The host command registry changed (host/commands-changed passthrough).
* Pure invalidation signal: subscribers refetch `command.list` in the
* background rather than diffing.
* @mode emit
*/
'commands/changed'(): void
/**
* A connection generation was (re-)established. Wire-derived caches must
* treat their state as stale and repull (commands directory; the queue
* mirrors reset themselves through the session resync path).
* @mode emit
*/
'connection/reset'(): void
}
interface Context {
slots: import('./slots.ts').SlotsService
sessions: import('./sessions/service.ts').SessionsService
workspaces: import('./workspaces/service.ts').WorkspacesService
}
}
/** Required services: the wire handle mounted by the connection plugin. */
export const inject = ['connection']
/**
* Client plugin body: mount slots + sessions, start the stream loop.
* @param ctx - client cordis context.
/** Mounts the browser runtime services and connection stream.
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
ctx.plugin(SlotsService)
const connection = ctx.get('connection') as ConnectionHandle
const sessions = new SessionsService(ctx, connection.api)
const workspaces = new WorkspacesService(ctx, connection.api, sessions)
ctx.effect(
() => workspaces.startInitialSelection(),
'runtime: initial Workspace selection',
)
const loop = connection.start({
onMuxEnvelope: (envelope) => { sessions.manager.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => { sessions.manager.handleHostEnvelope(envelope) },
onConnected: () => { sessions.manager.handleConnected() },
onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) },
onHostEnvelope: (envelope) => {
sessions.handleHostEnvelope(envelope)
workspaces.handleHostEnvelope(envelope)
// Typed-event bridge: the session layer ignores registry frames (no
// session routing); consumers (command directory caches) subscribe on ctx.
if (envelope.payload.type === 'host/commands-changed') ctx.emit('commands/changed')
},
onConnected: () => {
sessions.handleConnected()
workspaces.handleConnected()
ctx.emit('connection/reset')
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}

View File

@@ -0,0 +1,43 @@
/**
* Merge an authoritative baseline without moving identities already visible to
* the client. Baseline-only identities are inserted relative to the nearest
* following known identity; identities absent from the baseline are removed.
*
* @param current - the established client order.
* @param baseline - the latest authoritative rows.
* @param keyOf - stable identity selector.
* @returns baseline-valued rows with the established relative order retained.
*/
export function mergeOrderedBaseline<T>(
current: readonly T[],
baseline: readonly T[],
keyOf: (value: T) => unknown,
): T[] {
const baselineByKey = new Map<unknown, T>()
for (const value of baseline) baselineByKey.set(keyOf(value), value)
const merged = current
.map(value => baselineByKey.get(keyOf(value)))
.filter((value): value is T => value !== undefined)
const mergedKeys = new Set(merged.map(keyOf))
for (let index = 0; index < baseline.length; index++) {
const value = baseline[index]
/* v8 ignore next -- dense-array guard: index is bounded by baseline.length. */
if (value === undefined || mergedKeys.has(keyOf(value))) continue
let insertion = merged.length
for (let following = index + 1; following < baseline.length; following++) {
const candidate = baseline[following]
/* v8 ignore next -- dense-array guard: following is bounded by baseline.length. */
if (candidate === undefined) continue
const known = merged.findIndex(item => keyOf(item) === keyOf(candidate))
if (known !== -1) {
insertion = known
break
}
}
merged.splice(insertion, 0, value)
mergedKeys.add(keyOf(value))
}
return merged
}

View File

@@ -3,10 +3,16 @@
// substructures keep their references (the React.memo premise). callId/approvalId stay plain
// string here (narrow to real brands when convenient).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
RpcError, SessionId, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -82,7 +88,6 @@ export interface ContextMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
meta?: unknown
}
/** A tool result paired (when in-window) with its call head. */
@@ -116,6 +121,31 @@ export interface UnknownSurfaceNode {
data: unknown
}
/**
* One slash-command lifecycle folded from the log-only `command/run` /
* `command/done` pair (paired by commandId, mirroring tool call↔result).
* Log-only events never enter the surface fold, so the FoldAdapter indexes
* them separately and merges the nodes into the flow by seq. A window cut
* between the pair soft-falls like tool pairs: a done with no in-window run
* still builds a node (name/args null), and a run with no done renders as
* still executing.
*/
export interface CommandNode {
kind: 'command'
/** Seq of the command/run event; the done event's seq when only the done is in-window. */
seq: number
/** Unix epoch ms of the anchoring event. */
time: number
/** Pairing id minted by the host executor. */
commandId: CommandId
/** Command name (run payload's structured field); null when the run fell outside the window. */
name: string | null
/** Verbatim rawInput after the name, separator whitespace included (run payload); null when the run fell outside the window. */
args: string | null
/** Settlement outcome (done payload); null while the command is still executing. */
outcome: { kind: 'success' | 'error'; text?: string } | null
}
/** Finalized conversation node union (kind discriminates; seq is the React key). */
export type ConversationNode =
| UserMessageNode
@@ -123,8 +153,24 @@ export type ConversationNode =
| SteeringMessageNode
| ContextMessageNode
| ToolResultNode
| CommandNode
| UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized in the native call-block shapes so
* every consumer (tool rows, details panel) renders it through the exact
* components that render a native call: a started-but-unsettled sub-call is a
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the surface `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
* settled sub-call's complete logged outcome.
*/
export type CodeSubCall = RunningToolCall | ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
@@ -139,6 +185,12 @@ export interface RunningToolCall {
}
/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */
export interface QueuedMessage {
readonly key: string
readonly preview: string
}
/** In-progress assistant output (chunk accumulator product). */
export interface PartialAssistant {
turn: number
@@ -149,6 +201,28 @@ export interface PartialAssistant {
/** History-open lifecycle of a Session window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/**
* Input-area shape of an OPEN session, derived at snapshot assembly (the one
* place that knows the predicate — consumers switch, never re-derive):
*
* - `blank`: no activity ever (no nodes, no partial, not running, no pending
* waits, no prompt attempt) — the UI renders the blank-session guidance
* hero.
* - `engaging`: the first prompt was initiated but no content landed yet —
* the UI holds the composer through the accept → running → first-event
* frames. Entered synchronously before prompt()'s first await.
* - `active`: content exists (nodes, partial, running turn, or pending
* waits) — the ordinary conversation view.
*
* Monotone within a session object: blank → engaging → active, no returns.
* A failed first prompt stays `engaging` (composer + error strip — retry
* semantics; bouncing back to the hero would discard the error context).
* Sessions whose window is not open (`loading`/`error`) are outside phase
* jurisdiction: consumers branch on {@link ConversationSnapshot.openState}
* first (phase still reports `active`-ish facts but must not be rendered).
*/
export type ComposerPhase = 'blank' | 'engaging' | 'active'
/** Send/stop failure surfaced in the input error strip; op picks the user-facing copy (发送失败 vs 停止失败). */
export interface PromptError {
op: 'send' | 'stop'
@@ -164,8 +238,19 @@ export interface ConversationSnapshot {
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */
queue: readonly QueuedMessage[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */
removed: boolean
openState: OpenState
@@ -173,5 +258,16 @@ export interface ConversationSnapshot {
hasMore: boolean
loadingOlder: boolean
promptError: PromptError | null
/**
* Whether this session still has an empty log (no user message yet).
* Mirrors the host summary's derived blank bit: seeded from `session.list`
* / the `host/session-added` frame, flipped false by the first ACCEPTED
* prompt locally (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 remotely,
* and re-aligned by every list re-pull (the summary stays authoritative).
* Blank sessions are hidden from session lists and reused by New Session.
*/
blank: boolean
lastAgentError: string | null
}

View File

@@ -8,8 +8,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// go through it — the package root points at lib/index.js (needs a build) which the vite
// browser bundle cannot resolve; surface.ts has no Node dependencies.
import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { ConversationNode } from './conversation.ts'
import type { CommandNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
/** In-window tool/call index entry (result-card backfill + runningCalls material). */
@@ -24,10 +25,10 @@ export interface CallIndexEntry {
callView: ToolCallView | null
}
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
* place a synthetic event enters the window). */
/** Non-surface sentinel used to preserve paged-window sequence offsets.
* `noop/padding` is deliberately not a real event type, so it cannot acquire
* surface behavior; this cast is the only synthetic event entry point.
*/
function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
}
@@ -40,6 +41,14 @@ function materializeNode(
): ConversationNode {
switch (event.type) {
case 'user/message':
// Injected context (plugin/goal source) folds to a context node, not a
// user message; only a direct human prompt is a user node.
if (event.data.source.kind !== 'user') {
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
kind: 'user', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
@@ -48,34 +57,30 @@ function materializeNode(
return {
kind: 'assistant', seq: event.seq, time: event.time,
turn: event.data.turn, step: event.data.step,
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
}
case 'steering/message':
return {
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
content: event.data.content, source: event.data.source,
}
case 'context/message':
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
meta: event.data.meta,
content: event.data.message.content, source: event.data.message.source,
}
case 'tool/result': {
const call = callIndex.get(String(event.data.callId))
const result = event.data.message.content[0]
const callId = String(event.data.message.source.callId)
const call = callIndex.get(callId)
return {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: String(event.data.callId),
callId,
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
callTime: call?.time ?? null,
content: event.data.content, isError: event.data.isError,
content: result.content, isError: result.isError === true,
...(event.data.error !== undefined ? { error: event.data.error } : {}),
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
}
}
/* v8 ignore next 2 -- defensive arm: fold output only carries the five
/* v8 ignore next 2 -- defensive arm: fold output only carries the four
surface-eligible types, and each has a case above; reachable only if core
adds an eligible type. */
default:
@@ -97,6 +102,15 @@ export class FoldAdapter {
private callIdx = new Map<string, CallIndexEntry>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so the surface fold never
* emits it; this index folds the pair (done settles its run's node in
* place) and nodes() merges the products into the flow by seq. Window cuts
* soft-fall like tool pairs: a done with no in-window run still builds a
* node.
*/
private commandIdx = new Map<string, CommandNode>()
/** Window revision (bumped on reset/append) keying the nodes() result cache: an unchanged
* window returns the previous ARRAY reference, not just cached elements — the snapshot's
* reference-stability contract (§A.9.4) starts here. */
@@ -126,10 +140,14 @@ export class FoldAdapter {
this.degraded = false
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
if (event !== undefined) this.indexCall(event, views?.[i])
if (event !== undefined) {
this.indexCall(event, views?.[i])
this.indexCommand(event)
}
}
}
@@ -143,6 +161,7 @@ export class FoldAdapter {
this.rev++
this.padded.push(event)
this.indexCall(event, view)
this.indexCommand(event)
}
/**
@@ -178,7 +197,23 @@ export class FoldAdapter {
this.nodeCache.set(seq, node)
out.push(node)
}
const value = { nodes: out, degraded: this.degraded }
// Command nodes fold outside the surface (log-only events); merge by seq.
// Both inputs are seq-ascending (surface order and run-index insertion
// order share the log order), so one linear merge keeps flow order.
let nodes = out
if (this.commandIdx.size > 0) {
nodes = []
const commands = [...this.commandIdx.values()]
let next = 0
for (const node of out) {
for (let cmd = commands[next]; cmd !== undefined && cmd.seq < node.seq; cmd = commands[++next]) {
nodes.push(cmd)
}
nodes.push(node)
}
for (let cmd = commands[next]; cmd !== undefined; cmd = commands[++next]) nodes.push(cmd)
}
const value = { nodes, degraded: this.degraded }
this.nodesResult = { rev: this.rev, value }
return value
}
@@ -193,6 +228,36 @@ export class FoldAdapter {
return seqs
}
/** Fold one command lifecycle event into its node (run mints, done settles in place; done-only soft-falls). */
private indexCommand(event: SessionEvent): void {
// Log-only plugin events: the host-side dsh-commands declaration cannot
// enter the client program, so this wire consumer narrows structurally
// (the same posture as tool/code-dispatch in session.ts).
if ((event.type as string) === 'command/run') {
const data = event.data as unknown as { commandId: CommandId; name: string; args: string }
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: data.name, args: data.args, outcome: null,
})
return
}
if ((event.type as string) !== 'command/done') return
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
const run = this.commandIdx.get(data.commandId)
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
if (run === undefined) {
// Cross-window cut: the run page fell out of the window — build the
// node from the done alone (same soft-fall as a call-less tool result).
this.commandIdx.set(data.commandId, {
kind: 'command', seq: event.seq, time: event.time,
commandId: data.commandId, name: null, args: null, outcome,
})
return
}
// Settle in place: a fresh node object (published references stay immutable).
this.commandIdx.set(data.commandId, { ...run, outcome })
}
private indexCall(event: SessionEvent, view?: ToolEventView): void {
if (event.type === 'tool/result') {
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)

View File

@@ -1,6 +1,6 @@
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
// Roots sort by updatedAt desc, DFS expansion with children in the same order; orphaned lineage
// degrades to root level; cycles fail soft and emit as roots.
// The input order is authoritative; lineage only makes each child adjacent to its parent.
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
@@ -15,6 +15,8 @@ export interface SessionListEntry {
title?: string
updatedAt: number
running: boolean
/** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */
blank: boolean
parentSessionId?: SessionId
cwd?: string
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
@@ -24,8 +26,9 @@ export interface SessionListEntry {
}
/**
* summaries -> flat list with lineage indentation (pure; roots by updatedAt
* desc, DFS children in the same order, orphans degrade to roots).
* Summaries -> flat list with lineage indentation. Root and sibling order
* follows the established input order; this projection never re-sorts a
* hydrated list from mutable timestamps.
* @param summaries - the host's session.list items.
* @param waitingApproval - sessions with a pending approval question (manager-owned live fact; absent = false).
* @returns display rows in render order.
@@ -46,9 +49,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti
}
}
const byUpdatedDesc = (a: TitledSessionSummary, b: TitledSessionSummary): number => b.updatedAt - a.updatedAt
roots.sort(byUpdatedDesc)
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: TitledSessionSummary, depth: number): void => {
@@ -60,7 +60,6 @@ export function flattenLineage(summaries: readonly TitledSessionSummary[], waiti
out.push({ ...s, waitingApproval: waitingApproval?.has(s.sessionId) ?? false, depth })
const kids = children.get(s.sessionId)
if (kids === undefined) return
kids.sort(byUpdatedDesc)
for (const kid of kids) walk(kid, depth + 1)
}
for (const root of roots) walk(root, 0)

View File

@@ -2,31 +2,52 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import type { SessionListEntry, TitledSessionSummary } from './lineage.ts'
import { flattenLineage } from './lineage.ts'
// Type-only merge edge: the title domain's client-namespace outlet declares
// the 'title' projection key this manager projects into list rows (and any
// useProjection('title') consumer reads). Zero value imports by construction.
import type {} from '@deepseek-ai/dsh-session-title/client'
import { Notifier } from './notifier.ts'
import { ProjectionValueStore } from './projection-store.ts'
import { Session } from './session.ts'
/**
* List arrival lifecycle, orthogonal to the pull-activity `state` axis:
* `pending` (no successful pull yet — an empty items array means "nothing
* arrived", not "nothing exists") → `ready` (at least one pull landed).
* Monotone: `ready` never steps back — later pull failures and reconnect
* re-pulls ride the `state`/`error` axis, which is where failure is modeled
* (no `error` phase here; that would duplicate `state`).
*/
export type SessionListPhase = 'pending' | 'ready'
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
/** Selected Session id (validated against items; masked to undefined while its session is off the list). */
current: SessionId | undefined
state: 'idle' | 'loading' | 'error'
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: RpcError | null
}
type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
| { kind: 'status'; sessionId: SessionId; running: boolean }
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
| { kind: 'engaged'; sessionId: SessionId }
/** Per-session cap for pre-instantiation approval/question buffering (low-frequency frames; a few dozen covers any real backlog). */
const PENDING_BUFFER_CAP = 32
/** Latest title control snapshot retained independently of list/instance arrival. */
interface SessionTitleSnapshot {
title: string
eventSeq: number
updatedAt: number
}
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
export class SessionManager {
@@ -41,11 +62,21 @@ export class SessionManager {
* because the sidebar must light up for sessions never instantiated. Cleared per connection
* generation — the reopen replay re-adds still-pending questions — and on session-removed. */
private readonly waitingApprovals = new Map<SessionId, Set<string>>()
private readonly titleSnapshots = new Map<SessionId, SessionTitleSnapshot>()
/** Per-session projection value stores, retained independently of instance arrival (the
* title-snapshot precedent, generalized): push frames land here whether or not the Session
* is instantiated (list rows read the 'title' key), and an instantiated Session adopts the
* same store so history-baseline seeding and frames converge on one row set. */
private readonly projectionStores = new Map<SessionId, ProjectionValueStore>()
private summaries: SessionSummary[] = []
private listState: 'idle' | 'loading' | 'error' = 'idle'
/** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */
private listPhase: SessionListPhase = 'pending'
private listError: RpcError | null = null
private listInflight: Promise<void> | null = null
/** Mutations arriving after a list request starts are replayed over its response. */
private listMutations: SessionListMutation[] | null = null
private selected: SessionId | undefined
private listSnapshotCache: SessionListSnapshot
/** Entry-identity cache (§C.2 reference stability): list rebuilds reuse the previous entry
@@ -57,12 +88,50 @@ export class SessionManager {
this.listSnapshotCache = this.buildListSnapshot()
})
constructor(private readonly api: IApiClient) {
/**
* @param api - shared wire client.
* @param restoredSelection - persisted real-Session selection candidate.
*/
constructor(
private readonly api: IApiClient,
restoredSelection?: SessionId,
) {
this.selected = restoredSelection
this.listSnapshotCache = this.buildListSnapshot()
}
// ---- Selection ----
/**
* Select a listed Session.
* @param sessionId - listed Session id.
*/
select(sessionId: SessionId): void {
if (!this.summaries.some(summary => summary.sessionId === sessionId)) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
this.selected = sessionId
this.notifier.notifyNow()
}
/** Clear the selection (the layout falls to the no-session view state). */
clearSelection(): void {
this.selected = undefined
this.notifier.notifyNow()
}
// ---- Instance management ----
/**
* Drop a session instance (scope-prune companion, decision 12: instance
* and scope share one lifecycle). The host session log is the durable
* truth — a later get() lazily rebuilds and open() backfills history.
* @param sessionId - the session to drop.
*/
drop(sessionId: SessionId): void {
this.sessions.delete(sessionId)
}
/**
* Lazy build: return the existing instance or construct one (no auto-open —
* open is triggered by the container's select callback).
@@ -72,21 +141,53 @@ export class SessionManager {
get(sessionId: SessionId): Session {
let session = this.sessions.get(sessionId)
if (session === undefined) {
session = new Session(sessionId, this.api)
session = this.createSession(sessionId)
this.sessions.set(sessionId, session)
// Sync the running bit from the list snapshot into the new instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) session.handleRunning(summary.running)
// Replay approval/question frames buffered before instantiation (rpcId verbatim, same semantics as the subscribed baseline replay).
// Replay approval/question/queued frames buffered before instantiation (rpcId
// verbatim, same semantics as the subscribed baseline replay). Replay happens
// BEFORE the running-bit sync: a not-running summary must sweep replayed queue
// rows the same way a live status flip would (their retirement events dropped
// while the session was uninstantiated).
const buffered = this.pendingBuffers.get(sessionId)
if (buffered !== undefined) {
this.pendingBuffers.delete(sessionId)
for (const envelope of buffered) session.handleMuxEnvelope(envelope.rpcId, envelope.payload)
}
// Sync the running and blank bits from the list snapshot into the new
// instance (consistency when the list precedes open).
const summary = this.summaries.find(s => s.sessionId === sessionId)
if (summary !== undefined) {
session.handleBlank(summary.blank)
session.handleRunning(summary.running)
}
}
return session
}
private createSession(sessionId: SessionId): Session {
return new Session(sessionId, this.api, {
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
onEngaged: (engaged) => {
this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId })
},
projections: this.projectionStore(sessionId),
})
}
/** Resident per-session projection store (create-on-demand; outlives instantiation). */
private projectionStore(sessionId: SessionId): ProjectionValueStore {
let store = this.projectionStores.get(sessionId)
if (store === undefined) {
store = new ProjectionValueStore()
// List rows project off store keys (title); any-key changes re-enter
// the manager's own batched rebuild channel.
store.subscribeAny(() => { this.notifier.markDirty() })
this.projectionStores.set(sessionId, store)
}
return store
}
// ---- List surface ----
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
@@ -94,15 +195,28 @@ export class SessionManager {
if (this.listInflight !== null) return this.listInflight
this.listState = 'loading'
this.listError = null
const established = this.summaries
const mutations: SessionListMutation[] = []
this.listMutations = mutations
this.notifier.markDirty()
this.listInflight = (async () => {
try {
const { result } = await this.api.sessions.list({})
if (result.ok) {
this.summaries = result.value.items
let summaries = this.listPhase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId)
for (const mutation of mutations) summaries = applyMutation(summaries, mutation)
this.summaries = summaries
this.listState = 'idle'
// Push running bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) this.sessions.get(s.sessionId)?.handleRunning(s.running)
this.listPhase = 'ready'
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
for (const s of this.summaries) {
const session = this.sessions.get(s.sessionId)
if (session === undefined) continue
session.handleBlank(s.blank)
session.handleRunning(s.running)
}
} else {
this.listState = 'error'
this.listError = result.error
@@ -113,6 +227,7 @@ export class SessionManager {
/* v8 ignore next -- the `? null` arm is unreachable: transportError always returns ok:false. */
this.listError = folded.ok ? null : folded.error
} finally {
this.listMutations = null
this.listInflight = null
this.notifier.markDirty()
}
@@ -122,19 +237,38 @@ export class SessionManager {
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh).
* @param cwd - optional working directory for the new session.
* wait for the next refresh). A created session is blank by definition
* (entity birth precedes the first message).
* @param opts - target workspace or working directory, plus an optional caller-owned id.
* @returns the create result.
*/
async create(cwd?: string): Promise<RpcResult<{ sessionId: SessionId }>> {
async create(
opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {},
): Promise<RpcResult<{ sessionId: SessionId }>> {
try {
const { result } = await this.api.sessions.create(cwd === undefined ? {} : { cwd })
if (result.ok && !this.summaries.some(s => s.sessionId === result.value.sessionId)) {
this.summaries = [
{ sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, ...(cwd !== undefined ? { cwd } : {}) },
...this.summaries,
]
this.notifier.markDirty()
const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId }
const payload = opts.workspaceId !== undefined
? { workspaceId: opts.workspaceId, ...shared }
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
const { result } = await this.api.sessions.create(payload)
if (result.ok) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
} })
} else {
const publishedSessionId = workspaceAttachSessionId(result.error)
// Publication precedes attachment. The error's id is a real Session,
// so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
sessionId: publishedSessionId,
updatedAt: Date.now(),
running: false,
blank: true,
} })
}
}
return result
} catch (error) {
@@ -142,6 +276,23 @@ export class SessionManager {
}
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the
* create() echo race — whichever lands second must fill the placeholder's
* missing cwd/parentSessionId, never overwrite list-refresh data).
*/
private mergeSummary(summary: SessionSummary): void {
this.recordMutation({ kind: 'upsert', summary })
}
/** Apply immediately and retain for replay when a list response is in flight. */
private recordMutation(mutation: SessionListMutation): void {
this.listMutations?.push(mutation)
this.summaries = applyMutation(this.summaries, mutation)
this.notifier.markDirty()
}
// ---- Subscription surface (for useSessionList) ----
/**
@@ -173,22 +324,32 @@ export class SessionManager {
handleMuxEnvelope(envelope: RpcRequest<MuxFrame>): void {
const frame = envelope.payload
if (frame.type === 'stream/error') return // Controller already treats this as stream failure
if (frame.type === 'session/title') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq >= frame.eventSeq) return
this.titleSnapshots.set(frame.sessionId, {
title: frame.title,
eventSeq: frame.eventSeq,
updatedAt: frame.updatedAt,
})
if (frame.type === 'session/projection') {
// Finished host-computed value: land it in the resident store whether or
// not the Session is instantiated (list rows read the 'title' key). The
// synchronous markDirty keeps the list snapshot same-tick fresh (the
// store's own any-key channel is microtask-batched).
this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq)
this.notifier.markDirty()
return
}
if (frame.type === 'session/subscribed') {
const current = this.titleSnapshots.get(frame.sessionId)
if (current !== undefined && current.eventSeq > frame.lastSeq) {
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
// Rows past the host's durable baseline rode state a restart lost; drop
// them so last-wins cannot pin a phantom value over recomputed truth.
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
this.notifier.markDirty()
// New mux-generation baseline: buffered session/queued frames belong to
// the previous generation and the host is about to resend the live
// snapshot — drop them, or every reconnect appends a duplicate batch
// (and enough reconnects push real approval/question frames past the
// cap). Same re-baseline signal Session uses for its own mirror.
const buffered = this.pendingBuffers.get(frame.sessionId)
if (buffered !== undefined) {
const kept = buffered.filter(item => item.payload.type !== 'session/queued')
if (kept.length !== buffered.length) {
if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId)
else this.pendingBuffers.set(frame.sessionId, kept)
}
}
}
// List-level waiting-approval bit (the sidebar amber dot): tracked here for
@@ -209,13 +370,15 @@ export class SessionManager {
}
const session = this.sessions.get(frame.sessionId)
if (session === undefined) {
// Approval/question frames never hit history: buffer for replay on instantiation;
// everything else drops (not instantiated — history fully backfills on open).
// Approval/question/queued frames never hit history: buffer for replay on
// instantiation; everything else drops (not instantiated — history fully
// backfills on open).
switch (frame.type) {
case 'approval/requested':
case 'approval/resolved':
case 'question/requested':
case 'question/resolved': {
case 'question/resolved':
case 'session/queued': {
const buffer = this.pendingBuffers.get(frame.sessionId) ?? []
buffer.push(envelope)
if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP)
@@ -237,32 +400,25 @@ export class SessionManager {
const frame = envelope.payload
switch (frame.type) {
case 'host/session-added': {
if (!this.summaries.some(s => s.sessionId === frame.sessionId)) {
this.summaries = [
{
sessionId: frame.sessionId, updatedAt: Date.now(), running: false,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
},
...this.summaries,
]
this.notifier.markDirty()
}
this.mergeSummary({
sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank,
...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}),
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
return
}
case 'host/session-removed': {
this.summaries = this.summaries.filter(s => s.sessionId !== frame.sessionId)
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
this.sessions.get(frame.sessionId)?.handleRemoved() // instance survives (resident-instance rule), only flagged in the snapshot
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
this.titleSnapshots.delete(frame.sessionId)
this.notifier.markDirty()
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
return
}
case 'host/session-status': {
this.summaries = this.summaries.map(s =>
s.sessionId === frame.sessionId && s.running !== frame.running ? { ...s, running: frame.running } : s)
this.recordMutation({ kind: 'status', sessionId: frame.sessionId, running: frame.running })
this.sessions.get(frame.sessionId)?.handleRunning(frame.running)
this.notifier.markDirty()
return
}
case 'host/agent-error': {
@@ -274,7 +430,7 @@ export class SessionManager {
}
}
/** After each connection generation (first connect included): refresh the list + resync opened instances (reconnect = rebuild). */
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
// Approvals resolved while disconnected send no frame: drop the bits and
// let the mux-open replay re-add every still-pending question.
@@ -288,16 +444,19 @@ export class SessionManager {
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
const title = this.titleSnapshots.get(summary.sessionId)
return title === undefined
? summary
: { ...summary, title: title.title, updatedAt: Math.max(summary.updatedAt, title.updatedAt) }
// List rows read the generic 'title' projection key (host-computed unit
// value; the bespoke session/title frame is retired).
const title = this.projectionStores.get(summary.sessionId)?.get('title')
return typeof title === 'string' && title !== ''
? { ...summary, title }
: summary
})
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
const items = fresh.map((entry) => {
const prev = this.entryCache.get(entry.sessionId)
if (
prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.title === entry.title && prev.depth === entry.depth
&& prev.waitingApproval === entry.waitingApproval
@@ -310,6 +469,57 @@ export class SessionManager {
}
const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i])
if (!sameOrder) this.itemsCache = items
return { items: this.itemsCache, state: this.listState, error: this.listError }
const selected = this.selected
const current = selected !== undefined && items.some(item => item.sessionId === selected)
? selected
: undefined
return {
items: this.itemsCache,
current,
state: this.listState,
phase: this.listPhase,
error: this.listError,
}
}
}
/** Apply one list mutation without deriving display order. */
function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] {
switch (mutation.kind) {
case 'upsert': {
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId)
if (existing === undefined) return [mutation.summary, ...summaries]
const filled: SessionSummary = {
...existing,
// Blank only lowers: a stale true (session-added racing the local
// first send) never re-hides an already-surfaced session.
blank: existing.blank && mutation.summary.blank,
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
? { parentSessionId: mutation.summary.parentSessionId } : {}),
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.blank === existing.blank) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
case 'remove':
return summaries.filter(summary => summary.sessionId !== mutation.sessionId)
case 'status':
// running:true doubles as the cross-端 blank flip (a blank session
// never runs, so the first running frame proves a message landed).
return summaries.map(summary => summary.sessionId === mutation.sessionId
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
: summary)
case 'engaged':
return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank
? { ...summary, blank: false }
: summary)
}
}
/** Temporary source-plane bridge while the Host contract and client project build independently. */
function workspaceAttachSessionId(error: RpcError): SessionId | undefined {
const candidate = error as unknown as { code: string; details: { sessionId?: SessionId } }
return candidate.code === 'workspace-attach-failed' ? candidate.details.sessionId : undefined
}

View File

@@ -3,11 +3,17 @@
// the flush rebuilds the snapshot cache BEFORE notifying (useSyncExternalStore requires a stable
// getSnapshot reference). With no listeners the rebuild is skipped and only the dirty bit is set
// (keeps frame storms cheap); the next getSnapshot rebuilds lazily.
//
// Freshness and notification are SEPARATE bits: a pull (ensureFresh) between
// markDirty and the scheduled flush rebuilds the snapshot but must not
// swallow the notification — push subscribers (object-layer watchers) would
// otherwise starve whenever any reader pulls first.
/** Subscription + microtask-batched notification primitive (shared by Session and SessionManager). */
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled = false
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
@@ -28,14 +34,18 @@ export class Notifier {
/** State-change entry: mark dirty and schedule the batched flush. */
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled) return
this.scheduled = true
queueMicrotask(() => {
this.scheduled = false
if (!this.dirty) return
if (this.listeners.size === 0) return // lazy: no subscribers, keep dirty for the next getSnapshot
this.dirty = false
this.rebuild()
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: no subscribers; dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
for (const listener of this.listeners) listener()
})
}
@@ -46,13 +56,18 @@ export class Notifier {
*/
notifyNow(): void {
this.dirty = true
this.notifyPending = true
if (this.listeners.size === 0) return // lazy: same as markDirty, next getSnapshot rebuilds
this.notifyPending = false
this.dirty = false
this.rebuild()
for (const listener of this.listeners) listener()
}
/** Pre-getSnapshot check: rebuild synchronously when dirty (read path before first subscribe / while unobserved). */
/**
* Pre-getSnapshot check: rebuild synchronously when dirty (read path
* before first subscribe / while unobserved). Notification stays pending.
*/
ensureFresh(): void {
if (!this.dirty) return
this.dirty = false

View File

@@ -0,0 +1,183 @@
/**
* Generic per-session projection value store (session-projection RFC, push
* model): the host is the only computation site; the client holds finished
* whole values per key — `key → { value, seq }` — seeded by the history tail
* page's projections block and updated by `session/projection` push frames,
* under the single rule **higher seq wins**. No client-side domain folding
* exists: a domain ships projection support with zero client code. Per-key
* bare observable faces feed `useProjection` (web-react binds them).
*/
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from './notifier.ts'
// The single projection type table, typed end to end (host unit, wire block,
// client store, React hook) — the interface package's pure-type outlet
// (`/types`, zero imports), never the package root: the root's dsh-agent →
// dsh-session chain would drag the host `Context.sessions` merge into the
// client program (one program must not hold both sides). No second
// client-side "views" table (user ruling, RFC Alternatives).
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
/**
* The fifth framework hook seat (session-projection RFC): key-addressed
* projection reader delivered through the standard kit. `undefined` uniformly
* means capability absent — host unit unmounted, or no baseline/frame has
* carried the key yet. The selector overload mirrors useSession (per-key uSES
* binding; reference stability holds because a key's value reference changes
* only when a frame or baseline lands).
*/
export type UseProjection = {
<K extends Extract<keyof SessionProjectionMap, string>>(key: K): SessionProjectionMap[K] | undefined
<K extends Extract<keyof SessionProjectionMap, string>, S>(
key: K,
selector: (value: SessionProjectionMap[K] | undefined) => S,
eq?: (a: S, b: S) => boolean,
): S
}
/**
* Tail-page projections baseline — structurally identical to the wire's
* `SessionProjectionsBlock` (apiproxy api layer), restated here so the
* React-free store depends only on the type table, not the wire package's
* response vocabulary.
*/
export interface ProjectionsBaseline {
/** The consistent-cut seq (equals the window tail seq by construction). */
asOfSeq: number
/** Whole current values by key; a registered key absent here means the capability is absent. */
values: Partial<SessionProjectionMap>
}
/** One key's row: the latest finished value and the seq it is consistent with. */
interface Row {
value: unknown
seq: number
}
/** Per-key notification channel: the bare face plus its batching notifier. */
interface Channel {
face: ObservableSnapshot<unknown>
notifier: Notifier
}
/**
* One session's projection values. Framework semantics, uniform across every
* key: a baseline seeds rows at its cut, a push frame updates one row, and in
* both paths a lower-or-equal seq loses — a replayed frame cannot regress a
* value, a stale baseline cannot overwrite a newer frame. A key the store has
* never seen reads `undefined` (capability absent). Faces are identity-stable
* per key (create-on-demand, cached) so the React side binds each exactly
* once; the store-level channel (`subscribeAny`) serves coarse consumers (the
* manager's list projection reads the `title` key).
*/
export class ProjectionValueStore {
private readonly rows = new Map<string, Row>()
private readonly channels = new Map<string, Channel>()
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
private readonly anyNotifier = new Notifier(() => {})
/**
* Key-addressed bare observable face (the useProjection resolution path).
* Always defined — absence is an `undefined` snapshot, never a missing
* face, so a component may subscribe before the key ever carries a value.
* @param key - projection key.
* @returns the identity-stable face for this key.
*/
faceOf(key: string): ObservableSnapshot<unknown> {
return this.channel(key).face
}
/**
* Current whole value for a key (erased framework read; typed reads go
* through `useProjection`'s map lookup).
* @param key - projection key.
* @returns the value, or undefined while the key is absent.
*/
get(key: string): unknown {
return this.rows.get(key)?.value
}
/**
* Subscribe to any-key changes (microtask-batched) — the manager's list
* rebuild channel.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribeAny(listener: () => void): () => void {
return this.anyNotifier.subscribe(listener)
}
/**
* Apply one finished value (the `session/projection` push-frame path).
* @param key - projection key.
* @param value - whole value computed by the host unit.
* @param seq - the unit's watermark at emission.
*/
apply(key: string, value: unknown, seq: number): void {
const row = this.rows.get(key)
if (row !== undefined && seq <= row.seq) return // higher seq wins; replays and stale frames drop
this.rows.set(key, { value, seq })
this.changed(key)
}
/**
* Seed from a history tail page's projections block: every carried key
* lands under the same seq rule as frames; a key the block omits is
* capability-absent as of the cut — its row clears unless a newer frame
* already superseded the cut (a stale baseline can neither overwrite nor
* clear newer values).
* @param baseline - the response's projections block.
*/
seed(baseline: ProjectionsBaseline): void {
// Erased walk: the framework crosses the open key space; per-key typing
// is re-established at the consumer (useProjection's map lookup).
const values = baseline.values as Record<string, unknown>
for (const key of Object.keys(values)) this.apply(key, values[key], baseline.asOfSeq)
for (const [key, row] of this.rows) {
if (Object.hasOwn(values, key)) continue
if (row.seq > baseline.asOfSeq) continue
this.rows.delete(key)
this.changed(key)
}
}
/**
* Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`):
* a row claiming knowledge beyond the host's own durable baseline rode
* state a restart lost — under last-wins it would wrongly outrank the
* host's recomputed (lower-seq) values forever. Durable replay and the next
* baseline re-seed whatever truly survived (the title-snapshot precedent,
* generalized).
* @param lastSeq - the subscribed frame's durable baseline seq.
*/
truncate(lastSeq: number): void {
for (const [key, row] of this.rows) {
if (row.seq <= lastSeq) continue
this.rows.delete(key)
this.changed(key)
}
}
private changed(key: string): void {
this.channels.get(key)?.notifier.markDirty()
this.anyNotifier.markDirty()
}
private channel(key: string): Channel {
let channel = this.channels.get(key)
if (channel === undefined) {
// The notifier only batches (no snapshot cache to rebuild: faces read rows directly).
const notifier = new Notifier(() => {})
channel = {
notifier,
face: {
getSnapshot: () => this.rows.get(key)?.value,
subscribe: listener => notifier.subscribe(listener),
},
}
this.channels.set(key, channel)
}
return channel
}
}

View File

@@ -2,8 +2,9 @@
* SessionsService: root sessions service — list snapshot store (manager
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off — migrated here from ui-layout per the
* slot-parity design), session scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag), stable SessionBinding cache, ancestry walk.
* slot-parity design), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, ancestry walk.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
@@ -15,11 +16,15 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
import type { IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionCell } from '@deepseek-ai/dsh-client-ui-slots'
import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionListPhase } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
@@ -34,6 +39,13 @@ export interface SessionSummary {
running: boolean
/** An approval question is pending on this session (sidebar amber-dot state). */
waitingApproval: boolean
/**
* Empty-log bit (host summary derivation mirror). New Session reuses a blank
* one targeting the same workspace. Filtering stays with the consumer: the
* store carries every row, while the Workspace browser shows only the
* selected blank entry.
*/
blank: boolean
updatedAt: number
}
@@ -42,7 +54,29 @@ export interface SessionSummary {
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and SessionProvider share one fact source).
*/
export interface SessionListState { ids: SessionId[]; byId: Record<SessionId, SessionSummary>; current: SessionId | undefined }
export interface SessionListState {
ids: SessionId[]
byId: Record<SessionId, SessionSummary>
current: SessionId | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
}
/** Structured session-create failure. */
export class SessionCreateError extends Error {
override readonly name = 'SessionCreateError'
/**
* @param rpcError - Host business or folded transport error.
* @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation.
*/
constructor(
readonly rpcError: RpcError,
readonly requestedSessionId: SessionId | undefined,
) {
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
export interface SessionBinding {
@@ -51,21 +85,25 @@ export interface SessionBinding {
readonly ctx: Context
}
/** Scope tag key (client counterpart of the host dsh-scope pattern). */
const kScope = Symbol('dsh.client.scope')
// Scope primitives live in ../agents/scope.ts (the client mirror of host
// dsh-scope, keyed by Agent identity); re-exported here so existing
// consumers keep their import site.
export { scopeOf } from '../agents/scope.ts'
/**
* Read the session scope tag off a context.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
* Workspace display title of a session cwd: the path's last non-empty
* segment (both separators accepted; trailing separators ignored), or ''
* for separator-only paths — callers own their fallback (session id, raw
* cwd, default-directory copy). The repo-wide single basename derivation —
* every surface naming a workspace (picker rows, toggle labels, list titles)
* calls this instead of re-splitting paths.
* @param cwd - workspace directory path.
* @returns basename title, or '' when no non-empty segment exists.
*/
export function scopeOf(ctx: Context): SessionId | undefined {
return (ctx as Context & { [kScope]?: SessionId })[kScope]
export function workspaceTitleOf(cwd: string): string {
return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
}
/** Shared no-op plugin backing each session scope fiber. */
function sessionScope(): void {}
/**
* Display title projection: durable title, project directory basename, then
* the raw id.
@@ -73,8 +111,8 @@ function sessionScope(): void {}
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
if (base !== undefined && base !== '') return base
const base = workspaceTitleOf(cwd)
if (base !== '') return base
}
return id
}
@@ -83,16 +121,45 @@ interface ScopeRecord {
fiber: Fiber
ctx: Context
binding: SessionBinding
/** Render-layer standard kit (identity-stable per scope; the renderer's per-cell caches key off it). */
cell: SessionCell
/** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
provideInfo: SessionProvideInfo
}
/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */
export interface SessionProvideContribution {
/** Bare observable sources, keyed by hook base name ('input' → useInput). */
hooks?: Record<string, HostObservable<unknown>>
/** Stable plain members (action callbacks etc.), spread into standard props verbatim. */
props?: Record<string, unknown>
}
/**
* Static declaration plus per-session resolver for one standard-kit
* contribution. The declared names let the renderer construct the same hook
* and prop surface while no session is current.
*/
export interface SessionProvideDescriptor {
/** Hook base names (`input` becomes `useInput`). */
hooks?: readonly string[]
/** Plain standard-prop names. */
props?: readonly string[]
/** Resolve every declared member for one definite session. */
resolve(binding: SessionBinding): SessionProvideContribution
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService {
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry (wired to the connection by the runtime apply). */
readonly manager: SessionManager
/** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source (the renderer
* host's `sessions.provide` feed), so a roster change under a stable
* current id republishes the bundle instead of stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/**
* Persisted selection cell (the durable half of `list.current`). Private on
@@ -105,6 +172,14 @@ export class SessionsService {
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** Registered per-session standard-props providers, in registration order. */
private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
@@ -119,12 +194,14 @@ export class SessionsService {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
this.manager = new SessionManager(api)
constructor(private readonly rootCtx: Context, api: IApiClient) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
this.list = createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined })
this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
this.manager.subscribe(() => { this.projectList() })
@@ -134,69 +211,211 @@ export class SessionsService {
// dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() })
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
this.providers.push({
hooks: ['session'],
resolve: binding => ({ hooks: { session: binding.session } }),
})
this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
},
}
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Register a per-session standard-props provider: every session-scope slot
* component receives the contributed members as standard props (`hooks`
* sources become `use<Name>` selector hooks on the render side; `props`
* spread verbatim). Contributions materialize lazily with the session's
* scope record and die with it. Registration order is resolution order;
* duplicate member names fail loud at materialization.
* @param descriptor - static member roster plus per-session resolver.
* @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops).
*/
provide(descriptor: SessionProvideDescriptor): () => void {
this.providers.push(descriptor)
// Scopes may already exist (boot order: the list lands and resolves
// scopes before later plugins register) — their bundles must include
// every provider by first render, so re-materialize on roster change.
this.rematerializeProvideBundles()
return () => {
const at = this.providers.indexOf(descriptor)
if (at >= 0) this.providers.splice(at, 1)
this.rematerializeProvideBundles()
}
}
/** Rebuild every live scope's standard-props bundle after a provider roster change. */
private rematerializeProvideBundles(): void {
this.maybeInfo = this.materializeMaybeProvideInfo()
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
}
/** Build the static no-session kit and reject duplicate declared names. */
private materializeMaybeProvideInfo(): SessionMaybeProvideInfo {
const hooks: Record<string, undefined> = {}
const props: Record<string, undefined> = {}
for (const descriptor of this.providers) {
for (const name of descriptor.hooks ?? []) {
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = undefined
}
for (const name of descriptor.props ?? []) {
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = undefined
}
}
return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
}
/** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */
private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo {
const hooks: Record<string, HostObservable<unknown>> = {}
const props: Record<string, unknown> = {}
for (const descriptor of this.providers) {
const contribution = descriptor.resolve(binding)
const contributedHooks = contribution.hooks ?? {}
const contributedProps = contribution.props ?? {}
for (const name of Object.keys(contributedHooks)) {
if (!(descriptor.hooks ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared hook "${name}"`)
}
}
for (const name of Object.keys(contributedProps)) {
if (!(descriptor.props ?? []).includes(name)) {
throw new Error(`sessions.provide: undeclared prop "${name}"`)
}
}
for (const name of descriptor.hooks ?? []) {
const source = contributedHooks[name]
if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
hooks[name] = source
}
for (const name of descriptor.props ?? []) {
if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
props[name] = contributedProps[name]
}
}
return {
sessionId: binding.sessionId,
hooks,
props,
// The useProjection seat: key-addressed bare value faces off the
// session's projection store (open key space — never a static roster member).
projections: { faceOf: key => binding.session.projections.faceOf(key) },
}
}
/**
* Select a session as current. Unknown ids fail loud instead of navigating
* nowhere.
* @param id - session id (must exist in the list store).
*/
open(id: SessionId): void {
if (this.list.getSnapshot().byId[id] === undefined) {
throw new Error(`sessions.open: unknown session ${id}`)
}
this.selection.update((draft) => { draft.sessionId = id })
this.list.update((draft) => { draft.current = id })
this.manager.select(id)
}
/**
* Clear the current selection so the layout shows the no-session empty
* state. Wipes the persisted selection too — a reload stays on empty until
* the user opens or starts a session. Staging holds the previous occupant
* across the blank (same masked-gap rule as a transient list miss).
* state (new-session affordance and the workspace preselection flow).
* Wipes the persisted selection too — a reload stays on empty until the
* user opens or starts a session. The staged scope keeps its frozen view
* per the masked-gap contract until the next open() moves the stage.
*/
clear(): void {
this.selection.set({})
this.list.update((draft) => { draft.current = undefined })
this.manager.clearSelection()
}
/**
* Create a session on the host.
* @param opts - creation options (project directory).
* @returns the new session id.
* Refresh the real Session baseline, reusing an in-flight pull.
* @returns completion of the current or newly started baseline pull.
*/
async create(opts: { cwd?: string } = {}): Promise<SessionId> {
const result = await this.manager.create(opts.cwd)
if (!result.ok) throw new Error(`session create failed: ${result.error.code}: ${result.error.message}`)
refresh(): Promise<void> {
return this.manager.refreshList()
}
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.
*/
handleMuxEnvelope(envelope: Parameters<SessionManager['handleMuxEnvelope']>[0]): void {
this.manager.handleMuxEnvelope(envelope)
}
/**
* Route a Host stream envelope into the Session object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<SessionManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Session baseline and every opened window after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
/**
* Create a session on the host. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and
* {@link SessionsService.binding} resolves it — callers (New Session
* draft hand-off) may address the scope synchronously, without waiting a
* notifier flush. The synchronous projection below makes this structural
* rather than an accident of microtask ordering.
* @param opts - target workspace or directory and an optional preallocated id.
* @returns the new session id.
* @throws {SessionCreateError} with the requested id.
*/
async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise<SessionId> {
const result = await this.manager.create(opts)
if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId)
this.projectList()
return result.value.sessionId
}
/**
* Create a workspace folder under the host process cwd and a session in it.
* Name is a single path segment (no separators); the host mkdir runs inside
* session.create. Caller opens the returned id when it wants the session staged.
* @param name - workspace folder basename.
* @returns the new session id.
*/
async createWorkspace(name: string): Promise<SessionId> {
const trimmed = name.trim()
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
if (/[/\\]/.test(trimmed)) {
throw new Error('sessions.createWorkspace: name must not contain path separators')
}
const { result } = await this.api.host.describe({})
if (!result.ok) {
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
}
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
return this.create({ cwd: `${hostCwd}/${trimmed}` })
}
/**
* Resolve a session-scoped context view (use-and-discard).
* @param id - session id.
* Resolve an Agent-scoped context view (use-and-discard).
* @param id - session id (the agent identity — 1:1 same axis).
* @returns scoped ctx, or undefined for a session neither listed nor already scoped.
*/
scope(id: SessionId): Context | undefined {
@@ -204,7 +423,7 @@ export class SessionsService {
}
/**
* Read the session scope tag off a context. Service-method seam: fetch
* Read the Agent scope tag off a context. Service-method seam: fetch
* bundles must reach scope resolution through ctx.sessions — a cross-bundle
* value import of the standalone helper would inline a second module
* instance whose private tag Symbol never matches.
@@ -212,7 +431,22 @@ export class SessionsService {
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
return scopeOf(ctx)
return scopeTagOf(ctx)
}
/**
* Resolve the business Session behind an Agent-scoped context — the one
* hop every scoped consumer (event listeners, per-session controllers)
* takes from ctx-space into object-space (the client mirror of host
* `agent.session`). Same service-method seam as
* {@link SessionsService.scopeOf}.
* @param ctx - an Agent-scoped context.
* @returns the Session, or undefined when the ctx is untagged or its scope was pruned.
*/
sessionOf(ctx: Context): Session | undefined {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
}
/**
@@ -226,16 +460,22 @@ export class SessionsService {
}
/**
* Resolve the render-layer session cell (SessionProvider's feed through
* the renderer host; ctx never enters the render layer). Pure resolution —
* render-safe: SessionProvider calls this during render, so no staging, no
* window side effects (StrictMode double-invokes and concurrent discarded
* passes must stay free).
* @param id - session id.
* @returns cell, or undefined for a session neither listed nor already scoped.
* Resolve one session's render-layer standard-props bundle (ctx never
* enters the render layer; the renderer subscribes to
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
* no staging, no window side effects (StrictMode double-invokes and
* concurrent discarded passes must stay free).
*/
cell(id: string): SessionCell | undefined {
return this.resolve(id as SessionId)?.cell
private provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
*/
private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}
/**
@@ -246,11 +486,12 @@ export class SessionsService {
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
const current = this.list.getSnapshot().current
const snapshot = this.list.getSnapshot()
const current = snapshot.current
// A masked gap (current blanked while the selection's session is
// transiently absent) holds the stage: tearing down on the gap would
// destroy exactly the frozen scope the mask exists to preserve.
if (current === undefined || current === this.watched) return
if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
@@ -280,30 +521,42 @@ export class SessionsService {
return chain
}
/** Lazily mint the scope + binding for a listed (or already-scoped) session. */
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate (decision 12): listed on the host — a scope is
* born when its session enters the client's view (list mirror row from the
* baseline pull, a create() echo, or the session-added frame) and dies with
* the prune when the row leaves.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
// Frozen scopes outlive the list; new scopes are only minted for listed sessions.
if (this.list.getSnapshot().byId[id] === undefined) return undefined
const fiber = this.rootCtx.plugin(sessionScope)
const ctx = fiber.ctx.extend({ [kScope]: id })
if (!this.eligible(id)) return undefined
const { fiber, ctx } = createScope(this.rootCtx, id)
const session = this.manager.get(id)
// The Session owns its scoped dispatch point (host Agent.loopCtx mirror);
// mint and bind are one step so a live scope record implies a bound actx.
session.bindScope(ctx)
const binding: SessionBinding = { sessionId: id, session, ctx }
const record: ScopeRecord = {
fiber,
ctx,
binding: { sessionId: id, session, ctx },
// Bare source form (store migration): the Session object IS the
// observable; the React side binds the useSession hook per cell.
cell: { sessionId: id, session },
binding,
// Sources are bare observables; React binds selector hooks at its own seam.
provideInfo: this.materializeProvideInfo(binding),
}
this.scopes.set(id, record)
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed. */
private eligible(id: SessionId): boolean {
return this.list.getSnapshot().byId[id] !== undefined
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const items = this.manager.getListSnapshot().items
const { items, current, phase } = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
@@ -313,24 +566,30 @@ export class SessionsService {
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
waitingApproval: entry.waitingApproval,
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
}
}
// current = the persisted selection, masked while its session is absent
// (falls to the empty state; resurfaces if the session returns).
const selected = this.selection.getSnapshot().sessionId
const current = selected !== undefined && byId[selected] !== undefined ? selected : undefined
this.list.set({ ids, byId, current })
const persisted = this.selection.getSnapshot().sessionId
// No current (cleared, or masked gap) wipes the persisted cell — a reload
// stays on empty; the in-memory selection still resurfaces a masked id.
if (current === undefined) {
if (persisted !== undefined) this.selection.set({})
} else if (byId[current] !== undefined && persisted !== current) {
this.selection.set({ sessionId: current })
}
this.list.set({ ids, byId, current, phase })
this.pruneScopes(byId)
}
/** Tear down scopes for removed sessions off stage; the staged one defers until the stage moves. */
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(byId: Record<SessionId, SessionSummary>): void {
void byId
for (const [id, record] of this.scopes) {
if (byId[id] !== undefined) continue
if (this.eligible(id)) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
@@ -341,12 +600,22 @@ export class SessionsService {
}
}
/** Dispose a scope fiber and its session-keyed slot-store instances together (single lifecycle axis). */
/**
* One teardown for the whole per-session axis (decision 12): the scope
* fiber (cascading every actx-registered effect: input shell, slash
* controller, popup, plugin stores, listeners), the session-keyed slot
* stores, and the Session instance itself — the host session log is the
* durable truth, a reopen lazily rebuilds and backfills via open().
*/
private dropScope(id: SessionId, record: ScopeRecord): void {
void record.fiber.dispose()
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.binding.session.unbindScope()
// Optional lookup: slots and sessions are sibling services with no
// declared dependency; a slots-less boot (object-layer tests) skips.
this.rootCtx.get('slots')?.pruneStoreScope(id)
this.manager.drop(id)
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
@@ -356,8 +625,8 @@ export class SessionsService {
* stage move sweeps first, so the set cannot contain the id the stage just
* moved to; kept as a guard against future extra sweep call sites. */
if (id === this.watched) continue
// Still absent from the list? (A re-added id cancels the deferred teardown.)
if (this.list.getSnapshot().byId[id] !== undefined) {
// Eligible again? (A re-added id cancels the deferred teardown.)
if (this.eligible(id)) {
this.deferredRemovals.delete(id)
continue
}

View File

@@ -1,8 +1,6 @@
// Session: wraps every contract call that needs a sessionId + all conversation state for this
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
// created, they keep consuming mux frames in the background; React connects directly via
// subscribe/getSnapshot.
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
@@ -14,22 +12,62 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ConversationNode, ConversationSnapshot, OpenState, PromptError, RunningToolCall,
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
import { FoldAdapter } from './fold-adapter.ts'
import { Notifier } from './notifier.ts'
import { PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
* into its list row so the session surfaces without waiting for a host
* frame. Acceptance is the flip point because it proves the user message
* is in the host log; a rejected first prompt keeps the session blank
* (hidden, still reusable by connectWorkspace).
*/
onEngaged?(session: Session): void
/**
* Manager-owned projection value store to adopt (frames route through the
* manager and values outlive instantiation); omitted, the Session owns a
* private store (bare object-layer construction).
*/
projections?: ProjectionValueStore
}
/** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */
const QUEUE_PREVIEW_CHARS = 200
/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */
interface QueuedEntry {
row: QueuedMessage
steering: boolean
/** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */
sourceJson: string
}
/** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */
function queuePreviewOf(content: readonly ContentBlock[]): string {
const flat = content
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
.join(' ').replace(/\s+/g, ' ').trim()
const chars = Array.from(flat)
return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}` : flat
}
/**
* Per-session state owner: event window + fold + partial, snapshot out via
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
* only (store migration): the React machinery binds the per-cell useSession
* hook at its own seam — no selector hook member lives on the data layer.
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer.
*/
export class Session implements ObservableSnapshot<ConversationSnapshot> {
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
@@ -54,8 +92,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
private frozenNodes: ConversationNode[] = []
private pending = new Map<string, PendingInteraction>()
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
@@ -63,28 +100,95 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
/** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history,
* so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */
private queued: QueuedEntry[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
* engaging edge of the phase machine (see ComposerPhase).
*/
private promptAttempted = false
/** Empty-log mirror (see ConversationSnapshot.blank); monotone false once flipped. */
private blankBit = false
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
/** Live events buffered during open/resync and stitched by sequence once history lands. */
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
private stitching = false
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
private subscribedLastSeq: number | null = null
/**
* Per-session projection value store (session-projection RFC, push model):
* finished whole values computed on the host, seeded by the tail page's
* projections block and updated by `session/projection` frames under the
* one higher-seq-wins rule. Keys are read via `projections.faceOf(key)`
* (the useProjection resolution face); the conversation snapshot never
* carries projection values, and no client-side domain folding exists.
* Manager-owned when constructed through SessionManager (frames route and
* the store outlives instantiation, the title-snapshot precedent); a bare
* construction gets a private store.
*/
readonly projections: ProjectionValueStore
private snapshotCache: ConversationSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* Agent-scoped cordis context, bound once by SessionsService when it
* mints the scope (the client mirror of the host Agent's loopCtx). The
* Session dispatches its own scoped events through it; undefined means
* unbound (bare object-layer construction) or already pruned — both skip
* dispatch-dependent behavior rather than fail.
*/
private actx: Context | undefined
constructor(readonly sessionId: SessionId, private readonly api: IApiClient) {
/**
* @param sessionId - Host session identity (client sessions are always Host-born).
* @param api - shared wire client.
* @param options - optional manager-owned state observers.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.snapshotCache = this.buildSnapshot()
}
/**
* Bind the Agent-scoped context minted by SessionsService (single write;
* a second bind is a wiring error and throws). Direction stays one-way at
* the seam: consumers still reach the Session via `sessions.sessionOf`,
* while the Session holds its own dispatch point (host Agent.loopCtx
* mirror).
* @param actx - the agent's scoped context.
*/
bindScope(actx: Context): void {
if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`)
this.actx = actx
}
/** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */
unbindScope(): void {
this.actx = undefined
}
// ---- Operations ----
/**
@@ -96,6 +200,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>> {
this.promptError = null
this.lastAgentError = null
// Synchronous, before the first await: the blank → engaging edge must be
// visible on the session area's very first frame when a caller sends
// ahead of navigation (first-send flow).
this.promptAttempted = true
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
@@ -106,6 +214,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (!result.ok) {
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
return result
}
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt has logged
// its user/message on the host (events.length > 0 is fact, not
// optimism), while a rejected first prompt must keep the session blank
// — the client-side blank mirror only ever lowers, so flipping early on
// a failure would surface the session forever and strip its
// connectWorkspace reuse eligibility against the host's authority.
if (this.blankBit) {
this.blankBit = false
this.options.onEngaged?.(this)
this.notifier.markDirty()
}
return result
}
@@ -207,6 +327,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* in-flight open first — its history request rode the dead connection and must not settle
* the fresh generation into 'error' (audit S4). */
async resync(): Promise<void> {
// The queue mirror is NOT cleared here: onConnected (which drives resync)
// races the mux frames — the fresh generation's baseline may have landed
// already, and the host never resends it. The mirror re-baselines on the
// session/subscribed frame instead (same stream as the queue snapshot
// that follows it, so ordering is guaranteed).
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
this.openGeneration++
this.openPromise = null
@@ -255,12 +380,36 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void {
switch (frame.type) {
case 'session/event': {
this.retireQueued(frame.event)
this.acceptLiveEvent(frame.event, frame.view)
return
}
case 'session/queued': {
const message = frame.message
// Row key: the enqueueing prompt's rpcId when it rode this wire (the
// provisional-echo reconciliation key); otherwise the frame envelope id.
const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}`
this.queued.push({
row: { key, preview: queuePreviewOf(message.content) },
steering: frame.steering,
sourceJson: JSON.stringify(message.source),
})
this.queueRev++
this.notifier.markDirty()
return
}
case 'session/subscribed': {
this.subscribedLastSeq = frame.lastSeq
return // pure baseline bookkeeping, no visible change
// New mux-generation baseline: the host pushes this session's queue
// snapshot AFTER the subscribed frame on the same stream, so the
// stale mirror clears here — race-free against onConnected/resync
// timing (clearing there could wipe a baseline that already landed).
if (this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
return
}
case 'approval/requested': {
const { type: _type, sessionId: _sid, ...payload } = frame
@@ -297,11 +446,40 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
// Leave-running sweep (host queuedMirror precedent): discard paths (cancel,
// terminal steering drop) have no per-entry frame, so ANY not-running signal
// with a nonempty mirror clears it — checked before the equality return so a
// stale replay on an already-idle session still sweeps.
if (!running && this.queued.length > 0) {
this.queued = []
this.queueRev++
this.notifier.markDirty()
}
// Turn-start conversion: a blank session never runs, so the first
// running:true proves another端's first message landed (设计稿 2.2).
if (running && this.blankBit) {
this.blankBit = false
this.notifier.markDirty()
}
if (this.running === running) return
this.running = running
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (list baseline and
* the session-added frame). Monotone: once any signal (local first send,
* running flip, an earlier summary) cleared it, a stale true never
* re-blanks.
* @param blank - the summary's derived empty-log bit.
*/
handleBlank(blank: boolean): void {
if (blank === this.blankBit) return
if (blank && (this.promptAttempted || this.running)) return
this.blankBit = blank
this.notifier.markDirty()
}
/** host/session-removed relay: flag the snapshot (instance survives — resident-instance rule). */
handleRemoved(): void {
this.removed = true
@@ -317,8 +495,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.notifier.markDirty()
}
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
/** No-op because session instances remain resident. */
dispose(): void {}
// ---- 私有 ----
@@ -350,13 +527,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.openError = result.error
return
}
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore)
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
this.openState = 'open'
} catch (error) {
@@ -373,14 +550,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Install the history window + stitch the liveBuffer (seq is the sole dedup key).
* Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here
* (doOpen flips it after install), so recursing would push every buffered event straight
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */
private installWindow(entries: HistoryEntry[], hasMore: boolean): void {
* back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1).
* A carried projections block seeds the value store (higher seq wins, so a stale
* baseline cannot overwrite a newer push frame); the window events themselves are
* never folded — the host is the only computation site. */
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.events = entries.map(e => e.event)
this.views = entries.map(e => e.view)
this.baseSeq = this.events[0]?.seq ?? 0
this.hasMore = hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views)
this.rebuildDerivedFromWindow()
if (projections !== undefined) this.projections.seed(projections)
const buffered = this.liveBuffer
this.liveBuffer = []
for (const item of buffered) this.appendLive(item.event, item.view)
@@ -429,7 +610,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore)
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
} catch (error) {
console.error('[web-runtime] gap repair failed:', error)
@@ -438,9 +619,89 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered
* turn/start claims the oldest non-steering entry; a steering/message drains the oldest
* steering entry with the same source (loop-authored steering matches nothing and drops none). */
private retireQueued(event: SessionEvent): void {
if (this.queued.length === 0) return
let index = -1
if (event.type === 'turn/start') {
if (event.data.trigger.kind !== 'message') return
index = this.queued.findIndex(entry => !entry.steering)
} else if (event.type === 'steering/message') {
const source = JSON.stringify(event.data.message.source)
index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source)
} else {
return
}
if (index < 0) return
this.queued.splice(index, 1)
this.queueRev++
this.notifier.markDirty()
}
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
// wire consumer narrows them structurally — the same posture as every
// other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch-start') {
// A started sub-dispatch enters the index as a RunningToolCall — the
// exact shape a native in-flight call renders from — under its parent
// run_code callId; it never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: CodeSubCall = {
callId: data.subCallId, name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
return
}
if ((event.type as string) === 'tool/code-dispatch') {
// Settlement replaces the running entry in place (same array position,
// so parallel sub-calls keep their start order) with the
// ToolResultNode form; a settle with no observed start (history window
// cut mid-pair, or a pre-start-event log) appends directly.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
// Duration source: the paired start's time when observed; null =
// unknown (settle-only window), matching the native tool-result
// contract so views never present a fabricated zero duration.
callTime: started === undefined ? null : started.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(
data.parentCallId,
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
)
this.dispatchesRev++
return
}
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
@@ -466,7 +727,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return
}
case 'tool/result': {
if (this.openCalls.delete(String(event.data.callId))) this.callsRev++
if (this.openCalls.delete(String(event.data.message.source.callId))) this.callsRev++
return
}
case 'turn/end': {
@@ -520,6 +781,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -552,21 +815,50 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
foldDegraded: degraded,
partial: this.partial?.toPartial() ?? null,
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,
this.promptAttempted,
),
removed: this.removed,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
}
}
}
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
* object: `hasContent` only grows within a window and `promptAttempted` is
* sticky, so blank → engaging → active never steps back; a failed first
* prompt stays engaging (retry semantics — see ComposerPhase).
* @param hasContent - any conversation material exists (nodes, partial, running turn, pending waits).
* @param promptAttempted - a prompt was initiated on this session object.
* @returns the derived phase.
*/
function derivePhase(hasContent: boolean, promptAttempted: boolean): ComposerPhase {
if (hasContent) return 'active'
return promptAttempted ? 'engaging' : 'blank'
}

View File

@@ -235,19 +235,16 @@ export class SlotsService extends Service {
}
}
/** Build (once) the host face the installed renderer reads; sessions resolve lazily at first render. */
/** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */
private hostFace(): SlotRendererHost {
if (this._host !== undefined) return this._host
const sessions = this.ctx.get('sessions')
if (sessions === undefined) {
throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
}
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
const workspaces = this.ctx.get('workspaces')
if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
}
this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn),
@@ -259,9 +256,9 @@ export class SlotsService extends Service {
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: {
list: sessions.list,
current,
cell: id => sessions.cell(id),
provideInfo: sessions.currentProvideInfo,
},
workspaces: { list: workspaces.list },
}
return this._host
}
@@ -270,13 +267,13 @@ export class SlotsService extends Service {
private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
const record = this._stores.get(handle)
if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
const key = record.scope === 'session' ? sessionId : ROOT_INSTANCE_KEY
if (key === undefined) throw new Error('session-scoped store resolution requires a session id')
const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId
if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
let instance = record.instances.get(key)
if (instance === undefined) {
// Session instances get the scope key (the engine suffixes the persist
// key per session); root instances stay keyless.
instance = record.scope === 'session' ? handle.create(key) : handle.create()
instance = record.scope === 'root' ? handle.create() : handle.create(key)
record.instances.set(key, instance)
}
return instance

View File

@@ -0,0 +1,289 @@
/** Workspace baseline, incremental-frame, and unary-action owner. */
import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
import { Notifier } from '../sessions/notifier.ts'
import { Workspace, type WorkspaceCreateInput } from './workspace.ts'
/** Monotone workspace-list arrival lifecycle. */
export type WorkspaceListPhase = 'pending' | 'ready'
/** Immutable workspace-list snapshot. */
export interface WorkspaceListSnapshot {
items: readonly WorkspaceView[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
}
type WorkspaceDelta =
| { type: 'upsert'; workspace: WorkspaceView }
| { type: 'remove'; workspaceId: WorkspaceId }
/** Workspace object cluster driven by one list baseline and changed-frame upserts. */
export class WorkspaceManager {
private items: Workspace[] = []
private itemViewsSource: readonly Workspace[] | null = null
private itemViewsCache: readonly WorkspaceView[] = []
private state: WorkspaceListSnapshot['state'] = 'idle'
private phase: WorkspaceListPhase = 'pending'
private error: RpcError | null = null
private inflight: Promise<void> | null = null
private refreshFrames: WorkspaceDelta[] | null = null
/**
* Ids this process has seen removed, kept for the connection's lifetime so
* a late changed frame or a stale baseline row cannot resurrect a deleted
* row. Correctness rests on Host ids never being reused (the registry mints
* a fresh `randomUUID` per record, including when the same directory is
* registered again) — a path-derived id scheme would turn these entries
* into permanent blindfolds and must clear them instead.
*/
private readonly removedIds = new Set<WorkspaceId>()
private snapshotCache: WorkspaceListSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/** @param api - shared wire client. */
constructor(private readonly api: IApiClient) {
this.snapshotCache = this.buildSnapshot()
}
/**
* Refresh from workspace.list. The first successful response establishes
* Host order; later responses update membership and values without moving
* identities already visible to the client. Frames arriving during the RPC
* are replayed over its response.
* @returns the shared in-flight refresh.
*/
refresh(): Promise<void> {
if (this.inflight !== null) return this.inflight
this.state = 'loading'
this.error = null
const established = this.itemViews()
const frames: WorkspaceDelta[] = []
this.refreshFrames = frames
this.notifier.markDirty()
this.inflight = (async () => {
try {
const { result } = await this.api.workspace.list({})
if (result.ok) {
let items = this.phase === 'pending'
? result.value.items
: mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId)
items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId))
for (const delta of frames) items = applyWorkspaceDelta(items, delta)
this.installViews(items)
this.state = 'idle'
this.phase = 'ready'
} else {
this.state = 'error'
this.error = result.error
}
} catch (error) {
this.state = 'error'
const folded = transportError<never>(error)
/* v8 ignore next -- transportError always returns the failure branch. */
this.error = folded.ok ? null : folded.error
} finally {
this.refreshFrames = null
this.inflight = null
this.notifier.markDirty()
}
})()
return this.inflight
}
/**
* Create or resolve a real Workspace, then publish its returned snapshot
* without waiting for the changed frame.
* @param input - name under workspaceRoot or an existing absolute path.
* @returns the wire result.
*/
async create(input: WorkspaceCreateInput): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
const workspace = new Workspace(this.api, input)
const completion = workspace.materialize()
if (completion === undefined) throw new Error('a local Workspace must be materializable')
const result = await completion
if (result.ok) this.upsert(result.value.workspace, workspace)
return result
}
/**
* Rename a Workspace, then publish its returned snapshot without waiting
* for the changed frame.
* @param workspaceId - target workspace.
* @param title - new display title.
* @returns the wire result.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> {
const { result } = await this.api.workspace.rename({ workspaceId, title })
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Delete a Workspace registration and remove its local projection from the
* unary response without waiting for the Host frame.
* @param workspaceId - target workspace.
* @returns the wire result.
*/
async delete(workspaceId: WorkspaceId): Promise<RpcResult<{ deleted: true }>> {
const { result } = await this.api.workspace.delete({ workspaceId })
if (result.ok) this.remove(workspaceId, true)
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the wire result.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<RpcResult<{ workspace: WorkspaceView }>> {
const { result } = await this.api.workspace.insertSessionBefore({
workspaceId, sessionId,
...beforeSessionId === undefined ? {} : { beforeSessionId },
})
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
* @param envelope - host stream envelope.
*/
handleHostEnvelope(envelope: RpcRequest<HostFrame>): void {
if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace)
else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId)
}
/** Re-pull the baseline after each connection generation. */
handleConnected(): void {
void this.refresh()
}
/**
* Subscribe to workspace snapshot invalidation.
* @param listener - snapshot invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached workspace snapshot after flushing pending notifications.
* @returns the cached workspace snapshot.
*/
getSnapshot(): WorkspaceListSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
private buildSnapshot(): WorkspaceListSnapshot {
return {
items: this.itemViews(),
state: this.state,
phase: this.phase,
error: this.error,
}
}
/** Upsert one Host view, optionally retaining the local object that materialized it. */
private upsert(view: WorkspaceView, identity?: Workspace): void {
if (this.removedIds.has(view.workspaceId)) return
this.refreshFrames?.push({ type: 'upsert', workspace: view })
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
// Mutation responses and changed frames race (two carriers, no ordering):
// reject a snapshot strictly older than the installed projection so a
// late unary response cannot roll back a newer frame.
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]
: this.items.map((item, position) => position === index ? identity : item)
} else if (index === -1) {
this.items = [new Workspace(this.api, view), ...this.items]
} else {
this.items[index]?.adopt(view)
this.items = [...this.items]
}
this.notifier.markDirty()
}
/** Remove one id idempotently and retain a tombstone against late echoes. */
private remove(workspaceId: WorkspaceId, direct = false): void {
this.refreshFrames?.push({ type: 'remove', workspaceId })
this.removedIds.add(workspaceId)
const items = this.items.filter(item =>
item.getSnapshot().view?.workspaceId !== workspaceId)
if (items.length === this.items.length) {
// The Host frame may have removed the row first but left its batched
// notification pending. A successful unary echo still flushes that
// committed state before the user action resolves.
if (direct) this.notifier.notifyNow()
return
}
this.items = items
if (direct) this.notifier.notifyNow()
else this.notifier.markDirty()
}
private installViews(views: readonly WorkspaceView[]): void {
const existing = new Map(
this.items.flatMap((workspace) => {
const view = workspace.getSnapshot().view
return view === undefined ? [] : [[view.workspaceId, workspace] as const]
}),
)
const installed = new Map<WorkspaceView['workspaceId'], Workspace>()
for (const view of views) {
const duplicate = installed.get(view.workspaceId)
if (duplicate !== undefined) {
duplicate.adopt(view)
continue
}
const workspace = existing.get(view.workspaceId) ?? new Workspace(this.api, view)
workspace.adopt(view)
installed.set(view.workspaceId, workspace)
}
this.items = [...installed.values()]
}
private itemViews(): readonly WorkspaceView[] {
if (this.itemViewsSource === this.items) return this.itemViewsCache
this.itemViewsSource = this.items
this.itemViewsCache = this.items.flatMap((workspace) => {
const view = workspace.getSnapshot().view
return view === undefined ? [] : [view]
})
return this.itemViewsCache
}
}
/** Known ids retain their position; a newly created Workspace enters first. */
function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceView): WorkspaceView[] {
const index = items.findIndex(item => item.workspaceId === workspace.workspaceId)
return index === -1
? [workspace, ...items]
: items.map((item, position) => position === index ? workspace : item)
}
/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */
function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] {
return delta.type === 'upsert'
? upsertWorkspace(items, delta.workspace)
: items.filter(workspace => workspace.workspaceId !== delta.workspaceId)
}

View File

@@ -0,0 +1,291 @@
/** WorkspacesService projects the Workspace object manager for UI consumers. */
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsService } from '../sessions/service.ts'
import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
/** Workspace list plus the two-baseline readiness and default-target projection. */
export interface WorkspaceListState {
items: readonly WorkspaceView[]
state: 'idle' | 'loading' | 'error'
phase: WorkspaceListPhase
error: RpcError | null
/** True only after both workspace.list and session.list have succeeded. */
baselinesReady: boolean
/** Most recently active Workspace, derived without changing `items` order. */
recentWorkspaceId: WorkspaceId | undefined
}
/** Structured create failure for UI flows that distinguish Host business errors. */
export class WorkspaceCreateError extends Error {
constructor(readonly rpcError: RpcError) {
super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'WorkspaceCreateError'
}
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
/** UI-facing immutable projection; the manager remains wire truth. */
readonly list: SnapshotStore<WorkspaceListState>
/** Workspace baseline and frame owner. */
private readonly manager: WorkspaceManager
/** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
/** Guards the runtime-owned one-shot initial-selection subscription. */
private initialSelectionStarted = false
/**
* @param ctx - client root context.
* @param api - shared wire client.
* @param sessions - lower-level Session service used for recency and blank-session reuse.
*/
constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
this.manager = new WorkspaceManager(api)
this.list = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'pending', error: null,
baselinesReady: false, recentWorkspaceId: undefined,
})
this.manager.subscribe(() => { this.project() })
this.sessions.list.subscribe(() => { this.project() })
ctx.reflect.provide('workspaces', this, undefined)
}
/**
* Resolve the session a New Session flow lands in once this Workspace is
* chosen: reuse the workspace's existing blank session when one is in the
* list mirror, else create a fresh one on the host (`session.create` births
* the full Session+Agent — the client holds no intermediate state). The
* caller owns navigation: take the returned id to `sessions.open`.
* Resolution guarantee (both arms): the returned id is already in the list
* store and `sessions.binding(id)` resolves synchronously — draft hand-off
* may write the new scope's machine before opening.
* @param workspaceId - chosen Workspace (must be in the workspace list).
* @returns the reused or newly created session id.
*/
async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId)
if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`)
// Coalesce concurrent connects: a create's summary lands without cwd
// until the host frame arrives, so a second call inside that window
// would miss the reuse scan and mint another hidden blank session.
const inflight = this.connecting.get(workspaceId)
if (inflight !== undefined) return inflight
// Reuse: blank && same canonical cwd (workspace.path is the host realpath
// canon; summary cwd is the session header passthrough of the same canon).
const sessions = this.sessions.list.getSnapshot()
for (const id of sessions.ids) {
const summary = sessions.byId[id]
if (summary !== undefined && summary.blank && summary.cwd === workspace.path) return summary.id
}
const attempt = this.sessions.create({ workspaceId })
.finally(() => { this.connecting.delete(workspaceId) })
this.connecting.set(workspaceId, attempt)
return attempt
}
/**
* Follow the first complete Workspace/Session baseline and select a default
* session exactly once. A restored current session wins; otherwise the most
* recent Workspace is connected (reusing or creating its blank session).
* Later explicit clears stay cleared instead of retriggering this startup
* policy. A failed connect may retry on the next baseline projection.
* @returns disposer for the baseline subscription; late work cannot navigate after disposal.
*/
startInitialSelection(): () => void {
if (this.initialSelectionStarted) {
throw new Error('workspaces.startInitialSelection: already started')
}
this.initialSelectionStarted = true
let state: 'waiting' | 'connecting' | 'done' = 'waiting'
let disposed = false
const reconcile = (): void => {
if (disposed || state !== 'waiting') return
const workspace = this.list.getSnapshot()
if (!workspace.baselinesReady) return
const current = this.sessions.list.getSnapshot().current
const target = workspace.recentWorkspaceId
if (current !== undefined || target === undefined) {
state = 'done'
return
}
state = 'connecting'
void this.connectWorkspace(target).then(
(sessionId) => {
if (disposed) return
if (this.sessions.list.getSnapshot().current === undefined) {
this.sessions.open(sessionId)
}
state = 'done'
},
(reason: unknown) => {
if (disposed) return
state = 'waiting'
console.warn('initial workspace selection failed:', reason)
},
)
}
const unsubscribe = this.list.subscribe(reconcile)
reconcile()
return () => {
disposed = true
unsubscribe()
}
}
/**
* The shared New Session action behind the shell entry points (sidebar
* button, workspace browser): resolve the target Workspace — explicit wins,
* else the recent-Workspace projection — connect its blank session and
* navigate there; with no Workspace at all, clear the selection into the
* New Session view state. Connect failures are non-fatal (console
* diagnostics; the current view stays usable).
* @param workspaceId - explicit target Workspace for scoped actions.
*/
startSession(workspaceId?: WorkspaceId): void {
const target = workspaceId ?? this.list.getSnapshot().recentWorkspaceId
if (target === undefined) {
this.sessions.clear()
return
}
void this.connectWorkspace(target).then(
(sessionId) => { this.sessions.open(sessionId) },
(reason: unknown) => { console.warn('new session failed:', reason) },
)
}
/**
* Create a Workspace by name or register an existing path.
* @param input - exactly one Host create spelling.
* @returns the created or idempotently resolved Workspace.
*/
async create(input: { name: string } | { path: string }): Promise<WorkspaceView> {
const result = await this.manager.create(input)
if (!result.ok) throw new WorkspaceCreateError(result.error)
return result.value.workspace
}
/**
* Open the Host's native directory picker.
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
const response = await this.api.host.pickDirectory({})
if (!response.result.ok) {
throw new Error(`directory picker failed: ${response.result.error.message}`)
}
return response.result.value.path
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.
*/
async openPath(path: string): Promise<void> {
const response = await this.api.host.openPath({ path })
if (!response.result.ok) {
throw new Error(`path open failed: ${response.result.error.message}`)
}
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - new display title (trimmed non-empty by the Host).
* @returns the renamed Workspace view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
const result = await this.manager.rename(workspaceId, title)
if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Delete one Workspace registration. Sessions, session logs, and the
* directory remain Host-owned outside this operation.
* @param workspaceId - target workspace.
*/
async delete(workspaceId: WorkspaceId): Promise<void> {
const result = await this.manager.delete(workspaceId)
if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<WorkspaceView> {
const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Refresh the workspace baseline, reusing an in-flight pull.
* @returns completion of the current or newly started workspace baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refresh()
}
/**
* Route a Host stream envelope into the Workspace object layer.
* @param envelope - validated Host stream envelope.
*/
handleHostEnvelope(envelope: Parameters<WorkspaceManager['handleHostEnvelope']>[0]): void {
this.manager.handleHostEnvelope(envelope)
}
/** Rebuild the Workspace baseline after connection. */
handleConnected(): void {
this.manager.handleConnected()
}
private project(): void {
const workspace = this.manager.getSnapshot()
const sessions = this.sessions.list.getSnapshot()
const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
this.list.set({
items: workspace.items,
state: workspace.state,
phase: workspace.phase,
error: workspace.error,
baselinesReady,
recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
})
}
}
/** Stable tie-breaking follows Host Workspace order. */
function recentWorkspace(
workspaces: readonly WorkspaceView[],
sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
): WorkspaceId | undefined {
let selected: WorkspaceId | undefined
let selectedTime = Number.NEGATIVE_INFINITY
for (const workspace of workspaces) {
let latest = Number.NEGATIVE_INFINITY
for (const sessionId of workspace.sessionIds) {
const session = sessions[sessionId]
if (session !== undefined) latest = Math.max(latest, session.updatedAt)
}
if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
if (selected === undefined || latest > selectedTime) {
selected = workspace.workspaceId
selectedTime = latest
}
}
return selected
}

View File

@@ -0,0 +1,143 @@
/** React-free Workspace entity with a client-local materialization lifecycle. */
import type {
IApiClient, RpcResult, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import { Notifier } from '../sessions/notifier.ts'
/** Host input retained by a local Workspace until materialization succeeds. */
export type WorkspaceCreateInput = { name: string } | { path: string }
/** Observable state of a client-local Workspace intent. */
export interface WorkspaceIntentSnapshot {
name: string
phase: 'ready' | 'creating'
error?: string
}
/** A Workspace is either a local intent or a materialized Host view. */
export interface WorkspaceSnapshot {
view: WorkspaceView | undefined
intent: WorkspaceIntentSnapshot | undefined
}
interface WorkspaceIntent {
input: WorkspaceCreateInput
snapshot: WorkspaceIntentSnapshot
}
/**
* Observable Workspace object whose identity survives Host materialization.
* Local instances retain their create input and failure state; materialized
* instances expose the latest Host view.
*/
export class Workspace implements ObservableSnapshot<WorkspaceSnapshot> {
private view: WorkspaceView | undefined
private intent: WorkspaceIntent | undefined
private materialization: Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | null = null
private snapshotCache: WorkspaceSnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
/**
* @param api - shared wire client.
* @param source - local create input or an existing Host Workspace view.
*/
constructor(private readonly api: IApiClient, source: WorkspaceCreateInput | WorkspaceView) {
if ('workspaceId' in source) {
this.view = source
} else {
this.intent = {
input: source,
snapshot: { name: intentName(source), phase: 'ready' },
}
}
this.snapshotCache = this.buildSnapshot()
}
/**
* Materialize this local Workspace through the Host create seam.
* Re-entry shares the in-flight completion; a materialized instance returns undefined.
* @returns the Host result, or undefined when this Workspace is already materialized.
*/
materialize(): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> | undefined {
if (this.materialization !== null) return this.materialization
const intent = this.intent
if (intent === undefined) return undefined
intent.snapshot = { name: intent.snapshot.name, phase: 'creating' }
this.notifier.notifyNow()
const completion = this.completeMaterialization(intent).finally(() => {
if (this.materialization === completion) this.materialization = null
})
this.materialization = completion
return completion
}
/**
* Adopt a Host view without replacing this Workspace object.
* An existing materialized identity accepts updates only for the same Workspace id.
* @param view - latest Host projection.
*/
adopt(view: WorkspaceView): void {
if (this.view !== undefined && this.view.workspaceId !== view.workspaceId) {
throw new Error('cannot adopt a different Workspace id')
}
this.view = view
this.intent = undefined
this.notifier.markDirty()
}
/**
* Subscribe to Workspace snapshot invalidation.
* @param listener - snapshot invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Read the cached Workspace snapshot after flushing pending notifications.
* @returns the cached Workspace snapshot.
*/
getSnapshot(): WorkspaceSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
private async completeMaterialization(
intent: WorkspaceIntent,
): Promise<RpcResult<{ workspace: WorkspaceView; created: boolean }>> {
let result: RpcResult<{ workspace: WorkspaceView; created: boolean }>
try {
result = (await this.api.workspace.create(intent.input)).result
} catch (error) {
result = transportError(error)
}
if (this.intent !== intent) return result
if (result.ok) {
this.adopt(result.value.workspace)
} else {
intent.snapshot = {
name: intent.snapshot.name,
phase: 'ready',
error: `${result.error.code}: ${result.error.message}`,
}
this.notifier.markDirty()
}
return result
}
private buildSnapshot(): WorkspaceSnapshot {
return { view: this.view, intent: this.intent?.snapshot }
}
}
function intentName(input: WorkspaceCreateInput): string {
if ('name' in input) return input.name
const trimmed = input.path.replace(/[\\/]+$/, '')
return trimmed.split(/[\\/]/).pop() ?? input.path
}

View File

@@ -1,11 +1,4 @@
/**
* Runtime plugin, node half. The implementation lives entirely in the client
* half (src/client/ — SlotsService, SessionsService + object layer, and the
* shell-held ClientLoader under ./loader); consumers import the /client or
* /loader subpaths. The empty apply exists so the plugin appears in the host
* Loader (lifecycle governance + dshClient discovery). Contract:
* api-contracts v3 section 4.
*/
/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
/** Host plugin body — no host-side behavior for the runtime plugin. */
export function apply(_ctx: unknown): void {}