1
0
Эх сурвалжийг харах

refactor(client): carve outward interfaces for the session, workspace, layout, slash, and conversation services

Feature packages now reach these domains through interface types only:
ISession/SessionFace (identity + prompt/cancel/loadOlder + the useSession
snapshot source), ISessions, IWorkspaces, ILayout, IConversation, and the
existing SlashServiceContract now actually mounted on Context.slash. The
concrete services implement their face; wire-pump and assembly entry
points stay on the classes. The provide-channel materialization and
current-projection logic moves into SessionProvideChannel so the
production service and the client test runtime share one implementation.
The workspaces service consumes sessions through the narrow SessionsPort.
imccyu 1 сар өмнө
parent
commit
0efc7f045e

+ 56 - 0
packages/client/runtime/src/client/contract/session.ts

@@ -0,0 +1,56 @@
+/**
+ * The outward session face. Feature packages never see the concrete Session
+ * class: components read conversation state through `useSession` (the
+ * ObservableSnapshot half), and orchestration code calls the behavior verbs
+ * below — nothing else. Widening this interface is the explicit act of
+ * widening what features may do to a session (and what every test fixture
+ * must stub); runtime-internal entry points (history staging, wire-frame
+ * dispatch) stay on the class, invisible out here.
+ */
+import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { ConversationSnapshot } from '../sessions/conversation.ts'
+import type { ObservableSnapshot } from './store.ts'
+
+/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */
+export interface ProjectionsFace {
+  /**
+   * The identity-stable bare observable for one projection key (absence is
+   * an `undefined` snapshot, never a missing face).
+   * @param key - projection key.
+   * @returns the key's value face.
+   */
+  faceOf(key: string): ObservableSnapshot<unknown>
+}
+
+/** Identity plus the behavior verbs features may invoke on a session. */
+export interface ISession {
+  /** The session's host identity (agent id — same axis). */
+  readonly sessionId: SessionId
+  /** Host-computed projection values by key (the useProjection seat). */
+  readonly projections: ProjectionsFace
+  /**
+   * Send a prompt into the session.
+   * @param content - model-facing content blocks.
+   * @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
+   * @returns acceptance, or the business error (also mirrored into snapshot.promptError).
+   */
+  prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise<RpcResult<{ accepted: true }>>
+  /**
+   * Cancel the running turn.
+   * @returns acceptance, or the business error.
+   */
+  cancel(): Promise<RpcResult<{ accepted: true }>>
+  /**
+   * Extend the history window backwards (older messages pagination).
+   * @returns completion; failures land in snapshot.openState/loadingOlder.
+   */
+  loadOlder(): Promise<void>
+}
+
+/**
+ * The full outward face: behavior verbs plus the conversation read side
+ * (the `useSession` hook source). This is the type carried by
+ * `SessionBinding.session` and the provide channel.
+ */
+export type SessionFace = ISession & ObservableSnapshot<ConversationSnapshot>

+ 47 - 0
packages/client/runtime/src/client/contract/sessions-port.ts

@@ -0,0 +1,47 @@
+/**
+ * Cross-domain sessions face: the contract surface sibling domains (today:
+ * workspaces) consume instead of the sessions implementation. The sessions
+ * domain satisfies it structurally — SessionsService is assignable, checked
+ * wherever the assembly layer or a test injects the real service — so
+ * widening this face is the explicit act of widening the inter-domain
+ * dependency.
+ */
+
+import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
+import type { ObservableSnapshot } from './store.ts'
+
+/** Session-list row facts sibling domains read: recency, blank-reuse eligibility, and its cwd canon. */
+export interface SessionsPortSummary {
+  id: SessionId
+  /** Empty-log bit (blank sessions are reused by New Session instead of minting another). */
+  blank: boolean
+  cwd?: string
+  updatedAt: number
+}
+
+/** Session-list facts sibling domains read: readiness, selection, and the row map. */
+export interface SessionsPortList {
+  ids: SessionId[]
+  byId: Record<SessionId, SessionsPortSummary>
+  current: SessionId | undefined
+  phase: 'pending' | 'ready'
+}
+
+/** The sessions-service face injected into sibling domains. */
+export interface SessionsPort {
+  /** Observable list snapshot (read face only; writes stay inside the sessions domain). */
+  readonly list: ObservableSnapshot<SessionsPortList>
+  /**
+   * Create a session on the host.
+   * @param opts - target workspace.
+   * @returns the new session id.
+   */
+  create(opts: { workspaceId: WorkspaceId }): Promise<SessionId>
+  /**
+   * Select a session as current.
+   * @param id - session id (must exist in the list store).
+   */
+  open(id: SessionId): void
+  /** Clear the current selection into the no-session view state. */
+  clear(): void
+}

