Merge remote-tracking branch 'origin/master' into dshw/pr-deepseek-harness-deepseek-harness-2781

# Conflicts:
#	.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
#	.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
#	.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/api/session-controller/tests/session-cold.host.spec.ts
#	packages/bundle/web-app/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/session/session-projection-cache/package.json
This commit is contained in:
_Kerman committed 2026-08-24 15:13:10 +08:00
commit 334dd53c92
3162 files changed
+119688 -47896

No files matched your search

@@ -0,0 +1,414 @@
/** Agent activation, composition, and model-selection policy owned by API Session. */
import { mkdir } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
import type { SessionError } from './types.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
/** Session identity whose lifecycle belongs to subagent routing. */
export class ApiSessionSubagentOwnership extends Error {
/** @param sessionId - identity reserved to subagent routing. */
constructor(readonly sessionId: SessionId) {
super(`session "${sessionId}" is a subagent session; use subagent delivery`)
}
}
/** Explicit-id creation attempted to adopt a Session under another cwd. */
export class ApiSessionCwdConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedCwd: string,
readonly existingCwd: string | undefined,
) {
super(
existingCwd === undefined
? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"`
: `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`,
)
}
}
/** Explicit-id creation attempted to adopt a Session under another preset. */
export class ApiSessionPresetConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedPreset: string,
readonly existingPreset: string | undefined,
) {
super(
existingPreset === undefined
? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"`
: `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`,
)
}
}
/** Failures produced while resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentError = Extract<
SessionError,
{ readonly code: 'session-not-found' | 'agent-busy' | 'internal' }
>
/** Result of resolving one ordinary Session identity to its live Agent. */
export type ApiSessionAgentResult =
| { readonly agent: Agent }
| { readonly error: ApiSessionAgentError }
type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection }
/**
* Test whether generic Session routing must leave an identity to subagent routing.
* @param ctx - Host context carrying the Agent ownership registry.
* @param session - attached or live Session whose ownership is tested.
* @param agent - live Agent when one exists for the Session.
* @returns whether subagent routing owns the Session identity.
*/
export function hasApiSessionSubagentOwner(
ctx: Context,
session: Pick<Session, 'header'>,
agent: Agent | undefined,
): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}
/**
* Build the stable caller-facing subagent ownership rejection.
* @param sessionId - Session identity owned by subagent routing.
* @returns a stable Session-domain failure.
*/
export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError {
return {
code: 'agent-busy',
message: `session "${sessionId}" is owned by subagent routing`,
details: { reason: 'use subagent delivery for this child session' },
}
}
/**
* Inspect one cold Session without repairing, resuming, or publishing it.
* @param ctx - Host context carrying Session persistence.
* @param sessionId - durable Session identity.
* @param signal - optional cancellation for persistence reads.
* @returns the persisted header and complete event prefix.
*/
export async function inspectApiSession(
ctx: Context,
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
}
const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
if (meta === undefined || meta.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
const inspected = await persistence.inspect(sessionId, signal)
if (inspected.meta.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return { meta: inspected.meta, events: [...inspected.events] }
}
/** Owns every operation that may create, resume, or configure a Web Agent. */
export class ApiSessionAgentController {
private readonly resumes = new Map<SessionId, Promise<Agent>>()
private readonly creations = new Map<SessionId, Promise<Agent>>()
private readonly selections = new WeakMap<Agent, InstalledSelection>()
private readonly imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
/** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
constructor(private readonly ctx: Context) {
ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
return found.agent
})
ctx.typert.lookups.configure('session', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
return found.agent.session
})
ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw new TypertLookupFailure(found.error)
return found.agent.ctx
})
}
/**
* Resolve or resume one ordinary Session, deduplicating concurrent resumes.
* @param sessionId - ordinary Session identity.
* @returns the live Agent or a stable Session-domain failure.
*/
async resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
const live = this.liveAgent(sessionId)
if (live !== undefined) return live
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
let resume = this.resumes.get(sessionId)
if (resume === undefined) {
resume = this.resume(sessionId).finally(() => { this.resumes.delete(sessionId) })
this.resumes.set(sessionId, resume)
}
try {
return { agent: await resume }
} catch (error: unknown) {
if (error instanceof ApiSessionNotFound) {
return {
error: {
code: 'session-not-found',
message: error.message,
details: { sessionId },
},
}
}
if (error instanceof ApiSessionSubagentOwnership) {
return { error: apiSessionSubagentOwnershipError(error.sessionId) }
}
const raced = this.liveAgent(sessionId)
if (raced !== undefined) return raced
const racedSession = this.ctx.sessions.get(sessionId)
if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) {
return { error: apiSessionSubagentOwnershipError(sessionId) }
}
return {
error: {
code: 'internal',
message: `resume failed for session "${sessionId}": ${String(error)}`,
details: {},
},
}
}
}
/**
* Resolve one requested identity, creating or resuming it once.
* @param sessionId - requested Session identity.
* @param cwd - directory the Session must own.
* @param checkPersistedIdentity - whether to inspect a cold identity before creation.
* @param presetId - optional Agent preset the Session must own.
* @returns the matching live ordinary Agent.
*/
async ensureSession(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId?: string,
): Promise<Agent> {
let creation = this.creations.get(sessionId)
if (creation === undefined) {
creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId)
.catch((error: unknown) => {
const live = this.ctx.agents.get(sessionId)
if (live !== undefined) {
if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
return live
}
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
throw error
})
.finally(() => { this.creations.delete(sessionId) })
this.creations.set(sessionId, creation)
}
const agent = await creation
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
if (agent.session.header.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
return agent
}
/**
* Install or return the Session-local model selection used by prompt assembly.
* @param agent - live Agent that owns the selection.
* @returns the installed mutable selection reference.
*/
selectionFor(agent: Agent): InstalledSelection {
const installed = this.selections.get(agent)
if (installed !== undefined) return installed
let picked: AgentModelSelection | undefined
const defaultModel = this.ctx.agentDefaultModel
const selection: InstalledSelection = {
get current(): AgentModelSelection {
if (picked !== undefined) return picked
const logged = agent.session.requestHeader()?.config
if (logged === undefined) return defaultModel.currentSelection()
return {
provider: logged.provider,
model: logged.model,
...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }),
}
},
set current(next: AgentModelSelection) {
picked = next
},
assembled: undefined,
}
installModelSelection(agent.ctx, selection)
this.selections.set(agent, selection)
return selection
}
/**
* Serialize image admission and model selection for one Agent.
* @param agent - live Agent that owns the serialization chain.
* @param operation - asynchronous operation admitted after prior work settles.
* @returns the operation result or rejection.
*/
serializeImageAdmission<Value>(agent: Agent, operation: () => Promise<Value>): Promise<Value> {
const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
return result
}
/**
* Resolve the preset id and pre-publication Agent setup for a create or resume.
* @param presetId - requested preset or the configured default when omitted.
* @returns the resolved preset identity and Agent setup callback.
*/
async composeAgent(presetId: string | undefined): Promise<{
readonly agentPreset?: string
readonly setup: AgentSetup
}> {
const presets = this.ctx.get('agentPresets')
if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } }
const resolvedId = (await presets.resolve(presetId)).id
return {
agentPreset: resolvedId,
setup: async (agentCtx) => {
this.installSelection(agentCtx)
await presets.mount(agentCtx, resolvedId)
},
}
}
private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined {
const agent = this.ctx.agents.get(sessionId)
if (agent === undefined) return undefined
return hasApiSessionSubagentOwner(this.ctx, agent.session, agent)
? { error: apiSessionSubagentOwnershipError(sessionId) }
: { agent }
}
private async resume(sessionId: SessionId): Promise<Agent> {
const inspected = await inspectApiSession(this.ctx, sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
const composition = await this.composeAgent(resolveSessionPreset({
header: inspected.meta,
events: inspected.events,
}))
const published = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
}
private async createOrAdopt(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId: string | undefined,
): Promise<Agent> {
const attached = this.ctx.sessions.get(sessionId)
const live = this.ctx.agents.get(sessionId)
if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (live !== undefined) return live
const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined
const stored = persistence === undefined
? undefined
: (await persistence.list()).find(header => header.id === sessionId)
if (persistence !== undefined && stored !== undefined) {
const inspected = await persistence.inspect(sessionId)
if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) {
throw new ApiSessionSubagentOwnership(sessionId)
}
if (inspected.meta.cwd !== cwd) {
throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
this.assertPresetUnchanged(sessionId, presetId, storedPreset)
const composition = await this.composeAgent(storedPreset)
return (await this.ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: this.agentOptions(),
setup: composition.setup,
})).agent
}
try {
await mkdir(cwd, { recursive: true })
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
const composition = await this.composeAgent(presetId)
return (await this.ctx.agents.create({
sessionId,
agentOptions: this.agentOptions(),
meta: {
cwd,
...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }),
},
setup: composition.setup,
})).agent
}
private agentOptions(): AgentOptions {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
return { provider, model }
}
private installSelection(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent')
this.selectionFor(agent)
}
private assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
existing: string | undefined,
): void {
if (requested === undefined || requested === existing) return
throw new ApiSessionPresetConflict(sessionId, requested, existing)
}
}
@@ -0,0 +1,63 @@
/** Shared projection of the live LLM registry into the browser model catalog. */
import type { Context } from '@deepseek-ai/cordis'
import type {
ModelCatalogFailure,
ModelProviderGroup,
ModelReasoning,
} from './types.ts'
/**
* Build the browser model catalog without requiring a Session.
* @param ctx - Host context carrying the live LLM registry.
* @returns successful non-empty provider groups and isolated provider failures.
*/
export async function buildModelCatalog(ctx: Context): Promise<{
readonly groups: ModelProviderGroup[]
readonly failures: ModelCatalogFailure[]
}> {
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
try {
const models = await ctx.llm.listModels(provider.id)
const entries = await Promise.all(models.map(async (model) => {
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
? undefined
: {
efforts: resolved.reasoning.efforts.map(effort => ({
id: effort.id,
name: effort.name,
...(effort.description === undefined ? {} : { description: effort.description }),
})),
...(resolved.reasoning.defaultEffort === undefined
? {}
: { defaultEffort: resolved.reasoning.defaultEffort }),
}
return {
id: model.id,
name: model.name,
...(model.description === undefined ? {} : { description: model.description }),
...(reasoning === undefined ? {} : { reasoning }),
}
}))
return {
kind: 'group' as const,
group: { id: provider.id, name: provider.name, models: entries },
}
} catch (error) {
return {
kind: 'failure' as const,
failure: {
id: provider.id,
name: provider.name,
message: error instanceof Error ? error.message : String(error),
},
}
}
}))
return {
groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
.filter(group => group.models.length > 0),
failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
}
}
@@ -0,0 +1,146 @@
/** Observable contiguous Session event window consumed by domain assemblers. */
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { SessionEventEntry } from '../../types.ts'
interface EventWindowLeaf {
readonly kind: 'leaf'
readonly entries: readonly SessionEventEntry[]
readonly length: number
}
interface EventWindowConcat {
readonly kind: 'concat'
readonly left: EventWindowNode
readonly right: EventWindowNode
readonly length: number
}
type EventWindowNode = EventWindowLeaf | EventWindowConcat
function leaf(entries: readonly SessionEventEntry[]): EventWindowLeaf {
return { kind: 'leaf', entries, length: entries.length }
}
function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat {
return { kind: 'concat', left, right, length: left.length + right.length }
}
function materialize(node: EventWindowNode): readonly SessionEventEntry[] {
if (node.kind === 'leaf') return node.entries
const entries = new Array<SessionEventEntry>(node.length)
const pending: EventWindowNode[] = [node]
let index = 0
while (pending.length > 0) {
const current = pending.pop() as EventWindowNode
if (current.kind === 'concat') {
pending.push(current.right, current.left)
continue
}
for (const entry of current.entries) {
entries[index] = entry
index += 1
}
}
return entries
}
function windowSnapshot(
node: EventWindowNode,
hasMore: boolean,
revision: number,
change: SessionEventChange,
): SessionEventWindow {
let entries: readonly SessionEventEntry[] | undefined
return {
get entries() {
entries ??= materialize(node)
return entries
},
hasMore,
revision,
change,
}
}
/** Exact delta that produced the latest event-window revision. */
export type SessionEventChange =
| { readonly kind: 'replace'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventEntry[] }
| { readonly kind: 'append'; readonly entries: readonly SessionEventEntry[] }
/** Current contiguous event window and its latest synchronous delta. */
export interface SessionEventWindow {
readonly entries: readonly SessionEventEntry[]
readonly hasMore: boolean
readonly revision: number
readonly change: SessionEventChange
}
/** Conversation-facing event source exposed by one Session binding. */
export type SessionEventSource = ObservableSnapshot<SessionEventWindow>
/** Session-owned event feed; every accepted window mutation publishes synchronously. */
export class MutableSessionEventSource implements SessionEventSource {
private readonly listeners = new Set<() => void>()
private window: EventWindowNode = leaf([])
private snapshot: SessionEventWindow = windowSnapshot(
this.window,
false,
0,
{ kind: 'replace', entries: [] },
)
/** @returns the cached event-window snapshot. */
getSnapshot(): SessionEventWindow { return this.snapshot }
/**
* Subscribe to synchronous window publication.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Replace the complete contiguous window.
* @param entries - complete window.
* @param hasMore - whether older history remains.
*/
replace(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.window = leaf(entries)
this.publish(hasMore, { kind: 'replace', entries })
}
/**
* Prepend one older contiguous page.
* @param entries - newly loaded older entries.
* @param hasMore - whether still older history remains.
*/
prepend(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.window = concat(leaf(entries), this.window)
this.publish(hasMore, { kind: 'prepend', entries })
}
/**
* Append one contiguous live entry.
* @param entry - live tail entry.
*/
append(entry: SessionEventEntry): void {
const entries = [entry]
this.window = concat(this.window, leaf(entries))
this.publish(this.snapshot.hasMore, {
kind: 'append',
entries,
})
}
private publish(
hasMore: boolean,
change: SessionEventChange,
): void {
this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change)
notifySubscribers(this.listeners, '[session-controller] event feed')
}
}
@@ -0,0 +1,28 @@
/** Client operation results spanning Session Remote calls and the legacy subagent carrier. */
import type { RpcError } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionError } from '../../types.ts'
/** Failure surfaced by the Client Session object layer. */
export type ClientFailure = RpcError | SessionError
/** Success or failure returned by a Client Session operation. */
export type ClientResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: ClientFailure }
/**
* Fold a rejected carrier operation into the Client Session failure vocabulary.
* @param error - rejection from a legacy subagent or local carrier call.
* @returns the failure branch of a Client Session result.
*/
export function transportResult<T>(error: unknown): ClientResult<T> {
return {
ok: false,
error: {
code: 'internal',
message: error instanceof Error ? error.message : String(error),
details: {},
},
}
}
@@ -0,0 +1,94 @@
/**
* The outward session face. Feature packages never see the concrete Session
* class: components read lifecycle 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); implementation-internal entry points (history staging, wire-frame
* dispatch) stay on the class, invisible out here.
*/
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PromptContentPart, QueueAction } from '../../types.ts'
import type { ClientResult } from './result.ts'
import type { SessionSnapshot } from './snapshot.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 - text plus browser-owned temporary image uploads.
* @param mode - 'queue' appends a turn; 'steer' interrupts the running one.
* @returns acceptance, or the business error (also mirrored into snapshot.promptError).
*/
prompt(
content: PromptContentPart[],
mode: 'queue' | 'steer',
signal?: AbortSignal,
): Promise<ClientResult<{ accepted: true }>>
/**
* Resolve one durable image referenced by this session.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
readAttachment(
attachmentId: AttachmentIdType,
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
/**
* Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
* @param action - requested queue operation.
* @returns acceptance, or a business/transport error.
*/
updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>>
/**
* Cancel the running turn. Pending queued work remains and resumes in FIFO
* order after the Host reaches cancellation quiescence.
* @returns acceptance, or the business error.
*/
cancel(): Promise<ClientResult<{ accepted: true }>>
/**
* Rename this session (explicit user title; pins it against automatic
* regeneration).
* @param title - raw title text (the host normalizes acceptance).
* @returns the normalized accepted title and its event seq, or the business error.
*/
rename(title: string): Promise<ClientResult<{ title: string; seq: number }>>
/**
* Extend the history window backwards (older messages pagination).
* @returns completion; failures land in snapshot.openState/loadingOlder.
*/
loadOlder(): Promise<void>
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the Remote face's error branch.
*/
command(line: string): Promise<RemoteResult<{ matched: boolean }>>
}
/**
* The full outward face: behavior verbs plus the Session lifecycle read side
* (the `useSession` hook source). This is the type carried by
* `SessionBinding.session` and the provide channel.
*/
export type SessionFace = ISession & ObservableSnapshot<SessionSnapshot>
@@ -0,0 +1,131 @@
/**
* The outward sessions-service face — what `ctx.sessions` exposes to feature
* packages. Transport entry points and implementation internals stay on
* the concrete class. Widening this interface is the
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type { AgentContext } from '../scope.ts'
import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type { SessionBinding, SessionListState } from '../sessions/service.ts'
import type { ClientResult } from './result.ts'
import type { SessionFace } from './session.ts'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
export type { AgentContext } from '../scope.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>
/**
* The `session.search` result bound the wire schema fixes, exposed to
* presentation as injected data. Not per-connection state: every transport
* (fixture included) reports the same number.
*/
readonly searchResultLimit: number
/**
* Create or adopt a Session on the Host.
* @param opts - target workspace, directory, and optional preallocated identity.
* @returns the Session identity after its local binding is addressable.
*/
create(opts?: {
workspaceId?: WorkspaceId
cwd?: string
sessionId?: SessionId
}): Promise<SessionId>
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
*/
open(id: SessionId): void
/**
* Open a healthy catalog child through its exact direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
openSubagent(address: SubagentAddress): void
/**
* Resolve an already discovered direct-parent address without opening it.
* @param id - possible addressed child id.
* @returns the retained address, when present.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined
/**
* Mark whether a catalog menu is consuming live membership updates.
* @param parentSessionId - catalog owner.
* @param open - current menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
* @returns completion of the current or newly started refresh.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void>
/**
* Record the composition one session now runs. The agent-preset seat calls
* this after a successful blank-session switch, so the header label moves
* with the composition instead of waiting for the next full list refresh.
* @param sessionId - the switched session.
* @param agentPreset - the preset id the host confirmed.
*/
noteAgentPreset(sessionId: SessionId, agentPreset: string): void
/** Clear the current selection into the no-session view state. */
clear(): void
/**
* Refresh the Host-authoritative Session list.
* @returns completion of the current or newly started Session-list refresh.
*/
refresh(): Promise<void>
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results, or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
/**
* 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): AgentContext | undefined
/**
* Read the Agent scope tag off a context (service-method boundary: 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
}
@@ -0,0 +1,45 @@
/** Session-owned observable state excluding Conversation target data. */
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientFailure } from './result.ts'
/** One transient inbox occurrence from the authoritative queue snapshot. */
export interface QueuedMessage {
readonly id: MessageId
readonly messageId: MessageId
readonly placement: 'queued' | 'steering' | 'context'
readonly content: readonly ContentBlock[]
readonly preview: string
readonly text: string | null
}
/** History-open lifecycle of a Session event window. */
export type OpenState = 'cold' | 'loading' | 'open' | 'error'
/** Send/stop failure surfaced by Session consumers. */
export interface PromptError {
readonly op: 'send' | 'stop'
readonly error: ClientFailure
}
/** Immutable Session lifecycle and control snapshot. */
export interface SessionSnapshot {
readonly sessionId: SessionId
readonly queue: readonly QueuedMessage[]
readonly running: boolean
readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null
readonly removed: boolean
readonly openState: OpenState
readonly openError: ClientFailure | null
readonly hasMore: boolean
readonly loadingOlder: boolean
readonly promptError: PromptError | null
readonly blank: boolean
readonly lastAgentError: string | null
/** A prompt call has begun on this Client Session object. */
readonly promptAttempted: boolean
/** The first accepted prompt has not reached a durable `turn/start` event. */
readonly awaitingFirstTurn: boolean
}
@@ -0,0 +1,103 @@
/** Client Session object layer, Agent scopes, and Remote lifecycle wiring. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent/types'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import { createSessionControlStream } from './transport.ts'
import { ClientSessions } from './sessions/service.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
import type {} from '../remote-events.ts'
export {
createSessionControlStream,
SessionEventStream,
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
sessionStreamFailure,
} from './transport.ts'
export type {
ClientSessionPageRequest,
SessionControlStream,
SessionControlStreamOptions,
SessionEventStreamOptions,
SessionJournalChange,
SessionRemote,
} from './transport.ts'
export { createScope, scopeOf } from './scope.ts'
export type { AgentContext, AgentScopeHandle } from './scope.ts'
export { SessionCreateError, SessionForkError } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export type {
SessionListPhase,
SessionListSnapshot,
SessionSearchResultItem,
SubagentCatalogSnapshot,
} from './sessions/manager.ts'
export type { Session } from './sessions/session.ts'
export type {
ProjectionsBaseline,
ProjectionValueStore,
SessionProjectionMap,
UseProjection,
} from './sessions/projection-store.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
export { MutableSessionEventSource } from './contract/events.ts'
export type { SessionEventChange, SessionEventSource, SessionEventWindow } from './contract/events.ts'
export type {
OpenState,
PromptError,
QueuedMessage,
SessionSnapshot,
} from './contract/snapshot.ts'
export type { ClientFailure, ClientResult } from './contract/result.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Client Session object layer and Agent scope owner. */
sessions: import('./contract/sessions.ts').ISessions
}
}
/** Required wire, Remote, and Context projection services. */
export const inject = [
'connection',
'typert',
'remote',
'remote.commands',
'remote.session',
]
/**
* Install Client Session state and its reconnecting control stream.
* @param ctx - Client Cordis context.
*/
export function apply(ctx: Context): void {
const connection = ctx.get('connection') as ConnectionHandle
const remotes = ctx.remote as unknown as SessionRemotes
const sessions = new ClientSessions(ctx, connection.api, remotes)
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
ctx.remote.$on('api-session/status', (sessionId, running) => {
sessions.handleSessionStatus(sessionId, running)
})
ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => {
sessions.handleSessionActivity(sessionId, updatedAt)
})
ctx.remote.$on('api-session/error', (sessionId, message) => {
sessions.handleSessionError(sessionId, message)
})
const control = createSessionControlStream(remotes, {
accept: (frame) => { sessions.handleControlFrame(frame) },
failed: (error) => { console.error('[session-controller] control stream failed:', error) },
})
control.start()
ctx.on('connection/reset', () => { sessions.handleConnected() })
if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected()
ctx.typert.contexts.registerClient('agent', {
identity: candidate => sessions.scopeOf(candidate),
resolve: sessionId => sessions.resolveAgentScope(sessionId),
})
ctx.effect(() => async () => { await control.dispose() }, 'session-controller.client.control')
}
@@ -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
}
@@ -0,0 +1,78 @@
/**
* 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 '@deepseek-ai/cordis'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol'
/** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */
export type AgentContext = Omit<Context, 'remote'> & {
readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'>
}
/** 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: AgentContext
/** 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)
const scoped = fiber.ctx.extend({
[kScope]: key,
[CordisContext.filter](listenerCtx: Context): boolean {
const tag = scopeOf(listenerCtx)
return tag === undefined || tag === key
},
}) as AgentContext
return {
fiber,
ctx: scoped,
}
}
/**
* 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]
}
@@ -0,0 +1,88 @@
// flattenLineage: summaries -> flat list with lineage indentation (pure function).
// 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 } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { SessionSummary } from '../../types.ts'
/** Host list summary enriched with the latest Session Controller title projection. */
export interface TitledSessionSummary extends SessionSummary {
title?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
}
/** One flattened session-list row with lineage depth. */
export interface SessionListEntry {
sessionId: SessionId
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
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
cwd?: string
/** Agent preset the session's agent was composed from (summary passthrough). */
agentPreset?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
/** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */
completed: boolean
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
depth: number
}
/**
* 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 completed - sessions with a pending completion reminder (manager-owned live fact; absent = false).
* @returns display rows in render order.
*/
export function flattenLineage(
summaries: readonly TitledSessionSummary[],
completed?: ReadonlySet<SessionId>,
): SessionListEntry[] {
const byId = new Map<SessionId, TitledSessionSummary>()
for (const s of summaries) byId.set(s.sessionId, s)
const children = new Map<SessionId, TitledSessionSummary[]>()
const roots: TitledSessionSummary[] = []
for (const s of summaries) {
if (s.parentSessionId !== undefined && byId.has(s.parentSessionId)) {
const list = children.get(s.parentSessionId) ?? []
list.push(s)
children.set(s.parentSessionId, list)
} else {
roots.push(s) // root, or an orphan whose parent is absent from summaries (degrade to root, never drop)
}
}
const out: SessionListEntry[] = []
const visited = new Set<SessionId>()
const walk = (s: TitledSessionSummary, depth: number): void => {
if (visited.has(s.sessionId)) {
console.warn(`[session-controller] lineage cycle at ${s.sessionId}; emitting as root`)
return
}
visited.add(s.sessionId)
out.push({
...s,
completed: completed?.has(s.sessionId) ?? false,
depth,
})
const kids = children.get(s.sessionId)
if (kids === undefined) return
for (const kid of kids) walk(kid, depth + 1)
}
for (const root of roots) walk(root, 0)
// Cycle members (unreachable from any root): emit as roots so no entry is lost.
for (const s of summaries) {
if (!visited.has(s.sessionId)) walk(s, 0)
}
return out
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,97 @@
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
/**
* Batches structural updates in microtasks and stream updates by animation
* frame. Reads may rebuild a dirty snapshot without consuming the pending
* subscriber notification.
*/
export class Notifier {
private listeners = new Set<() => void>()
private dirty = false
private notifyPending = false
private scheduled: 'none' | 'microtask' | 'frame' = 'none'
private scheduleGeneration = 0
/** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */
constructor(private readonly rebuild: () => void) {}
/**
* uSES subscription entry.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
/** Mark the snapshot dirty and notify in a microtask. */
markDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled === 'microtask') return
this.schedule('microtask')
}
/** Mark the snapshot dirty and publish cumulative state at most once per frame. */
markFrameDirty(): void {
this.dirty = true
this.notifyPending = true
if (this.scheduled !== 'none') return
this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask')
}
/**
* Synchronous flush: controlled-input writes must notify in the same tick as
* onChange, or React rolls the DOM back to the stale value and the caret jumps to the end.
*/
notifyNow(): void {
this.dirty = true
this.notifyPending = true
this.invalidateSchedule()
this.flush()
}
/**
* 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
this.rebuild()
}
private schedule(kind: 'microtask' | 'frame'): void {
const generation = ++this.scheduleGeneration
this.scheduled = kind
const publish = () => {
if (generation !== this.scheduleGeneration) return
this.scheduled = 'none'
this.flush()
}
if (kind === 'frame') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
private invalidateSchedule(): void {
this.scheduleGeneration++
this.scheduled = 'none'
}
private flush(): void {
if (!this.notifyPending) return
if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot
this.notifyPending = false
if (this.dirty) {
this.dirty = false
this.rebuild()
}
notifySubscribers(this.listeners, '[session-controller]')
}
}
@@ -0,0 +1,199 @@
/**
* Generic per-session projection value store (push model; see the
* session-projection subsystem page, docs/subsystems/session-projection.md):
* the host is the only computation site; the client holds finished
* whole values per key — `key → { value, seq }` — seeded by a Session page's
* projections block and updated by Session Controller `projection` 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` (ui-renderer binds them).
*/
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import { Notifier } from './notifier.ts'
// The single projection type table, typed end to end (host unit, wire block,
// client store, React hook) — the Service Definition 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 (rejected in the Alternatives of
// .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
export type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
/**
* The fifth framework hook seat (see the session-projection subsystem page,
* docs/subsystems/session-projection.md): 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 Session
* Controller's `SessionProjectionsBlock`, 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: Readonly<Record<string, unknown>>
}
/** 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>()
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
/** 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
}
/**
* Read every current projection value as one reference-stable snapshot.
* @returns The same frozen value map until a row changes.
*/
values(): Readonly<Partial<SessionProjectionMap>> {
if (this.valuesCache === undefined) {
this.valuesCache = Object.freeze(Object.fromEntries(
[...this.rows].map(([key, row]) => [key, row.value]),
))
}
return this.valuesCache
}
/**
* 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 from the Session control stream.
* @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 beyond a replacement control baseline. Such rows describe
* process state the Host lost before persisting it and would otherwise
* outrank recomputed lower-seq values forever. The caller seeds the new
* baseline immediately afterward.
* @param lastSeq - highest durable sequence reflected by the baseline.
*/
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.valuesCache = undefined
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
}
}
@@ -0,0 +1,67 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionQueuedItem } from '../../types.ts'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { QueuedMessage } from '../contract/snapshot.ts'
const QUEUE_PREVIEW_CHARS = 200
function previewOf(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
}
function textOf(content: readonly ContentBlock[]): string | null {
if (!content.every(block => block.type === 'text')) return null
return content.map(block => block.text).join('')
}
type QueueItems = readonly SessionQueuedItem[]
/** Authoritative transient queue projection and durable steering handoff. */
export class SessionQueueMirror {
private current: readonly QueuedMessage[] = []
/**
* Return the current immutable queue projection.
* @returns current queue rows.
*/
snapshot(): readonly QueuedMessage[] {
return this.current
}
/**
* Replace from one authoritative stream queue frame.
* @param items - complete host queue snapshot.
*/
replace(items: QueueItems): void {
this.current = items.map((item) => {
const content = item.message.content as unknown as readonly ContentBlock[]
return {
id: item.id,
messageId: item.message.id,
placement: item.placement,
content,
preview: previewOf(content),
text: textOf(content),
}
})
}
/**
* Retire a transient steering row once its durable message enters the log.
* @param event - newly contiguous durable Session event.
* @returns whether the projection changed.
*/
acceptDurable(event: SessionEvent): boolean {
if (event.type !== 'user/message') return false
const messageId = event.data.id
const index = this.current.findIndex(item =>
item.placement === 'steering' && item.messageId === messageId)
if (index < 0) return false
this.current = this.current.filter((_item, candidate) => candidate !== index)
return true
}
}
@@ -0,0 +1,29 @@
/**
* Remote namespaces the Session cluster calls. One parameter for one concept:
* the generated surface a Session and its manager reach the Host through.
*
* @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes
*/
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemote } from '../transport.ts'
/** Narrow Commands namespace consumed by a Client Session. */
export interface SessionCommandsRemote {
execute(
agentId: SessionId,
line: string,
images: readonly EncodedImageAttachment[],
signal?: AbortSignal,
): Promise<RemoteResult<object | undefined>>
}
/** Generated Remote namespaces consumed by the Client Session object layer. */
export interface SessionRemotes {
readonly $stream: ClientRemote['$stream']
readonly commands: SessionCommandsRemote
readonly session: SessionRemote
}
@@ -0,0 +1,735 @@
/**
* ClientSessions: root sessions service — list snapshot store (manager
* projection; carries `current`, the persisted selection every
* session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin
* Fiber + ctx.extend scope tag; one scope per session, agent id === session
* id), stable SessionBinding cache, breadcrumb-route projection.
*
* Scope lifecycle is stage-driven: a scope is minted lazily on first
* resolution (pure — resolution has no side effects and is render-safe);
* the event window and deferred teardown key off the STAGED session, which
* follows `list.current` exactly. Staging is the open signal: the window
* opens ⟺ the session is on stage (the stage is `current`; the staged
* state can widen to a multi-pane list later). A session leaving the list
* tears its scope down immediately unless it is the staged one, whose scope
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type {
IApiClient, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts'
import type { SessionJob as JobView } from '../../types.ts'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-store'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import type { SessionEventSource } from '../contract/events.ts'
import type { SessionFace } from '../contract/session.ts'
import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
id: SessionId
/** Latest durable log-backed title, absent until the host projects one. */
title?: string
/** Human-facing label: durable title, project basename, then session id. */
displayTitle: string
cwd?: string
/**
* Agent preset this session's agent was composed from; absent when the
* deployment composes no presets. The session header labels what the
* session actually runs rather than the deployment's current default.
*/
agentPreset?: string
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
running: boolean
/** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */
completed?: 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
/** Current host-computed projection values retained by the object layer. */
projectionValues?: Readonly<Partial<SessionProjectionMap>>
}
/**
* Session list store shape. `current` rides the same snapshot (arbitrated:
* the single useSessions standard hook reads list and selection together —
* sidebar highlighting and current-session consumers share one fact source).
*/
export interface SessionListState {
/** Host-list order; addressed breadcrumb-only rows are excluded. */
ids: SessionId[]
/** Host rows plus the current addressed subagent route used by navigation. */
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
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/**
* Background jobs each session can see, mirrored last-wins from Session
* Controller's control baseline and `jobs` frames. A missing key is an empty
* set, so consumers read absence rather than a sentinel.
*/
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
/** Current session's catalog-derived address, absent on ordinary navigation. */
currentAddress: SubagentAddress | undefined
}
/** Persisted navigation cell: address survives refresh for correct history routing. */
interface SessionSelection {
sessionId?: SessionId
subagentAddress?: SubagentAddress
}
/** 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: ClientFailure,
readonly requestedSessionId: SessionId | undefined,
) {
super(`session create failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Structured session-fork failure. */
export class SessionForkError extends Error {
override readonly name = 'SessionForkError'
/**
* @param rpcError - Host business or folded transport error.
* @param sourceSessionId - the session the fork was cut from.
*/
constructor(
readonly rpcError: ClientFailure,
readonly sourceSessionId: SessionId,
) {
super(`session fork failed: ${rpcError.code}: ${rpcError.message}`)
}
}
/** Identity-stable logical binding for one materialized Client Session. */
export interface SessionBinding {
readonly sessionId: SessionId
/** The outward session face only — feature code never sees the concrete class. */
readonly session: SessionFace
/** Contiguous event window reserved for Conversation assembly. */
readonly eventSource: SessionEventSource
readonly ctx: AgentContext
}
// Scope primitives live in ../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 '../scope.ts'
/**
* Display title projection: durable title, project directory basename, then
* the raw id.
*/
function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string {
if (title !== undefined) return title
if (cwd !== undefined && cwd !== '') {
const base = workspaceTitleOf(cwd)
if (base !== '') return base
}
return id
}
/**
* Increment a trailing fork number while preserving its half-width or
* full-width parentheses; an unnumbered title starts with ` (1)`.
* @param title - source session's durable title.
* @returns the title assigned to the fork child.
*/
function increasedForkTitle(title: string): string {
const ascii = /^(.*?)\((\d+)\)$/u.exec(title)
if (ascii?.[1] !== undefined && ascii[2] !== undefined) {
return `${ascii[1]}(${BigInt(ascii[2]) + 1n})`
}
const fullWidth = /^(.*?)((\d+))$/u.exec(title)
if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) {
return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})`
}
return `${title} (1)`
}
interface ScopeRecord {
fiber: Fiber
ctx: AgentContext
binding: SessionBinding
/** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */
session: Session
}
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */
export class ClientSessions implements ISessions {
/**
* The wire schema's own result bound, re-exposed for presentation plugins as
* injected data. Not per-connection state: the `session.search` response
* schema caps `items` at this constant, so every transport (fixture included)
* reports the same number.
*/
readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** 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. */
private readonly manager: SessionManager
/**
* Persisted selection cell (the durable half of `list.current`). Private on
* purpose: reads go through the list snapshot; writes through {@link
* ClientSessions.open} / {@link ClientSessions.clear}. Projection
* validates it against the live list instead of destructively pruning, so a
* selection survives transient list states (reconnect re-pull) and
* resurfaces when its session returns.
*/
private readonly selection: SnapshotStore<SessionSelection>
private readonly scopes = new Map<SessionId, ScopeRecord>()
/** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */
private readonly scopeDrops = new Set<Promise<void>>()
/**
* The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks
* `current` without moving the stage, so reconnect re-pulls and removals
* keep the staged scope's frozen view alive until the stage moves on).
*/
private watched: SessionId | undefined
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
private readonly deferredRemovals = new Set<SessionId>()
/**
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
* @param remote - generated Remote namespaces shared with every Session.
*/
constructor(
private readonly rootCtx: Context,
api: IApiClient,
remote: SessionRemotes,
) {
this.selection = createSnapshotStore<SessionSelection>(
{},
{ persist: { name: 'dsh.sessions.current' } })
const restored = this.selection.getSnapshot()
this.manager = new SessionManager(
api,
remote,
restored.sessionId,
restored.subagentAddress,
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
const disposeManagerProjection = this.manager.subscribe(() => {
this.projectList()
})
// Stage follower: every current write (open() and projection alike)
// re-evaluates staging, so startup restore (persisted selection validated
// by the projection) and reconnect resurfacing open their window with no
// 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.
const disposeStageFollower = this.list.subscribe(() => {
this.followCurrent()
})
rootCtx.effect(() => async () => {
disposeStageFollower()
disposeManagerProjection()
const scopes = [...this.scopes]
this.scopes.clear()
this.deferredRemovals.clear()
this.watched = undefined
for (const [id, record] of scopes) this.startScopeDrop(id, record)
await this.drainScopeDrops()
await this.manager.dispose()
}, 'session-controller.client.sessions')
rootCtx.reflect.provide('sessions', this, undefined)
}
/**
* Select a listed or retained catalog-addressed session as current.
* @param id - listed or addressed session id.
*/
open(id: SessionId): void {
this.manager.select(id)
}
/**
* Open a healthy catalog child through its direct-parent address.
* @param address - catalog-derived parent and child ids.
*/
openSubagent(address: SubagentAddress): void {
this.manager.selectSubagent(address)
}
/**
* Resolve an already discovered direct-parent address without opening it.
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
* @param id - possible addressed child id.
* @returns The retained address, when present.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined {
return this.manager.subagentAddress(id)
}
/**
* Inform the Session Controller whether a catalog menu is consuming membership updates.
* @param parentSessionId - selected parent.
* @param open - menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
this.manager.setSubagentCatalogOpen(parentSessionId, open)
}
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
return this.manager.refreshSubagents(parentSessionId)
}
noteAgentPreset(sessionId: SessionId, agentPreset: string): void {
this.manager.noteAgentPreset(sessionId, agentPreset)
}
/**
* Clear the current selection so the layout shows the no-session empty
* 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.manager.clearSelection()
}
/**
* Refresh the real Session baseline, reusing an in-flight pull.
* @returns completion of the current or newly started baseline pull.
*/
refresh(): Promise<void> {
return this.manager.refreshList()
}
/**
* Search the Host's visible message-content index. Results stay
* request-local; the list snapshot remains the metadata authority.
* @param query - non-blank literal phrase.
* @param signal - cancellation for a superseded search.
* @returns bounded results or a business/transport error.
*/
search(
query: string,
signal: AbortSignal,
): Promise<ClientResult<{ items: SessionSearchResultItem[]; hasMore: boolean }>> {
return this.manager.search(query, signal)
}
/**
* Apply one Session Controller live-control frame.
* @param frame - baseline or live control replacement.
*/
handleControlFrame(frame: Parameters<SessionManager['handleControlFrame']>[0]): void {
this.manager.handleControlFrame(frame)
}
/**
* Apply one remotely forwarded Session-list addition.
* @param summary - current Host summary for the added Session.
*/
handleSessionAdded(summary: Parameters<SessionManager['handleSessionAdded']>[0]): void {
this.manager.handleSessionAdded(summary)
}
/**
* Apply one remotely forwarded Session removal.
* @param sessionId - removed Session identity.
*/
handleSessionRemoved(sessionId: Parameters<SessionManager['handleSessionRemoved']>[0]): void {
this.manager.handleSessionRemoved(sessionId)
}
/**
* Apply one remotely forwarded running-state change.
* @param args - Session identity and current Agent running state.
*/
handleSessionStatus(...args: Parameters<SessionManager['handleSessionStatus']>): void {
this.manager.handleSessionStatus(...args)
}
/**
* Apply one remotely forwarded list-activity change.
* @param args - Session identity and durable activity timestamp.
*/
handleSessionActivity(...args: Parameters<SessionManager['handleSessionActivity']>): void {
this.manager.handleSessionActivity(...args)
}
/**
* Apply one remotely forwarded Agent failure.
* @param args - Session identity and caller-visible failure description.
*/
handleSessionError(...args: Parameters<SessionManager['handleSessionError']>): void {
this.manager.handleSessionError(...args)
}
/** 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 ClientSessions.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
}
/**
* Fork a session from a completed-turn prefix of the source (same
* synchronous-addressability guarantee as {@link ClientSessions.create}:
* on resolution the child is in the list store and open() can target it).
* @param opts - source session id, the optional event seq anchoring the
* cut (the boundary is the first turn/end at or after it; an in-log
* anchor in an open turn is unavailable rather than clipped backward),
* and whether to increment an inherited durable title before resolving.
* A fractional anchor floors to a real event seq: the frozen nodes of an
* interrupted turn carry flow-ordering seqs between two events, and the
* wire takes integers only.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
*/
async fork(opts: {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
}): Promise<SessionId> {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
: undefined
const result = await this.manager.fork({
sessionId: opts.sessionId,
// Flooring lands inside the anchor's own turn (every turn opens with a
// turn/start), so the host's first-turn/end-at-or-after cut still ends
// on that turn — never clipped back to the previous one.
...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
if (sourceTitle !== undefined) {
const child = this.binding(childId)?.session
if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`)
const renamed = await child.rename(increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
}
return childId
}
/**
* 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): AgentContext | undefined {
return this.resolve(id)?.ctx
}
/**
* Materialize the Agent scope named by a validated Host Remote Event.
* The first successful Session-list baseline becomes authoritative for its
* lifetime; until then, transport streams may address the scope in either
* arrival order.
* @param id - Host-projected Agent identity (the matching Session id).
* @returns the identity-stable Agent Context.
*/
resolveAgentScope(id: SessionId): AgentContext {
return (this.scopes.get(id) ?? this.materializeScope(id)).ctx
}
/**
* Read the Agent scope tag off a context. Service-method boundary: 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.
* @param ctx - any client context.
* @returns the session id, or undefined on root contexts.
*/
scopeOf(ctx: Context): SessionId | undefined {
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 boundary as
* {@link ClientSessions.scopeOf}.
* @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 {
const id = scopeTagOf(ctx)
if (id === undefined) return undefined
return this.scopes.get(id)?.binding.session
}
/**
* Resolve the stable session binding (scope-addressed assembly feed). Pure
* resolution — no staging, no window side effects.
* @param id - session id.
* @returns binding, or undefined for a session neither listed nor already scoped.
*/
binding(id: SessionId): SessionBinding | undefined {
return this.resolve(id)?.binding
}
/**
* Move the stage to the list's current session: sweep teardowns deferred
* behind the previous occupant and pull the new occupant's history window.
* Staging IS the open signal — the window opens ⟺ the session is on stage
* — and open() is idempotent (an in-flight or completed open no-ops; a
* failed one retries the next time current is touched).
*/
private followCurrent(): void {
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 || snapshot.byId[current] === undefined || current === this.watched) return
this.watched = current
this.sweepDeferred()
const record = this.resolve(current)
/* v8 ignore next 3 -- defensive: current is always a listed id (open()
* 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.session.open()
void this.manager.refreshSubagents(current)
}
}
/**
* Lazily mint the scope + binding for an eligible session. Eligibility and
* prune share one predicate: listed on the host or selected
* through a retained subagent address. Breadcrumb-only ancestors remain
* summary data and do not keep scopes alive.
*/
private resolve(id: SessionId): ScopeRecord | undefined {
const existing = this.scopes.get(id)
if (existing !== undefined) return existing
if (!this.eligible(id)) return undefined
return this.materializeScope(id)
}
/** Materialize one scope after its caller establishes that the id may be addressed. */
private materializeScope(id: SessionId): ScopeRecord {
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, eventSource: session.eventSource, ctx }
const record: ScopeRecord = {
fiber,
ctx,
binding,
session,
}
this.scopes.set(id, record)
return record
}
/** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */
private eligible(id: SessionId): boolean {
const { ids, current } = this.list.getSnapshot()
return current === id || ids.includes(id)
}
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, jobsBySession, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
for (const entry of items) {
ids.push(entry.sessionId)
byId[entry.sessionId] = {
id: entry.sessionId,
displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId),
running: entry.running,
...(entry.completed ? { completed: true } : {}),
blank: entry.blank,
updatedAt: entry.updatedAt,
...(entry.projectionValues === undefined
? {}
: { projectionValues: entry.projectionValues }),
...(entry.title !== undefined ? { title: entry.title } : {}),
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
}
}
if (current !== undefined && currentAddress !== undefined) {
const seen = new Set<SessionId>()
let address: SubagentAddress | undefined = currentAddress
while (address !== undefined && !seen.has(address.childSessionId)) {
const childId = address.childSessionId
seen.add(childId)
const child = subagentsByParent[address.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === childId)
if (child?.kind !== 'child') break
const displayTitle = child.label ?? childId
const summary = byId[childId]
if (summary === undefined) {
byId[childId] = {
id: childId,
displayTitle,
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
blank: false,
updatedAt: 0,
}
} else if (summary.displayTitle !== displayTitle) {
byId[childId] = { ...summary, displayTitle }
}
const parent = byId[address.parentSessionId]
if (parent !== undefined && parent.origin !== 'subagent') break
address = this.manager.navigationAddress(address.parentSessionId)
}
}
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.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId
|| this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId
|| this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) {
this.selection.set({
sessionId: current,
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
this.pruneScopes()
}
/** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */
private pruneScopes(): void {
if (this.list.getSnapshot().phase === 'pending') return
for (const [id, record] of this.scopes) {
if (this.eligible(id)) continue
if (id === this.watched) {
this.deferredRemovals.add(id)
continue
}
this.scopes.delete(id)
this.deferredRemovals.delete(id)
this.startScopeDrop(id, record)
}
}
private startScopeDrop(id: SessionId, record: ScopeRecord): void {
const drop = this.dropScope(id, record)
this.scopeDrops.add(drop)
void drop.then(
() => { this.scopeDrops.delete(drop) },
() => { this.scopeDrops.delete(drop) },
)
}
private async drainScopeDrops(): Promise<void> {
while (this.scopeDrops.size > 0) {
await Promise.allSettled([...this.scopeDrops])
}
}
/**
* One teardown for the whole per-session axis: the scope
* fiber (cascading every actx-registered effect: input shell, slash
* controller, popup, plugin stores, listeners), the session-keyed slot
* registrations and the Session instance itself — the host session log is the
* durable truth, a reopen lazily rebuilds and backfills via open().
*/
private async dropScope(id: SessionId, record: ScopeRecord): Promise<void> {
// Release the Session's dispatch point with the scope it belongs to (a
// surviving instance — the live Intent — rebinds when resolve re-mints).
record.session.unbindScope()
await Promise.allSettled([
record.fiber.dispose(),
this.manager.drop(id),
])
}
/** Run deferred teardowns whose session is no longer staged (called when the stage moves). */
private sweepDeferred(): void {
for (const id of [...this.deferredRemovals]) {
/* v8 ignore next -- defensive: only the staged id ever defers, and every
* 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
// Eligible again? (A re-added id cancels the deferred teardown.)
if (this.eligible(id)) {
this.deferredRemovals.delete(id)
continue
}
const record = this.scopes.get(id)
this.deferredRemovals.delete(id)
/* v8 ignore next -- defensive: prune deletes a scope and its deferral
* together, so a deferred id always still owns its record; kept so a
* future teardown path cannot double-dispose. */
if (record !== undefined) {
this.scopes.delete(id)
this.startScopeDrop(id, record)
}
}
}
}
@@ -0,0 +1,659 @@
// Sessions remain resident after creation so their open Remote sources keep running off-screen.
import type { Context } from '@deepseek-ai/cordis'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {
IApiClient, SubagentAddress,
} from '@deepseek-ai/dsh-client-connection/client'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import {
SessionEventStream,
sessionStreamFailure,
} from '../transport.ts'
import type { SessionJournalChange } from '../transport.ts'
import type {
PromptContentPart,
QueueAction,
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionQueuedItem,
SessionRequestId,
SessionError,
} from '../../types.ts'
import type { ClientFailure, ClientResult } from '../contract/result.ts'
import { transportResult } from '../contract/result.ts'
import type { SessionFace } from '../contract/session.ts'
import type {
OpenState, PromptError, SessionSnapshot,
} from '../contract/snapshot.ts'
import { MutableSessionEventSource } from '../contract/events.ts'
import { Notifier } from './notifier.ts'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemotes } from './remotes.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { resolvedClientTimeZone } from '../time-zone.ts'
import { SessionQueueMirror } from './queue-mirror.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read. */
parentAvailable?: boolean
/**
* 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
}
/**
* Owns a session's event window, lifecycle state, and observable
* 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 Session Controller internals.
*/
export class Session implements SessionFace {
// ---- Window and derived state (all private; the snapshot is the only read API) ----
private eventWindow: SessionEvent[] = []
private baseSeq = 0
private hasMore = false
private openState: OpenState = 'cold'
private openError: ClientFailure | null = null
private openPromise: Promise<void> | null = null
/** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private readonly queueMirror = new SessionQueueMirror()
private running = false
private address: SubagentAddress | undefined
private parentAvailable = 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
/** A first accepted prompt stays in the engaging phase until its turn is observable. */
private firstPromptPendingTurn = false
/** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */
private blankBit = true
private removed = false
private promptError: PromptError | null = null
private lastAgentError: string | null = null
/** Owns the addressed page/follow lifecycle while this Session is open. */
private events: SessionEventStream | undefined
/**
* Per-session projection value store (push model; see the session-projection
* subsystem page, docs/subsystems/session-projection.md): finished whole
* values computed on the Host, seeded by the tail page's
* projections block and updated by Session Controller control 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
/** Contiguous history and live tail consumed by Conversation assembly. */
readonly eventSource = new MutableSessionEventSource()
private snapshotCache: SessionSnapshot
private readonly notifier: Notifier
/**
* Agent-scoped cordis context, bound once by ClientSessions 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
/**
* @param sessionId - Host session identity (client sessions are always Host-born).
* @param api - shared wire client.
* @param remote - generated Remote namespaces this session calls.
* @param options - optional manager-owned state observers.
*/
constructor(
readonly sessionId: SessionId,
private readonly api: IApiClient,
private readonly remote: SessionRemotes,
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
this.snapshotCache = this.buildSnapshot()
}
/**
* Bind the Agent-scoped context minted by ClientSessions (single write;
* a second bind is a wiring error and throws). Direction stays one-way at
* this binding boundary: 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 ----
/**
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
* @param content - text plus browser-owned temporary image uploads.
* @param mode - queue appends after the current turn; steer interrupts it.
* @returns the prompt result (also mirrored into promptError on failure).
*/
async prompt(
content: PromptContentPart[],
mode: 'queue' | 'steer',
signal?: AbortSignal,
): Promise<ClientResult<{ 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
if (this.blankBit) this.firstPromptPendingTurn = true
this.notifier.markDirty()
let result: ClientResult<{ accepted: true }>
try {
if (this.address === undefined) {
const clientTimeZone = resolvedClientTimeZone()
result = toSessionResult(await this.remote.session.prompt({
requestId: randomUUID() as SessionRequestId,
sessionId: this.sessionId,
mode,
content,
clientTimeZone,
}, signal))
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
error: {
code: 'subagent-not-resumable',
message: 'one-shot subagent conversations are read-only',
details: { childSessionId: this.address.childSessionId },
},
}
} else {
if (content.some(part => part.type === 'image')) {
result = {
ok: false,
error: {
code: 'attachment-error',
message: 'Image input is unavailable for subagent continuations.',
details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' },
},
}
} else {
const routed = (await this.api.subagents.prompt({
...this.address,
content: content.flatMap(part => part.type === 'text'
? [{ type: 'text' as const, text: part.text }]
: []),
clientTimeZone: resolvedClientTimeZone(),
}, signal)).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
}
} catch (error) {
result = transportResult(error)
}
if (!result.ok) {
this.promptError = { op: 'send', error: result.error }
this.notifier.markDirty()
return result
}
// Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the
// conversation's first turn on the host (the host criterion — a logged
// turn/start — is fact, not optimism; standalone command and projection
// events never flip it), 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
}
/**
* Resolve one image referenced by this session into browser-consumable bytes.
* @param attachmentId - opaque id found in the folded session log.
* @returns the authenticated reference and decoded bytes.
*/
async readAttachment(
attachmentId: AttachmentIdType,
): Promise<ClientResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>> {
try {
const result = await this.remote.session.attachment({
sessionId: this.sessionId,
attachmentId,
})
if (!result.ok) return toSessionResult(result)
const binary = atob(result.value.data)
const data = Uint8Array.from(binary, char => char.charCodeAt(0))
return { ok: true, value: { attachment: result.value.attachment, data } }
} catch (error) {
return transportResult(error)
}
}
/** Apply one operation to a still-pending queue occurrence. */
async updateQueue(itemId: MessageId, action: QueueAction): Promise<ClientResult<{ accepted: true }>> {
try {
return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action }))
} catch (error) {
return transportResult(error)
}
}
/**
* Stop the active turn while the Host preserves pending inbox work; failures
* land in promptError (same error-strip display slot). A continuable
* subagent address routes through `subagent.interrupt`, whose durable
* parent-address authority works without a live parent Agent; a one-shot
* address stays uncancellable (the UI offers no stop action, so this arm is
* defensive).
* @returns the cancel result.
*/
async cancel(): Promise<ClientResult<{ accepted: true }>> {
const address = this.address
if (address !== undefined && address.mode === 'one-shot') {
const result: ClientResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
return result
}
let result: ClientResult<{ accepted: true }>
try {
result = address !== undefined
? (await this.api.subagents.interrupt(address)).result
: toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId }))
} catch (error) {
result = transportResult(error)
}
if (!result.ok) {
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
}
return result
}
/**
* Rename: contract session.rename 1:1. On success settle the 'title'
* projection cell from the response's `{title, seq}` under the store's
* higher-seq-wins rule (the push frame arriving later is a no-op replay),
* so the list row and any useProjection('title') reader update without
* waiting for the control-stream projection update.
* @param title - raw title text (the host normalizes acceptance).
* @returns the rename result (normalized accepted title + title event seq).
*/
async rename(title: string): Promise<ClientResult<{ title: string; seq: number }>> {
try {
const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title }))
if (result.ok) this.projections.apply('title', result.value.title, result.value.seq)
return result
} catch (error) {
return transportResult(error)
}
}
/**
* Execute one slash-command line against this session's agent — pure
* admission semantics (the host executor durably logs the lifecycle;
* outcomes render as flow nodes, never as a response echo).
* @param line - the full command line, leading slash included.
* @returns the admission result, or the error branch on transport failure.
*/
async command(line: string): Promise<RemoteResult<{ matched: boolean }>> {
const result = await this.remote.commands.execute(this.sessionId, line, [])
if (!result.ok) return result
return { ok: true, value: { matched: result.value !== undefined } }
}
/** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */
open(): Promise<void> {
if (this.openState === 'open') return Promise.resolve()
if (this.openPromise !== null) return this.openPromise
const promise = this.doOpen(this.openGeneration).finally(() => {
// Identity-guarded: a superseded open must not null out the promise resync just started.
if (this.openPromise === promise) this.openPromise = null
})
this.openPromise = promise
return promise
}
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
const events = this.events
if (events === undefined) return
this.loadingOlder = true
this.notifier.markDirty()
try {
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
} catch (error) {
if (sessionStreamFailure(error) === undefined) {
console.error('[session-controller] loadOlder failed:', error)
}
} finally {
this.loadingOlder = false
this.notifier.markDirty()
}
}
/** Rebuild an opened history source after address replacement.
* Invalidates any in-flight open first; queue state belongs to the independently
* reconnecting control stream and remains untouched. */
async resync(): Promise<void> {
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++
const events = this.events
this.events = undefined
await events?.dispose()
this.openPromise = null
this.openState = 'cold'
this.openError = null
this.eventWindow = []
this.baseSeq = 0
this.notifier.markDirty()
await this.open()
}
// ---- Subscription API (useSyncExternalStore direct wiring) ----
/**
* uSES subscription entry.
* @param listener - change callback.
* @returns the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
return this.notifier.subscribe(listener)
}
/**
* Cached Session snapshot (rebuilt lazily when dirty with no listeners).
* @returns the cached reference (stable until the next flush).
*/
getSnapshot(): SessionSnapshot {
this.notifier.ensureFresh()
return this.snapshotCache
}
// ---- Manager-only entry points (@internal; never called by the UI) ----
/**
* Replace every transient control value for this Session from one stream baseline.
* @param queue - complete pending queue for this Session.
*/
replaceControl(queue: readonly SessionQueuedItem[]): void {
this.queueMirror.replace(queue)
this.notifier.markDirty()
}
/**
* Apply one Session-addressed live control update.
* @param frame - queue replacement addressed to this Session.
*/
handleControlFrame(frame: Extract<SessionControlFrame, { type: 'queue' }>): void {
this.queueMirror.replace(frame.items)
this.notifier.markDirty()
}
/**
* Running-bit relay from the host stream (list entry and snapshot stay consistent).
* @param running - the new running state.
*/
handleRunning(running: boolean): void {
// Turn-start conversion: a blank session never runs, so the first
// running:true proves another side's first message landed.
if (running && this.blankBit) {
this.blankBit = false
this.notifier.markDirty()
}
if (running) this.firstPromptPendingTurn = false
if (this.running === running) return
this.running = running
this.notifier.markDirty()
}
/**
* Install or clear the catalog-discovered transport address. A changed
* address rebuilds an already-open window through its new history route.
* @param address - direct parent/child address, or undefined for ordinary transport.
* @param parentAvailable - latest exact-parent availability hint.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
&& this.address?.mode === address?.mode
this.address = address
this.parentAvailable = parentAvailable
if (!same && this.openState !== 'cold') void this.resync()
else this.notifier.markDirty()
}
/**
* Update only the parent availability hint from a catalog refresh.
* @param available - whether the exact direct parent is live.
*/
handleSubagentParentAvailable(available: boolean): void {
if (this.parentAvailable === available) return
this.parentAvailable = available
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (`session.list` and
* `api-session/added`). 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()
}
/** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
handleRemoved(): void {
this.removed = true
this.notifier.markDirty()
}
/**
* `api-session/error` relay: the outlet for live failures with no turn position.
* @param message - the stringified error.
*/
handleAgentError(message: string): void {
this.lastAgentError = message
this.notifier.markDirty()
}
/**
* Stop the Session's live Remote source.
* @returns when the Remote iterator has completed teardown.
*/
async dispose(): Promise<void> {
this.openGeneration++
const events = this.events
this.events = undefined
await events?.dispose()
}
// ---- Private ----
/** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */
private async doOpen(generation: number): Promise<void> {
this.openState = 'loading'
this.openError = null
this.notifier.markDirty()
const events = new SessionEventStream(this.remote, this.sessionAddress(), {
publish: (change) => {
if (generation !== this.openGeneration || this.events !== events) return
this.acceptEventChange(change)
},
failed: (error) => {
this.failEventStream(events, generation, error)
},
})
this.events = events
try {
await events.open({ maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration || this.events !== events) return
this.openState = 'open'
} catch (error) {
if (generation !== this.openGeneration || this.events !== events) return
this.events = undefined
this.openState = 'error'
this.openError = openFailure(error)
} finally {
if (generation === this.openGeneration) this.notifier.markDirty()
}
}
/** Apply one contiguous journal update already reconciled by the Remote stream. */
private acceptEventChange(change: SessionJournalChange): void {
switch (change.type) {
case 'replace':
this.installWindow(change.entries, change.hasMore, change.page.projections)
return
case 'prepend':
this.prependWindow(change.entries, change.hasMore)
return
case 'append':
if (this.appendLive(change.entry)) this.notifier.markDirty()
}
}
/** Replace the complete contiguous window and apply page-owned projection metadata. */
private installWindow(entries: readonly SessionEventEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
this.eventWindow = entries.map(entry => entry.event as SessionEvent)
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
if (this.eventWindow.some(event => event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(entries, hasMore)
this.notifier.markDirty()
}
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventEntry[], hasMore: boolean): void {
this.eventWindow = [...entries.map(entry => entry.event as SessionEvent), ...this.eventWindow]
this.baseSeq = this.eventWindow[0]?.seq ?? 0
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
}
/** Append one stream-validated live event. */
private appendLive(entry: SessionEventEntry): boolean {
const event = entry.event as SessionEvent
this.eventWindow.push(event)
const awaitingFirstTurn = this.firstPromptPendingTurn
if (event.type === 'turn/start') this.firstPromptPendingTurn = false
const queueChanged = this.queueMirror.acceptDurable(event)
this.eventSource.append(entry)
return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn
}
/** Publish a terminal background failure only while this stream still owns the Session. */
private failEventStream(events: SessionEventStream, generation: number, error: unknown): void {
if (generation !== this.openGeneration || this.events !== events) return
this.openGeneration++
this.events = undefined
this.openPromise = null
this.openState = 'error'
this.openError = openFailure(error)
void events.dispose()
this.notifier.markDirty()
}
private buildSnapshot(): SessionSnapshot {
return {
sessionId: this.sessionId,
queue: this.queueMirror.snapshot(),
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
removed: this.removed,
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,
promptAttempted: this.promptAttempted,
awaitingFirstTurn: this.firstPromptPendingTurn,
}
}
private sessionAddress(): SessionAddress {
return this.address === undefined
? { kind: 'session', sessionId: this.sessionId }
: { kind: 'subagent', ...this.address }
}
}
/** Convert a terminal Session stream failure to the Client error vocabulary. */
function openFailure(error: unknown): ClientFailure {
const failure = sessionStreamFailure(error)
if (failure !== undefined) return failure as SessionError
const folded = transportResult<never>(error)
/* v8 ignore next -- transportResult never returns an ok result. */
if (folded.ok) throw new Error('transportResult returned an unexpected success')
return folded.error
}
/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */
function toSessionResult<T>(result: RemoteResult<T>): ClientResult<T> {
return result.ok ? result : { ok: false, error: result.error as SessionError }
}
@@ -0,0 +1,14 @@
/** Browser-owned time-zone sampling for prompt RPC provenance. */
/**
* Resolve the current browser IANA zone for one outbound operation.
* @returns The browser-provided canonical zone.
* @throws when the runtime cannot provide a non-empty zone.
*/
export function resolvedClientTimeZone(): string {
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone
if (typeof timeZone !== 'string' || timeZone.length === 0) {
throw new Error('browser time zone is unavailable')
}
return timeZone
}
@@ -0,0 +1,181 @@
/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import {
RemoteJournalStream,
RemoteSnapshotStream,
RemoteStreamCarrierError,
RemoteStreamError,
type ClientRemote,
type RemoteJournalChange,
type RemoteJournalFrame,
} from '@deepseek-ai/dsh-api-gateway/client'
import type {
SessionAddress,
SessionControlFrame,
SessionEventEntry,
SessionPage,
SessionPageRequest,
} from '../types.ts'
export {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
} from '../types.ts'
/** Pagination fields bound to an already-addressed Session journal. */
export type ClientSessionPageRequest = Omit<SessionPageRequest, 'address' | 'throughSeq'>
/** Complete generated `ctx.remote.session` namespace. */
export type SessionRemote = ClientRemote['session']
/** One complete publication from the Session journal stream. */
export type SessionJournalChange = RemoteJournalChange<SessionPage, SessionEventEntry>
type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
/** Gateway-owned control snapshot stream configured for Session frames. */
export type SessionControlStream = RemoteSnapshotStream<
SessionControlBaselineFrame,
SessionControlDeltaFrame
>
type SessionStreamRemote = Pick<ClientRemote, '$stream' | 'session'>
/** Domain sinks used by the Host-wide Session control stream. */
export interface SessionControlStreamOptions {
/** Apply a complete baseline or one later update. */
readonly accept: (frame: SessionControlFrame) => void
/** Observe a retryable carrier loss before reconnection. */
readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
/** Publish a terminal business or protocol failure. */
readonly failed: (error: unknown) => void
}
/** Domain sinks used by one addressed Session event journal. */
export interface SessionEventStreamOptions {
/** Apply one complete event-window change. */
readonly publish: (change: SessionJournalChange) => void
/** Observe a retryable carrier loss before reconnection. */
readonly carrierFailed?: (error: RemoteStreamCarrierError) => void
/** Publish a terminal stream, page, or protocol failure after opening. */
readonly failed: (error: unknown) => void
}
/**
* Create the Host-wide Session control snapshot stream.
* @param remote - generated Session namespace and Gateway stream factory.
* @param options - Session state destinations.
* @returns an unstarted stream owned by the Client Session runtime.
*/
export function createSessionControlStream(
remote: SessionStreamRemote,
options: SessionControlStreamOptions,
): SessionControlStream {
const stream = remote.$stream<SessionControlFrame>({
name: 'session control stream',
open: signal => remote.session.control(signal),
ended: accepted => accepted
? new RemoteStreamCarrierError('session control stream ended without a terminal result')
: new Error('session control stream ended before its opening snapshot'),
...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }),
})
return new RemoteSnapshotStream(stream, {
name: 'session control stream',
isSnapshot: (frame): frame is SessionControlBaselineFrame => frame.type === 'baseline',
replace: options.accept,
update: options.accept,
failed: options.failed,
})
}
/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
export class SessionEventStream extends RemoteJournalStream<
SessionPage,
SessionEventEntry,
number,
ClientSessionPageRequest
> {
/**
* @param remote - generated Session namespace and Gateway stream factory.
* @param address - durable ordinary-Session or direct-subagent address.
* @param options - Session event-window destinations.
*/
constructor(
private readonly remote: SessionStreamRemote,
private readonly address: SessionAddress,
options: SessionEventStreamOptions,
) {
super(remote, {
name: 'session event stream',
emptyCursor: -1,
entries: page => page.events,
hasMore: page => page.hasMore,
cursor: entry => entry.event.seq,
compare: (left, right) => left - right,
follows: (left, right) => right === left + 1,
publish: options.publish,
...(options.carrierFailed === undefined
? {}
: { carrierFailed: options.carrierFailed }),
failed: options.failed,
})
}
/** @inheritdoc */
protected override async * follow(
afterSeq: number | undefined,
signal: AbortSignal,
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number>> {
const request = afterSeq === undefined
? { address: this.address }
: { address: this.address, afterSeq }
for await (const frame of this.remote.session.follow(request, signal)) {
if (frame.type === 'opened') {
yield frame
continue
}
const { type: _type, ...entry } = frame
yield { type: 'entry', entry }
}
}
/** @inheritdoc */
protected override async readPage(
request: ClientSessionPageRequest,
throughSeq: number,
signal: AbortSignal,
): Promise<SessionPage> {
const result = await this.remote.session.page(
{ address: this.address, throughSeq, ...request },
signal,
)
if (!result.ok) {
throw new RemoteStreamError(
result.error.code,
result.error.message,
result.error.details,
)
}
return result.value
}
/** @inheritdoc */
protected override repairRequest(
request: ClientSessionPageRequest,
): ClientSessionPageRequest {
return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }
}
}
/**
* Recover a Host Session failure from a Remote stream terminal error.
* @param error - value thrown while opening or consuming a Session stream.
* @returns the Host failure, or `undefined` for carrier and local failures.
*/
export function sessionStreamFailure(error: unknown): RemoteFailure | undefined {
if (!(error instanceof RemoteStreamError)) return undefined
return { code: error.code, message: error.message, details: error.details }
}
@@ -0,0 +1,614 @@
/** Session commands whose activation policy is explicit at each Remote method. */
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import {
PresetMountError, UnknownPresetError, resolveSessionPreset,
} from '@deepseek-ai/dsh-agent-presets'
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import {
ReasoningEffortId, createUserMessage, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
ApiSessionNotFound,
ApiSessionPresetConflict,
ApiSessionSubagentOwnership,
apiSessionSubagentOwnershipError,
hasApiSessionSubagentOwner,
inspectApiSession,
} from './agent.ts'
import { buildModelCatalog } from './catalog.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
SessionCancelValue,
SessionCreateRequest,
SessionCreateValue,
SessionForkRequest,
SessionForkValue,
SessionModels,
SessionModelsRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
SessionRenameValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from './types.ts'
interface SessionReadState {
readonly id: SessionId
readonly header: SessionHeader
readonly events: SessionEvent[]
}
/** Implements Session business commands delegated by the Session Controller Remote service. */
export class SessionCommandController {
/**
* @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
* @param agents - sole owner of create, resume, and Session-local model selection.
* @param defaultCwd - project directory used when create names neither a Workspace nor a cwd.
*/
constructor(
private readonly ctx: Context,
private readonly agents: ApiSessionAgentController,
private readonly defaultCwd: string,
) {}
/**
* Create or idempotently adopt one ordinary Session.
* @param request - requested identity, location, and Agent preset.
* @returns the Session identity and resolved preset when configured.
*/
async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
if (request.workspaceId !== undefined && request.cwd !== undefined) {
reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {})
}
const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`)
let workspace: Workspace | undefined
if (request.workspaceId !== undefined) {
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
if (workspace === undefined) {
reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, {
workspaceId: request.workspaceId,
})
}
}
const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
let adopted: Agent
try {
adopted = await this.agents.ensureSession(
sessionId,
cwd,
request.sessionId !== undefined,
request.agentPreset,
)
} catch (error) {
this.rejectCreation(sessionId, error)
}
if (workspace !== undefined) {
try {
await workspace.attachSession(sessionId)
} catch (error) {
reject(
'workspace-attach-failed',
`session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
{ sessionId, workspaceId: workspace.id },
)
}
}
const agentPreset = resolveSessionPreset(adopted.session)
return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
}
/**
* Read the current selection and advisory model catalog, explicitly resuming the Session.
* @param request - Session whose model state is requested.
* @returns the current selection and available model groups.
*/
async models(request: SessionModelsRequest): Promise<SessionModels> {
const agent = await this.resolveAgent(request.sessionId)
const current = this.agents.selectionFor(agent).current
const { groups, failures } = await buildModelCatalog(this.ctx)
return {
current: { ...current },
routable: routeServed(this.ctx, current.provider),
groups,
failures,
}
}
/**
* Validate and install one Session-local model selection.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
*/
async selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
const agent = await this.resolveAgent(request.sessionId)
return this.agents.serializeImageAdmission(agent, async () => {
try {
const resolved = await this.ctx.llm.resolveCallConfig({
provider: request.provider,
model: request.model,
...(request.reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }),
})
const selected: AgentModelSelection = {
provider: resolved.provider,
model: resolved.model,
...(resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort }),
}
this.agents.selectionFor(agent).current = selected
try {
await this.ctx.agentDefaultModel.saveSelection(selected)
} catch (error) {
this.ctx.logger.warn(
`session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`,
)
}
return { selected: { ...selected } }
} catch (error) {
if (error instanceof TypertRemoteFailure) throw error
reject(
'model-unavailable',
error instanceof Error ? error.message : String(error),
{ provider: request.provider, model: request.model },
)
}
})
}
/**
* Normalize and append a user-owned Session title.
* @param request - Session identity and proposed title.
* @returns the accepted title and durable event sequence.
*/
async rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
const agent = await this.resolveAgent(request.sessionId)
const titles = this.ctx.get('sessionTitle')
if (titles === undefined) {
reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
}
try {
const accepted = titles.rename(agent.session, request.title)
return { title: accepted.title, seq: accepted.eventSeq }
} catch (error) {
if (error instanceof SessionTitleInvalidError) {
reject('title-invalid', error.message, { sessionId: request.sessionId })
}
reject(
'internal',
`failed to rename session "${request.sessionId}": ${String(error)}`,
{},
)
}
}
/**
* Create a new ordinary Session from one completed-turn prefix.
* @param request - source Session and optional event anchor.
* @returns the new Session identity.
*/
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
if (request.atSeq !== undefined
&& (!Number.isInteger(request.atSeq) || request.atSeq < 0)) {
reject('bad-request', 'atSeq must be a non-negative integer', {})
}
let source: SessionReadState
try {
source = await this.readSessionState(request.sessionId)
} catch (error) {
if (error instanceof ApiSessionNotFound) {
reject('session-not-found', error.message, { sessionId: request.sessionId })
}
reject(
'internal',
`fork source unavailable for session "${request.sessionId}": ${String(error)}`,
{},
)
}
const lastSeq = source.events.at(-1)?.seq ?? -1
const atSeq = request.atSeq
const anchoredBoundary = atSeq === undefined
? undefined
: source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
const boundary = anchoredBoundary
?? (atSeq === undefined || atSeq > lastSeq
? source.events.findLast(event => event.type === 'turn/end')
: undefined)
if (boundary === undefined) {
reject(
'fork-unavailable',
atSeq !== undefined && atSeq <= lastSeq
? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
: `session "${request.sessionId}" has no completed turn to fork from`,
{ sessionId: request.sessionId },
)
}
let cut = boundary.seq + 1
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++
let workspace: Workspace | undefined
try {
workspace = await this.forkWorkspace(source)
} catch (error) {
reject(
'internal',
`failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`,
{},
)
}
const childId = SessionId(`session-${randomUUID()}`)
const composition = await this.agents.composeAgent(resolveSessionPreset(source))
try {
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
await this.ctx.agents.create({
sessionId: childId,
seed: source.events.slice(0, cut),
meta: {
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
parentSession: source.id,
seedLength: cut,
...(composition.agentPreset === undefined
? {}
: { agentPreset: composition.agentPreset }),
},
agentOptions: { provider, model },
setup: composition.setup,
})
} catch (error) {
reject(
'internal',
`failed to fork session "${request.sessionId}": ${String(error)}`,
{},
)
}
if (workspace !== undefined) {
try {
await workspace.attachSession(childId)
} catch (error) {
reject(
'workspace-attach-failed',
`session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
{ sessionId: childId, workspaceId: workspace.id },
)
}
}
return { sessionId: childId }
}
/**
* Admit one browser prompt after explicit Agent resume and image validation.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @returns acknowledgement that the Agent accepted the prompt.
*/
async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
const clientTimeZone = request.clientTimeZone === undefined
? undefined
: canonicalClientTimeZone(request.clientTimeZone)
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
reject(
'invalid-time-zone',
'clientTimeZone must be UTC or a valid IANA Area/Location name',
{ value: request.clientTimeZone },
)
}
const agent = await this.resolveAgent(request.sessionId)
const selection = this.agents.selectionFor(agent).current
if (!routeServed(this.ctx, selection.provider)) {
reject(
'model-unavailable',
`no adapter serves provider "${selection.provider}"; select a model for this session`,
{ provider: selection.provider, model: selection.model },
)
}
const source: MessageSource = {
kind: 'user',
rpcId: request.requestId,
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
}
const hasImage = request.content.some(part => part.type === 'image')
const admit = async (): Promise<SessionPromptValue> => {
try {
if (hasImage) {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
reject(
'attachment-error',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
}
}
const content = await durablePromptContent(this.ctx, request.content)
const message: UserMessage = createUserMessage({ content, source })
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error) {
if (error instanceof TypertRemoteFailure) throw error
if (error instanceof AttachmentError) {
reject('attachment-error', error.message, { reason: error.code })
}
reject('agent-busy', 'prompt rejected', { reason: String(error) })
}
return { accepted: true }
}
return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
}
/**
* Read one durable image after proving the Session log references it.
* @param request - Session and attachment identities used for authorization.
* @returns the durable attachment reference and base64-encoded bytes.
*/
async attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
let source: SessionReadState
try {
source = await this.readSessionState(request.sessionId)
} catch (error) {
if (error instanceof ApiSessionNotFound) {
reject('session-not-found', error.message, { sessionId: request.sessionId })
}
reject(
'internal',
`attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`,
{},
)
}
const ref = referencedImage(source.events, String(request.attachmentId))
if (ref === undefined) {
reject(
'attachment-error',
'Image is not referenced by this session.',
{ reason: 'ATTACHMENT_NOT_REFERENCED' },
)
}
try {
const stored = await this.ctx.attachments.readImage(ref)
return {
attachment: stored.ref,
data: Buffer.from(stored.data).toString('base64'),
}
} catch (error) {
if (error instanceof AttachmentError) {
reject('attachment-error', error.message, { reason: error.code })
}
reject('internal', 'Unable to read image attachment.', {})
}
}
/**
* Mutate one still-pending queue occurrence without resuming a cold Agent.
* @param request - Session, queue item, and requested mutation.
* @returns acknowledgement that the queue mutation was applied.
*/
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
if (request.action.kind === 'edit'
&& request.action.content.some(block => block.type !== 'text')) {
reject(
'attachment-error',
'queue edits accept text content only',
{ reason: 'QUEUE_EDIT_NON_TEXT' },
)
}
const agent = this.ctx.agents.get(request.sessionId)
if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
}
if (agent === undefined) {
reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
}
const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
const located = nextTurn === undefined
? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep }
: { target: 'next-turn' as const, message: nextTurn }
if (located === undefined) {
reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
}
const { target, message } = located
if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
}
if (request.action.kind === 'edit') {
agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
...message,
content: [...request.action.content],
}))
} else {
agent.inbox.remove(request.itemId)
if (request.action.kind === 'steer') agent.steer(message)
}
return { accepted: true }
}
/**
* Cancel one live ordinary Agent while retaining pending inbox work.
* @param request - Session whose active Agent turn is cancelled.
* @returns acknowledgement that cancellation was requested.
*/
cancel(request: SessionCancelRequest): SessionCancelValue {
const agent = this.ctx.agents.get(request.sessionId)
if (agent === undefined) {
reject(
'session-not-found',
`session "${request.sessionId}" not found (not attached)`,
{ sessionId: request.sessionId },
)
}
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
rejectFailure(apiSessionSubagentOwnershipError(request.sessionId))
}
agent.cancel({ kind: 'user' }, { keepInbox: true })
return { accepted: true }
}
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
const found = await this.agents.resolveAgent(sessionId)
if ('error' in found) rejectFailure(found.error)
return found.agent
}
private rejectCreation(sessionId: SessionId, error: unknown): never {
if (error instanceof ApiSessionPresetConflict) {
reject('agent-preset-conflict', error.message, {
sessionId: error.sessionId,
requestedPreset: error.requestedPreset,
...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
})
}
if (error instanceof UnknownPresetError) {
reject('agent-preset-not-found', error.message, {
agentPreset: error.presetId,
available: [...error.available],
})
}
if (error instanceof PresetMountError) {
reject('agent-preset-invalid', error.message, {
agentPreset: error.presetId,
reason: error.reason,
})
}
if (error instanceof ApiSessionCwdConflict) {
reject('session-conflict', error.message, {
sessionId: error.sessionId,
requestedCwd: error.requestedCwd,
...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }),
})
}
if (error instanceof ApiSessionSubagentOwnership) {
rejectFailure(apiSessionSubagentOwnershipError(error.sessionId))
}
reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {})
}
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
return { id: attached.id, header: attached.header, events: [...attached.events] }
}
const inspected = await inspectApiSession(this.ctx, sessionId)
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
}
private async forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
const workspaces = this.ctx.workspaceRegistry.list()
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
if (direct !== undefined || source.header.origin !== 'subagent') return direct
const lineage = await this.ctx.sessionQuery.traceSession(source.id)
for (const ancestor of lineage.ancestors) {
const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
if (workspace !== undefined) return workspace
}
return undefined
}
}
function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never {
throw new TypertRemoteFailure(error)
}
function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
async function durablePromptContent(
ctx: Context,
content: readonly SessionPromptRequest['content'][number][],
): Promise<ContentBlock[]> {
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image'))
let next = 0
return content.map(part => part.type === 'text'
? { type: 'text', text: part.text }
// admitEncodedImages returns one reference per image part in order.
: { type: 'image', attachment: refs[next++] as ImageAttachmentRef })
}
function imageBlockIn(
content: unknown,
match: (ref: ImageAttachmentRef) => boolean,
): ImageAttachmentRef | undefined {
if (!Array.isArray(content)) return undefined
for (const value of content) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown }
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
const ref = block.attachment as ImageAttachmentRef
if (match(ref)) return ref
}
if (block.type === 'tool-result') {
const nested = imageBlockIn(block.content, match)
if (nested !== undefined) return nested
}
}
return undefined
}
function imageInEvent(
event: SessionEvent,
match: (ref: ImageAttachmentRef) => boolean,
): ImageAttachmentRef | undefined {
const data = event.data as {
readonly content?: unknown
readonly message?: { readonly content?: unknown }
readonly inserted?: readonly { readonly content?: unknown }[]
readonly chunk?: { readonly type?: unknown; readonly block?: unknown }
}
const direct = imageBlockIn(data.content, match)
if (direct !== undefined) return direct
const message = imageBlockIn(data.message?.content, match)
if (message !== undefined) return message
for (const inserted of data.inserted ?? []) {
const found = imageBlockIn(inserted.content, match)
if (found !== undefined) return found
}
return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end'
? imageBlockIn([data.chunk.block], match)
: undefined
}
function referencedImage(
events: readonly SessionEvent[],
attachmentId: string,
): ImageAttachmentRef | undefined {
for (const event of events) {
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
if (found !== undefined) return found
}
return undefined
}
const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
function canonicalClientTimeZone(value: string): string | undefined {
if (value.length === 0 || value.trim() !== value
|| (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
try {
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone
} catch {
return undefined
}
}
function routeServed(ctx: Context, provider: string): boolean {
return ctx.llm.listProviders().some(entry => entry.id === provider)
}
@@ -0,0 +1,211 @@
/** Live Session queue, jobs, and projection state with reconnect baselines. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
import type {
JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
} from '@deepseek-ai/dsh-session'
import type {
SessionControlBaseline,
SessionControlFrame,
SessionJob,
SessionProjectionsBlock,
SessionProjectionValues,
SessionQueuedItem,
} from './types.ts'
/** Owns the Host-wide Session control stream. */
export class SessionControlController {
private readonly streams = new Set<ControlQueue>()
/** @param ctx - Host context carrying live Agent, projection, and jobs services. */
constructor(private readonly ctx: Context) {
ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
ctx.on('session/created', (session) => {
const jobs = this.jobsFor(this.ctx.agents.get(session.id))
if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
})
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
this.broadcast({
type: 'projection',
sessionId: session.id,
key,
value: value as JsonValue,
seq,
})
})
})
ctx.inject(['jobs'], (jobsCtx) => {
jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
})
ctx.effect(() => () => {
for (const stream of this.streams) stream.end()
this.streams.clear()
}, 'session-controller.control')
}
/**
* Open one generation of Host-wide live control state.
* @param signal - Remote stream cancellation.
* @returns one complete baseline followed by live replacement frames.
*/
async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
signal.throwIfAborted()
const queue = new ControlQueue()
this.streams.add(queue)
try {
yield { type: 'baseline', value: this.baseline() }
yield* queue.iterate(signal)
} finally {
this.streams.delete(queue)
queue.end()
}
}
private baseline(): SessionControlBaseline {
const sessions = this.ctx.sessions.list()
const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
for (const session of sessions) {
const agent = this.ctx.agents.get(session.id)
queues[session.id] = agent?.session === session ? queueItems(agent) : []
jobs[session.id] = this.jobsFor(agent)
}
return {
queues,
jobs,
projections: this.projectionBaseline(sessions),
}
}
private projectionBaseline(
sessions: readonly Session[],
): Readonly<Record<SessionId, SessionProjectionsBlock>> {
const registry = this.ctx.get('sessionProjections')
const blocks = Object.create(null) as Record<SessionId, SessionProjectionsBlock>
for (const session of sessions) {
const snapshot = registry?.snapshot(session)
blocks[session.id] = snapshot === undefined
? { asOfSeq: session.seq - 1, values: {} }
: {
asOfSeq: snapshot.asOfSeq,
// Every projection definition validates its value before snapshot publication.
values: snapshot.values as SessionProjectionValues,
}
}
return blocks
}
private onSessionEvent(session: Session, event: SessionEvent): void {
if (event.type !== 'agent/inbox/spliced') return
const agent = this.ctx.agents.get(session.id)
if (agent?.session !== session) return
this.broadcast({
type: 'queue',
sessionId: session.id,
items: queueItems(agent, event.data),
})
}
private onJobsChanged(owner: Agent | undefined): void {
if (owner !== undefined) {
this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) })
return
}
for (const session of this.ctx.sessions.list()) {
this.broadcast({
type: 'jobs',
sessionId: session.id,
jobs: this.jobsFor(this.ctx.agents.get(session.id)),
})
}
}
private jobsFor(agent: Agent | undefined): SessionJob[] {
const jobs = this.ctx.get('jobs')
return jobs === undefined ? [] : jobs.list(agent).map(jobView)
}
private broadcast(frame: SessionControlFrame): void {
for (const stream of this.streams) stream.push(frame)
}
}
class ControlQueue {
private readonly buffer: SessionControlFrame[] = []
private wake: (() => void) | undefined
private done = false
push(frame: SessionControlFrame): void {
if (this.done) return
this.buffer.push(frame)
const wake = this.wake
this.wake = undefined
wake?.()
}
end(): void {
if (this.done) return
this.done = true
const wake = this.wake
this.wake = undefined
wake?.()
}
async *iterate(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
const onAbort = (): void => { this.end() }
signal.addEventListener('abort', onAbort, { once: true })
try {
while (!this.done && !signal.aborted) {
const frame = this.buffer.shift()
if (frame !== undefined) {
yield frame
continue
}
await new Promise<void>((resolve) => { this.wake = resolve })
}
while (this.buffer.length > 0 && !signal.aborted) yield this.buffer.shift() as SessionControlFrame
} finally {
signal.removeEventListener('abort', onAbort)
this.end()
}
}
}
function queueItems(
agent: Agent,
splice?: SessionEventMap['agent/inbox/spliced'],
): SessionQueuedItem[] {
const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
return splice?.target === target
? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
: messages
}
return [
...project('next-turn').map(message => ({
id: message.id,
placement: 'queued' as const,
message: { id: message.id, content: message.content as unknown as JsonValue[] },
})),
...project('next-step').map(message => ({
id: message.id,
placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
message: { id: message.id, content: message.content as unknown as JsonValue[] },
})),
]
}
function jobView(job: JobSnapshot): SessionJob {
return {
id: job.id,
kind: job.kind,
label: job.label,
status: job.status,
...(job.detail === undefined ? {} : { detail: job.detail }),
startedAt: job.startedAt,
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
}
}
@@ -0,0 +1,325 @@
/** Cold Session history pagination and live-event source. */
import type { Context } from '@deepseek-ai/cordis'
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type {
SessionAddress,
SessionEventEntry,
SessionFollowRequest,
SessionFollowFrame,
SessionPage,
SessionPageRequest,
SessionProjectionsBlock,
SessionProjectionValues,
SessionWireEvent,
} from './types.ts'
const DEFAULT_MAX_MESSAGES = 50
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
type SessionSource =
| { readonly kind: 'attached'; readonly session: Session }
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: readonly SessionEvent[] }
/** Implements cold-safe history operations delegated by the Session Controller. */
export class SessionHistoryController {
private readonly closeFollowers = new Set<() => void>()
/** @param ctx - Host context carrying Session, persistence, and projection services. */
constructor(private readonly ctx: Context) {
ctx.effect(() => () => {
for (const close of this.closeFollowers) close()
this.closeFollowers.clear()
}, 'session-controller.history')
}
/**
* Read one message-aligned history page without activating an Agent.
* @param request - durable address and backwards-page cursor.
* @param signal - caller cancellation for persistence reads.
* @returns a contiguous event page and a projection baseline on tail reads.
*/
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
validatePageRequest(request)
const source = await this.sourceFor(request.address, signal)
signal.throwIfAborted()
const sourceLog = sourceEvents(source)
const sourceCursor = sourceLog.at(-1)?.seq ?? -1
if (request.throughSeq > sourceCursor) {
reject(
'bad-request',
`session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`,
{},
)
}
const events = sourceLog.filter(event => event.seq <= request.throughSeq)
if ((events.at(-1)?.seq ?? -1) !== request.throughSeq) {
reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {})
}
const page = paginate(events, request.beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
const entries = page.events.map(entryFor)
const projections = request.beforeSeq === undefined
? this.projectionsFor(request.address, source, events)
: undefined
return {
events: entries,
hasMore: page.hasMore,
...(projections === undefined ? {} : { projections }),
}
}
/**
* Follow events appended after an initial cursor on one durable address.
* @param request - durable address and last committed sequence already held by the caller.
* @param signal - stream cancellation owned by the Remote carrier.
* @returns an opened cursor followed by gap-free event frames.
*/
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
validateFollowRequest(request)
const { address, afterSeq } = request
const target = addressId(address)
const buffered: SessionEvent[] = []
let wake: (() => void) | undefined
const notify = (): void => {
const resume = wake
wake = undefined
resume?.()
}
const follower = { closed: false }
const close = (): void => {
follower.closed = true
notify()
}
this.closeFollowers.add(close)
const disposeEvent = this.ctx.on('session/event', (session, event) => {
if (session.id !== target) return
buffered.push(event)
notify()
}, { global: true })
const disposeCreated = this.ctx.on('session/created', (session) => {
if (session.id !== target) return
// Session construction appends session/end-seed before attachment, so the
// marker has no session/event notification. Earlier session/created listeners
// may publish later setup events first; this suffix must precede those notifications.
const suffix = session.events.slice(session.firstLiveSeq)
buffered.unshift(...suffix)
notify()
}, { global: true })
const onAbort = (): void => { notify() }
signal.addEventListener('abort', onAbort, { once: true })
try {
const source = await this.sourceFor(address, signal)
const events = [...sourceEvents(source)]
signal.throwIfAborted()
const cursor = events.at(-1)?.seq ?? -1
if (afterSeq !== undefined && afterSeq > cursor) {
reject('bad-request', `session event resume seq ${String(afterSeq)} is past cursor ${String(cursor)}`, {})
}
let nextSeq = (afterSeq ?? cursor) + 1
yield { type: 'opened', cursor }
if (afterSeq !== undefined) {
for (const event of events) {
if (event.seq < nextSeq) continue
if (event.seq !== nextSeq) {
reject('internal', `session event replay skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield { type: 'event', ...entryFor(event) }
}
}
while (!follower.closed && !signal.aborted) {
const item = buffered.shift()
if (item === undefined) {
await new Promise<void>((resolve) => { wake = resolve })
continue
}
if (item.seq < nextSeq) continue
if (item.seq !== nextSeq) {
reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {})
}
nextSeq++
yield { type: 'event', ...entryFor(item) }
}
} finally {
this.closeFollowers.delete(close)
signal.removeEventListener('abort', onAbort)
disposeCreated()
disposeEvent()
}
}
private async sourceFor(address: SessionAddress, signal: AbortSignal): Promise<SessionSource> {
const sessionId = addressId(address)
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
validateAddress(address, attached.header, attached.events)
return { kind: 'attached', session: attached }
}
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
reject('internal', 'session persistence is not configured', {})
}
signal.throwIfAborted()
const header = (await persistence.list(signal)).find(candidate => candidate.id === sessionId)
if (header === undefined || header.cwd === undefined) rejectNotFound(address)
const inspected: SessionInspection = await persistence.inspect(sessionId, signal)
signal.throwIfAborted()
if (inspected.meta.cwd === undefined) rejectNotFound(address)
validateAddress(address, inspected.meta, inspected.events)
return { kind: 'detached', header: inspected.meta, events: inspected.events }
}
private projectionsFor(
address: SessionAddress,
source: SessionSource,
events: readonly SessionEvent[],
): SessionProjectionsBlock | undefined {
const registry = this.ctx.get('sessionProjections')
if (registry === undefined) return undefined
try {
const throughSeq = events.at(-1)?.seq ?? -1
const snapshot = source.kind === 'attached'
? source.session.seq - 1 === throughSeq
? registry.snapshot(source.session)
: registry.restore({}, events, 0).snapshot
// The cache owns the cold-read recipe (seed from the cached
// checkpoint, skip the cached prefix's applies, write the refreshed
// checkpoint back); without the cache the registry restores from
// init over the full log.
: this.ctx.get('sessionProjectionCache')?.coldSnapshot(source.header, events)
?? registry.restore({}, events, 0).snapshot
return {
asOfSeq: snapshot.asOfSeq,
// Projection definitions validate whole JSON values before snapshot publication.
values: snapshot.values as SessionProjectionValues,
}
} catch (error) {
if (address.kind === 'session') throw error
this.ctx.logger.warn(`session.page: projections for "${address.childSessionId}" failed: ${String(error)}`)
return undefined
}
}
}
function validatePageRequest(request: SessionPageRequest): void {
if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) {
reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
}
if (request.beforeSeq !== undefined
&& (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) {
reject('bad-request', 'beforeSeq must be a non-negative safe integer', {})
}
if (request.maxMessages !== undefined
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
reject('bad-request', 'maxMessages must be a positive safe integer', {})
}
}
function validateFollowRequest(request: SessionFollowRequest): void {
if (request.afterSeq !== undefined
&& (!Number.isSafeInteger(request.afterSeq) || request.afterSeq < -1)) {
reject('bad-request', 'afterSeq must be an integer greater than or equal to -1', {})
}
}
function addressId(address: SessionAddress): SessionId {
return address.kind === 'session' ? address.sessionId : address.childSessionId
}
function validateAddress(
address: SessionAddress,
header: SessionHeader,
events: readonly SessionEvent[],
): void {
if (address.kind === 'session') {
if (header.origin === 'subagent') {
reject('agent-busy', 'subagent Sessions require their durable parent address', {
reason: 'use subagent delivery for this child session',
})
}
return
}
if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', {
childSessionId: address.childSessionId,
})
}
let descriptor
try {
descriptor = foldSubagentDescriptor(events.slice(header.seedLength ?? 0))
} catch {
reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'corrupt',
})
}
if (descriptor === undefined) {
reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
reason: 'unsupported',
})
}
if (descriptor.mode !== address.mode) {
reject('subagent-unauthorized', 'subagent mode does not match the supplied address', {
childSessionId: address.childSessionId,
})
}
}
function rejectNotFound(address: SessionAddress): never {
if (address.kind === 'session') {
reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
}
reject('subagent-not-found', 'subagent is unavailable', {
parentSessionId: address.parentSessionId,
childSessionId: address.childSessionId,
})
}
function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
function sourceEvents(source: SessionSource): readonly SessionEvent[] {
return source.kind === 'attached' ? source.session.events : source.events
}
function paginate(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number,
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
let count = 0
let cut = 0
for (let index = window.length - 1; index >= 0; index--) {
const event = window[index] as SessionEvent
if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
count++
const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs
let groupStart = event.seq
if (sources !== undefined) {
for (const source of sources) groupStart = Math.min(groupStart, source)
}
if (count >= maxMessages) {
cut = groupStart
break
}
}
return { events: window.filter(event => event.seq >= cut), hasMore: cut > 0 }
}
function entryFor(event: SessionEvent): SessionEventEntry {
return {
// Session.append validates and freezes event data as JSON before publication.
event: event as unknown as SessionWireEvent,
}
}
@@ -0,0 +1,294 @@
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
ApiSessionAgentController,
inspectApiSession,
type ApiSessionAgentResult,
} from './agent.ts'
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import type {
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
SessionCancelValue,
SessionControlFrame,
SessionCreateRequest,
SessionCreateValue,
SessionFollowFrame,
SessionFollowRequest,
SessionForkRequest,
SessionForkValue,
SessionListRequest,
SessionListValue,
SessionModels,
SessionModelsRequest,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
SessionPromptValue,
SessionRenameRequest,
SessionRenameValue,
SessionSearchRequest,
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from './types.ts'
export type * from './types.ts'
export { ApiSessionNotFound } from './agent.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host Session business API and Remote namespace owner. */
sessionController: SessionController
}
}
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size read to determine blankness. */
readonly coldBlankProbeMaxBytes?: number
}
/** Host service backing the generated `ctx.remote.session` namespace. */
export class SessionController extends TypertRemoteService {
static inject = [
'agentDefaultModel',
'agents',
'attachments',
'llm',
'sessions',
'sessionQuery',
'typert',
'workspaceRegistry',
]
static Config: z<Config> = z.object({
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
})
private readonly agents: ApiSessionAgentController
private readonly commands: SessionCommandController
private readonly controlState: SessionControlController
private readonly history: SessionHistoryController
private readonly listState: ApiSessionList
/**
* @param ctx - Host context containing the Session capability assembly.
* @param config - cold-list read policy.
*/
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionController', { namespace: 'session' })
this.agents = new ApiSessionAgentController(ctx)
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
this.controlState = new SessionControlController(ctx)
this.history = new SessionHistoryController(ctx)
this.listState = new ApiSessionList(
ctx,
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
)
ctx.on('session/created', (session) => {
ctx.emit('api-session/added', this.listState.summaryFor(session))
})
ctx.on('session/disposed', (session) => {
ctx.emit('api-session/removed', session.id)
})
ctx.on('agent/status', ({ agent, status }) => {
ctx.emit('api-session/status', agent.id, status === 'running')
})
ctx.on('agent/error', ({ agent, error }) => {
ctx.emit('api-session/error', agent.id, errorChain(error))
})
ctx.on('session/event', (session, event) => {
if (event.type !== 'user/message' || event.data.source.kind !== 'user') return
ctx.emit('api-session/activity', session.id, event.time)
})
}
/**
* Resolve or resume one ordinary Session for another Host API domain.
* @param sessionId - Session identity whose Agent owns the operation.
* @returns the live Agent or the stable Session-domain failure.
*/
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult> {
return this.agents.resolveAgent(sessionId)
}
/**
* Inspect one attached or persisted Session without activating its Agent.
* @param sessionId - durable Session identity.
* @param signal - optional caller cancellation for persistence reads.
* @returns the current attached state or persisted header and event prefix.
*/
inspect(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
return Promise.resolve({ meta: attached.header, events: [...attached.events] })
}
return inspectApiSession(this.ctx, sessionId, signal)
}
/**
* Read all visible Session rows without resuming an Agent.
* @param _request - reserved empty list request.
* @param signal - cancellation for persistence reads.
* @returns visible Session summaries ordered by activity.
*/
@Remote('list')
async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue> {
return { items: await this.listState.list(signal) }
}
/**
* Search visible Session content without resuming an Agent.
* @param request - literal message-content query.
* @param signal - cancellation for list and search reads.
* @returns authorized bounded Session search results.
*/
@Remote('search')
search(request: SessionSearchRequest, signal: AbortSignal): Promise<SessionSearchValue> {
return this.listState.search(request.query, signal)
}
/**
* Create or idempotently adopt one ordinary Session.
* @param request - requested identity, location, and Agent preset.
* @returns the Session identity and resolved preset when configured.
*/
@Remote('create')
create(request: SessionCreateRequest): Promise<SessionCreateValue> {
return this.commands.create(request)
}
/**
* Read model choices after explicitly resuming the addressed Session.
* @param request - Session whose model state is requested.
* @returns the current selection and available model groups.
*/
@Remote('models')
models(request: SessionModelsRequest): Promise<SessionModels> {
return this.commands.models(request)
}
/**
* Select one Session-local model after explicitly resuming the Session.
* @param request - Session identity and requested model selection.
* @returns the normalized selection installed for the Session.
*/
@Remote('selectModel')
selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
return this.commands.selectModel(request)
}
/**
* Rename one Session after explicitly resuming it.
* @param request - Session identity and proposed title.
* @returns the accepted title and durable event sequence.
*/
@Remote('rename')
rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
return this.commands.rename(request)
}
/**
* Fork one cold-readable completed-turn prefix into a new Session.
* @param request - source Session and optional event anchor.
* @returns the new Session identity.
*/
@Remote('fork')
fork(request: SessionForkRequest): Promise<SessionForkValue> {
return this.commands.fork(request)
}
/**
* Admit one prompt after explicitly resuming its Session.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @returns acknowledgement that the Agent accepted the prompt.
*/
@Remote('prompt')
prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
signal.throwIfAborted()
return this.commands.prompt(request)
}
/**
* Read one image proven reachable from the addressed Session log.
* @param request - Session and attachment identities used for authorization.
* @returns the durable attachment reference and base64-encoded bytes.
*/
@Remote('attachment')
attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
return this.commands.attachment(request)
}
/**
* Mutate one still-pending queue occurrence on a live Agent.
* @param request - Session, queue item, and requested mutation.
* @returns acknowledgement that the queue mutation was applied.
*/
@Remote('updateQueue')
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
return this.commands.updateQueue(request)
}
/**
* Cancel one active Agent turn without dropping its pending inbox.
* @param request - Session whose active Agent turn is cancelled.
* @returns acknowledgement that cancellation was requested.
*/
@Remote('cancel')
cancel(request: SessionCancelRequest): SessionCancelValue {
return this.commands.cancel(request)
}
/**
* Read one cold-safe, message-aligned Session history page.
* @param request - durable address, backward cursor, and page budget.
* @param signal - cancellation for persistence reads.
* @returns one chronological page and optional latest projections.
*/
@Remote('page')
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
return this.history.page(request, signal)
}
/**
* Follow one Session log from its opening or resume cursor.
* @param request - durable address and last committed sequence already held by the caller.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns an opened cursor followed by gap-free event frames.
*/
@Remote({ mode: 'stream' })
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
return this.history.follow(request, signal)
}
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns one complete baseline followed by live replacement frames.
*/
@Remote({ mode: 'stream' })
control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
return this.controlState.control(signal)
}
}
export { buildModelCatalog } from './catalog.ts'
export default SessionController
@@ -0,0 +1,20 @@
/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-session-controller/invariant */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-api-session-controller'
/** Cordis companion plugin name. */
export const name = 'api-session-controller-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: every page and frame is checked against the addressed durable Session. */
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+408
View File
@@ -0,0 +1,408 @@
/** Cold-safe Session list and search projection. */
import { stat } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import {
SESSION_SEARCH_RESULT_LIMIT,
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
} from './types.ts'
import type {
SessionListMetadata, SessionProjectionsBlock, SessionProjectionValues, SessionSearchItem,
SessionSearchValue, SessionSummary,
} from './types.ts'
/** Default maximum artifact size eligible for one cold blankness read. */
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
const COLD_SUMMARY_BATCH_SIZE = 16
const SEARCH_PROVIDER_CALL_LIMIT = 100
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
const sessionListMetadataSchema: z.ZodType<SessionListMetadata> = z.object({
blank: z.boolean(),
lastPromptAt: z.number().nullable(),
})
const imageLimitsSchema = z.object({
maxImageBytes: z.number().int().positive(),
maxImagesPerMessage: z.number().int().positive(),
maxMessageImageBytes: z.number().int().positive(),
maxImagePixels: z.number().int().positive(),
maxImageDimension: z.number().int().positive(),
mediaTypes: z.array(z.string()),
}) as unknown as z.ZodType<ImageAttachmentLimits>
/**
* Advance the Session-list metadata projection by one committed event.
* @param state - metadata before the event.
* @param event - next committed Session event.
* @returns the original or advanced metadata value.
*/
export function applySessionListMetadata(
state: SessionListMetadata,
event: SessionEvent,
): SessionListMetadata {
const blank = state.blank && event.type !== 'turn/start'
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
? event.time
: state.lastPromptAt
return blank === state.blank && lastPromptAt === state.lastPromptAt
? state
: { blank, lastPromptAt }
}
/**
* Fold exact list metadata for an attached Session.
* @param events - complete attached Session event log.
* @returns metadata derived from the event prefix.
*/
export function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata {
let state: SessionListMetadata = { blank: true, lastPromptAt: null }
for (const event of events) state = applySessionListMetadata(state, event)
return state
}
/**
* Return the longest prefix containing at most `maximum` Unicode code points.
* @param value - source text.
* @param maximum - maximum number of Unicode code points.
* @returns the source text or its longest allowed prefix.
*/
export function truncateUnicodeCodePoints(value: string, maximum: number): string {
let count = 0
let end = 0
for (const codePoint of value) {
if (count === maximum) return value.slice(0, end)
count++
end += codePoint.length
}
return value
}
/** Owns list projection registration, cold summaries, and authorized search. */
export class ApiSessionList {
/**
* @param ctx - Host context carrying Session, persistence, and projection services.
* @param coldBlankProbeMaxBytes - maximum physical artifact size read to verify cold blankness.
*/
constructor(
private readonly ctx: Context,
private readonly coldBlankProbeMaxBytes: number,
) {
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
stateSchema: sessionListMetadataSchema,
init: () => ({ blank: true, lastPromptAt: null }),
apply: applySessionListMetadata,
wire: { viewSchema: sessionListMetadataSchema, view: state => state },
stateVersion: 1,
})
})
ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'imageLimits', null>({
key: 'imageLimits',
stateSchema: z.null(),
init: () => null,
apply: state => state,
wire: {
viewSchema: imageLimitsSchema,
view: () => projectionCtx.attachments.imageLimits,
},
stateVersion: 1,
})
})
}
/**
* Build one current attached-Session summary.
* @param session - attached Session to summarize.
* @returns current list metadata and available projections.
*/
summaryFor(session: Session): SessionSummary {
const metadata = sessionListMetadata(session.events)
const projections = this.projectionsFor(session.header, session)
return {
sessionId: session.id,
updatedAt: updatedAt(session.header, metadata),
running: this.ctx.agents.get(session.id)?.status === 'running',
blank: metadata.blank,
...listFields(session.header, session.events),
...(projections === undefined ? {} : { projections }),
}
}
/**
* Read every visible attached and persisted Session without activating an Agent.
* @param signal - optional cancellation for persistence reads.
* @returns visible Session summaries ordered by activity.
*/
async list(signal?: AbortSignal): Promise<SessionSummary[]> {
signal?.throwIfAborted()
const items = this.ctx.sessions.list().map(session => this.summaryFor(session))
const attached = new Set(items.map(item => item.sessionId))
const persistence = this.ctx.get('sessionPersistence')
if (persistence !== undefined) {
const cold = (await persistence.list(signal))
.filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
signal?.throwIfAborted()
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
.map(async (meta) => {
const projections = this.projectionsFor(meta, undefined)
const summary = await summarizeCold(
this.ctx,
persistence,
meta,
projections?.values.sessionListMetadata,
this.coldBlankProbeMaxBytes,
signal,
)
const raced = this.ctx.sessions.get(meta.id)
if (raced !== undefined) return this.summaryFor(raced)
return { ...summary, ...(projections === undefined ? {} : { projections }) }
}))
const summaries = settled.map((result) => {
if (result.status === 'rejected') throw result.reason
return result.value
})
signal?.throwIfAborted()
items.push(...summaries)
}
}
items.sort((left, right) => right.updatedAt - left.updatedAt)
return items
}
/**
* Search current visible message content without activating any matching Session.
* @param query - literal message-content query.
* @param signal - cancellation for list and search reads.
* @returns authorized bounded Session search results.
*/
async search(query: string, signal: AbortSignal): Promise<SessionSearchValue> {
const normalizedQuery = normalizeSearchQuery(query)
signal.throwIfAborted()
const provider = this.ctx.get('sessionQuery')
if (provider === undefined) {
reject(
'internal',
'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
{},
)
}
try {
const visible = await this.list(signal)
signal.throwIfAborted()
if (visible.length === 0) return { items: [], hasMore: false }
const visibleIds = new Set(visible.map(item => item.sessionId))
const authorized: SessionSearchItem[] = []
const acceptedIds = new Set<SessionId>()
const seenCursors = new Set<SessionSearchCursor>()
let cursor: SessionSearchCursor | undefined
let providerCalls = 0
let pageLimit = SESSION_SEARCH_RESULT_LIMIT
while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
signal.throwIfAborted()
if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) {
throw new Error(`session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`)
}
providerCalls++
const requestedCursor = cursor
const requestedLimit = pageLimit
let page
try {
page = await provider.searchSessions({
query: normalizedQuery,
eventFilters: [
{ kind: 'type', values: ['user/message', 'assistant/message'] },
{ kind: 'surface', values: ['current'] },
],
limit: requestedLimit,
...(requestedCursor === undefined ? {} : { cursor: requestedCursor }),
}, { signal })
signal.throwIfAborted()
} catch (error: unknown) {
signal.throwIfAborted()
if (requestedCursor === undefined
&& error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_INVALID_LIMIT'
&& requestedLimit > 1) {
pageLimit = Math.max(1, Math.floor(requestedLimit / 2))
continue
}
if (requestedCursor !== undefined
&& error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_STALE_CURSOR') {
authorized.length = 0
acceptedIds.clear()
seenCursors.clear()
cursor = undefined
continue
}
throw error
}
if (page.items.length > requestedLimit) {
throw new Error(`session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`)
}
for (const hit of page.items) {
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
if (!visibleIds.has(hit.header.id)
|| hit.bestMatch.sessionId !== hit.header.id
|| hit.bestMatch.surface !== 'current'
|| !MESSAGE_TYPES.has(hit.bestMatch.type)
|| acceptedIds.has(hit.header.id)) continue
acceptedIds.add(hit.header.id)
authorized.push({
sessionId: hit.header.id,
snippet: truncateUnicodeCodePoints(hit.bestMatch.snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS),
})
}
if (page.nextCursor !== undefined) {
if (seenCursors.has(page.nextCursor)) {
throw new Error('session search provider repeated a continuation cursor')
}
seenCursors.add(page.nextCursor)
}
if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break
cursor = page.nextCursor
}
return {
items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
}
} catch (error: unknown) {
signal.throwIfAborted()
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') {
reject('cancelled', 'session search was aborted', {})
}
reject('internal', `session search failed: ${String(error)}`, {})
}
}
private projectionsFor(
header: SessionHeader,
session: Session | undefined,
): SessionProjectionsBlock | undefined {
try {
const block = session === undefined
? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header)
: this.ctx.get('sessionProjections')?.snapshot(session)
return block !== undefined && Object.keys(block.values).length > 0
? {
asOfSeq: block.asOfSeq,
// Projection definitions validate whole JSON values before snapshot publication.
values: block.values as SessionProjectionValues,
}
: undefined
} catch (error) {
this.ctx.logger.warn(
`api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`,
)
return undefined
}
}
}
function normalizeSearchQuery(query: string): string {
const normalized = query.trim()
if (normalized.length === 0) {
reject('bad-request', 'session search query must not be empty', {})
}
if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) {
reject(
'bad-request',
`session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`,
{},
)
}
if (normalized.includes('\0')) {
reject('bad-request', 'session search query must not contain NUL', {})
}
return normalized
}
function reject(code: string, message: string, details: object): never {
throw new TypertRemoteFailure({ code, message, details })
}
function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
}
function listFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
readonly parentSessionId?: SessionId
readonly origin?: 'subagent'
readonly cwd?: string
readonly agentPreset?: string
} {
const agentPreset = resolveSessionPreset({ header, events })
return {
...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }),
...(header.origin === undefined ? {} : { origin: header.origin }),
...(header.cwd === undefined ? {} : { cwd: header.cwd }),
...(agentPreset === undefined ? {} : { agentPreset }),
}
}
async function summarizeCold(
ctx: Context,
persistence: SessionPersistence,
header: SessionHeader,
metadata: SessionListMetadata | undefined,
blankProbeMaxBytes: number,
signal?: AbortSignal,
): Promise<SessionSummary> {
const probed = metadata?.blank === false
? undefined
: await probeColdMetadata(ctx, persistence, header, blankProbeMaxBytes, signal)
return {
sessionId: header.id,
updatedAt: updatedAt(header, probed ?? metadata),
running: false,
blank: metadata?.blank === false ? false : probed?.blank ?? false,
...listFields(header),
}
}
async function probeColdMetadata(
ctx: Context,
persistence: SessionPersistence,
header: SessionHeader,
maxBytes: number,
signal?: AbortSignal,
): Promise<SessionListMetadata | undefined> {
if (maxBytes === 0) return undefined
signal?.throwIfAborted()
const location = persistence.locate(header)
if (location === undefined) return undefined
let size: number
try {
size = (await stat(location.path)).size
} catch {
signal?.throwIfAborted()
return undefined
}
if (size > maxBytes) return undefined
try {
const { events } = await persistence.readFrom(header.id, 0, signal)
signal?.throwIfAborted()
return sessionListMetadata(events)
} catch (error) {
signal?.throwIfAborted()
ctx.logger.warn(
`api-session.list: blank probe for "${header.id}" failed; serving it as visible: ${String(error)}`,
)
return undefined
}
}
@@ -0,0 +1,13 @@
/** Session Controller events forwarded unchanged through the Remote Event carrier. */
export const SESSION_CONTROLLER_REMOTE_EVENTS = [
'api-session/activity',
'api-session/added',
'api-session/error',
'api-session/removed',
'api-session/status',
] as const
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertRemoteEventSelection extends
Record<typeof SESSION_CONTROLLER_REMOTE_EVENTS[number], true> {}
}
@@ -0,0 +1,456 @@
/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
import type {
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
} from '@deepseek-ai/dsh-attachment'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { JsonValue, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
/** Host state persisted for cold Session list summaries. */
sessionListMetadata: SessionListMetadata
/** Host state for the boot-constant image-limit view. */
imageLimits: null
}
interface SessionProjectionMap {
/** Persisted facts used to summarize a Session without activating it. */
sessionListMetadata: SessionListMetadata
/** Image-intake limits enforced by the Session prompt endpoint. */
imageLimits: ImageAttachmentLimits
}
}
/** Persisted hints used to summarize a cold Session. */
export interface SessionListMetadata {
/** Whether the folded prefix contains no turn. */
readonly blank: boolean
/** Latest human-authored prompt time in the folded prefix. */
readonly lastPromptAt: number | null
}
/** Projection values and the durable event position they represent. */
export interface SessionProjectionsBlock {
readonly asOfSeq: number
/** Provider-validated values across the merge-extensible projection key space. */
readonly values: SessionProjectionValues
}
/** Typed known projections plus JSON-safe values contributed outside this compilation face. */
export type SessionProjectionValues = Partial<SessionProjectionMap>
& Readonly<Record<string, SessionProjectionValue>>
/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
export type PromptContentPart =
| { readonly type: 'text'; readonly text: string }
| {
readonly type: 'image'
readonly mediaType: ImageMediaType
readonly data: string
readonly name?: string
}
/** Complete model selection for one Session. */
export interface ModelSelection {
readonly provider: string
readonly model: string
readonly reasoningEffort?: string
}
/** One adapter-owned reasoning effort for an exact model route. */
export interface ModelReasoningEffort {
readonly id: string
readonly name: string
readonly description?: string
}
/** Selectable reasoning metadata for one exact model route. */
export interface ModelReasoning {
readonly efforts: readonly ModelReasoningEffort[]
readonly defaultEffort?: string
}
/** One model displayed inside its provider group. */
export interface ModelCatalogModel {
readonly id: string
readonly name: string
readonly description?: string
readonly reasoning?: ModelReasoning
}
/** One provider and its successfully loaded model catalog. */
export interface ModelProviderGroup {
readonly id: string
readonly name: string
readonly models: readonly ModelCatalogModel[]
}
/** One provider whose model catalog lookup failed. */
export interface ModelCatalogFailure {
readonly id: string
readonly name: string
readonly message: string
}
/** Detached model-directory snapshot for one Session. */
export interface SessionModels {
readonly current: ModelSelection
readonly routable: boolean
readonly groups: readonly ModelProviderGroup[]
readonly failures: readonly ModelCatalogFailure[]
}
/** One client-requested mutation of a still-pending queue item. */
export type QueueAction =
| { readonly kind: 'edit'; readonly content: readonly ContentBlock[] }
| { readonly kind: 'remove' }
| { readonly kind: 'steer' }
/** One Session list entry. */
export interface SessionSummary {
readonly sessionId: SessionId
readonly updatedAt: number
readonly running: boolean
readonly blank: boolean
readonly parentSessionId?: SessionId
readonly origin?: 'subagent'
readonly cwd?: string
readonly agentPreset?: string
readonly projections?: SessionProjectionsBlock
}
/** One session-content search result. */
export interface SessionSearchItem {
readonly sessionId: SessionId
readonly snippet: string
}
/** Maximum number of Sessions returned by one search. */
export const SESSION_SEARCH_RESULT_LIMIT = 20
/** Maximum search snippet length in Unicode code points. */
export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240
/** Error details returned by Session Remote methods. */
export interface SessionErrorDetailsMap {
'bad-request': Record<never, never>
cancelled: Record<never, never>
'session-not-found': { readonly sessionId: SessionId }
'model-unavailable': { readonly provider: string; readonly model: string }
'session-conflict': {
readonly sessionId: SessionId
readonly requestedCwd: string
readonly existingCwd?: string
}
'invalid-time-zone': { readonly value: string }
'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
'workspace-not-found': { readonly workspaceId: string }
'agent-preset-conflict': {
readonly sessionId: SessionId
readonly requestedPreset: string
readonly existingPreset?: string
}
'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] }
'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string }
'agent-busy': { readonly reason: string }
'attachment-error': { readonly reason: string }
'queue-item-not-found': { readonly itemId: MessageId }
'steer-unavailable': { readonly itemId: MessageId }
'title-invalid': { readonly sessionId: SessionId }
'fork-unavailable': { readonly sessionId: SessionId }
'subagent-not-found': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
}
'subagent-catalog-diagnostic': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
'subagent-unauthorized': { readonly childSessionId: SessionId }
internal: Record<never, never>
}
/** Session business failure returned without throwing a carrier error. */
export type SessionError = {
[Code in keyof SessionErrorDetailsMap]: {
readonly code: Code
readonly message: string
readonly details: SessionErrorDetailsMap[Code]
}
}[keyof SessionErrorDetailsMap]
/** Session list request. */
export interface SessionListRequest {
readonly cursor?: string
}
/** Session list response value. */
export interface SessionListValue {
readonly items: readonly SessionSummary[]
}
/** Session search request. */
export interface SessionSearchRequest {
readonly query: string
}
/** Session search response value. */
export interface SessionSearchValue {
readonly items: readonly SessionSearchItem[]
readonly hasMore: boolean
}
/** Session creation or explicit-id adoption request. */
export interface SessionCreateRequest {
readonly workspaceId?: WorkspaceId
readonly cwd?: string
readonly sessionId?: SessionId
readonly agentPreset?: string
}
/** Session creation response value. */
export interface SessionCreateValue {
readonly sessionId: SessionId
readonly agentPreset?: string
}
/** Model-directory request. */
export interface SessionModelsRequest {
readonly sessionId: SessionId
}
/** Session model-selection request. */
export interface SessionSelectModelRequest extends ModelSelection {
readonly sessionId: SessionId
}
/** Accepted model selection after Host resolution. */
export interface SessionSelectModelValue {
readonly selected: ModelSelection
}
/** Session rename request. */
export interface SessionRenameRequest {
readonly sessionId: SessionId
readonly title: string
}
/** Normalized title and the durable event position that committed it. */
export interface SessionRenameValue {
readonly title: string
readonly seq: number
}
/** Session fork request. */
export interface SessionForkRequest {
readonly sessionId: SessionId
readonly atSeq?: number
}
/** Identity of a newly forked Session. */
export interface SessionForkValue {
readonly sessionId: SessionId
}
/** Session prompt request. */
export interface SessionPromptRequest {
/** Client-minted identity persisted on the exact accepted user message. */
readonly requestId: SessionRequestId
readonly sessionId: SessionId
readonly mode: 'queue' | 'steer'
readonly content: readonly PromptContentPart[]
readonly clientTimeZone?: string
}
/** Receipt after one prompt enters the target Agent inbox. */
export interface SessionPromptValue {
readonly accepted: true
}
/** Durable image read request. */
export interface SessionAttachmentRequest {
readonly sessionId: SessionId
readonly attachmentId: AttachmentIdType
}
/** Durable image read response value. */
export interface SessionAttachmentValue {
readonly attachment: ImageAttachmentRef
readonly data: string
}
/** Pending queue mutation request. */
export interface SessionUpdateQueueRequest {
readonly sessionId: SessionId
readonly itemId: MessageId
readonly action: QueueAction
}
/** Receipt after one pending queue mutation commits. */
export interface SessionUpdateQueueValue {
readonly accepted: true
}
/** Active-turn cancellation request. */
export interface SessionCancelRequest {
readonly sessionId: SessionId
}
/** Receipt after cancellation is admitted to the live Agent. */
export interface SessionCancelValue {
readonly accepted: true
}
/** Client-minted prompt identity used to reconcile optimistic and durable messages. */
export type SessionRequestId = Branded<'session-request-id'>
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/** Browser prompt correlation and optional Host-validated time zone. */
'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string }
}
}
/** Durable identity selecting an ordinary Session or one direct subagent child. */
export type SessionAddress =
| { readonly kind: 'session'; readonly sessionId: SessionId }
| {
readonly kind: 'subagent'
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
readonly mode: 'one-shot' | 'continuable'
}
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly event: SessionWireEvent
}
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
export interface SessionWireEvent {
readonly type: string
readonly seq: number
readonly time: number
readonly data: JsonValue
readonly ignorable?: true
readonly sourceEventSeqs?: number[]
readonly surfaceOp?: SurfaceOp
}
/** One message-aligned backwards-history request. */
export interface SessionPageRequest {
readonly address: SessionAddress
/** Inclusive log cut obtained from the corresponding follow opening frame. */
readonly throughSeq: number
readonly beforeSeq?: number
readonly maxMessages?: number
}
/** One live event request, optionally resuming after an already-applied event. */
export interface SessionFollowRequest {
readonly address: SessionAddress
readonly afterSeq?: number
}
/** One contiguous backwards page of a Session log. */
export interface SessionPage {
readonly events: readonly SessionEventEntry[]
readonly hasMore: boolean
readonly projections?: SessionProjectionsBlock
}
/** Initial cursor followed by ordered events appended after that cursor. */
export type SessionFollowFrame =
| { readonly type: 'opened'; readonly cursor: number }
| ({ readonly type: 'event' } & SessionEventEntry)
/** One pending inbox occurrence in the authoritative queue snapshot. */
export interface SessionQueuedItem {
readonly id: MessageId
readonly placement: 'queued' | 'steering' | 'context'
/** JSON-safe message fields consumed by pending-queue presentation. */
readonly message: {
readonly id: MessageId
readonly content: readonly JsonValue[]
}
}
/** Browser-safe background-job row. */
export interface SessionJob {
readonly id: JobId
readonly kind: string
readonly label: string
readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
readonly detail?: string
readonly startedAt: number
readonly finishedAt?: number
}
/** Complete live control baseline emitted once per control stream generation. */
export interface SessionControlBaseline {
readonly queues: Readonly<Record<SessionId, readonly SessionQueuedItem[]>>
readonly jobs: Readonly<Record<SessionId, readonly SessionJob[]>>
readonly projections: Readonly<Record<SessionId, SessionProjectionsBlock>>
}
/** One finished projection value and its durable watermark. */
export interface SessionProjectionUpdate {
readonly sessionId: SessionId
readonly key: string
readonly value: JsonValue
readonly seq: number
}
/** Host-wide live state stream. Each generation starts with exactly one baseline. */
export type SessionControlFrame =
| { readonly type: 'baseline'; readonly value: SessionControlBaseline }
| { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] }
| { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
| ({ readonly type: 'projection' } & SessionProjectionUpdate)
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A Session became visible to Session list consumers.
* @mode emit
* @param summary - initial list row for the Session.
*/
'api-session/added'(summary: SessionSummary): void
/**
* A Session left the live Host registry.
* @mode emit
* @param sessionId - removed Session identity.
*/
'api-session/removed'(sessionId: SessionId): void
/**
* One Agent changed running state.
* @mode emit
* @param sessionId - Agent and Session identity.
* @param running - whether the Agent is running.
*/
'api-session/status'(sessionId: SessionId, running: boolean): void
/**
* One user-authored durable message advanced Session list activity.
* @mode emit
* @param sessionId - addressed Session identity.
* @param updatedAt - durable message time used for list ordering.
*/
'api-session/activity'(sessionId: SessionId, updatedAt: number): void
/**
* One Agent failed outside a durable turn position.
* @mode emit
* @param sessionId - Agent and Session identity.
* @param message - user-safe failure chain.
*/
'api-session/error'(sessionId: SessionId, message: string): void
}
}
/** JSON-compatible projection value accepted by list consumers. */
export type SessionProjectionValue = JsonValue