+ 64 - 0
packages/client/runtime/src/client/contract/sessions.ts

@@ -0,0 +1,64 @@
+/**
+ * The outward sessions-service face — what `ctx.sessions` exposes to feature
+ * packages and the renderer host, and therefore exactly what the test
+ * runtime's sessions double must implement. Wire-pump entry points
+ * (handleMuxEnvelope/handleConnected/refresh) and runtime internals stay on
+ * the concrete class; cross-domain consumers keep the narrower
+ * [SessionsPort](./sessions-port.ts). Widening this interface is the
+ * explicit act of widening what features may do to the sessions domain.
+ */
+import type { Context } from 'cordis'
+import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  SessionBinding, SessionListState, SessionProvideDescriptor,
+} from '../sessions/service.ts'
+import type { SessionFace } from './session.ts'
+import type { ObservableSnapshot } from './store.ts'
+
+/** The sessions-service face injected as `ctx.sessions`. */
+export interface ISessions {
+  /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */
+  readonly list: ObservableSnapshot<SessionListState>
+  /** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
+  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
+  /**
+   * Select a session as current.
+   * @param id - session id (must exist in the list; unknown ids fail loud).
+   */
+  open(id: SessionId): void
+  /** Clear the current selection into the no-session view state. */
+  clear(): void
+  /**
+   * Register a per-session standard-props provider (hooks become `use<Name>`
+   * selector hooks on the render side; props spread verbatim).
+   * @param descriptor - static member roster plus per-session resolver.
+   * @returns disposer removing the provider.
+   */
+  provide(descriptor: SessionProvideDescriptor): () => void
+  /**
+   * Resolve an Agent-scoped context view (use-and-discard).
+   * @param id - session id.
+   * @returns scoped ctx, or undefined for a session neither listed nor already scoped.
+   */
+  scope(id: SessionId): Context | undefined
+  /**
+   * Read the Agent scope tag off a context (service-method seam: fetch
+   * bundles must reach scope resolution through ctx.sessions).
+   * @param ctx - any client context.
+   * @returns the session id, or undefined on root contexts.
+   */
+  scopeOf(ctx: Context): SessionId | undefined
+  /**
+   * Resolve the session face behind an Agent-scoped context.
+   * @param ctx - an Agent-scoped context.
+   * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
+   */
+  sessionOf(ctx: Context): SessionFace | undefined
+  /**
+   * Resolve the stable session binding (scope-addressed assembly feed).
+   * @param id - session id.
+   * @returns binding, or undefined for a session neither listed nor already scoped.
+   */
+  binding(id: SessionId): SessionBinding | undefined
+}

+ 65 - 0
packages/client/runtime/src/client/contract/workspaces.ts

@@ -0,0 +1,65 @@
+/**
+ * The outward workspaces-service face — what `ctx.workspaces` exposes to
+ * feature packages and the renderer host, and therefore exactly what the
+ * test runtime's workspaces double must implement. Wire-pump entry points
+ * (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
+ * the concrete class. Widening this interface is the explicit act of
+ * widening what features may do to the workspaces domain.
+ */
+import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
+import type { WorkspaceListState } from '../workspaces/service.ts'
+import type { ObservableSnapshot } from './store.ts'
+
+/** The workspaces-service face injected as `ctx.workspaces`. */
+export interface IWorkspaces {
+  /** The useWorkspaces standard feed (read face — writes stay inside the domain). */
+  readonly list: ObservableSnapshot<WorkspaceListState>
+  /**
+   * Connect a Workspace to its reusable or freshly created blank session.
+   * @param workspaceId - target workspace.
+   * @returns the connected session id.
+   */
+  connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
+  /**
+   * The New Session flow: connect the target (or recent) Workspace and open
+   * the resulting session; failures surface on the session list state.
+   * @param workspaceId - explicit target; omitted uses the recency projection.
+   */
+  startSession(workspaceId?: WorkspaceId): void
+  /**
+   * Create a Workspace by name or register an existing path.
+   * @param input - exactly one Host create spelling.
+   * @returns the created or idempotently resolved Workspace.
+   */
+  create(input: { name: string } | { path: string }): Promise<WorkspaceView>
+  /**
+   * Open the Host's native directory picker.
+   * @returns the selected path, or null when the user cancelled.
+   */
+  pickDirectory(): Promise<string | null>
+  /**
+   * Open a filesystem path with the Host operating system's default application.
+   * @param path - absolute or host-resolvable path.
+   */
+  openPath(path: string): Promise<void>
+  /**
+   * Rename a Workspace.
+   * @param workspaceId - target workspace.
+   * @param title - the new display title.
+   * @returns the updated Workspace view.
+   */
+  rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
+  /**
+   * Delete a Workspace (its sessions fall back to the unaccounted group).
+   * @param workspaceId - target workspace.
+   */
+  delete(workspaceId: WorkspaceId): Promise<void>
+  /**
+   * Move an accounted session within/into a Workspace's ordered list.
+   * @param workspaceId - target workspace.
+   * @param sessionId - accounted session to move.
+   * @param beforeSessionId - accounted anchor to insert before; omitted appends.
+   * @returns the updated Workspace view.
+   */
+  insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
+}

+ 11 - 2
packages/client/runtime/src/client/index.ts

@@ -12,10 +12,17 @@ import type { UseProjection } from './sessions/projection-store.ts'
 export { SlotsService } from './slots.ts'
 export type { RootOwnerProps } from './slots.ts'
 export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
+// The provide channel is shared with the client test runtime (one
+// materialization/projection implementation; no test-side mirror to drift).
+export { SessionProvideChannel } from './sessions/provide.ts'
+export type { SessionProvideChannelHost } from './sessions/provide.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 { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
+export type { ISessions } from './contract/sessions.ts'
+export type { IWorkspaces } from './contract/workspaces.ts'
 export type {
   SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
 } from './sessions/service.ts'
@@ -109,8 +116,10 @@ declare module 'cordis' {
   }
   interface Context {
     slots: import('./slots.ts').SlotsService
-    sessions: import('./sessions/service.ts').SessionsService
-    workspaces: import('./workspaces/service.ts').WorkspacesService
+    /** The outward face only; the concrete service stays inside the runtime. */
+    sessions: import('./contract/sessions.ts').ISessions
+    /** The outward face only; the concrete service stays inside the runtime. */
+    workspaces: import('./contract/workspaces.ts').IWorkspaces
   }
 }
 

+ 190 - 0
packages/client/runtime/src/client/sessions/provide.ts

@@ -0,0 +1,190 @@
+/**
+ * The session standard-props provide channel: provider roster, bundle
+ * materialization (fail-loud on undeclared/missing/duplicate members), the
+ * static no-session projection, and the atomic current-session projection
+ * observable. One implementation — SessionsService drives it from wire
+ * truth, the test runtime's sessions double drives it from fixtures — so
+ * the materialization rules and the projection semantics cannot drift
+ * between production and the test bench.
+ */
+import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
+
+/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
+export interface SessionProvideChannelHost {
+  /**
+   * Re-materialize every already-materialized bundle against the new roster
+   * (call {@link SessionProvideChannel.materializeInfo} per live binding).
+   * Lazily-materialized sessions pick the new roster up on first resolve.
+   */
+  rebuildBundles(): void
+  /** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
+  resolveCurrent(): SessionMaybeProvideInfo
+}
+
+/**
+ * Provider roster + materialization + current projection. The channel owns
+ * every rule a provider contribution must satisfy; owners keep only their
+ * per-session bundle storage and the definition of "current".
+ */
+export class SessionProvideChannel {
+  private readonly providers: SessionProvideDescriptor[] = []
+  private maybeInfoCache: SessionMaybeProvideInfo
+  /** Latest published current bundle (identity comparison dedupes republish). */
+  private currentSnapshot: SessionMaybeProvideInfo
+  /** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
+  private readonly listeners = new Set<() => void>()
+
+  /**
+   * Atomic current-session provide projection: selection changes and
+   * provider-roster changes publish through this one source, so a roster
+   * change under a stable current id republishes the bundle instead of
+   * stranding mounted entries.
+   */
+  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
+
+  /**
+   * @param host - owner-side bundle storage and current-selection resolution.
+   */
+  constructor(private readonly host: SessionProvideChannelHost) {
+    // 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.maybeInfoCache = this.materializeMaybeInfo()
+    this.currentSnapshot = this.maybeInfoCache
+    this.currentProvideInfo = {
+      getSnapshot: () => this.currentSnapshot,
+      subscribe: (fn) => {
+        this.listeners.add(fn)
+        return () => { this.listeners.delete(fn) }
+      },
+    }
+  }
+
+  /** The static no-session projection under the current roster (declared names present, values undefined). */
+  get maybeInfo(): SessionMaybeProvideInfo {
+    return this.maybeInfoCache
+  }
+
+  /**
+   * Register a per-session standard-props provider (see
+   * SessionsService.provide for the product contract). Live bundles rebuild
+   * immediately; misdeclared providers fail loud here, at the registration
+   * edge, and the registration rolls back — the channel never stays on a
+   * roster it cannot materialize.
+   * @param descriptor - static member roster plus per-session resolver.
+   * @returns disposer removing the provider.
+   */
+  provide(descriptor: SessionProvideDescriptor): () => void {
+    this.providers.push(descriptor)
+    try {
+      this.applyRosterChange()
+    } catch (error) {
+      this.providers.splice(this.providers.indexOf(descriptor), 1)
+      // Restore the previous (valid) roster's bundles; cannot rethrow — the
+      // pre-push roster materialized successfully before.
+      this.applyRosterChange()
+      throw error
+    }
+    return () => {
+      const at = this.providers.indexOf(descriptor)
+      if (at >= 0) this.providers.splice(at, 1)
+      this.applyRosterChange()
+    }
+  }
+
+  /**
+   * Re-derive the current selection's bundle and publish it when it changed.
+   * Bundles are identity-stable per (scope, roster) materialization, so an
+   * identity compare is exact; synchronous notify — call sites (the owner's
+   * list subscription, provide()) already sit behind their own batching or
+   * registration edges.
+   */
+  publishCurrent(): void {
+    const next = this.host.resolveCurrent()
+    if (next === this.currentSnapshot) return
+    this.currentSnapshot = next
+    for (const fn of [...this.listeners]) {
+      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)
+      }
+    }
+  }
+
+  /**
+   * Materialize the standard-props bundle for one session (fails loud on
+   * undeclared, missing, and duplicate member names).
+   * @param binding - session assembly handle fed to every resolver.
+   * @returns the materialized bundle (identity-stable until the next materialization).
+   */
+  materializeInfo(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) },
+    }
+  }
+
+  /** Rebuild the static projection and the owner's live bundles, then republish the current one. */
+  private applyRosterChange(): void {
+    this.maybeInfoCache = this.materializeMaybeInfo()
+    this.host.rebuildBundles()
+    this.publishCurrent()
+  }
+
+  /** Build the static no-session kit and reject duplicate declared names. */
+  private materializeMaybeInfo(): 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
+  }
+}

+ 29 - 129
packages/client/runtime/src/client/sessions/service.ts

@@ -22,9 +22,12 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SnapshotStore } from '../contract/store.ts'
 import { createSnapshotStore } from '../contract/store.ts'
+import type { SessionFace } from '../contract/session.ts'
+import type { ISessions } from '../contract/sessions.ts'
 import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
 import { SessionManager } from './manager.ts'
 import type { SessionListPhase } from './manager.ts'
+import { SessionProvideChannel } from './provide.ts'
 import type { Session } from './session.ts'
 
 /** Session list row projected from the host list RPC plus live stream increments. */
@@ -79,7 +82,8 @@ export class SessionCreateError extends Error {
 /** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */
 export interface SessionBinding {
   readonly sessionId: SessionId
-  readonly session: Session
+  /** The outward session face only — feature code never sees the concrete class. */
+  readonly session: SessionFace
   readonly ctx: Context
 }
 
@@ -119,6 +123,8 @@ interface ScopeRecord {
   fiber: Fiber
   ctx: Context
   binding: SessionBinding
+  /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
+  session: Session
   /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */
   provideInfo: SessionProvideInfo
 }
@@ -146,7 +152,7 @@ export interface SessionProvideDescriptor {
 }
 
 /** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
-export class SessionsService {
+export class SessionsService implements ISessions {
   /** 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. */
@@ -170,14 +176,8 @@ 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 provide channel (roster, materialization rules, current projection) — shared with the test runtime's double. */
+  private readonly provideChannel: SessionProvideChannel
   /**
    * The staged session id — follows `list.current` exactly, holding its last
    * defined value across masked gaps (a transiently absent selection blanks
@@ -212,23 +212,17 @@ export class SessionsService {
     // The current-provide projection follows the same current writes.
     this.list.subscribe(() => {
       this.followCurrent()
-      this.updateCurrentProvideInfo()
+      this.provideChannel.publishCurrent()
     })
-    // 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) }
+    this.provideChannel = new SessionProvideChannel({
+      rebuildBundles: () => {
+        for (const record of this.scopes.values()) {
+          record.provideInfo = this.provideChannel.materializeInfo(record.binding)
+        }
       },
-    }
+      resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current),
+    })
+    this.currentProvideInfo = this.provideChannel.currentProvideInfo
     rootCtx.reflect.provide('sessions', this, undefined)
   }
 
@@ -243,105 +237,10 @@ export class SessionsService {
    * @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) },
-    }
+    // scopes before later plugins register) — the channel rebuilds their
+    // bundles through the host hooks so every provider lands by first render.
+    return this.provideChannel.provide(descriptor)
   }
 
   /**
@@ -439,9 +338,9 @@ export class SessionsService {
    * `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.
+   * @returns the session face, or undefined when the ctx is untagged or its scope was pruned.
    */
-  sessionOf(ctx: Context): Session | undefined {
+  sessionOf(ctx: Context): SessionFace | undefined {
     const id = scopeTagOf(ctx)
     if (id === undefined) return undefined
     return this.scopes.get(id)?.binding.session
@@ -473,7 +372,7 @@ export class SessionsService {
    * 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
+    return (id === undefined ? undefined : this.provideInfo(id)) ?? this.provideChannel.maybeInfo
   }
 
   /**
@@ -497,7 +396,7 @@ export class SessionsService {
      * validates and the projection masks absent selections), so resolve
      * cannot miss; kept so a future current writer cannot crash the notify. */
     if (record !== undefined) {
-      void record.binding.session.open()
+      void record.session.open()
     }
   }
 
@@ -540,8 +439,9 @@ export class SessionsService {
       fiber,
       ctx,
       binding,
+      session,
       // Sources are bare observables; React binds selector hooks at its own seam.
-      provideInfo: this.materializeProvideInfo(binding),
+      provideInfo: this.provideChannel.materializeInfo(binding),
     }
     this.scopes.set(id, record)
     return record
@@ -608,7 +508,7 @@ export class SessionsService {
     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()
+    record.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)

+ 5 - 3
packages/client/runtime/src/client/sessions/session.ts

@@ -10,7 +10,7 @@ import type {
 // 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 type { ObservableSnapshot } from '../contract/store.ts'
+import type { SessionFace } from '../contract/session.ts'
 import type {
   CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
   PromptError, QueuedMessage, RunningToolCall,
@@ -67,9 +67,11 @@ function queuePreviewOf(content: readonly ContentBlock[]): string {
 
 /**
  * Owns a session's event window, derived conversation state, and observable
- * snapshot. React bindings remain outside this data layer.
+ * snapshot. React bindings remain outside this data layer. Features see only
+ * the {@link SessionFace} slice (ISession verbs + the snapshot source); the
+ * remaining public members are manager/runtime entry points.
  */
-export class Session implements ObservableSnapshot<ConversationSnapshot> {
+export class Session implements SessionFace {
   // ---- Window and derived state (all private; the snapshot is the only read surface) ----
   private events: SessionEvent[] = []
   /** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).

+ 6 - 5
packages/client/runtime/src/client/workspaces/service.ts

@@ -6,7 +6,8 @@ import type {
 } 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 type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
+import type { IWorkspaces } from '../contract/workspaces.ts'
 import { WorkspaceManager, type WorkspaceListPhase } from './manager.ts'
 
 /** Workspace list plus the two-baseline readiness and default-target projection. */
@@ -30,7 +31,7 @@ export class WorkspaceCreateError extends Error {
 }
 
 /** Real Workspace object layer and Host actions. */
-export class WorkspacesService {
+export class WorkspacesService implements IWorkspaces {
   /** UI-facing immutable projection; the manager remains wire truth. */
   readonly list: SnapshotStore<WorkspaceListState>
   /** Workspace baseline and frame owner. */
@@ -43,9 +44,9 @@ export class WorkspacesService {
   /**
    * @param ctx - client root context.
    * @param api - shared wire client.
-   * @param sessions - lower-level Session service used for recency and blank-session reuse.
+   * @param sessions - cross-domain sessions face used for recency and blank-session reuse.
    */
-  constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) {
+  constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsPort) {
     this.manager = new WorkspaceManager(api)
     this.list = createSnapshotStore<WorkspaceListState>({
       items: [], state: 'idle', phase: 'pending', error: null,
@@ -271,7 +272,7 @@ export class WorkspacesService {
 /** Stable tie-breaking follows Host Workspace order. */
 function recentWorkspace(
   workspaces: readonly WorkspaceView[],
-  sessions: ReturnType<SessionsService['list']['getSnapshot']>['byId'],
+  sessions: SessionsPortList['byId'],
 ): WorkspaceId | undefined {
   let selected: WorkspaceId | undefined
   let selectedTime = Number.NEGATIVE_INFINITY

+ 4 - 4
packages/client/ui-command/src/client/service.ts

@@ -9,10 +9,10 @@
 import { Service } from 'cordis'
 import type { Context } from 'cordis'
 import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
-import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
 import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick,
-  SlashServiceContract, SubmitOutcome,
+  SubmitOutcome,
 } from '@deepseek-ai/dsh-client-ui-slash/client'
 import type { CommandContribution, CommandServiceContract } from './contract.ts'
 import type { CommandDescriptor } from './directory.ts'
@@ -46,7 +46,7 @@ export class CommandService extends Service implements CommandServiceContract {
       if (!result.ok) throw new Error(`command.list failed: ${result.error.code}: ${result.error.message}`)
       return result.value.commands
     })
-    const slash = ctx.get('slash') as SlashServiceContract | undefined
+    const slash = ctx.get('slash')
     if (slash === undefined) throw new Error('ui-command: slash service unavailable')
     ctx.effect(() => slash.registerSource({
       trigger: '/',
@@ -292,7 +292,7 @@ export class CommandService extends Service implements CommandServiceContract {
     return this.sessions().scope(id)
   }
 
-  private sessions(): SessionsService {
+  private sessions(): ISessions {
     const sessions = this.ctx.get('sessions')
     if (sessions === undefined) throw new Error('ui-command: sessions service unavailable')
     return sessions

+ 4 - 3
packages/client/ui-conversation/src/client/apply.ts

@@ -1,7 +1,7 @@
 /** Registers the conversation components, shared store, and service callbacks. */
 import type { Context } from 'cordis'
 import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { ViewTab } from './contract/views.ts'
 import type {
@@ -10,6 +10,7 @@ import type {
 import { resolveToolPath } from './contract/tool-call-model.ts'
 import { createChatStore } from './stores.ts'
 import { ConversationService } from './service.ts'
+import type { IConversation } from './service.ts'
 import { InputHub } from './input/hub.ts'
 import { InputBar } from './skeleton/InputBar.tsx'
 import { ChatView } from './chat/ChatView.tsx'
@@ -24,8 +25,8 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
 /** Services required by the conversation plugin. */
 export const inject = ['slots', 'layout', 'sessions', 'workspaces']
 
-/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
-function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
+/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
+function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
   const scoped = sessions.scope(id)
   if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
   const conversation = scoped.get('conversation')

+ 3 - 3
packages/client/ui-conversation/src/client/index.ts

@@ -3,10 +3,9 @@
  * between the independently implemented skeleton and chat domains; `apply.ts`
  * owns their slot assembly.
  */
-import type { ConversationService } from './service.ts'
-
 export { apply, inject } from './apply.ts'
 export { ConversationService } from './service.ts'
+export type { IConversation } from './service.ts'
 
 export type {
   CallId, ChatStoreState, SelectionTarget, ViewTab,
@@ -22,6 +21,7 @@ export type {
 
 declare module 'cordis' {
   interface Context {
-    conversation: ConversationService
+    /** The outward face only; the concrete service stays inside this plugin. */
+    conversation: import('./service.ts').IConversation
   }
 }

+ 5 - 6
packages/client/ui-conversation/src/client/input/hub.ts

@@ -8,9 +8,8 @@
  * bail events) and owns the default-sink choreography: every session is a
  * real host entity, so the sink is one unconditional prompt path.
  */
-import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
-import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
+import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client'
 import { queueReadFaceOf } from '../queue/store.ts'
 import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
 import type { PopupDismissFace } from './facade.ts'
@@ -113,7 +112,7 @@ export class InputHub implements InputService {
    * exactly one path; a failed first prompt is an ordinary prompt failure
    * (error strip via promptError, draft restored only while untouched).
    */
-  private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
+  private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
     if (text === '') return
     const shell = this.shells.get(session.sessionId)
     // Commit, not an editable clear: undo must not resurrect sent content.
@@ -129,7 +128,7 @@ export class InputHub implements InputService {
   }
 
   private controller(actx: ClientContext): SlashController | undefined {
-    const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
+    const slash = this.rootCtx.get('slash')
     return slash?.sessionOf(actx)
   }
 
@@ -138,7 +137,7 @@ export class InputHub implements InputService {
     return command?.popupFor(actx)
   }
 
-  private sessions(): SessionsService {
+  private sessions(): ISessions {
     const sessions = this.rootCtx.get('sessions')
     if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
     return sessions

+ 3 - 3
packages/client/ui-conversation/src/client/queue/store.ts

@@ -5,7 +5,7 @@
  * reference-stable across unrelated snapshot swaps, so this is a pure
  * projection — no second store, no copy.
  */
-import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-runtime/client'
 import type { QueuedMessage } from '../input/contract.ts'
 
 /**
@@ -13,10 +13,10 @@ import type { QueuedMessage } from '../input/contract.ts'
  * The wiring layer (T5) overlays this onto InputState.queue; the runtime
  * QueuedMessage and the input-contract QueuedMessage are structurally the
  * same frozen shape ({key, preview}).
- * @param session - the resident session instance.
+ * @param session - the resident session face.
  * @returns the queue read face (snapshot reference stable while the queue is unchanged).
  */
-export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
+export function queueReadFaceOf(session: SessionFace): ObservableSnapshot<readonly QueuedMessage[]> {
   return {
     getSnapshot: () => session.getSnapshot().queue,
     subscribe: fn => session.subscribe(fn),

+ 40 - 15
packages/client/ui-conversation/src/client/service.ts

@@ -12,24 +12,50 @@ import type { Context } from 'cordis'
 // Type-only imports: a plugin-to-plugin value import is a bundle purity
 // error, so scope resolution goes through the sessions service (scopeOf
 // method) instead of the standalone helper.
-import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
-import { InputHub } from './input/hub.ts'
+import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { InputService } from './input/contract.ts'
+
+/**
+ * The outward conversation face (`ctx.conversation`): the scope-addressed
+ * verbs and the input registry other plugins may reach — and exactly what a
+ * test fake must supply.
+ */
+export interface IConversation {
+  /** The per-session input machine registry (InputService face). */
+  readonly input: InputService
+  /**
+   * Send a prompt into the caller scope's session.
+   * @param text - prompt text, sent verbatim as one text block.
+   * @param mode - queue after the current turn, or steer into it.
+   * @returns completion; business failures reject (and land in promptError).
+   */
+  send(text: string, mode: 'queue' | 'steer'): Promise<void>
+  /**
+   * Cancel the scoped session's in-flight turn.
+   * @returns completion; failures reject as in send.
+   */
+  cancel(): Promise<void>
+  /**
+   * Pull one older history page for the scoped session.
+   * @returns completion of the page pull.
+   */
+  loadOlder(): Promise<void>
+}
 
 /** Scope-addressed conversation service (root singleton, provided as `conversation`). */
-export class ConversationService extends Service {
+export class ConversationService extends Service implements IConversation {
   /** The per-session input machine registry (InputService face, design §5.2). */
-  readonly input: InputHub
+  readonly input: InputService
 
   /**
    * @param ctx - owning root context (the plugin apply context; the service
    * registers itself and follows that fiber's lifetime).
-   * @param config - the shared InputHub constructed by the plugin apply
-   * (shared with the slot inject factories); absent = own instance
-   * (object-layer tests that never touch slots).
+   * @param config - carries the InputService instance constructed by the
+   * plugin apply (the same InputHub the slot inject factories close over).
    */
-  constructor(ctx: Context, config?: { input?: InputHub }) {
+  constructor(ctx: Context, config: { input: InputService }) {
     super(ctx, 'conversation')
-    this.input = config?.input ?? new InputHub(ctx)
+    this.input = config.input
   }
 
   /**
@@ -57,8 +83,8 @@ export class ConversationService extends Service {
     await this.scopedSession('loadOlder').loadOlder()
   }
 
-  /** Resolve the caller scope's Session or throw on root contexts. */
-  private scopedSession(op: string): Session {
+  /** Resolve the caller scope's session face or throw on root contexts. */
+  private scopedSession(op: string): SessionFace {
     const id = this.scopeId(op)
     const binding = this.requireSessions().binding(id)
     if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
@@ -74,10 +100,9 @@ export class ConversationService extends Service {
     return id
   }
 
-  private requireSessions(): SessionsService {
-    // ctx.get instead of ctx.sessions: the typed Context merge is suspended
-    // while the client/host `sessions` declaration collision awaits
-    // arbitration (see the runtime package's Context merge note).
+  private requireSessions(): ISessions {
+    // Strict ctx.get, not the injection proxy: the scope-addressed pattern
+    // reads the service off whatever context the tracker rebound.
     const sessions = this.ctx.get('sessions')
     if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
     return sessions

+ 4 - 2
packages/client/ui-layout/src/client/index.ts

@@ -17,14 +17,16 @@ import { ThemePresenter } from './theme-presenter.ts'
 
 // Contract surface only (export-convergence rule: cross-package consumers
 // keep a symbol exported; test-only/package-internal symbols live off /src).
-// LayoutService: the ctx.layout service class (consumers type against it).
+// ILayout: the ctx.layout face consumers and test fakes type against.
 // OwnerShare contracts below are the render-side halves registrants compose
 // against; the frame components and the store factory are package-internal.
 export { LayoutService } from './service.ts'
+export type { ILayout } from './service.ts'
 
 declare module 'cordis' {
   interface Context {
-    layout: LayoutService
+    /** The outward face only; the concrete service stays inside this plugin. */
+    layout: import('./service.ts').ILayout
   }
 }
 

+ 16 - 1
packages/client/ui-layout/src/client/service.ts

@@ -14,8 +14,23 @@ import type { createLayoutStore } from './stores.ts'
 /** The layout store's bound action set (framework-baked, draft params peeled). */
 export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
 
+/**
+ * The outward layout face (`ctx.layout`): the panel transitions other
+ * plugins may trigger — and exactly what a test fake must supply. The
+ * attachPanels wiring hook stays on the concrete class (root-entry assembly
+ * only).
+ */
+export interface ILayout {
+  /** Toggle the sidebar panel (closed ⟷ contract default width). */
+  toggleSidebar(): void
+  /** Open the details panel (no-op when already open). */
+  openDetails(): void
+  /** Close the details panel. */
+  closeDetails(): void
+}
+
 /** Cross-plugin panel-action face (ctx.layout). */
-export class LayoutService {
+export class LayoutService implements ILayout {
   #panels: PanelActions | undefined
 
   /**

+ 2 - 1
packages/client/ui-slash/src/client/index.ts

@@ -24,7 +24,8 @@ export type { SlashServiceContract } from './contract.ts'
 
 declare module 'cordis' {
   interface Context {
-    slash: SlashService
+    /** The outward face only; the concrete service stays inside this plugin. */
+    slash: import('./contract.ts').SlashServiceContract
   }
 }
 

+ 2 - 2
packages/client/ui-slash/src/client/service.ts

@@ -7,7 +7,7 @@
  */
 import { Service } from 'cordis'
 import type { Context } from 'cordis'
-import type { ClientContext, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SlashSource } from '../types.ts'
 import { SlashController } from './controller.ts'
 import type { SlashServiceContract } from './contract.ts'
@@ -99,7 +99,7 @@ export class SlashService extends Service implements SlashServiceContract {
     return controller
   }
 
-  private sessions(): SessionsService {
+  private sessions(): ISessions {
     const sessions = this.ctx.get('sessions')
     if (sessions === undefined) throw new Error('ui-slash: sessions service unavailable')
     return sessions